Concurrency — параллелизм и асинхронность
Решения этой группы определяют модель параллельных вычислений Nova: как fiber-runtime обеспечивает невидимую приостановку, какие structured-concurrency примитивы есть в языке, и как параллелизм выражается в коде.
| # | Решение |
|---|---|
| D14 | Fiber runtime — невидимая инфраструктура |
| D50 | Concurrency model: spawn, detach, Blocking |
| D71 | Bootstrap concurrency runtime: cooperative scheduler, Time.sleep yield-point, capture-by-value |
| D75 | ⚠️ REVISED: supervised(cancel: tok) — структурная отмена с внешним токеном (keyword cancel_scope удалён) |
| D79 | ⚠️ Уточнено D91: Channel[T] (старая Go-style модель — один объект, send+recv на нём) |
| D80 | Handler scoping per-fiber — with X = handler локален для fiber’а, наследуется через spawn |
| D80 | Handler scoping per-fiber — with X = h биндинги изолированы между fibers |
| D91 | Channel revision: capability-split на ChanWriter[T] / ChanReader[T]; send→bool; tx.clone() multi-writer ✅ |
| D94 | select { ... } — финальный синтаксис: Some(v) = rx =>, ChanReader.close_after(Duration) для timeout ✅ реализован (Plan 31 ✅; Plan 44.1 Ф.3 hardening; Plan 65 — Duration-typed API revision) |
| D124 | Monotonic vs Timestamp — раздельные типы для wall-clock и монотонных часов |
| D138 | ✅ ACTIVE — Default-on M:N runtime production semantics (Plan 83.4.5.8 closure 2026-05-24) |
| D168 | Sized atomic types API contract: 12 types × 13 ops, MemOrdering-aware overloads, wraparound semantics |
| D169 | Mutex / RwLock / ReentrantMutex family — fiber-aware locking, fair FIFO default, writer-priority RwLock, recursive ReentrantMutex |
| D170 | Coordination primitives — Semaphore (bounded permits), Barrier (reusable N-party rendezvous), CountDownLatch (one-shot), Condvar (tied to Mutex) |
| D172 | realtime { } / blocking { } × sync-primitive enforcement: #parks / #wakes / #realtime annotation system |
| D174 | Consume guards V2 — MutexGuard, ReadGuard, WriteGuard, Permit, OnceGuard consume types; guard-returning API; D169–D171 cross-refs updated |
| D425 | CAS возвращает свидетеля провала: compare_exchange/compare_exchange_weak bool → Result[(), T] (amends D168 §1) |
| D426 | Atomic-семейство: консолидация имён — AtomicIsize/AtomicUsize → AtomicInt/AtomicUint, легаси AtomicInt и AtomicPtr сняты (amends D168, D425) |
| D439 | supervised{} — прямая блокирующая операция в теле без spawn парkуется штатно (amends D14/D50/D71/D75); её “не защищена дедлайном/токеном ЭТОГО scope” boundary ОТМЕНЕНА амендментом D442 |
| D442 | supervised(cancel:/timeout:/deadline:) покрывает ВЕСЬ блок, включая прямую блокирующую операцию тела — retracts D439’s boundary (amends D75/D408) |
D14. Fiber runtime — невидимая инфраструктура
⚠️ REVISED → D62, D64. Изначально D14 объявлял
Asyncкак эффект. После D62Asyncне является эффектом — это runtime-инфраструктура, ambient capability. В сигнатурах не пишется. Гарантия не-приостановки даётся блокомrealtimeкак inverse-маркер, а не отсутствиемAsyncв сигнатуре. Структурный параллелизм через D50 (spawn,parallel,race,supervised(cancel:)).📋 CONCRETIZED IN D71. Bootstrap-runtime: round-robin scheduler
nova_supervised_run,nova_fiber_yieldдля cooperative suspension. Без preemption, single-threaded. Production-runtime — расширение D71.
Что
Fiber runtime обеспечивает приостановку без видимого await/
Future<T>. Цвет функции отсутствует: вызов sync-функции и
suspend-функции выглядят одинаково. Точки приостановки невидимы в
типах — программист и LLM их не видят (это сознательное решение D62:
runtime-факт, не tipo-факт). Если нужна гарантия, что приостановки
нет, используется блок realtime { ... }.
Structured concurrency — примитивы языка (spawn, supervised
[+ опц. cancel:/deadline:/timeout: — D408], select, parallel for,
detach, blocking), race — stdlib поверх них, см. D50,
D75.
(Plan 173 §3a п.4: with_timeout субсумирован supervised(timeout:) —
подлежит удалению, [M-174-retract-with-timeout]; race {…}-блок ниже —
эскиз, не реализован: N-арный race — дизайн 173.1 §2a.)
Правило
Внешне — синхронно выглядящий код
fn fetch(url str) Net -> Response => ...
fn handler(req Request) Net Db -> Response {
ro user = fetch_user(req.id) // никаких .await
ro posts = fetch_posts(user.id) // никаких .await
Response.json(posts)
}
Тип возврата Response, не Future<Response>. Программист пишет
последовательный код, а компилятор + scheduler делают остальное.
Async НЕ присутствует в сигнатуре (D62).
Внутри — fiber scheduler
Под капотом — fiber-based scheduler (как в Go или OCaml 5).
Когда операция эффекта Net приостанавливается, fiber кладётся в
очередь ожидания, scheduler берёт другой fiber. Память —
сегментированный стек или cactus stack.
Structured-concurrency примитивы
Это примитивы языка, не функции stdlib — управление параллелизмом нельзя выразить только через эффект:
// parallel for — ждёт всех, отменяет хвост при ошибке
fn fetch_all(urls []str) Net Fail -> []Response =>
parallel for url in urls {
fetch(url)
}
// race — кто первый ответил, тот и победил
// (эскиз: block-форма НЕ реализована; сейчас std race2, общий N-арный
// race — дизайн 173.1 §2a; см. нота выше)
race {
fetch(url_a),
fetch(url_b),
}
// select — ожидание любого из событий (финальный синтаксис — D94,
// Plan 65 revision: ChanReader.close_after заменил Time.after(ms))
ro t = ChanReader.close_after(Duration.from_secs_f64(5.0))
select {
Some(msg) = channel_a.recv() => { process(msg) }
Some(msg) = channel_b.recv() => { process(msg) }
None = t.recv() => { default_value }
}
// supervised(cancel:) — структурная отмена с внешним токеном (D75)
ro tok = CancelToken.new()
supervised(cancel: tok) {
spawn do_thing()
spawn do_other()
}
// bound на время выполнения — параметр области (D408; ex-with_timeout,
// субсумирован Plan 173 §3a / Plan 174)
supervised(timeout: 2.seconds()) {
Db.exec(sql`UPDATE counters SET v = v + 1`)
}
region { ... } (05-memory.md → D6) живёт в этой
же категории — runtime-примитив, отвечающий за память real-time зон.
Почему
- Невирусность. Отсутствие
await/Future<T>снимает «цвет функции» — вызов suspend-функции из suspend-функции выглядит как обычный вызов, без обёрток. Это значительно упрощает рефакторинг и AI-генерацию. - D62: Async — runtime-инфраструктура, не type-fact. Программист
не должен думать про suspension при чтении сигнатуры. Если в
будущем какая-то операция станет sync — сигнатура не меняется.
Тип отражает поведение, не реализацию. Гарантия отсутствия
suspension даётся блоком
realtime { ... }(D64). - Прецеденты. Erlang и Go доказали, что fiber-runtime работает на масштабе backend (миллионы fiber’ов на узел). OCaml 5 показал тот же подход в строго типизированном языке.
- Structured concurrency встроена. Не нужны библиотеки типа
Trio/structured-concurrency RFC —
parallel for,supervised(cancel:/deadline:/timeout:)— часть языка (race— stdlib). Это значительно безопаснее для AI-генерации (нет утечек fiber’ов).
Сравнение с Rust async
| Rust async | Nova | |
|---|---|---|
| Цвет функции | да (async fn) | нет |
await нужен | да | нет |
| Тип возврата меняется | Future<T> | T (не меняется) |
| Стоимость задачи | ~64 байта (state machine) | ~4-8 KB (fiber stack) |
| Cancellation | ручная (Drop) | structured (supervised(cancel:)) |
| C interop blocking | без проблем | требует detach to OS thread |
| Видимость suspension в сигнатуре | есть | нет (см. D62) |
Nova ближе к Erlang/Go по runtime, к Koka по типам. Платит памятью (fiber stacks) ради простоты кода (невирусность).
Стоимость fiber’а
Каждый fiber — несколько килобайт минимум (растёт по необходимости).
Дороже Rust state machine, дешевле OS thread. Миллион fiber’ов на
машину — норма (как Erlang). Миллиард — нет, для таких задач есть
Stream/событийная модель.
Async — runtime, не тип
«Всё — эффект» (D10) — это типовая модель,
не runtime-модель. На уровне типов Async НЕ существует
(D62). На уровне runtime async требует
fiber-инфраструктуры, как memory regions требуют allocator’а
(D6). Симметрия: GC, region и fiber-scheduler — три
runtime-капабилити, которые не отражаются в эффектах.
Что отвергнуто
Future<T>в типе возврата (Rust/TS-стиль) — заставляет программиста писатьawait, заражает все вызывающие функции цветом.async/awaitkeywords — отвергнуты. Cuspension — runtime-факт, не в типах.Asyncкак эффект в сигнатуре — отвергнуто в D62. Программист не должен видеть suspension в типах; ему достаточно inverse-маркераrealtime(D64) для гарантии no-suspend.- Stackless coroutines (Rust state machines) — экономят память,
но требуют
Pin/Send/Syncбойлерплейта; не подходят для AI-кодинга. - OS threads as default — слишком тяжёлые для миллионов задач.
- Custom Promise как магия компилятора — отвергнут.
Promise[T]как пользовательская структура, если нужна, пишется обычным кодом (handler-обёртка надAsync).
Открытые вопросы
- Реализация fiber stacks — segmented vs cactus vs on-demand growable. Решается на этапе runtime-разработки.
- Дефолтный размер fiber stack — баланс между начальной стоимостью и частотой роста.
- C interop для синхронных C-вызовов — механизм
detach to OS threadнужен для блокирующих C-функций (например,libcurl).
Связь
- 01-philosophy.md → D10 — «всё — эффект» применимо к Net/Db/Fail/Log; suspension — исключение (D62).
- 04-effects.md — система эффектов в целом.
- 05-memory.md → D6 —
regionкак родственный runtime-примитив. - 08-runtime.md — три режима компиляции работают с fiber’ами одинаково.
Эволюция
D14 в первой редакции объявлял Async как эффект в сигнатуре. После
D62 Async убран из type-system целиком —
suspension стала ambient capability runtime’а. Гарантия отсутствия
suspension даётся блоком D64 realtime { } как
inverse-маркером.
Открытый вопрос про C interop через detach to OS thread закрыт
D50 — эффект
Blocking + примитив blocking { ... }.
D50. Concurrency model: spawn, detach, Blocking
🔴
blocking { }block-form +Blockingeffect REMOVED. Plan 113 (D172, 2026-05-30) retracted theblocking { ... }block-form: the parser now rejects it with[D172-block-form-removed]and directs users to a#blocking fnattribute instead. Plan 91.15 (2026-06-17) completed the removal — theBlockingeffect is gone from the compiler entirely (no longer a built-in effect name, no longer in the realtime-suspend-effect list) and fromstd/net(TCP/UDP I/O methods carry onlyTcpNet/UdpNet; the effect handler parks the fiber on the libuv event loop, so no separateBlockingdeclaration is needed). The§4 blocking { … }subsection below and everyBlocking/blocking { }mention in this D50 are historical — kept for context, no longer normative.⚠️ REVISED → D62, D64. Исходный D50 трактовал
Asyncкак эффект и упоминал «единый эффектAsync». После D62Async— ambient runtime-инфраструктура, не эффект.Parтоже не существует. Гарантия не-приостановки даётся блокомrealtime.Detach/Blockingостаются эффектами — у них есть видимый side-effect для caller’а (fire-and-forget семантика и блокировка ОС-потока соответственно), что делает их кандидатами на type-level декларацию.⚠️ Detach-эффект — ЧАСТИЧНО (обновлено Plan 173 Ф.3 п.2, 2026-07-09): «Detach» — зарезервированное builtin-имя (types/mod.rs); требование эффекта в сигнатуре ТЕПЕРЬ enforced в checker’е (
[E_DETACH_REQUIRES_EFFECT], D414 §2); самостоятельного effect-type-декла в std всё ещё нет. Поведение сирот при ошибке/панике — LogAndDrop, зашито в runtime.c (не handler); escalate-to-scope — opt-in design ([M-173-detach-escalate-to-scope]).📋 PARTIALLY IMPLEMENTED IN D71. Bootstrap’ом реализованы:
supervised,parallel for,detach(Plan 83.4.5.2 Ф.4 amend — default AsyncDetach черезnova_runtime_spawn_orphanprimitive: armed runtime → worker pool fire-and-forget; bootstrap cooperative → global orphan scope drained on atexit либо черезruntime.drain_orphans()explicit-sync API),Time.sleepкак yield-point,blocking { }(Plan 83.3 — libuv- threadpool offload, см. §4 «Реализация»). Capture-by-value для immutable scalars. Статус (обновлено Plan 173 Ф.5.4, 2026-07-10):selectРЕАЛИЗОВАН (D94 + None-арм D414 §3);supervised(deadline:/timeout:)РЕАЛИЗОВАНЫ (D408) —with_timeoutсубсумирован (ретракция отложена[M-174-retract-with-timeout]); общий N-арныйraceНЕ реализован (stdrace2; дизайн 173.1 §2a); cancellation/error-propagation между fibers — 173.0 retention + D414 precedence. Orphan errors → LogAndDrop в caller’s stderr, non-propagate (escalate-to-scope —[M-173-detach-escalate-to-scope]).
Что
Конкретизация D14:
spawnразрешён только внутри structured-scope.detach { ... }— отдельный примитив для долгоживущих задач (требует эффектаDetach).blocking { ... }— примитив для синхронных C-вызовов (требует эффектаBlocking).- Никакой синтаксической отметки на месте вызова suspend-функции
(нет
await/?async) — suspension это ambient (D62).
Правило
1. Suspension — ambient (D62), не эффект
Suspension fiber’а не пишется в сигнатуре. parallel for,
select — синтаксические примитивы языка (D14; race — stdlib поверх),
они работают на уровне fiber-runtime’а, не type-system’ы.
fn fan_out(urls []str) Net Fail -> []Response =>
parallel for url in urls {
fetch(url)
}
// в сигнатуре только Net Fail; suspension — ambient
Декларация «эта функция может suspend» — через имя или док-коммент.
Гарантия не-suspend даётся блоком realtime { }.
2. spawn — только в structured-scope, возвращает unit
spawn — keyword-конструкция (не функция). Синтаксис: spawn expr, где
expr — любое выражение: вызов функции, блок, и т.д.
spawn body — это statement (fire-and-forget внутри scope). Возвращает
unit, не результат body. Это сознательное решение — см. «Почему» ниже.
spawn fetch_users() // вызов функции
spawn { compute(x) } // inline-блок
// ✗ ОШИБКА компиляции — spawn возвращает unit, нет смысла связывать
ro r = spawn fetch_a()
Чтобы получить результат от concurrent-выполнения:
| Сценарий | Идиома |
|---|---|
| Нужен результат, можно подождать sequentially | прямой вызов: let users = fetch_users() (async прозрачный — D62) |
| Гомогенный fan-out с массивом результатов | let xs = parallel for url in urls { fetch(url) } |
| Гетерогенная параллельность с разными типами | mut-захваты внутри supervised |
parallel for → []T— ЛЮБОЙ элементT, ЛЮБОЙ итератор (Plan 173.1 Ф.2, 2026-07-09; D414 §4): сбор — через канал (клонSender’а создаётся в родителе на моментspawn→ move в ребёнка →sendtrailing-значения → close на выходе ребёнка; выделенный drain-fiber внутри scope; буферK = min(len, 16)— back-pressure, память O(CAP), не O(N)). Элемент — примитив, heap-record, value-record (D228), анонимный и именованный tuple (D215), sum-тип, вложенный[]T. Порядок элементов = completion order (плотный, без дыр); итерационный порядок НЕ гарантирован — коду, которому нужен порядок, —xs.sort()/ сортировка по ключу. Прежний V1-guard[E_PARFOR_RESULT_UNSUPPORTED](Plan 173 Ф.1 #7) и примитив-whitelist УДАЛЕНЫ ([M-parfor-record-result-miscompile]закрыт на всей матрице типов —nova_tests/err173_1/parfor_elem_matrix.nv).
Пример возврата результатов из детей (Ред. Plan 173.3 / D415 —
голый mut-захват в spawn теперь [E_CONCURRENT_MUT_CAPTURE]; результаты
идут через #share-примитивы или каналы):
mut a = AtomicInt.new(0)
mut b = AtomicInt.new(0)
supervised {
spawn { a.store(compute_a()) } // #share-примитив — санкционированный путь
spawn { b.store(compute_b()) }
}
use_both(a.load(), b.load())
spawn() с пустыми скобками — запрещено: скобки не несут смысла и
создают иллюзию вызова функции. Подробно — D43.
spawn запрещён вне structured-блока. Допустимые скоупы:
supervised (в т.ч. supervised(cancel:)), parallel for, select;
а также stdlib-функции, построенные на них (race2; ex-with_timeout
— субсумирован supervised(timeout:), D408), внутри своих тел. Вне такого скоупа spawn foo() — ошибка компиляции.
// ✓ ОК — spawn внутри supervised
supervised {
spawn fetch_a()
spawn fetch_b()
}
// ✗ ОШИБКА компиляции — spawn вне scope'а
fn handler(req Request) Net -> Response =>
spawn write_audit(req) // ← запрещено
Response.ok()
Отмена прорастает от scope’а, ошибки одного fiber’а ловятся scope’ом (D14 structured-concurrency).
3. detach { ... } для долгоживущих задач
Если задача должна пережить caller’а (фоновый аудит, отложенная
запись, метрики) — detach { ... }. Это:
- syntactic primitive языка (как
region,parallel), - запускает блок как новый fiber,
- не возвращает handle (fire-and-forget),
- привязан к глобальному runtime supervisor, не к локальному scope’у.
Использование требует эффекта Detach в сигнатуре:
fn handle_request(req Request) Net Db Detach -> Response {
ro resp = process(req)
detach {
write_audit(req, resp) // живёт после возврата handler'а
}
resp
}
Detach — обычный эффект в системе (D2): handler в скоупе можно
подменить (для тестов), capability запретить (sandbox), линтер
проверяет на лишние detach’и.
// тесты — detach исполняется синхронно, никаких background-задач
with Detach = SyncDetach {
handle_request(req)
}
Глобальный default-handler Detach — LogAndDrop: throw из detached-
fiber’а логируется как warning, panic — как critical (с D13 семантикой
«fiber мёртв»).
3.1. Default detach semantic — AsyncDetach (Plan 83.4.5.2 Ф.4, 2026-05-23)
detach { body } под production runtime (armed M:N либо bootstrap
cooperative) — fire-and-forget на orphan fiber (паритет Go go fn(),
tokio tokio::spawn без JoinHandle, Kotlin GlobalScope.launch { … }).
Runtime routing:
- armed M:N runtime (
runtime.is_initialized() == true): orphan body push’ится в worker deque черезnova_runtime_spawn_orphan→nova_runtime_spawn_global(round-robin worker assignment). Caller возвращается мгновенно; body выполнится на одном из worker’ов. - bootstrap cooperative (default до Plan 83.2 flip): orphan body
push’ится в global
_nova_orphan_scopequeue; drain’ится черезnova_supervised_drain_main_scopeлибо наatexit, либо явным вызовомruntime.drain_orphans().
runtime.drain_orphans() — stdlib-API (analog Go sync.WaitGroup.Wait()
для anonymous-spawn’ов). Используется test-suite’ом для explicit-sync
между detach { side_effect } и assert; production-кодом редко
требуется (caller обычно не ждёт fire-and-forget).
// Test pattern — explicit sync:
mut x = 0
detach { x = 42 }
runtime.drain_orphans() // wait для orphan body completion
assert(x == 42) // OK
LogAndDrop errors (как и до 83.4.5.2): orphan body throw →
fprintf(stderr, ...) + fiber dies cleanly. Caller не abort’ится;
другие orphans + main flow продолжают.
Handler inheritance: orphan fiber видит outer with X = h биндинги
captured на spawn-time (Plan 83.4.5.4 spawn-time TLS snapshot — паритет
Node AsyncLocalStorage, Kotlin CoroutineContext.Element).
Bootstrap SyncDetach (inline в caller’е) — legacy semantic; всё ещё
работает через with Detach = SyncDetach { … } для test-mocking
patterns. AsyncDetach — production default.
4. blocking { ... } для синхронных C-вызовов
Синхронные C-функции (read(2) без O_NONBLOCK, pthread_mutex_lock,
тяжёлые computational библиотеки) блокируют ОС-поток. На M:N
scheduler’е это значит, что весь worker встал. Решение — отдельный
pool ОС-потоков для блокирующих задач:
fn read_file_sync(path str) Blocking Fail[IoError] -> []u8 =>
blocking {
c_read_file(path) // выполняется на blocking-pool потоке
}
blocking { ... }:
- syntactic primitive языка,
- уводит тело на отдельный ОС-поток из blocking-pool, fiber паркуется,
- worker scheduler’а возвращается в общий пул, обслуживает другие fiber’ы,
- когда C-код вернулся — fiber резюмится на своём home-worker’е,
- отдаёт значение trailing-выражения тела (
let data = blocking { c_read() }), - requires эффект
Blockingв сигнатуре enclosing-функции.
Blocking-эффект:
- виден в сигнатуре (caller знает «может заблокировать поток»),
- запрещён внутри
realtime { }-блока (D64) — блок гарантирует не-suspension, а blocking-pool вызывает suspend на ОС-потоке.
Размер blocking-pool — runtime-конфиг (NOVA_BLOCKING_THREADS,
default 64). Если пул заполнен — fiber ждёт в очереди (graceful, не
дедлок).
Реализация: Plan 83.3 (2026-05-22)
Bootstrap-runtime реализует blocking { } через libuv threadpool
(uv_queue_work) — процесс-глобальный пул ОС-потоков:
- fiber вызывает
blocking { }→ runtime пакует тело вuv_work_t,uv_queue_workна loop home-worker’а; - fiber паркуется (park/wake D93, тот же путь, что
Time.sleep) — worker свободен, берёт другой fiber; work_cbисполняется на threadpool-потоке — делает блокирующую работу;after_work_cbна loop’е home-worker’а будит fiber с результатом;- fiber резюмится со значением тела.
NOVA_BLOCKING_THREADS (default 64) пробрасывается в
UV_THREADPOOL_SIZE в runtime-прологе (nova_evloop_init); явный
пользовательский UV_THREADPOOL_SIZE уважается.
Аменд: Plan 83.11 Ф.4 (2026-06-08) — offload через centralized driver
После D466 (centralized I/O driver) шаг 1 выше уточнён: когда
driver-thread запущен (production M:N), nova_blocking_offload не
вызывает uv_queue_work(nova_current_loop()) на loop’е home-worker’а, а
submit’ит job NOVA_DRV_JOB_ARM_BLOCKING в driver queue. Driver thread в
_nova_driver_handle_arm_blocking вызывает uv_queue_work на своём
loop’е (&_nova_driver.loop). Соответственно шаг 4 (after_work_cb)
исполняется на driver thread, а не на home-worker’е, и будит fiber
кросс-потоково через nova_sched_wake (тот же dispatch-путь, что
driver-routed Time.sleep из D466). Legacy per-worker путь
сохранён для bootstrap/single-thread режима (nova_driver_is_started()
== false).
Cross-thread wake-before-park закрывается тем же механизмом, что у sleep:
(а) park_until fast-path проверяет done-predicate до yield; (б) перед
submit’ом job’а делается pre-init nova_sched_get_state(scope) — чтобы
wake с driver-потока, прилетевший до register_pending, не потерялся на
find_state == NULL; (в) pending_wake[]-counter (Plan 83.11 Ф.3.A).
uv_cancel(&st->work) thread-safe для work-request’ов на обоих loop’ах,
так что cancel-путь (_nova_blocking_stop_cb) работает без изменений.
✅ ЗАКРЫТО (Plan 173.0 Ф.1 / Plan 83.11, 2026-06-11). Ранее существовавший pre-existing M:N race в общей park/wake машинерии (torn-чтение массивов
sched_stateвnova_sched_grow_stateпри росте scope, гонка сnova_sched_wakedriver-потока) структурно устранён: park/wake storage (NovaSchedState) переведён на chunked stable-address директории (fibers.h:398-445— chunk=64, alloc ровно раз, RELEASE-publish + ACQUIRE-read,capacityпубликуется ПОСЛЕДНЕЙ; grow = CAS-publish, не realloc), плюс single-winner park_state WAIT→DISPATCHED CAS (fibers.h:1864) и nested-cascade cancel-propagation (fibers.h:893) — torn-base физически невозможен. supervised корректен под РЕАЛЬНЫМ M:N:NOVA_MAXPROCS=1БОЛЬШЕ НЕ ТРЕБУЕТСЯ как race-guard (D14/D50/D75). Регресс-гарды:nova_tests/concurrency/grow_vs_wake_explicit.nv,nova_tests/err173_0/supervised_drain_mn_guard.nv(оба ARMED M:N, безNOVA_MAXPROCS=1/NOVA_AUTOARM=0). Трекер[M-83.11-grow-vs-wake-race]— CLOSED (simplifications.md); driver-routing blocking-offload корректен.
blocking { } — примитив, не handler-эффект. В отличие от detach
(with Detach = SyncDetach), blocking { } не диспетчеризуется через
handler: контекст-чувствительный codegen всегда либо offload’ит (в
fiber-контексте), либо выполняет тело inline (на main-потоке — нет
worker’а пинить). Blocking в сигнатуре — требование декларации, не
точка подмены.
V1 leaf-контракт (GC-safety)
work_cb исполняется на threadpool-потоке, который не
Boehm-GC-registered и не является fiber’ом. Поэтому V1-контракт:
тело blocking { } обязано быть leaf — FFI/syscall без
- GC-аллокации (
GC_mallocс не-registered потока — UB), - вызовов обратно в Nova-рантайм (нет fiber/event-loop-контекста),
- control-flow-escape наружу (
return/break/continue, пересекающих границуblocking { }).
Проверяется компилятором (Plan 83.3 Ф.6). Тело blocking { }
type-check’ается как nogc + бан suspend-эффектов:
- alloc-вызовы (
[]T.new,HashMap.new,StringBuilder.new,str.from, …) внутриblocking { }→ compile error; - вызов функции/эффект-операции с эффектом
Net/Fs/Db/Timeвнутриblocking { }→ compile error (нужен event-loop, которого на threadpool-потоке нет).
Остаётся documented-риском, не enforced’ным: nogc-проверка —
консервативный whitelist (не ловит user-record-литералы); throw/?
(Fail-эффект) — throw делает longjmp без fail-frame на
threadpool-потоке. Спековый пример blocking { c_read_file(path) } с
Fail[IoError] под V1 безопасен только если FFI сигналит ошибку
возвратом (Result), а не Nova-throw.
Покрывает основной use-case — блокирующий FFI. V2 (followup):
GC-регистрация threadpool-потоков (GC_register_my_thread once-per-
thread) разрешит произвольный Nova-код под Blocking (включая alloc и
throw); отложена — V1 достаточно для целевого паритета.
Cancellation
- Не стартовавшая
uv_work_tотменяетсяuv_cancel()→ fiber будится с cancel. - In-flight блокирующая работа не прерывается — C-вызов
непрозрачен и доводится до конца, результат отбрасывается, бросается
cancel. Это industry-standard: Go не прерывает блокирующий
cgo-вызов, tokio не отменяет running
spawn_blocking. В обоих случаяхafter_work_cbотрабатывает → fiber гарантированно будится. - Интеграция с
CancelToken(D75) / supervised-cancel — черезstop_cb, зарегистрированный в pending-таблице scope’а.
Detach и Blocking могут комбинироваться:
fn submit_log(event Event) Detach Blocking -> () =>
detach {
blocking {
c_send_to_syslog(event)
}
}
5. Никакого await / маркера на месте вызова
Подтверждение D14 (REVISED):
вызов suspend-функции из любой функции — обычный вызов, без
.await/?async/любого маркера. Suspension — ambient
(D62), не type-fact. Точки suspend —
implementation detail (preemption после v1.0 делает их
несущественными).
Почему
-
Suspension как ambient (D62) упрощает ментальную модель. Программист не выбирает между
AsyncиPar— это деление искусственное и устранено. AI-friendly: suspension — runtime-факт, не type-факт. Гарантия non-suspension — черезrealtime { }блок (D64). -
spawnтолько в scope’е защищает от утечек fiber’ов. Главная ошибка Go-style fire-and-forget — задачи, переживающие caller’а незаметно. Structured concurrency (Trio, Kotlin coroutines, Swift TaskGroup) — общепризнанный путь решения. -
detachкак эффект делает long-lived задачи видимыми. Если функция запускает что-то, переживающее её — это видно в сигнатуре (D10 «всё — эффект», AI-first). БезDetachв сигнатуреdetach { ... }— ошибка компиляции, аналогичноthrowбезFail[E]. -
Blocking— явная модель Tokio. Tokio (spawn_blocking) доказал, что явный примитив для блокирующих операций — рабочая модель. Альтернативы:- Авто-детект (Go/Loom) требует deep runtime hooks, сложен и хрупок.
- Без поддержки превращает любой блокирующий syscall в bottleneck для всего scheduler’а.
-
Отсутствие
await— прецедент Erlang/Go/Java virtual threads. Эти языки работают без маркера suspend много лет, на масштабе backend. Опыт показывает: маркер не даёт реального контроля (preemption всё равно вставляет suspend), но создаёт boilerplate. D14 уже зафиксировал это — D50 подтверждает. -
spawn bodyвозвращает unit (а не результат body). Async прозрачный (D62) делает синхронный результат от concurrent-вызова избыточным:- Если результат нужен sequentially → пиши прямой вызов
let users = fetch_users(). Suspension случится сама собой, никакого.await/.value()не пишется. - Если нужна параллельность с гомогенным результатом →
parallel for ... { ... }возвращает массив. - Гетерогенная параллельность → channels (D79) или
parallel { ... }typed tuple (открытый Q-parallel-tuple). ⚠️mut-захваты — race-prone в preemptive runtime, безопасны только в D71 single-threaded bootstrap; для production использовать channel или parallel-tuple.
Альтернативы — implicit-await (= «цвет функции», D62 запрещает) или
Handle[T].value()(= новый тип в системе, дополнительный boilerplate, типичный Rust-стиль). И то, и другое противоречит принципу D9 «один очевидный путь». - Если результат нужен sequentially → пиши прямой вызов
Что отвергнуто
- Раздельные
Async/Par. Искусственное разделение, AI-unfriendly, не даёт информации сверх «функция fan-out» (которая лучше через имя/док). - Fire-and-forget
spawnсвободно (как Go). Утечки fiber’ов становятся систематическими, отмена не прорастает, supervision ломается. detachбез эффекта (просто примитив). Скрывает важную информацию из сигнатуры — функция «что-то запускает в фоне» неотличима от обычной. Нарушает D10/D14.- Авто-детект блокирующих syscall’ов (Go runtime hooks, Loom carrier-thread magic). Сложнее реализовать, хрупче на нестандартных C-библиотеках, прячет важное поведение от сигнатуры.
await/?asyncмаркер на call site. Не даёт реальных гарантий после введения preemption (v1.0+); добавляет boilerplate.- Отдельный supervisor для каждого detach. Глобальный default
supervisor (handler
Detach=LogAndDrop) проще; явный supervisor ставится handler’ом в скоупе при необходимости. spawn bodyвозвращает результат body. Изначальная редакция D50 /syntax.mdподразумевала это (let r = spawn { compute() }). Отвергнуто: либо неявно блокирует caller’а до завершения spawn’а (тогдаsupervisedтеряет смысл — нет параллельности), либо требует implicit-await (= «цвет функции», нарушение D62), либо требуетHandle[T]тип с blocking.value()(= boilerplate + новый тип в системе). Все три плохи. Async прозрачный (D62) делает синхронные значения от concurrent-вызова избыточными — если значение нужно, пиши прямой вызов. spawn — fire-and-forget statement; результаты через#share-примитивы (Atomic*/Mutex) / каналы (D415 — голый mut-захват отвергается) илиparallel for(массив-результат).Handle[T]/ future-объект от spawn. Aналог RustJoinHandleили KotlinDeferred. Отвергнуто: добавляет тип в систему, требует.value()синтаксиса (то же что implicit-await но явно в коде), не даёт ничего сверх#share-примитивов/каналов (D415).
Связь
- D14 — D50 конкретизирует
D14 (suspension ambient, structured
spawn,detach/blockingкак отдельные примитивы с эффектами). - D2 / D10 —
Detach,Blocking— обычные эффекты в системе, handler-механизм работает одинаково. - D64 —
realtime { }блок запрещаетBlockingвнутри (suspend на blocking-pool несовместим с гарантией не-приостановки). - D13 — panic в detached-fiber’е = смерть fiber’а (как везде); глобальный supervisor логирует.
- revolutionary.md R7, R9 — structured primitives и supervision; D50 уточняет, что они — единственный способ запустить fiber внутри scope’а.
Открытые вопросы
Channel[T]API — формализован в D79.Mutex/Atomic— stdlib (D167-D172 + D370 (ex-D370)), не prelude; owner-actor pattern предпочтителен, escape hatch черезimport runtime.sync.{...}.- Размер blocking-pool по умолчанию — детали реализации runtime’а.
- Поведение при отмене detached-задачи — отдельный handler-сахар или работа через capability?
Эволюция
D50 active. До его принятия D14 оставлял несколько вопросов (Q12.1 spawn-семантика, Q12.2 Async vs Par, Q12.6 C interop) — закрыты этим решением. Q12 в open-questions сжимается до stdlib-API (переходит в Q9).
D71. Bootstrap concurrency runtime
Status: active. Конкретизирует D14 и D50 для bootstrap-компилятора: минимальная реализация
supervised,detach,parallel for,Time.sleep— достаточно для тестов с реальным переключением корутин и pre-production-кода. Production-runtime будет надстройкой (preemption, timer-wheel, multi-thread, cancellation, error-propagation).
Что
D71 фиксирует минимальную, но spec-faithful реализацию concurrency-примитивов из D14/D50 в bootstrap-runtime’е:
supervised { body }— round-robin scheduler над локальной очередью fiber’ов.spawnимеет две семантики, выбираемые контекстом:- Внутри
supervised— кладётся в очередь scope’а, запускается scheduler’ом при выходе из scope. - Вне
supervised— eager-blocking (запускается до завершения немедленно). Это не spec-compliant поведение D50 (по спеке должно быть compile error), но сохранено для bootstrap-совместимости. См. «Что упрощено».
- Внутри
detach { body }— fire-and-forget. Default-handlerSyncDetachисполняет body inline (как обычный block). ЭффектDetachв сигнатуре пока не требуется компилятором.parallel for x in iter { body }— D14 fan-out. Десугарится вsupervised { for x in iter { spawn { body } } }.Time.sleep(ms)— yield-point с context-sensitive диспатчизацией.- Capture-by-value для immutable scalars. Без этого parallel for и любой spawn-в-цикле дают неправильную семантику (все queued fibers видят последнее значение loop-переменной).
- Heap-allocated ctx-struct в supervised. Без этого N spawn’ов в одной итерации цикла разделяют один stack-slot.
Правило
Тип результата supervised и parallel for
supervised { body } — value-expression (Plan 173.1 Ф.1, 2026-07-09;
D414 §4): возвращает своё trailing-выражение, вычисленное ПОСЛЕ join’а
всех детей (post-join). Void-форма (нет trailing) — по-прежнему unit.
Bootstrap-заглушка «возвращает unit, trailing отбрасывается (void)»
(2026-05-06) снята. parallel for = сахар над этой формой.
ro total = supervised {
spawn { part_a() }
spawn { part_b() }
acc // ← вычисляется после завершения ВСЕХ детей
}
Мутируемое состояние между детьми — не через голый mut-захват (с
D415
это [E_CONCURRENT_MUT_CAPTURE]), а через #share-примитивы/каналы; результат
блока — trailing-выражение post-join.
parallel for x in iter { body } — по spec D14 это expression
типа []T (где T — тип body). Это параллельный map, не loop.
ro responses []Response = parallel for url in urls { fetch(url) }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// параллельный map: 1 element → 1 response
Codegen (Plan 173.1 Ф.2, 2026-07-09): array-mode — канальный сбор для
ЛЮБОГО элемента T и ЛЮБОГО итератора (Iter-protocol, без len()):
клон Sender’а создаётся в родителе на момент spawn (refcount++ ДО
закрытия родительского tx) и move’ится в ребёнка; ребёнок send’ит
trailing-значение (int-скаляры — прямо в слот, heap-указатели — через
intptr, value-типы — boxed-копией) и закрывает клон на ЛЮБОМ выходе
(success/throw/cancel); родительский tx закрывается после enqueue-цикла;
выделенный drain-fiber внутри scope дренирует до None (= последний клон
закрыт) и пушит в результирующий Vec[T]. Буфер K = min(len, 16) —
back-pressure. Порядок = completion order (плотный) — упавший ребёнок
не шлёт, дыр нет; итерационный порядок НЕ гарантирован. Ошибка ребёнка →
отмена siblings + re-throw после дренажа (173.0) — накопленный массив НЕ
возвращается. Без trailing — statement-семантика (unit), включая
inline-threshold оптимизацию. См. nova_tests/err173_1/ +
nova_tests/concurrency/parallel_for_array.nv.
for vs parallel for — разные семантики
Обычный for x in iter { body } — это statement (тип unit).
Тело выполняется ради side-effects:
for url in urls {
Log.info(url) // только side effect, ничего не собирается
}
// for сам — unit
Если нужен sequential map (собрать массив результатов
последовательно) — использовать iter.map(|x| body):
ro names []str = users.map(|u| u.name)
// или с trailing-fn (для длинных тел):
ro names []str = users.map() fn(u) => u.name
parallel for — expression (тип []T). Тело — функция от элемента
к результату:
ro responses []Response = parallel for url in urls { fetch(url) }
Сводная таблица:
| Форма | Тип | Семантика |
|---|---|---|
for x in iter { body } | unit | statement, side-effects |
iter.map(|x| body) | []T | sequential map |
parallel for x in iter { body } (body has trailing) | []T | parallel map (fan-out) |
parallel for x in iter { body } (no trailing) | unit | parallel side-effect loop |
Это намеренное различие — for для side-effects (большинство
случаев), parallel for для structured fan-out. Sequential map
выражается через method-chain, не через for-form, чтобы избежать
аллокации []unit для side-effect-циклов и сохранить привычную
семантику for из Go/Rust/Java.
Bootstrap-реализация (2026-05-06): array-mode работает для
T ∈ {int, bool, f64, str} и для итераторов a..b, a..=b, array
literal. Pre-allocate NovaArray_T* размера N (end - start [+1]
для range, длина литерала для array), per-iteration ctx содержит
_nova_par_idx + _nova_par_result, spawn body’s trailing
автоматически пишет в result.data[idx]. Если trailing отсутствует —
старая семантика (statement, unit). Spread в array literal не
поддержан в v1 — degrade to unit. См. nova_tests/concurrency/parallel_for_array.nv.
1. supervised { body } — round-robin scope
supervised {
spawn fiber_a() // в очередь, не запускается
spawn fiber_b() // в очередь
do_main_work() // исполняется eager в текущем потоке
spawn fiber_c() // в очередь
} // ← scheduler крутит resume A, B, C по кругу
// пока все не MCO_DEAD
Семантика:
- Очередь scope’а — локальная
NovaFiberQueueс фиксированной capacity (64 в bootstrap). Превышение → runtime panic. spawnв scope — создаёт coroutine черезmco_create, кладёт в очередь, не делает resume. Возвращает unit.- Scope-exit —
nova_supervised_runкрутит циклdo { step } while alive, гдеstep— один full pass очереди (resume каждый живой fiber один раз). - Тело
supervisedисполняется eager в потоке вызвающего, до scheduler-runa. Yield-point на main-уровне (см. п. 5) даёт main-flow возможность переключиться с queued fiber’ами. - Captures в spawn-body живут на стеке (по pointer) или копируются в ctx-struct (по value) — см. п. 6.
2. spawn — две семантики по контексту
// (a) Внутри supervised — отложенный запуск
supervised {
spawn { compute_a() } // запустится при scope-exit (или раньше при yield)
spawn { compute_b() }
}
// (b) Вне supervised — eager (legacy bootstrap-семантика)
ro r = spawn { compute_x() } // запускается СРАЗУ до завершения,
// r получает результат
В bootstrap’е разрешены оба варианта. По спеке D50 (b) должен быть compile error.
Закрытие этого расхождения — после миграции существующих тестов на supervised.
Тип результата spawn
spawn body возвращает unit, всегда (resolution от 2026-05-06).
Обоснование — D50 «Почему» п. 6: async прозрачный (D62) делает
синхронный результат от concurrent-вызова избыточным, альтернативы
(implicit-await, Handle[T]) хуже по AI-friendliness.
Идиомы получения значений от concurrent-выполнения:
// (1) Прямой вызов — async прозрачный (D62).
ro users = fetch_users() // тип []User; suspension случается сама
// (2) parallel for — массив гомогенных результатов.
ro responses = parallel for url in urls { fetch(url) } // []Response
// (3) #share-примитивы — гетерогенная параллельность (D415;
// голый mut-захват = E_CONCURRENT_MUT_CAPTURE).
mut a = AtomicInt.new(0)
mut b = AtomicInt.new(0)
supervised {
spawn { a.store(compute_a()) }
spawn { b.store(compute_b()) }
}
use_both(a.load(), b.load())
Bootstrap-исключение (legacy). spawn вне supervised сейчас
работает в eager-blocking семантике (см. п. 2). Для совместимости с
существующими тестами до их миграции на supervised — let r = spawn { body } вне scope временно возвращает значение body (через
type-erased nova_int в ctx-поле _nova_result). Это не
spec-faithful, удалится вместе с переходом «spawn вне scope =
compile error».
После закрытия legacy-расхождения:
spawn bodyвсегда unit, во всех контекстах.- Поле
_nova_resultв ctx-struct убирается. - Все обращения к результату concurrent-вызова — через прямой вызов /
parallel for/#share-примитивы и каналы (D415).
3. detach { body } — fire-and-forget с default handler
fn handle_request(req Request) Net Db Detach -> Response {
ro resp = process(req)
detach { write_audit(req, resp) }
resp
}
Default-handler SyncDetach: тело исполняется inline в потоке caller’а —
никакого fiber’а, никакого scheduler’а. Семантически валидно для тестов
(spec D50 явно описывает with Detach = SyncDetach { ... } как тестовый default,
bootstrap-default = это).
В bootstrap’е:
- ⚠ UPDATED (Plan 173 Ф.3 п.2, D414 §2):
требование эффекта
Detachтеперь enforced в checker’е —detachбезDetachв сигнатуре →[E_DETACH_REQUIRES_EFFECT](exempt: test-root / ambientwith Detach/ handler-op-тело).Detachостаётся зарезервированным builtin-именем (не самостоятельный effect-type-декл в std). - Глобальный supervisor (для реального async-execution на отдельном OS-thread’е) — отложен до production-runtime.
- Panic-containment (
LogAndDrop) — дефолт-политика (D414 §2).
4. parallel for x in iter { body } — fan-out
fn fetch_all(urls []str) Net Fail -> []Response =>
parallel for url in urls {
fetch(url)
}
Семантически идентично supervised { for x in iter { spawn { body } } }.
Codegen строит этот AST синтетически и эмитит через общий путь.
Loop-переменная — immutable scalar (для range — всегда int; для array —
тип элемента). Captures её по value (см. п. 6), что обеспечивает корректный
snapshot в каждой итерации.
5. Time.sleep(ms) — context-sensitive yield-point
fn anywhere() {
Time.sleep(0) // вне scope: no-op
}
supervised {
spawn { ... }
Time.sleep(0) // в scope-body: один pass очереди
// (main-flow yield'ает queued fibers'ам)
spawn {
Time.sleep(0) // в fiber: nova_fiber_yield()
// — corutine суспендится, scheduler крутит других
}
}
В bootstrap’е ms учитывается через monotonic wall-clock (2026-05-06).
sleep(0) даёт один yield (compatibility с устоявшимся Time.sleep(0)
идиомом). sleep(N>0) ждёт реально N миллисекунд:
| Контекст вызова | Поведение для ms<=0 | Поведение для ms>0 |
|---|---|---|
| Внутри fiber-body (spawn) | nova_fiber_yield() (один yield) | yield-loop пока _nova_monotonic_ms() < deadline; каждый yield проверяет cancel |
Вне fiber, внутри supervised body | nova_supervised_step(&queue) (drain один раз) | drain queue per pass пока < deadline |
| Полностью вне любого scope | no-op | native OS sleep (Sleep на Win, nanosleep на POSIX) |
Time.now() возвращает monotonic ms (GetTickCount64 на Win,
clock_gettime(CLOCK_MONOTONIC) на POSIX). Эпоха unspecified —
тесты должны сравнивать только разности, не абсолютные значения.
Это spec-faithful по D62 (Async — ambient): Time.sleep — обычная функция
без эффект-окраски, callable откуда угодно. Поведение зависит от ambient
runtime-окружения в точке вызова.
Чем bootstrap отличается от production-timer-wheel: bootstrap делает busy-yield-loop с проверкой clock’а — fiber, ожидающий 100ms, всё это время съедает CPU yield-проверками. Production-runtime поставит fiber в sleep-list с deadline и scheduler пропустит sleeping fibers до их пробуждения (нулевой CPU между yield’ами). Поведение из Nova-кода неотличимо; это чисто оптимизация.
6. Capture-by-value для immutable scalars
При запуске spawn внутри supervised, его захваты переменных делятся на:
- By value — переменная объявлена как
let(неlet mut) И тип scalar (int,bool,f64,f32,u8). Значение копируется в ctx-struct какT name— fiber видит snapshot на момент spawn’а. - By pointer — переменная mutable (
let mut) или non-scalar (record, array, string). В ctx-struct хранитсяT* name, fiber разделяет состояние с caller’ом и другими fiber’ами.
Зачем это: очередь supervised держит fiber’ы до scope-exit. Если бы все
captures были by-pointer, loop-переменные (после for’а указывающие на последний
элемент) видели бы все queued fibers как «последний элемент» — parallel for x in [1,2,3] { sum += x } дал бы 9, не 6. By-value snapshot этого избегает.
Mutable shared state работает как ожидается: let mut acc = 0; spawn { acc += x } — acc остаётся by-pointer (mutable), все fiber’ы пишут в одну ячейку.
7. Heap-allocated ctx-struct в supervised
ctx-struct для spawn внутри supervised аллоцируется через nova_alloc (не на
стеке). Без этого N итераций цикла перезаписали бы один stack-slot, и все
queued fibers видели бы только последнее значение captures. Stack-allocation
сохраняется для eager-blocking spawn вне scope.
Почему
-
Минимальный delta vs full D50. D14/D50 определяют большой набор примитивов (
spawn/detach/parallel for/race/select/cancel_scope/with_timeout/blocking). Без preemption и scheduler-thread’а реализуемы только cooperative-варианты — они и реализованы. Остальное — production. -
Spec-faithful по D62. Async ambient →
Time.sleepcallable откуда угодно и не требует эффекта в сигнатуре. Context-sensitive диспатчизация в bootstrap — естественное следствие: где scheduler есть — yield, где нет — no-op. -
Capture-by-value для immutable closes a real correctness hole. Без этого
parallel for+ любой spawn-в-цикле дают неправильную семантику. Это не опциональная оптимизация, а необходимость для базовой корректности. -
Heap-ctx — единственный способ дать каждой итерации независимый snapshot при отложенном запуске. Альтернативы (stack-allocated array of ctx) сложнее и не лучше по производительности (всё равно нужно держать N структур до scope-exit).
-
Eager-blocking
spawnвне scope — bootstrap legacy. Существующие тесты38_deep_spawn.nv(top section) рассчитывают на эту семантику. Перевод на strict-spec (compile error без supervised) требует одновременной миграции всех тестов — отдельная задача.
Что отвергнуто
spawnвсегда eager-blocking (включая внутри supervised). Это убирает весь смыслsupervised— нет очереди, нет round-robin, нет interleave. Отвергнуто.spawnвсегда deferred-into-queue (включая вне scope). Ломает 28 legacy- тестов. Отвергнуто до миграции.- Implicit fiber-wrap для тела
supervised. Альтернатива main-yield: само тело scope’а становится первым fiber’ом в очереди. Семантически корректнее (главный flow тоже full participant), но требует переноса всех локальных переменных body в ctx-struct, что усложняет capture-семантику для других spawn’ов в том же scope. Отвергнуто в пользу простогоnova_supervised_stepдля main-yield. #define cap (*_c->cap)macro для capture access. Использовалось до 2026-05-06. Ломалось при nested supervised/spawn: имяcapрекурсивно расширялось в struct field-declarators (nova_int* order;→ garbage). Заменено на inline rewrite вExprKind::Ident.- Stack-allocated ctx внутри supervised. Один slot шарится между итерациями цикла → bug. Heap-alloc обязателен.
yieldkeyword. АльтернативаTime.sleep(0). Отвергнут: D62 говорит «suspension — runtime, не type/syntax-level», keyword подсветил бы то что спека прячет.Time.sleep— обычная функция, валидная спецификационно.
Открытые вопросы
- Когда переключить
spawnвне scope на compile error? После миграции38_deep_spawn.nvверхней части наsupervised-обёртки. Затрагивает 28 существующих тестов. detachчерез OS-thread в bootstrap? Сейчас SyncDetach. Реальный background требует pthread/Win32-интеграции — большая работа, отложена.- Эффект
Detachв effect-system. Объявление + compile-time проверка требования в сигнатуре. Сейчас не выполняется. - Удалить eager-blocking
spawnвне scope. Закрыт спор о типе результата (spawnвсегда unit), но bootstrap всё ещё разрешает legacy-семантикуlet r = spawn ...внеsupervised. Удаляется одновременно с переходом «spawn вне scope = compile error» — после миграции 28 legacy-тестов в38_deep_spawn.nvверхней части. - Эффект
Timeв effect-system — РЕАЛИЗОВАН (2026-05-06). По D11/D31/D62: pre-registered как built-in effect (sleep(int),now() -> int);Time.sleep/Time.nowидут через стандартный effect-dispatch путь (Nova_Time_sleep / Nova_Time_now). Real wall-clock реализован (2026-05-06):Time.now()возвращает monotonic ms (GetTickCount64 на Win, clock_gettime на POSIX);Time.sleep(ms>0)ждёт реально через yield-loop с deadline в fiber/ scope-context’е, native OS sleep на top-level.Time.sleep(0)— один yield (compatibility-режим). User override черезwith Time = effect Time { ... } { body }— работает (тесты46_time_handler.nv). Что НЕ закрыто: production-timer-wheel (sleeping fiber’ы съедают CPU yield-проверками — бизнес-логика этого не видит, это оптимизация). - Cooperative cancellation propagation реализована (2026-05-06):
fiber-throw → scope
cancel_requested = true→ остальные fiber’ы при следующем yield (Time.sleepили scheduler step) делаютnova_throw("scope cancelled"). Это spec-faithful по D50. Что НЕ работает: fiber без yield-точек не отменится (cooperative-only). Preemption — в production runtime (timer-based safepoint check). - Positive-тесты на throw из fiber. Без top-level
try/catch(D25) невозможно protected-call.throwиз fiber → rethrow на main → abort работает корректно, но не testable как PASS. race,select,cancel_scope,with_timeout. Каждый — отдельная задача после cancellation propagation.- Channels (
Channel[T]). Формализованы в D79 (2026-05-07). В D71 bootstrap-runtime реализация — следующая задача (single- threaded queue + yield). До тех пор producer-consumer тестируется через shared mut + yields (валидно только в D71 single-threaded).
Связь
- D14 — fiber-runtime как ambient capability. D71 даёт минимальный конкретный runtime.
- D50 — language-level модель concurrency. D71 — её первая bootstrap-реализация.
- D62 — Async ambient. Объясняет почему
Time.sleepне требует эффекта в сигнатуре. - D64 —
realtime { }запрещает suspension. По D71Time.sleepвнутри realtime-блока должен давать compile error (compile-time check эффектаTimeв сигнатуре). Не реализовано в bootstrap’е.
Реализация
bootstrap-codegen (compiler-codegen/):
nova_rt/fibers.h:NovaFiberQueue,nova_supervised_step,nova_supervised_run,nova_fiber_yield,nova_fiber_spawn_into.src/codegen/emit_c.rs:emit_supervised,emit_detach,emit_parallel_for,emit_spawn(with by-value/heap-ctx logic), context-sensitiveTime.sleepdispatch.src/lexer/,src/ast/,src/parser/: keywordssupervised,parallel,detach; AST variantsSupervised,Detach,ParallelFor.- Тесты:
nova_tests/concurrency/deep_spawn.nv(section 10, 9 interleave- тестов),detach_test.nv(13),parallel_for.nv(12),main_yield.nv(11). Полный suite вnova_tests/concurrency/.
Эволюция
- 2026-05-06: D71 introduced — bootstrap busy-yield + cooperative
cancellation через
nova_fiber_yieldre-check. - Plan 22 Ф.4 (2026-05-11): scheduler становится libuv-event-loop
driven.
Time.sleepчерез park-on-uv_timer_t(см. D93) — CPU idle на sleep period вместо busy-yield.nova_supervised_runidle черезuv_run UV_RUN_ONCEкогда все живые fiber’ы parked. - Plan 22 Ф.5 (2026-05-11): top-level main оборачивается в implicit
supervised scope (см. D92)
—
_nova_active_scopeвсегда non-NULL в user-code. - Plan 22 Ф.6 (2026-05-11): park/wake state production-grade lazy
pointer-в-
NovaFiberQueue(Вариант B) — O(1) lookup, нет cap’а на nested scopes, память выделяется только когда реально park’аем. - Plan 44 (M:N, milestone v1.0+): scheduler становится work-stealing
per-worker, park/wake API D93 расширяется на cross-worker wake через
uv_async_t.
D75. supervised(cancel: tok) — структурная отмена с внешним токеном
⚠️ REVISED (2026-05-14). Раньше D75 вводил отдельный keyword
cancel_scope { tok => body }. Он удалён. Внешняя отмена теперь выражается именованным аргументомcancel:уsupervised(D102):supervised(cancel: tok) { body }. Никакого нового keyword’а, никакого scope-introducedtok =>binding (которого больше нет нигде в языке, ср. отменуf(args) { x => body }в D43).Bootstrap-реализация старого keyword’а (
cancel_scope, 2026-05-06) остаётся в дереве до миграции — см. Plan 47. Старый текст D75 — в history/.
Что
supervised(cancel: tok) { body } — обычный supervised-scope
(D50), которому
снаружи можно сообщить «отмени всех fiber’ов внутри». Связь идёт
через tok типа CancelToken — caller-owned значение: создаётся
вызывающим кодом, передаётся в supervised именованным аргументом,
переживает scope.
fn fetch_all(urls []str, cancel CancelToken) -> []Response {
mut results []Response = []
supervised(cancel: cancel) {
for url in urls {
spawn { results.push(fetch(url)) }
}
}
results
}
// caller-side:
ro tok = CancelToken.new()
spawn {
Time.sleep(5_000)
tok.cancel() // через 5s валим scope извне
}
fetch_all(urls, tok)
supervised без cancel: — закрытый scope, извне не отменяемый
(только panic изнутри). supervised(cancel:) — escape hatch для
kill-switch’а (timeout-обёртка, user cancel button, fail-fast по
внешнему сигналу). Наличие cancel: делает код самодокументирующимся:
видно cancel: — scope намеренно отменяемый.
Модель токена
CancelToken — это caller-owned handle для запроса отмены, не
scope-binding. Создаётся CancelToken.new(), живёт сколько нужно
вызывающему, может быть захвачен в замыкание / передан аргументом /
положен в канал. Все держатели ссылки работают с одним объектом.
Capabilities:
tok.cancel()— запросить отмену. Если токен привязан к живому scope’у — все fiber’ы scope’а на следующем yield-point’е бросят"scope cancelled"(механизмcancel_requestedиз D71). Если токен не привязан или scope уже завершён — no-op (безвредно). Idempotent.tok.is_cancelled() -> bool— чтение флага без yield. Не throws.child.cancelled_by(parent CancelToken)— направленный каскад:parent.cancel()отменяет иchild; обратно НЕ течёт (child.cancel()не трогаетparent). Композиция в более широкий родительский kill-switch — как деревоcontextв Go. Имя несёт направление: «child отменяется по parent».
Правило bind-check — один токен, один живой scope
supervised(cancel: tok) при входе привязывает tok к scope’у;
при выходе — отвязывает.
Один CancelToken нельзя привязать к двум живым scope’ам
одновременно. Повторный supervised(cancel: tok) с уже-привязанным
токеном — ошибка (runtime panic «token already bound to a live
scope»).
ro tok = CancelToken.new()
supervised(cancel: tok) {
supervised(cancel: tok) { ... } // ОШИБКА: tok уже привязан
}
Почему это безопасно ограничивать: делиться токеном «вниз» по вложенности не нужно. Если внешний scope отменяется — его файбер стоит на yield-point’е внутри вложенного scope’а, поэтому вложенный рвётся автоматически как часть structured-отмены. Нужен независимо отменяемый внутренний scope — заводится новый токен.
После выхода из scope’а токен отвязан и может быть привязан заново (или, для простоты реализации, токены — single-use; решается в Plan 47).
Почему runtime-check, а не compile-time
Compile-time enforcement «токен не привязан дважды» потребовал бы
affine/linear-типов с borrow-различием (&tok для bind, &tok для
cancel(), borrow-checker следит за непересечением) — это Rust
borrow checker. В Nova его нет (GC + эффекты); тащить affine-типы
ради одной фичи несоразмерно.
Escape токена за пределы scope’а не опасен: tok.cancel() на
завершённом scope’е — no-op by design (в отличие от scope-handle,
через который можно spawn в мёртвый scope — вот это был бы UB).
Поэтому защищать надо только aliasing (double-bind), а он ловится
дёшево одним сравнением поля в bind() и проявляется на первом же
прогоне теста.
Отличие форм supervised
supervised { body } | supervised(cancel: tok) { body } | |
|---|---|---|
| Wait для всех fiber’ов | да | да |
| Cancel изнутри (через throw) | да | да |
Cancel снаружи (tok.cancel()) | нет | да |
| Token-binding (родительский kill-switch) | нет | да (через child.cancelled_by(parent)) |
supervised остаётся keyword’ом — это неустранимая магия, точка,
куда spawn регистрирует fiber’ы (D14/D50; spawn — тоже
keyword-исключение в D43). cancel: — обычный
именованный аргумент keyword-конструкции; новых keyword’ов D75 больше
не вводит.
Семантика отмены
- Ручная отмена изнутри scope’а (
tok.cancel()в spawn-body) — допустима. Остальные spawn’ы в том же scope’е получают cancel-сигнал на следующем yield. Так реализован stdlibrace(победитель отменяет проигравших). - Auto-уборка fiber’ов: на выходе из
supervised(cancel:) { ... }гарантируется, что все spawn’ы завершились — сработала отмена или нет (как в обычномsupervised). - Throw + cancel:
throwвнутри scope’а сначала ставитcancel_requested = true, потом re-throw’ит на main flow. Token остаётся cancelled.
race / with_timeout — stdlib, не keyword’ы
race и with_timeout — обычные функции стандартной библиотеки,
построенные на supervised(cancel:) + spawn + Channel +
ChanReader.close_after(Duration) (Plan 65 revision),
вызываются через trailing-форму D43:
export fn within[T](dur Duration, body fn() -> T) -> T | Cancelled {
ro tok = CancelToken.new()
spawn { Time.sleep(dur); tok.cancel() }
supervised(cancel: tok) { body() }
}
export fn race[T](competitors []fn() -> T) -> T {
ro ch = Channel[T].new(capacity: competitors.len())
ro tok = CancelToken.new()
supervised(cancel: tok) {
for comp in competitors {
ro c = comp
spawn { ch.send(c()); tok.cancel() } // self-cancel изнутри
}
}
ch.recv()!!
}
Что отвергнуто
- Keyword
cancel_scope(старый D75) — отдельный keyword радиsupervised+ токен. Схлопывается в именованный аргументcancel:без потери выразительности. cancel_scope { tok => body }scope-introduced binding —tok =>не существует больше нигде в языке (ср. отменуf(args) { x => body }в D43). Один pattern вместо edge-case’а.- Compile-time token-scope enforcement (affine/linear-типы) — несоразмерно; см. «Почему runtime-check».
- Передача
tokчерез channel (Goctx.Done()-стиль) — в Nova явныйbind: композиция compile-time видима, без аллокации канала. - Auto-cancel через Drop — Nova не имеет Drop. Cancellation —
явная операция через
cancel(), не побочный эффект scope-exit.
Связь
- D102
— именованные аргументы;
cancel:— обычный именованный аргумент. Ревизия D75 зависит от D102. - D43 — trailing-форма для stdlib
race/within. - D14 — fiber-runtime.
- D50 — concurrency model.
- D71 —
cancel_requestedflag, cooperative cancellation propagation. D75 надстраивается над ним. - D93
— park/wake API.
cancel()черезnova_sched_cancel_all_pendingпробуждает parked-fiber’ов immediate через generic stop_cb mechanism (Plan 22 Ф.4). - Plan 47 — реализация:
миграция bootstrap’а с keyword
cancel_scopeнаsupervised(cancel:).
История: keyword cancel_scope (2026-05-06) → удалён в Plan 47 (2026-05-14)
✅ Keyword
cancel_scopeудалён в Plan 47. Раздел сохранён как контекст миграции.
Старая реализация — отдельный keyword cancel_scope { tok => body }:
лексер KwCancelScope, AST CancelScope { token_name, body }, парсер
parse_cancel_scope, codegen emit_cancel_scope, NovaCancelToken со
scope-owned моделью (токен хранил указатель на queue-frame).
Plan 47 (2026-05-14) заменил это на supervised(cancel: tok):
- AST:
Supervised { body, cancel: Option<Expr> }; вариантCancelScopeудалён. Лексер/парсер/emit_cancel_scope— удалены. NovaCancelTokenпереписан на caller-owned модель: поляcancel_requested+bound_scope(nullable) + динамическийlinked[]. API:nova_cancel_token_new/_bind(scope-binding, panic при double-bind) /_unbind/_cancel/_is_cancelled/_bind_cascade(бывший_bind— каскад токенов).emit_supervisedдля cancel-формы:nova_cancel_token_bindпередnova_supervised_run_cancel(после тела — прямой throw в body-стейтменте не оставит danglingbound_scope);unbind— внутриnova_supervised_run_cancelна всех путях выхода (нормальный возврат + re-throw).
Что caller-owned модель исправила:
- Dangling token. Старый токен хранил указатель на queue-frame и после
scope-exit’а становился dangling. Новый — caller-owned,
unbindчиститbound_scope,cancel()на отвязанном токене — безвредный no-op, токен переживает scope. NOVA_CANCEL_LINKED_CAP=8— фиксированный массив каскадов заменён на динамическийlinked[](GC-managed, геометрический рост).
Унаследованное ограничение (вне scope Plan 47, см. §«Что НЕ входит»
плана): cancel-throw на main flow приходит как plain nova_throw, не
через Nova_Fail_fail/handler-vtable. Корректный фикс требует различать
fiber-throw-from-handler vs cooperative-cancel-throw — отдельная задача.
Из-за этого stdlib within/race (Ф.5) пришлось бы оборачивать в
with Fail[any] handler с конфляцией реальных ошибок и timeout’а
([M-within-error-conflation]) — Ф.5 отложена, см. план.
D79. Channels — coordination между fiber’ами
⚠️ Частично уточнено D91 (2026-05-10): Модель API изменена с Go-style (один
Channel[T]объект сsend/recv) на Rust mpsc-style ((tx, rx) = Channel[T].new()— capability-split наSender[T]иReceiver[T]). Это breaking change API. Остальное в D79 (capacity-bounded buffer, owner-actor pattern, отказ от Mutex/Atomic,selectчерез channels) остаётся. Старая формулировка ниже сохранена для исторического контекста.
Что
Channel[T] — типизированный канал для передачи значений между
fiber’ами с blocking-семантикой. First-class value (не effect),
обеспечивает safe-by-default взаимодействие в concurrent коде.
select { ... } — мультиплексирование recv-операций по нескольким
каналам с опциональным timeout case. Был упомянут в D14/D50 как
пример без формальной декларации; D79 закрывает эту дыру.
Channels — единственный safe способ разделять данные между
fiber’ами в production-runtime (D14 с preemption). Альтернатива —
shared mut через захваты — ⚠️ undefined behavior в preemptive
runtime, разрешён только в D71 single-threaded bootstrap.
Правило
Тип Channel[T]
type Channel[T] { ... } // opaque в spec; реализация в runtime
fn Channel[T].new(capacity int) -> Channel[T]
// capacity = 0 — unbuffered (rendezvous, send блокирует пока recv не пришёл)
// capacity = N>0 — bounded buffer, send блокирует когда полон
Channel[T] — обычный value-тип. Передаётся между fiber’ами
через capture в spawn-body или как параметр функции. Это
single canonical pattern; никаких глобальных channel-handler’ов не
нужно (channel сам по себе — handle-объект).
Operations
fn Channel[T] @send(v T) -> () // блокирует если буфер полон
fn Channel[T] @recv() -> Option[T] // None ⇔ closed и буфер пуст
fn Channel[T] @try_send(v T) -> bool // true если послал, false если полон
fn Channel[T] @try_recv() -> Option[T] // None если пусто (вне closed-семантики)
fn Channel[T] @close() -> () // idempotent
fn Channel[T] @is_closed() -> bool
fn Channel[T] @len() -> int // текущий размер буфера
fn Channel[T] @capacity() -> int // фиксированный, из new()
Семантика closed-channel:
| Operation | Closed + buffer empty | Closed + buffer non-empty |
|---|---|---|
send(v) | false (Plan 30 Ф.1) | false |
try_send(v) | false | false |
recv() | None | Some(item) — дренаж |
try_recv() | None | Some(item) — дренаж |
send на closed channel возвращает false, не panic (Plan 30 Ф.1, D91).
Caller сам решает что делать с false. Это recoverable — не programming error.
Закрытый канал — нормальное runtime состояние (producer закрыл, pipeline
продолжает).
recv после close: дренаж буфера, потом None. Receivers могут
безопасно итерировать while let Some(v) = ch.recv() { ... } без
явной проверки is_closed.
Suspension и signature
Send/recv блокируют → требуют suspension. По D62 suspension — ambient runtime mechanic, не effect. Сигнатура чистая:
fn process(ch Channel[Request]) Db -> () {
while Some(req) = ch.recv() {
Db.exec(req.sql)
}
}
В сигнатуре только бизнес-эффекты (Db), никакого Async. Suspension
неявная.
select { ... } — мультиплексирование
⚠️ УСТАРЕВШИЙ синтаксис (
msg <- ch,timeout(expr) =>) заменён D94. Финальный синтаксис — см. D94.
// D94 финальный синтаксис (Plan 65 revision):
ro timeout = ChanReader.close_after(Duration.from_secs_f64(5.0))
select {
Some(msg) = rx_a.recv() => { process_a(msg) }
Some(msg) = rx_b.recv() => { process_b(msg) }
None = timeout.recv() => { default_action() }
}
Семантика:
- Проверяет каждый arm в псевдослучайном порядке (Fisher-Yates). Если ≥1 готов немедленно — выполняет первый найденный без park’а.
- Иначе — паркует fiber, регистрирует waiter для каждого arm. Первый готовый будит fiber; остальные waiters unlinked.
- Если несколько готовы одновременно — выбор non-deterministic. Программист не должен полагаться на конкретный порядок.
- Closed channel →
rx.recv()возвращаетNone(после дренажа буфера); arm считается ready.None = rx-паттерн различает closed от value (реализовано Plan 173 Ф.3 п.3, D414 §3):Some(v) = rxfires только на значение;None = rx— только на closed+empty;_ = rx— на любой результат (value ИЛИ closed). - Без default и все каналы закрыты (нет
None/_-арма их ловящего) — panic “select: all channels closed”.
Timeout — через ChanReader.close_after(Duration) возвращающий
ChanReader[()] (обычный recv arm, никакого специального синтаксиса).
Канонические patterns
Producer/consumer:
fn pipeline(input ChanReader[Request]) Db -> () {
ro (processed_tx, processed_rx) = Channel[Response].new(100)
spawn {
while Some(req) = input.recv() {
ro resp = process(req)
processed_tx.send(resp)
}
processed_tx.close()
}
spawn {
while Some(resp) = processed_rx.recv() {
Db.exec(resp.persist_sql)
}
}
}
Fan-out:
ro (work_tx, work_rx) = Channel[Task].new(0)
for i in 0..10 {
ro rx = work_rx // capture by value
spawn {
while Some(task) = rx.recv() {
task.run()
}
}
}
for t in tasks {
work_tx.send(t)
}
work_tx.close()
Worker pool с graceful shutdown (D94 select):
ro (work_tx, work_rx) = Channel[Task].new(0)
ro (stop_tx, stop_rx) = Channel[()].new(1)
spawn {
select {
Some(task) = work_rx.recv() => { task.run() }
Some(_) = stop_rx.recv() => { return () }
}
}
Bootstrap-семантика (D71)
В D71 bootstrap-runtime (single-threaded cooperative):
sendна полный буфер — yield, продолжается когда recv освобождает местоrecvна пустой — yield, продолжается когда send добавит- Memory ordering тривиальна (single thread)
- Fisher-Yates shuffle между select-armами (псевдослучайный, LCG)
В production-runtime (D14 future):
- Memory-barriers / atomic counters для buffer indexes
- Wait queues для blocked senders/receivers
- Channel — единственный гарантированно-safe primitive
runtime.sync — stdlib, не prelude (D167-D172 + D370 (ex-D370))
Channel — предпочтительный primitive для координации fiber’ов (owner-actor
pattern, Erlang-стиль). Однако Mutex, RwLock, Atomic и другие sync-примитивы
доступны как stdlib через import runtime.sync.{...} — в тех случаях
когда actor-модель избыточна.
Default: owner-actor pattern. Если мутируемое разделяемое состояние нужно — первый выбор: dedicated owner-fiber + channel. Owner владеет данными; остальные шлют сообщения через channel.
fn counter_actor(input Channel[CounterMsg], output Channel[int]) {
mut value = 0
while Some(msg) = input.recv() {
match msg {
Increment => value += 1
Get => output.send(value)
Reset => value = 0
}
}
}
Это safe by construction — нет shared state, только message-passing.
Escape hatch: runtime.sync. Когда actor-модель действительно избыточна
(счётчик статистики, одноразовая инициализация, read-heavy конфигурация),
используй explicit import:
import runtime.sync.{Mutex, AtomicI64, RwLock, Semaphore, Once}
// см. D370 decision tree — «когда что выбрать»
Детальное описание всех sync-примитивов:
- D167 — Memory ordering &
fence()API - D168 — Atomic типы (12 sized × 13 ops)
- D169 —
Mutex/RwLock/ReentrantMutex - D170 —
Semaphore/Barrier/CountDownLatch/Condvar - D171 —
Once/OnceCell/Lazy - D172 —
realtime { }/blocking { }interaction matrix - D370 — AI-first guidance: decision tree + canonical patterns
Почему
-
Закрывает реальный пробел spec’и. D14/D50 упоминали
selectкак structured-concurrency primitive без формальной декларации. D79 формализует; D94 фиксирует финальный синтаксис. -
Production-correctness. В preemptive runtime (D14) shared
mutмежду fiber’ами — UB. Channels единственный safe primitive по умолчанию. -
AI-first. LLM пишет concurrent код по узнаваемому паттерну (Go-style channels). Никаких lock ordering задач, deadlock detection через структуру pipeline’а.
-
D62-согласованность. Suspension ambient → channel methods чистая сигнатура. Никаких Channel-effects в effect-row.
-
selectкак primitive. D14 уже описывалselectкак structured-concurrency primitive (наряду сparallel for/race); D79 даёт ему точную семантику относительно channels. -
Прецеденты:
- Go — channels + select как core feature; основа large-scale production систем (Kubernetes, Docker).
- Erlang/Elixir — message-passing через mailboxes, та же философия.
- Crystal — Go-style channels.
- Rust (
std::sync::mpsc) — channels как отдельный modul, не core; результат — community предпочла tokio crate с собственной моделью. - OCaml 5 — domains + channels (effect-handlers).
Что отвергнуто
-
Channel[T]как effect, требующийwith Channel = .... Channel — это value-handle, не resource-capability. Подменять channel в тестах = передавать другой channel-объект (parameter injection), не handler-substitution. -
Mutex / Atomic в prelude. Низкоуровневые, легко misuse, deadlock-prone. Owner-actor pattern закрывает 99% use case’ов. Escape hatch доступен через
import runtime.sync.{...}— stdlib, не prelude (D167-D172 + D370 (ex-D370)). -
<-как recv-оператор в select. Отвергнут в D94 — заменён наSome(v) = rx.recv() =>. Причина:<-вводил новый оператор только для select;= rx.recv()согласуется сwhile let Some(v) = rx.recv()(уже в языке) — никаких новых операторов. -
Unbounded channels по умолчанию. Bounded channel явно — лучшая practice для backpressure.
Channel[T].new(0)для unbuffered; unbounded — отвергнуто (опасный antipattern). Если действительно нужен — через explicit buffer-grow в user-коде. -
Channels как structural protocol. Channel — конкретный type с runtime-implementation, не protocol. Возможны разные Channel- типы (например,
BroadcastChannel), но они отдельные типы. -
Builtin priority в select.
selectnon-deterministic между ready-armами. Если нужен приоритет — программист сам пишет if-cascade с try_recv.
Цена
-
Runtime сложность. Channel требует buffer, lock-free queue (production), wait list, close-state machine. Bootstrap (D71) — проще: single-threaded queue + yield. Production — серьёзная реализация.
-
selectв parser. Новая конструкция:select { pattern = rx.recv() => body }. Реализация — Plan 31 (D94). Синтаксис финализирован. -
Closed-channel panic vs throw. Send на closed — panic. Это осознанный выбор: programmer error, не recoverable. Альтернатива (
Fail[ChannelClosed]) усложнила бы каждый send. Cost: программист должен следить за close-protocol (обычно single owner закрывает). -
Non-determinism в select. Программист не может полагаться на порядок arms. Тесты должны не зависеть от порядка (или использовать try_recv для строгого порядка).
Связь
- D14 — fiber-runtime основа; channels — primitive поверх него.
- D50 — concurrency model; D79 формализует упомянутые там channels.
- D62 — suspension ambient → чистые signatures для channel methods.
- D71 — bootstrap runtime; channels там тривиальная queue + yield.
- D72 — generic bounds;
Channel[T Clone]если понадобится требование на T (пока не требуется). - D73 —
From/Into; для channels не применимо (channel — handle, не value-конверсия). - D75
—
supervised(cancel:); channels часто используются с cancellation tokens. - D13 — panic vs Fail; close+send → panic.
Открытые вопросы
- Broadcast channels (один send → все receivers). Q-broadcast — отдельная задача после v1.0. Pattern: можно реализовать через владельческий fiber, который рассылает в N output-каналов.
- Channel of channels для dynamic worker pools. Технически работает (Channel[Channel[T]]), нужны примеры в stdlib.
@send_timeout(v T, d Duration)— отдельная вариация. Можно через select с timeout, но iдиома громоздкая. Q-send-timeout.- Memory model между fibers. В preemptive runtime — strong ordering (как Go: channel send/recv — happens-before). В D14 production-runtime — нужно явно зафиксировать. Q-memory-model.
Эволюция
До D79:
- D14 (2024-2025) — упомянул
selectпример с<- channel_aбез определения Channel. - D50 — упомянул «channels» в обсуждении spawn’а с
mut-захватами, но без типа. - D71 (2026-05-06) — bootstrap runtime; channels отложены как «producer-consumer через shared mut + yields».
- spec-review (2026-05-07) — компиляторный агент идентифицировал Channel/Mutex как spec-gap.
D79 закрывает gap: формальная декларация Channel[T] + select + семантика closed/non-deterministic ordering/owner-actor pattern.
Bootstrap-реализация — следующий шаг (компиляторный агент).
D80. Handler scoping per-fiber
Что
with X = handler { body } устанавливает binding X = handler
только для текущего fiber’а (D14). Другие fiber’ы — работающие
concurrent на том же OS-thread (D71 cooperative) или разных
OS-thread’ах (D14 production multithreaded) — не видят этот
binding.
При spawn/parallel for/supervised-spawn новый fiber наследует
текущий handler-stack (snapshot всех активных handler-pointers).
Изменения handler’ов внутри fiber’а (через дополнительные with-блоки)
видны только этому fiber’у.
Правило
Семантика:
- Каждый fiber имеет собственный snapshot handler-pointers для всех эффектов.
- При resume fiber’а scheduler’ом: handler-state восстанавливается из fiber’s snapshot.
- После yield/return: handler-state сохраняется обратно в fiber’s snapshot.
- Handler-state восстанавливается к outer-flow state (как до resume).
spawnнового fiber’а наследует current handler-state как initial snapshot — structured-concurrency наследование.
Грамматика без изменений — это runtime-инвариант, не языковая конструкция.
Пример
Изоляция между fiber’ами:
fn use_clock_100() -> int {
with Time = effect Time { sleep(_) => () now() => 100 } {
Time.now() // ВСЕГДА 100, независимо от других fiber'ов
}
}
fn use_clock_200() -> int {
with Time = effect Time { sleep(_) => () now() => 200 } {
Time.now() // ВСЕГДА 200
}
}
supervised {
spawn { ro a = use_clock_100() } // a == 100, гарантированно
spawn { ro b = use_clock_200() } // b == 200, гарантированно
}
Inheritance + override:
with Time = effect Time { ... now() => 42 } {
supervised {
spawn {
assert(Time.now() == 42) // наследовал outer
with Time = effect Time { ... now() => 999 } {
assert(Time.now() == 999) // inner override виден только здесь
}
assert(Time.now() == 42) // outer восстановлен
}
}
}
Почему
-
Корректность. Без per-fiber scoping handler одного fiber’а может быть перезаписан другим fiber’ом на shared TLS-globals. Тихий data corruption — наихудший класс багов в concurrent коде.
-
D14 invariant. «Невидимая инфраструктура fiber-runtime’а» подразумевает, что fiber’ы логически независимы. Shared mutable state — нарушение.
-
AI-friendly. LLM генерирует код по логической модели «каждый spawn — независимый поток вычисления». Без per-fiber scoping эта модель ломается на handler’ах.
-
Прецеденты.
- OCaml 5 effect handlers — handler scope follows fiber-tree.
- Koka effect handlers — то же.
- Rust
tokio::task_local!— explicit per-task storage с parent inheritance.
Что отвергнуто
- Shared TLS handlers (старая bootstrap-семантика до 2026-05-07). Тихий data corruption между fiber’ами на одном OS-thread’е.
- Explicit handler passing через параметры. Нарушает D62 «handler — implicit через with-scope».
- Copy-on-write snapshot. Premature optimization; bootstrap использует eager save/restore, ~µs overhead per resume.
Цена
- Memory: один snapshot per fiber, размер = N × pointer (N = количество зарегистрированных эффектов). В bootstrap’е N ≤ 5, ~256 байт. Heap-allocated чтобы не overflow’ить fiber stack.
- CPU: save/restore — N memcpy-equivalent на каждый resume. Production может использовать lazy/COW snapshots.
Implementation invariant: handler-storage не static
Codegen эмитит handler-storage (_nova_handler_X для каждого
эффекта X) с external linkage — без static:
__declspec(thread) NovaVtable_X* _nova_handler_X = NULL; // ✓ correct
__declspec(thread) static NovaVtable_X* _nova_handler_X = NULL; // ✗ WRONG
static ограничивает visibility одним translation unit (TU). Это
ломает D80 в трёх случаях:
-
Registry в другом TU.
nova_register_effect_storage(&_nova_handler_X)вызывается из main wrapper. Если storagestaticв module-TU, а registry вeffects.c— registry формально не должен видеть storage. В bootstrap’е (single-TU compilation) случайно работает, но архитектурно неверно. -
Production multi-module compilation. При разделении проекта на multiple
.cфайлов user-defined effect, объявленный в module A, может использоваться в module B (черезimport). Storage обязан быть extern-видимым. -
Snapshot save/restore через
void**. Registry хранитvoid**(адрес slot’а). Доступ через TLS-pointer должен следовать правилам external linkage; соstaticэто implementation-defined behavior.
Built-in эффекты (Fail, Time, Mem) в nova_rt/effects.c уже без
static — правильно. User-defined effect storage обязан
следовать тому же правилу. Codegen compiler-codegen/src/codegen/ emit_c.rs эмитит без static начиная с 2026-05-07 (commit
55d896de3); до этого эмитился static, что работало случайно
из-за single-TU bootstrap-компиляции.
Связь
- D14 — fiber-runtime как «невидимая инфраструктура». D80 уточняет, что handler-state входит в эту инфраструктуру (per-fiber, не shared).
- D50 —
spawn/detachестественно расширяются handler-наследованием. - D61 — effect/handler keywords; D80 — runtime invariant, который семантика D61 уже подразумевала.
- D71 — bootstrap runtime;
снапшот save/restore реализован в
nova_supervised_step(2026-05-07). - D92 — implicit main-scope. D80 handler-snapshot работает одинаково внутри main-scope и любого supervised блока (D92 делает main симметричным).
- D93 — park/wake API. Park’нутый fiber сохраняет свой handler-snapshot (per-fiber invariant D80) до wake’а, callback от libuv не видит чужие handlers — он работает на main-thread context’е до resume.
- D75
—
supervised(cancel:)использует тот же per-scope state pattern.
Производительность и roadmap оптимизации
Текущая bootstrap-реализация (snapshot save/restore через registry) корректна, но не оптимальна по скорости. Зафиксируем стоимость и варианты оптимизации для production-runtime.
Текущая стоимость (bootstrap)
При каждом fiber-switch в nova_supervised_step:
1× snapshot_restore(outer) — N pointer-copy
for each fiber:
1× snapshot_restore(fiber) — N pointer-copy
mco_resume — actual coroutine switch
1× snapshot_save(fiber) — N pointer-copy
1× snapshot_restore(outer) — N pointer-copy
Итого: 4 × N memory operations per switch (N = registered effects, обычно ≤ 5 в bootstrap, потенциально 10-20 в большом проекте).
Дополнительно:
- Heap allocation snapshot’а при spawn (
nova_alloc(sizeof(snapshot))≈ 256 B) → GC pressure. - Indirection через registry:
*registry.slots[i] = snap.values[i]— extra pointer chase per restore. Nova_X.op(): один indirect call через TLS pointer + один indirect через vtable function pointer = 2 indirect calls вместо direct.
Для типичного backend-кода (handler’ы редко перезапускаются, fiber switches на уровне сотен/секунду) — negligible. Для hot-path / real-time / game-loop — может стать bottleneck.
Варианты оптимизации (от простого к сложному)
1. Linked-list cactus stack handler-frames (умеренно быстрее)
Каждый fiber имеет указатель current_handler_frame в его coroutine
context. with X = h { body } пушит frame в linked list:
typedef struct HandlerFrame {
EffectId effect_id;
void* vtable;
void* ctx;
struct HandlerFrame* prev;
} HandlerFrame;
__declspec(thread) HandlerFrame* _nova_handler_top;
// Nova_X.op() walks the chain
static inline ret_t Nova_X_op(args) {
for (HandlerFrame* f = _nova_handler_top; f; f = f->prev)
if (f->effect_id == X_ID)
return ((Vt_X*)f->vtable)->op(f->ctx, args);
abort_no_handler();
}
Плюсы:
- Switch: O(1) — просто swap
_nova_handler_top(один pointer вместо массива). Может быть встроено в mco-coroutine state, switch — free. - No heap allocation для snapshot — frames живут на fiber stack.
- Spawn inheritance — копировать только указатель
_nova_handler_topродителя.
Минусы:
Nova_X.op()теперь O(depth) — walk handler-stack. На практике depth обычно 1-2, но в plagued-with-handlers коде может быть 5-10.- Branch prediction менее предсказуем (depth разная per call).
Сложность реализации: ~100 строк runtime, codegen меняется минимально
(with X = h { body } → push/pop frame вместо assign/restore TLS).
Целевой gain: ~3-5× быстрее snapshot save/restore для switches.
Nova_X.op() слегка медленнее (1 extra branch + memory read).
2. Inline handler-frames на fiber stack + statically-resolved op-call
Самое быстрое — Koka/Effekt-style runtime. Compiler во время
type-check’а определяет какой handler-frame будет активен в каждой
точке Nova_X.op() call’а (через effect-row analysis), и эмитит
прямой call через known offset.
fn process() X -> () => X.op() // X известен в типе
Компилируется в:
static void process(HandlerFrame_X* x_frame) {
x_frame->op(x_frame->ctx); // direct call, 0 overhead vs обычная функция
}
with X = h { body } создаёт HandlerFrame_X на стеке и передаёт
адрес в body как явный параметр (или через register).
Плюсы:
Nova_X.op(): 0 overhead vs обычная функция (один direct call).- Switch: трогать handler-state не надо вообще — передаются с фреймом.
- Inlinable: компилятор может полностью inline
op()если handler-литерал известен.
Минусы:
- Требует полную мономорфизацию по effect-rows в compiler’е.
Effect[X]как first-class value (fn make() -> Effect[X]) сложнее — нужен fallback dynamic dispatch когда handler передан как value.- Dependent на static effect-resolution; rank-2 effect polymorphism усложняется.
- Major compiler work — ~3-5k строк для proper effect type-checker’а.
Целевой gain: ~10-50× для hot-path effect ops (от dispatch overhead до полного inline).
Прецеденты: Koka, Effekt (academic), OCaml 5 (multicore).
3. Lazy / Copy-on-Write snapshot
Промежуточный вариант: оставить registry-based snapshot, но не делать save/restore на каждом switch. Tracking dirty-bit per effect:
typedef struct {
void* values[N];
uint64_t dirty_mask; // bit i set if effect i was modified by this fiber
} Snapshot;
with X = h { body } устанавливает dirty bit. На fiber-switch:
- Restore: только те slots что были dirty в old fiber + те что dirty в new fiber.
- Save: только dirty slots.
Плюсы: для типичного кода где fiber меняет 0-1 handlers → 0-1 copy на switch (вместо N).
Минусы: добавляет complexity tracking + branch на каждый with.
Целевой gain: ~3-10× для typical code, нет gain для plagued-with- handlers.
Рекомендуемый roadmap
| Phase | Что | Когда |
|---|---|---|
| bootstrap (now) | Snapshot save/restore (текущее) | done |
| v0.5 | Cactus-list handler-frames | первый perf-critical use-case (game/real-time/proxy) |
| v0.7+ | Static effect resolution + inline frames | при работе над production type-checker’ом (rank-2 effect polymorphism, Koka-style) |
Принцип: не оптимизировать преждевременно. Текущая реализация — ~µs overhead на switch, для backend-кода это <1% от стоимости request’а. Когда найдётся реальный bottleneck (профилирование production-приложения) — перейдём на cactus-list. Inline frames — финальная стадия, требует significant compiler work и не имеет смысла до того как остальные части compiler’а matured.
Что не делать
- Локализовать handler в каждый scope через текущее save/restore с меньшим N (через тонкую регистрацию). Добавляет complexity без существенного gain’а — N в bootstrap уже маленькое.
- Atomic compare-and-swap для multi-thread — преждевременно; D14 production multithreaded — отдельный future D-decision (handler-mt), там handler-storage per OS-thread + per-fiber внутри thread.
- Caching last-resolved handler in fiber state — добавляет invalidation complexity без чёткого gain’а.
Эволюция
До 2026-05-07 bootstrap-runtime хранил handler’ы в __declspec(thread)
TLS-globals без per-fiber изоляции — handler одного fiber’а на
том же OS-thread’е перезаписывал handler другого. Compiler-агент
выявил bug на тестах с разными with Time = ... handler’ами в
параллельных fiber’ах и пофиксил через snapshot save/restore вокруг
mco_resume + nova_register_effect_storage registry. D80
формализует invariant в spec’е (тесты:
nova_tests/concurrency/per_fiber_handlers.nv — 4 случая).
D91. Channel revision — capability-split на ChanWriter / ChanReader
Уточняет D79 — модель API меняется с Go-style (один объект с
send/recv) на Rust mpsc-style (capability-split). Остальное D79 (buffer, owner-actor pattern,select) сохраняется.Plan 59.1 amend (2026-06-01): signature
fn Channel[T].new(cap int) -> (ChanWriter[T], ChanReader[T])теперь буквально implementable — generic anonymous tuple monomorphization работает для произвольных user fns после Plan 59.1 (см. D354). Текущая реализация bootstrap-периода продолжает использовать runtime structNova_ChannelPairчерез 3 ad-hoc codegen branches (emit_c.rs 18435/20159/22694) — это implementation detail, не противоречит signature. Cleanup ad-hoc paths → followup[M-59.1-channel-new-cleanup](требует Nova-sideexternal fn[T] Channel[T].new(...)declaration через Plan 115 Pattern B).
Что
Channel[T].new(capacity) возвращает пару объектов с разными
capabilities:
ro (tx, rx) = Channel[int].new(4)
tx.send(10)
ro v = rx.recv()
defer tx.close() // close — обязателен, см. D90
ChanWriter[T]— capability «отправлять в канал». Методы:send,try_send,close,clone.ChanReader[T]— capability «получать из канала». Методы:recv,try_recv.
Внутренний state (buffer, sync) скрыт — не доступен напрямую, только через capabilities.
Амендмент 2026-07-27 (Plan 221.1 №144,
[M-channel-heap-value-shared- not-moved]): канал ЗАБИРАЕТ владение отправленным значением. До этого амендмента спека про владение при передаче в канал МОЛЧАЛА — это и был корень измеренного дефекта: отправитель кладётVec[int]с одним элементом, ПОСЛЕsendдописывает второй — получатель видит ОБА (канал передаёт общий указатель на кучу без изоляции и без передачи владения; отправитель сохранял полный доступ к отправленному). Под M:N это гонка данных по построению: два fiber’а на разных ОС-потоках мутируют один объект.Разбор вариантов (владелец, 2026-07-27): (а) «только значения со стека» — отвергнут, режет каналы записей/векторов (основной сценарий); (б) «глубокое клонирование при send и recv» — отвергнут, дорого на каждой передаче и ломает сценарий передачи владения большим буфером; (в) передача владения (
consume) — принято:send(consume v)/try_send(consume v)— после отправкиvнедоступен, алиасинга нет ПО ПОСТРОЕНИЮ, нулевая цена копирования, механизм совпадает с RustSender<T>(см. [reference-rustc-as-reference]). Использование значения послеsend/try_sendловится ОБЫЧНОЙ линейной проверкой (D131 use-after-consume) — той же, что и для любого другого consume-параметра.Намеренное разделение остаётся возможным, но НЕ через повторное использование уже отправленного значения — только явно, на уровне КАНАЛА:
tx.share()/tx.clone()создаёт дополнительный writer-handle над тем же буфером (см. «Multi-writer» ниже), каждый handle шлёт СВОИ значения. Общее value-level разделение вне канала — стандартный#shareмеханизм spawn-захватов (D415 §2), кsend/recvотношения не имеет.Связанное: [M-channel-elem-type-not-tracked] —
TвChannel[T].newсегодня не отслеживается сквозьsend/recv(типовая проверка элемента — отдельный, ещё не закрытый дефект); этот амендмент фиксирует ТОЛЬКО владение, не типизацию элемента.Амендмент 2026-08-02 (Plan 221.1 №143/№286, окно p-chan): элемент
TТЕПЕРЬ отслеживается сквозьsend/recv, когда объявлен явно. Измерение интегратора (реестр 221.1 №286) показало: до этого амендментаChannel[int]принималsend("строка")БЕЗ ошибки чекера (ловилась только codegen-ограждение слотаE_CHANNEL_UNSOUND_ELEM_TYPE, и то по РАЗМЕРУ C-представления, не по типу —Channel[Meters]молча принималSecondsтого же размера);recv()статически типизировалсяOption[int]НЕЗАВИСИМО от объявленногоT, а доступ к полям/методам полученного значения резолвился через legacy name-only fallback — НЕДЕТЕРМИНИРОВАННО при co-присутствии одноимённых методов в одном compile-unit (тихая мискомпиляция,[M-channel-recv-erased-elem-method- nondeterministic]).Правило теперь: когда
Tобъявлен ЯВНО — турбофишем (Channel[T].new(cap)) либо аннотацией параметра/локали (ChanWriter[T]/ChanReader[T], см. «Passing to functions» вdocs/guide/channels.md) — чекер отслеживаетTСКВОЗНЫМ образом:send(v)/try_send(v)требуютvсовместимого сTтипа (ошибка[E_CHANNEL_ELEM_TYPE_MISMATCH], включая различение двух newtype одинакового размера —MetersvsSeconds);recv()/try_recv()типизируются настоящимOption[T], так что доступ к полям/методам полученного значения резолвится по РЕАЛЬНОМУ типу, не по имени.Channel.new(cap)БЕЗ турбофиша/аннотации по-прежнему оставляетTнеотслеженным (permissive legacy-путь, обратная совместимость — поведение НЕ регрессирует для существующего непомеченного кода). Word-safe-ограничение рантайм-слота (E_CHANNEL_UNSOUND_ELEM_TYPE, ниже) остаётся независимой, ортогональной проверкой — само представлениеTв одном machine-word слотеnova_rt/channels.hэтим амендментом не меняется.НЕ закрыто этим амендментом — снос 3 ad-hoc codegen-веток
Channel.new(Nova_ChannelPair) в пользу настоящего generic-tuple возврата(ChanWriter[T], ChanReader[T]). Проверено:Plan 115(external fn/tuple-FFI ABI, изначально названный prerequisite’ом) до сих пор НЕ реализован (docs/plans/115-ptr-type-and-tuple-ffi.md, статус PLANNED) — и, как выяснилось, для этого конкретного случая не строго обязателен (Channel.newзовёт СОБСТВЕННЫЙ runtime-intrinsic, не внешнюю C-библиотеку), но регистрацияChannel.newкак настоящей generic-fn декларации в реестрах чекера (подключение к уже landed Plan 59.1 tuple-mono инфраструктуре) — риск для ВСЕХ существующих callsites по всему репозиторию, отдельного объёма/окна.[M-59.1-channel-new- cleanup]остаётся открытым;Nova_ChannelPairпродолжает быть implementation detail, не противоречащим сигнатуре (см. Plan 59.1 amend выше).Амендмент 2026-08-09 (№513, окно p513-channel): голый
Channel.new(cap)БЕЗ турбофиша/аннотации БОЛЬШЕ НЕ КОМПИЛИРУЕТСЯ. Отменяет «permissive legacy-путь» амендмента 2026-08-02 непосредственно выше — тот путь позволял голой форме компилироваться с неотслеженнымT; дерево показало 232 носителя, использующих голую форму как ОСНОВНУЮ (не запасную), включая 87 страниц публикуемой докой (docs/guide/), при каноне, который с самого начала D79 (выше, «Тип Channel[T]») требовалChannel[T].new. Реализация теперь приведена к решению, а не решение подогнано под реализацию: bare-форма (ExprKind::Ident("Channel")/ExprKind::Path(["Channel","new"])без турбофиша) — ошибка компиляции[E_CHANNEL_NEW_BARE], гейт в чекере (consume_walk_expr,compiler-codegen/src/types/mod.rs), срабатывает на каждомCallв каждой fn/method body до кодогена. Единственная форма — явный турбофиш (Channel[T].new(cap)) или аннотация capability-типов (ChanWriter[T]/ChanReader[T]). Word-safe-ограничение рантайм-слота (E_CHANNEL_UNSOUND_ELEM_TYPE) и type-mismatch-проверка (E_CHANNEL_ELEM_TYPE_MISMATCH) из амендмента 2026-08-02 — без изменений, применяются к явно типизированной форме как и раньше.
Правило
API — типы
// Writer capability:
type ChanWriter[T] protocol {
send(consume v T) -> bool // ЗАБИРАЕТ владение v; true если послал; false если канал закрыт
try_send(consume v T) -> bool // ЗАБИРАЕТ владение v; true если послал, false если полон или закрыт
close() -> () // закрыть (idempotent; ref-counted при clone)
clone() -> ChanWriter[T] // дополнительный writer на тот же буфер
}
// Reader capability:
type ChanReader[T] protocol {
recv() -> Option[T] // blocking; None = closed+drained
try_recv() -> Option[T] // None = пусто (НЕ означает closed)
}
ChanWriter и ChanReader — protocols. Конкретная реализация скрыта
внутри Channel.new. Типы-аннотации в сигнатурах функций:
fn fill(tx ChanWriter[int], items []int) { ... }
fn drain(rx ChanReader[int]) -> int { ... }
Factory
fn Channel[T].new(capacity int) -> (ChanWriter[T], ChanReader[T])
capacity = 0 — unbuffered channel (rendezvous: send блокирует
пока recv не примет; так же как D79).
Close semantics
Explicit close. Nova не имеет deterministic destructor’ов (D6 managed heap), поэтому auto-on-drop (как Rust mpsc) не работает predictably — GC соберёт sender «когда-нибудь», receiver висит непредсказуемо.
Решение: программист обязан явно вызвать tx.close(). Идиома —
через D90 defer:
fn run_pipeline() Net -> () {
ro (tx, rx) = Channel[Job].new(10)
defer tx.close() // гарантированный close
supervised {
spawn { for j in jobs { tx.send(j) } }
spawn { while Some(j) = rx.recv() { process(j) } }
}
} // <- tx.close() сработает; rx.recv() в spawn'е получит None и завершится
close() — idempotent: повторный вызов не error.
После close:
tx.send(v)— возвращаетfalse(канал закрыт). Не panic — программист может проверить результат:if !tx.send(v) { /* канал закрыт */ }.tx.try_send(v)— возвращаетfalse.rx.recv()— возвращаетSome(v)пока буфер не пуст; потомNone.rx.try_recv()— то же.
Multi-writer: tx.clone()
ChanWriter поддерживает clone() — создаёт дополнительный writer
на тот же буфер с ref-count семантикой:
ro (tx, rx) = Channel[Job].new(10)
ro tx2 = tx.clone()
supervised {
spawn { tx.send(1); tx.close() }
spawn { tx2.send(2); tx2.close() }
spawn { while Some(v) = rx.recv() { process(v) } }
}
Семантика close с несколькими writers: канал закрывается только
когда все writers вызвали close(). Внутри — ref-count
(writer_count): Channel.new инициализирует в 1, clone()
инкрементирует, close() декрементирует и закрывает при 0.
Идиома для spawn-fan-in:
ro (tx, rx) = Channel[int].new(8)
supervised {
for item in work_items {
ro worker_tx = tx.clone()
spawn { worker_tx.send(process(item)); worker_tx.close() }
}
tx.close() // close «корневого» writer'а
spawn { while Some(v) = rx.recv() { collect(v) } }
}
Managed heap и captures. Без
clone()дваspawnмогут захватить одинtxчерез managed reference — оба могут слать. Ноclose()первого spawn’а закрыл бы канал для второго.clone()решает это: каждый spawn держит свою capability и закрывает её независимо.
select после revision
select работает через ChanReader (Plan 31, не реализован):
ro (_, rx_a) = Channel[int].new(0)
ro (_, rx_b) = Channel[int].new(0)
select {
Some(v) = rx_a.recv() => process_a(v)
Some(v) = rx_b.recv() => process_b(v)
_ = Time.sleep(5.0) => default_action()
}
Синтаксис и D94-решение — в Plan 31.
Почему
Зачем capability-split
В Go-style (D79 текущий) Channel[T] имеет и send, и recv на
одном объекте. Это удобно для simple случаев, но проблематично
в концurrency-патернах:
-
Producer/consumer. Producer должен только слать, consumer только получать. С Go-style — оба могут случайно вызвать
recv/sendна чужой стороне, типы это не запрещают. -
Передача в spawn. Хочется передать в spawn только sender- capability (
spawn { for x in source { tx.send(x) } }), без возможности recv’ить. С Go-style нельзя — передаётся весь объект. -
API дизайн. Функция возвращает «вы можете только читать из этого» — нужен Receiver-only тип. Go-style не даёт.
Capability-split решает все три.
Прецеденты
| Язык | Модель |
|---|---|
| Go | один chan T с send/recv |
| Rust mpsc | (Sender<T>, Receiver<T>) через channel() |
| Tokio mpsc | то же |
Python Queue | один объект (Go-style) |
Python multiprocessing.Pipe | (conn1, conn2) (split) |
JS MessageChannel | (port1, port2) (split) |
OCaml 5 Eio.Stream | один объект (Go-style) |
Capability-split — доминирующая модель в Rust ecosystem. Nova переходит на неё, потому что:
- Type-safety capabilities в сигнатуре функции.
- Структурное совпадение с Rust — programmers familiar.
Почему close — explicit, не auto-on-drop
В Rust auto-on-drop работает благодаря deterministic destruction
(ownership). Когда последний Sender уходит из scope —
drop::drop() вызывается немедленно, канал закрывается, receiver
видит None.
В Nova нет destructor’ов (D6). GC соберёт sender когда-нибудь — может через 100ms, может через 10s. Если auto- on-drop завязан на GC-сборку:
{
ro (tx, rx) = Channel[int].new(4)
tx.send(42)
// tx уходит из scope здесь
}
// rx видит close — когда? Зависит от GC. Тесты flaky.
Это неприемлемо. Closing должно быть детерминированным — от него зависят receiver’ы.
Решение: explicit close через defer tx.close() (D90).
defer выполняется при exit’е scope’а, deterministically. Идиома:
fn pipeline() Net -> () {
ro (tx, rx) = Channel[Job].new(10)
defer tx.close() // в каждой функции, где tx уходит из scope
// ...
}
Почему recv() -> Option[T], не Fail[Closed] -> T
Closed-channel — не ошибка. Это валидный исход «source закончился».
Receiver-loop через while let Some(x) = rx.recv() { ... }
идиоматичен: цикл сам завершается на close.
Если бы recv бросал — каждый receiver-loop обёрнут handler’ом,
шум. Option[T] композируется с ? и match, не требует
дополнительных эффектов.
Это согласовано с Rust mpsc recv() -> Result<T, RecvError> —
семантически то же, но Result там в Rust-context, в Nova
Option[T] чище (нет специального RecvError типа).
Migration от D79 (Go-style)
Было (D79):
ro ch = Channel[int].new(4)
ch.send(10)
ro v = ch.recv()
ch.close()
Стало (D91):
ro (tx, rx) = Channel[int].new(4)
defer tx.close()
tx.send(10)
ro v = rx.recv()
Изменения:
Channel.new(N)возвращает(ChanWriter, ChanReader), неChannel.sendчерезtx,recvчерезrx.closeчерезtx.close()(илиdefer tx.close()).Channel[T]как type-аннотация не используется в коде — есть толькоChanWriter[T]иChanReader[T].
Что нужно мигрировать:
std/— нет существующихChannel-API.nova_tests/runtime/channels.nv— переписать все тесты.- Bootstrap
nova_rt/channels.h— переделать API: state-struct- sender/receiver wrappers.
select { ... }— синтаксис заменён на D94 (Some(v) = rx.recv() =>), см. Plan 31.
Реализация — отдельный план (Plan 22+).
Что отвергнуто
- Auto-on-drop (Rust-style). Не работает в managed heap без deterministic destruction. См. «Почему close — explicit».
recv() Fail[Closed] -> T. Closed — не ошибка, валидный исход.Option[T]композируется чище.- Auto-close по GC. Не работает — GC недетерминирован, explicit
tx.close()обязателен (см. «Почему close — explicit»). - Сохранить Go-style как альтернативу. Два API для одной задачи — нарушение D40 «один очевидный путь». Полная замена D79 → D91-семантика.
- Многотиповые каналы (broadcast, oneshot, watch как в Tokio). Не в bootstrap. mpsc — основной use-case. Остальные — расширения позже.
Связь
- D79 — частично
пересмотрено. API меняется, остальное (buffer, owner-actor pattern,
select) сохраняется. - D14, D50 — fiber-runtime для blocking send/recv.
- D6 — managed heap, мотивирует explicit close (нет destructor’ов).
- D90 —
deferдля гарантированного close. - D85 —
?для composingrecv() -> Option[T]. - Q-keyword-symmetry — capability-split factory как use-case для anonymous protocol-impl.
Bootstrap-status
- ✅ Реализовано в Plan 21 (2026-05-11). Улучшено в Plan 30 (2026-05-11).
nova_rt/channels.h— D91 capability-split:Nova_ChanWriter*/Nova_ChanReader*, park/wake через D93 sched API, heap-allocated waiters (safe под M:N Plan 44).emit_c.rs—Channel.new(cap)→Nova_ChannelPair, dispatch по типу объекта.nova_tests/runtime/channels.nv— 23 теста: FIFO, ring-buffer, closed-channel, try_send/try_recv, while-let, concurrent spawn, producer-consumer, ping-pong, передачаChanWriter[T]/ChanReader[T]в функции; send→bool тесты; fan-in тесты.- Негативные тесты:
channel_sender_no_recv,channel_receiver_no_send(EXPECT_CC_ERROR). - Plan 30 Ф.1 (2026-05-11):
send()возвращаетnova_bool—falseесли канал закрыт, не бросает;assert(tx.send(v))иlet ok = tx.send(v)работают. - Plan 30 Ф.2 (2026-05-11):
tx.clone()— multi-writer ref-count (writer_countвNova_ChannelState); канал закрывается только когда все writers вызвалиclose(). select— вынесен в Plan 31 (отдельный план с runtime SelectWaiter).
D93. Park/wake — нормативный runtime primitive для блокирующих операций
Введён: Plan 22 Ф.3 (2026-05-11). Реализация:
compiler-codegen/nova_rt/sched.h(header-only inline).
Что
Runtime exposes стандартный API через nova_rt/sched.h для park/wake
fiber’ов. Любая блокирующая операция в runtime’е (Time.sleep, Channel.recv,
socket-read, file-read) обязана использовать этот API. Это
contract на котором держится unified event-loop driven scheduling
(Plan 22 Ф.4+), Channel D91 (Plan 21), и любые будущие IO operations
(Plan 83.12 std/net ✅, std.fs Plan 18+).
API surface
/* ─── Park / wake ───────────────────── */
void nova_sched_park(NovaFiberQueue* scope, int slot);
void nova_sched_wake(NovaFiberQueue* scope, int slot);
nova_bool nova_sched_is_parked(NovaFiberQueue* scope, int slot);
/* ─── Cancel-integration (Plan 22 Ф.8 sync/async contract) ──── */
typedef enum {
NOVA_STOP_SYNC = 0, /* handle полностью freed после return; unpark immediate */
NOVA_STOP_ASYNC = 1, /* close initiated; wake придёт от backend (close_cb / waitlist) */
} NovaStopMode;
typedef NovaStopMode (*NovaCancelStopCb)(void* handle);
void nova_sched_register_pending(NovaFiberQueue* scope, int slot,
void* handle, NovaCancelStopCb stop_cb);
void nova_sched_unregister_pending(NovaFiberQueue* scope, int slot);
/* ─── Introspection ────────────────── */
int nova_sched_count_alive(NovaFiberQueue* scope);
int nova_sched_count_parked(NovaFiberQueue* scope);
int nova_sched_count_ready(NovaFiberQueue* scope);
Семантика
1. Park atomic-with-yield. nova_sched_park ставит parked[slot] = true
и сразу делает mco_yield. Race-window нулевой — single-thread bootstrap
обеспечивает это естественно. Под M:N (Plan 44)
потребуется memory fence перед yield’ом.
1a. Park-with-predicate (Plan 83.4.1, ред. 2026-05-23). Новые
блокирующие операции под M:N обязаны использовать
nova_sched_park_until(scope, slot, pred, ctx) вместо bare
nova_sched_park. Park возвращается ТОЛЬКО когда pred(ctx) → true;
spurious wake (включая M:N drain-quiescence-wake до завершения
async close_cb / after_work_cb) автоматически re-park’ится в loop’е.
Memory ordering contract: предикат-функция читает опубликованное
состояние с ACQUIRE-ordering; wake-сайт публикует predicate-
affecting состояние с RELEASE-ordering ДО nova_sched_wake.
Это индустриальный паттерн: POSIX pthread_cond_wait + caller-loop,
C++ std::condition_variable::wait(lock, pred), Go gopark(unlockf),
tokio Notify::notified(). Existing sites: sleep-park
(_nova_sleep_via_libuv) и blocking-offload (nova_blocking_offload)
обновлены в Plan 83.4.1; bare nova_sched_park остаётся для
legacy/caller-loop сценариев (channels park_with_unlock + BaseWaiter. fired recheck по Plan 44.1 R2 C6).
2. Wake idempotent. Повторный wake без park’а между ними — no-op.
Это упрощает callback’и: libuv uv_close cleanup может вызвать wake
после нормального wake, нужно быть устойчивыми.
3. Wake безопасен из libuv-callback’а. Callback’и выполняются в
uv_run под main-thread. В этот момент никакой fiber не resume’ен —
ставить parked[slot] = false безопасно без atomic-операций.
4. Scheduler skips parked. nova_supervised_step пропускает
parked[i] slot’ы (но считает их alive, чтобы scheduler не выходил
раньше времени). Когда нет ready-fiber’ов и есть parked — main-loop
будет уходить в uv_run UV_RUN_ONCE (Plan 22 Ф.4 добавит этот path).
5. Cancel-during-park — sync/async stop_cb contract (Plan 22 Ф.8).
Любая операция, паркующая fiber, обязана зарегистрировать handle
через nova_sched_register_pending. stop_cb возвращает NovaStopMode:
SYNC — handle полностью cleaned после stop_cb return. Используется когда cleanup synchronous (отвязать waitlist-node, освободить buffer):
- Вызывается
stop_cb(handle)→ cleanup inline, возвращает SYNC. cancel_all_pendingсразу делаетparked[slot] = false— fiber resume’ится на ближайшемsupervised_step.- Fiber видит
scope->cancel_requested == true→ throw"scope cancelled".
ASYNC — stop_cb лишь инициировал close, wake придёт от backend (uv close_cb / waitlist callback). Используется когда cleanup asynchronous (uv_close на handle с close_cb, uv_cancel на request):
- Вызывается
stop_cb(handle)→ инициирует close, возвращает ASYNC. cancel_all_pendingНЕ unpark’ает — fiber остаётся parked.- Backend выполняет cleanup → callback fires → ставит final state +
nova_sched_wake(scope, slot). - Fiber resume’ится, видит
cancel_requested→ throw.
Это единственный способ корректно прервать blocking-операцию. Плата за нерегистрацию — fiber виснет навсегда при cancel.
Use-cases:
| Backend | Mode | Reason |
|---|---|---|
| Sleep (uv_timer_t) | ASYNC | uv_close требует close_cb pass |
| Channel waitlist | SYNC | отвязка node inline, no async cleanup |
| Socket read (uv_tcp_t) | ASYNC | uv_read_stop + uv_close → close_cb |
| File read (uv_fs_t) | ASYNC | uv_cancel async на request |
6. Multiple pending per slot — запрещено в bootstrap. Slot держит
один (handle, stop_cb) — достаточно для всех known use-cases (один
fiber = одна блокирующая операция в момент времени). Если будущая
операция потребует multi-handle (например select на N receiver’ах) —
расширение через pending_handle_list[] со cap’ом.
Контракт пользователя API
Любая операция, использующая park/wake, следует паттерну:
NovaXxxState st = { ... };
nova_xxx_init_handle(&st.handle);
/* (1) Регистрация для cancel-wake — ОБЯЗАТЕЛЬНО ПЕРЕД park'ом. */
nova_sched_register_pending(_nova_active_scope, _nova_active_slot,
&st.handle, _nova_xxx_stop_cb);
/* (2) Park: scheduler не resume'ит, пока кто-то не вызовет wake. */
nova_sched_park(_nova_active_scope, _nova_active_slot);
/* ← control возвращается сюда после wake (callback либо cancel). */
/* (3) Cleanup + cancel-check. */
nova_sched_unregister_pending(_nova_active_scope, _nova_active_slot);
if (st.handle_active) {
nova_xxx_close_handle(&st.handle);
}
if (_nova_active_scope && _nova_active_scope->cancel_requested) {
nova_throw(nova_str_from_cstr("scope cancelled"));
}
Воспроизводится для:
- Plan 22 Ф.4:
Time.sleep→uv_timer_t+uv_timer_stopstop_cb. - Plan 21 Ф.1+:
Channel.recv/send→ waitlist node + waitlist-remove stop_cb. - Plan 83.12
std/net✅:TcpStream.read_bytes→uv_read_start+ wake из_tcp_read_cb. See D370. - Plan 44+
std.fs:File.read→uv_fs_t+uv_cancelstop_cb.
Почему
Без D93 каждый блокирующий primitive писал бы свою park/wake логику.
В bootstrap’е до Plan 22 sleep делал busy-yield (D71,
секция Time.sleep), Channel.recv — busy-spin на буфере. Cancel
работал через cooperative yield-check в nova_fiber_yield — это
терпимо для busy-yield, но не работает при настоящем park’е (на
yield-point нет потому что fiber suspend’ит на libuv handle).
D93 фиксирует единый mechanism:
- Park = выход из ready-queue.
- Wake = возврат в ready-queue.
- Cancel = generic stop_cb, прерывает любой pending handle.
Любая будущая блокирующая операция через тот же contract = автоматически cancel-aware, автоматически CPU-idle при ожидании, автоматически интегрируется с event loop. Это revolutionary изменение — unifies сейчас раздроблённые blocking-mechanism’ы.
Связь
- D14 — fiber-runtime обоснование. Park/wake — implementation primitive для невидимого Async.
- D50 —
Blockingeffect; D93 не покрывает (Blocking использует OS-thread pool, не park). - D71 — bootstrap scheduler. D93 — расширение D71 park-state. Update’ится в Plan 22 Ф.6 с указанием на D93 как точку перехода с busy-yield на event-loop driven.
- D75
—
supervised(cancel:). D93 описывает как cancel прерывает blocking-операции через generic stop_cb mechanism (вместо cooperative yield-check). - D79,
D91 —
Channel
recv/sendбудут реализованы через D93 API в Plan 21. - D80 — per-fiber handler scoping. D93 park/wake не меняет handler state (snapshot уже per-fiber).
Эволюция
- Pre-Plan 22: sleep, channel recv — busy-yield либо busy-spin.
Cancel — cooperative через
nova_fiber_yieldre-check. - Plan 22 Ф.3: введён D93 API. NovaFiberQueue расширен
parked[],pending_handle[],pending_stop_cb[]. cancel_token_cancel итерируется по pending_stop_cb. Stop_cb тип —void (*)(void*), unpark всегда synchronous после stop_cb (предположение). - Plan 22 Ф.4: Time.sleep переходит на D93 (
uv_timer_tpark-on-timer).nova_supervised_runрасширяется: idle →uv_run UV_RUN_ONCE. Sleep close-wait через ms-busyuv_run NOWAITloop (~1-2 iter). - Plan 22 Ф.7: sched_state arrays → heap-allocated с capacity-doubling. NOVA_SCOPE_CAP cap ушёл.
- Plan 22 Ф.8: stop_cb тип расширен — возвращает
NovaStopModeenum{SYNC, ASYNC}. Sleep stop_cb теперь ASYNC: stop_cb инициирует uv_close, wake приходит из close_cb (не synchronous из cancel_all_pending). Это убирает ms-busy close-wait loop из sleep’а (R7 «no busy-loops anywhere» полностью enforced). Channel waitlist (Plan 21) — SYNC. - Plan 21: Channel.recv/send переходят на D93 (waitlist + SYNC stop_cb).
- Plan 83.12 (std/net) ✅: socket-read/write/connect/accept — ASYNC stop_cb; std.fs — Plan 18+.
- Plan 44 (M:N): park/wake становится cross-worker. Wake может
идти из worker B в fiber на worker A через
uv_async_tqueue.
Bootstrap-status
- ✅ Header-only API в
nova_rt/sched.h. - ✅ NovaFiberQueue расширен
parked[],pending_handle[],pending_stop_cb[](Ф.7: heap-allocated с capacity-doubling). - ✅ nova_supervised_step skips parked.
- ✅ nova_cancel_token_cancel проходит по pending_stop_cb.
- ✅ Sync/async stop_cb contract (Ф.8) —
NovaStopModeenum, cancel_all_pending различает SYNC (unpark immediate) vs ASYNC (ждёт backend wake). - ✅ Time.sleep через D93 (Ф.4 register/park; Ф.8 ASYNC close_cb wake).
- 🟡 Channel waitlist (Plan 21) — SYNC stop_cb, ждёт реализации.
- ✅ std/net IO (Plan 83.12) — ASYNC stop_cb, реализован D370.
- 🟡 std/fs IO (Plan 18+) — ASYNC stop_cb, ждёт реализации.
D92. Top-level main как implicit supervised scope
Введён: Plan 22 Ф.5 (2026-05-11). Реализация:
compiler-codegen/src/codegen/emit_c.rs(emit_main_wrapper)
nova_rt/fibers.h(nova_supervised_drain_main_scope).
Что
Каждый fn main() codegen’ится с implicit supervised scope —
NovaFiberQueue _nova_main_scope обёрнутый вокруг user-body. Это
унифицирует runtime-семантику: внутри main user-code всегда имеет
_nova_active_scope != NULL, как любая функция внутри supervised-блока.
Правила
Правило 1 — _nova_active_scope всегда non-NULL в user-code. Все
блокирующие операции (Time.sleep, Channel.recv, IO) опираются на это
для park/wake API (D93).
Правило 2 — drain до quiescence. Main-body завершается → emit_main
вызывает nova_supervised_drain_main_scope(&_nova_main_scope). Этот
drain работает пока есть alive fiber’ы:
- Detach-fiber’ы (D50) доработают.
- Pending libuv-handle’ы (Plan 22 Ф.4 sleep’ы) отстреливают callback’и.
- Все fiber’ы пробуждённые callback’ами после main-body завершаются.
После quiescence — nova_evloop_close() → nova_gc_shutdown() → return 0.
Правило 3 — error propagation. Throw в main-body → propagates как
обычно (через D85 Fail mechanism, либо panic). Throw в detach-fiber
после main-body — logged to stderr, но процесс завершается
exit code 0. Это согласовано с D50 fire-and-forget семантикой detach’а:
detach не имеет owner для re-throw, и abort процесса из-за detach-error
неприемлем (другие detach’ы могут быть корректны).
Правило 4 — exit(code, msg) bypass’ит drain. D13 exit() гасит
процесс немедленно, без drain, без cleanup’ов. Это согласовано с
D90 §8 (exit обходит defer’ы): catastrophic shutdown, не graceful.
Правило 5 — detach в top-level кладёт fiber в main-scope. До D92
top-level detach был SyncDetach (inline-исполнение). После D92 — fiber
в implicit main-scope, доживёт до drain’а. Это поведенческое изменение,
breaking change для кода полагавшегося на inline’ность top-level detach.
Правило 6 — РЕТРАКТИРОВАНО (Plan 221.1 №108, 2026-07-25). Было:
_nova_active_slot = -1 означает main-flow — slot −1 не индексирует
fiber-array, park/wake API не работает (main-flow не может park’нуться
через mco_yield — нет coroutine’ы), top-level Time.sleep шёл через
busy-yield (supervised_step), блокирующий TcpListener.accept()
(и любой другой D93 park-based primitive) ПРЯМО в main() крашил
nova_sched_park: invalid scope/slot — «вторая дверь» на одну и ту же
семантику (park/wake), диагноз владельца: «выглядит как несколько дверей
на одно и то же».
Стало: main-body исполняется КАК ФАЙБЕР — полноценный слот
планировщика (mco-coroutine), а не сырой C-поток. emit_main_wrapper
спавнит его в implicit main-scope и гоняет планировщик до завершения
(nova_supervised_run). _nova_active_slot >= 0 для user-кода на всём
протяжении main — park/wake (D93) работает напрямую: блокирующий
accept()/Time.sleep()/Channel.recv() и т.п. легальны ПРЯМО в
main(), без обёртки supervised { spawn { … } } }. Ошибки main-body
пробрасываются как обычно (Правило 3 не изменилось — top-level всё ещё
без fail-frame, необработанная ошибка всё ещё abort()’ит с тем же
диагностическим сообщением, что и раньше).
Спавн-путь — ТОЛЬКО bootstrap, НИКОГДА armed (амендмент к амендменту,
интегратор-гейт 2026-07-25, мега-CU 577/5, все 5 TIMEOUT). Первая версия
этого фикса спавнила main-fiber ТЕМ ЖЕ dual bootstrap/armed-M:N путём, что
emit_spawn — nova_runtime_is_initialized() ? nova_runtime_spawn_into(...) : nova_fiber_spawn_into(...). ОШИБКА: nova_runtime_auto_arm() (несколько
строк выше в int main(), unconditional, D138 default-on) взводит
_armed = true ДО этой точки — nova_runtime_is_initialized() здесь
ВСЕГДА true, так что main-body ВСЕГДА пушился в WORKER-THREAD deque, а не
исполнялся на настоящем главном OS-потоке. Это молча превращало КАЖДЫЙ
top-level supervised{}/detach{}/cross-effect-throw/test-runner-chunk
ЛЮБОЙ программы в «вложенный supervised на worker-потоке» с точки зрения
рантайма (_nova_on_worker_thread() — thread-identity-based) — другой,
более узкий кодопуть (cooperative nova_runtime_worker_pump_scope вместо
plain main-thread uv_run-ожидания, watchdog отключён, другие допущения
о thread-affinity в orphan-scope/signal_main). Симптом: 4 из 5
таймаутов (top-level detach ×2, folder-CU test-runner на сотнях чанков,
multierror-scope) — зависания без крашей, ровно сигнатура «тихо попал не
в ту дверь». Фикс: main-fiber ВСЕГДА nova_fiber_spawn_into (bootstrap,
пришпилен к ВЫЗЫВАЮЩЕМУ OS-потоку — истинному процесс-main), НЕЗАВИСИМО
от process-wide armed/unarmed состояния. Не потеря конкурентности: дети,
которых user-код спавнит/detach’ит ИЗНУТРИ main-body, по-прежнему проходят
ОБЫЧНОЕ armed/bootstrap решение в СВОИХ СОБСТВЕННЫХ точках spawn{}/
detach{} (emit_spawn/emit_detach, не тронуты) — пришпилен только
хостинг САМОГО main-body, так что каждое существующее допущение рантайма
«я на главном потоке или на worker’е» видит ровно то же, что видело до
№108.
D61 cross-effect handler-arm routing — main-fiber = main-flow-эквивалент
для этого gate’а (тот же гейт-раунд). 5-й таймаут-файл (repro_cross_ effect_throw) после спавн-фикса выше перестал висеть, но начал давать
НЕВЕРНОЕ значение (не таймаут). Корень: nova_interrupt/nova_interrupt_ ptr (effects.c, D61 Plan 61 followup #1) гейтуют cross-effect
handler-arm fast-path (throw ДРУГОГО типа изнутри handler-arm должен
skip’нуть направо к OWNER-у, минуя текущий handler) условием
!mco_running() — раньше корректный proxy «точно main-flow, точно один
стек» (top-level ВСЕГДА исполнялся на сыром потоке, никогда в coroutine).
№108 сделал main body ВСЕГДА mco_running()!=NULL — proxy перестал
соответствовать своему исходному свойству ДАЖЕ для plain top-level кода
без единой spawn/supervised-границы. Фикс: _nova_main_fiber_co
(effects.h/effects.c, void* — не видит mco_coro, чтобы не тянуть
minicoro.h в effects.h) — mco_coro* корневого main-fiber’а, ставится/
чистится в _nova_main_fiber_entry. Гейт заменён на
nova_cross_effect_route_safe() = !mco_running() || mco_running() == _nova_main_fiber_co — восстанавливает исходное свойство («точно
main-fiber, без риска cross-fiber stack») для main-hosted кода, оставляя
ГЕНУИННО spawn’нутые/detach’нутые дети (где риск cross-stack longjmp
реален) на старом, безопасном deferred-interrupt_pending-пути.
Стек main-файбера НЕ выделен
особым образом — тот же arena-slot (NOVA_FIBER_STACK env /
nova.toml [runtime].fiber_stack, builtin-default 4MB), что у любого
другого fiber’а: (а) держит main внутри Boehm-видимого arena-range
(бespoke больший стек вне арены потребовал бы отдельного GC_add_roots
— лишний движущийся элемент ради спорной выгоды); (б) 4MB-дефолт уже
щедрее типичного OS-стека главного потока на большинстве платформ; (в)
единая ручка NOVA_FIBER_STACK для «насколько глубоко Nova-код может
рекурсировать» — что в spawn{}, что в main() — без бимодального
сюрприза. Если глубокий top-level main() реально упрётся в лимит —
отдельный NOVA_MAIN_FIBER_STACK-оверрайд заводится ТОГДА (YAGNI сейчас).
Второй дверь снесена (busy-yield main-flow ветка time_sleep_ms’s
else if (_nova_active_scope) теперь структурно недостижима для
top-level sleep — mco_running() всегда non-NULL внутри main-body).
Правило 7 (future, не реализуется в Plan 22): SIGINT/Ctrl+C через
uv_signal_t отменяет main-scope cancel-token, fiber’ы получают
cooperative cancel. Optional extension, отдельный план если потребуется.
Семантика codegen
emit_main_wrapper эмитит (Plan 221.1 №108, Правило 6 ретракция —
main-body теперь спавнится ФАЙБЕРОМ в implicit main-scope вместо
прямого вызова на сыром C-потоке; _nova_main_fiber_entry — hand-
transcribed копия emit_spawn’s generated entry-fn, NovaSpawnCtxBase
без доп. полей; ВСЕГДА bootstrap — см. интегратор-гейт followup выше,
nova_runtime_spawn_into для main конкретно бьёт thread-identity
допущения по всему рантайму):
static void _nova_main_fiber_entry(mco_coro* _co) {
NovaSpawnCtxBase* _c = (NovaSpawnCtxBase*)mco_get_user_data(_co);
_nova_main_fiber_co = (void*)_co; /* D61 cross-effect gate, см. выше */
_c->_nova_worker_slot = -1; /* всегда bootstrap — nova_supervised_step владеет slot'ом */
NovaFailFrame _ff;
nova_fail_push(&_ff);
if (setjmp(_ff.jmp) == 0) {
nova_fn_main_impl();
nova_fail_pop();
} else {
/* kinded error report — local path only (никогда remote/worker) */
...
}
_nova_main_fiber_co = NULL;
/* нет epilogue: bootstrap-fiber'ы не инкрементят pending_remote */
}
int main(int argc, char** argv) {
nova_gc_init();
nova_evloop_init();
/* effect-storage registration ... */
/* D92: implicit main-scope. */
NovaFiberQueue _nova_main_scope;
nova_scope_init(&_nova_main_scope);
_nova_active_scope = &_nova_main_scope;
_nova_active_slot = -1;
nova_evloop_install_sigint(&_nova_main_scope);
/* Plan 221.1 №108: spawn main-body as a real fiber into the scope,
* then drive the scheduler until it completes — `_nova_active_slot
* >= 0` for the ENTIRE duration of user code, so D93 park/wake works
* directly. ALWAYS `nova_fiber_spawn_into` (bootstrap, pinned to THIS
* calling — the true process main — OS thread), NEVER
* `nova_runtime_spawn_into` (would push main-body onto a worker
* thread's deque — see the gate-red followup note above). */
NovaSpawnCtxBase* _nova_main_ctx = (NovaSpawnCtxBase*)nova_alloc(sizeof(NovaSpawnCtxBase));
_nova_main_ctx->_nova_parent_scope = NULL;
nova_fiber_spawn_into(&_nova_main_scope, _nova_main_fiber_entry, _nova_main_ctx);
nova_supervised_run(&_nova_main_scope);
/* D92: drain detach'ов / pending fiber'ов до quiescence (Правила
* 1-5 неизменны — detach top-level идёт через отдельный orphan-scope,
* не через _nova_main_scope; этот вызов остаётся belt-and-braces —
* идемпотентен, nova_supervised_run уже полностью дренировал ЭТУ
* scope-очередь, единственный ребёнок которой — сам main-fiber). */
nova_supervised_drain_main_scope(&_nova_main_scope);
_nova_active_scope = NULL;
_nova_active_slot = -1;
nova_evloop_close();
nova_gc_shutdown();
return 0;
}
Почему
До D92 top-level main не имел scope — _nova_active_scope = NULL.
Это создавало корзину edge cases:
Time.sleepна top-level → kernel-blocking (Plan 22 Ф.4 не мог использовать park/wake без scope).detachна top-level → inline execution (SyncDetach), не настоящий fire-and-forget.- IO operations (Plan 83.12
std/net✅, Plan 18+std/fs) на top-level — не работают через park/wake API, требовали бы special-case.
D92 устраняет эти edge cases одним решением: main всегда внутри scope. User-code не видит разницы (семантика sleep / detach / IO одинакова из любого контекста). Runtime simplifies — нет двух кодопутей для fiber-context vs main-context.
Что отвергнуто
(a) Эволюция D71 без нового D-блока. Изменение значимое — behavioural breaking change для detach. Заслуживает отдельного D-номера для discoverability.
(b) Не оборачивать main в scope, оставить top-level kernel-blocking.
Это сохранило бы простоту, но рассыпает Plan 22 Ф.4 цель — единый
event-loop driven scheduler. Под Plan 83.12 (std/net ✅) и Plan 18 (std.fs+)
все IO operations требовали бы special-case для top-level. Нежелательно.
(c) Implicit scope с full nova_supervised_run (re-throw fiber-errors).
Re-throw на main-flow после main-body завершён = abort. Detach-fiber
throw’ы в D50 fire-and-forget — должны быть logged, не abort. Поэтому
drain-no-throw variant (nova_supervised_drain_main_scope).
Связь
- D13 —
panic/exitсемантика. D92 Правило 4:exit()bypass’ит drain. - D50 —
detachfire-and-forget. D92 Правило 3 + 5: detach-throw logged not abort’ed. - D71 — bootstrap scheduler. D92 расширяет: main всегда в scope.
- D75
—
supervised(cancel:). D92 Правило 7 (future): SIGINT через main-scope cancel. - D90 —
defer/errdefer. D92 Правило 4:exit()обходит defer’ы (согласовано с D90 §8). - D93
— park/wake API. D92 обеспечивает
_nova_active_scope != NULLв user-code, что необходимо для park/wake.
Bootstrap-status
- ✅ Codegen
emit_main_wrapperоборачивает в implicit scope. - ✅ Runtime
nova_supervised_drain_main_scopedrain до quiescence. - ✅ Detach behavior change verified (no regression в
detach_test.nv). - ✅ Plan 221.1 №108 (2026-07-25): main-body = файбер. Правило 6
ретрактировано —
_nova_main_fiber_entryспавнится в implicit main-scope ВСЕГДА bootstrap-путём (nova_fiber_spawn_into, пришпилен к вызывающему — истинному процесс-main — OS-потоку; НИКОГДАnova_runtime_spawn_into, см. интегратор-гейт followup выше — armed path пушил main-body на worker-поток, молча меняя thread-identity для ЛЮБОГО top-levelsupervised{}/detach{}в программе),nova_ supervised_runгоняет планировщик до завершения._nova_active_slot >= 0для user-кода всю жизнь main — блокирующийTcpListener.accept()/Time.sleep()/Channel.recv()ПРЯМО вmain()(безsupervised { spawn { … } } }) работает штатно через D93 park/wake. Busy-yield main-flow веткаtime_sleep_ms’selse if (_nova_active_scope)структурно недостижима для top-level sleep теперь (mco_running()всегда non-NULL). D61 cross-effect handler-arm routing (nova_interrupt/nova_interrupt_ptr) исправлен симметрично —_nova_main_fiber_coдаёт main-fiber-hosted коду тот же fast-path, что раньше давал!mco_running(). Фикстуры:spec_tests/conformance/standalone/m2211_108_main_fiber_accept.nv,m2211_108_main_fiber_sleep.nv. - 🟡 SIGINT handler (Правило 7) — future extension, НЕ пересмотрено
этим окном:
nova_evloop_install_sigint(&_nova_main_scope)по-прежнему целится в внешний_nova_main_scope, а не в тот момент, когда main-fiber реально паркуется внутри своего собственного слота — та же зона неполноты, что и раньше (не хуже, просто не решена).
D94. select { ... } — multiplexed channel operations
Введён: Plan 31 (2026-05-11). Статус: ✅ реализован (2026-05-11), ✅ hardening Plan 44.1 Ф.3 (2026-05-12). Уточняет D79 — финализирует синтаксис и семантику
select.Реализованный синтаксис (bootstrap, Plan 31):
Some(v) = rx => { }— recv с binding_ = rx => { }— recv wildcard (срабатывает на Some и None/closed)tx.send(val) => { }— send armSome(v) = rx if guard => { }— recv с guard_ => { }— default (non-blocking)Bootstrap-ограничения:
None = rx => { }— отдельный arm для закрытого канала не введён; используйте_ = rx => { }(wildcard срабатывает на Some и на None/closed)
matchвнутри тела arm’а для дифференциации, либоrx.is_closed()после recv’а.Some(v) = rxarm на already-closed канале не срабатывает — только wildcard_ = rxловит closed-state. См. Plan 31 §«Отличия от spec».Реализовано в полной форме (Plan 31 Ф.6, Plan 44.1 Ф.2/Ф.3):
- Panic «select: all channels closed» при all-closed без default — ✅ (Plan 31 Ф.6; работает и в main-thread context’е через pre-check).
ChanReader.close_after(Duration)timer cleanup при non-winning arm — ✅ (Plan 44.1 Ф.2 B7:on_select_lostcallback + idempotentcancelledflag наNovaAfterState; reused by Plan 65 close_after API).Channel.new(0)— explicit panic «capacity must be >= 1` перед allocate’ом (Plan 44.1 Ф.3 B9, без leak’а на throw).- Adaptive per-call storage без cap’а на arm count (Plan 44.1 Ф.3 B5): codegen эмитит
SelectSlot _arms[n_ch]; SelectWaiter _waiters[n_ch];на стеке fiber’а через compound literal (literal size, MSVC-compatible — не VLA).nova_select_try_immediateиспользуетalloca(n*sizeof(int))для внутреннего shuffle order. Stack frame ~84n байт. На default minicoro 56 KB stack ≈ 600+ arms безопасно. Идиоматический Go код = 2-8 arms; cap’а нет.Plan 44.1 Ф.1 (2026-05-12, ✅ Этапы 1-6 закрыты — production-grade M:N prerequisites):
- Atomics + mutex на shared state (B1): writer_count/closed/reader_closed atomic; head/count/waiter-lists под mutex. Все ops lock-then-mutate.
- Refcount idiom Release-dec + Acquire-fence-on-zero (A1, Arc::drop pattern).
- Race-free select wake через
selectdoneCAS (B2) — unified protocol для recv/send/select waiters. Direct-copy sender→waiter (Go’s sendDirect equivalent).- Doubly-linked waiter list O(1) unlink (T2).
- BaseWaiter common prefix (C1, strict-aliasing safe).
- stop_cb lock-free contract (C2, atomic cancelled flag).
nova_sched_park_with_unlockAPI (C6, lost-wakeup-free park).- Cache padding by access group (C5, 300× perf win под contention).
- TOCTOU re-check protocol (A2).
- Symmetric
nova_chan_reader_close(R1 B2, Tokio Receiver::close parity).- All-arms-disabled panic (C3, не silent forever-park).
- Linux Docker validation infrastructure + pthread stress tests (b1_mutex_stress/b2_selectdone_cas/t2_waiter_churn) под TSan/ASan/UBSan.
Tier 1 toolchain backends (sync.h):
- Linux x86_64 (Ubuntu 22.04+, glibc 2.35+) — pthread + ADAPTIVE_NP.
- Windows + clang LLVM 15+ — SRWLOCK native.
- macOS arm64 + Apple Clang — os_unfair_lock (40% faster than pthread).
- Atomics:
__atomic_*GCC/Clang builtins.Что отложено в Plan 44.2+ / Plan 50+:
oneshot::channel<T>/watch::channel<T>/broadcast::channel<T>— Tokio type variants (Plan 44.2).recv_manybatch API (Ф.4 follow-up).- Lock-free SPSC flavor (Plan 50+, Loom-verified).
- Loom/CDSChecker formal verification (Plan 50+).
- NUMA-aware allocation (Plan 50+ multi-socket servers).
- Priority inheritance mutex (RT scheduling, doc only сейчас).
- Zero-capacity rendezvous channels (
Channel.new(0)— cap=0 case).- Per-channel metrics (
NOVA_CHANNEL_METRICS=1opt-in).Plan 44 (M:N runtime) integration — Plan 44.1 Ф.1 = prerequisite, теперь готов. M:N runtime отдельный план; этот блок channels гарантирует что под M:N scheduler’ом channel layer thread-safe.
Что
select ожидает сразу несколько channel-операций, пробуждается по
первому готовому arm’у.
select {
Some(v) = rx1.recv() => { process(v) }
Some(v) = rx2.recv() => { process(v) }
None = rx1.recv() => { break } // rx1 закрылся
_ = tx.send(val) => { /* sent */ } // send arm
default => { /* non-blocking */ }
}
Грамматика:
select-expr = 'select' '{' NL* select-arm+ '}'
select-arm = channel-arm | default-arm
channel-arm = pattern '=' (recv-op | send-op) guard? '=>' arm-body NL*
recv-op = expr '.' 'recv' '(' ')'
send-op = expr '.' 'send' '(' expr ')'
guard = 'if' expr
default-arm = 'default' '=>' arm-body NL*
arm-body = block | stmt
Синтаксис pattern = rx.recv() согласуется с while let Some(v) = rx.recv()
(уже в языке). Оператор <- не вводится (отвергнут).
Timeout через ChanReader.close_after(Duration)
Специального timeout arm’а нет — timeout через обычный recv arm:
ro t = ChanReader.close_after(Duration.from_secs(1)) // ChanReader[()] закрывается через 1 сек
select {
Some(v) = rx.recv() => { process(v) }
None = t.recv() => { log_idle() } // timeout сработал
}
ChanReader.close_after(d Duration) -> ChanReader[()] —
capability-split static constructor в stdlib/concurrency.
Select не знает про “timeout” специально. Plan 65 revision:
ранее API назывался Time.after(int ms) (bare int, без type
safety); переименован и переведён на Duration для D91 capability
namespace + строгой type safety. Migration tool
cargo run --bin migrate_plan65 -- --apply автоматически переводит
literal arguments.
Правило
- Guard evaluation —
if <expr>после паттерна делает arm disabled если false. - Immediate check — проверяет все enabled arms в псевдослучайном порядке (Fisher-Yates). Если ≥1 ready — выполняет без park’а.
- Park — если ни один не ready и нет
default: регистрирует waiter для каждого arm, паркует fiber. - Wake — первый готовый arm будит fiber; остальные waiters unlinked.
done-флаг предотвращает double-wake при одновременной готовности. - Fairness — Fisher-Yates shuffle на каждой итерации (нет starvation).
default— если присутствует: шаг 2 всегда succeeds (не паркуем).- Все каналы закрыты + нет default → panic “select: all channels closed”.
- cancel (
tok.cancel()отsupervised(cancel:)) — отменяет все pending waiters, fiber просыпается, проверяетcancel_requested.
Arm guards
select {
Some(v) = rx.recv() if v > 0 => { process(v) } // arm активен только если v > 0
Some(v) = rx.recv() => { skip(v) }
}
Guard — pre-condition (arm disabled если false). Аналог if в Rust Tokio select!.
Go не поддерживает guards в select.
Почему
- Ключевой primitive для fan-in. Без select нельзя элегантно объединить несколько producers в одном consumer’е.
ChanReader.close_after(Duration)вместоtimeout(expr)— timeout как обычный channel (Go-styletime.After, но с type-safe Duration вместо bare int). Нет специального синтаксиса, нет special-casing в runtime. Plan 65 revision: ранееTime.after(int ms)— bare int был отвергнут как unsafe, переименован в D91 capability namespace.=вместо<-— согласованность сwhile let Some(v) = rx.recv(). Один оператор recv по всему языку.- Fisher-Yates shuffle — fairness (Go использует то же). Нет starvation при постоянно-готовых arms.
Что отвергнуто
<-оператор в select — нарушает consistency; отдельный оператор только для select (было в D79, удалено).timeout(expr) =>arm — special-casing в грамматике и runtime ради того, что решается обычнымChanReader.close_after(Duration)channel’ом (см. Эволюция: bareTime.after(int)исторический artefact, заменён в Plan 65).- Biased mode — детерминированный выбор arm’а (Tokio
biased). Достигается через--jobs 1+ фиксированный seed в тестах. - Вложенный select запрещён — излишнее ограничение; снято.
Bootstrap-status
- ✅ Runtime: Plan 31 Ф.1 —
SelectCtx,SelectWaiter,nova_select_*API - ✅ Send arm: Plan 31 Ф.2
- ✅ Parser + codegen: Plan 31 Ф.3
- ✅ Arm guards: Plan 31 Ф.4
- ✅
Time.after(d)+ тесты: Plan 31 Ф.5- Plan 65 (2026-05-18) revision:
Time.after(int ms)removed, replaced byChanReader.close_after(Duration). See “Эволюция API” subsection below.
- Plan 65 (2026-05-18) revision:
- ✅ All-closed panic: Plan 31 Ф.6 (с pre-check для main-thread)
- ✅ Hardening: Plan 44.1 Ф.2 (timer cleanup, formerly Time.after, now
ChanReader.close_afterafter Plan 65) + Ф.3 (select cap=32 + compile-error overflow + Channel.new check ordering) - 🟡 M:N safety: Plan 44.1 Ф.1 (atomics + selectdone CAS + doubly-linked + per-call storage) — отложено вместе с Plan 44 M:N runtime
Эволюция API — timeout channel constructor (Plan 65, 2026-05-18)
Time.after(int ms) -> ChanReader[()] (D94 v1, Plan 31 Ф.5)
переименован в ChanReader.close_after(d Duration) -> ChanReader[()]
(D94 v2, Plan 65). Три ортогональных дефекта закрыты:
- Domain mismatch: функция возвращает read-capability ChanReader,
но жила в
Timenamespace — discoverability проседала. Capability namespace по D91 —ChanReader.<constructor>. - Type safety: bare int
(1000)неоднозначно (мс/мкс/сек). Duration делает unit explicit:Duration.from_secs(1). - Capability mismatch D91: получение reader через
Time.Xнеявно подразумевало что Time владеет также writer — на самом деле runtime.
Семантика runtime неизменна — внутренне всё ещё Nova_Time_after
(libuv timer) + on_select_lost cleanup. Атомарный break без
deprecated alias (Plan 60 atomic-migration convention): legacy
вызов ловится диагностикой E5101 с machine-applicable fix-it
suggestion. Migration tool migrate_plan65 автоматически переводит
literal arguments (int → from_millis, float → from_secs_f64).
Дополнительные production-grade capabilities — cancel via D75
CancelToken, mockable virtual time via Time effect, absolute
deadline close_at(Instant), observability counters — добавляются
в Plan 65 Ф.10-Ф.14 (hardening), либо отложены в Plan 66
(periodic ticker + custom timer-wheel optimisation).
D97. Fiber stack allocation — per-thread mmap arena (Linux/macOS)
Status: active. Ред. 3 (Plan 259 Слой 1, 2026-08-09) — guard-страницы (Linux/macOS
mprotect(PROT_NONE)) пробиваются лениво, на первое использование СЛОТА, а не все разом при инициализации арены. Причина — №457: eager-пробивка ВСЕХ слотов (16384 по умолчанию) на арену КАЖДОГО воркера умножалаmprotect- вызовы (каждый берётmmap_lockпроцесса на запись) в десятки/сотни тысяч на старте — сериализованный шторм системных вызовов, не укладывающийся в пользовательский бюджетtimeout:, и тот же механизм отдельно бил вvm.max_map_count([M-187-docker-linux- runtime-hang]). Инициализация арены — теперьO(1)syscalls (mmap+madvise, БЕЗmprotect-цикла); guard слота пробивается ровно один раз, в момент, когдаnova_fiber_allocвпервые выдаёт этот слот (slot == high_waterна момент аллокации — слоты выдаются в порядке возрастания при первом использовании, переиспользование НЕ пробивает guard повторно). Число VMA процесса теперь следует за числом РЕАЛЬНО живших файберов, а не заNOVA_FIBERS_PER_WORKER; зажимNOVA_ARENA_VMA_RESERVE/NOVA_ARENA_VMA_SAFETY_PCT(Plan[M-187-docker-linux-runtime-hang], лечил VMA-шторм следствием) снят как более не нужный. Guard-page-per-slot ИНВАРИАНТ (описанный ниже) не меняется — меняется только МОМЕНТ, когда конкретный guard создаётся; любой реально используемый слот guard имеет ровно как раньше. Windows arena (fiber_arena_win.c) уже была lazy-commit до этого амендмента (see «Windows» ниже) — Ред. 3 не меняет Windows-путь, только Linux/macOSmmap-путь. Полное обоснование и связь с материализацией пула (Слой 2) — D451.Ред. 2 (Plan 82, 2026-05-22) — Windows переведён с calloc на
VirtualAlloclazy-commit arena; диагноз Plan 44.3 «Windows fiber arena fundamentally blocked» опровергнут. Ред. 1 (Plan 44.2 Этапы 1-3, 2026-05-12) — Linux/macOSmmap-arena. Уточняет D14 для bootstrap-runtime: где живут fiber stacks и как они видны GC.
Что
Suspended fiber stacks не на OS-стеке — они лежат в пользовательской памяти, выделяемой allocator’ом minicoro. Поскольку Boehm GC сканирует только OS-стек активного потока + явно зарегистрированные roots, fiber stacks нужно сделать видимыми GC явно. D97 фиксирует единую стратегию — per-thread large-reserve arena с lazy commit — реализованную разными OS-примитивами по платформам:
Linux/macOS — per-thread mmap arena с lazy commit:
- На первое использование thread’a резервируется 8 GB virtual через
mmap(MAP_NORESERVE)—16384 слота × 4 MBНА КАЖДЫЙ ВОРКЕР.
АМЕНДМЕНТ 2026-08-17 (аудит самосогласованности, раздел 4, пункт 40). Здесь стояло «4096 слотов × 2 MB» со стражем 4 КБ, а D233 называет 16384 фибера на воркер, 4 МБ стека и 16 КБ стража. Два места спеки называли РАЗНЫЕ размеры одной и той же памяти, и это не вопрос трактовки — это факт, у которого есть один верный ответ. Ответ взят не из спора, а из рантайма:
compiler-codegen/nova_rt/fiber_arena.h—NOVA_FIBER_STACK_SIZE(4 * 1024 * 1024),NOVA_FIBER_GUARD_SIZE(16 * 1024),NOVA_FIBERS_PER_WORKER_DEFAULT=NOVA_FIBERS_PER_WORKER_BUILTIN(16384, и комментарий рядом говоритper-worker runtime default— это число НА ВОРКЕР, а не на процесс; потерять уточнение значит завести новую ошибку вместо исправленной). Значит устарел D97, а D233 верен. Урок записи: когда спека расходится в ЧИСЛАХ, спрашивать владельца не о чем — надо смотреть в код, который эти числа исполняет.
- Lazy commit: physical pages приходят только при touch’е (lazy COW).
- 4 KB guard page в начале каждого слота — stack overflow ловится
через
SIGSEGV(не silent corruption). - Bitmap free-list для reuse слотов после fiber termination.
- Один GC root на тред —
[base, base + high_water * slot_size]— снимает MAX_ROOT_SETS=128 ограничение Boehm. madvise(MADV_DONTNEED)после dealloc — physical memory возвращается ОС.madvise(MADV_NOHUGEPAGE)для guard-page granularity.- pthread_key cleanup освобождает arena при thread exit.
Windows — per-thread VirtualAlloc arena с lazy commit (Plan 82):
- Per-thread арена — один
VirtualAlloc(MEM_RESERVE): 16384 слота × 8 MB = 128 GB виртуального резерва (нулевой commit-charge; на 64-bit резерв адресного пространства бесплатен). - Lazy commit. Физический commit — только под minicoro-header +
начальное окно стека у вершины слота. Рост стека — OS-native:
после TIB-свопа minicoro-asm’а ядро Windows растит коро-стек штатно
через
PAGE_GUARD-фолт (какCreateFiber-стек). Декоммит освобождённого слота — послотный, при переиспользовании (idle-batch по 128 GB-диапазону на Windows деградирует — Plan 82 §3). - 16 KB hard guard (
PAGE_NOACCESS, reserved) в начале каждого слота + движущаясяPAGE_GUARD-вершина над minicoro-header’ом. Stack overflow → детерминированныйSTATUS_STACK_OVERFLOW+ диагностика «nova: fiber stack overflow in slot N» (паритет с LinuxSIGSEGV— было silent corruption calloc-стека). - Atomic bitmap free-list для reuse слотов; cross-thread dealloc (work-stealing migration A→B) — арена-владелец по адресу.
- GC-видимость —
GC_set_push_other_roots-колбэк, НЕ плоскийGC_add_roots. На Windows conservative-чтениеMEM_RESERVE-но-не-MEM_COMMITстраницы —STATUS_ACCESS_VIOLATION; плоский root уронил бы сканер. Колбэк на mark-фазе пушит только закоммиченные диапазоны[committed_low, top]каждого живого fiber’а + native scheduler-стеки всех worker’ов (GC_push_all_eager). ЧислоGC_add_roots-записей на fiber-арену = 0 → ограничениеMAX_ROOT_SETS=128снято. - Арены — heap-структуры в глобальном append-only списке; TLS хранит лишь указатель → арена переживает поток-владельца (нужно GC-колбэку и cross-thread dealloc).
Корректировка диагноза Plan 44.3. Ред. 1 D97 объявляла «Windows
arena fundamentally blocked»: minicoro MCO_USE_ASM якобы переключает
только RSP, не обновляя TIB. Plan 82 §1.1–1.2 опроверг это:
minicoro Windows-asm (_mco_switch) свопает 4 поля TIB
(NT_TIB.StackBase/StackLimit, TEB.DeallocationStack,
NT_TIB.FiberData) на каждом switch — ровно как corosensei /
Boost.Context. Git-археология: minicoro.h неизменен с 2026-05-05, а
4 провала 44.3 — 2026-05-13/14 → гипотеза «старый minicoro без
TIB-свопа» ложна. Настоящий блокер 44.3 был иным — conservative
GC-скан reserved-страниц арены (AV на первой незакоммиченной); Plan 82
решает его push-колбэком выше. SEH-unwind, /GS, /guard:cf через
arena-стек верифицированы (Plan 82 Ф.0/Ф.4); context-switch на
arena-стеке — 16–20 ns, паритет с Boost.Context (Ф.5).
Зачем разные примитивы, единая стратегия
Со ред. 2 (Plan 82) обе платформы реализуют одну стратегию — per-thread large-reserve arena с lazy physical commit, guard-page overflow-детекцией и GC-видимостью suspended-стеков — но через разные OS-примитивы, потому что семантика памяти ОС различается:
- Linux/macOS:
mmap(MAP_NORESERVE)+madvise(MADV_DONTNEED)+SIGSEGV-handler. GC-root — плоский active-rangeGC_add_roots: чтение незакоммиченнойNORESERVE-страницы даёт zero-page от ядра, conservative-скан fault-free. - Windows:
VirtualAlloc(MEM_RESERVE)+ послотныйVirtualFree(MEM_DECOMMIT)+ VEH. GC-root —push_other_roots- колбэк: чтениеMEM_RESERVE-страницы =ACCESS_VIOLATION, плоский root недопустим (Plan 82 §1.3). Колбэк пушит только закоммиченное.
Built-in minicoro MCO_USE_VMEM_ALLOCATOR отвергнут на обеих
платформах — он MEM_COMMIT-ит весь стек upfront (нет lazy commit).
GC-модель Windows (registry+push) строже Linux-овой active-range и
может быть бэкпортирована (Plan 82 Ф.6 — опциональная Linux-унификация,
gated «0 регрессий на Linux»).
Introspection — std.runtime.fibers
Плакируется (std/src/runtime/fibers.nv):
import std.runtime.fibers
ro virt = fibers.virtual_reserved() // bytes зарезервировано
ro total = fibers.slot_count() // 4096 Linux/macOS, 16384 Win
ro active = fibers.slots_active() // running fibers сейчас
ro peak = fibers.high_water() // peak concurrent
slot_count() == 0 — honest sentinel «arena не активирована» (арена
ленивая — создаётся на первом fiber’е потока; до того статы нулевые).
Со ред. 2 арена активна на всех трёх платформах — нулевой
slot_count() больше не означает «Windows», только «поток ещё не
спавнил fiber’ов».
Что отвергнуто
MCO_USE_VMEM_ALLOCATOR(built-in minicoro VMEM) — commits all upfront; не работает с lazy commit semantics; ломает Windows budget.GC_add_rootsper-fiber — упирается вMAX_ROOT_SETS = 128Boehm compile-time константу; нельзя bump’нуть без rebuild library (см. правило «не патчить сторонние библиотеки»).GC_disableworkaround вокруг scheduler tick — был vestigial scaffolding в Plan 27 R4; удалён в Plan 44.2 Этап 2. Реальная защита приходила от single-thread cooperative invariant + arena root, не от disable.- Плоский
GC_add_rootsповерх Windows-арены — conservative-скан читает root по-байтно; перваяMEM_RESERVE-но-не-MEM_COMMITстраница →ACCESS_VIOLATION(на Linux безопасно —NORESERVEzero-page). Заменёнpush_other_roots-колбэком, пушащим только закоммиченное (Plan 82 §1.3, §5.2). MCO_USE_FIBERS(CreateFiberAPI) для Windows — ред. 1 D97 называла его возможным обходом «TIB-блока». TIB-блок опровергнут (см. выше) → обход не нужен;CreateFiberк тому же не даёт arena-аллокатор (N независимых kernel-fiber’ов, нет lazy-commit контроля, нет cross-thread arena-dealloc).
Bootstrap-status
- ✅ Arena infrastructure (Plan 44.2 Этап 1, commit
0b75bdcb06) - ✅ Wire-up в minicoro через
_NOVA_MCO_DESC_INIT(Plan 44.2 Этап 1 wire-up landing, commit5ed208e84f) - ✅ Удаление
_NOVA_GC_DISABLE(Plan 44.2 Этап 2, commit810898de06) - ✅
std.runtime.fibersintrospection (Plan 44.2 Этап 3, commitf8d345e536) - ⏸ Linux Docker validation (Plan 44.2 Этап 4) — требует Docker daemon
- ⏸ SIGSEGV pretty handler (P41-6) — P2, отложено
- ✅ Windows
VirtualAlloclazy-commit arena (fiber_arena_win.c, Plan 82 Ф.1) — заменяет calloc-путь - ✅ Windows GC-интеграция fiber-стеков —
push_other_roots-колбэк (Plan 82 Ф.2); первая корректная GC-видимость fiber-стеков на Windows - ✅ M:N-safe arena — cross-thread migration, multi-worker GC-колбэк, atomic bitmap (Plan 82 Ф.3)
- ✅ Windows overflow-детекция — guard-page →
STATUS_STACK_OVERFLOW- VEH-диагностика (Plan 82 Ф.1); негативный тест
expected_runtime/fiber_stack_overflow.nv(Plan 82 Ф.4)
- VEH-диагностика (Plan 82 Ф.1); негативный тест
- ✅ Context-switch паритет — 16–20 ns/switch на arena-стеке, класс Boost.Context (Plan 82 Ф.5)
- ⏸ Опциональная Linux-унификация на registry+push GC-модель (Plan 82 Ф.6) — gated «0 регрессий на Linux»
D98. Per-worker libuv loop — TLS _nova_current_loop
Правило. Каждый OS-thread исполняющий fiber’ы имеет own
uv_loop_t. Все timer / handle / I/O registrations в runtime (Time.sleep, Time.after, channel-select-timer, future Net/Fs) регистрируют libuv handles на own loop текущего thread’а, а не на globalnova_evloop(). Discovery — через TLS_nova_current_loop.
Проблема
libuv uv_loop_t — thread-bound resource. uv handles
(uv_timer_t, uv_signal_t, uv_tcp_t, uv_async_t) registered’ы
на конкретный loop; их callback’и fire’ются ТОЛЬКО когда тот loop
крутится через uv_run. Cross-thread callback firing — undefined.
В bootstrap N:1 (D71) был один thread + один loop — проблема не
существовала. Под M:N (Plan 44)
worker thread имеет own loop (NovaWorker.loop).
Fiber на worker N park’нувшийся через Time.sleep создавал timer на
main thread’s loop (через nova_evloop()); main thread не крутил
этот loop в синхронной точке (он либо в supervised_run, либо exit’нут);
worker N крутил own loop где timer не было. Result: fiber hangs
permanently.
Решение
TLS _nova_current_loop (uv_loop_t*) — declared в
eventloop.h:
#ifdef _MSC_VER
extern __declspec(thread) uv_loop_t* _nova_current_loop;
#else
extern __thread uv_loop_t* _nova_current_loop;
#endif
uv_loop_t* nova_current_loop(void); /* TLS либо fallback на nova_evloop */
Set’ится:
- Main thread: в
nova_evloop_init()=_evloop(глобальный default). - Worker thread: в
_worker_main(runtime.c) =&worker->loopсразу после_current_worker_id = w->id.
Все timer/handle creation в runtime call’ает nova_current_loop():
_nova_sleep_via_libuv(fibers.h) — fiber-context sleep._nova_time_default_sleep(fibers.h) — main-flow sleep.nova_supervised_run/nova_supervised_drain_main_scope— idle uv_run.Nova_Time_after(channels.h) — select-timer.
nova_evloop() остаётся только для глобально main-thread операций:
nova_evloop_install_sigint— single SIGINT handler per process.nova_evloop_close— finalize main loop в exit path.
Fallback semantics
nova_current_loop() сначала проверяет TLS; если NULL — lazily set’ит
к nova_evloop() (default). Это покрывает:
- C-static initializer’ы что вызывают timer creation до
nova_evloop_init(). - Threads без
runtime.init()(тесты что не активируют M:N).
Ограничение D98
Fiber pin’ится к worker’у на котором park’нулся. Wake происходит из close_cb на том же worker’е. Migration между workers требует отдельной machinery (TLS state migration, handle re-registration на target loop) — отложено в Plan 44.7+.
Practical implication: long-running fiber на worker A блокирует worker A до завершения. Other workers продолжают независимо. Cooperative scheduling работает в пределах one worker.
Что отвергнуто
nova_supervised_run(scope, loop)параметризация через codegen — early Plan 44.5 idea. Требовало menyatemit_supervised(codegen-side change) emit’ить_nova_current_loopв каждый call site. TLS-based подход transparent’но решил это без codegen изменений — любой call site читает TLS without API change.uv_default_loop()per-worker — нельзя, libuv даёт один default loop на process. Workers используютuv_loop_init(&w->loop)с новойuv_loop_tструктурой.- Shared loop через mutex — обходит изоляцию libuv, kills parallelism (один thread crank’ает — others ждут).
Bootstrap-status
Layer 3 (TLS loop) — Plan 44.5 L3 (originally Plan 44.6, re-merged):
- ✅ TLS infrastructure (eventloop.h+c)
- ✅
_worker_mainset TLS (runtime.c) - ✅ Replace
nova_evloop()→nova_current_loop()в fibers/channels - ✅ Regression: 274/274 single-thread baseline сохранён
- ✅ 3 mn_runtime regression-теста PASS
Layer 5 (implicit M:N — codegen routing) — Plan 44.5 L5 partial (2026-05-14):
- ✅ Runtime atomics:
pending_remote(Go’s WaitGroup pattern) +first_error_atomic(Go’s errgroup.errOnce pattern) +_main_wakeuv_async +nova_runtime_spawn_into+nova_runtime_signal_main. - ✅ Codegen routing:
emit_spawnэмитит conditionalif (runtime_is_initialized) nova_runtime_spawn_into else nova_fiber_spawn_into. Один и тот жеspawn { body }работает single-thread и distributed (Go’sgo func()model). - ✅ Cross-worker error propagation через atomic CAS на parent’s
first_error_atomic(first-writer-wins). - ✅
mn_runtime_actual_workload.nvPASS — 16 fibers распределены на 4 workers черезruntime.current_worker_id()distribution (round-robin, не all одного worker’а). - ✅ 278/278 PASS Windows.
Critical fix: runtime.h включён в nova_rt.h явно. Без этого
codegen использовал implicit-int declaration для
nova_runtime_is_initialized → ABI mismatch (bool vs int) → garbage
return → wrong code path → underflow pending_remote → infinite loop
(38 timeout’ов в первой попытке Plan 44.5 L5).
Boehm GC_THREADS — обязательный client-side define (НЕ feature flag!)
Запомнить, чтобы не передиагностировать каждый раз. Boehm bdwgc уже собран thread-safe — и vcpkg Windows (
build.ninjaсодержит-DGC_THREADSвDEFINES), иlibgc-devUbuntu. Никакой кастомный vcpkgbdwgc[multithreaded]feature НЕ нужен — это была неверная гипотеза.
Корень проблемы был в клиентском коде: <gc.h> прячет
GC_register_my_thread / GC_unregister_my_thread / GC_get_stack_base
за #ifdef GC_THREADS. Если клиент инклудит <gc.h> без -DGC_THREADS,
прототипы невидимы → worker’ы не регистрируются в GC → Boehm STW walker
пропускает их стеки → GC-объекты, на которые ссылается только worker stack,
преждевременно собираются → use-after-free / SIGSEGV.
Правило (все платформы): при сборке с Boehm GC клиент обязан
передать -DGC_THREADS (/DGC_THREADS для MSVC) тем же compiler invocation,
что инклудит <gc.h>. Это не Linux/macOS-специфично — Windows регистрирует
worker’ы точно так же.
Где зафиксировано в коде:
- test_runner.rs —
-DGC_THREADS//DGC_THREADSво всех 3 compiler-path (gcc/clang, MSVC, доп. path), рядом с-DNOVA_GC_BOEHM. - runtime.c —
NOVA_GC_THREADS_REGISTERактивируется безусловно приNOVA_GC_BOEHM(никакого&& defined(__linux__)guard’а);GC_register_my_threadв_worker_main,GC_unregister_my_threadв cleanup.
Исправлено в commit 8fcbc67fddb (Plan 44.5 Layer 5). Результат: Windows
multi-fiber Time.sleep перестал флакать (был ~14% segfault).
Open:
- ✅ Park/wake migration к worker scope (Time.sleep / Channel.recv в
worker fiber’е) — закрыто Plan 44.5 Layer 5, commit
8fcbc67fddb: TLS-swap +nova_scope_alloc_slotв entry preamble,dispatch_readyhook (same-thread → deque push, cross-thread →wake_pending+uv_async_send). - ⏸ Linux Docker validation Plan 44.5 L5 — требует Docker daemon.
D103. Preemption — sysmon-thread + codegen safepoints
Status: active (Plan 44.7, Вариант B, закрыт 2026-05-14). Note: номер D103, а не D102 — D102 на ветке main занят «именованными аргументами» (Plan 46); preemption перенумерован при подготовке к sync. Дополняет D71 (M:N прозрачность) и D93: fair CPU-sharing — часть гарантии прозрачности M:N.
Что
CPU-bound fiber без явного runtime.yield() НЕ монополизирует worker
thread. Runtime автоматически вытесняет fiber’у, крутящуюся дольше
timeslice’а (~10ms), на ближайшем safepoint’е — peer fiber’ы получают CPU.
runtime.init(1)
supervised {
spawn {
mut i = 0
while i < 1_000_000_000 { i = i + 1 } // НЕ блокирует worker
}
spawn { Time.sleep(10); /* ... */ } // запустится, не дождавшись
} // конца loop'а соседа
Механизм
sysmon thread — отдельный OS-thread (аналог Go’s sysmon), не привязан
к worker’ам. Каждые ~10ms проходит workers; если worker крутит одну fiber’у
дольше NOVA_PREEMPT_SLICE_NS — выставляет NovaWorker.preempt_flag.
Codegen safepoints — nova_preempt_check() эмитится в прологе каждой
Nova-функции и первым стейтментом тела каждого цикла. Читает живой флаг
через TLS _nova_preempt_ptr → при выставленном флаге кооперативно
nova_fiber_yield(). Стоимость на горячем пути: TLS-load + predicted-not-
taken branch (~1-2 такта). В single-thread режиме _nova_preempt_ptr == NULL → чистый no-op.
yielded-FIFO — вытесненный fiber кладётся в per-worker FIFO, не обратно в LIFO-deque (иначе worker сразу re-pop’ит его, голодя peer’ов). Worker loop: deque (свежие/разбуженные) → yielded-FIFO → steal → block.
uv_run каждую итерацию — worker сервисит libuv loop (UV_RUN_NOWAIT) на
каждой итерации, не только когда deque пуст. Без этого вытесненный CPU-fiber
держал бы deque непустым → таймеры (Time.sleep) никогда не fire’или бы.
Отличие от Go — и почему так
Go использует SIGURG async signal + ASM asyncPreempt. Nova — кооперативные
codegen safepoint’ы. Причина: minicoro mco_yield НЕ async-signal-safe,
yield из signal handler = UB. Вариант B (safepoints) даёт observable
паритет — CPU-bound fiber не морит голодом соседей — за ~20% сложности
Варианта C. Непокрыто: tight loop целиком в inline-ASM/FFI без
codegen-backedge’а (нишевой кейс).
Что отвергнуто
- SIGURG/SuspendThread async preemption (Вариант C) — 2-3 недели engineering, ASM-level, высокий риск; observable benefit над Вариантом B только для нишевого inline-ASM-loop кейса. См. docs/plans/44.7-preemption.md.
- Snapshot флага в TLS перед resume — worker застревает в
mco_resumeна весь CPU-loop, не может перечитать снапшот; sysmon выставляет флаг уже после старта fiber’ы. Поэтому_nova_preempt_ptr— указатель на живойNovaWorker.preempt_flag, а не копия. - Re-push вытесненного fiber’а в deque — LIFO → мгновенный re-pop → starvation peer’ов. Отсюда отдельная yielded-FIFO.
Файлы
compiler-codegen/nova_rt/runtime.c— sysmon thread,preempt_flag,current_fiber_start, yielded-FIFO,uv_run(NOWAIT)каждую итерацию.compiler-codegen/nova_rt/fibers.h—_nova_preempt_ptrextern,nova_preempt_check(),NOVA_UNLIKELY.compiler-codegen/nova_rt/effects.c—_nova_preempt_ptrTLS def.compiler-codegen/src/codegen/emit_c.rs— safepoint emit вemit_fn+emit_loop_body_inline.nova_tests/concurrency/mn_runtime_preemption.nv— 2 positive + 2 negative.
D124. Monotonic vs Timestamp — раздельные типы для wall-clock и монотонных часов
Введён: 2026-05-18 (Plan 65 Ф.12 driver). Статус: принят; реализация в Plan 65 Ф.12.1-Ф.12.6. Уточняет существующий
Timestamp(std/time/duration.nv) иTimeeffect (emit_c.rs:1037-1046).
Что
Nova вводит два различных типа для представления «момента во времени», разделяя их по источнику clock’а:
type Timestamp { ro nanos i64 } // wall-clock (Unix epoch nanos)
type Monotonic { ro nanos i64 } // monotonic (process-local epoch)
Соответственно, Time effect имеет два метода:
Time.now() -> Timestamp // wall-clock: для логов, дат, сериализации
Time.now_monotonic() -> Monotonic // monotonic: для timers, deadlines, profiling
Эти типы не interconvertible — компилятор отвергает let t Monotonic = Time.now()
(тип Timestamp), и наоборот. Сериализация Monotonic запрещена
(нет epoch, бессмысленно вне процесса).
Правило
-
Timestamp— для семантического времени: логи, файлы, протоколы, БД, человеко-читаемые даты. Источник:clock_gettime(CLOCK_REALTIME)/GetSystemTimeAsFileTime. Прыгает при NTP-синхронизации, DST, manual time set. Сериализуется в Unix-epoch nanos. -
Monotonic— для измерения промежутков и дедлайнов: таймеры, timeouts, retry, profiling. Источник:clock_gettime(CLOCK_MONOTONIC)/QueryPerformanceCounter. Никогда не идёт назад. Бессмысленно сериализовать (Monotonicодного процесса нерасшифровываема в другом). СравнениеMonotonicмежду процессами — compile-error. -
Арифметика:
Timestamp - Timestamp -> Duration(wall-clock interval, может быть отрицательным при NTP backwards)Monotonic - Monotonic -> Duration(monotonic interval, всегда ≥ 0 если оба из same process)Timestamp + Duration -> TimestampMonotonic + Duration -> MonotonicTimestamp - Monotonic— compile-error «cannot subtract incompatible clock types»Monotonic.as_unix_secs()— compile-error «Monotonic не представимы в Unix epoch»
-
API контракты:
ChanReader.close_after(Duration)— без изменений (длительность clock-agnostic).ChanReader.close_at(Monotonic)— только Monotonic; иначе NTP может вызвать early/late fire (silent bug).Timestamp.from_unix_*/Timestamp.as_unix_*— без изменений.Monotonic.now()(==Time.now_monotonic()) — единственный способ construct’нуть; нетMonotonic.from_nanos(raw bytes бессмысленны).
-
Timeeffect для тестов (Plan 34 Ф.7 mock_clock):- Mock-handler должен реализовать обоих
now()иnow_monotonic()для consistency. Default mock:now() == EPOCH + elapsed_virtual,now_monotonic() == elapsed_virtual(от старта mock scope).
- Mock-handler должен реализовать обоих
Почему
Проблема, которая закрывается: silent bug при использовании wall-clock для timing logic. Сценарий:
// БАГ под старым API (одна Timestamp на всё):
ro deadline = Time.now() + Duration.from_secs(60)
// ... 30 сек проходит ...
// NTP синхронизирует часы НАЗАД на 5 секунд:
// Time.now() теперь "moment - 25s" вместо "moment - 30s"
// Таймер сработает через 35 реальных секунд вместо 30.
Параллели в индустрии (все пришли к разделению после bug-bash):
| Язык | Wall-clock | Monotonic | Когда разделили |
|---|---|---|---|
| Rust | std::time::SystemTime | std::time::Instant | с самого начала (1.0, 2015) |
| Java | java.time.Instant | System.nanoTime() (long, не тип) | partial — Java 8, full Type — never |
| Go | time.Time | time.Time с monotonic component | Go 1.9 (2017) — раньше использовали wall-clock everywhere → silent bugs |
| C# | DateTime / DateTimeOffset | Stopwatch.GetTimestamp() | partial |
| Python | time.time() | time.monotonic() | PEP 418 (Python 3.3, 2012) — явно разнесли после real-world failures |
| JS | Date.now() | performance.now() | DOM Performance API |
Все — после реальных production-инцидентов (Go 1.9 release notes: «невозможно правильно измерять timeouts во время DST/NTP без monotonic»).
Type safety > runtime documentation. Альтернатива — «один Timestamp + документация „не используйте для timers”» — ловит баги только при ревью, не компилятором. Type-разделение делает ошибку невыразимой.
Что отвергнуто
-
Один
Timestampтип с tag-fieldkind: ClockKind— runtime branch на каждой арифметической операции; теряется compile-time guarantee. -
Go 1.9-стиль (один
Timeс обоими компонентами) —Timeнесёт и wall-clock, и monotonic; runtime сам выбирает что использовать. Проще для users, но: два syscall на каждыйnow(); сериализация требует drop’а monotonic component (silent footgun); невозможно type-checker’ом запретить misuse; историческая правка после bug-bash, не оригинальный дизайн. -
Instantимя (Rust convention) — отвергнут в пользуMonotonicпотому что «Instant» в Java семантически = wall-clock (java.time.Instant), путает Java-разработчиков. «Monotonic» прямо описывает свойство, без культурных ассоциаций. PairTimestamp / Monotonicчитается симметрично. -
Time.tick()как low-level i64 monotonic ns — даёт raw int, теряет type-safety. Reserved какMonotonic.@as_nanos() -> i64(escape hatch для FFI / bench). -
Отдельный
Duration_monoдля monotonic-interval’ов — overkill;Durationуже type-safe (signed, nanos), unit-agnostic.Mono - MonoиTs - Tsоба возвращают тот жеDuration.
AMEND (Plan 175, 2026-07-04..07-10 — актуализация под D316/D317/D318;
исходный текст выше писался до Time-schema-унификации, имена и детали
дрейфовали):
Time.now()/Time.now_monotonic()из §«Что» устарели по имени — переименованы вTime.now_unix_ms()/Time.now_monotonic_ns()(D316 owner unit-rename, 2026-07-06; чистое переименование wire-op).Timestamp/Monotonicтеперьvalue-records (Ф.1b), не heap{}.Monotonic.now()больше НЕ compiler-builtin (Ф.3a, 2026-07-10) — обычная.nv-функция (std/time/duration.nv), мокабельна черезwith Time = handler {...}(закрывает п.5 «mock-handler должен реализовать обаnow()» — теперь буквально верно и для monotonic).Monotonic.from_nanosпо-прежнему НЕ введён (п.4 остаётся в силе — opaque-контракт; см. также D316-amend «Ф.2-находка» — эта opacity ЕСТЬ причина, почему typed-wire-в-схеме архитектурно дороже, чем typed-сахар-поверх-int-wire).Monotonic - Monotonic -> Durationтеперь ДВЕ формы: named@elapsed_since(было) + operator@minus(Monotonic)(Ф.3c, alias,m2 - m1дispatch). Non-regression — saturate-to-zero (D318), не «всегда ≥0 by assumption» как исходный текст п.3 наивно предполагал (HW/VM/OS-баг возможен).- Serialization-запрет на
Monotonic(п. «Что») — verification (derive- путь отсутствует) вынесена в план 175 Ф.6, ещё не пройдена в этой волне.
Связь
- D75 —
CancelTokenможет иметь deadline (tok.cancel_at(Monotonic)) — только monotonic, иначе same NTP-skew bug. - D94 —
selectarms с timeout черезChanReader.close_after(Duration)(clock-agnostic) илиChanReader.close_at(Monotonic). - Plan 65 Ф.12 —
driver для D124 (нужен
close_atдля absolute deadline). - Plan 65 Ф.12.1-Ф.12.6 —
реализация D124:
Monotonicтип +Time.now_monotonic()+close_at+ runtimeclock_gettime(CLOCK_MONOTONIC)/QueryPerformanceCounterper OS. Driver — нужен дляChanReader.close_at(Monotonic). - Plan 22 — libuv
monotonic timer infra (
uv_hrtime()) reused fornow_monotonic.
Эволюция API
| Что | Сейчас | После Plan 68 (D124 closure) |
|---|---|---|
Time.now() (compiler schema) | () -> nova_int (raw ms, противоречит stdlib usage) | () -> Timestamp (record) |
Time.now() (stdlib calls) | Timestamp (.gt(), .minus()) — silent mismatch с schema | aligned с schema |
Time.now_monotonic() | ❌ нет | () -> Monotonic |
Time.sleep(d) | (int ms) (legacy) | (Duration) (out-of-scope для Ф.12; отдельная задача) |
| Deadline в API | Time.now() + d (wall-clock baked in) | Monotonic.now() + d (no NTP skew) |
ChanReader.close_at(...) | ❌ нет | (Monotonic) -> ChanReader[()] (Ф.12.4) |
Latent bug под текущим API (resolved Plan 65 Ф.12.3): time_schema
в emit_c.rs:1044 declares Time.now() -> nova_int, но stdlib
std/time/duration.nv:538-714 использует как Timestamp record.
Работает сейчас через handler-bridge (тот же mechanism что Plan 65
fixed для Duration handler params, [M-handler-duration-schema-mismatch]).
Plan 65 Ф.12.3 aligns schema с реальным usage.
Файлы (затронуты при реализации Plan 65 Ф.12)
std/time/duration.nv— добавитьtype Monotonic { ro nanos i64 }- конструкторы только через
Monotonic.now()/Monotonic.@as_nanos().
- конструкторы только через
compiler-codegen/src/codegen/emit_c.rs:1042-1046— обновитьtime_schema:now() -> Timestamp, добавитьnow_monotonic() -> Monotonic.compiler-codegen/nova_rt/time.c(новый) —nova_time_now_realtime_ns()nova_time_now_monotonic_ns()per-OS implementations.
nova_tests/plan65/f12_*— типы не interconvertible (negative tests), NTP-skew resilience (mock Time effect),close_at(Monotonic)integration.
D136. M:N worker-count — порядок разрешения и NOVA_MAXPROCS
Введён: 2026-05-22 (Plan 83.1 Ф.1–Ф.3 driver). Статус: принят; реализация в
compiler-codegen/nova_rt/runtime.c(nova_runtime_resolve_maxprocs). Дополняет D98 / D103 (M:N-рантайм).
Что
Число worker-потоков M:N-рантайма резолвится из трёх источников по строгому приоритету:
explicit runtime.init(n>0) > ENV NOVA_MAXPROCS > uv_available_parallelism()
- explicit — аргумент
runtime.init(n)приn > 0. NOVA_MAXPROCS— переменная окружения (аналогGOMAXPROCSв Go). Невалидное значение (не целое / ≤ 0 / overflow) → диагностика на stderr + fallback на auto-detect (НЕ abort процесса).- auto-detect —
uv_available_parallelism()(libuv 1.52, уже cgroup- и affinity-aware).
Результат клэмпится в [1, 1024]. Запрос выше потолка (любой источник) → клэмп до 1024 + диагностика на stderr.
runtime.maxprocs() возвращает резолвнутую цель (даже до runtime.init
и после runtime.shutdown); runtime.worker_count() — фактически
поднятые потоки.
Почему
- Паритет с Go:
GOMAXPROCS— стандартный способ управления параллелизмом;NOVA_MAXPROCSповторяет семантику и стиль имени (NOVA_*, какNOVA_TARGET_OS). uv_available_parallelism()уже корректен в контейнерах (cgroup- квота, CPU affinity) — переизобретать черезsysconf/GetSystemInfoбыло бы регрессией по cgroup-корректности.- explicit > env: явный код важнее окружения. env > auto: оператор деплоя может переопределить без пересборки.
- Клэмп [1, 1024]: 1 — минимум осмысленного пула; 1024 — потолок, выше которого запрос почти наверняка ошибка конфигурации, которую честнее диагностировать, чем исполнять.
Известная дельта vs Go
cgroup-квота читается один раз при резолве (на runtime.init либо
первом runtime.maxprocs()). Go 1.25+ перечитывает квоту динамически и
ресайзит пул на лету. Динамический re-read — followup Plan 83.x
(требует Ф.4 lazy-spawn V2 с инкрементальным ростом пула). Для деплоев
с фиксированным лимитом контейнера (норма) статическое чтение корректно.
Связь
- Plan 83.1 (M:N-инфраструктура) — реализация.
- Plan 83.2 — перевод M:N в дефолт (отдельное решение, gated на Plan 82).
- D98 / D103 — M:N-рантайм (per-worker loop, preemption).
D137. M:N — ленивая материализация пула, runtime.init как тюнер
Введён: 2026-05-22 (Plan 83.1 Ф.4). Статус: принят; реализация в
compiler-codegen/nova_rt/runtime.c. Дополняет D136.⚠️ AMENDED by Plan 259 (D451) (2026-08-09). Материализация пула больше НЕ ленивая — она происходит на старте процесса, а не на первом
spawn(№457: ленивая материализация могла случайно попасть внутрь пользовательскогоsupervised(timeout:)-бюджета). Секции «Что»/«Следствия» ниже — историческое описание V1; действующее поведение — в D451.
Что
runtime.init(n) НЕ создаёт worker-потоки немедленно. Он лишь
ARM’ит рантайм: резолвит и фиксирует целевое число worker’ов
(порядок — D136). Реальный пул worker-потоков + sysmon-поток
материализуются лениво — на первом worker-bound spawn.
Следствия:
- Программа без
spawn(hello-world) исполняется на одном главном потоке: 0 worker-потоков, 0 sysmon, даже еслиruntime.initвызван. runtime.worker_count()== 0 до первого spawn;runtime.maxprocs()возвращает целевое число с моментаinit.runtime.is_initialized()== true с моментаinit(armed), а не с момента материализации пула.
runtime.init — одноразовый тюнер:
- до материализации пула повторный
init(m)— валидный re-tune целевого числа (последний выигрывает); - после материализации
init— диагностируемый no-op на stderr (не abort: существующий пул корректен и продолжает работать).
V1: на первом spawn поднимается весь пул maxprocs.
Инкрементальный рост пула (полный Go-M-паритет) — followup.
Почему
- Нулевая цена для не-конкурентного кода: hello-world не платит за
M:N-инфраструктуру. Паритет с Go (ленивый
M) или лучше — у Go sysmon-поток живёт всегда. - Готовит флип дефолта (Plan 83.2): когда M:N включится по умолчанию, ленивость гарантирует, что однопоточные программы не регрессируют по числу потоков.
init-как-тюнер: явныйinit(n)остаётся опциональным override’ом числа worker’ов, но перестаёт быть «включателем».
Связь
- D136 — резолв числа worker’ов (explicit > NOVA_MAXPROCS > auto).
- Plan 83.1 Ф.4 — реализация; Ф.5 — thread-budget для nova test/bench.
- Plan 83.2 — флип M:N в дефолт.
D138. Default-on M:N runtime — production semantics (Plan 83.4.5.6 Ф.3, ACTIVE 2026-05-24)
✅ ACTIVE — semantic specification finalized; activation completed Plan 83.4.5.8 (2026-05-24). M:N runtime default-on в compiled binaries:
nova_runtime_auto_arm()calls at main start (codegen emit_main_wrapper). Все 8 prerequisite fix’ов сошлись:⚠️ Rule 3 и acceptance-пункт «Hello-world без spawn — 0 worker threads» AMENDED by Plan 259 (D451) (2026-08-09) — пул материализуется на старте процесса, не на первом spawn; hello-world под default-on M:N теперь поднимает
maxprocs()worker-потоков без единого spawn. 0 потоков — только подNOVA_AUTOARM=0.
- Plan 83.4.1 park-with-predicate (D93 ASYNC close_cb).
- Plan 83.4.2 Ф.1+Ф.2 supervised_step worker-owned skip + per-fiber handler-snapshot save/restore.
- Plan 83.4.3 B5 cancel_requested atomic.
- Plan 83.4.5.1 cancel-wake-all + dispatch_ready re-queue.
- Plan 83.4.5.2 AsyncDetach production-grade + orphan-spawn tracking via _nova_orphan_scope.pending_remote.
- Plan 83.4.5.4 spawn-time handler-snapshot TLS capture.
- Plan 83.4.5.5 NOVA_NO_AUTOARM=1 escape hatch (cooperative-only tests).
- Plan 83.4.5.7 Ф.1 atomic fiber state machine (NovaSpawnCtxBase._nova_fiber_state + CAS guards mco_resume sites + idempotent wake CAS на parked flag + nova_runtime_shutdown ordering).
- Plan 83.4.5.8 nova_alloc_uncollectable для SpawnCtx + worker-side GC_free post mco_destroy (defeats Boehm GC race на Windows fiber arena ctx visibility).
Что
Compiled Nova-программы (Plan 83.2 flip) запускают M:N runtime по
умолчанию — паритет с Go (GOMAXPROCS=NumCPU), tokio multi-thread
runtime, Kotlin Dispatchers.Default. Hello-world без spawn не платит
за worker-потоки (lazy pool), но любая supervised{spawn}/parallel
for/detach автоматически распределяется на доступные ядра.
Правило
-
Default model. Compiled binary — armed M:N по умолчанию.
nova run(интерпретатор) остаётся однопоточным. -
Worker-count resolution (D136 паритет):
- explicit
runtime.init(n>0)побеждает; - иначе
NOVA_MAXPROCSenv var; - иначе
uv_available_parallelism()(cgroup/affinity-aware). Клэмп[1, 1024].
- explicit
-
Lazy worker pool (D137 паритет). Workers поднимаются на первом spawn; hello-world без spawn = 0 worker threads.
-
Escape hatch. Два режима:
NOVA_MAXPROCS=1— один worker (deterministic single-thread под M:N machinery; полезно для precision-bench’ей).NOVA_AUTOARM=0— полный bootstrap mode (runtime never armed, spawn идёт через cooperative scope queue; полезно для tests, specifically проверяющих cooperative-only semantics). Plan 83.4.5.9 (2026-05-24): renamed из legacyNOVA_NO_AUTOARM=1ради positive env-name convention (без двойного отрицания). Принимаются значения “0” / “false” / “no” / “n” / “f” (case-insensitive); unset либо “1” / “true” / другое → default (armed enabled).
-
Worker blocking ban. Worker НЕ делает блокирующую работу inline — все FFI / syscall’ы обязаны быть в
blocking { … }(D50 §4; Plan 83.3 V1-контракт). -
Spawn ordering — НЕ специфицирован (Go-паритет: “Spawn ordering: no guarantees”). Tests, опирающиеся на specific scheduler order, должны использовать set-equality assertions либо escape hatch
NOVA_AUTOARM=0. -
Cancellation — hierarchical через scope-tree (Plan 83.4.5.1
nova_scope_cancel_wake_all+ cancel_all_pending dispatch_ready re-queue для SYNC slots; tokioCancellationToken.notify_waitersпаритет). Token-tree cascade черезlinked[](Plan 49). Atomic cancel_requested flag (Plan 83.4.3 B5). -
Detach (D50 §3.1 amend, Plan 83.4.5.2) — fire-and-forget на worker pool через
nova_runtime_spawn_orphan(Gogo fn()/ tokio::spawn без JoinHandle / Kotlin GlobalScope.launch паритет). LogAndDrop fail-handler. Sync черезruntime.drain_orphans()(Go sync.WaitGroup.Wait analog). -
Per-fiber state — handler-snapshot per fiber (Plan 83.4.2 Ф.2 worker save/restore + Plan 83.4.5.4 spawn-time TLS inheritance — Node
AsyncLocalStorage.run, KotlinCoroutineContext.Elementauto-inherit паритет). Snapshot travels с fiber’ом cross-worker через work-stealing migration.
Почему
- Default-on M:N — production-grade ожидание для современного concurrent runtime’а. Все референсные runtimes (Go, tokio, Kotlin) default к multi-thread; user opt-out, не opt-in.
- Lazy pool — нулевая цена для не-конкурентных программ. Hello-world не платит за worker-потоки.
- Escape hatches (MAXPROCS=1, NO_AUTOARM=1) — для test-suite specific needs без compromising production default.
- Spawn ordering unspecified — позволяет work-stealing scheduler максимальную свободу для CPU-affinity / load-balancing.
Cross-runtime parity таблица
| Aspect | Go | tokio | Kotlin | Node | Nova цель |
|---|---|---|---|---|---|
| Default model | M:N | M:N (multi-thread) | M:N (Dispatchers.Default) | single-thread | M:N |
| Worker count | GOMAXPROCS | tokio::main(…) | Dispatchers.Default = NumCPU | n/a | NOVA_MAXPROCS |
| Lazy pool | M materialized on demand | task spawn → executor | Coroutine first launch | n/a | first spawn |
| Cancel | context.Done close | CancellationToken | Job.cancel cascade | AbortController | scope-tree + token-tree |
| Detach | go fn() | tokio::spawn (no JoinHandle) | GlobalScope.launch | setImmediate | nova_runtime_spawn_orphan |
| Per-fiber state | goroutine context | task_local! | CoroutineContext.Element | AsyncLocalStorage | fiber_effect_snapshot |
| Wake parked | runtime.gopark | Notify | JobSupport | n/a | nova_scope_cancel_wake_all |
Связь
- D14 — fiber runtime фундамент.
- D50 §3.1 — detach semantic amend (AsyncDetach).
- D71 — bootstrap baseline.
- D80 — per-fiber handler scoping.
- D93 — park/wake D-block.
- D136 + D137 — worker resolution + lazy pool.
- Plan 83.2 — flip-default activation.
- Plan 83.4.5.6 — closure target.
- Plan 83.4.5.7 — multi-worker race fix (GATED dependency).
Acceptance
- Compiled binary без
runtime.init()использует все CPU при fiber-нагрузке. - Hello-world без spawn — 0 worker threads.
NOVA_MAXPROCS=Nenv var корректно clamp’ит worker count.NOVA_AUTOARM=0env var полностью отключает auto-arm (bootstrap
АМЕНДМЕНТ 2026-08-17 (аудит самосогласованности, раздел 4, пункт 38). Здесь и ниже стояло снятое имя
NOVA_NO_AUTOARM=1. Действующее —NOVA_AUTOARM=0, и спрашивать было не о чем: вtest_runner.rs(2883, 2889, 3980, 8334) живёт именно оно, а старое осталось лишь в комментарииemit_c.rs:29750как ссылка на план 83.4.5.5. Отдельно поучительно, что само переименование спека фиксирует строкой выше («renamed из legacyNOVA_NO_AUTOARM=1», Plan 83.4.5.9) — а три соседние строки продолжали пользоваться старым именем как действующим. Правку внесли в одно место из четырёх; ровно этот класс отчёт и ловит.
mode).
- 24 NEW regressions из Plan 83.4.5 Ф.0 enumeration все PASS под default-on M:N (после Plan 83.4.5.7 race fix).
- Speedup vs single-thread ≥3.0× на CPU-bound parallel_for (4 cores).
Статус
📋 DRAFT — спецификация intended behavior. Имплементация подготовлена (Plan 83.4.5.1-5 ✅). Activation в codegen-emit GATED на Plan 83.4.5.7 multi-worker double-resume race fix. После fix’а
- Plan 83.4.5.6 закроет roadmap Plan 83.
D451. M:N — пул воркеров материализуется на старте процесса (amends D137/D138)
Введён: 2026-08-09 (Plan 259 Слой 2). Статус: ✅ принят; реализация в
compiler-codegen/nova_rt/runtime.c(nova_runtime_auto_arm()). Amends D137 §«Следствия» и D138 правило 3 + acceptance «Hello-world без spawn — 0 worker threads». Реестр №457 (симптом), №470/№474 (тот же класс — запрет компенсировать дедлайн длительностью инициализации),[M-187-docker-linux-runtime-hang](родственная волна, тот же файбер-арена subsystem).
Что
Материализация worker-пула (потоки + sysmon-поток) больше НЕ
откладывается до первого worker-bound spawn. Она происходит
синхронно на старте процесса, внутри nova_runtime_auto_arm() —
первого рантайм-вызова эмитируемого main() (сразу после
nova_gc_init()/nova_consts_init(), до исполнения тела программы).
Отменяет описание D137 «пул поднимается лениво на первом spawn» и
связанное следствие D138 «Hello-world без spawn — 0 worker threads»:
теперь ЛЮБАЯ армированная программа (default-on M:N, т.е. без
NOVA_AUTOARM=0) поднимает полный целевой пул ДО первой строки
пользовательского кода — включая hello-world без единого spawn.
Почему
Причина — №457: пул, поднятый лениво «на первом spawn», списывает цену
инициализации (потоки, sysmon, per-worker libuv loop, per-worker
fiber-арена) на ТОГО, кто случайно оказался первым, кто вызвал spawn
— которым может оказаться spawn ВНУТРИ пользовательского
supervised(timeout:)-блока. Инфраструктура рантайма обязана
укладываться в СВОЙ бюджет, а не вычитаться из чужого дедлайна —
прямой прецедент владельца по №470/№474 («не компенсировать дедлайн
длительностью паузы — прятать симптом»; здесь то же самое про
длительность инициализации). Перенос материализации на старт процесса
убирает саму возможность этой цене попасть в произвольный
пользовательский бюджет: она оплачивается ДО того, как какой-либо
таймер пользователя мог начать отсчёт.
Слой 1 того же плана (259, D97
амендмент — см. docs/plans/259-fiber-arena-guard-pages.md) сделал
guard-страницы файбер-арены ленивыми (пробиваются на первое
использование СЛОТА, а не все разом при инициализации арены каждого
воркера). Без слоя 1 материализация пула на старте оставалась бы такой
же дорогой, как была (шторм mprotect при создании арены каждого
воркера, №457 root cause) — просто переехавшей раньше по времени, а не
устранённой. Слои дополняют друг друга и нужны ОБА: слой 1 снижает
абсолютную цену инициализации арены, слой 2 гарантирует, что
ОСТАТОЧНАЯ цена (создание потоков, sysmon, per-worker libuv loop)
платится вне пользовательского бюджета, а не внутри него.
Правило
nova_runtime_auto_arm()— единственный автоматический arm-путь, emit’ится codegen’ом безусловно первой содержательной строкойmain()(compiler-codegen/src/codegen/emit_c.rs,emit_main_wrapper) — теперь материализует пул сразу после arm, а не откладывает это до первого spawn.- Явный
runtime.init(n), вызванный из пользовательского кода, исполняется ПОСЛЕ того, какmain()уже начал выполняться — то есть ПОСЛЕ того, какnova_runtime_auto_arm()уже материализовал пул с auto-detect/NOVA_MAXPROCS-резолвнутым числом воркеров.runtime.init(n)остаётся валидным вызовом (не ошибка) — это диагностируемый no-op на stderr, поведение, которое D137 уже описывал для случая «после материализации»; теперь это единственный достижимый для пользовательского кода случай, а не крайний. Чтобы задать число воркеров под default-on M:N, остаётсяNOVA_MAXPROCS(env, читается ДО материализации, на arm) —runtime.init(n>0)как «тюнер до первого spawn» для скомпилированных бинарников с default-on M:N больше недостижим тем способом, каким был раньше (вызовinitв начале пользовательскогоmainдо первогоspawn): пул уже поднят к моменту, когда пользовательский код может вызватьinit. NOVA_AUTOARM=0(полный bootstrap/cooperative-режим) — по-прежнему НЕ армит рантайм вовсе, соответственно НЕ материализует пул. Единственный оставшийся способ получить 0 worker threads на старте.runtime.worker_count()возвращает целевое число воркеров сразу после старта armed-программы (даже без единогоspawn), а не 0.runtime.is_initialized()не меняется (уже былоtrueс момента arm, до этого амендмента).- Зажим
NOVA_ARENA_VMA_RESERVE/NOVA_ARENA_VMA_SAFETY_PCT(арена файберов,fiber_arena.c, лечил VMA-шторм СЛЕДСТВИЕМ) снят Слоем 1 того же плана — ленивые guard-страницы убирают причину, зажим не нужен.
Cross-runtime parity
Go поднимает sysmon-поток безусловно при старте рантайма (не
лениво); worker-M-потоки — по требованию, но GOMAXPROCS-логическая
инфраструктура (allp[]) строится на старте через schedinit(), а не
на первом go statement. Данный амендмент сближает Nova с этой
моделью: инфраструктурная цена (не то же самое, что «поднять КАЖДЫЙ
worker-поток» — Go тоже создаёт M-потоки по требованию через
newm) переносится к моменту, когда рантайм точно знает свой бюджет
(старт процесса), а не к первому пользовательскому запросу параллелизма.
Связь
- Plan 259 — Слой 1 (ленивые guard-страницы файбер-арены) + Слой 2 (этот блок).
- №457 — таймаут
supervised(timeout:)не укладывался в бюджет из-за ленивой материализации пула, случайно попавшей внутрь таймд-блока. - №470/№474 — тот же класс дефекта («не компенсировать дедлайн» запрет).
[M-187-docker-linux-runtime-hang]— родственная волна (VMA-шторм той же файбер-арены), закрыта Слоем 1 этого плана снятием зажимаNOVA_ARENA_VMA_*.- D97 — файбер-арена (Слой 1 меняет guard-page timing внутри неё, отдельно от материализации пула).
- D137 — amended (материализация больше не ленивая).
- D138 — правило 3 + acceptance amended.
D167. Memory ordering & happens-before между fiber’ами
Статус: ✅ final (Plan 103.7, 2026-05-27). Реализован в Plan 103.1.
Что
MemOrdering enum (5 вариантов) экспонирует C11/GCC __ATOMIC_* ordering
constants на Nova-уровне. Используется с fence() (Plan 103.1) и будущими
atomic operations с explicit ordering (Plan 103.2+). Контракт
happens-before между fiber’ами через paired Acquire/Release.
Типы: MemOrdering в std/runtime/sync — НЕ путать с prelude Ordering
(three-way comparison: Less|Equal|Greater).
Варианты MemOrdering
| Вариант | C constant | Семантика | Valid для |
|---|---|---|---|
Relaxed | __ATOMIC_RELAXED | Только атомарность; нет happens-before | load, store, RMW, fence (no-op) |
Acquire | __ATOMIC_ACQUIRE | Все subsequent ops happen-after prior Release | load, RMW, fence |
Release | __ATOMIC_RELEASE | Все prior ops happen-before subsequent Acquire | store, RMW, fence |
AcqRel | __ATOMIC_ACQ_REL | Acquire+Release combined | RMW, fence |
SeqCst | __ATOMIC_SEQ_CST | Total order на всех SeqCst ops | все ops |
Default ordering (Plan 103.2+ simple-overload методы): SeqCst — безопасно
для всех use cases, переопределяется через _ordered overloads для
perf-critical кода (design decision M1).
fence(MemOrdering) семантика
fence(MemOrdering.Relaxed): no-op (валиден синтаксически, нет ordering-эффекта)fence(MemOrdering.Acquire): sequenced ordering point для последующих loads/storesfence(MemOrdering.Release): sequenced ordering point для предыдущих loads/storesfence(MemOrdering.AcqRel): combinationfence(MemOrdering.SeqCst): total-order participation
Validation правила (compile-time, Plan 103.2+)
| Operation | Запрещённые | Error code |
|---|---|---|
| load | Release, AcqRel | E_INVALID_ORDERING_LOAD |
| store | Acquire, AcqRel | E_INVALID_ORDERING_STORE |
| fence | — (все валидны; Relaxed = no-op) | — |
| RMW (swap, CAS, fetch_*) | — | — (Plan 103.2) |
Validation — compile-time при literal ordering (типичный случай: MemOrdering.Acquire).
Runtime-value ordering (let ord = pick()) → fallback runtime panic.
Memory model contract
Bootstrap-runtime (single-fiber, single-threaded fiber pool):
All ordering variants are semantically equivalent — sequenced-before covers all.
MemOrdering.Relaxed и MemOrdering.SeqCst имеют идентичные эффекты.
M:N runtime (Plan 23, Plan 83.x): Full C11 memory model via __atomic_* GCC/Clang
builtins (cross-platform: Linux, macOS, Windows/Clang). Happens-before между fiber’ами
через paired Acquire/Release.
Performance note: x86 имеет strong memory model (TSO); большинство ordering барьеров дёшевы (load-acquire ≈ plain load, SeqCst store ≈ MFENCE). ARM64 — более explicit барьеры (DMB LD/ST/ISH); Relaxed vs Acquire savings значительны для hot-path counters (~1-2ns per op).
Связи
- D14, D50 (fiber runtime) — D167 specifies memory model для D14 production-runtime
- D79 (Channels) — channel send/recv → implicit happens-before (Go-style); see D91/D94
- D138 (Default-on M:N) — производительность зависит от корректного ordering
- Plan 103.1 — реализация MemOrdering enum + fence(MemOrdering) + codegen helper
- Plan 103.2 — AtomicX.load(MemOrdering) / .store(v, MemOrdering) / RMW overloads
- Plan 103.7 — финальная редакция D167; D370 AI-first guidance для паттернов ordering
- D370 — decision tree: когда нужен explicit ordering vs SeqCst-default
D168. Sized atomic types — API contract (Plan 103.2)
Статус: ✅ final (Plan 103.7, 2026-05-27). Реализован в Plan 103.2.
Что
Nova предоставляет 11 sized atomic типов† с детерминированной шириной:
| Тип | Ширина | Диапазон | C-репрезентация |
|---|---|---|---|
AtomicI8 | 8 бит | −128..127 | int8_t |
AtomicI16 | 16 бит | −32768..32767 | int16_t |
AtomicI32 | 32 бит | −2³¹..2³¹−1 | int32_t |
AtomicI64 | 64 бит | −2⁶³..2⁶³−1 | int64_t |
AtomicU8 | 8 бит | 0..255 | uint8_t |
AtomicU16 | 16 бит | 0..65535 | uint16_t |
AtomicU32 | 32 бит | 0..2³²−1 | uint32_t |
AtomicU64 | 64 бит | 0..2⁶⁴−1 | uint64_t |
AtomicInt | платформенная | −2^(W−1)..2^(W−1)−1 | nova_int (= intptr_t) |
AtomicUint | платформенная | 0..2^W−1 | nova_uint (= uintptr_t) |
AtomicBool | 1 логический бит | false/true | bool (8-битное хранение) |
† Амендировано D426
(Plan 207, 2026-07-16): было 12 типов под именами AtomicIsize/AtomicUsize/AtomicPtr
(плюс отдельный легаси AtomicInt, int32-precision, вне этой таблицы). Консолидация:
AtomicIsize→AtomicInt, AtomicUsize→AtomicUint (Nova не вводит типы
isize/usize — int/uint уже address-sized, Plan 133); легаси AtomicInt
снят (API-подмножество покрыто новым AtomicInt); AtomicPtr снят целиком
(int-proxy дубль без generic [T], GC-root integration не реализована —
см. D426).
Все типы являются value types в Nova, копируются по значению при передаче в функцию / возврате. Семантически — ячейки с атомарным доступом, не разделяемые reference-типы. Для разделения между fiber’ами — передавать mutable-ссылку или хранить в heap-структуре.
Правило
1. Матрица операций
Все 11 типов поддерживают следующие операции (обозначение: T — тип значения,
Bool — bool, Int — int). Столбец AtomicPtr снят (D426,
Plan 207, 2026-07-16) — см. §4 ниже:
| Операция | AtomicI*/U*/Int/Uint | AtomicBool |
|---|---|---|
new(v T) → AtomicX | ✓ | ✓ (v bool) |
load() → T | ✓ | ✓ |
load(ord MemOrdering) → T | ✓ | ✓ |
store(v T) | ✓ | ✓ |
store(v T, ord MemOrdering) | ✓ | ✓ |
swap(v T) → T | ✓ | ✓ |
swap(v T, ord MemOrdering) → T | ✓ | ✓ |
compare_exchange(expected T, desired T) → Result[(), T]* | ✓ | ✓ |
compare_exchange(exp T, des T, ord MemOrdering) → Result[(), T]* | ✓ | ✓ |
compare_exchange_weak(exp T, des T) → Result[(), T]* | ✓ | ✓ |
compare_exchange_weak(exp T, des T, ord MemOrdering) → Result[(), T]* | ✓ | ✓ |
fetch_add(v T) → T | ✓ (int/uint только) | — |
fetch_add(v T, ord MemOrdering) → T | ✓ | — |
fetch_sub(v T) → T | ✓ | — |
fetch_sub(v T, ord MemOrdering) → T | ✓ | — |
fetch_or(v T) → T | ✓ | ✓ (v bool) |
fetch_or(v T, ord MemOrdering) → T | ✓ | ✓ |
fetch_and(v T) → T | ✓ | ✓ |
fetch_and(v T, ord MemOrdering) → T | ✓ | ✓ |
fetch_xor(v T) → T | ✓ | ✓ |
fetch_xor(v T, ord MemOrdering) → T | ✓ | ✓ |
fetch_nand(v T) → T | ✓ | — |
fetch_nand(v T, ord MemOrdering) → T | ✓ | — |
fetch_max(v T) → T | ✓ | — |
fetch_max(v T, ord MemOrdering) → T | ✓ | — |
fetch_min(v T) → T | ✓ | — |
fetch_min(v T, ord MemOrdering) → T | ✓ | — |
Примечание по AtomicPtr (снят, D426): ранее хранил int (адрес GC-объекта как intptr_t).
Арифметика не поддерживается (fetch_add нет). Typed generic form AtomicPtr[T]
с GC-root integration — откладывается в Plan 103.9+.
* CAS-строки амендированы D425 (Plan 207, 2026-07-15): возврат
bool → Result[(), T] (Ok(()) успех / Err(actual) провал с witness-значением).
2. MemOrdering-aware overloads
Каждая операция, принимающая MemOrdering, является overload к базовой
операции без ordering-параметра. Обе формы — валидные публичные API:
ro a = AtomicI64.new(0)
a.store(42) // default: SeqCst
a.store(42, MemOrdering.Relaxed) // explicit: Relaxed
ro v = a.load() // default: SeqCst
ro v2 = a.load(MemOrdering.Acquire) // explicit: Acquire
Default ordering = SeqCst для всех операций без явного параметра. Это максимально безопасный выбор; производительность при нужде оптимизируется явным указанием Relaxed/Acquire/Release.
Ограничения (из D167):
loadне принимаетRelease,AcqRel→ CC error (undefined C call).storeне принимаетAcquire,AcqRel→ CC error.- Эти ограничения enforced через отсутствие соответствующих C-функций в
sync_primitives.h— compiler error, не runtime.
3. Wraparound semantics (целочисленный overflow)
Все integer RMW операции используют модульную арифметику. Переполнение не вызывает panic, trap или UB — результат определён спецификацией:
// AtomicI8 range: -128..127
ro a = AtomicI8.new(127)
ro prev = a.fetch_add(1) // prev = 127, a.load() = -128 (wraparound)
// AtomicU8 range: 0..255
ro b = AtomicU8.new(255)
ro prev2 = b.fetch_add(1) // prev2 = 255, b.load() = 0 (wraparound)
Wraparound семантика идентична C uint8_t/int8_t unsigned/signed overflow
согласно стандарту C11 _Atomic. Это согласовано с поведением Nova non-atomic
integers (2’s complement, no-panic overflow — Plan 101 решение).
Почему не checked overflow: atomic increment с panic при overflow — бесполезен для counters, spinlocks, sequence numbers. Caller несёт ответственность за выбор ширины типа, достаточной для его диапазона.
4. AtomicPtr — СНЯТ (Plan 207, 2026-07-16 — см. D426)
Секция ниже — историческая (V1 API, Plan 103.2).
AtomicPtrснят целиком: несмотря на заявленное ниже «GC safety», реализация вsync_primitives.hНИКОГДА не регистрировала GC roots (голый__atomic_*надnova_int) — чистый int-proxy дубльAtomicIntпод другим именем. До появления настоящего typed genericAtomicPtr[T]с GC-tracing (Plan 103.7) тип не существует; заменить наAtomicInt.
AtomicPtr хранил int (= intptr_t) как GC-безопасный адрес (заявлено;
де-факто без root-регистрации). Это не typed generic — Plan 103.7
вводит AtomicPtr[T] с proper GC-tracing.
V1 семантика (Plan 103.2, снята):
AtomicPtr.new(v int)— создать из raw int (адрес).AtomicPtr.null()— создать с значением 0 (null-адрес).load()→int— прочитать адрес как int.store(v int)— записать адрес.swap(v int)→int— атомарный обмен адресов.compare_exchange(expected int, desired int)→Result[(), int]— CAS на адресах (D425:Ok(())/Err(actual), былоbool).- Arithmetic (
fetch_add) не поддерживается — AtomicPtr не счётчик.
GC safety V1 (заявленная, не реализованная): приложение несёт
ответственность за то, что int-значение в AtomicPtr остаётся живым
GC-объектом. V2 (Plan 103.7+): AtomicPtr[T] с типизированным GC-root —
откладывается (typed generic в codegen non-trivial); до его прихода
типа AtomicPtr в Nova нет.
5. compare_exchange vs compare_exchange_weak
compare_exchange(expected, desired) — strong: гарантирует успех если
*self == expected. На CISC (x86/x64) реализован через cmpxchg — no spurious
failures.
compare_exchange_weak(expected, desired) — weak: может fail spuriously
даже если *self == expected. На RISC (ARM, RISC-V) реализован через LL/SC;
spurious fail = retry loop в caller. Более эффективен на ARM для retry loops:
// Правильный паттерн для weak CAS retry loop:
mut ok = false
while !ok {
ro cur = a.load(MemOrdering.Relaxed)
ok = a.compare_exchange_weak(cur, cur + 1, MemOrdering.Release)
}
На x86 compare_exchange_weak эквивалентен compare_exchange (нет
LL/SC — no spurious fails). На ARM различие значительно.
Почему
-
12 sized types вместо single
Atomic[T]generic. Детерминированная ширина критична для lock-free алгоритмов (ABA-prevention через tagged pointer требует точной ширины слова). GenericAtomic[T]потребовал бы мономорфизацию на C-уровне; текущая реализация — 12 конкретных C-struct’ов, по одному на тип. -
Default SeqCst + explicit ordering overloads. Большинство кода не нуждается в тонкой настройке ordering. SeqCst по умолчанию безопасен и корректен. Explicit overloads — escape hatch для performance-critical hot paths (счётчики, sequence numbers, SPSC queues).
-
Wraparound, не panic. Атомарные операции применяются в tight loops (fetch_add счётчики, sequence numbers). Panic при overflow сделал бы AtomicI8/U8 непригодными для cyclic counters. Поведение консистентно с Nova integer semantics.
-
AtomicPtr как
intв V1 (снят, D426). TypedAtomicPtr[T]требует GC root integration в codegen — это non-trivial и откладывается в Plan 103.7+.int-proxy без root-регистрации оказался дублемAtomicIntпод другим именем — снят целиком до прихода typed generic формы. -
compare_exchange_weak — отдельная операция. На ARM разница ~30% на retry-heavy workloads. Наличие обеих форм позволяет писать переносимый код с оптимальными характеристиками.
Что отвергнуто
-
Generic
Atomic[T]вместо 12 конкретных типов. Требует мономорфизации на C-уровне, усложняет ExternalRegistry (нет arity-based dispatch по типу элемента). Отклонено в V1 (12 конкретных типов покрывают все стандартные use cases; generic форма как future plan если докажет ценность). -
Checked overflow для narrow types (AtomicI8).
fetch_addна переполненном AtomicI8 → panic неожиданен для счётчиков. Wraparound — стандартная C11 семантика, консистентна с остальными Nova integers. -
Arity-based dispatch через Nova type system. Nova integer literals имеют тип
nova_int(широкий), а typed atomic операции принимаютint32_t/uint8_t— строгое type matching не работает. Решение (Plan 103.2): arity-based fallback в codegen (N vs N+1 параметров для default vs ordering overload) — финализировано. -
AtomicPtr.fetch_add(n)— pointer arithmetic. Небезопасно без bounds checking, противоречит Nova memory safety goals. Pointer arithmetic — отдельный unsafe-escaped API, не часть стандартногоAtomicPtr. -
Единственный
compare_exchangeбез weak variant. На ARM RISC-V LL/SC реализует CAS; weak variant существенно эффективнее для retry loops. Обе формы — часть стандартного C11 atomic API.
Связь
- D167 — ordering semantics, MemOrdering variants + запреты для load/store.
- D26 — prelude numeric types (int, uint, bool, u8..u64 и т.д.).
- D50 — fiber concurrency model; atomic ops — примитив координации между fiber’ами.
- D138 — production M:N runtime; атомарные операции используются внутри scheduler’а.
- Plan 103.1 — MemOrdering enum + fence(MemOrdering) + nova_mo_c() codegen helper.
- Plan 103.7 — D168 финальная редакция (это plan).
- D370 — AI-first guidance: counter/swap/CAS паттерны выбора atomic типа.
- Plan 103.9+ — AtomicPtr[T] typed generic с GC-root integration (deferred).
Реализация (Plan 103.2, 2026-05-25)
std/runtime/sync.nv: 12 типов объявлены какexternal type AtomicXс полным наборомexternal fnобъявлений.compiler-codegen/nova_rt/sync_primitives.h: C реализация через__atomic_*GCC/Clang builtins.Nova_AtomicX= struct с одним полем.nova_mo_c()helper конвертируетNova_MemOrdering*tag в__ATOMIC_*константу.compiler-codegen/src/codegen/emit_c.rs:- ExternalRegistry
last_param_suffixlogic для overload disambiguation. - ExternalRegistry →
method_overloadsregistration (multi-overload dispatch). - Arity-based fallback: когда strict type matching fails и все кандидаты
is_external— arity alone disambiguates (N vs N+1 параметров). - Все 12 типов добавлены в
BUILTIN_RUNTIME_TYPES.
- ExternalRegistry
Эволюция
D168 введён как draft (Plan 103.2, 2026-05-25). Финализирован в Plan 103.7. Амендирован D426 (Plan 207, 2026-07-16) — консолидация имён.
Backward compatibility note (устарела, см. D426): формулировка ниже была
неточна уже на момент написания — pre-103.2 legacy AtomicInt был int32-
precision типом без MemOrdering, НЕ алиасом AtomicI64. Оба факта разошлись
с реальностью; D426 снимает legacy AtomicInt и переиспользует имя AtomicInt
для бывшего AtomicIsize (address-sized, intptr_t) — актуальное состояние
см. в таблице §Что выше.
Отложено в Plan 103.7+:
AtomicPtr[T]typed generic с GC root integration (сам нетипизированныйAtomicPtrснят D426 — см. §4).- Lint
W_NARROW_ATOMIC_OVERFLOW_RISKдля подозрительного использования narrow types (AtomicI8/U8) с большими константами. - ARM CI validation для
compare_exchange_weakspurious-fail paths.
D171. Once / OnceCell / Lazy — single-initialization primitives (Plan 103.5)
Статус: ✅ final (Plan 103.7, 2026-05-27). Реализован в Plan 103.5. V2 API hygiene — Plan 103.9.
Что
Nova предоставляет три single-initialization примитива для координации одноразовой инициализации между fiber’ами:
| Тип | Назначение | Хранит значение | Init по требованию |
|---|---|---|---|
Once | one-shot гейт без значения | — | — |
OnceCell[T] | lazy cell, set вручную или через get_or_init | Option[T] | да |
Lazy[T] | wrapper над OnceCell[T] с init-closure в конструкторе | T после force | да |
Все три типа являются value types в Nova, передаются по reference внутри fiber-арены (через mutable param или heap-структуру). Семантически — shared state с гарантией exactly-once init при произвольной concurrency.
Правило
1. OnceState — публичный sum-type
type OnceState =
| Fresh // init ещё не начат
| Running // init выполняется (другим fiber'ом)
| Done // init успешно завершён
| Poisoned // init panicked — все последующие операции re-throw
Tag-значения зафиксированы (Fresh=0, Running=1, Done=2, Poisoned=3) для
координации с C runtime (Nova_OnceState_Tag в sync_primitives.h).
2. Once API
type Once
namespace Once {
fn new() -> Once
/// Выполняет body ровно один раз. Subsequent calls — no-op (DONE)
/// или re-throw (POISONED).
/// Concurrent callers: park (fiber) / spin (non-fiber) до завершения runner'а.
/// Запрещён в realtime context (E_REALTIME_VIOLATION).
fn call_once(self, body: () -> ()) throws Fail
/// Heap-allocated snapshot текущего состояния (для match / introspection).
fn state(self) -> OnceState
/// true ⟺ state == Done. False для Fresh, Running, Poisoned.
fn is_completed(self) -> bool
/// DEPRECATED (W_ONCE_RUN_DONE_DEPRECATED): use call_once.
/// run() возвращает true ровно одному вызывающему (становится runner'ом).
/// done() требует matching run()==true иначе runtime panic.
fn run(self) -> bool // deprecated
fn done(self) // deprecated, throws Fail if state != Running
}
Poison semantics: если body в call_once panic’ует (через Fail effect или
nova_throw), Once переходит в Poisoned permanently. Все waiting fiber’ы
просыпаются и re-throw тот же panic message. Все subsequent call_once тоже
re-throw. Восстановление невозможно — Once одноразовый.
3. OnceCell[T] API
type OnceCell[T]
namespace OnceCell {
fn new[T]() -> OnceCell[T]
/// None если init ещё не выполнен; Some(value) если выполнен.
fn get[T](self) -> Option[T]
/// Idempotent set. Возвращает true если значение было установлено первым
/// (winner); false если кто-то другой уже выполнил set/get_or_init.
fn set[T](self, v: T) -> bool
/// Если значение уже есть — вернуть его. Иначе выполнить body ровно один
/// раз, сохранить результат и вернуть. Re-entrant guard: рекурсивный
/// вызов get_or_init из тела body → runtime panic (deadlock-prevention).
/// Запрещён в realtime context.
fn get_or_init[T](self, body: () -> T) -> T throws Fail
/// Извлечь значение и сбросить состояние в Fresh. Возвращает Some(v) если
/// было Done; None для Fresh/Running. Poisoned cells остаются Poisoned.
/// Не-atomic относительно concurrent get_or_init — caller отвечает за
/// внешнюю синхронизацию.
fn take[T](self) -> Option[T]
}
Poison & retry: в отличие от Once, panic в body функции get_or_init
не делает cell terminally poisoned в V1. Состояние возвращается в Fresh,
позволяя retry. Plan 103.9 пересмотрит poison semantics на основе real-world usage
(возможно введение Poisoned варианта с явным recover()).
Re-entrant guard: если внутри body, переданного в get_or_init, происходит
рекурсивный вызов cell.get_or_init на том же cell — runtime panic с message
“OnceCell.get_or_init: recursive initialization”. Это deadlock-prevention,
не семантическая ошибка ленивой инициализации.
4. Lazy[T] API
type Lazy[T]
namespace Lazy {
/// Сохраняет init closure для отложенного вызова. Не выполняет body.
fn new[T](init: () -> T) -> Lazy[T]
/// При первом вызове — выполнить init body, сохранить значение,
/// вернуть. Subsequent calls — вернуть кэшированное значение.
/// Panic в body → Poisoned (terminal); все subsequent force() re-throw.
/// Запрещён в realtime context.
fn force[T](self) -> T throws Fail
/// true ⟺ force() уже завершён успешно.
fn is_forced[T](self) -> bool
}
Poison semantics: в отличие от OnceCell.get_or_init (retry-on-panic),
Lazy.force имеет terminal Poisoned state. Panic в init closure → все
subsequent force() re-throw тот же message. Восстановление невозможно.
Различие мотивировано тем, что init closure хранится в самом Lazy и не может
быть заменён — retry с тем же body даст тот же panic.
5. Memory ordering (D167 contract)
Все три примитива гарантируют Acquire/Release ordering между init body и последующими read’ами:
- Завершение init body happens-before любого
get()/force()/is_completed(), возвращающего успешный результат. - Запись result-значения (
OnceCell.value,Lazy.value) использует__ATOMIC_RELEASE; fast-path read —__ATOMIC_ACQUIRE. - Состояния (
Done,Poisoned,has_value) публикуются через__atomic_store_n(..., __ATOMIC_RELEASE)и читаются через__atomic_load_n(..., __ATOMIC_ACQUIRE).
Это гарантирует data-race-free доступ к закэшированному значению без дополнительной синхронизации со стороны caller’а.
6. Realtime context forbidden
Once.call_once, OnceCell.get_or_init, Lazy.force могут заблокировать
вызывающий fiber (park до завершения runner’а). Это противоречит realtime
гарантиям (bounded execution time), поэтому:
fn realtime_handler() with Realtime {
ro v = lazy.force() // CC error: E_REALTIME_VIOLATION
once.call_once { ... } // CC error: E_REALTIME_VIOLATION
cell.get_or_init { 42 } // CC error: E_REALTIME_VIOLATION
}
Проверка выполняется в emit_c.rs через in_realtime флаг (D87 effect-aware
codegen). get(), set(), is_completed(), is_forced(), take(),
state() — разрешены (lock-free fast paths).
Почему
-
Три отдельных типа, не один.
Onceбез значения дешевле OnceCell[T] когда нужен только гейт (lazy-init глобального ресурса без возврата значения).Lazy[T]удобнееOnceCell[T] + get_or_initкогда init body известен в конструкторе. Three-tier API покрывает все стандартные use cases без overhead. -
OnceState public sum-type. Pattern matching (
match once.state() { Done => ..., Poisoned => ... }) — идиоматичный Nova-стиль для introspection. Отдельные предикаты (is_completed,is_forced) — для fast-path checks без аллокации. -
Poison terminal в Once/Lazy, retry в OnceCell. В Once и Lazy init body фиксирован (Once: каждый вызов передаёт свой body, но первый panic poisonит для всех; Lazy: один body хранится в конструкторе) — retry даст тот же panic. В OnceCell body передаётся каждый раз → retry с другим body осмыслен. V1 поведение; Plan 103.9 пересмотрит на основе real-world usage.
-
Re-entrant guard вместо deadlock. Рекурсивный
get_or_initбез guard’а = вечный self-park. Panic с понятным message > undebuggable hang. -
Acquire/Release explicit. SeqCst было бы избыточно (init publishes only once). Acquire/Release достаточен для happens-before между init и read, с меньшим overhead на ARM (no LDAR-after-DMB-ISH).
-
Realtime forbidden, не silent slow.
call_onceможет park’нуть fiber на произвольное время (зависит от length init body другого fiber’а). В realtime context это violation contract’а; CC error лучше, чем missed deadline в production.
Что отвергнуто
-
Single generic
Once[T]instead of Once + OnceCell + Lazy. Усложняет API (Once[()]для unit-case ugly), иLazy[T]всё равно требуется как syntactic sugar поверх stored init. Three разных типа = ясный intent. -
OnceCell poison terminal (как Lazy). Усложняет retry-after-recover паттерн; V1 retry-on-panic совместим с traditional
lazy_staticsemantics в других языках. Если окажется опасно — Plan 103.9 добавитpoison_modeпараметр вnew(). -
Lock-free OnceCell через CAS-only. Реализация через
stateenum + mutex + waker list проще и подходит для M:N scheduler’а с park/wake. CAS-only сложнее (ABA-prevention для waker list) и не быстрее когда init body длинный. Может быть пересмотрено если профилирование покажет. -
AtomicOnceCell для primitives. Специализированная версия для
int/bool/f64без mutex (через 2-word CAS). Избыточно для V1 — общая реализация с mutex достаточно быстра для типичных use cases (init выполняется один раз, дальше — lock-free fast path read). -
OnceCell.set_if_absent/swap. Лишние операции;set+take+get_or_initпокрывают все use cases. Минимальный API легче эволюционировать.
Связи
- D50 — fiber model; Once/OnceCell/Lazy используются для lazy-init shared state между fibers.
- D87 —
in_realtimeflag в codegen; основа для E_REALTIME_VIOLATION проверок. - D167 — MemOrdering enum; Acquire/Release константы из этого D-block’а.
- D168 — sized atomics;
внутренние state-поля Once/OnceCell/Lazy реализованы через
__atomic_*. - Plan 103.5 — реализация Once hardening + OnceCell + Lazy.
- D370 — AI-first guidance: выбор Once/OnceCell/Lazy по паттерну; decision tree.
- Plan 103.9 — V2 API hygiene pass: возможно удаление
run/done, пересмотр OnceCell poison semantics.
Реализация (Plan 103.5, 2026-05-26)
-
std/runtime/sync.nv:external type Once+ методы (call_once,is_completed,state, deprecatedrun/done).external type OnceCell[T]+ методы (new,get,set,get_or_init,take).external type Lazy[T]+ методы (new,force,is_forced).type OnceState = | Fresh | Running | Done | Poisoned(declared in Nova).
-
compiler-codegen/nova_rt/sync_primitives.h:Nova_Oncestruct +Nova_Once_method_call_once/is_completed/state+ deprecatedrun/done.Nova_OnceStatetypedef + 4 constructor функций (nova_make_OnceState_*).Nova_Once_method_doneиспользует unconditional state check черезNova_Fail_fail + nova_throw(fix: NOVA_SYNC_ASSERT — no-op в Dev builds).
-
compiler-codegen/src/codegen/emit_c.rs:emit_oncecell_instance(mangled, t_cty)— мономорфизируетOnceCell[T]per instantiation (struct + 5 методов).emit_lazy_instance(mangled, t_cty)— мономорфизируетLazy[T]per instantiation (struct + 2 методов; stored init closure).in_realtimeфлаг + E_REALTIME_VIOLATION дляcall_once/get_or_init/force.- W_ONCE_RUN_DONE_DEPRECATED warning при использовании
run/done. "OnceState"добавлен вRUNTIME_DEFINED_TYPES(skip emit_sum_type).
-
20 тестов в
nova_tests/plan103_5/: 11 positive + 3 negative + 2 property- 1 stress (16 fibers × 100 calls). All PASS.
Эволюция
D171 введён как draft (Plan 103.5, 2026-05-26). Финализирован в Plan 103.7.
D370 (этот plan) содержит AI-first guidance для init-pattern выбора (Once vs OnceCell vs Lazy) — см. decision tree «exactly-once init» branch.
Отложено в Plan 103.9 (API hygiene pass):
- Удаление deprecated
run/done(после миграционного периода). - Пересмотр OnceCell poison semantics на основе real-world usage.
- Возможный typed-poison API (
recover() throws PoisonMsg).
D169. Mutex / RwLock / ReentrantMutex family (Plan 103.3)
Статус: ✅ final (Plan 103.7, 2026-05-27). Реализован в Plan 103.3. V2 consume guards — Plan 103.9.
Что
Nova предоставляет три fiber-aware locking примитива:
| Тип | Назначение | Reentrant | Fairness |
|---|---|---|---|
Mutex | Взаимное исключение, baseline | ❌ (документировано) | fair FIFO default, unfair opt-in |
RwLock | Concurrent reads / exclusive write | ❌ | writer-priority default, reader-priority opt-in |
ReentrantMutex | Рекурсивный mutex для legacy-migration | ✅ | fair FIFO |
Все три типа — value types в Nova, передаются через mutable-ссылку или
heap-структуру внутри fiber-арены. Семантически это blocking coordination
primitives: вызов lock() / read() / write() при наличии contention
приостанавливает fiber (через nova_sched_park_with_unlock), не блокирует OS
thread.
Правило
AMEND (2026-07-17, lint-разкраснение W_TRY_WITHOUT_SIBLING, владелец):
Mutex.lock_for/ReentrantMutex.lock_for (ex-try_lock_for), RwLock.read_for/
RwLock.write_for (ex-try_read_for/try_write_for), Once.start/Once.start_won
(ex-try_start/try_start_won), CountDownLatch.await_for (ex-try_await_for),
Semaphore.acquire_for (ex-try_acquire_for) переименованы — сняли try_-префикс.
Правило (R3 D325, nv-coding-style §1): try_ легален ТОЛЬКО как fallible-половина
ОДНОИМЁННОГО infallible (from/try_from, D77); ни у одного из этих timeout/racy-
методов нет infallible-сиблинга с ТЕМ ЖЕ именем (lock()/read()/write()/await()/
acquire() — другая арность/семантика, не тайм-аутный/racy вариант) — соло-операция
без такого сиблинга берёт обычное имя. Сигнатуры/возвраты (bool/Option[OnceGuard])
не менялись, только имена. Все примеры/таблицы ниже в этом разделе обновлены на новые
имена; диагностика W_REALTIME_TRY_LOCK_FOR_TIMER (§ realtime/blocking ниже) СОХРАНИЛА
свой id (переименование самого id — отдельный D-амендмент, вне периметра этой правки).
1. Mutex API
module runtime.sync
/// Fair FIFO fiber-aware mutex. NOT reentrant.
/// lock() при contention: park fiber (не блокирует OS thread).
#stable(since = "0.1")
export external fn Mutex.new() -> Self
/// Unfair (LIFO-leaning) opt-in: лучший throughput на высоком contention,
/// возможна starvation. Использовать только после benchmark.
#stable(since = "0.1")
export external fn Mutex.new_unfair() -> Self
#stable(since = "0.1")
export external fn Mutex mut @lock()
#stable(since = "0.1")
export external fn Mutex mut @unlock()
#stable(since = "0.1")
export external fn Mutex mut @try_lock() -> bool
/// Попытаться получить lock в течение timeout.
/// true — acquired; false — timeout истёк.
/// Использует libuv uv_timer_t (Plan 22 / Plan 103.3 pattern).
#stable(since = "0.1")
export external fn Mutex mut @lock_for(timeout Duration) -> bool
/// Best-effort observability. НЕ atomic test-and-set — может гонка.
#stable(since = "0.1")
export external fn Mutex @is_locked() -> bool
/// PREFERRED V1 PATTERN. Closure-form: lock + defer unlock.
/// Unlock выполняется даже при panic в body.
/// V2 (Plan 103.9): тонкая обёртка над MutexGuard consume — без breaking change.
#stable(since = "0.1")
export fn Mutex mut @with_lock[R](body fn() -> R) -> R {
self.lock()
defer self.unlock()
body()
}
Unlock invariant: unlock() без предшествующего lock() — unconditional
runtime panic (через Nova_Fail_fail + nova_throw), не зависит от build mode.
2. RwLock API
/// Fiber-aware reader-writer lock. Writer-priority default (M7):
/// новый writer блокирует новых читателей → no writer starvation.
#stable(since = "0.1")
export external fn RwLock.new() -> Self
/// Reader-priority opt-in: читатели не блокируются ожидающим writer'ом.
/// Риск: writer starvation на read-heavy workloads.
#stable(since = "0.1")
export external fn RwLock.new_reader_priority() -> Self
#stable(since = "0.1")
export external fn RwLock mut @read()
#stable(since = "0.1")
export external fn RwLock mut @read_unlock()
#stable(since = "0.1")
export external fn RwLock mut @try_read() -> bool
#stable(since = "0.1")
export external fn RwLock mut @read_for(timeout Duration) -> bool
#stable(since = "0.1")
export external fn RwLock mut @write()
#stable(since = "0.1")
export external fn RwLock mut @write_unlock()
#stable(since = "0.1")
export external fn RwLock mut @try_write() -> bool
#stable(since = "0.1")
export external fn RwLock mut @write_for(timeout Duration) -> bool
/// best-effort снимок (не синхронизирован с reader_count)
#stable(since = "0.1")
export external fn RwLock @reader_count() -> int
#stable(since = "0.1")
export external fn RwLock @is_write_locked() -> bool
#stable(since = "0.1")
export fn RwLock mut @with_read[R](body fn() -> R) -> R {
self.read()
defer self.read_unlock()
body()
}
#stable(since = "0.1")
export fn RwLock mut @with_write[R](body fn() -> R) -> R {
self.write()
defer self.write_unlock()
body()
}
Writer-priority алгоритм (default):
read(): еслиwrite_locked || write_waiting→ park reader; иначе incrreader_count.write(): setwrite_waiting=true; ждатьreader_count=0 && !write_locked→ setwrite_locked=true.write_unlock():write_locked=false; если есть ожидающие writers → разбудить одного; иначе → разбудить всех readers.
Unlock invariants: read_unlock() без read(), write_unlock() без
write(), и read_unlock() после write() — unconditional runtime panic.
3. ReentrantMutex API
/// Reentrant mutex: один fiber может lock() несколько раз без deadlock.
/// Unlock требует соответствующего количества unlock() от того же fiber'а.
///
/// Use case: legacy migration, callbacks-into-locked-context.
/// Recommended default: обычный Mutex (deadlock-detection на ранней стадии).
///
/// Взаимодействие с Condvar (Plan 103.4): Condvar.wait() освобождает ВСЕ
/// уровни lock (count → 0); пробуждение re-acquires с count = 1.
/// Исходная глубина рекурсии НЕ восстанавливается.
/// Диагностика: W_REENTRANT_CONDVAR_RECOMMEND при mix.
#stable(since = "0.1")
export external fn ReentrantMutex.new() -> Self
#stable(since = "0.1")
export external fn ReentrantMutex mut @lock()
#stable(since = "0.1")
export external fn ReentrantMutex mut @unlock()
#stable(since = "0.1")
export external fn ReentrantMutex mut @try_lock() -> bool
#stable(since = "0.1")
export external fn ReentrantMutex mut @lock_for(timeout Duration) -> bool
/// Глубина рекурсии для текущего fiber'а; 0 если не locked этим fiber'ом.
#stable(since = "0.1")
export external fn ReentrantMutex @lock_count() -> int
#stable(since = "0.1")
export fn ReentrantMutex mut @with_lock[R](body fn() -> R) -> R {
self.lock()
defer self.unlock()
body()
}
Owner tracking: owner_coro = mco_running() (thread-local mco_coro*
из minicoro.h). NULL на main thread или вне mco_resume. Уникален на всём
протяжении жизни fiber’а (не переиспользуется пока mutex locked).
Unlock invariant: unlock() не от owner fiber → unconditional runtime panic.
4. C runtime layer
Все три типа аллоцируются через nova_alloc_uncollectable (Boehm
GC_malloc_uncollectable):
static inline Nova_Mutex* Nova_Mutex_static_new(void) {
Nova_Mutex* m = (Nova_Mutex*)nova_alloc_uncollectable(sizeof(Nova_Mutex));
...
}
Причина: на Windows под M:N runtime первый supervised{spawn{}} вызывает
_ensure_materialized() → nova_scope_grow (7× nova_alloc) → Boehm GC
может не видеть pointer на sync primitive, хранящийся на стеке main thread’а,
и произвести premature collection. GC_malloc_uncollectable полностью
исключает эту проблему (объект не собирается GC, но сканируется на interior
pointers).
Plan 151 (2026-06-13) — КОРНЕВОЙ фикс той же проблемы. GC_malloc_uncollectable
выше защищает ТОЛЬКО sync-примитивы; произвольные heap-объекты на main-стеке (напр.
замыкание body в supervised{spawn{ ro r = body() }}) оставались уязвимы — при ≥4
worker’ах GC во время _materialize_pool (до создания ленивой main-арены) не видел
main-стек → premature collect → closure->fn = 0 → worker зовёт NULL → RIP=0
(маскируется под «fiber stack overflow in slot 0»; диагностика — docs/dev/debugging-races.md
§2.1.1). Фикс (nova_rt/fiber_arena_win.c): main-thread
NT_TIB.StackBase фиксируется ДО создания worker-пула (nova_fiber_arena_set_main_stack,
зовётся из _materialize_pool); его committed-only регион (MEM_COMMIT && !PAGE_GUARD && !PAGE_NOACCESS) пушится в GC push_other_roots-колбэк. Обобщает решение на ВСЕ
main-stack-rooted объекты; GC_malloc_uncollectable для sync-примитивов остаётся как
defense-in-depth. Инвариант (M:N): каждый root-bearing native-стек (main + worker-
арены) ДОЛЖЕН сканироваться GC до любого STW. Дискриминаторы GC-collect-vs-overflow:
GC_DONT_GC=1 чинит; NOVA_FIBER_STACK=64MB не помогает; MAXPROCS≤3 pass / ≥4 fail.
Будущий hardening — единый global stack-registry ([M-mn-gc-root-unified-stack-registry], → Plan 144).
5. Realtime context ban
Методы lock() / read() / write() и lock_for / read_for /
write_for — запрещены в realtime { } блоках (Plan 103.6):
они могут park fiber, нарушая realtime-гарантию. try_lock() / try_read()
/ try_write() без timeout разрешены (no park, return bool немедленно).
Диагностика: E_REALTIME_VIOLATION (compile-time).
Отвергнутые альтернативы
| Альтернатива | Причина отклонения |
|---|---|
Mutex<T> data-carrying (Rust style) | M4: требует borrow checker; в Nova consume-типы (Plan 103.9) решают проблему по-другому |
Mutex poisoning (LockResult) | M5: сложность без реального преимущества в fiber-модели; Nova предпочитает явные defer panic |
| Upgradeable read lock для RwLock | Сложная семантика (deadlock-risk); отдельный future plan |
| RwLock reader-priority по умолчанию | Writer starvation в нагрузочных тестах; writer-priority = better default |
(scope, slot) как ReentrantMutex owner-id | Risk использования после fiber завершения; mco_coro* гарантированно валиден пока fiber активен |
| UUID для owner tracking | Overhead; mco_coro* проще и надёжнее в контексте Nova fiber runtime |
Связь
- D50 —
supervised,spawn; Mutex park работает внутри supervised-дерева. - D138 — M:N runtime; uncollectable alloc fix специфичен для M:N + Windows Boehm.
- D168 —
AtomicI32 для
reader_count; AtomicBool дляwrite_locked. - D171 — аналогичный паттерн uncollectable alloc для sync primitive structs.
- Plan 103.4 —
Condvartied toMutex;W_REENTRANT_CONDVAR_RECOMMEND. - Plan 103.6 —
realtime { }ban на park-ing methods. - Plan 103.7 — D169 final closure.
- Plan 103.9 —
V2:
MutexGuard consume;with_lock(fn)→ non-breaking migration path.
Эволюция
D169 введён как draft (Plan 103.3, 2026-05-26). Финализирован в Plan 103.7.
D370 (этот plan) содержит AI-first guidance: выбор Mutex vs RwLock vs ReentrantMutex по паттерну — см. decision tree «exclusive access» branch + canonical patterns 3 (producer-consumer) и 4 (read-heavy snapshot).
Отложено в Plan 103.9 (V2):
MutexGuard consumeзаменяетlock()/unlock()как primary API.with_lock(fn)становится thin wrapper над guard — user code не меняется.W_REENTRANT_CONDVAR_RECOMMENDпереходит в E_REENTRANT_CONDVAR_ERROR если статически выявимо (Plan 103.4 + checker).
D170. Coordination primitives — Semaphore / Barrier / CountDownLatch / Condvar (Plan 103.4)
Статус: ✅ final (Plan 103.7, 2026-05-27). Реализован в Plan 103.4. V2 consume guards — Plan 103.9.
Контекст
После D169 (Mutex/RwLock/ReentrantMutex), std.runtime.sync
содержит lock-family. Coordination patterns (bounded concurrency, N-party
rendezvous, one-shot signal, “wait until predicate”) требуют отдельные
примитивы. D170 закрывает industry gap vs Go (только channels), Rust
(std::sync::Barrier + tokio Semaphore), Java (полный набор), Kotlin
(только Semaphore).
API surface
Semaphore — bounded counting permits (M11)
type Semaphore /* opaque */
fn Semaphore.new(permits int) -> Self /* permits >= 0 */
fn Semaphore mut @acquire() /* parks until permit available */
fn Semaphore mut @release() /* incr permits, wake FIFO head */
fn Semaphore mut @try_acquire() -> Option[Permit] /* AMEND 2026-07-26 (№R3-ревью
владельца, D325 R3): non-parking половина пары acquire/try_acquire, Permit-guard
(@cleanup авто-release, D432). Прежние ДВЕ двери ретрактированы: bool-форма
`try_acquire() -> bool` (manual-release анти-паттерн, D9) стала приватным
мостом @try_acquire_raw; `try_acquire_permit` (R3-нарушитель: сиблинга
acquire_permit не существовало) переименован в try_acquire. */
fn Semaphore mut @acquire_for(timeout Duration) -> bool
fn Semaphore mut @acquire_n(n int) /* batch */
fn Semaphore mut @release_n(n int)
fn Semaphore @available_permits() -> int /* best-effort */
fn Semaphore mut @with_permit[R](body fn() -> R) -> R /* M15 V1 helper */
Семантика:
- Bounded: initial permits = upper bound;
release()past initial → permits растёт (Java behavior;W_SEMAPHORE_OVER_RELEASElint опционально в V2). - Fair FIFO (M6 consistency с Mutex default). Unfair вариант — не V1.
- Negative init permits → runtime panic.
with_permit(fn)— preferred V1 pattern (M15); V2 →Permit consumeguard (Plan 103.9).acquire/acquire_n/acquire_for/with_permit— park-ing methods, banned вrealtime { }(Plan 103.6 enforcement; M12).
Barrier — reusable N-party rendezvous (CyclicBarrier-style)
type Barrier /* opaque */
fn Barrier.new(parties int) -> Self /* parties >= 1 */
fn Barrier mut @wait() -> int /* arrival index 0..parties-1 */
fn Barrier mut @wait_with_action(action fn() -> ()) -> int
fn Barrier mut @wait_for(timeout Duration) -> Option[int]
fn Barrier @is_broken() -> bool
fn Barrier mut @reset()
Семантика:
- Reusable cyclic: после того как
partiesfibers вызвалиwait(), счётчик атомарно сбрасывается,generation++; следующий round начинается. - Arrival index: last-arrival fiber получает
parties-1(может выполнитьactionесли использованwait_with_action). wait_with_action(action): action выполняется last-arrival fiber’ом ВНУТРИ барьера; остальные waiters wake только после завершения action.wait_for(timeout): возвратNone⇒ barrier broken (все текущие waiters released сbroken=true).brokenstate: если any fiber в barrier interrupted/cancelled/timed out → barrier broken; все waiters просыпаются и видят broken.reset()сбрасывает broken и начинает новый round.Barrier.new(0)→ runtime panic (parties >= 1).wait/wait_with_action/wait_for— park-ing, banned вrealtime { }.
CountDownLatch — one-shot signal (Java-style)
type CountDownLatch /* opaque */
fn CountDownLatch.new(count int) -> Self /* count > 0 */
fn CountDownLatch mut @count_down() /* saturating: count==0 -> no-op */
fn CountDownLatch mut @count_down_n(n int) /* batch saturating */
fn CountDownLatch @await() /* park until count == 0 */
fn CountDownLatch @try_await() -> bool
fn CountDownLatch @await_for(timeout Duration) -> bool
fn CountDownLatch @current_count() -> int /* best-effort */
Семантика:
- Immutable initial count (safer than WaitGroup, который позволяет
add()послеwait()→ race risk). count_down()saturating: вызов когдаcount == 0— no-op (НЕ panic); Java parity.count_down_n(n):n <= 0→ no-op;n > current count→ saturates на 0.CountDownLatch.new(0)→ runtime panic (count > 0).await/await_for— park-ing, banned вrealtime { }.
Condvar — condition variable tied to Mutex (M10)
type Condvar /* opaque */
type WaitResult { Notified | TimedOut }
fn Condvar.new() -> Self
/* Mutex overload (primary): */
fn Condvar @wait(mut m Mutex)
fn Condvar @wait_for(mut m Mutex, timeout Duration) -> WaitResult
fn Condvar @wait_until(mut m Mutex, predicate fn() -> bool)
/* ReentrantMutex overload (Java-pitfall-aware): */
fn Condvar @wait(mut m ReentrantMutex)
fn Condvar mut @notify_one() /* wake FIFO head */
fn Condvar mut @notify_all() /* wake all FIFO order */
Семантика:
- Tied to Mutex (M10): wait требует уже-locked mutex (precondition runtime-
enforced unconditional throw, не debug-assert). Mutex освобождается атомарно
с парковкой (
park_with_unlockpattern); re-acquired на wake. - Spurious wakeup contract:
wait()может вернуться без notify (scheduler rebalance, M:N migration). Caller обязан использовать predicate loop илиwait_until(m, predicate)helper. wait_for(m, timeout)->WaitResult { Notified | TimedOut }: typed возврат, не bool — лучше RustResultAPI.- ReentrantMutex overload: wait освобождает ВЕСЬ recursive lock_count
(count -> 0). На wake re-acquired как
count=1(НЕ restored original count — Java pitfall: восстановление count может deadlock’нуть). Caller осведомлён черезW_REENTRANT_CONDVAR_RECOMMENDlint (type-checker hook когда inferred тип = ReentrantMutex). - FIFO wake order:
notify_one()wakes oldest waiter (fair);notify_all()wakes all в порядке регистрации. wait/wait_for/wait_until— park-ing, banned вrealtime { }.
Дизайн-решения
- M10 (Condvar tied to Mutex): type-safer чем Java
Condition(loosely tied кLock). Compiler-enforced связь through API signature. - M11 (bounded Semaphore): unbounded counting — отдельная concept, для этого
use
AtomicI32напрямую (D168). Dedicated unbounded type — over-engineering. - M15 (with_permit / with_lock — V1 helpers; consume guards — V2):
with_permit(fn)consistent сMutex.with_lock(fn)— closure-based scoping ergonomic для V1. V2 (Plan 103.9) добавляетPermit consumeguard (RAII-style);with_permitостаётся как thin wrapper, user code не меняется. - No
Phaser: Java’s Phaser over-engineered (dynamic party count, multi- phase advancement). Barrier + WaitGroup покрывают realistic use cases. - No writer-priority Condvar: Rust
parking_lot::Condvarимеетnotify_one_writersemantics — отложено в V2 если запрос. - No
Barrier.cyclic_actionчерез closure-with-effects (V1): action runs inline в last-arrival fiber какfn() -> (). Effect-typed action — V2.
Запреты / соглашения
realtime { }ban: все park-ing methods (Semaphore.acquire,Barrier.wait,CountDownLatch.await,Condvar.wait) banned внутриrealtime { }блока (Plan 103.6 type-checker enforcement; M12).Condvar.wait(reentrant_mutex)warning:W_REENTRANT_CONDVAR_RECOMMEND— рекомендует regular Mutex (Java pitfall preempted by design).- Stability:
#stable(since = "0.1")на всё.
Реализация
- Runtime:
compiler-codegen/nova_rt/sync_semaphore.h,sync_barrier.h,sync_countdown_latch.h,sync_condvar.h— per-primitive header files (отдельные отsync_primitives.h). - GC race fix:
nova_alloc_uncollectableдля all fourstatic_new()(Plan 103.3 D169 pattern — Boehm misses pointer на main stack под M:N). - FIFO waiter lists: doubly-linked, stack-allocated waiter nodes (WaitGroup precedent); dequeue под inner mutex.
- Condvar park_with_unlock: combined callback releases cv->mu AND user mutex atomically после yield (lost-wakeup fix).
- Memory ordering:
__ATOMIC_SEQ_CSTдля parked[slot] store (Plan 83.10.2 + Plan 103.4 Ф.2 fix —__ATOMIC_RELEASEна x86 компилируется в plain MOV без fence → store buffer не flush’ит). - Build:
sync_primitives.hincludessync_<primitive>.h(alphabetical parallel-merge markers — Plan 103.4 parallel agent split).
Тестовое покрытие
nova_tests/plan103_4/ — 25 tests:
- Semaphore (7): bounded_concurrency, batch_n, acquire_for_timeout, with_permit_panic_safety, no_overcommit_prop, release_more_than_acquired_neg, negative_init_permits_neg.
- Barrier (5): n_party_rendezvous, cyclic_reusable, wait_with_action, all_or_none_prop, zero_parties_neg.
- CountDownLatch (4): one_shot_signal, count_down_n, count_down_at_zero_neg, init_zero_or_negative_neg.
- Condvar (9): notify_one, notify_all, wait_for_timeout, wait_until_predicate, producer_consumer, no_lost_wakeup_prop, wait_without_lock_neg, in_realtime_neg (TODO Plan 103.6), with_reentrant_warn.
Связь
- D168 — sized atomic types (используются internal для counters).
- D169 — Mutex/RwLock/ReentrantMutex (required для Condvar).
- Plan 22 —
Duration- libuv timer (для
*_fortimeouts).
- libuv timer (для
- Plan 47 — cancel-token propagation through wait methods (V2 cancel integration).
- Plan 103.6 —
realtime { }ban enforcement. - Plan 103.7 — D170 final closure.
- Plan 103.9 —
V2:
Permit consumeдля Semaphore.
Эволюция
D170 введён как draft (Plan 103.4, 2026-05-27). Финализирован в Plan 103.7.
D370 (этот plan) содержит AI-first guidance: rate-limited workers (Semaphore), N-party rendezvous (Barrier/CountDownLatch), wait-until-predicate (Condvar) — см. canonical patterns 3 и 5 + decision tree lower branches.
Отложено в Plan 103.9 (V2):
Permit consumeguard заменяетacquire()/release()как primary API для Semaphore.with_permit(fn)остаётся thin wrapper над guard.- Barrier/CountDownLatch/Condvar — НЕ мигрируются на consume guards (M16: stateless по природе).
D172. #realtime / #blocking attribute-only model + sync-class annotations (Plan 103.6, amended Plan 113)
Статус: ✅ final (Plan 103.7, 2026-05-27). Amended by Plan 113 (2026-05-29): block-forms removed,
#realtime_safe→#realtime,#blocking fnreplacesblocking { }. V2 inference — Plan 103.8.
Что
Plan 113 (2026-05-29) — attribute-only simplification. Две исходных формы заменены единым механизмом — attribute на функции:
#realtime fn— callee guarantee: тело fn может вызывать только другие#realtimefns/primitives. GC-pause-free, scheduler-interaction-free. Caller unrestricted — любая fn свободно вызывает#realtimefn. До Plan 113:realtime { }block (D64 — retracted) и#realtime_safeSyncClass.#realtime nogc fn— амендмент №273 (2026-08-02): опциональныйnogc-модификатор на#realtime fn, жёсткий режим. Всё из#realtime(suspend-ban) плюс запрет вызовов, аллоцирующих на managed heap (Vec[T].new/.of/.with_capacity,HashMap.new,StringBuilder.new,str.from, …) — см. §7 ниже. Синтаксис и намерение — прямое продолжение retracted-блокаrealtime nogc { }(D64: «никаких аллокаций, кроме как в region’е»); D172 (Plan 103.7 final, Plan 113 amend) описывал только#realtime/#blocking/#thread_affine— атрибутnogcпарсился и частично enforced’ился (Plan 16nogc_blacklisted_call), но не был задокументирован в этом D-блоке. №273 закрывает и документационный разрыв, и найденную дыру enforcement’а (см. §7). До Plan 113:realtime nogc { }block (D64 §«Опционально — запрет аллокации»).region { ... }(D6) исключение из запрета — в текущем компиляторе не реализовано (regionне парсится как block-конструкция), поэтому исключений из nogc-запрета сейчас нет никаких.#blocking fn— runtime threadpool offload: вся fn выполняется на libuv threadpool worker, fiber паркуется до завершения. До Plan 113:blocking { }block (D50 §4, Plan 83.3).#thread_affine extern fn— M:N-небезопасный лист (A-V10, D441 §5 amend, реестр 221.1 №167): маркируетextern fn, привязанную к ОС-потоку, который её впервые вызвал (thread-local состояние / нереэнтерабельный C-side handle) — семантика «привязан к ОС-потоку», НЕ «медленный» (#blocking— другая ось). Транзитивно поднимается по именованному графу вызовов (fn, зовущая лист прямо или транзитивно В СВОЁМ файбере, сама становится thread-affine); вызов на границеspawn/detach/parallel for—E_THREAD_AFFINE_IN_FIBER(см. D441 §5).
Аналогия (Plan 113): #realtime — как C++ constexpr: вызываема из
runtime-кода, но внутри только constexpr/realtime ops. Callee guarantee,
не caller constraint.
Исходные execution-context блоки (до Plan 113):
realtime { }(D64) — (retracted, Plan 113) заменён на#realtime fn.blocking { }(D50 §4, Plan 83.3) — (retracted, Plan 113) заменён на#blocking fn.
Проблема (до Plan 103.6)
Enforcement realtime/blocking-ограничений был hardcoded в emit_c.rs:
// До Plan 103.6 (hardcoded match-список)
fn is_realtime_blocking(recv: &str, method: &str) -> bool {
matches!((recv, method),
("Mutex", "lock") | ("Mutex", "wait") | ("RwLock", "read") | ...)
}
Проблемы:
- Список не синхронизирован с реальной реализацией sync-примитивов.
- Добавление нового примитива требует патча compiler (не spec.nv).
- Нет различия между
#parks(park fiber) и#wakes(wake other fibers). - Нет механизма для user-определённых функций.
Решение: annotation-driven sync-class
Plan 103.6 вводит SyncClass attribute system:
§1. Annotations (bare #-attributes)
#realtime // Leaf op: no scheduler interaction. Safe in realtime{} and blocking{}.
#parks // May park the current fiber. Forbidden in realtime{}. Error in blocking{}.
#wakes // May wake another fiber (scheduler signal). Forbidden in realtime{}.
Аннотации ставятся перед export external fn в .nv файлах:
#parks
#stable(since = "0.1")
export external fn Mutex mut @lock()
#realtime
#stable(since = "0.1")
export external fn Mutex @try_lock() -> bool
#wakes
#stable(since = "0.1")
export external fn Mutex mut @unlock()
§2. Матрица sync-class
| Примитив | Метод | SyncClass | realtime{} | blocking{} |
|---|---|---|---|---|
Mutex | lock() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
Mutex | try_lock() | #realtime | ✅ | ✅ |
Mutex | lock_for(d) | #realtime ¹ | ⚠️ W_REALTIME_TRY_LOCK_FOR_TIMER | ✅ |
Mutex | unlock() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ✅ |
RwLock | read() / write() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
RwLock | try_read() / try_write() | #realtime | ✅ | ✅ |
RwLock | unlock_read() / unlock_write() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ✅ |
Semaphore | acquire() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
Semaphore | try_acquire() | #realtime | ✅ | ✅ |
Semaphore | release() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ✅ |
Barrier | wait() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
CountDownLatch | await() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
CountDownLatch | count_down() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ✅ |
Condvar | wait(m) | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
Condvar | notify_one() / notify_all() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ⚠️ W_BLOCKING_NOTIFY_RISK ² |
WaitGroup | wait() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
WaitGroup | done() | #wakes | ❌ E_REALTIME_SYNC_WAKE | ✅ |
OnceCell[T] | get_or_init(f) | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
OnceCell[T] | get() / set(v) | #realtime | ✅ | ✅ |
Lazy[T] | force() | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
Lazy[T] | is_forced() | #realtime | ✅ | ✅ |
Once | call_once(f) | #parks | ❌ E_REALTIME_SYNC_PARK | ❌ E_BLOCKING_SYNC_PARK |
Once | is_completed() | #realtime | ✅ | ✅ |
fence(ord) | — | #realtime | ✅ | ✅ |
AtomicX.* | все методы | #realtime | ✅ | ✅ |
¹ lock_for является #realtime технически (не парков fiber),
но использует libuv timer → W_REALTIME_TRY_LOCK_FOR_TIMER предупреждает
об overhead таймера внутри realtime-блока.
² notify_one/notify_all в blocking{} работает (wake технически возможен),
но wake другого nova-fiber изнутри threadpool worker семантически сомнительен
→ W_BLOCKING_NOTIFY_RISK (design decision: prefer fiber-native patterns).
§3. Error codes
| Код | Уровень | Условие |
|---|---|---|
E_REALTIME_SYNC_PARK | error | #parks-метод вызван внутри realtime { } |
E_REALTIME_SYNC_WAKE | error | #wakes-метод вызван внутри realtime { } |
E_REALTIME_NESTED_SYNC_VIA_FN | error | user-fn с #parks-аннотацией вызвана из realtime { } |
E_BLOCKING_SYNC_PARK | error | #parks-метод вызван внутри blocking { } |
E_REALTIME_NOGC_ALLOC | error | allocating-вызов (blacklist, §7) внутри #realtime nogc fn (амендмент №273) |
E_BLOCKING_NOGC_ALLOC | error | allocating-вызов внутри blocking { } body (V1 leaf-contract, Plan 83.3) — код зарезервирован, ветка сейчас недостижима: block-form blocking { } retracted Plan 113, #blocking fn не заводит этот флаг (см. §7) |
W_REALTIME_TRY_LOCK_FOR_TIMER | warning | Mutex.lock_for в realtime { } |
W_BLOCKING_NOTIFY_RISK | warning | #wakes-метод в blocking { } |
§4. User-defined function propagation (V1)
В V1 (Plan 103.6 / Plan 113) propagation только explicit:
#parks
fn my_critical_wait() {
mutex.lock() // внутри — парков, поэтому fn annotated #parks
}
#realtime
fn audio_callback() {
my_critical_wait() // ❌ E_REALTIME_NESTED_SYNC_VIA_FN
}
V1 не поддерживает автоматический inference (transitive propagation): если fn A
вызывает fn B которая #parks, но A не annotated — вызов A из #realtime fn
не даёт ошибку. V2 (Plan 103.8) добавит inference-based propagation.
§5. Unseen / uninstrumented methods
Методы без явной аннотации (#parks/#wakes/#realtime) внутри
realtime{} консервативно трактуются как #parks (worst-case):
[E_REALTIME_SYNC_PARK] `T.method()` has no sync annotation and is conservatively
treated as park-ing; forbidden inside realtime{}.
Add #parks / #realtime annotation to declare intent.
Это предотвращает silent miscompilation при добавлении новых методов без аннотации.
§6. Implementation (V1, updated Plan 113)
Compiler-side:
SyncClassenum в AST:Realtime | Parks | Wakes(Plan 113:RealtimeSafe→Realtime)ContractAttrs.sync_class: Option<SyncClass>— parsed из#realtime/#parks/#wakesRealtimeAttrenum наFnDecl— body-restriction enforcement для#realtime fnbodiesCEmitter.in_realtime: bool/CEmitter.in_blocking: bool— flags set при входе в fn bodiesmono_fn_declsрасширен: non-generic#parks-annotated fns хранятся для lookup в emit_call- Generic-type methods (OnceCell[T], Lazy[T]) — проверяются в
generic_type_methodsdispatch
Runtime-side:
- Нет runtime overhead: все проверки compile-time.
nova_fn_fence/ атомарные операции — безусловно safe (нет park/wake).#blocking fn— codegen wrap’ает вызов вuv_queue_work(Plan 113, Ф.3).
§7. #realtime nogc fn — no-alloc restriction (amend №273, 2026-08-02)
Механизм — hardcoded call-name blacklist, НЕ call-graph анализ:
nogc_blacklisted_call (compiler-codegen/src/types/mod.rs, checker-канал,
CapabilityCtx::check_capabilities_at) matches’ит Type.method пары
известных allocating-конструкторов ([]T.new/.with_capacity/.of,
HashMap/Set/Vec/Deque/LinkedList/Lru/BloomFilter.new/.with_capacity/.of,
StringBuilder/WriteBuffer/ReadBuffer.new/.with_capacity/.from,
Channel.new/.with_capacity, str.from). Внутри #realtime nogc fn вызов
одного из них → [E_REALTIME_NOGC_ALLOC].
Находка №273 (2026-08-02): до этого амендмента список matches’ил
.new/.with_capacity, но не .of — вариадик-литерал-конструктор,
ставший каноническим способом строить Vec[T] из литерала (D259 amend);
Vec[int].of(1, 2, 3) внутри #realtime nogc fn проходил nova check
молча. Хуже: сама проверка вообще не доходила ни до какого имени, если
receiver вызова нёс explicit generic type argument — Vec[int].new()
парсится как Member{obj: TurboFish{base: Ident("Vec"), ..}, name: "new"}
(D38 turbofish), а path-extraction в check_capabilities_at не разворачивал
TurboFish, попадая в defensive _ => return — silently skip’ая все
capability-проверки этой функции (не только nogc-alloc, но и forbid/
effect-checks) для любого вызова с explicit generic-типизированным
receiver’ом. Оба дефекта закрыты в одном слиянии — детали в
nogc_blacklisted_call’s doc-comment (types/mod.rs) и check_capabilities_at.
Известные пределы (documented risk, аналогично blocking { } V1
leaf-контракту §выше):
- Не call-graph. Если
fn f()без#realtime/#realtime nogcannotation сама вызывает allocating-конструктор, а#realtime nogc fnвызываетf()— не ловится: нет transitive inference, тот же V1-предел, что и#parks-propagation (§4). Честная альтернатива — call-graph may-GC-анализ (Plan 144.0,nova gc-effect-analyze,codegen/may_gc.rs,MayGcSet), но он рассчитан на пост-mono codegen-tier, другую фазу пайплайна, чем pre-mono checker-walk; интеграция — отдельная задача, вне периметра №273. - User-defined record-конструкторы (
Foo.new()) не покрыты: codegen всегда heap-боксит record-литералы, так что фактически любой record-литерал «аллоцирующий», но detection требует bigger inference — сейчас флагуются только статические factory-методы известных типов. Vec.ofallocates via variadic arg packing, не через телоof(export fn Vec[T].of(...args []T) -> Self => args— тело тривиально); блэклист матчит по имени call-сайта, поэтому это не проблема, но подчёркивает: список — по callee-имени, не по анализу тела callee.region { ... }-исключение (D6) не реализовано —regionне парсится как block-конструкция в текущем компиляторе (см.#realtime nogc fnв «Что» выше), поэтому сейчас уnogcнет способа явно разрешить arena-аллокацию внутри своего тела.
Правило
- Annotate все external fn в
.nvstdlib с#parks/#wakes/#realtime. - Compile-time enforcement:
in_realtime/in_blockingflags в CEmitter. - Conservative default: unannotated method inside realtime{} → E_REALTIME_SYNC_PARK.
- User fns: explicit
#parksannotation triggers E_REALTIME_NESTED_SYNC_VIA_FN. - lock_for:
#realtime(no park) + W_REALTIME_TRY_LOCK_FOR_TIMER (timer overhead).
Тесты
Positive (10): realtime_{atomic_load,atomic_fetch_add,mutex_try_lock, semaphore_try_acquire,lazy_is_forced,oncecell_get,oncecell_set_first_call,fence}ok, blocking{atomic_fetch_add,mutex_unlock}_ok
Negative (14): realtime_{mutex_lock,rwlock_read,rwlock_with_write,barrier_wait, condvar_wait,countdown_await,semaphore_acquire,lazy_force,once_call_once, oncecell_get_or_init}neg, realtime_via_user_fn_neg, blocking{mutex_lock,condvar_wait}neg, realtime{lock_for_zero_warn,mutex_lock_for_neg} (warnings)
Связь
- D64 —
realtime { }semantics (GC-pause-free, no scheduler yield) - D50 —
blocking { }semantics (threadpool offload) - D168 — AtomicX ops: все
#realtime - D169 — Mutex/RwLock: lock →
#parks, try_lock →#realtime, unlock →#wakes - D170 — Coordination primitives sync-class matrix
- D171 — Once/OnceCell/Lazy: force/get_or_init/call_once →
#parks, is_*/get/set →#realtime - Plan 103.8 — V2: transitive
#parksinference (planned)
Эволюция
D172 введён как draft (Plan 103.6, 2026-05-27). Финализирован в Plan 103.7.
Amended Plan 113 (2026-05-29):
#realtime_safe→#realtime(rename SyncClass).realtime { }/blocking { }block forms retracted — заменены на#realtime fn/#blocking fn.#blocking fn— fn-level threadpool offload (вся fn выполняется на threadpool).#realtime fn— callee guarantee model: caller unrestricted; body restriction only.- D64 retracted (block-form removed); D50 §4 blocking-block removed.
Отложено в Plan 103.8 (V2 sync propagation):
- Автоматический inference: если
fn Aвызывает#parks-fn, A также помечается#parks. - LSP integration: hover shows sync-class; quick-fix добавляет
#parksannotation. - Полный propagation-граф: транзитивное закрытие через call graph.
Amended №273 (2026-08-02, [M-realtime-nogc-silent-no-enforce]): #realtime nogc fn документирован явно (§«Что» + §7) — раньше D172 описывал только
#realtime/#blocking/#thread_affine, хотя nogc-модификатор парсился
и частично enforced’ился со времён Plan 16 (пробел документации, унаследованный
от retracted-блока D64). Закрыты два enforcement-дефекта одновременно: (1)
nogc_blacklisted_call не matches’ил .of (D259 amend variadic-конструктор);
(2) path-extraction в check_capabilities_at не разворачивал TurboFish
(explicit generic type argument на receiver’е, D38), из-за чего любая
capability-проверка (не только nogc) silently skip’алась для вызовов вида
Vec[int].method(). Новый код E_REALTIME_NOGC_ALLOC (+ зарезервированный,
сейчас недостижимый E_BLOCKING_NOGC_ALLOC).
D370. AI-first guidance — sync-primitive decision tree (Plan 103.7)
Статус: ✅ final (Plan 103.7, 2026-05-27). Новый D-блок; нет предшествующего draft.
Зачем
Nova runtime.sync содержит 12+ sync-примитивов. Выбор правильного примитива
для конкретной задачи — типичный вопрос разработчика (и AI-агента, генерирующего
код). D370 формализует decision tree и canonical patterns — официальный
ответ Nova на вопрос «что использовать для X». Это Nova edge: ни один другой
язык не имеет in-spec guidance на этом уровне детализации.
Правило: Decision tree
Нужно ли разделить mutable state между fiber'ами?
│
├── НЕТ → не нужен sync. Используй channel + actor pattern (D79).
│ Пример: counter_actor(input Channel[Msg]) с match msg { ... }
│
├── ДА, exactly-once init:
│ ┌── stateless action (no value) → Once.call_once(fn) [D171]
│ ├── value-capturing (return T) → OnceCell[T].get_or_init(fn)
│ └── auto-init on first access (wrap T) → Lazy[T].new(fn) + .force()
│
├── ДА, counter / numeric stat:
│ ┌── single counter / sequence number → AtomicI64.fetch_add(delta, Relaxed)
│ ├── max/min tracking → AtomicI64.fetch_max(v, Relaxed)
│ └── bitset / flags → AtomicU32.fetch_or/fetch_and(bits, SeqCst)
│
├── ДА, one-shot ownership / «первый побеждает»:
│ ┌── bool flag (first caller wins) → AtomicBool.swap(true) == false → winner
│ └── pointer publish (first-to-publish) → см. ниже (AtomicPtr снят D426)
│ `AtomicPtr` снят (Plan 207, D426) — до `AtomicPtr[T]` (Plan 103.7)
│ использовать `Mutex`/`OnceCell[T]` для publish-once указателя;
│ голый int-адрес без GC-root — только через `unsafe`-эскейп.
│
├── ДА, exclusive access к complex state:
│ ┌── short critical section, general → Mutex + with_lock(fn) [D169]
│ ├── read-heavy, occasional writes → RwLock + with_read(fn) / with_write(fn)
│ └── recursive callbacks (migration path) → ReentrantMutex (opt-in; prefer Mutex)
│
├── ДА, bounded concurrency / rate limit → Semaphore.new(N) + with_permit(fn) [D170]
│
├── ДА, N-party rendezvous (epoch sync):
│ ┌── reusable (cyclic, round-based) → Barrier.new(N) + wait()
│ └── one-shot signal (latch-style) → CountDownLatch.new(N) + count_down() / await()
│
└── ДА, «wait until predicate» (park until condition):
→ Mutex + Condvar + wait_until(m, predicate) [D170]
Canonical patterns (≥5)
Pattern 1. Counter (AtomicI64)
Сценарий: подсчёт событий/запросов между fiber’ами.
import runtime.sync.{AtomicI64, MemOrdering}
ro requests = AtomicI64.new(0)
// В любом fiber'е:
requests.fetch_add(1, MemOrdering.Relaxed) // счётчик не синхронизирует другие данные
// Чтение для метрики (periodic reporter fiber):
ro total = requests.load(MemOrdering.Relaxed)
Почему Relaxed: счётчик событий не устанавливает happens-before с другими данными — Relaxed достаточен и эффективен (на x86 дешевле SeqCst-store).
Anti-pattern: Mutex.with_lock { counter = counter + 1 } — избыточно,
serializes всех readers и writers. Atomic — wait-free, без парковки fiber.
Pattern 2. One-shot init (Once / OnceCell / Lazy)
Сценарий: ленивая инициализация глобального ресурса (connection pool, config, singleton) — ровно один раз при первом обращении.
import runtime.sync.{Lazy}
// Глобальный Lazy: init-closure известен заранее
ro db_pool = Lazy.new(|| DbPool.connect(config.db_url()))
fn handle_request(req Request) {
ro pool = db_pool.force() // безопасно из любого fiber'а; init = once
pool.execute(req.query)
}
Если возвращаемое значение неизвестно в точке объявления (нужен runtime-аргумент):
import runtime.sync.{OnceCell}
ro config_cell: OnceCell[Config] = OnceCell.new()
fn init_config(path str) {
config_cell.set(Config.load(path)) // idempotent; первый вызов устанавливает
}
fn get_config() -> Config {
config_cell.get_or_init(|| Config.default())
}
Anti-pattern (DCL — Double-Checked Locking):
// ❌ ОПАСНО: race condition без Acquire/Release fence
if !initialized {
mutex.lock()
if !initialized {
value = expensive_init()
initialized = true // store может появиться до value готово (ARM)
}
mutex.unlock()
}
Используй Once / Lazy / OnceCell — они содержат корректные
__ATOMIC_RELEASE / __ATOMIC_ACQUIRE барьеры (D167 contract, D171).
Pattern 3. Producer-consumer bounded buffer (Mutex + Condvar)
Сценарий: типизированная очередь с backpressure, когда нативный Channel[T]
недостаточен (нужен custom flush, batch-drain, priority и т.д.).
import runtime.sync.{Mutex, Condvar}
ro mu = Mutex.new()
ro not_full = Condvar.new()
ro not_empty = Condvar.new()
ro buffer: []Item = []
fn producer(item Item) {
mu.with_lock { ||
not_full.wait_until(mu, || buffer.len() < MAX_SIZE)
buffer.push(item)
not_empty.notify_one()
}
}
fn consumer() -> Item {
mu.with_lock { ||
not_empty.wait_until(mu, || buffer.len() > 0)
ro item = buffer.pop()
not_full.notify_one()
item
}
}
Spurious wakeup: wait_until(mu, predicate) — всегда использовать predicate
loop (встроен в wait_until). Bare condvar.wait(mu) без предиката — уязвим
к spurious wakeups (D170 §spurious wakeup contract).
Anti-pattern: Если backpressure нативный и не нужна custom логика — Channel[T] лучше в 90% случаев:
// ✅ Проще: нативный backpressure через Channel (D91 capability-split)
ro (tx, rx) = Channel.new[Item](MAX_SIZE)
// producer: tx.send(item)
// consumer: rx.recv()
Pattern 4. Read-heavy snapshot (RwLock)
Сценарий: структура данных часто читается (N readers), редко обновляется (1 writer). Пример: конфигурация, routing table, кэш.
import runtime.sync.{RwLock}
ro config_lock = RwLock.new()
// config хранится снаружи (heap-структура, доступ через mutable ref)
fn read_config() -> str {
config_lock.with_read { ||
config.value // много concurrent readers без блокировки
}
}
fn update_config(new_value str) {
config_lock.with_write { ||
config.value = new_value // эксклюзивный доступ
}
}
Почему writer-priority (M7): default RwLock блокирует новых readers при ожидающем writer’е → no writer starvation на read-heavy workloads.
Anti-pattern: Mutex вместо RwLock на read-heavy data:
// ❌ Sub-optimal: serializes ALL readers
mutex.with_lock { || config.value } // только один reader за раз
На read-heavy data RwLock даёт N-кратное ускорение (N = кол-во читателей).
Pattern 5. Rate-limited workers (Semaphore)
Сценарий: ограничить количество одновременно выполняемых операций (N concurrent HTTP-запросов, N worker’ов к базе данных и т.д.).
import runtime.sync.{Semaphore}
ro concurrency_limit = Semaphore.new(MAX_CONCURRENT)
fn handle_request(req Request) {
concurrency_limit.with_permit { || // parks если MAX_CONCURRENT уже запущено
process(req)
}
// permit автоматически освобождён после with_permit
}
Batch acquire: если одна операция потребляет N permits (напр., bulk-insert):
concurrency_limit.acquire_n(batch_size)
defer concurrency_limit.release_n(batch_size)
do_bulk_work()
Anti-pattern (token channel):
// ❌ Работает, но verbose, intent не очевиден + capability-split удваивает шум
ro (tok_tx, tok_rx) = Channel.new[unit](MAX_CONCURRENT)
for _ in 0..MAX_CONCURRENT { tok_tx.send(()) }
fn handle_request(req Request) {
tok_rx.recv() // acquire
process(req)
tok_tx.send(()) // release
}
Semaphore выражает intent явно; Channel как семафор — workaround.
Правило выбора ordering (supplement к D167)
| Задача | Рекомендуемый ordering |
|---|---|
| Счётчик событий, метрики | Relaxed (нет happens-before требований) |
| Публикация данных (writer) | Release (гарантирует видимость записей) |
| Чтение опубликованных данных (reader) | Acquire (syncs с Release) |
| RMW в tight loop (retry CAS) | Release на success; Relaxed на failure |
| Любые случаи (safe default) | SeqCst (дороже, но всегда корректно) |
| spin/global coordination flag | SeqCst (total order требуется) |
Default = SeqCst (D167 M1): если не уверен — SeqCst всегда корректен. Оптимизация на Relaxed/Acquire/Release — только после профилирования.
Anti-patterns (сводная таблица)
| Anti-pattern | Проблема | Решение |
|---|---|---|
Mutex.with_lock { counter += 1 } | Overkill для счётчика; parks fiber | AtomicI64.fetch_add(1, Relaxed) |
DCL без Once/Lazy | Race condition на ARM (store ordering) | Once.call_once / Lazy.new(fn) |
Channel[unit] как semaphore | Verbose; intent не очевиден | Semaphore.new(N).with_permit(fn) |
Mutex вместо RwLock на read-heavy | Serializes всех readers | RwLock.with_read / with_write |
condvar.wait(mu) без predicate | Spurious wakeup UB | condvar.wait_until(mu, predicate) |
ReentrantMutex по умолчанию | Скрывает re-entrancy bugs | Mutex default; ReentrantMutex opt-in |
Mutex в realtime { } | E_REALTIME_SYNC_PARK | AtomicX ops (#realtime) |
Mutex в blocking { } (lock) | E_BLOCKING_SYNC_PARK | Restructure: lock вне blocking, pass result |
Связь
- D79 — Channels (actor-model alternative к shared state); decision tree первая ветвь.
- D167 — MemOrdering; ordering supplement таблица основана на D167 contract.
- D168 — Atomic types; counter/CAS/swap patterns.
- D169 — Mutex/RwLock/ ReentrantMutex; exclusive-access branch + Patterns 3/4.
- D170 — Semaphore/Barrier/CountDownLatch/Condvar; rate-limit + rendezvous + predicate-wait branches.
- D171 — Once/OnceCell/Lazy; exactly-once init branch + Pattern 2.
- D172 — realtime/blocking sync-class; anti-pattern Mutex-in-realtime пункт.
Эволюция
D370 введён в Plan 103.7 как новый D-блок (нет предшествующего draft). Контент разработан для AI-readability: decision tree структурирован для LLM-навигации (иерархические ветви с explicit mapping). Canonical patterns содержат Nova-код с комментариями и anti-pattern сравнением.
Возможные расширения в будущих plans:
- Pattern 6: distributed counter через
AtomicI64+ periodic aggregation. - Pattern 7: async-safe init с
cancel-shielding(Plan 100.4.2 integration). - D174 (Plan 103.9): consume-guards pattern (когда Permit consume > with_permit).
D174. Sync primitives consume integration (Plan 103.9)
Статус: ✅ final (Plan 103.9, 2026-05-27). V2 guard-returning API. AMENDED 2026-08-01 (owner decision, срочный пакет звучности mut/захватов, [M-router-handler-mut-capture-escape-soundness] §4):
Mutex/RwLock’s lock-acquire/lock-release API (@lock,@unlock,@try_lock,@lock_for,@with_lock,@read,@read_unlock,@try_read,@read_for,@write,@write_unlock,@try_write,@write_for,@with_read,@with_write) DROPS themutreceiver → ro receiver (fn Mutex mut @lock()→fn Mutex @lock()). Rationale (rustc-эталон, [feedback-rustc-as-reference]): real Rust’sMutex::lock(&self)takes a SHARED reference — the whole point ofMutex/RwLockis interior mutability THROUGH the opaque handle (Mutex(*()),#share-vouched, D415 §2), not through the Nova binding — mut-viral receiver was a category error the checker never caught (Mutex(*())’s own field is a raw pointer, not a value — “mutating” through it needs nomut @any more than mutating a heap-record’s CONTENT needs amutreceiver, D246 B). This mut-virality was the ROOT CAUSE of everymut lock = @lockfield-launder workaround innova-polaris/src/metrics.nv([M-router-handler-mut-capture-escape-soundness]§2 field-launder canal, closed separately in02-types.mdD246 §72-amendment) — anMetricsRegistry-method declaredfn MetricsRegistry @method(...)(ro receiver, D176-default) could not callself.lock.lock()directly while@lockrequiredmut, forcing the launder. Not migrated (out of this window’s scope, no live launder-forcing repro found):Semaphore/Once/ReentrantMutex/Condvar— same rustc-analogy rationale would apply, but theirmut @-receiver methods are NOT themetrics.nvroot cause and were left as a documented follow-up (do NOT assume covered; audit before relying on ro-receiver there).
Зачем
V1 sync API (D169–D171) использует lock()/unlock() pair — API without
static enforcement. Два класса ошибок не обнаруживаются компилятором:
- Забытый unlock: fiber паркуется навсегда (deadlock) или ресурс утечёт.
- Double unlock: UB; Nova_Mutex состояние corrupted.
V2 (D174) применяет Plan 100 consume-type mechanism (D131–D166) к sync
примитивам: lock() возвращает MutexGuard consume — linear type, must-be-consumed.
Компилятор статически обнаруживает:
- забытый unlock = E_CONSUME_NOT_CONSUMED (D133);
- double unlock = E_CONSUMED_AFTER_USE (D133);
- утечку guard в другой fiber = E_CONSUME_CROSS_FIBER (D157).
Правила API (Guard-returning contract)
Mutex V2
| Метод | Сигнатура | Примечание |
|---|---|---|
lock() | Mutex @lock() -> MutexGuard consume | Parks; returns guard (D174 amend 2026-08-01: ro receiver) |
⚠️ ПЕРЕКРЫТО D445 §616 (пометка 2026-08-17 по аудиту самосогласованности). Форма
-> consume Tв позиции ВОЗВРАТА снята ЦЕЛИКОМ — «не переопределена, а удалена»:E_RETURN_CONSUME_PREFIX_RETRACTEDдля префиксной иE_RETURN_CONSUME_POSTFIX_RETRACTEDдля постфиксной (№301). Пять носителей вstd/src/runtime/sync.nvпереписаны на голый-> MutexGuardтем же слиянием. Класс держится стражемcheck-retracted-param-form(храповик осадка по зонам). Текст выше сохранён как история. |MutexGuard.unlock()|MutexGuard @unlock(consume self)| Consumes guard; wakes next | |unlock()(bare) |Mutex @unlock()| Deprecated V1;W_BARE_UNLOCK_DEPRECATED(D174 amend: ro receiver) | |with_lock(fn)|Mutex @with_lock[R](body fn() -> R) -> R| Thin wrapper; backward compat (D174 amend: ro receiver) |
C mangling (Plan 100.6 D164):
MutexGuard.unlock(consume self)→Nova_MutexGuard_consume_unlockMutex.lock()→Nova_Mutex_method_lock(returnsNova_MutexGuard*)
RwLock V2
| Метод | Сигнатура | Примечание |
|---|---|---|
read() | RwLock @read() -> ReadGuard consume | Parks; returns read guard (D174 amend: ro receiver) |
write() | RwLock @write() -> WriteGuard consume | Parks; returns write guard (D174 amend: ro receiver) |
ReadGuard.unlock() | ReadGuard @unlock(consume self) | Consumes guard; wakes if needed |
WriteGuard.unlock() | WriteGuard @unlock(consume self) | Consumes guard; wakes next |
read_unlock() (bare) | RwLock @read_unlock() | Deprecated V1 (D174 amend: ro receiver) |
write_unlock() (bare) | RwLock @write_unlock() | Deprecated V1 (D174 amend: ro receiver) |
with_read(fn) | RwLock @with_read[R](...) -> R | Thin wrapper; backward compat (D174 amend: ro receiver) |
with_write(fn) | RwLock @with_write[R](...) -> R | Thin wrapper; backward compat (D174 amend: ro receiver) |
Semaphore V2
| Метод | Сигнатура | Примечание |
|---|---|---|
acquire() | Semaphore mut @acquire() -> Permit consume | Parks; returns permit |
Permit.release() | Permit @release(consume self) | Consumes permit; wakes next waiter |
release() (bare) | Semaphore mut @release() | Deprecated V1 |
with_permit(fn) | Semaphore mut @with_permit[R](...) -> R | Thin wrapper; backward compat |
Once V2
| Метод | Сигнатура | Примечание |
|---|---|---|
start() | Once mut @start() -> Option[OnceGuard consume] | Nova body; Some = won race |
OnceGuard.commit() | OnceGuard @commit(consume self) | Once → DONE; wakes waiters |
OnceGuard.abort() | OnceGuard @abort(consume self) | Once → POISONED; wakes waiters (re-panic on resume) |
call_once(fn) | Once mut @call_once(body fn() -> ()) | V1 external; kept as-is |
run() (bare) | Once mut @run() -> bool | Deprecated V1 |
done() (bare) | Once mut @done() | Deprecated V1 |
start() is implemented as a Nova body:
export fn Once mut @start() -> Option[OnceGuard consume] {
if self.start_won() {
Some(self.make_guard())
} else {
None
}
}
Where start_won() is the internal external fn (Nova_Once_method_start_won = alias to run()),
and make_guard() allocates the guard heap object (Nova_Once_method_make_guard).
Guard type declarations
All 5 guard types are consume record types with a single ptr int field (opaque pointer to the owning primitive):
type MutexGuard consume { ptr int } // → Nova_MutexGuard { nova_int ptr; }
type ReadGuard consume { ptr int } // → Nova_ReadGuard { nova_int ptr; }
type WriteGuard consume { ptr int } // → Nova_WriteGuard { nova_int ptr; }
type Permit consume { ptr int } // → Nova_Permit { nova_int ptr; }
type OnceGuard consume { ptr int } // → Nova_OnceGuard { nova_int ptr; }
C struct definitions live in compiler-codegen/nova_rt/sync_primitives.h (Plan 103.9 section).
They are listed in RUNTIME_DEFINED_TYPES in emit_c.rs to prevent duplicate struct emission.
Decisions
M-D174-1. Opaque ptr field: guard stores int (= nova_int = int64_t),
cast from pointer. Avoids exposing internal Nova_Mutex/Nova_RwLock C types to Nova type system.
Safe: intptr_t can hold any pointer on LP64 / LLP64.
M-D174-2. Drop without explicit consume → ERROR. The consume-checker enforces
explicit call to unlock() / release() / commit() / abort(). No implicit
RAII — unlike Rust Drop. This makes the contract explicit and visible in code.
M-D174-3. with_lock(fn) etc. preserved. with_lock remains the recommended
pattern for most use cases (#parks + panic-safe). Guard form (consume g = mu.lock())
is for advanced control (cross-scope unlock, conditional release, etc.).
M-D174-4. try_lock() -> bool kept as V1. To avoid breaking regression tests,
Mutex.try_lock() retains bool return type in this iteration. Guard-returning
try_lock_guard() -> Option[MutexGuard consume] is a future follow-up (Plan 103.9 V2.1).
M-D174-5. Bare unlock deprecated, not removed. Edition 0.2: #deprecated warning.
Edition V3 (future): removal candidate. Giving users migration runway via with_lock
wrappers which continue to work without modification.
M-D174-6. Atomics NOT migrated. M16 from Plan 103 master: AtomicX types are shared-state primitives (multiple concurrent readers/writers), not resources. consume semantics require single-owner transfer — incompatible with Atomic’s sharing model.
M-D174-7. OnceGuard.abort() → POISONED. When the winning fiber aborts
initialization, Once transitions to POISONED (not back to NEW). Subsequent callers
of start() / call_once() re-panic with OncePoisoned. Rationale: abort
means the resource initialization failed — retrying typically fails again for the same reason.
If retry-after-failure is needed, use OnceCell[T] which allows re-initialization after take().
Backward compatibility
V1 patterns continue to work without modification:
// V1 (still works, bare unlock is #deprecated warning):
mu.lock()
defer mu.unlock() // deprecated warning
// V1 with_lock (still works, now thin wrapper over guard):
mu.with_lock { || critical_section() }
// V2 (explicit guard):
consume g = mu.lock()
defer g.unlock()
Связь
- D131-D166 — Plan 100 consume foundation: D133 (not-consumed E), D157 (cross-fiber), D164 (mangling).
- D169 — V1 Mutex/RwLock contract (updated).
- D170 — V1 Semaphore (updated).
- D171 — V1 Once (updated).
- D370 — Decision tree updated to reference D174.
Эволюция
D174 введён в Plan 103.9 (2026-05-27) как финальный D-блок Plan 103 серии. Закрывает «V2 consume guards migration» задачу из Plan 100.7 (stdlib migration playbook).
Предполагаемые follow-ups:
- Plan 103.9 V2.1:
try_lock() -> Option[MutexGuard consume](M-D174-4 follow-up). - Plan 103.9 V2.2: Edition-gated removal of deprecated bare
unlock()/release()/done(). - Plan 100.8 LSP: quick-fix «wrap in consume guard» for deprecated bare-unlock sites.
D466. Centralized I/O driver — sleep cancel state-machine + scope-lifetime invariants (Plan 83.11)
Перенумерован 2026-08-26 из D228 (решение владельца; реестр 221.1 №772). Номер D228 несли ДВА разных решения: это и
### D228 NEW — Value-record allocation contractв 02-types.md. Номер оставлен value-record — на него ссылаются сотни мест, включая фикстуру и имя типа; переехал менее вросший блок — тот же принцип, что в №123 (D435 → D440) и у D375 (ex-D277). Ссылки вида «D228» в смысле I/O-драйвера читай как D466.
Принято 2026-06-05. Закрывает Plan 83.11 Ф.9 design debt (83.11-centralized-io-driver.md). Сужает D98 (per-worker libuv loop) — timer/sleep handles теперь регистрируются на dedicated driver-thread loop, а не на TLS
_nova_current_loopworker’а. Связан с D71 (cancel_requested), D75 (supervised(cancel: tok)), D93 (park/wake), D167 (memory ordering). Закрывает [M-83.11-gc-cancel-token-alias] и [M-83.11-supervised-spawn-cancel-memcpy-segv].
Что
Cancellation timer-cancel events в Nova runtime обрабатываются
единственным dedicated OS-thread’ом — I/O driver — а не
worker thread’ом, на котором fiber park’нулся. Driver владеет own
uv_loop_t, принимает работу через single MPSC очередь
(NovaDriverJob), пробуждается через uv_async_send, и является
единственным мутатором sleep-state структур
(NovaSleepState, NovaFiberQueue.armed_sleeps_head list).
Это архитектурный pivot от D98 для timer-class handles:
вместо “каждый worker управляет своими timers на своём loop’е” — один
владелец всей timer-state. Worker’ы остаются полноценными исполнителями
fiber’ов и продолжают использовать TLS _nova_current_loop для прочих
loop-операций (idle drain, future Net/Fs handles).
Decision вводит:
- Driver-path sleep state machine (
NOVA_SLEEP_DRV_*) с явными CAS-точками race-разрешения между natural fire и external cancel. expected_coidentity field — fiber-pointer, capture’нутый в момент arm, против которого close_cb проверяетscope->fibers[slot]для детекции STALE-slot race (предотвращает WRONG-FIBER wake).ctx_pins[]GC-root anchor — каноничный pattern для любого объекта, держащегося только через worker register / heap-internal указатель, который иначе может быть собран conservative GC во время inner-alloc.pending_driver_jobslifetime counter — каноничный pattern для stack-allocated ресурса, указатель на который пересекает thread boundary через job queue.
Правило
1. Sleep state-machine — single-mutator с явным CAS
Состояния (enum NovaSleepStage, fibers.h:2032+):
NEW (10) ──arm_sleep──▶ ARMED (11) ──┬─timer_cb (won CAS)───▶ FIRING (12) ──▶ CLOSED (14)
│
└─cancel_scope (won CAS)─▶ CANCEL_REQ (13) ─▶ CLOSED (14)
- NEW → ARMED — driver thread в
_nova_driver_handle_arm_sleepпослеuv_timer_init+uv_timer_start. RELEASE-store, single-mutator (driver), no CAS нужен. - ARMED → FIRING —
_nova_driver_sleep_timer_cbпри естественном истечении timer’а. CAS (loser → cancel-job уже выиграл, callback тихо выходит). - ARMED → CANCEL_REQ —
_nova_driver_handle_cancel_scope(CANCEL_SCOPE job) walk’аетarmed_sleeps_headlist и пытается CAS каждого. CAS (loser → timer_cb уже выиграл, тоже initiate close). - FIRING|CANCEL_REQ → CLOSED —
_nova_driver_sleep_close_cbпосле того как libuv вернёт handle. SEQ_CST store. Здесь же — wake worker’а черезnova_sched_wake(или direct dispatch, см. §3 ниже).
Инвариант. Оба пути (natural fire и cancel) сходятся на одном
uv_close((uv_handle_t*)&st->timer, _nova_driver_sleep_close_cb).
Wake fiber’а происходит только из close_cb — никогда из timer_cb
напрямую. Это гарантирует, что к моменту wake handle полностью
released, и subsequent re-arm безопасен.
2. CAS race resolution — обе стороны конвергируют на close_cb
Когда natural fire и cancel-scope конкурируют за один slot:
/* timer_cb */
int32_t expected = NOVA_SLEEP_DRV_ARMED;
if (!nova_aint_cas(&st->stage, &expected, NOVA_SLEEP_DRV_FIRING)) return;
uv_close((uv_handle_t*)&st->timer, _nova_driver_sleep_close_cb);
/* handle_cancel_scope (для каждого st в armed list) */
int32_t expected = NOVA_SLEEP_DRV_ARMED;
if (nova_aint_cas(&st->stage, &expected, NOVA_SLEEP_DRV_CANCEL_REQ)) {
uv_close((uv_handle_t*)&st->timer, _nova_driver_sleep_close_cb);
}
/* CAS loser: timer_cb already won, will close. Skip. */
Loser молча выходит. Winner initiate close. close_cb не различает, какой именно путь привёл к close — поведение wake одинаковое; разница видна только worker fiber’у через его собственный cancel-check на yield-point’е после wake.
3. expected_co identity — защита от STALE-slot race
NovaSleepState.expected_co (mco_coro*) фиксируется в момент ARM_SLEEP
job-emit, до того как driver actually arms timer. В close_cb:
mco_coro* actual_co = scope->fibers[slot];
if (actual_co != st->expected_co) {
/* WRONG-FIBER — slot был переиспользован пока expected_co была parked.
* Sub-case A: expected_co всё ещё MCO_SUSPENDED → direct dispatch её
* через sc->dispatch_ready, bypass nova_sched_wake (slot уже
* принадлежит другому fiber'у). Mark her ctx->_nova_worker_slot
* = -2 (DISPLACED) чтобы epilogue не вызвал free_slot.
* Sub-case B: expected_co dead → just store CLOSED и выйти. */
}
Почему это нужно. nova_sched_wake(scope, slot) адресует slot,
не fiber. Без identity-check’а WRONG-FIBER приводил к hang оригинального
fiber’а (Plan 83.11 Ф.3 STALE-slot race, ~10 часов диагностики,
см. §10.3).
4. armed_sleeps_head — driver-thread-only linked list
NovaFiberQueue.armed_sleeps_head — singly-linked list всех в данный
момент ARMED timer’ов под данным supervised scope’ом, с pprev_in_scope
для O(1) unlink. Single-mutator (driver thread) — arm_sleep
вставляет, close_cb удаляет, handle_cancel_scope walk’ает.
Никаких locks — list mutation никогда не race’ит сама с собой,
потому что все три операции выполняются в driver loop’е sequentially.
Workers / main thread никогда не читают и не пишут эту list напрямую.
5. ctx_pins[] — каноничный GC-root anchor
Инвариант. Любой GC-managed объект, ссылка на который держится ТОЛЬКО через worker register или через указатель внутри другой heap-allocated структуры, должен быть pin’нут в supervised-scope’s
ctx_pins[]до того, как может произойти inner-alloc (любойnova_alloc/ spawn / scope-grow).
Pattern:
NovaFiberQueue _nova_scope_q_0 = {0};
nova_scope_init(&_nova_scope_q_0);
NovaCancelToken* _nova_cancel_tok_0 = tok;
nova_scope_pin_ctx(&_nova_scope_q_0, (void*)_nova_cancel_tok_0); /* ← MANDATORY */
nova_runtime_spawn_into(&_nova_scope_q_0, ...); /* теперь spawn-loop безопасен */
nova_cancel_token_bind(_nova_cancel_tok_0, &_nova_scope_q_0);
nova_supervised_run_cancel(&_nova_scope_q_0, _nova_cancel_tok_0);
Почему scope-frame. scope.ctx_pins[] живёт на main stack (часть
stack-allocated NovaFiberQueue _nova_scope_q_0), который scanned
conservative GC как root безусловно. Объект, добавленный туда, остаётся
reachable пока scope жив — даже если регистр-копия cancel-token’а уже
сброшена компилятором.
Когда срабатывает баг без pin. ctx_pins[] сама удваивается на
степенях 2 (16 → 32 → … → 1024). При ~512 одновременно живых fiber’ов
grow вызывает nova_alloc → triggers Boehm GC sweep → token в регистре
не виден conservative scanner’у → token collected → следующий
nova_alloc_uncollectable (для NovaSpawnCtxBase) попадает на тот же
адрес → структурный aliasing (offset +8: bound_scope vs
_nova_parent_scope) → panic
«token already bound to a live scope». См.
§11.2-11.3.
Codegen ответственность (emit_c.rs::emit_supervised):
на каждый supervised(cancel: tok) сразу после декларации
NovaCancelToken* локальной emit’ить nova_scope_pin_ctx(&queue, (void*)tok);
до body. Plan 44.5 L5 использует тот же pattern для NovaSpawnCtxBase.
6. pending_driver_jobs — каноничный stack-lifetime counter
Инвариант. Если job в driver queue несёт указатель на caller’s stack-frame (typical:
NovaFiberQueue*для supervised scope), caller обязан дождаться completion job’а перед возвратом — иначе use-after-free, когда driver thread finally drain’нёт queue.
Pattern (NovaFiberQueue.pending_driver_jobs, ACQ_REL/RELEASE/ACQUIRE):
/* worker thread — _nova_cancel_via_driver: */
nova_aint_inc(&scope->pending_driver_jobs); /* ACQ_REL */
if (nova_driver_submit_job(job) != 0) {
free(job);
__atomic_fetch_sub(&scope->pending_driver_jobs, 1, __ATOMIC_ACQ_REL);
}
/* driver thread — _nova_driver_handle_cancel_scope (end): */
(void)__atomic_fetch_sub(&scope->pending_driver_jobs, 1, __ATOMIC_RELEASE);
/* main thread — nova_supervised_run_impl, after pending_remote == 0 loop exit: */
while (nova_aint_load(&q->pending_driver_jobs) > 0) { /* ACQUIRE */
uv_run(nova_current_loop(), UV_RUN_NOWAIT);
if (nova_aint_load(&q->pending_driver_jobs) > 0) uv_sleep(1);
}
nova_sched_drop_state(q);
/* unbind + return ... */
Why ordering is correct. Inc(ACQ_REL) happens-before submit (program order, same thread); submit happens-before driver’s job-load (uv_async_send is SEQ_CST inside libuv); driver’s processing reads scope fields (visible because scope stack-frame is still alive due to main’s wait); dec(RELEASE) synchronizes-with main’s load(ACQUIRE). Main proceeds only когда counter == 0 ⇒ все referenced jobs полностью processed.
Без этого counter’а driver thread может dequeue CANCEL_SCOPE job через
1-10 ms после того, как nova_supervised_run_impl уже вернулся,
test-runner pop’нул main’s stack frame, и _nova_scope_q_0.armed_sleeps_head
читается из reused memory → wild pointer → SEGV. См.
§12.31.
Почему централизованный driver thread, а не per-worker
| Подход | Cross-thread visibility | Race surface | Cost |
|---|---|---|---|
| Per-worker loop (старое D98 для всех handles) | timer на worker A, cancel из worker B — нужно сериализовать через handle migration или uv_async_send | Большая (N×N pair’ов worker↔worker) | Cheap parallelism — каждый сам себе хозяин |
| Centralized driver (D466) | Любой worker → driver через single MPSC job queue | Линейная (только worker↔driver) | Driver = single point of contention, но cheap для timer/cancel workloads |
| Sharded driver pool | Hash(scope) → driver_id | Линейная per shard | Сложность codegen + load balancing |
Конкретные обоснования:
- Tokio 1.x precedent. Tokio multi-thread runtime в 2020 году сделал ровно такой pivot для timer driver по тем же причинам (cross-thread timer migration nightmares). Plan 83.11 портирует архитектуру TimerEntry pattern напрямую.
- Cross-thread visibility races eliminated. Single owner timer-state
означает, что любая мутация
NovaSleepState.stageилиarmed_sleeps_head— sequential. Memory ordering сводится к acquire-release между worker (submit) и driver (process). - STALE-slot race локализован. Когда timer-cb и cancel-job race’или на одном worker’е (D98 модель), нужен был сложный wake-before-park futex (Plan 83.11 Attempts 1-5). С driver-thread моделью оба теперь сходятся через CAS на одной structure, owned одним thread’ом.
- Trade-off митигирован batching’ом. Driver thread = single point
of contention; для timer-heavy workload’а это потенциально bottleneck.
Mitigated job batching’ом (драйнер обрабатывает все накопленные jobs
в одном
uv_asyncwake), и тем фактом, что typical Nova program создаёт O(fibers) timer’ов, не O(operations).
Что отвергнуто
- Per-worker timer loops с handle migration на cancel. Это изначальная D98 модель. Provoked Plan 83.10.x races + Plan 83.11 STALE-slot race; 5+ attempts at wake-before-park futex (§10.2). Architectural root cause не решался tactically.
nova_alloc_uncollectableдля CancelToken. Семантически верно (caller-owned), но требует явногоnova_free_uncollectable(tok)на cleanup-пути, которого сейчас нет. Без cleanup-path → leak per token.ctx_pins[]решает корректнее: token GC-managed, держится scope-frame’ом, автоматически освобождается когда scope сам становится unreachable.- Heap-allocate scope вместо stack для решения use-after-free.
Изменило бы D14/D50 (supervised — stack frame, не heap-handle) ради
одного fix’а. Counter-based wait
pending_driver_jobsдостаточен и sound. - Sharded driver pool (
Hash(scope) % N). Преждевременная оптимизация; добавляет load-balancing complexity и cross-shard race surface обратно. Закладываем как опцию в Q24 на случай measured bottleneck. - Wake fiber’а из
timer_cbнапрямую (skip close_cb). Сокращает latency на одну uv-loop iteration, но re-arm после wake может race с outstandinguv_close— нужно tracking «handle in flight». Уход convergence на single point (close_cb) дешевле.
Связь
- D14 — fiber-runtime; D466 уточняет structure cancel-path.
- D50 —
spawn/supervised; scope-frame инвариант усиливается. - D71 — bootstrap concurrency,
cancel_requestedflag; D466 раскрывает механику пробуждения parked-fiber’ов на cancel. - D75 —
supervised(cancel: tok); обе invariant’а (ctx_pins[]pin для token +pending_driver_jobswait) обязательны для корректной реализации D75 при ≥1 worker thread. - D93 — park/wake; D466 строит timer-cancel путь поверх него.
- D98 — per-worker libuv loop; D466 сужает D98 — timer/sleep handles теперь живут на driver loop’е, не на worker’ском TLS-loop’е. Остальные планируемые handle-классы (Net, Fs, channels-select-timer) следуют centralized-driver pattern по умолчанию; per-worker — только для handles, у которых нет cross-worker cancel semantics.
- D103 — preemption; orthogonal.
- D167 — memory ordering; D466 §6 фиксирует ACQ_REL/RELEASE/ACQUIRE contract как нормативный для любого job-pointer-to-stack pattern.
- Plan 83.11 — full
implementation history; §10 (STALE-slot post-mortem), §11.6
(
ctx_pinsclosure), §12.31 (pending_driver_jobsclosure). - open-questions.md → Q24-Q27 — driver
sharding, drain-and-cancel barrier API,
ctx_pins[]threshold tuning, sysmon introspection. - Closes [M-83.11-gc-cancel-token-alias] (§11.6).
- Closes [M-83.11-supervised-spawn-cancel-memcpy-segv] (§12.31).
- Closes [M-83.10.1-armed-cancel-timer-hang] V2 sweep PARTIAL (2026-06-08): 15 тестов с
// ENV NOVA_AUTOARM=0убраны — cancel+sleep корректен под armed M:N. 2 теста re-gated (park_wake_stress, semaphore_batch_n) — stress-верификация выявила TIMEOUT под armed M:N; причина — open [M-83.11-grow-vs-wake-race] (§13.6.1), не закрыт Plan 83.11 Ф.3. [M-83.10.1-armed-cancel-timer-hang] остаётся 🟡 PARTIAL до fix [M-83.11-grow-vs-wake-race].
Открытые вопросы
- Должен ли driver thread шардироваться (
N driversчерезhash(scope) % N) для high-I/O workloads? — Q24. - Нужен ли explicit
drain_and_cancelbarrier API (scope.barrier()) для пользовательского кода, который хочет дождаться полной обработки outstanding cancel’ов? — Q25. - Должен ли
ctx_pins[]doubling threshold (сейчас 16 → 32 → … → 1024) быть tunable env var’ом для embedded таргетов? — Q26. - Должен ли
pending_driver_jobscounter экспонироваться черезnova runtime introspect/ sysmon для observability long-running cancel storms? — Q27. (Plan 83-go-cmn Ф.1b / D243) Должен ли guard читать capacity ACQUIRE?— Q28 ✅ ЗАКРЫТО 2026-06-11: да, ACQUIRE (nova_sched_cap_acq), commit98b4b05c6ae. Закрывает[M-83.11-f1b-acquire-capacity]. См. D243 §Followup.- (Plan 83-go-cmn Ф.4) Должен ли
runnext(LIFO fast-slot) быть stealable под устойчивым дисбалансом (Go делает stealable после grace-tick) или остаться non-stealable (Tokio-parity, текущий выбор Nova)? — Q29. - (Plan 83-go-cmn Ф.3→Ф.4, D245) Чтобы nspinning-coalescing стал safe, cross-thread
работа должна попадать в очередь, которую spinner сканит. Routing’ить cross-thread
spawn/goready в global queue (вместо per-worker
wake_pending) — в Ф.4? Или сделать spinner-recheck дренящим чужиеwake_pending(cross-worker access + синхронизация)? Первое чище и совпадает с Go shared-global топологией. — Q30.
D243 — M:N run/park storage: fixed-size ring + chunked stable-address park-state (Plan 83-go-cmn Ф.1)
Создан: 2026-06-11 (Plan 83-go-cmn Ф.1a+Ф.1b). Закрывает
[M-83.11-grow-vs-wake-race]. Порт принципа Go 1.4 (fixedP.runq[256]never-realloc). D-нумерация: D241/D242 заняты Plan 138 (Iterable/Next/Iter) — семейство 83-go-cmn перенумеровано на D243+.
Контракт
Любое хранилище, к которому конкурентно обращаются worker-owner и driver/peer-потоки на hot-path планировщика (run queue + park/wake state), ОБЯЗАНО иметь стабильные адреса элементов — никаких realloc-с-перестановкой-указателя под конкурентным доступом. Перестановка базового указателя массива (realloc) гонится с читателем другого потока → torn/orphaned base → потерянный wake → вечный hang. Это был root cause grow-vs-wake.
1. Per-worker run queue — fixed-size inline ring (runq.h).
mco_coro* runq[NOVA_RUNQ_CAP]инлайн вNovaWorker;CAP= степень двойки (4096 в Nova; Go = 256 — Nova больше, т.к.nova testставит MAXPROCS=1 → все fiber’ы на одном worker). Базовый адрес стабилен всю жизнь worker’а.- monotonic
uint32head/tail (маска только при доступе; len=tail-head; full=tail-head>=CAP). - single-producer tail (owner: store-release, без CAS); multi-consumer head (owner
runq_get+ ворыrunq_grab/runq_steal: каждый advance — CAS). - Overflow → spill HALF (
runq_put_slow) в ОДНУ глобальную overflow-очередь (NovaGlobalRunq, intrusive singly-linked черезSpawnCtxBase.schedlink, под spinlock). - Consumer overflow ОБЯЗАН быть в find-work loop (
globrunq_get_oneпосле local+yielded, до steal) + shutdown drain + pump_scope — иначе spilled fiber’ы strand → hang. - Memory-ordering как PPoPP-2013 deque: значение слота, прочитанное до head-CAS, стабильно до reuse (reuse требует CAP put’ов) → pre-CAS read race-free.
- Заменяет растущий Chase-Lev deque (
deque.h, ретайрнут).
2. Park/wake state — chunked stable-address (NovaSchedState, nova_sched.h).
- 4 параллельных массива (
parked[],pending_handle[],pending_stop_cb[],pending_wake[]), индексируемые (scope,slot), бэкаются директориями фиксированных chunk’ов (X_chunks[MAX_CHUNKS], chunk =NOVA_SCHED_CHUNK=64 элементов). - Chunk’и аллоцируются РАЗ и НИКОГДА не двигаются/освобождаются/realloc’атся →
&parked[slot]стабилен навсегда → torn-pointer структурно невозможен. nova_sched_grow_stateбольше НЕ realloc’ит — CAS-публикует chunk’и (X_chunks[c]NULL→chunk, RELEASE/ACQUIRE; проигравший CAS отбрасывает свой chunk, GC соберёт). grow НЕ single-writer (spawn_into/get_state/register_pending растят без slot_lock) → CAS-публикация обязательна.capacityпубликуется RELEASE’ом ПОСЛЕДНИМ, после всех chunk-публикаций.- Доступ:
*accessor(st,slot)=&X_chunks[slot>>6][slot&63](accessor ACQUIRE-загружает chunk-ptr). Все атомарные операции (SEQ_CST parked store, t1-t4 pending_wake CAS, register_pending SEQ_CST piggy-back, wake deliver-then-CAS) — байт-идентичны (меняется только lvalue, не__ATOMIC_*аргумент). pending_handle/pending_stop_cbв РАЗНЫХ директориях — register_pending SEQ_CST всё равно глобально упорядочивает их (store-buffer drain, не per-array). НЕ колоцировать.- Потолок:
MAX_CHUNKS=1024 ⇒ 65536 slots/scope (abort при превышении). На практике раньше упирается в Plan 82 fiber-arena (~16384 одновр. fiber’ов/worker).
Альтернатива отклонена
Перенос park-state на SpawnCtxBase (per-fiber, Option A) — ставил бы slot_lock +
mco_get_user_data в lock-free wake-путь И переоткрывал lost-wake при slot-reuse (wake
резолвит NEW ctx переиспользованного слота). Chunked (Option C) сохраняет slot-индексацию
→ fences не пере-доказываются. См. plan 83-study-go-c-mn §9.4.
Followup
[M-83.11-f1b-acquire-capacity] — ✅ RESOLVED 2026-06-11 (commit 98b4b05c6ae). Добавлен
nova_sched_cap_acq(st) = ACQUIRE-load capacity; применён на всех ~14 accessor-guard сайтах
(nova_sched.h ×10, driver.c ×2, runtime.c ×2). Теперь guard slot < cap_acq(st) парится с
RELEASE-store capacity → на ARM accessor chunk-ptr не спекулируется вперёд guard’а → NULL-окно
закрыто. Диагностические dump-чтения + register_pending grow-trigger оставлены plain.
Verified x86: smoke + grow_vs_wake 40/40 (race stays closed). Q28 ✅ закрыт.
Validation
grow_vs_wake_explicit 100/100 (MP=1) + 66/66 (MP=16); stress_iso_3e 66/66; semaphore_batch_n 30/30 armed; ring_overflow_drain 10/10 (5000 fibers overflow, exact-count); 1k 30/30, 10k 10/10; concurrency suite 105/4. adversarial diff-review: fence_hazards VERIFIED CLEAN, verdict safe-to-commit.
D244 — gopark/goready park/wake ordering (Plan 83-go-cmn Ф.2)
Создан: 2026-06-11 (Plan 83-go-cmn Ф.2). Порт принципа Go runtime·gopark/goready. Заменяет pending_wake-счётчик + t1-t4 dance + TLS-deferred-unlock (D466-эры) единым lost-wakeup-free протоколом. Хранилище — D243 (chunked stable-address).
Контракт
Любая блокирующая операция паркуется через nova_gopark, будится через nova_goready.
Состояние ожидания — per-fiber 4-state latch _nova_park_state на NovaSpawnCtxBase
(NIL=0/WAIT/READY/DISPATCHED; zero-init=NIL; адресуется by-co через
mco_get_user_data(co), НЕ по (scope,slot)). Ортогонален _nova_fiber_state
(IDLE/RUNNING/PARKED/DEAD = resume-ownership): park_state = wait/ready handshake.
nova_gopark(unlock_fn, unlock_arg) — строгий порядок (lost-wakeup-free):
- G0
_nova_fiber_state→ PARKED (RELEASE; до G1, чтобы SEQ_CST G1 опубликовал и PARKED). - G1
_nova_park_state→ WAIT (SEQ_CST = XCHG на x86, full fence). Несущая публикация: глобально видима до того, как любой waker возьмёт lock ресурса (который отпускает unlock_fn). - G2 stash unlock_fn/arg в TLS — scheduler сливает ПОСЛЕ yield’а (unlock, а значит reachability для peer-waker’а, строго после видимости WAIT).
- G3 commit-recheck: CAS READY→DISPATCHED. Успех = ready-before-park (goready латчнул NIL→READY до G1 или в гонке) → НЕ yield: unlock_fn вызывается inline (через local copies) + TLS self-drain (scheduler не сливает на этом пути) + fiber_state IDLE + return.
- G4 иначе
mco_yield. После resume: TLS clear + reset park_state→NIL (end-of-wait; иначе поздний cross-thread NIL→READY заставил бы СЛЕДУЮЩИЙ gopark пропустить yield).
nova_goready(co) — single-winner CAS-ladder (by-pointer):
- CAS WAIT→DISPATCHED → единственный диспетчер:
dispatch_ready(co)+ fiber_state PARKED→IDLE + clearparked[slot]/parked_co[slot]. - иначе CAS NIL→READY → латч (ready-before-park).
- иначе (READY/DISPATCHED/dead) → идемпотентный no-op.
mco_status != MCO_DEADгардит каждую мутацию/dispatch.
Сосуществование cancel (by-slot) + примитив (by-pointer): оба воронкуют re-queue через
ОДИН nova_goready(co) → single-winner (WAIT→DISPATCHED CAS) электит ровно одного диспетчера
для любой пары wakers {sender,cancel}/{timer,cancel}/{cancel,cancel} → double-push невозможен
by construction. Cancel-walk резолвит co = parked_co[slot] (chunked, set@gopark/clear@goready),
НИКОГДА scope->fibers[slot] (мог быть NULL’нут/reused). parked[] демотирован до
cancel-reachability бита; alloc_slot skip-stale = parked[i] один (пинит slot пока parked).
Удалено
pending_wake counter directory + accessor + t1-t4 barrier-dance + _nova_park_unlock_fn
как deferred-hack (TLS переиспользован как gopark unlock-carrier) + nova_sched_is_parked
gate в deferred-unlock. SEQ_CST на pending_stop_cb (register_pending, Plan 83.10.2 guard) —
СОХРАНЁН.
Followup
[M-83.11-f2-arm-tsan] (P2): G0(RELEASE)/G1(SEQ_CST) x86-корректны (XCHG дренит store-buffer);
для ARM/weak-memory — валидировать под TSAN на Linux (gated на [M-nova-linux-build]).
Не регрессия (x86 — целевая платформа).
Validation
adversarial diff-review (3 линзы + synth) verdict safe-to-commit (все fatal/high опровергнуты построчно; fence не ослаблен; codegen ABI верифицирован). Independent stress: grow_vs_wake 40/40, cross_channel 40/40, condvar_no_lost_wakeup 40/40, nested_cancel 30/30, mutex_cancel 30/30; concurrency 105/4, plan103_4 25/25. grow-vs-wake остаётся CLOSED.
D245 — worker-wakeup: uv_async IS the note primitive (Plan 83-go-cmn Ф.3 finding)
Создан: 2026-06-11 (Plan 83-go-cmn Ф.3 design-finding). Решение: НЕ заменять worker-park примитив; nspinning-coalescing отложен. Без кода (de-risking-исход).
Решение
Worker-thread park/wake в M:N-рантайме остаётся на libuv uv_async — он УЖЕ
удовлетворяет контракту Go-note:
- notesleep =
uv_run(&w->loop, UV_RUN_ONCE)(worker блокируется до loop-события). - notewakeup =
uv_async_send(&w->wake_handle)(cross-thread, async-signal-safe, lock-free). - Idempotent + before/after ordering: libuv хранит pending-async флаг в handle; send в ЛЮБОЙ момент (до входа в uv_run ИЛИ во время блокировки) даёт ровно один wake; множественные send’ы между двумя uv_run коалесцируются. Lost-wake невозможен (состояние в handle, не в transient).
- Windows: IOCP-backed (libuv). Nova НЕ владеет примитивом → весь класс Windows-note-багов (WaitForMultipleObjects / un-register corner) ВНЕ scope. Это главное снижение риска vs ручной lock_sema.
Собственный note.h НЕ вводится ([M-83-gocmn-note-primitive-deferred]) — понадобится
только если Ф.6 (timer-heap) / Ф.8 (netpoll) уберут libuv из worker-park.
nspinning coalescing — отложен (gated на Ф.4)
Go-оптимизация «не слать wakep, если есть spinner» (if nmspinning>0: skip) небезопасна в
текущей топологии Nova: cross-thread работа публикуется в per-worker wake_pending
(dispatch_ready, spawn_global round-robin), но spinner сканит лишь runnext|local|global|steal →
НЕ дренит чужой wake_pending → coalesce-skip → lost-wakeup. Go-инвариант «spinner ⇒ вся
работа достижима» держится только для shared-global-runq топологии Go, не для Nova.
⇒ Coalescing gated на Ф.4 (global-queue routing — spinner сканит global, поэтому cross-thread
работа через global станет coalesce-safe). Порядок ФЛИПается: Ф.4 → Ф.3-coalesce
([M-83-f3-coalesce-gated-on-f4]). Безопасный subset (nspinning accounting + recheck-after-decr
без gate) — без value (uv_async уже корректен), поэтому не реализован.
Ф.4 финальный (2026-06-12): global-routing (cross-thread → global) ОТЛОЖЕН (review нашёл
stranding; home-affinity Nova уже корректен; [M-83-f4-global-routing-gated-on-bench]).
Реализован безопасный subset БЕЗ routing (только runtime.c find-work loop): steal random-victim
start (xorshift32, anti-herd) + post-steal global re-poll + 61-tick global fairness (anti-starve).
Subset добавляет только global-DRAIN точки (consumers), не producers → stranding/lost-wakeup
невозможны. Suite 106/4 no-regression; grow_vs_wake 25/25; ring_overflow @MP=4 25/25. Ф.3
coalescing остаётся заблокирован (нужен routing). См. план §9.11.1.
D270 — Loop codegen opt: preempt-check elision + copy-loop bulk-lowering (Plan 143 §2, [M-opt-preempt-strided-loop])
Создан: 2026-06-14 (Plan 143 §2, merge
7c047a1b). Две correctness-neutral codegen-оптимизации per-iteration loop-overhead. Обе семантически прозрачны — наблюдаемое поведение цикла не меняется.
§A — Preempt-check elision на provably-short const-bound циклах
nova_preempt_check() (M:N cooperative safepoint, Plan 44.7) эмитится в back-edge КАЖДОГО цикла —
барьер-call, блокирующий clang-векторизацию/unroll. Решение: опускать его для provably-short
range-циклов for i in lo..hi, где ОБА bound’а — целочисленные литералы И iteration-count ∈ [0, 1024].
Контракт вытеснения: такой цикл ограничен по построению (≤1024 итер.) → не может
монополизировать worker → отсутствие safepoint starvation-safe. Variable/unbounded и large-const
(count>1024) циклы check СОХРАНЯЮТ (иначе возврат tight-loop-starvation — ровно то, что Plan 44.7
предотвращает). Порог 1024 консервативен; вложенный unbounded цикл имеет СВОЙ safepoint (элизия —
только внешнего const-small). Реализация: emit_loop_body_inline_ex(skip_preempt).
§B — Copy-loop → overlap-safe bulk copy (memmove)
for i in lo..hi { dst[i] = src[i] } на Vec[T] → одиночный bulk-copy вместо per-element цикла
(убирает preempt-check + per-element bounds-branch; clang векторизует). Консервативный recognizer
(иначе fallback на корректный цикл): single plain-assign без trailing; оба индекса = loop-var; dst/src
= plain Ident’ы (pure, single-eval); одинаковый Vec[T] flat-storage (не raw *mut Vec); flat-POD
элемент (nova_* / pointer _p).
Инвариант overlap-семантики (полное соответствие циклу, НЕ упрощение): восходящий per-element
цикл ПРОПАГИРУЕТ при destructive forward-overlap (dst строго внутри [src, src+n) — достижимо через
writable offset-overlap Vec-view’ы: a=v[1..]; b=v[0..]; a[i]=b[i]). Поэтому emit’ится runtime
overlap-guard: if (dst ≤ src ∨ нет overlap) memmove; else forward-element-loop (пропагация). Bounds
сохранены: highest-index last формируется БЕЗ hi+1 (inclusive-overflow-safe при end==i64::MAX).
Acceptance
plan143 12 кейсов PASS (preempt skip/keep/large; copy full/partial/self/empty/inclusive/offset-overlap- propagation/non-destructive-overlap/extra-stmt-fallback); сгенерённый C проверен (skip + memmove с overlap-guard + bounds); регрессия 0 new fail (Vec/collection слайс + concurrency smoke). Оба бага (inclusive overflow, aliasing) найдены adversarial-review + эмпирическим baseline-vs-ветка probe.
Long-term
General async-preemption (Go 1.14 SIGURG) уберёт per-iteration call для variable-bound циклов без
порога — SIGURG-часть [M-opt-preempt-strided-loop] (open). Cross-link Plan 144 §7.4: SIGURG =
ОБЩИЙ async-yield (preempt + async GC safe-point).
D271 — Function-entry preempt-check elision на provably-leaf функциях (Plan 143 §2.B, [M-opt-leaf-preempt-entry-elision])
Создан: 2026-06-14 (Plan 143 §2.B, ветка
plan-143-leaf-preempt-elision). Correctness-neutral codegen-оптимизация: элидирует function-prologuenova_preempt_check()(Plan 44.7 — first statement КАЖДОЙ Nova-функции, emit_c.rs:14925). Sibling [D270 §A] элидит per-loop back-edge check; D271 — per-function entry check. Семантически прозрачна.
Инвариант вытеснения (соундность)
Fiber бежит безгранично без yield’а ТОЛЬКО через (a) цикл — его back-edge nova_preempt_check()
СОХРАНЁН (D270 §A элидит лишь const-small finite); (b) рекурсию — цикл в call-графе. ⇒ Entry-check
безопасно опустить, если функция провабельно НЕ может крутиться без safepoint’а, т.е. каждый
рекурсивный цикл сохраняет ≥1 члена с safepoint’ом.
Правило (conservative KEEP)
Функция f СОХРАНЯЕТ prologue entry-check ⟺ любое из:
fна call-граф цикле — self-recursive ИЛИ член SCC>1 (взаимная рекурсия);fделает indirect/closure/fn-ptr-call (цель статически неизвестна → ацикличность не доказать);fделает FFI/extern-call (C может вызвать Nova-callback → неизвестная цель);faddress-taken (используется как fn-value/callback → цель чьего-то indirect-вызова).
ELIDE иначе (non-recursive, статически-резолвимые callee, не-indirect, не-address-taken). has_loop
для entry-check НЕ важен (цикл держит свой back-edge check; bounded straight-line терминируется).
Интеграция — source-level whole-program pre-pass
Call-граф над source-level FnDecl-символами (entry module.items + все импортируемые peer_files),
computed ДО эмита (compute_preempt_keep_set в compiler-codegen/src/codegen/preempt_keep.rs). Узлы =
non-external FnDecl (ключ receiver::name::param-signature); рёбра = direct resolved-call; per-fn флаги
indirect/ffi/address_taken; итеративный Tarjan SCC. KEEP = cycle ∪ makes_indirect ∪ makes_ffi ∪ address_taken. Соундно над монтоморфизацией: рекурсия/indirect/FFI/address-taken определяются
ИСХОДНИКОМ → KEEP-статус шаблона наследуется ВСЕМИ инстансами (over-approximation: лишние рёбра только
ДОБАВЛЯЮТ KEEP, ни один цикл не теряется). On-demand emit-пути (mono/erased-инстансы) консультируют тот же
KEEP-set по тому же ключу; closure/trailing-block/spawn-fiber-entry (без source-key) — unconditional KEEP.
Conservative default: любой неразрешённый/cross-module-неуверенный callee, named/spread-args, ambiguous
overload, любое сомнение → KEEP. Никогда не элидим под вопросом — соундность важнее агрессивности.
Acceptance
plan143_2 7/7 PASS: positives (pure_leaf, pure_branch, leaf_callee, forward_once, forward_chain)
ЭЛИДИРОВАНЫ; negatives — self-recursion, mutual-recursion SCC, indirect/closure, FFI, address-taken — KEPT
(проверено в сгенерённом C). Probe: рекурсивный generic gcount[T] держит check в обоих моно-инстансах;
non-recursive gid[T] элидит. Регрессия 0 new fail (Vec/collection/concurrency слайс; pre-existing
подтверждены на main-бинаре). Adversarial-review (≥5 линз) закрыл интеграционные emit-дыры (рекурсивный
generic терял safepoint в моно-инстансе — реальная starvation-дыра).
Open / long-term
Cross-module precision + minimal-SCC-cut (KEEP 1 члена на цикл вместо всех) — [Q-loop-opt-thresholds]
(open-questions.md). Compiler-synthesized conversions (str.from(int/f64) — нет FnDecl) держат
StringBuilder.append(f32) в KEEP (нет source-доказательства arg-type) — корректный conservative-KEEP, НЕ
shortcut. Частично снимается SIGURG’ом (Plan 144 §7.4, общий async-yield).
D273 — may-GC effect lattice (Plan 144.0, closes precise-GC hole H4 / Q15)
Создан: 2026-06-14 (Plan 144.0, ветка
plan-144-may-gc-effect-analysis). Compile-time, EMIT-NOTHING анализ: вычисляет для каждой функции внутренний эффект may-GC. Closes soundness-дыру H4 (Plan 144 §7.6) и вопрос Q15 этого слайса. Sibling [D271]: тот же source-level whole-program call-graph pre-pass (fn_key/overload-резолюция/Tarjan SCC изpreempt_keep.rs), но другая решётка и направление пропагации. Ничего не эмитит в генерируемый C — потребляется тиром O1 позже (Plan 144 Ф.2: frame-elision / write-back-skip), отдельно и под гейтом.
Решётка (двухточечная, дефолт = top)
MayGC (top, ⊤) ← ДЕФОЛТ (soundness — H4)
│
NoGC (bottom, ⊥) ← доказывается
is_no_gc(f) истинно ТОЛЬКО когда весь статический конус вызовов из f полностью разрешён по
именам И не содержит ни одной аллокации. Любое сомнение → MayGC. Ложный NoGC = пропущенный
GC safe-point / корень = use-after-free (как только Ф.2 обопрётся на набор), поэтому соундность
важнее агрессивности элизии.
Seed self_may_gc(node) — узел сам MayGC, если ИСТИННО любое из
- Аллоцирует — тело содержит аллоцирующее выражение (см. allowlist ниже).
- Indirect-вызов — closure / fn-ptr / метод-на-не-self / trailing-block /
with/spawn/select/parallel-for, а также first-class method value (obj.@m/Type.@mв value-позиции — codegen эмитит env+closurenova_alloc’и): цель/аллокация неизвестна → top. - FFI/extern — внешняя функция; её may-GC неизвестен → top (C может вызвать Nova-callback).
- Неразрешённый callee — bare-ident /
Type.method/module.func, не сматченный ни к одному knownFnDecl(cross-module / closure-local) → top.
NB (отличие от [D271]):
address_takenсам по себе НЕ делает узел MayGC — быть first-class значением не значит аллоцировать; MayGC у КОЛЛЕРА черезmakes_indirect.
Принцип allowlist’а аллокации (soundness)
Allowlist provably-non-allocating, всё прочее → аллоцирует. Не-аллоцирующими считаются ТОЛЬКО:
целочисленная/плавающая арифметика, сравнения, доступ к полю/индексу без копии, as-касты
скаляров, literal-скаляры, return/break/continue, чтение локала, интернированный str-литерал
(static const u8[]). Аллоцируют: RecordLit/sum-конструкторы/ArrayLit/MapLit,
интерполяция/конкатенация str (буфер), лямбды/замыкания (env), spawn/detach/blocking/
supervised/parallel-for (fiber/runtime), boxing escaping-значений, vec/StringBuilder/Map
конструкторы и .clone() на heap-типах. Любой неизвестный AST-узел → аллоцирует (top).
Транзитивная пропагация (SCC-конденсация, обратный топо)
MayGC течёт вверх по коллерам: SCC помечается MayGC ⟺ любой его член self_may_gc=true ИЛИ
любое исходящее ребро (в ДРУГУЮ SCC) ведёт в MayGC-SCC; обработка SCC в обратном топологическом
порядке (callee → caller). NoGC = {узлы вне MayGC-SCC}. Рекурсивная SCC с аллокацией в любом
члене → вся SCC MayGC; чистая рекурсия без аллокаций и без MayGC-рёбер → NoGC.
Соундность над мономорфизацией
Свойства seed (аллокация / indirect / FFI / unresolved) — исходные (source-level): вердикт,
вычисленный на шаблоне, наследуется КАЖДЫМ мономорфным/erased-инстансом. Over-approximation
рёбер/seed’ов ⇒ over-approximation MayGC ⇒ ни один реальный may-GC-инстанс не пропущен; spurious-
рёбра лишь теряют элизию (NoGC→MayGC), не ломают соундность. Conservative-gating как
PreemptKeepSet: при populated==false (анализ не прогонялся над непустой вселенной) — никто не
NoGC (всё MayGC).
Реализация и introspection
compiler-codegen/src/codegen/may_gc.rs — compute_may_gc_set / MayGcSet / is_no_gc;
introspection-CLI nova gc-effect-analyze <path> [--format json|text] (зеркало consume-analyze,
в бинарь ничего не эмитит). emit_c.rs НЕ зовёт модуль во время эмиссии (emit-nothing инвариант).
Acceptance
plan144_0: 3 позитивные (pure_leaf/leaf_forwarder/pure_recursion → NoGC) + 7 негативных
(allocates_record/calls_allocator/ffi_call/indirect_call/method_value/recursive_alloc/
unknown_callee → MayGC) фикстуры через релизный nova gc-effect-analyze; 19/19 may_gc unit-тестов
PASS; release-сборка чистая, генерируемый C не изменён (emit-nothing). Adversarial-review закрыл
дыру first-class-method-value (Type.@m в value-позиции эмитит env+closure alloc — был ложный NoGC,
ровно H4-форма) — @-префиксный Member теперь seed’ит аллокацию. Без упрощений как для прода.
Open / long-term
Cross-module callee-резолюция, точность str-literal-interning, более тонкая классификация alloc- сайтов — [Q-may-gc-precision] (open-questions.md). Все консервативны (теряют элизию, остаются соундны). O1-потребление набора (frame-elision / write-back-skip) — Plan 144 Ф.2, [M-144.0-may-gc-effect-analysis].
D408. supervised(deadline:/timeout:) — областной срок → отмена → TimeoutError (Plan 174)
Расширяет D50 / D75 (
supervised/supervised(cancel: tok)). Нормативная база — план 173 §3a «Bounded-shutdown — дедлайн на SCOPE, не на cleanup». Owner sign-off 2026-07-06.
Форма. К именованным аргументам keyword-конструкции supervised добавлены
deadline: и timeout: (набор: cancel: / deadline: / timeout:, в любом
порядке, через запятую; каждый — не более одного раза; deadline: и timeout:
взаимно исключающи):
supervised(deadline: <Monotonic>) { тело с любыми эффектами } // абсолютная точка (канон)
supervised(timeout: <Duration>) { тело с любыми эффектами } // относительный сахар
supervised(cancel: tok, timeout: 5.seconds()) { ... } // комбинируется с cancel:
deadline: <Monotonic>— абсолютная точка на монотонных часах (D124); канон, потому что при пропагации во вложенные области складываются точки, а не относительные интервалы (иначе внутренний интервал «съезжал» бы на своё время входа). Codegen извлекаетi64 nanosvalue-record’а.timeout: <Duration>— относительный сахар, равныйdeadline: Monotonic.now() + d. Codegen лоуэрит в_nova_monotonic_ns() + (int64_t)d.nanos.
Обе формы низводятся к абсолютному сроку в наносекундах (NovaFiberQueue.deadline_ns,
0 = нет срока).
Структурная конструкция, НЕ функция-обёртка. В отличие от ретрактируемого
with_timeout[T](ms, fn() -> T) (std/concurrency), который берёт чистую
fn() -> T и потому НЕ пропускает эффекты тела, supervised(deadline:) — часть
самой keyword-конструкции: тело выполняется с любым effect-row (Http, Net,
Fail[E], …), эффекты протекают наружу естественно.
Механика (таймер → отмена → TimeoutError).
- Вход области.
nova_scope_initнаследует срок enclosing-области (_nova_active_scope->deadline_ns); codegen ужимает его собственнымdeadline:/timeout:черезnova_deadline_combine(минимум ненулевых — внутренний срок может только УЖЕСТОЧИТЬ, никогда не продлить внешний). - Драйв.
nova_supervised_run_implограничивает свою idle-парковку сроком (_nova_scope_deadline_run_once: armed stack-timer +uv_run(UV_RUN_ONCE)), так что drain-loop просыпается в точке срока даже когда все волокна запаркованы на далёкомTime.sleep/сетевом ожидании. - Истечение. В точке срока (и только если область ещё не отменена cancel-
токеном — earliest-of-two) доставляется кооперативная областная отмена
ровно тем же путём, что
cancel:(nova_scope_deliver_cancel: cancel_requested- wake всех запаркованных волокон + worker-fibers + driver). Sleep/сетевой park прерывается РАНО (не досыпает до конца), cleanup’ы (defer/consume) добегают (completes-by-default, §3a).
- Наружу. По завершении drain, если срок сработал, наружу летит
типизированный
TimeoutError { deadline_ns i64 }(prelude/errors) — ловитсяwith Fail[TimeoutError]илиis TimeoutError(D54/174.3). Таймер гасится при нормальном выходе (stack-timer — ноль утечки).
USER-precedence. Если внутри области случилась НАСТОЯЩАЯ user-ошибка И истёк
срок — наружу летит user-ошибка (не TimeoutError): срок лишь отменил siblings.
Только CANCEL-исход (или его отсутствие) при сработавшем сроке → TimeoutError.
Нулевой / отрицательный / прошедший срок. timeout: 0 (или прошедший
deadline:) → срок истёк немедленно: дети отменяются на первом suspend, наружу
TimeoutError. Значение runtime’ное (выражение Duration/Monotonic) — не
compile-ошибка; мгновенный таймаут — принцип D317 (никогда silent-мусор).
Вложенность (пропагация точки). Плоский нижележащий supervised {} без
своего срока наследует ambient-срок через nova_scope_init (ноль изменений
codegen — byte-identical для не-deadline областей). Внутренний supervised(timeout: 30s)
внутри внешнего supervised(timeout: 100ms) эффективно ограничен 100ms.
Longjmp-safety. Throw (в т.ч. TimeoutError), пробивающий тело внешней
области, чей run-loop ещё не исполнялся, восстанавливает _nova_active_scope
(a) в nova_supervised_run_impl на всех путях выхода и (b) в with Fail[...]-
блоке при входе/выходе — иначе следующая область унаследовала бы deadline_ns из
освобождённого stack-фрейма (spurious immediate TimeoutError).
Отличие от CleanupTimeoutError (D192). Та была про cleanup-бюджет ОДНОГО
ресурса — РЕТРАКТИРОВАНА §3a и УДАЛЕНА из prelude (Plan 173 Ф.5 п.2:
watchdog-варн + duration_ms/overrun в ResourceTrace exit-событии);
эта — про срок ЦЕЛОЙ области (bounded-shutdown) и живёт.
Ретракция with_timeout (§3a п.4) — UNBLOCKED, отложена маркером. Теперь,
когда supervised(timeout:) приземлён, with_timeout[T]/within[T]
(std/concurrency/cancellation.nv) субсумированы и подлежат удалению вместе с
миграцией ~7 тест-ссылок. Не сделано в этом заходе (не «дёшево/безопасно» —
cancellation.nv уже независимо сломан retired-API-дрейфом) → маркер
[M-174-retract-with-timeout]. race2 остаётся до общего race (173.1 §2a).
Реализация: parser parse_supervised (мультиаргумент); AST
Supervised { body, cancel, deadline: Option<SupervisedDeadline{expr, relative} >};
codegen emit_supervised (.nanos-извлечение + nova_deadline_combine);
runtime nova_rt/fibers.h (NovaFiberQueue.deadline_ns/saved_active_scope,
nova_scope_init inherit, nova_deadline_combine/nova_scope_deliver_cancel/
_nova_scope_deadline_run_once/nova_throw_scope_timeout + run_impl deadline-gate);
typed-throw splice _nova_throw_scope_timeout_impl (по образцу ныне-удалённого CleanupTimeoutError-splice);
with Fail[...] active-scope restore. Тесты: std/concurrency/supervised_deadline_test.nv
(8/8: within-budget; timeout→TimeoutError+is; sleep interrupted early <2000ms для
sleep(5000); абсолютный deadline; вложенность inner-can’t-extend; deadline+cancel
earliest-of-two ×2; zero→immediate).
D449. supervised(on_timeout:) — срок как ЗНАЧЕНИЕ, а не как эффект
Принято владельцем 2026-08-09 («
on_timeout: |_| Err(ReadFailed)— ОК»). Расширяет D408 (deadline:/timeout:). Записано ДО реализации: спека первой, окно реализует по решению.
Что
К набору именованных аргументов supervised (cancel: / deadline: /
timeout:) добавляется необязательный on_timeout: — обработчик, который
вызывается вместо возбуждения Fail[TimeoutError]:
ro res = supervised(timeout: 10.to_millis(), on_timeout: |_e| Err(ReadFailed)) {
read_headers(client, max_bytes)
}
Тип конструкции всегда равен типу тела. Обработчик обязан вернуть значение
того же типа; несовпадение — ошибка компиляции. Обработчик получает
TimeoutError { deadline_ns i64 }.
Без on_timeout: поведение прежнее — Fail[TimeoutError] летит наружу
(D408) и ловится где угодно выше через with Fail[TimeoutError].
Зачем
Сегодня «сколько ждём» стоит в supervised, а «что делать, если не дождались» —
уровнем выше, в обёртке with Fail, между ними уровень вложенности:
with Fail[TimeoutError] = |_e| Err(ReadFailed) { // что делать
supervised(timeout: 10.to_millis()) { read_headers(…) } // сколько ждём
}
Выигрыш не в длине, а в близости: два решения об одном сроке стоят рядом.
Почему обработчик, а не готовое значение ошибки
Рассматривалась форма on_timeout_err: ReadFailed (исходное предложение
владельца). Отвергнута по одной причине: она заставляет конструкцию менять
свой тип в зависимости от набора аргументов — без аргумента supervised { … }
даёт тип тела, с ним Result[тело, E]. Такую неоднородность трудно объяснить и
легко забыть; вдобавок форма требует, чтобы тело уже возвращало Result.
Обработчик свободен от обоих недостатков и покрывает исходную форму как частный
случай — on_timeout: |_| Err(ReadFailed). Он же сохраняет deadline_ns,
который при передаче готового значения теряется.
Прецеденты
D43 (функциональный параметр за скобками вызова),
D102 (именованные аргументы), with Fail[E] = |e| …
(обработчик как значение — уже в языке).
Открыто (не блокирует реализацию timeout:)
- нужен ли парный
on_cancel:для внешней отмены (cancel:), или отмена и срок делят один обработчик; - поведение, если обработчик сам падает либо сам не укладывается в срок;
- при
deadline:— один обработчик на область или по одному на вид завершения.
Порядок реализации
Зависит от №457 (реестр 221.1): supervised(timeout:)
сегодня не достаёт до .read(). Строить форму поверх несрабатывающего механизма
нельзя — фикстура будет зелёной и при работающем, и при неработающем. Сперва
№457, затем D449. Решение владельца: делать до тега безусловно.
D414. Structured error propagation — primary-selection precedence, detach-policy, channel closed-vs-value (Plan 173 Ф.3)
Дом Ф.3-остатка плана 173 (structured-concurrency error handling), поверх субстрата 173.0 (per-slot
child_error[]+ serialized decision-loop). Расширяет D50 (spawn/detach), D75 (supervised), D94 (select); опирается на D13 (три уровня катастрофы). Owner sign-off 2026-06-21 (§3a/§3b); реализация 2026-07-09.
§1. Primary-selection precedence: PANIC > USER/USER_TYPED > CANCEL
Когда supervised/parallel for-scope удержал несколько падений детей
разных kind (субстрат 173.0 хранит каждое в своём слоте child_error[], не
схлопывает), при дефолтной эскалации (нет супервизора — D50/173.2)
primary (перевыбрасывается наружу) выбирается строгим рангом:
| kind | rank | смысл |
|---|---|---|
PANIC | 3 | fiber-катастрофа (bug/abort-class, D13); не деградирует до ловимого USER |
USER / USER_TYPED | 2 | управляемая ошибка (реальная throw-ошибка) |
CANCEL | 1 | кооперативная отмена siblings (следствие чужого падения, не корневая причина) |
Правило overwrite: входящая ошибка становится primary ⇔ rank(incoming) > rank(current) (строго больше → ties keep-first: first-PANIC-wins,
first-USER-wins, first-CANCEL-wins). Не-primary ошибки уходят в suppressed-
карман (D158 / Ф.4 — MultiError-агрегация).
Инварианты и мотивация.
- D13-соундность: panic ребёнка всегда становится primary, даже если
реальная USER-ошибка была записана раньше — panic не должен «прятаться» за
ловимой ошибкой (иначе
with Fail[E]на call-site мог бы проглотить процесс- катастрофу). Порядок прибытия под M:N недетерминирован — ранг делает выбор детерминированным. - USER > CANCEL: реальная ошибка приоритетнее отмены (Go
errgroupделает first-wins и теряет реальную ошибку, если отмена случилась раньше — у Nova не теряется). CANCEL — производное состояние (siblings отменены из-за первого падения), оно не должно вытеснять корневую причину.
Реализация. nova_throw_kind_precedence(NovaThrowKind) (nova_rt/fibers.h) —
единый ранг; используется обеими report-точками: nova_fiber_report_error_kinded
(local/single-thread) и nova_fiber_report_atomic_kinded (M:N cross-worker
CAS-loop). Заменяет прежнюю 2-уровневую CANCEL→USER-таблицу (роняла входящий
PANIC на уже-записанном USER). Тест: nova_tests/expected_runtime/supervised_precedence_panic_over_user.nv
(10 USER + 10 PANIC детей → primary детерминированно PANIC, ×5 стабильно).
Стыкуется с catchability-инвариантом Ф.4 п.6.
Амендмент (2026-07-13, волна «173 хвосты»): suppressed-агрегация РЕАЛИЗОВАНА. Обещание «Не-primary ошибки уходят в suppressed-карман» теперь факт: re-throw хвост
nova_supervised_run_impl(fibers.h) собирает все ПРОЧИЕ retained детские падения в suppressed-цепочку primary-броска (staging-слот_nova_pending_suppressed→ транспорт черезnova_last_error_set); после ловли primary они читаютсяsuppressed() -> []any— та же поверхность D158 модели Б, что у cleanup-ошибок (is T/try_as[T]narrowing). Паритет KotlincoroutineScope/ JavaJoiner(агрегация), строго лучше Swift TaskGroup first-wins. В карман НЕ попадают: CANCEL-производные падения siblings (следствие эскалации, не корневая причина) и Stop-решённые супервизором ошибки (D416 — хендлер осознанно выкинул; retained вchild_error[]для observability, наружу не текут). Primary исключается по идентичности (msg+payload/tid/kind — typed-броски делят литералmsg_repr); дубликаты схлопывает D193 identity-check. Видимый порядок = порядок слотов (spawn-порядок). Попутный фикс ABI:nova_any_from_boxed(typeid.h) для value-ABI примитивов (tid 1..7) кладёт heap-box значения вdataнапрямую —try_as[int]на suppressed-элементе возвращал адрес бокса вместо значения. Тесты:nova_tests/err173_2/scope_multierror_test.nv(детерминированный supervisor-барьер: оба слота retained; Stop не течёт; default-Escalate инвариант).
§2. detach error-policy + enforcement эффекта Detach
Enforcement (checker, CapabilityCtx). detach { … } — fire-and-forget
задача, переживающая caller’а (orphan fiber, глобальный supervisor, не локальный
scope) — это наблюдаемый capability и требует эффект Detach в effect-row
enclosing-fn (норма D50, ранее не проверялась — bootstrap-gap). Checker
отвергает detach без Detach чистым [E_DETACH_REQUIRES_EFFECT]. Разрешено
без объявления когда:
- effect-root — тело
test-блока (нет сигнатуры; корень, какmainдля скрипта) → baredetachлегален (гасится дефолт-handler’ом); - ambient handler —
with Detach = …в лексическом scope (эффект погашен на месте;with_handler_stack-проверка); - handler-op-литерал — тело операции
effect X { op(){ detach … } }эффект-полиморфно (эффект — обязанность use-site handler’а, не definition-site); capability-walker такие тела не обходит. Полная HOF-эффект-полиморфная проверка (Swiftrethrows-аналог) — вне периметра 173 (см. хаб НЕ-цели).
Тесты: neg nova_tests/err173/neg/f3_detach_requires_effect (fn без Detach →
[E_DETACH_REQUIRES_EFFECT]); pos nova_tests/err173_2/detach_effect_ok_test
(fn с Detach + test-root).
Error-policy словарь (runtime). Что происходит с ошибкой/паникой в detached-
fiber’е (у сироты нет call-site, некому вернуть Result):
LogAndDrop(default) — throw из orphan-тела →fprintf(stderr, …)+ fiber умирает чисто; caller НЕ abort’ится, другие orphans + main продолжают (runtime.c, §3.1). panic — critical-класс с D13-семантикой («fiber мёртв»). Паритет Gogo fn()(паника горутины роняет процесс — Nova orphan-panic логируется, процесс жив по дефолту), tokioJoinHandle-drop.escalate-to-scope(opt-in, design) — детач привязывается к enclosingsupervised-scope вместо глобального orphan-supervisor’а: ошибка сироты эскалируется в scope (участвует в §1-precedence как обычный child-fail). Это opt-in, т.к. противоречит fire-and-forget-семантике дефолта (сирота по определению переживает scope). Рычаг привязки — будущийdetach in <scope>/ атрибут; runtime-часть отслеживается[M-173-detach-escalate-to-scope](нужен scope-handle у detach-примитива + участие в decision-loop). Дефолт остаётсяLogAndDrop(изменение семантики orphan’а — owner-gated).
§3. Channel closed-vs-value — recv → Option канон + select None-арм
Канон (Ред. 2, sign-off). Channel[T].recv() -> Option[T] ОСТАЁТСЯ
(D91/D94); Result-миграции не будет. None ⇔ канал closed И буфер
пуст (после close буфер сперва дренится, потом None). try_recv() различает
EMPTY (пусто, открыт) от closed через is_closed(). Это механизм, на котором
стоит 173.1-десугар parallel for (completion-order dense через Option-канал).
Прямое различение: match rx.recv() { Some(v) => …; None => closed }.
Select None-арм (реализовано Plan 173 Ф.3 п.3). Раньше select не различал
Some(v) от None в dispatch (bootstrap-упрощение — любой ready recv-арм
срабатывал). Теперь recv-арм несёт паттерн (SelectSlot.want_none, channels.h):
Some(v) = rx— ready только когда есть значение (count > 0);None = rx— ready только когда closed и буфер пуст;_ = rx— ready на любой результат (value ИЛИ closed).
Readiness — в nova_select_try_immediate (централизованно; park-путь re-check’ает).
close() будит запаркованные select-recv-waiters → None-арм срабатывает при
переходе канала в closed. Edge (документирован): одинокий None = rx на
уже-closed канале с непустым буфером не срабатывает (ждёт пустоты; дренаж —
задача sibling Some-арма); без sibling’а → panic "select: all channels closed"
(чистый отказ, не hang) — идиоматично None пара́ется с Some на том же канале.
Тесты: nova_tests/err173_2/{channel_closed_vs_value,select_none_arm}_test
(recv-различение; select None/Some/wildcard на value/closed/multi-channel;
буфер-дренаж-до-None; wildcard-регресс).
§4. supervised — value-expression; parallel for → []T — канальный сбор, completion-order dense (Plan 173.1, 2026-07-09)
supervised { … v } — value-expression (Ф.1). Возвращает trailing-выражение
тела, вычисленное ПОСЛЕ join’а всех детей (post-join — мутации детей видны в
v). Void-форма — unit. Bootstrap-заглушка «возвращает unit» снята. parallel for = сахар над этой формой. Codegen: результат объявляется вне scope-C-блока;
unit-типизированный trailing остаётся eager/pre-join (байт-паритет со старым
поведением — типичный случай «spawn {…} последним стейтментом»).
parallel for x in xs { f(x) } → []T (Ф.2) — для ЛЮБОГО T и ЛЮБОГО
итератора. Сбор через канал, семантика §2.3 плана 173.1:
- Клон-в-родителе:
Sender-клон создаётся в РОДИТЕЛЕ на моментspawn(writer_count++строго ДО закрытия родительского tx) и move’ится в ребёнка; ребёнок владеет и закрывает клон на любом выходе (success/throw/cancel). - Транспорт элемента: int-скаляры ≤64 бит — прямо в слот канала;
heap-указатели — через
intptr_t; value-типы (f64/str/value-record/tuple/ Option/Result) — boxed-копией (GC-бокс; drain разыменовывает и пушит копию). Политика одна на send/recv-стороны (parfor_chan_repr). - Дренаж внутри scope: выделенный drain-fiber — единственный потребитель;
recv()доNone(= последний клон закрыт),pushвVec[T]. Плотный сбор, completion order — упавший ребёнок не шлёт (дыр нет); итерационный порядок НЕ гарантирован (нужен порядок —xs.sort()). - Back-pressure: буфер
K = min(len, CAP),CAP = 16— память O(CAP), не O(N); ленивый итератор (Iter-protocol, безlen()) → сразу CAP. - Escalate: ошибка ребёнка → отмена siblings + re-throw после дренажа (субстрат 173.0) — накопленный массив НЕ возвращается. Stop-стратегия (супервизор) — 173.2.
- Прежний примитив-whitelist
{int,bool,f64,str}+ slot-записьresult.data[idx]+ interim-guard[E_PARFOR_RESULT_UNSUPPORTED]— УДАЛЕНЫ (D71-amend;[M-parfor-record-result-miscompile]закрыт).
Runtime-хардненинг канала (обнаружено канальным сбором при N≥1000, armed M:N):
[M-chan-spurious-wake-retry]—send/recvобязаны ретраить spurious wake (не fired, канал открыт): прежнийsendмолча РОНЯЛ значение, прежнийrecvвозвращал ложныйNone(Gochansend/chanrecv— та же петля).[M-chan-close-phantom-zero]— close-side wake не ставитfired=1(fired = «значение передано»): прежний CAS давал parked-recv фантомныйSome(0)(len = N+1) и ложный «отправлено» parked-send’у.
Тесты: nova_tests/err173_1/ — parfor_elem_matrix (вся матрица видов
элемента), parfor_iter_edge (Iter-протокол без len / пустой / один /
completion-плотность / N=2000 back-pressure / Escalate), supervised_value_smoke,
neg/parfor_openended_range; nova_tests/concurrency/parallel_for_array.nv
(set-equality канон для user-тестов).
D415. Data-race freedom — #share-атрибут, capture-check, consume-в-spawn (Plan 173.3)
Дом Plan 173.3 (data-race-freedom: гонка → compile-error). Owner sign-off 2026-06-21 (решения §2 плана); реализация 2026-07-09/10. Расширяет D50 (
spawn), D75 (supervised), D14; поглощает Plan 118.3#fiber_send/E_POINTER_CROSS_FIBER(§5). Мировой контекст: RustSend/Sync≈ Swift 6Sendable(тип-маркер, авто-вывод по полям, проверка на границе конкуренции, audited unsafe-escape) победили Pony-caps по эргономике; Go-race(рантайм) — анти-модель.
§0. #share — АТРИБУТ типа, НЕ протокол
#share — bare-маркер на type-декларации (как #zero_on_move), парсится в
TypeAttr::Share. Протоколом не является намеренно: пустой протокол
(маркер без метода) был бы структурно истинен для всякого типа — систему
протоколов (#impl, verify_impl_protocols) не трогаем. Применим только к
kinds с собственной instance-идентичностью: record / named tuple / newtype /
opaque / sum; на effect/protocol/alias/type-set → [E_SHARE_INVALID_KIND].
§1. Одна ось: share. Два уровня доступа. Авто-вывод + ядовитая база
Ось одна — «можно ли алиасить значение из другого файбера». Send/move-оси
(как в Rust) НЕ нужно: под общим GC move всегда memory-safe — его покрывает
consume (§4). Чем алиас ОПАСЕН — зависит от доступа, который он даёт
(зеркало Rust-инварианта T: Sync ⇔ &T: Send):
- read-alias-safe (
is_alias_read_safe) — безопасно алиасить для ЧТЕНИЯ (ro-захват / immutable-поле): примитивы,(), deep-immutable агрегаты memberwise, контейнеры/tuple/Option/Result из read-safe элементов. - mut-alias-safe (
is_mut_alias_safe) — безопасно алиасить для МУТАЦИИ (mut-биндинг, захваченный теломspawn): ТОЛЬКО внутренняя синхронизация — audited#share-vouch (§2), или агрегат, все пути мутации которого упираются в такой тип. Голыйmut int-аккумулятор — мотивирующая гонка плана — read-safe, но НЕ mut-alias-safe.
Авто-вывод (memberwise, зеркало auto-derive Plan 126): обычный тип share
без единой аннотации — record share ⇔ каждое immutable-поле read-alias-safe
И каждое mut-поле mut-alias-safe; sum/named-tuple — по payload’ам (read).
Считается по запросу capture-check’а (protocols/share_check.rs), нигде не
кешируется и не публикуется.
Ядовитая база: сырой указатель *T (любой pointee-модификатор) — не-share
структурно, оба уровня (аналог Rust UnsafeCell: !Sync); его binding-близнец —
записываемая ячейка (mut-поле / mut-скаляр) без audited-синхронизации вокруг.
Тип, транзитивно содержащий ядовитую базу, не-share; единственный выход —
собственный #share-vouch типа.
Консервативные направления (V1): неразрешённое имя (generic-параметр T,
registry-miss) / fn-тип / anonymous-protocol / opaque-без-vouch / Effect →
не-share. Share-bound для дженериков — вне V1 (см. §7 Q3/Q7).
§2. Capture-rule на границе spawn / parallel for / detach → E_CONCURRENT_MUT_CAPTURE
Тело spawn/parallel for/detach исполняется в НОВОМ файбере (M:N —
возможно на другом OS-потоке). Свободные переменные тела, резолвящиеся во
ВНЕШНИЕ биндинги (лексический скан CapabilityCtx, стек scope-фреймов),
могут пересечь границу только тремя способами:
| захват | правило |
|---|---|
consume-move (§4) | отдал во владение ребёнка — виден в коде, всегда ок |
ro-биндинг | deep-immutable view (D246) — всегда ок, share не требуется |
mut-биндинг | ок ⇔ тип mut-alias-safe (§1); иначе [E_CONCURRENT_MUT_CAPTURE] |
Audited vouch (#share в их .nv) несут sync-примитивы std —
Mutex/RwLock/ReentrantMutex/WaitGroup/Once/OnceCell/Lazy/
Barrier/Condvar/CountDownLatch/Semaphore/все Atomic* (std/runtime/sync.nv):
«внутри настоящая синхронизация, авто-вывод её не видит через opaque-handle».
Это утверждение АВТОРА, аналог Rust unsafe impl Sync — компилятор верит без
проверки (потому «audited»). Пользовательский lock-free тип пишет тот же
#share — механизм один (§3).
V1-границы скана (задокументированные, направление — недолов, не
false-positive): биндинги замыканий не трекаются как внешние (имя не
найдено → не флагаем); типы биндингов — из аннотаций + синтаксического
эскиза init-выражения (Type.new(..)/Type[T].new(..)/Type{..}/литералы);
не-выводимый тип mut-биндинга → консервативно флагаем.
Амендмент ([M-detach-consume-escape-unchecked], владелец P1, 2026-07-22):
match/if let/while letpattern-биндинги БОЛЬШЕ НЕ «не найдено». Владелец нашёл во флагмане (examples/flagship/aggregator/src/main.nv) ровно ту дыру, что была здесь честно задокументирована:match lst.accept() { Ok(consume stream) => { detach { …stream… } } }—streamсвязан явнымconsume-sub-bind’ом (D157/D180,Pattern::Ident{is_consume}) в match-arm’е, ОБЪЕМЛЮЩЕМ относительноdetach;detachзахватывал его БЕЗ явного move — use-after-consume/use-after-free по выходе из match-arm’а, который к моменту исполнения orphan-файбера уже мог завершиться.spawn consumeловит СИММЕТРИЧНЫЙ случай (§4) —detachэтого не делал, потому чтоcheck_capture_boundaryв принципе не мог УВИДЕТЬstream: ни одинmatch/if let/while letarm не заводил scope-фрейм для своих pattern-биндингов (state.scopesвtypes/mod.rs— единственный механизм, которым capture-check резолвит имя до биндинга). Фикс — симметричный дляspawn/parallel for/detachразом (общая точка входа,check_capture_boundary), в два слоя:
Match/IfLet/WhileLetтеперь заводят scope-фрейм для bound-имён своего pattern’а (мутабельность — изPattern::Ident.is_mut, как и везде) ПЕРЕД сканом guard/тела — то же, что уже делалиfor/parallel forдля loop-переменной. Побочный эффект (сознательно принят, не половинчато):mut-бинд из match/if-let/while-let pattern’а, захваченный в spawn/parallel-for/detach БЕЗ типовой аннотации, теперь ТОЖЕ консервативно флагуетсяE_CONCURRENT_MUT_CAPTURE(тип неизвестен на pattern-destructure сайте — тот же «не выводим ⇒ флагуем» путь, что уже стоит для обычногоlet) — раньше это тоже было «имя не найдено → не флагаем», симметричная дыра той же природы, закрыта тем же ходом.ScopeBinding.linear_pattern— явныйconsume-sub-bind (Ok(consume x)/Some(consume x)) не несёт статической type-аннотации (ty: None— V1 не выводит тип pattern-destructure), поэтому обычныйtype_decls-lookup поtyне может опознать линейность; сам факт, что ИСХОДНЫЙ синтаксис пометил биндингconsume, уже достаточен и авторитетен → флаг читается напрямую, независимо отty.Диагностика — тот же код,
E_LINEAR_CAPTURE_IN_FIBER(Plan 201/173.1, §4 ниже), текст различает «известный consume-тип» и «явный consume-pattern без известного типа». Требуемый фикс на месте ошибки — явный move:detach consume c [= expr] { … }— новая, симметричнаяspawn consumeформа (парсер:parse_detach, тот жеStmt::ConsumeScope-десугар verbatim, минус лишняяExpr::Block-обёртка, котораяSpawnтребует, аDetach— нет, у негоbody: Blockнапрямую). Neg-фикстура: точный флагман-паттерн,spec_tests/conformance/neg/detach_consume_escape_neg.nv; pos-твин (обе формыdetach consume):spec_tests/conformance/detach_consume_move_ok.nv. Флагман переписан наdetach consume stream { … }.
Амендмент (owner P1, 2026-07-11):
detachзакрывает идентичный gap. Первоначальная Ф.2-волна вкрутила capture-check только вspawn/parallel for—detach { }(D50: orphan-файбер на worker pool, fire-and-forget, НИКОГДА не join’ится с родителем) имеет РОВНО ту же race-поверхность, но проверка на неё не смотрела:detach { mut_var = … }над внешним не-#sharemutкомпилировалась чисто, хотя orphan-тело могло мигрировать на другой OS-поток и писать в ту же ячейку конкурентно с родителем/siblings без синхронизации. Чекер теперь гоняет ТУ ЖЕcheck_capture_boundaryна телеdetach(ExprKind::Detach,types/mod.rs), диагностика переименована в «spawn/parallel for/detachbody». Neg-фикстура:err173_3/neg/mut_capture_in_detach.nv; pos-твин (ro/#shareвdetachлегален):share_capture_ok_test.nv.
blocking { }— НЕ расширяется. Рассмотрено и отклонено: блок-формаblocking { }ретрактирована Plan 113 (D172) — парсер сегодня ВСЕГДА отвергаетblocking { ... }([D172-block-form-removed]), конструкторExprKind::Blockingбольше не производится (dead AST arm, см.[M-dead-exprkind-blocking-vestigial], backlog-followups.md). Живая замена — атрибут#blockingпередfn-декларацией (Plan 113): это top-levelfn-item со своим параметрам-скоупом, НЕ closure/lambda-литерал — Novafnне замыкает лексический скоуп вызывающего, поэтому у тела#blocking fnструктурно нет capture-поверхности для этой проверки (аргументы передаются обычным by-valuefn-вызовом, что вообще не «захват» в смысле §2).realtime/nogc— тот же класс fn-атрибутов, той же причине не касается.Принцип (согласовано владельцем 2026-07-11), закрывающий асимметрию blocking/detach нормативно — тест для будущих конструктов той же оси: конкурентные конструкты (
spawn/parallel for/detach) — БЛОК-формы: inline-конкуренция требует захвата внешних биндингов лексическим скэном → покрыты capture-check’ом (этот §2); и если unstructured (detach— не joined ни с кем) — требуют capability-эффект (Detach, D50/D414 §2), тогда как structuredspawnjoined супервизором и эффекта не требует. Класс-планирования (blocking/realtime) — АТРИБУТЫ функции (свойство ОПЕРАЦИИ — где/как её тело исполняется, — не inline-блок с лексическим захватом) → захвата нет → capture-check неприменим по построению → эффекта тоже нет (это деталь планирования, не capability; реальную блокирующую I/O-операцию уже моделирует соответствующий I/O-эффект —Net/Fsи т.п. — паркующий файбер сам по себе; отдельныйBlocking-эффект был бы дублем этой поверхности и правильно удалён Plan 113/D172).
§3. §3-философия соблюдена by construction
Компилятор хардкодит только правило (авто-вывод + ядовитая база + чтение
TypeAttr::Share) — ни одного имени типа в Rust-коде предикатов. Mutex не
особый: его share-ность приходит из #share в std/runtime/sync.nv, ровно
как у пользовательского типа.
§4. consume-в-spawn — явный move во владение ребёнка
spawn consume c = expr { body } // связать и отдать ребёнку
spawn consume c { body } // уже связанный c — переотдать ребёнку
Зеркало consume c = expr { body } (D188), но владеющий scope = ребёнок:
cleanup (Cleanup[E], D194/D196) срабатывает на выходе тела РЕБЁНКА, не
лексического блока родителя. Десугар: Spawn(Block[ConsumeScope]) —
переиспользует D188-машинерию verbatim (тело scope’а = тело spawn’а, значит
cleanup-на-выходе-тела = cleanup-на-child-exit «бесплатно», ни одного нового
runtime-примитива). Keyword’а move нет — используется consume (решение §2
п.8 плана: школа Rust move ||, анти-Go-неявный-захват).
Use-after-consume закрыт by construction: биндинг c существует ТОЛЬКО в
теле ребёнка; обращение после spawn — undefined identifier (V1/V2-грабли
173.1). Безопасные не-move захваты (ro, #share, by-value примитив в
ro) — неявные, но проверяемые (§2); полный capture-list
spawn(consume c, ro cfg) отвергнут (§7 Q9): обязательны явные только move’ы.
Амендмент ([M-detach-consume-escape-unchecked], владелец P1, 2026-07-22):
detach consume— та же форма, симметричноspawn consume.detach consume c = expr { body } // связать и отдать orphan-файберу detach consume c { body } // уже связанный c — переотдать orphan-файберуМотивация — см. амендмент к §2 выше:
detach— РОВНО такая же concurrency-граница, какspawn(fire-and-forget orphan-файбер, D50, может пережить лексический scope родителя), и до этого амендмента не имела явного move-эскейпа для consume-типов (spawn consumeуже была). Десугар идентичен:Detach(Block[ConsumeScope])— тот же D188-механизм verbatim, с той разницей, чтоExprKind::Detachуже хранитbody: Blockнапрямую (неBox<Expr>, какSpawn), поэтому обёрткаExpr::BlockвокругConsumeScope, которую требуетSpawn-десугар, здесь не нужна — парсер (parse_detach) строитBlockсо единственнымStmt::ConsumeScopeнапрямую. Cleanup срабатывает на выходе тела orphan-файбера, не лексического блока родителя — тем самым orphan переживает родителя БЕЗ use-after-consume (ownership уже внутри него). Capture-check (check_capture_boundary), codegen ctx-захват (emit_detach— «по образцуemit_spawn», уже было симметрично ДО этого амендмента) и consume-tracker (consume_walk_isolated_block, уже вызывался дляdetachс тем же телом) не потребовали отдельных изменений под эту форму — все три уже были написаны generic поBlock/Stmt::ConsumeScope, не по containing-конструкту.Коррекция (№379, 2026-08-06): последнее утверждение об
consume_walk_isolated_blockбыло неточным для «already-bound»-формы (detach consume c { … }, без= expr): в отличие отspawn, уdetachне было пост-обхода, повторно применяющегоConsumed-состояние в ПЕРЕЖИВАЮЩЕМ (не изолированном)ctxпосле restore (ExprKind::Spawn-ветка вtypes/mod.rsэто делала уже тогда,ExprKind:: Detach-ветка — нет). На практике:consume j = Job.new(1); detach consume j { … }; j.payloadкомпилировался чисто — use-after-consume послеdetachпропускался. Найдено и закрыто тем же слиянием, что ввело мульти-var форму ниже (см. следующий амендмент) — обе ветки теперь зовут общийreapply_spawn_detach_consume_moves.
Амендмент (№379, владелец, 2026-08-06): мульти-var форма
spawn/detach consume a, b, ... { body }.spawn consume a, b, ... { body } // все перечисленные — реальный move в ребёнка detach consume a, b, ... { body } // симметрично, в orphan-файберСписок идентов (≥2) вместо одного — мульти-var мирроринг single-var формы выше, НЕ D188-мультивар сахара
consume A, B, C { body }(03-syntax.md, Plan 174-амендмент): та форма — ЧИСТЫЙ сахар над вложенным re-consume в ТОМ ЖЕ владеющем scope, тело получает RO-view на каждый идент (E_CONSUME_BLOCK_MOVE_OUTзапрещает вынос владения изнутри). Эта форма — противоположность: КАЖДЫЙ перечисленный биндинг переходит в РЕАЛЬНОЕ владение ребёнка, move-out изнутри тела разрешён (как у single-varspawn consume c { … }—re_consume: false, «already-bound» ветка, move-out-запрет не вводился). Смешение со связывающей формой (spawn consume a, b = expr { … }) — парс-ошибка (E_SPAWN_CONSUME_MULTIVAR_BINDING_MIX): список — только re-consume уже существующих владеемых биндингов,=-инициализация остаётся одно-идентной (симметрия с D188-мультивар).Десугар (
parse_spawn_detach_consume_multivar,parser/mod.rs): вложенныеStmt::ConsumeScope— по одному слою на биндинг, самый внутренний слой = настоящее тело пользователя (та же форма, чтоparse_multi_reconsume_scopeстроит для D188-мультивар), КАЖДЫЙ слой сre_consume: false. Момент перехода владения — НЕ порядок вложенности: checker’овский free-variable capture-scan (capture_scan_stmt’sStmt::ConsumeScope-ветка, types/mod.rs) сканирует ВСЁ тело closure’а на owned/linear-ссылки независимо от глубины вложенности и освобождает от захватаinit-идент каждого слоя — так что все перечисленные биндинги захватываются (move’ятся) в ребёнка в ОДНОЙ точке (самspawn/detachstatement), до входа в тело ребёнка. Вложенность — чисто parse-time сахар, не история последовательного move’а в рантайме.Cleanup-порядок: LIFO — последний перечисленный биндинг лежит в самом внутреннем слое (ближе к телу пользователя), поэтому его cleanup срабатывает ПЕРВЫМ на обратном пути наружу — то же правило, что у D188-мультивар (там оно следствие вложенности; здесь оно то же самое, но явно зафиксировано, а не просто «наблюдаемо»).
Use-after-consume для ЛЮБОГО перечисленного биндинга закрыт ТЕМ ЖЕ механизмом, что single-var форма (D131, «использование потреблённой переменной») — обращение после
spawn/detach-statement’а к любому из них падает с той же диагностикой. Чекер-фикс:reapply_spawn_detach_ consume_moves(types/mod.rs) — обобщение прежнего однослойного матча ([Stmt::ConsumeScope { .. }] = b.stmts.as_slice(), ловил ТОЛЬКО первый биндинг) на всю цепочку вложенных слоёв; заодно закрыл gap уdetach(см. коррекцию к амендменту 2026-07-22 выше).Мотивирующий носитель — двунаправленный relay (proxy/tunnel-канон): без этой формы файберу нельзя было отдать обе половинки соединения (
consume (ar, aw) = a.into_split(), №378) — одна уходит move’ом, вторая становится захватом и (правильно) отвергается capture-check’ом. С формой:spawn consume ar, bw { pump(ar, bw) }/spawn consume br, aw { pump(br, aw) }— обе половинки каждого направления явно движутся в свой файбер. Фикстуры:spec_tests/conformance/spawn_detach_consume_multivar_ok.nv(pos, включая 3+-биндинг список, relay-эквивалент на независимых consume-локалах без деструктуризации, и regress single-var);neg/spawn_consume_multivar_use_after_neg.nv,neg/spawn_consume_multivar_binding_mix_neg.nv.
Амендмент (реестр 221.1 №456,
[M-consume-scope-cleanup-not-disarmable], 2026-08-08): ЯВНОЕ потребление биндинга внутри тела ДИЗАРМИТ его авто-@cleanup.spawn consume r, w { // обе половины во владении ребёнка … r.close() // явное потребление → cleanup r ДИЗАРМЛЕН w.close() // то же для w } // на выходе тела cleanup НЕ запускается§4 — ЕДИНСТВЕННАЯ форма, где тело владеет биндингом по-настоящему и move-out изнутри РАЗРЕШЁН (см. амендмент №379 выше: «move-out-запрет не вводился»). Прежняя редакция §4 говорила, КОГДА cleanup срабатывает («на выходе тела ребёнка»), но молчала о том, что делать, если тело этим разрешением воспользовалось. Пробел закрывается прямым следствием exactly-once (D131/D133): потреблённое значение не чистится повторно — иначе одна фибра потребляет ресурс ДВАЖДЫ.
Правило — D432 §4/§5 verbatim, без собственной механики: те же доказанно-безопасные дизарм-точки (голый
return X; receiver-вызов ЗАРЕГИСТРИРОВАННОГО consume-метода типаX, включаяX.close(); передачаXаргументом наconsume-позиции вызова) и тот же рантайм drop-флаг для ветвлений — потребление в ОДНОЙ веткеif/matchне подавляет cleanup на ветке, где потребления не было. Порядок LIFO по слоям (амендмент №379) сохраняется: дизарм индивидуален для каждого перечисленного биндинга.Почему у Plan-201 re-consume формы вопроса не возникало. У
consume X { … }(D188-амендмент) ровно то же действие — ошибкаE_CONSUME_BLOCK_MOVE_OUT(§«Правила формы» п.4): тело получает ro-view, а не владение, поэтому дизармить нечего. Именно поэтому «переиспользует D188-машинерию verbatim» (текст §4 выше) не давало ответа для ЭТОЙ формы — D188 запрещает то, что §4 разрешает.Что было сломано до поправки. Флаг
_activeвзводился, но НИ ОДНА дизарм-точка не могла его найти: реестры имени → флаг наполнялись только для Plan-201 re-consume-скоупов и для bare consume-let’ов (D432 §2), аre_consume=false-скоуп не попадал ни в один. Итог — безусловный второй consume на выходе тела. На носителе (TcpReadHalf/TcpWriteHalfотinto_split()) это давало use-after-free: одна фибра в одиночку гнала общийsplit_refcount2→1→0 и запускала реальное закрытие, после чего вторая половина читала освобождённую память (refcountуходил в −1), куча портилась и процесс зависал внутриGC_gcollect(). Рантайм этого различить не может — «закрылась вторая половина» и «та же половина закрылась дважды» для него неотличимы; identity даёт только компилятор. Поэтому защитный флаг «уже закрыто» вstd/src/net/tcp.nvбыл отвергнут как маскировка.Фикстуры:
spec_tests/conformance/m456_consume_scope_explicit_close_ disarm.nv— ДЕТЕРМИНИРОВАННЫЙ страж класса (счётчикResourceTrace.on_resource_exit; обе формы потребления — receiver-вызов и consume-параметр хелпера; контроль анти-передизарма «тело не потребляет → cleanup обязан сработать»; контроль MaybeConsumed);standalone/m456_crisscross_split_evloop_crash.nv— исходный носитель на двух реальных TCP-соединениях (стохастический, ~1 % прогонов, гейтом класса служить не может).
§5. Поглощение #fiber_send / E_POINTER_CROSS_FIBER (Plan 118.3) — Ф.4
Ad-hoc запрет Plan 118.3 «сырой *T через границу файбера»
(E_POINTER_CROSS_FIBER + opt-out #fiber_send) поглощён принципиальной
проверкой: *T = ядовитая база (§1), содержащий тип не-share, mut-захват →
E_CONCURRENT_MUT_CAPTURE (фикстура err173_3/neg/pointer_cell_not_share).
#fiber_send/E_POINTER_CROSS_FIBER в компиляторе никогда не существовали
(118.3 Ф.1 не был реализован) — ретайр чисто документальный: opt-out-маркер
per-binding не вводится (эскейп = #share-vouch на обёрточном ТИПЕ,
аудируемый в одном месте — декларации, не на каждом биндинге).
§6. Конкуренция в module-level инициализаторах → E_CONST_INIT_CONCURRENCY
Дополнение волны ([M-const-init-concurrency-gate]; амендмент к D199-partition,
03-syntax.md): module-level ro/const-инициализаторы исполняются в
nova_consts_init() ДО арминга M:N-воркеров (eager-фикс
[M-lazy-const-init-race]). Конкуренция там лениво подняла бы воркеров
(_auto_arm_if_needed()) ПОСРЕДИ consts_init — хвост инициализации стал бы
multi-threaded, возврат гонки. Чекер отвергает в init-выражениях:
spawn / detach / supervised / parallel for / select, канальные
блокирующие вызовы .send()/.recv() (паркуют без воркеров = pre-main hang;
имя-эвристика по D91-поверхности; неблокирующие try_send/try_recv
легальны), вызовы свободных fn с эффектом Detach в сигнатуре →
[E_CONST_INIT_CONCURRENCY]. Обычные runtime/extern-вызовы и аллокация
в ro-init ОСТАЮТСЯ легальными (строже — только constexpr-правило const,
E_CONST_EFFECT_IN_INIT).
§7. Резолюции под-вопросов плана (V1)
- Захваты хендлеров (Q12): capability-walker не обходит тела
handler-op-литералов (эффект-полиморфны, D414 §2) — handler-state вне
периметра скана V1; эффект-row не несёт
#share-обязательства. Полная HOF-эффект-полиморфная проверка — вне периметра 173 (хаб НЕ-цели). #share-nameability результата spawn/эффекта: не нужен в V1 — spawn возвращает unit (D50), результаты через#share-примитивы/каналы; RTN- аналог не вводится.- Opt-out
!share: НЕ вводится (Rust держал!Sendнестабильным годами). Fiber-affine handle выражается ядовитой базой (сырое*T-поле) — тип автоматически не-share. - Точная ядовитая база:
*T-указатель + записываемая ячейка (mut-поле вне audited-типа). Обходной путь внутренней мутации —ref T(алиас на storage): предикат прозрачно рекурсирует в referent с ТЕМ ЖЕ уровнем доступа — обхода нет. ro/D246 и#share: derive кормит ось ДОСТУПА, не биндинга: immutable-поле — read-правило,mut-поле — mut-правило;ro-БИНДИНГ на границе — всегда ок (D246 deep-immutability). Известная V1-дырка:ro- алиас pointee, у которого ЖИВ второй mut-алиас у родителя, не ловится — зафиксировано как ограничение (направление — недолов).- Половинки канала: обе выражаются
#share+consumeбез спец-кейса —ro (tx, rx)-биндинги захватываются какro(send/recv не требуют mut); в десугаре сбора 173.1 Sender’ы clone+move (решение §2 п.9 плана). - Region/
sending-инференс (Swift SE-0414): НЕ нужен —consume/move покрывает передачу уникального,#share— алиасинг; регионов нет. - Диагностика leakage: сообщение E_CONCURRENT_MUT_CAPTURE на use-site
называет биндинг, лечения И путь первого отказа per-field (закрыто,
[M-173.3-share-leakage-explain]2026-07-10): предикаты share_check возвращают цепочкуType.field.subfield+ rendered-тип + причину («writable cell» / «raw pointer» / «unresolved name» / …) — например`Outer` is poisoned at `Outer.inner.buf` (`[]u8`): a writable cell with no audited synchronization. Добавление не-sharemut-поля вглубь типа больше НЕ снимает share-ность молча. - Полнота явности захвата: принята РЕКОМЕНДАЦИЯ плана — явные только
move’ы (
spawn consume);ro/#share/by-value — неявные-но-проверяемые. Полный capture-list отвергнут (многословен для каждого loop-var).
Диагностики / тесты
| код | что |
|---|---|
E_CONCURRENT_MUT_CAPTURE | mut-захват не-mut-alias-safe типа телом spawn/parallel-for/detach |
E_SHARE_INVALID_KIND | #share на kind без instance-идентичности |
E_CONST_INIT_CONCURRENCY | конкуренция в module-level ro/const-инициализаторе |
E_LINEAR_CAPTURE_IN_FIBER | consume-тип (или явный consume-pattern-биндинг) захвачен в spawn/parallel-for/detach без move (Plan 201/173.1; [M-detach-consume-escape-unchecked]) |
Тесты: nova_tests/err173_3/ — pos share_capture_ok_test (ro / #share-
примитивы / spawn consume / авто-share user-тип / user #share-vouch),
const_init_runtime_ok_test; neg mut_capture_in_spawn,
pointer_cell_not_share, mut_field_nested_path (вложенный 2-сегментный
путь отказа, §7 п.8), share_invalid_kind, use_after_spawn_consume
(by construction), const_init_{spawn,supervised,detach_fn}. Unit —
protocols/share_check.rs::tests (15, вкл. 6 на путь отказа). Миграция разом (решение §2 п.10):
std (concurrency/cancellation.nv within/race2 — реальные TOCTOU-гонки,
переведены на каналы; http/servernet smoke — на каналы) + nova_tests
(~19 файлов — на Atomic*).
[M-detach-consume-escape-unchecked] (2026-07-22, §2/§4 амендменты выше) —
detach consume-форма + match/if-let/while-let scope-фрейм: neg
spec_tests/conformance/neg/detach_consume_escape_neg.nv (точный
флагман-паттерн), pos spec_tests/conformance/detach_consume_move_ok.nv
(обе detach consume формы). Флагман (examples/flagship/aggregator/src/ main.nv) переписан на явный move.
D416. Supervision-as-effect — Supervisor/on_child_fail(idx int, err any) -> Decision (Plan 173.2)
Дом Plan 173.2 (sign-off решений §2 2026-06-21; §3b-резолюция owner 2026-06-26; реализация 2026-07-10). Стоит на субстрате Plan 173.0 (per-slot
child_error[]retention + serialized decision-loop + R1-guard) и D414 (precedence PANIC > USER > CANCEL). Erlang-style supervision = handler strategy (README), НЕ фиксированный набор стратегий-имён.
§1. Эффект и словарь
Supervisor — настоящий эффект в std/prelude/effects.nv (§3 — не хардкод
в Rust; лоуэринг = обычный user-effect путь, как Random):
type Decision enum Escalate | Stop
type Supervisor effect {
on_child_fail(idx int, err any) -> Decision
}
idx— retention-слот упавшего ребёнка (spawn-порядок remote-детей, 173.0child_error[]);err— ошибка какany: typed-throw payload (сужаетсяerr is T), string-throw / panic —str-сообщение.- Стратегии = хендлеры (значения эффекта):
with Supervisor = policy { … }, какTime/Fail. Встроенные политики —std.concurrency.supervisor.escalate()/.stop(). Имён-стратегий как сущности нет. - Амендмент 2026-07-10 (решение владельца):
Restart-семейство РЕТРАКТИРОВАНО из словаря (изначальный §3b-MVP держалRestart/RestartAll/RestartRestв типе за компайл-гейтомE_SUPERVISOR_RESTART_GATED). Мотив: рестарт — идиома акторных систем (Erlang/OTP: долгоживущий изолированный процесс), аsupervised— структурная конкуренция с лексическим scope’ом; эталоны класса (KotlincoroutineScope, SwiftTaskGroup, JavaStructuredTaskScope.Joiner) рестарта не имеют. Повтор попытки идиоматично живёт ВНУТРИ тела ребёнка (std/concurrency/retry) — там нет проблемы изоляции полусостояния. СловарьEscalate | Stop— ПОЛНЫЙ, решение прод-реди; параметрattemptне нужен (был осмыслен только с Restart).
§2. Семантика исполнения
- Дефолт (нет хендлера) = сегодняшний all-or-throw, байт-паритет.
Supervision-ветка рантайма не активируется вовсе: codegen штампует
has_supervisor = (_nova_handler_Supervisor != NULL)на входе scope’а (supervised/ array-modeparallel for), false → все новые ветки мертвы, репорт-пути byte-идентичны до-173.2. - Хендлер установлен → deferred-decision режим scope’а: падение ребёнка
пишется ТОЛЬКО в его per-slot
child_error[idx](release-publish per-slot; ни CAS-выбора primary, ни cancel-бродкаста) — выбор реакции принадлежит супервизору, а не упавшему. - Сериализация:
on_child_failисполняется на drive-потоке scope’а (drain-циклnova_supervised_run_impl— решения принимаются ПОКА siblings ещё бегут + финальный catch-up после дрейна, строго до освобождения retained SpawnCtx), по одному падению за раз, в порядке слотов; ровно один вызов на падение. Escalate— ошибка ребёнка идёт в primary-machinery scope’а (тот же precedence-путь D414 PANIC > USER > CANCEL + cancel_requested, что и на дефолте) → siblings кооперативно отменяются, scope перевыбрасывает primary наружу.Stop— ребёнок «выкинут» (Erlang temporary): без эскалации, без отмены siblings, scope продолжает; ошибка остаётся retained per-slot (не теряется молча — D414 §критерий 3).- panic ребёнка доходит до решения (kind PANIC — supervision на границе fiber’а санкционирована D13); индуцированные отмены siblings (CANCEL) хендлеру не показываются — следствие эскалации, не корневое падение.
- Мост рантайм↔Nova:
_nova_supervisor_decide_fn— fn-pointer, назначаемый generated main() (паттерн_nova_throw_scope_timeout_fn); импл (_nova_supervisor_decide_impl, emit_c splice) читает ambient TLS-vtable, боксируетNovaChildErrorвany, маппит тегDecisionвNOVA_SUPERVISE_*-коды.
§3. Ограничения хендлера (closed handler effect-set, Q-блок)
throw/Failразрешён = Escalate-with-handler-error: вызов хендлера огорожен локальным fail-frame (guard от handler-fails-self / longjmp мимо drain-цикла); брошенная хендлером ошибка идёт в primary-machinery scope’а первой (precedence решает исход: PANIC ребёнка всё равно бьёт USER хендлера — D13).interruptзапрещён —[E_SUPERVISOR_HANDLER_INTERRUPT]: ранний выход из with-блока бросил бы drain с живыми детьми.- suspend-операции запрещены —
[E_SUPERVISOR_HANDLER_SUSPEND](компилируемое приближение V1: прямойTime.sleepв теле хендлера; транзитивный вызов через функцию — followup эффект-row-анализом).
§4. Restart — РЕТРАКТИРОВАН (амендмент 2026-07-10, superseded §3b-гейт)
Исходная §3b-резолюция держала Restart-варианты в словаре за компайл-гейтом
[E_SUPERVISOR_RESTART_GATED] до появления изоляции restartable-тела.
Решением владельца 2026-07-10 семейство УДАЛЕНО из словаря целиком (мотив —
§1): гейт-диагностика ретрактирована вместе с вариантами, ссылка на
Decision.Restart — обычный unknown-variant; runtime-мост маппит любой
не-Stop тег в Escalate (defensive). Маркер [M-173.2-restart-all-rest]
закрыт ретракцией. Повтор попытки — std/concurrency/retry внутри тела.
§5. Периметр
Политика применяется к remote-детям armed M:N runtime — дефолтный путь
исполнения (auto-arm в main; источник (idx, err) = per-slot
child_error[] 173.0, который заполняет только remote-путь).
Bootstrap/single-thread (NOVA_AUTOARM=0) и implicit main-scope
(top-level detach) остаются на дефолтном Escalate-all — честно
задокументированное ограничение субстрата, не тихий пропуск политики.
Диагностики / тесты
| код | что |
|---|---|
E_SUPERVISOR_HANDLER_INTERRUPT | interrupt в теле Supervisor-хендлера |
E_SUPERVISOR_HANDLER_SUSPEND | Time.sleep в теле Supervisor-хендлера |
(E_SUPERVISOR_RESTART_GATED ретрактирован вместе с Restart-семейством — §4.)
Тесты: nova_tests/err173_2/ — pos supervisor_stop_test (siblings живут,
scope продолжает; multi-fail; panic-до-решения), supervisor_escalate_test
(паритет с дефолтом на идентичном теле; custom-политика с mut-состоянием и
err is int-narrowing + idx-диапазон; Escalate-with-handler-error),
supervisor_parfor_test (Stop = «собери выживших», dense []T); neg
handler_interrupt_neg, handler_sleep_neg (restart_gated_neg удалён
вместе с ретракцией §4 — unknown-variant покрыт общей диагностикой).
Модульные: std/concurrency/supervisor_test.nv.
D425. CAS возвращает свидетеля провала: compare_exchange bool → Result[(), T] (Plan 207)
Статус: ✅ landed (Plan 207, 2026-07-15). Amends D168 §1 (матрица операций). Retag
[M-cas-return-witnessed-value].
Мотив
AtomicI*/U*/Isize/Usize/Ptr/Bool (легаси AtomicInt тоже) compare_exchange/
compare_exchange_weak возвращали bool — при провале выбрасывали
свидетеля (актуально прочитанное значение). C-примитив
__atomic_compare_exchange_n(&obj, &expected, desired, weak, ...) УЖЕ пишет
фактически прочитанное значение в expected при провале (и оставляет
expected неизменным при успехе) — ровно то, что нужно в CAS-retry-цикле,
чтобы пересчитать без повторного load() (лишний барьер + окно гонки).
Обеднение было неосознанным — witness лежал в expected бесплатно (найдено
при дизайне 206, тот же принцип
«примитив не теряет информацию»).
Правило
Новая сигнатура (заменяет bool во всех строках D168 §1, отмеченных *):
fn AtomicX mut @compare_exchange(expected T, desired T) -> Result[(), T]
fn AtomicX mut @compare_exchange(expected T, desired T, success MemOrdering, failure MemOrdering) -> Result[(), T]
fn AtomicX mut @compare_exchange_weak(expected T, desired T) -> Result[(), T]
fn AtomicX mut @compare_exchange_weak(expected T, desired T, success MemOrdering, failure MemOrdering) -> Result[(), T]
Ok(())— успех, значение заменено наdesired.Err(actual)— провал;actual— фактически прочитанное значение (witness). Дляcompare_exchange(strong) провал гарантируетactual != expected(C11: strong CAS не фейлится spuriously). Дляcompare_exchange_weakwitness корректен и в spurious-failure случае (actual == expected, но своп не произошёл — платформо-зависимая ARM-семантика; вызывающий код должен различать черезErr, не через сравнение значений).- Применяется ко всем 11 CAS-методам (было 13 на момент landing 2026-07-15:
D426,
2026-07-16, снял легаси
AtomicIntиAtomicPtr, переименовал Isize/Usize → Int/Uint):AtomicI8/I16/I32/I64,AtomicU8/U16/U32/U64,AtomicInt,AtomicUint,AtomicBool.
CAS-retry-цикл идиома (после правки, мотивирующий пример):
mut cur = a.load()
loop {
ro next = f(cur)
match a.compare_exchange(cur, next) {
Ok(_) => break
Err(actual) => cur = actual // без повторного load(), witness даром
}
}
Лоуэринг (codegen)
Публичный compare_exchange/compare_exchange_weak — plain (non-extern)
.nv fn (не intrinsic): вызывает private @cmpxchg intrinsic (module-
private, не exported), который возвращает raw (ok bool, witness T) пару из
ОДНОГО atomic op (C11 __atomic_compare_exchange_n, weak-флаг передан
явным параметром — strong и weak делят один intrinsic), затем строит
Ok(())/Err(witness) обычным Nova constructor-синтаксисом. Result[(), T]
монoморфизируется штатным generic-codegen (как Vec.binary_search -> Result[int,int]) — hand-written C не участвует в сборке Result.
Raw-пара представлена named-tuple типом CasRaw<W>(ok bool, witness T)
(один на каждую witness-ширину: I8/I16/I32/I64/U8/U16/U32/U64/Int/Uint/Bool —
CasRawInt общий для AtomicInt; до D426
(2026-07-16) его также разделяли AtomicPtr и легаси AtomicInt, оба сняты).
C-структура
(NovaTuple_CasRaw*) — hand-written в sync_primitives.h, зарегистрирована в
RUNTIME_DEFINED_TYPES (emit_c.rs) — та же конвенция, что MutexGuard/
MemOrdering. Потребовалось расширение codegen (emit_type_decl,
RUNTIME_DEFINED_TYPES-ветка): для NamedTuple-типов в этом списке компилятор
теперь регистрирует field-schema/NovaTuple_<Name> type-alias БЕЗ повторной
эмиссии struct-body (раньше эта ветка обрабатывала только Sum/Effect
kind — NamedTuple падал в generic unknown-type fallback, Nova_<Name>*
pointer-record, что не совпадало с реальным value-struct’ом в хедере).
Границы
Только форма возврата CAS (bool→Result). НЕ меняет memory-ordering, НЕ
трогает fetch_*/load/store. Закрывает backlog [M-cas-return-witnessed-value].
Амендмент (cmpxchg-lint волна B, 2026-07-16) — компиляторная диагностика success/failure ordering
Решение владельца: compile-time варн на
failure-ordering сильнееsuccess-ordering — ОК.
Call-сайты compare_exchange/compare_exchange_weak (все Atomic*-типы, 4-арная
explicit-ordering overload) с литеральными MemOrdering-аргументами получают
две проверки. Не-литеральные (runtime-переменная) success/failure не
диагностируются — компилятору неизвестно значение.
-
Hard-error
E_CAS_FAILURE_ORDER_INVALID:failure ∈ {Release, AcqRel}. Семантически невалидно — failure-путь CAS это чистый load (сравниваемое значение НЕ изменилось), у него нет release-семантики. C11/C++11atomic_compare_exchange_*явно запрещалиRelease/AcqRelкак failure-memory-order (UB/ill-formed); это правило переносит запрет в compile-time диагностику Nova вместо тихого UB/undefined C-поведения. -
Warning
W_CAS_FAILURE_STRONGER:strength(failure) > strength(success)по тотальному порядкуRelaxed < Acquire ≈ Release < AcqRel < SeqCst(Acquire/Release— ничья: разнонаправленная синхронизация, ни один не «сильнее»). С C++17 такая комбинация формально валидна (ограничение «failureне сильнееsuccess» из C++11 снято), но почти всегда ошибка намерения — failure-путь (CAS не удался) обычно не должен требовать БОЛЬШЕ синхронизации, чем success-путь. Non-fatal — компиляция продолжается.
Легальные (нет диагностики) пары (success, failure): SeqCst/SeqCst,
AcqRel/Acquire, Release/Relaxed — и любая пара с strength(failure) <= strength(success) и failure ∉ {Release, AcqRel}.
Реализация: чекер (compiler-codegen/src/types/mod.rs::check_cas_ordering,
вызывается из BoundCtx::walk_expr — hard-error, errors-sink) + AST-lint
(compiler-codegen/src/lints.rs::lint_cas_failure_stronger, вызывается из
walk_expr_lints — non-fatal LintWarning-sink, тот же split что
W_PRELUDE_SHADOW). Receiver распознаётся по префиксу Atomic (не список
конкретных ширин) — новые sized-варианты семейства подхватываются без правки
диагностики.
Замечание (после merge с D426):
compare_exchange/compare_exchange_weak — ОДНА сигнатура с default-параметрами
(success MemOrdering = MemOrdering.SeqCst, failure MemOrdering = MemOrdering.SeqCst),
не отдельные 2-арг/4-арг overload’ы. Параметры с default — keyword-only (D102):
success/failure, если переданы, ОБЯЗАНЫ передаваться по имени
(a.compare_exchange(cur, next, success: MemOrdering.Relaxed, failure: MemOrdering.Acquire)) — позиционная передача (даже полная, все 4 арг-та) — compile
error, не диагностика этого амендмента. Каждый из success/failure может быть
пропущен независимо (например только failure: X при пропущенном success) — тогда
его значение — известный литерал MemOrdering.SeqCst (тот же default, что в
сигнатуре), участвующий в обеих проверках наравне с explicit литералом, а не молча
пропускаемый.
D426. Atomic-семейство: консолидация имён — AtomicIsize/AtomicUsize → AtomicInt/AtomicUint, легаси AtomicInt и AtomicPtr сняты (Plan 207)
Статус: ✅ landed (Plan 207, 2026-07-16). Amends D168 (таблица типов, §1 матрица, §4 AtomicPtr, Эволюция) и D425 (список CAS-методов, CasRaw witness-width). Решение владельца 2026-07-16.
Что
До этого амендмента atomic-семейство содержало имена, не соответствующие
типам Nova: isize/usize не существуют как типы языка (int/uint уже
address-sized, intptr_t/uintptr_t — Plan 133), а AtomicPtr был
нетипизированным int-proxy — дублем AtomicInt под другим именем, причём его
собственный doc-comment заявлял GC-root-регистрацию, которой в
sync_primitives.h не было (голый __atomic_* над nova_int, без единого
вызова root-API). Параллельно существовал отдельный legacy AtomicInt
(int32-precision, без MemOrdering-параметров, добавлен раньше Plan 103.2 и
никогда не обновлённый) — узкое подмножество API нового address-sized типа.
Три независимых изменения одним коммитом:
AtomicIsize→AtomicInt. LegacyAtomicInt(int32-backed, узкий API:new/load/store/fetch_add/fetch_sub/compare_exchange— все безMemOrdering) снят целиком; его вызовы покрываются 1:1 дефолтными SeqCst-перегрузками переименованного типа (все параметры с ordering у нового типа имеют= MemOrdering.SeqCstдефолт — Plan 207 cmpxchg-rename, тот же коммит-серия). ИмяAtomicIntвысвобождено под переименование.AtomicUsize→AtomicUint. Прямое переименование, без конфликта имён (legacyAtomicUintне существовало).AtomicPtr— снят. До появления typed genericAtomicPtr[T](Plan 103.7, требует GC-root integration в codegen — non-trivial) тип не существует в Nova. Потребители — только тесты (3 spec_tests файла); продакшн-кода, хранящего GC-managed адреса черезAtomicPtr, не найдено.AtomicInt— прямая замена там, где нужен голый platform-word int-proxy (без каких-либо GC-гарантий, которыхAtomicPtrи не давал).
Правило
AtomicInt(былоAtomicIsize) — platform-word signed atomic,int=nova_int=intptr_t. Полный API:new/load/store/swap/fetch_add/ fetch_sub/fetch_or/fetch_and/fetch_xor/fetch_max/fetch_min/fetch_nand/ compare_exchange/compare_exchange_weak, все с default-SeqCst и explicit-MemOrderingперегрузками (2-арг / 3-арг с позиционным overridesuccess/ 4-арг с явнымиsuccess+failure— дефолт-параметры, не отдельные overload-декларации после Plan 207 cmpxchg-rename).AtomicUint(былоAtomicUsize) — тот же API,uint=nova_uint=uintptr_t.AtomicPtr— не существует. Call-сайты, хранящие адрес как int-proxy — мигрировать наAtomicInt. TypedAtomicPtr[T]с GC-tracing — Plan 103.7 (не переиспользует C-реализацию снятого V1; будет отдельный дизайн).RUNTIME_DEFINED_TYPES/debt_is_runtime_backed_newtype/BUILTIN_RUNTIME_TYPES/RUNTIME_NATIVE_CONCRETE_TYPES(4 независимых name-list’а вemit_c.rs, историческая accretion — не консолидированы в этом амендменте, вне scope) обновлены синхронно:AtomicIsize/AtomicUsizeпереименованы,AtomicPtrудалён, дубликатAtomicInt-записи (legacy + Isize) схлопнут в одну.
Почему
- Имена типов Nova —
int/uint, неisize/usize. Rust-стиль именование (Isize/Usize) вводило читателя в заблуждение — предполагало существование отдельных типовisize/usize, которых в Nova нет (Plan 133 сделалint/uintaddress-sized универсально). Имя atomic-типа обязано отражать имя wrapped-типа. AtomicPtrбыл дублем, не отдельной абстракцией. Ни один production потребитель не зависел от заявленной (и не реализованной) GC-root семантики — удерживать нетипизированный int-proxy под отдельным именем до появления настоящегоAtomicPtr[T]не давало преимущества надAtomicIntи создавало ложное впечатление GC-безопасности.- Legacy
AtomicInt(int32) был исторической случайностью, не дизайн-решением. Добавлен до формализации Plan 103.2 sized-atomics, никогда не расширялся (нетMemOrdering, нетswap/fetch_or/and/xor), его собственный doc-comment уже помечал его как «deprecated alias» (ошибочно — не алиасAtomicI64, а отдельный int32 тип). Держать имя занятым под мёртвый API блокировало переиспользование под address-sized тип.
Что отвергнуто
- Оставить
AtomicPtrкак есть, просто задокументировав отсутствие GC-root. Живой тип с ложной семантикой в имени (Ptrподразумевает указатель-специфичные гарантии) хуже, чем явное отсутствие типа до прихода корректной typed generic формы. - Переименовать legacy
AtomicIntво что-то другое вместо снятия. Узкий API (без ordering) не нёс уникальной ценности — 100% его вызовов покрывается дефолт-параметрами нового типа; держать две параллельные реализации одного и того же концепта (address-sized atomic int) — чистый долг.
Связь
- D168 — sized atomic types API contract (амендированная таблица типов).
- D425 — CAS witness value (амендированный список методов/CasRaw).
- [reference-nova-int-intptr-not-i64.md] (проектная память) —
int=nova_int=intptr_t(address-sized), НЕint64_t; тот же принцип применён кuint/nova_uint/uintptr_t. - Plan 103.7 — typed generic
AtomicPtr[T]с GC-root integration (deferred; не переиспользует V1 C-реализацию, снятую этим амендментом). - Plan 207 — cmpxchg-rename (тот же коммит-серия):
@__cas_raw→@cmpxchgintrinsic rename + схлопывание ordering-перегрузок в default-параметры — предпосылка для п.1 (дефолты покрывают legacy 2-арг вызовы).
Реализация
std/src/runtime/sync.nv: legacyAtomicInt-секция иAtomicPtr-секция удалены;AtomicIsize/AtomicUsize→AtomicInt/AtomicUint(идентификатор + doc-comments + cross-ref «See Also» вAtomicI64/AtomicBool).compiler-codegen/nova_rt/sync_primitives.h: те же три изменения на C-уровне (Nova_AtomicInt/Nova_AtomicPtrlegacy-структуры удалены,Nova_AtomicIsize/Nova_AtomicUsize→Nova_AtomicInt/Nova_AtomicUint).compiler-codegen/src/codegen/emit_c.rs: 4 name-list’а обновлены (RUNTIME_DEFINED_TYPES,debt_is_runtime_backed_newtype’sRUNTIME_BACKED_NEWTYPES,BUILTIN_RUNTIME_TYPES,RUNTIME_NATIVE_CONCRETE_TYPES).- Тесты:
std/src/runtime/sync_test.nv,spec_tests/conformance/plan103_2_atomic_isize_usize.nv,spec_tests/conformance/neg/plan103_2_atomic_isize_no_such_method_neg.nvмигрированы 1:1 (переименование идентификаторов, не ослабление).spec_tests/conformance/plan103_2_atomic_ptr_basic.nvиspec_tests/conformance/neg/plan103_2_atomic_ptr_no_such_op_neg.nvудалены вместе со снятым типом (не ослабление — тип снят решением владельца, тестировать нечего).
Границы
Только переименование/снятие в atomic-семействе. НЕ меняет memory-ordering,
fetch_*/load/store/compare_exchange семантику сохранённых типов
(D425 остаётся в силе). НЕ вводит AtomicPtr[T] (Plan 103.7 — отдельная
работа). НЕ консолидирует 4 отдельных name-list’а в emit_c.rs в единый
источник (историческая accretion, вне scope этого амендмента).
D439. supervised{} — прямая блокирующая операция в теле без spawn (Plan 221.1 №162)
Статус: ✅ landed (реестр 221.1 №162, P1, 2026-07-30), **ограничение «cancel:/timeout: не защищают прямую блокирующую операцию тела» (Правило, третий пункт, и разбор в «Почему» ниже) ОТМЕНЕНО амендментом D442 (реестр 221.1 №165, 2026-08-01) —
cancel:/timeout:ОБЯЗАНЫ теперь покрывать и её. Текст этого D-блока ниже сохранён как исторический разбор корневой причины D439-бага (_nova_active_scope/_nova_active_slotне подменяются на время тела — это решение НЕ отменяется, D442 его не трогает); НЕ читать раздел «Правило» ниже как действующий — см. D442. Amends D14 (невидимая приостановка — распространяет гарантию наsupervised-тело без spawn), D50 (spawn-only concurrency model — уточняет, что НЕ-spawn’нутый код в теле тоже легитимен), D71 (bootstrap scheduler) и D75 (структурная отмена — уточняет её границу). Решение владельца 2026-07-30 (P1, срочно).
Что
Ни один из D14/D50/D71/D75 не описывал явно, что происходит, когда
блокирующая операция (Time.sleep, Channel.recv, select, любой
sync-primitive wait) написана непосредственно в теле supervised { ... }
— то есть НЕ внутри вложенного spawn { ... }. На практике (до этого
амендмента) реализация ронял процесс на первой же такой операции:
nova: nova_sched_park: invalid scope/slot
supervised { spawn { <блокирующая операция> } } работал; supervised { <блокирующая операция> }
(без spawn) — падал; без ОБОИХ обёрток (голый top-level) — работал.
Это была недоработка рантайма, не намеренно неподдерживаемая форма —
владелец классифицировал это как P1-баг языка/рантайма, не «программист
должен обернуть в spawn».
Правило
- Блокирующая операция, написанная непосредственно в теле
supervised{}(без промежуточногоspawn), паркуется и резюмируется штатно — так же, как если бы та же операция была написана внеsupervisedвообще. Она не аварийно завершает процесс и не требует оборачивания вspawnдля простой корректности. - Join-семантика этого
supervised-scope (дождаться всехspawn’нутых детей) не меняется: прямая блокирующая операция в теле ничего не меняет в порядке/итоге join’а — она просто выполняется как обычный statement тела, до начала join-цикла. - Cancel/deadline-семантика (
supervised(cancel: tok)/supervised(timeout:/deadline:), D75/D408) применяется только к зарегистрированным детям (spawn { ... }), НЕ к прямой блокирующей операции тела. Причина — структурная, не произвольное ограничение: enforcement дедлайна/токена (nova_scope_deliver_cancelна просрочкуdeadline_ns, forced-wake парковавшихся детей) выполняется ИСКЛЮЧИТЕЛЬНО изнутри join-цикла этого scope (nova_supervised_run/nova_supervised_run_cancel), который стартует строго после того, как все statement’ы тела (включая прямую блокирующую операцию, пока она паркуется) уже завершились. Пока тело выполняется, join-цикл ЭТОГО scope ещё не запущен — прервать парковку внутри тела попросту некому. - Если нужна защита дедлайном/токеном именно для конкретной блокирующей
операции — обернуть её в
spawn { ... }: тогда она получает собственный слот в scope и участвует в join-цикле (и его cancel/deadline-enforcement) с первого же резюма, как любой другой ребёнок.
// Работает (эта редакция): паркуется/резюмируется штатно, join ждёт
// spawn'нутого ребёнка тоже.
supervised {
spawn { child_work() }
(5).to_millis().sleep() // прямой блок в теле — НЕ падает
}
// timeout НЕ защищает прямой блок тела — он не является child'ом:
supervised(timeout: 10.to_millis()) {
long_blocking_call() // если реально блокирует >10мс — ждёт
// ПОЛНОСТЬЮ, timeout проверяется только
// в join-цикле (которого тут пока нет)
}
// Хотите, чтобы timeout мог прервать именно эту операцию — spawn:
supervised(timeout: 10.to_millis()) {
spawn { long_blocking_call() } // теперь под join-цикл + cancel
}
Почему
Корень бага (найден инструментированием nova_sched_park/
nova_scope_init/emit_supervised): nova_scope_init всегда стартует
scope с count == 0 (fibers.h) — слоты появляются только через
nova_fiber_spawn_into/nova_scope_alloc_slot, вызываемые для
по-настоящему spawn’нутых детей. Тело supervised{} выполняется
inline, на ТОЙ ЖЕ coroutine, что вошла в блок — кодогенерация
(emit_supervised) лишь подменяла TLS _nova_active_scope на новый
scope, не трогая _nova_active_slot; прямой блокирующий вызов затем
парковался на (новый_scope, старый_слот) — nova_sched_park’овская
проверка slot < scope->count проваливалась (count всё ещё 0), и
процесс падал.
Первая рассмотренная и отвергнутая идея (см. ниже) — завести
«self-slot» для тела в НОВОМ scope, зеркалируя то, как
emit_nova_main_scoped_inner заводит тело main как отдельный fiber
внутри _nova_main_scope. На практике это НЕ работает: единственный
корректный «водитель» (тот, кто реально вызывает mco_resume на этой
coroutine) — это ВНЕШНИЙ (родительский) scope или голый main(), чья
собственная bookkeeping-структура ничего не знает про парковку,
зарегистрированную во ВНУТРЕННЕМ scope. Внешний водитель, не увидев свой
собственный parked-бит выставленным, на следующей же итерации своего
цикла резюмирует ту же coroutine повторно — double-resume гонка поверх
single-winner park/wake протокола. Эмпирически: abort сменился на hang
(таймаут), не на исправление.
Настоящий фикс (emit_supervised, emit_c.rs) — НЕ подменять
_nova_active_scope/_nova_active_slot вообще на время выполнения
statement’ов тела. Эта пара TLS и так корректно указывает на (scope,
slot), под которым coroutine РЕАЛЬНО управляется прямо сейчас
(унаследовано от того, кто её резюмировал — внешний nova_supervised_step
либо main()’s собственный drive-loop) — то есть именно то, на чём
обязана парковаться прямая блокирующая операция, чтобы быть найденной и
разбуженной корректно. spawn{} внутри тела не страдает: он маршрутизуется
через compile-time-переменную current_scope_queue (никогда не через эту
runtime TLS), а spawn’нутый ребёнок получает СВОЮ корректную
(scope,slot) в первый момент, когда join-цикл (nova_supervised_run этого
же scope) его реально резюмирует.
Побочный эффект (документированное правило выше, не баг): раз
_nova_active_scope/_nova_active_slot в теле больше не указывают на
этот НОВЫЙ scope, прямая блокирующая операция физически не может быть
найдена/прервана enforcement’ом ЭТОГО scope (его parked[]/parked_co[]
— пустые массивы до первого spawn). Раз join-цикл этого scope — ЕДИНСТВЕННОЕ
место, где вообще проверяется его deadline_ns/cancel_requested, а
он стартует только после statement’ов тела — эта асимметрия существовала
бы при ЛЮБОМ решении, не завязанном на реальное создание второй coroutine
для тела (что стоило бы отдельного, гораздо большего архитектурного
изменения — вне P1-рамок этой правки).
Что отвергнуто
- Self-slot тела в новом scope (
nova_scope_alloc_slot(new_scope, mco_running())+ подмена_nova_active_slot) — двойная регистрация одной coroutine в двух scope одновременно; конкурирующий «водитель» (внешний scope) резюмирует её повторно, не видя парковку во внутреннем scope → double-resume race → hang вместо фикса. Подтверждено эмпирически на минимальном репро, отброшено. - Статическая проверка в чекере, запрещающая/предупреждающая о прямой
блокирующей операции в теле
supervised{}безspawn— не добавлена: после фикса эта форма СТАЛА валидной (не ошибочным состоянием, которое стоит ловить статически) — единственное её отличие отspawn-версии — отсутствие enforcement дедлайна/токена ЭТОГО scope, что не является синтаксической ошибкой и не поддаётся дешёвой статической проверке (компилятор не может знать, действительно ли конкретный вызов способен заблокироваться дольше дедлайна). Задокументировано в Правиле выше вместо диагностики. - Оставить
abort()как есть — противоречит P1-классификации владельца («это недоработка языка») и ломает валидный, распространённый паттерн (accept-loop прямо вsupervisedбез обёртки вspawn).
Связь
- D14 — невидимая
приостановка; это правило распространяет гарантию на
supervised-тело. - D50 —
spawnconcurrency model; уточняет, что не-spawn’нутый блокирующий код в теле тоже поддерживается (не требуетspawnдля простой корректности). - D75 /
D408 (
timeout:/deadline:) — структурная отмена; уточняет границу (только зарегистрированные дети). - Реестр 221.1 №162.
Реализация
compiler-codegen/src/codegen/emit_c.rs::emit_supervised— убрана безусловная подмена_nova_active_scope = &queue(и НЕ введена подмена_nova_active_slot) на время выполнения statement’ов тела;_nova_active_scope/_nova_active_slotне трогаются для этого окна вообще (доктрина зафиксирована развёрнутым doc-comment’ом в коде).compiler-codegen/nova_rt/nova_sched.h::nova_sched_park/nova_sched_park_with_unlock— диагностическое сообщение при оставшемся (защитном) abort-пути расширено: называет ситуацию явно («blocking operation attempted with no reserved fiber-slot in this scope») вместо голой bounds-check фразы, для остаточных runtime/codegen-багов, а не для этого (теперь легитимного) сценария.compiler-codegen/nova_rt/fibers.h— отвергнутый self-slot подход задокументирован комментарием на месте (не кодом), чтобы не переоткрывать тот же тупик.- Регресс-фикстура:
spec_tests/conformance/standalone/m162_supervised_body_direct_park.nv— 3 теста: прямойsleepбез spawn; прямой блок, сосуществующий соspawn’нутым ребёнком в том же scope; прямойChannel.recvбез spawn, дожидающийсяspawn’нутого отправителя.
Границы
- НЕ меняет поведение
spawn-basedsupervised(join/cancel/deadline для зарегистрированных детей — без изменений, регресс-фикстуры D75/supervised_drain_mn_guard/m107_supervised_spawn_option_fn_match_bind_callпроверены). НЕ добавляет enforcement дедлайна/токена для НЕ-— ОТМЕНЕНО амендментом D442 (реестр 221.1 №165, 2026-08-01): владелец классифицировал это как P1-баг (не сознательную границу), «скобки не врут» —spawn’нутой прямой блокирующей операции телаcancel:/timeout:теперь покрывают ВЕСЬ блок, включая прямую блокирующую операцию тела.- НЕ трогает
parallel for(emit_parallel_for) — его тело никогда не блокирует напрямую (всегдаfor x in iter { spawn { body } }+ выделенный drain-fiber), эта форма бага структурно недостижима там.
D442. supervised(cancel:/timeout:/deadline:) покрывает ВЕСЬ блок, включая прямую блокирующую операцию тела (Plan 221.1 №165)
Статус: ✅ landed (реестр 221.1 №165+№169, P1, 2026-08-01, окно M:N/Vela). Retracts the “НЕ добавляет enforcement… для прямой блокирующей операции тела” boundary of D439 (owner: «ДА», D439’s own limitation classified as a bug, not a design choice, once measured against the real serve()-shaped repro). Amends D75/ D408 (structural cancel / scope deadline — extends the covered surface).
Что
Живое подтверждение (owner-verified, nova-polaris src/net/serve.nv’s own
header comment, defect 3): supervised(cancel: tok) { ... listener.accept() ... } with accept() called DIRECTLY in the block’s own body (no inner
spawn) never actually got interrupted by tok.cancel(...) — a 15s test
timeout elapsed with the accept loop still parked. Wrapping the SAME call in
an inner spawn { ... } worked. timeout:/deadline: had the identical gap
(the join-loop’s own deadline gate only starts AFTER every body statement,
including a direct blocking one, has already returned).
Правило
cancel:/timeout:/deadline: on supervised now cover the WHOLE block —
a direct (non-spawn) blocking statement in the body is interrupted exactly
like a registered spawn’d child would be, no spawn wrapper required.
Механика
NovaFiberQueuegainsowner_scope/owner_slot— the OWNER coroutine’s REAL ambient(_nova_active_scope, _nova_active_slot), snapshotted once atnova_scope_inittime (plain-value copy, no cleanup needed on any exit path). D439’s own finding stands unchanged: that TLS pair is deliberately NOT repointed at the new scope during body execution — a direct blocking op parks under the OWNER’s real identity, not under the new scope. These two fields are how the new scope’s cancel/deadline machinery reaches it.nova_sched_cancel_pending_slot(scope, slot)(nova_sched.h) — a single-slot sibling ofnova_sched_cancel_all_pending: invokes whatever op is CURRENTLY, validly registered at(scope, slot)via the existing D93 register/stop_cb protocol, exactly as if that op’s OWN real scope had been cancelled. Safe no-op if nothing (or something unrelated, later) occupies the slot — same envelope asnova_sched_wake’s existingpco == NULLguard (spurious-wake-tolerant by construction, not new tolerance introduced for this).nova_scope_deliver_cancel(thecancel:broadcast) additionally callsnova_sched_cancel_pending_slot(q->owner_scope, q->owner_slot).nova_cancel_token_cancel_reason’sbound_scopebranch — previously five lines DUPLICATINGnova_scope_deliver_cancel’s own body verbatim (a maintenance hazard: the new targeted wake was almost added to only ONE of the two copies) — is now a single call tonova_scope_deliver_cancelitself (byte-identical behaviour, same five actions in the same order).timeout:/deadline:additionally arm a self-cleaning libuv timer at scope ENTRY (nova_scope_arm_early_deadline/nova_scope_disarm_early_ deadline), independent ofnova_supervised_run_impl’s own (later, join-loop) deadline gate. Its payload (NovaEarlyDl) is a plain(owner_scope, owner_slot)VALUE copy on a collectable (nova_alloc, notnova_alloc_uncollectable) heap block, freed by Boehm once unreachable — never a pointer into the new scope’s own stack frame, and never manually freed (a first version manually freed it in the libuv close_cb and crashed with a genuine use-after-free:uv_close’s close_cb runs on a LATER loop iteration than the firingtimer_cb, so the unconditional disarm call right after the body could — and, reproducibly, did — read already-freed memory).- The cancel-token BIND moved EARLIER — right after scope entry, before the
body runs, instead of right before
nova_supervised_run_cancel(i.e. strictly after every body statement, including the direct blocking one, had already returned — which is exactly whytok.cancel()used to seebound_scope == NULLmid-park and do nothing). The original delayed-bind design existed to avoid a DIFFERENT hazard (a body that throws a genuine, unrelated error directly would leavetokdangling-bound pastnova_supervised_run_cancel’s own unbind, which never runs). That hazard is now covered by a narrow, LOCALNovaFailFrametry/finally wrapped around JUST the body-statement-execution window: unbind (if a cancel token) + disarm the early-deadline timer + re-throw the same error (nova_rethrow_scope, unchanged Plan 201 mechanism) on any escaping throw. Gated oncancel:ORtimeout:/deadline:being present — a plainsupervised{}emits neither the bind nor the guard, byte-identical to before. - №169 (nested deadline, same window):
nova_scope_init’s own deadline inheritance reads the RUNTIME_nova_active_scopechain, which (per the point above) does NOT see a directly (non-spawn) entered enclosingsupervised{}block — a LEXICALLY nestedsupervised { supervised (timeout:) { ... } }therefore silently lost the outer block’s tightened deadline. Fixed by an ADDITIONAL, explicitnova_deadline_combineagainst the LEXICALLY enclosing scope’s owndeadline_ns, known at codegen time (current_scope_queue, still the outer value at that point) — a compile-time channel, independent of the runtime TLS one. No-op (reads the same already-correct value twice) for the pre-existing working case (aspawn’d child’s own nestedsupervised{}, where_nova_active_scopealready correctly equals the parent).
Гонко-анализ (STALE-slot-класс, mn-coding-conventions)
- Identity, not index (§5):
nova_sched_cancel_pending_slotacts on WHATEVER is validly registered at(owner_scope, owner_slot)at the moment it runs — it does not itself re-derive or cache an “expected” fiber pointer. This is safe ONLY because it reuses the EXISTING D93 register/stop_cb protocol’s own atomics (ACQUIRE-load ofpending_stop_cbpaired with the RELEASE/SEQ_CST store innova_sched_register_pending) — no new state-machine, no new CAS point was introduced by this change; the new caller is simply a second call-site into machinerynova_sched_ cancel_all_pendingalready exercises for every existing scope-cancel. - Residual (documented, accepted) race: if the OWNER coroutine’s body
throws a genuine, unrelated error DIRECTLY (not via the guarded window —
impossible, since the guard now wraps exactly that) this class is closed;
the remaining, narrower residual is the early-deadline timer firing LATE
(its bounded, user-specified deadline) after the SAME coroutine has since
died and
owner_slotwas reused by an unrelated LATER fiber underowner_scope— the straynova_sched_cancel_pending_slotcall then targets that unrelated fiber’s current op. Memory-safe (§5’s own wrong-fiber-but-safe envelope: the slot’s OWN atomics gate any action), the worst outcome is a spurious interrupt of unrelated, later work — a functional (liveness) edge case, not memory corruption, and only reachable through the disarm call already being skipped (guard’s own catch-arm disarms on every throw path it wraps). - Verified NOT racy by construction: the two new runtime functions
(
nova_sched_cancel_pending_slot,nova_scope_arm_early_deadline/nova_scope_disarm_early_deadline) never repoint_nova_active_scope/_nova_active_slot, never allocate a scope slot, never touchfibers[]/parked[]bookkeeping directly — the REJECTED “self-slot” approach documented in D439 (double-registration → double-resume) is structurally unreachable here; this design was chosen specifically to avoid re-discovering that dead end.
Приёмка
std/src/net/supervised_cancel_accept_test.nv — cancel:/timeout: each
interrupt a direct-body TcpListener.accept() (no inner spawn), matching
the serve()-shaped repro; 20 back-to-back runs, no flake verified via a
standalone harness outside std/’s own folder-module (see Границы below —
nova test std/src/net is blocked by an UNRELATED pre-existing compiler bug,
[M-io-write-all-tcpstream-mono-cc-fail], that predates this window and is
out of its scope). std/src/concurrency/supervised_deadline_test.nv
(includes the №169 nested-deadline case) — 20/20 clean via nova test.
Границы
-
Амендмент 2026-08-07 (реестр 221.1 №398/№224 переоткрытие, окно p398-cancel-direct-body): проба владельца (2026-08-06:
Time.sleep(3000)прямо в телеsupervised(cancel: tok), отмена через 200мс → сон досидел 3181мс) показала, что этот же gap бьёт не только_nova_sleep_via_driverкак теоретический разбор ниже предсказывал, но что иtimeout:/deadline:под M:N (driver уже запущен — т.е. в программе БЫЛ хотя бы одинspawnраньше по времени) страдали ИДЕНТИЧНО:_nova_early_dl_timer_cb(early-armed timer, см. «Механика» выше) звал ТОЛЬКОnova_sched_cancel_pending_slot, тот же самый недостаточный вызов, что иnova_scope_deliver_cancel’s_nova_cancel_via_driver(q)— оба смотрятq’s (пустой)armed_sleeps_head, а driver-armedTime.sleepрегистрирует себя подowner_scope’s списком (см. ниже). ПРОБА ИНТЕГРАТОРА, заявившая «timeout: 200 → прерван за 489мс, дедлайн-путь работает» — не была ошибочна, но её стенд не имел ни одногоspawnдо замера: без driver’аTime.sleepидёт ЛЕГАСИ_nova_sleep_via_libuv-путём, которыйnova_sched_cancel_pending_slotУЖЕ покрывал (fix №165) — с driver’ом запущенным (обычный случай в реальной M:N-программе)timeout:был сломан ТАК ЖЕ, какcancel:.Фикс: новый driver-job
NOVA_DRV_JOB_CANCEL_SLOT(driver.h/driver.c,_nova_driver_handle_cancel_slot) — таргетированный по(scope, slot)аналогCANCEL_SCOPE: walkarmed_sleeps_headтого scope’а, но CAS/close только запись, чейslotсовпадает — соседние операции ТОГО ЖЕ (возможно внешнего, долгоживущего) owner-scope не трогает. Submit-обёртка_nova_cancel_via_driver_slot(fibers.h) — вызывается из ОБОИХ прежних тупиков:nova_scope_deliver_cancel(cancel:) сразу после существующегоnova_sched_cancel_pending_slot(q->owner_scope, q->owner_slot), и_nova_early_dl_timer_cb(timeout:/deadline:) сразу после его собственного такого же вызова. Lifetime-контракт зеркалит_nova_cancel_via_driver: инкремент/декрементowner_scope->pending_ driver_jobs— безопасно, потому чтоowner_scopeесть ПРЕДОК текущего стека (его собственныйnova_supervised_run_implлибо ещё крутит тот же spin-wait для СВОИХ jobs, либо это root/main scope, чей фрейм жив весь процесс).Матрица приёмки (позитив+негатив,
Time.sleep×{прямая операция,spawn}×{cancel:,timeout:,deadline:,комбинация}) —docs/plans/wip/ PROGRESS-p398.md(сохранена как справочная запись; не переносится вstd/целиком — см. приёмку ниже). -
Остаток, НЕ закрытый этим амендментом (сузился, но не исчез):
Time.sleep()теперь корректно БУДИТСЯ рано под ОБОИМИ путями (_nova_sleep_via_libuv— уже было верно;_nova_sleep_via_driver— фикс выше), но пост-wake проверка ОБОИХ путей по-прежнему консультирует толькоcancel_scope->cancel_requested(OWNER-scope — НЕq) с нет per-opcancelled-латча, эквивалентногоchannels.h’sw->cancelled(уже добавлен и закрывает тот же класс дляChannel.recv/send/select, см. «Механика» выше). Практическое следствие: прерванный direct-bodysleepвозвращается управление БЕЗ throw — код, синтаксически идущий сразу за ним в теле, ещё исполняется (внешнийsupervisedтем не менее корректно и в срок ловит/бросаетcancel:/TimeoutError— прикладной наблюдатель снаружи блока не замечает разницы, но код МЕЖДУ прерванной операцией и концом блока успевает выполниться, не будучи “должен был”). Маркер сужен и остаётся открытым:[M-supervised-direct-sleep-timeout-silent](backlog-followups.md).
D446. Свойство «безопасен для одновременного использования» и требование на границах
Решение владельца 2026-08-04. Расширяет D415 (
#share-атрибут, capture-check) и D441 (модель памяти между файберами) с той стороны, которую они не покрывали. Повод — вопрос владельца оratelimitв nova-polaris и адверсарная разведка плана 238 Ф.7.
Что вскрыла разведка
Энфорс до этого решения был конечным списком распознанных форм, а не
свойством. Проверялись однохоповые случаи, подобранные под ранее найденные
регрессии; форма ловилась, только пока совпадала с закрытым классом
буквально. Шесть путей проходили молча, включая простейший — переприязку
имени (ro b = a) и возврат замыкания из функции.
Модель, выведенная из грамматики: значение может сменить владельца ровно десятью способами связывания, из них девять живые (десятый — модуль-level глобал — закрыт D184).
Утверждение, которое язык обязан выполнять
Значение может быть достижимо более чем из одного файбера ТОЛЬКО если оно неизменяемо, либо единолично принадлежит одному владельцу (передано забиранием), либо его тип объявлен безопасным для одновременного использования и это объявление ПРОВЕРЕНО.
Как это проверяется — три части, все локальные
1. Свойство типа. Тип может быть объявлен безопасным для одновременного
использования (сегодняшняя роль #share), но объявление перестаёт быть
ручательством автора и становится проверяемым: компилятор обязан убедиться,
что все изменяющие операции типа атомарны либо защищены.
2. Правило для замыканий — считается ПО ЗАХВАТАМ, без обхода программы. Замыкание безопасно тогда и только тогда, когда безопасно всё, что оно захватило.
3. Границы объявляют требование В ПОДПИСИ. Функция, принимающая значение, которое ПОЗЖЕ будет исполняться параллельно, обязана сказать это в своей подписи. Тогда неважно, кто и когда вызовет: небезопасное не проходит через границу.
Ключевое свойство алгоритма: анализ достижимости НЕ НУЖЕН. Компилятор не выясняет, откуда позовут; каждый вход в параллельность требует свойства от того, что принимает. Решение локально и разрешимо.
Отношение к прежнему решению
Плана 238 решение владельца от
2026-07-31 отвергло маркер ПЕРЕДАЧИ значения в другой поток («Send/Migrate —
YAGNI»). D446 его не отменяет: там речь о переносе значения, здесь — об
одновременном использовании. В мировой практике это два разных понятия
(Rust Send против Sync); отвергнут был аналог первого, вводится аналог
второго.
Цена, названная явно
Подписи функций, принимающих исполняемое-позже-параллельно значение (в нашем корпусе — установка middleware, регистрация обработчиков), обязаны обзавестись требованием. Это видимое изменение публичного API std и пакетов.
По принципу «ужесточать до релиза, ослаблять после» (план 221) требование вводится ДО тега: расширить принятое потом — не ломающее изменение, ужесточить потом — ломающее.
D441. Модель памяти между файберами — транзитивная линейность mut-захвата + белый список (Plan 238)
Статус: ✅ landed (реестр 221.1 №150, P1 звучность конкурентности, блокер тега v0.1, 2026-07-30/31). Расширяет D415 (
§2capture- check на СИНТАКСИЧЕСКОЙ границеspawn/parallel for/detach) и D416 §2 (Supervisor.on_child_fail— сериализация на drive-файбере). Дом: Plan 238, измерение —docs/plans/wip/scale150-migration-2026-07-30.md, репро — веткаp150-race-repro(race150/,RACE150_FINDINGS.md). A-V10-амендмент (2026-07-31): обе честные границы §5 (№167, №168) закрыты — см. «Amend D441 §5» в конце этого раздела. S1a-амендмент (2026-08-02): класс «замыкание как ПОЛЕ структуры» (реестр 221.1 №117 + №242§3) закрыт пятой точкой пересечения §3(г) — см. «Amend D441 §5 — S1a» в конце этого раздела.
§0. Почему D415 первого уровня не хватило (ретроспектива)
D415 §2 поставил capture-check РОВНО на синтаксическую границу
spawn/parallel for/detach: свободные переменные ТЕЛА этих конструкций
резолвятся против объемлющих scope’ов, и mut-биндинг небезопасного (не
#share) типа — ошибка. Это ловит ПРЯМОЙ захват. Владелец обобщил дыру ещё
в записи №150 (2026-07-28): «как и вызов любой функции из файбера, вызов
клоужры потоконебезопасен — компилятор ловит только обращения из spawn к
внешним переменным, а далее любая функция может иметь такой доступ».
Конкретно нашлись ДВА живых класса дыры, ни один из которых — обращение
ВНУТРИ синтаксической границы:
- Транзитивная передача значения. Замыкание с
mut-захватом, созданное СНАРУЖИspawn, передаётся ВНУТРЬ границы как ДАННЫЕ — параметром функции, которая сама зовёт этот параметр изspawn(race150/b_closure_bypass.nv:parallel_spawn(f fn()->(), ...) { supervised { spawn { f() } } }, вызванная какparallel_spawn(push, ...)гдеpush = || { v.push(1) }). ЗахватvвнутриpushНИКОГДА не пересекал синтаксическую границуspawnбуквально — граница видит только имяf, не то, что оно закрывает. - Обработчик как скрытая точка исполнения в файбере.
with Fail[T] = |e| { mut_var = ... } { ...spawn... }— обработчик формально стоит СНАРУЖИspawn, синтаксически не внутри тела. Но измерение Ф.0 (см. §2 ниже) доказало: обработчик исполняется В ФАЙБЕРЕ падающей операции, а не в файбере устанавливающегоwith. Мутация в обработчике — та же гонка, что и прямой захват, только D415 её не видел вовсе (capture-check не спускался вhandler-выражениеwith-биндинга).
Оба класса — ОДНА и та же машина (D131 линейность / consume), просто на НОВЫХ точках пересечения границы, которые D415 §2 не сканировал.
§1. Измерение (Ф.0, факты, не гипотеза)
- Обход границы (класс 1) — репро B,
race150/b_closure_bypass.nv:nova checkмолчал (0 errors). Рантайм: 60 прогонов из 60 — грязные (0 чистых). Серия N=8×M=10000 (20 прогонов×2): 32 неверныхlen()+ 8 крашей / 40. Серия N=16×M=100000 (нагрузка, 20 прогонов): 2 неверных + 18 крашей / 20 — под нагрузкой гонка деградирует в 90% крашей. Контроль (race150/c_atomic_control.nv, тот же код сAtomicIntвместоVec): 20/20 чисто — доказывает, что дело именно в отсутствии синхронизации, не в баге рантайма. - Обработчик как точка исполнения (класс 2): 64 падающих ребёнка × 20
раундов, 5 таких батчей; обработчик
with Fail[int]делает неатомарныйcnt = cnt + 1рядом сAtomicInt-контролем числа вызовов — потерянные обновления в 2 батчах из 5 (2 и 1 потерянных апдейта на 1280 вызовов в батче). Обработчик исполняется В ФАЙБЕРЕ падающего ребёнка, конкурентно с siblings — малая частота потерь + запись одинакового значения (= true) в 26 из 28 живых сайтов объясняют, почему идиом «казался рабочим» на практике. Supervisor.on_child_fail— D416§2 обратное измерение, затем ОПРОВЕРЖЕНИЕ (приёмка A-V10 2026-07-31): та же методика; в прогонах окон 238/A-V10 счётчик был точен (0 потерь), НО приёмочный мега-CU дал RUN-FAIL, и изолированный перезамер показал 5 падений из 10 — гарантия D416§2 рантаймом НЕ выполняется, ранним прогонам везло. Реестр №173 (P1, M:N-окно с №165/№169); репро запарковано в docs/plans/wip/d416-serialization-repro/. Урок: пин-гарантия должна перемеряться приёмкой, а не одним прогоном окна. Уточнение диагноза (M:N-окно №165/№169/№173, 2026-08-01): первоначальный диагноз («неатомарный счётчик теряет обновления» = конкурентный вызовon_child_fail) ОПРОВЕРГНУТ прямым измерением — инструментированная проба (in_handler-реентрантный счётчик наAtomicInt, busy-spin внутри хендлера вместо запрещённогоsleep, 10 изолированных прогонов × 64 падения × 20 раундов) показалаrace_detected == 0ВО ВСЕХ 10 прогонах, включая 4 «упавших» —nova_supervised_process_decisions(fibers.h) никогда не вызывается конкурентно для одного scope; это подтверждает чтение кода (drive-thread-only_deciding-latch,_h-> on_child_fail(...)— обычный синхронный C-вызов, БЕЗ переключения корутины). Настоящий баг — другого класса: ПОТЕРЯННОЕ падение ребёнка (total_callsкороче ожидаемыхn×roundsна 1-2 в ~40% изолированных прогонов) — какой-тоthrowребёнка НИКОГДА не доходит до decision-loop вовсе (не «два потока одновременно инкрементируют», а «одно падение потеряно ДО хендлера») — по мн-conventions §1/§2 классу (occupancy/child_count-видимость), НЕ по классу конкурентного вызова хендлера. Корень НЕ локализован в рамках этого окна (не найдено безопасного способа добавить трипваер в 173.0-субстрат — R1/R2-защищённый код — за оставшееся время); РЕШЕНИЕ ОКНА: STOP, аргументированный — carve-out НЕ возвращается (потерянное падение делаетon_child_failненадёжным НЕЗАВИСИМО от вопроса сериализации), но и «неатомарный счётчик»-формулировка диагноза больше не верна — см. правку ниже. Пин-фикстура окна 238 (docs/ plans/wip/d416-serialization-repro/) БОЛЬШЕ НЕ КОМПИЛИРУЕТСЯ (энфорс D441 §3, ожидаемо — carve-out отозван) — репро для будущего окна:_tmp/probe173/main.nv-форма этого окна (Atomic-only, checker-чистая, не в реестре).
§2. Правила (решение владельца, №150)
Три кирпича, БЕЗ нового языкового механизма (Send/Sync-аналог отклонён как YAGNI для v1 — размер механизма не оправдан текущим масштабом):
-
ro/mut— неизменяемое разделяемо по построению. Читатели без писателей безопасны; это НЕ меняется этим окном. -
Линейность для
mut— единый файбер. Замыкание/значение сmut-захватом — линейный ресурс ОДНОГО файбера; пересечение границы файбера (§3 ниже — все точки пересечения, не только синтаксическийspawn-блок) — ошибка той же машины D131/consume. -
Белый список синхронизированных, разделяемых ТРАНЗИТИВНО по составу (уже реализовано
share_check.rs/D415, этим окном НЕ менялось — инфраструктура уже была готова): share-типы (D415#share-vouch),Atomic*,Mutex, концы каналов (Sender/Receiver). Запись разделяема ⇔ каждое поле — из (1) или из этого списка; ходьба по составу —protocols::share_check::{is_mut_alias_safe, mut_alias_failure}(существующий, уже транзитивный поTypeDeclKind::Record/NamedTuple/Sum/ Newtype/Aliasдо этого окна — фикстурыf2_pos_record_atomic.nv/f2_neg_record_bare_mut.nvэто лишь ПОДТВЕРДИЛИ, кода не понадобилось).Оговорка звучности: «
ro⇒ разделяемо» держится ТОЛЬКО вместе с (2) — запрет второгоmut-алиаса гарантирует отсутствие писателя за спинами читателей. Есть ОДНО структурное исключение из «ro⇒ безопасно»: типfn/closure.ro-захваченное ЗНАЧЕНИЕ замыкания не «глубоко неизменяемо» — вызов замыкания может мутировать состояние, на которое оно закрылось, ГДЕ БЫ оно ни было объявлено.share_check::share_recотказываетTypeRef::Funcбезусловно (ShareReason::FnType) — вот почему §3 п.(а)/(б) ниже смотрит НЕ наro/mutсамого захваченного имени, а на то, ЧТО оно закрывает.
§3. Три точки закрытия транзитивного пути (реализация Ф.1-Ф.3)
(а) Параметр, ведущий в spawn/detach/parallel for/blocking.
Пре-пасс spawn_tainted_params_of_fn (по модулю, один раз): для каждой
именованной fn — какие из её СОБСТВЕННЫХ fn-типизированных параметров
вызываются внутри spawn/detach/parallel for/blocking-тела ГДЕ-ТО в
её определении (любая глубина вложенности). На КАЖДОМ вызове такой fn
(check_transitive_closure_arg) — если аргумент на этой позиции резолвится
к замыканию (буквальный литерал |..| {..} на месте вызова, ИЛИ простое
имя, снэпшот-зарегистрированное в ScopeBinding.closure_free_vars в
момент его let/ro-объявления), его свободные переменные проверяются
той же машиной share/линейности, что и прямой захват — на span’е
АРГУМЕНТА, с текстом «создано снаружи границы, передано параметром {p} в
вызов {f}, которая зовёт {p} внутри своего spawn/detach/parallel for».
Закрывает repro B буква-в-букву.
(б) Отправка в канал. chan.send(value) (имя-эвристика, тот же
паттерн, что уже использует state.const_init-гейт для send/recv в
этом же файле) — value, если резолвится к замыканию тем же путём, что
(а), проверяется идентично. Живых нарушителей класса (б) на момент замера
№150 не найдено (аудит scale150), но механизм универсален с (а).
(в) Обработчик вокруг spawn-содержащего тела. with X = handler { body }: если body содержит (транзитивно, любая глубина) spawn/
detach/parallel for/blocking (block_contains_fiber_boundary) —
свободные переменные handler-выражения (замыкание-литерал ИЛИ методы
effect X { ... }-литерала) проверяются той же машиной. Диагностика —
НОВЫЙ код E_HANDLER_MUT_CAPTURE_IN_FIBER (текст объясняет: обработчик
исполняется В ФАЙБЕРЕ падающей операции, не устанавливающего scope’а, со
ссылкой на измерение Ф.0 §1). Исключение (D416§2) ОТОЗВАНО (2026-07-31, №173): пин-фикстура
сериализации упала 5/10 изолированных прогонов — гарантия D416§2 рантаймом
не выполняется; обработчики Supervisor проверяются как все остальные,
пока рантайм не начнёт реально сериализовать (после фикса №173 carve-out
можно вернуть вместе с возвратом пин-фикстуры в conformance).
§4. Диагностики
| Код | Класс | Где ловит |
|---|---|---|
E_CONCURRENT_MUT_CAPTURE | mut-небезопасный тип, прямой ИЛИ транзитивный (§3 а/б) захват | spawn/detach/parallel-for тело (D415, было); аргумент вызова в spawn-tainted параметр; значение, отправленное в канал (оба — новые, Plan 238) |
E_LINEAR_CAPTURE_IN_FIBER | linear/consume-тип, та же транзитивность | как выше (D415 §4, расширено на транзитивные точки) |
E_HANDLER_MUT_CAPTURE_IN_FIBER | mut-небезопасный тип, захвачен with-обработчиком вокруг fiber-содержащего тела | НОВЫЙ (Plan 238 Ф.3) — единственный класс, реально населённый живыми нарушителями (28 сайтов) |
§5. Честные границы (НЕ городить сверх измеренного)
- Точка (4) плана (именованный граф вызовов /
infer_effects) — ЗАКРЫТО A-V10 (2026-07-31), реестр 221.1 №167. Ф.3 (Plan 238) проверила: существующийinfer_effectsподнимает ТОЛЬКО объявленные эффекты сигнатуры, у него нет понятия «M:N-safe/unsafe leaf» — план предполагал НОВУЮ эффект-категорию, отдельное окно. A-V10 добавила её узко и честно, БЕЗ нового языкового эффекта: атрибут#thread_affineнаextern fn(тот же parser-путь, что#blocking—E_THREAD_AFFINE_NOT_EXTERN, если не наextern) маркирует именно М:N-небезопасный лист. Транзитивный подъём —thread_affine_closure(compiler-codegen/src/types/mod.rs): fixed-point worklist над ИМЕНОВАННЫМ графом вызовов свободных fn (own_fiber_call_names_of_fn), а не черезinfer_effects/№131 (та машина принципиально НЕ транзитивна — по её собственному doc comment — и оперирует объявленными эффектами сигнатуры, не именами) и не через#pure-подъём (атрибут декларируется вручную, не выводится). Граница —E_THREAD_AFFINE_IN_FIBER: вызов листа или транзитивного носителя внутри телаspawn/detach/parallel for(та жеblock_contains_fiber_ boundary-класса обхода, что и остальной D441 §3), с именем листа и цепочкой подъёма (минимум один промежуточный фрейм) в тексте диагностики. Ключевое структурное решение: граф ОСТАНАВЛИВАЕТСЯ на вложенныхspawn/detach/parallel for/blockingпод-телах — вызов листа ВНУТРИ собственногоspawnобёртки НЕ делает саму обёртку «транзитивно небезопасной» (тотspawnзаводит СВЕЖИЙ, развязанный от вызывающего файбер; вложенный вызов уже ловится там же, как прямое нарушение).stdсегодня не маркирует НИ ОДНОГО живого extern (libuv fiber-aware) — механизм проверен тестовым extern в фикстуре (neg/thread_affine_direct_in_fiber_neg.nv,neg/thread_affine_transitive_in_fiber_neg.nv,handler_thread_affine_pos.nv); живая маркировка std-extern’ов — отдельное решение. Остающаяся узкая граница: граф именованный — вызов листа ЧЕРЕЗ closure-значение (параметр/поле, переданное как данные, не вызванное по имени напрямую) не отслеживается, тот же класс лимита, что у §3(а)/(в) ниже; методы (obj.method()) вне периметра — только свободные fn поIdent-имени. - Точка (6) плана (гейт на коэрсии fn→Handler, D52 стирание) — ЗАКРЫТО
A-V10 (2026-07-31), реестр 221.1 №168. §3(в) изначально ловил
handler-ЛИТЕРАЛ, написанный ПРЯМО на месте
with X = …(100% реальных 28 сайтов на дату Ф.3), но неwith X = some_precomputed_handler(имя, не литерал). A-V10 закрыла это МИНИМАЛЬНО, на месте —check_handler_ captureрезолвит bare-Identhandler-выражение через ТУ ЖЕ снэпшот- машину, что Ф.1 уже использует для transitive-параметров (ScopeBinding.closure_free_vars, записан наlet/ro-time регистрации), без нового кода обхода. Тот же E-кодE_HANDLER_MUT_CAPTURE_IN_FIBER, текст диагностики дополнен пометкой «PRECOMPUTED… обработчик, вычисленный заранее». Carve-outSupervisor(D416§2) на момент окна сохранялся; ОТОЗВАН приёмкой того же дня (№173 — пин сериализации опровергнут перезамером, см. правку §3 выше). Транзитивная установка ЗАКРЫТА ПОЛНОСТЬЮ, не узкой строкой: проверено, что Ф.1а-механизм (spawn_tainted_params_of_fn/check_transitive_closure_arg) НЕ покрывал install-позицию (handler-параметр, переданный в функцию, которая сама ставитwithвокругspawn) —collect_fiber_boundary_frees_expr’sWith-ветка не трактовала handler-выражение install-сайта как boundary-use параметра. Закрыто расширением ТОГО ЖЕ пре-пасса: когдаwith-тело содержит fiber-границу (block_contains_fiber_boundary, тот же гейт, что уже использует сам handler-check), свободные переменные handler-выражения install-сайта добавляются в тот же boundary-set, что и прямой вызов параметра внутриspawn—spawn_tainted_params_of_fnподхватывает install-позицию БЕЗ отдельного нового пре-пасса. Измерено фикстуройneg/handler_mut_capture_precomputed_install_transitive_neg.nv(функция-параметрh, установленная какwith-handler вокруг тела соspawn, ловится на call-сайте —E_CONCURRENT_MUT_CAPTURE, тот же путь, чтоrace150/b_closure_bypass.nv). - Класс «замыкание как ПОЛЕ структуры» (
BackgroundTasks.tasks []fn()->(),ServerResponse.upgrade/.backgroundи т.п.) — ЗАКРЫТО S1a (2026-08-02), реестр 221.1 №117 + №242§3 — см. «Amend D441 §5 — S1a» в конце этого раздела (§3 получила пятую точку (г)). - Ложные срабатывания на легальных паттернах (per-fiber аккумулятор
внутри собственного
spawn,Sender-отправка,AtomicInt) — ПРОВЕРЕНЫ отсутствующими: полный прогонspec_tests/conformanceдиректорией (после миграции Ф.4) даёт диагностики нового класса ТОЛЬКО в уже известныхneg/-фикстурах D415; ни одного нового ложного срабатывания на позитивных путях.
Миграция (Ф.4)
26 бесспорных живых сайтов (замер scale150) + 2 дополнительных, найденных
точным AST-энфорсом там, где грепом-замером они были пропущены
(m2217_15b_cancel_consume_shield_narrowed.nv,
d432_auto_cleanup_hybrid_c.nv, parfor_iter_edge.nv,
scope_multierror_test.nv — 4 файла, не 2, если считать по файлам, а не
по записи scale150-документа) — все мигрированы AtomicBool/AtomicInt
вместо mut-флага, тот же наблюдаемый исход (проверено ассертами тестов,
НЕ ослаблены). nova-polaris (другая репа) — 1 сайт (net/policy.nv:58
read_attempt) остаётся немигрированным, вне периметра этого окна.
Amend D441 §5 — A-V10: №168 (precomputed-обработчики) и №167
(M:N-небезопасный лист) закрыты (реестр 221.1, 2026-07-31)
Статус: ✅ landed. Обе честные границы §5 «НЕ городить сверх измеренного» из блокеров тега (владелец «ДА» 2026-07-31, реестр 221.1 №167/№168) закрыты одним окном, sonnet. §5 выше отредактирован на месте (bullet’ы «Точка (4)»/«Точка (6)» заменены описанием закрытия) — этот раздел суммирует изменение и фиксирует, что осталось честно открытым.
№168 (precomputed-обработчики). check_handler_capture в
compiler-codegen/src/types/mod.rs получил ветку ExprKind::Ident(name):
резолвит handler-имя через ScopeBinding.closure_free_vars (та же
снэпшот-машина, что Ф.1 уже использует для spawn-tainted параметров —
никакого нового обхода). Тот же E_HANDLER_MUT_CAPTURE_IN_FIBER, текст
дополнен пометкой про precomputed-форму. Транзитивная УСТАНОВКА (handler
как параметр функции, которая сама ставит with вокруг spawn) —
проверено и закрыто ПОЛНОСТЬЮ (не узкой строкой): collect_fiber_boundary_ frees_expr’s With-ветка теперь трактует handler-выражение install-сайта
как boundary-use параметра ТОЧНО ТАК ЖЕ, как прямой вызов параметра внутри
spawn — существующий spawn_tainted_params_of_fn/check_transitive_ closure_arg подхватывают install-позицию без отдельного пре-пасса.
№167 (M:N-небезопасный лист). Новый атрибут #thread_affine на
extern fn (парсер: тот же путь, что #blocking, E_THREAD_AFFINE_ NOT_EXTERN если не на extern). Транзитивный подъём — thread_affine_ closure (новый, честно НЕ переиспользованный код: ни infer_effects/№131
(принципиально не транзитивна — её собственный doc comment), ни #pure
(вручную декларируемый атрибут, не выводимый) не были литеральным
транзитивным графом вызовов; структурно ближайший прецедент — spawn_ tainted_params_of_fn’s форма «once-computed pre-pass, keyed by fn name»,
воспроизведена для этого нового графа) — fixed-point worklist над
именованным графом свободных fn (own_fiber_call_names_of_fn), который
ОСТАНАВЛИВАЕТСЯ на вложенных spawn/detach/parallel for/blocking
под-телах (развязанный от вызывающего файбер — обёртка не наследует
небезопасность листа, вызванного ВНУТРИ её собственного spawn; проверено
вручную — единственная ошибка остаётся на месте вложенного spawn, без
второй/лишней у вызывающего). Граница — E_THREAD_AFFINE_IN_FIBER на
spawn/detach/parallel for (та же block_contains_fiber_boundary-
класса обхода, что и остальной D441 §3), с именем листа + цепочкой подъёма
(минимум один промежуточный фрейм) в тексте. std сегодня не маркирует ни
одного extern — механизм проверен тестовым extern "C" fn abs в
фикстурах; живые кандидаты (libuv fiber-aware C-вызовы) не выявлены при
этом окне — маркировка живых std-extern’ов остаётся отдельным решением.
Осталось честно открытым (не молчаливый пробел):
- №167: граф именованный — thread-affine лист, вызванный ЧЕРЕЗ closure-
значение (параметр/поле, переданное как данные, а не вызванное по
прямому имени), не отслеживается; методы (
obj.method()) вне периметра. - №168:
with-handler в install-позиции resolvится через СНЭПШОТ, взятый на call-сайте передачи ПАРАМЕТРА; двух-и-более-хоповая передача (параметр передан ДАЛЬШЕ в третью функцию, которая уже ставитwith) — тот же принципиальный лимит глубины, что у §3(а)‘s исходного «2+ хопа не отслеживаются». - Класс «замыкание как ПОЛЕ структуры» (§5 выше) НЕ был тронут этим окном (A-V10) — закрыт ПОЗЖЕ, окном S1a (2026-08-02) — см. следующий раздел.
Фикстуры: neg/handler_mut_capture_precomputed_neg.nv,
neg/handler_mut_capture_precomputed_install_transitive_neg.nv,
handler_mut_capture_precomputed_pos.nv,
neg/thread_affine_direct_in_fiber_neg.nv,
neg/thread_affine_transitive_in_fiber_neg.nv,
handler_thread_affine_pos.nv (все — spec_tests/conformance/).
Amend D441 §5 — S1a: №117 + №242§3 закрыты — §3 получила точку (г)
«замыкание как ПОЛЕ структуры» (реестр 221.1, 2026-08-02)
Статус: ✅ landed. Последняя из честных границ §5, оставленных A-V10 («Класс «замыкание как ПОЛЕ структуры» … не тронут этим окном»), закрыта отдельным окном (S1a, sonnet) по личному контролю владельца (критерий: «частичное закрытие M:N-безопасности = невыполнение»). §3 выше отредактирована на месте (bullet «Класс «замыкание как ПОЛЕ структуры»» заменён ссылкой сюда); этот раздел — реализация и остаток.
Механизм — пятая точка пересечения §3(г). Симметрична (а)/(б)/(в):
замыкание с mut-захватом, ЗАПИСАННОЕ в поле записи/коллекции (вместо
прямой передачи параметром/каналом/handler’ом), чьё значение ВЫЗЫВАЕТСЯ
ГДЕ-ТО ещё внутри spawn/detach/parallel for/blocking-тела —
проверяется В МОМЕНТ ЗАПИСИ той же машиной (flag_boundary_captures),
что и остальные четыре точки. Два пре-пасса (compiler-codegen/src/types/ mod.rs, тот же файл/тайминг, что spawn_tainted_params/thread_affine_ closure):
spawn_tainted_fields_of_module— по всем методам (Item::Fnсreceiver) находит(TypeName, field)-пары, чьё значение вызывается внутри границы: прямой@field(), ИЛИ черезfor/parallel for-петлю по прямому@field-чтению (петля/тело зовёт свою pattern-переменную внутри вложенной границы —BackgroundTasks.drain()форма), ИЛИ черезlet-биндинг того же поля (ro x = @field, вызванный дальше в том же блоке внутри границы — добавлено ПОСЛЕ находки codegen-гэпа ниже: единственная форма, которая реально СОБИРАЕТСЯ для случая bare-fn-поля).spawn_tainted_method_params_of_module— один хоп ДАЛЬШЕ (НЕ fixed-point): какие СОБСТВЕННЫЕ параметры метод сам пишет в уже- тэйнтованное поле (fn BackgroundTasks mut @add(f fn()->()) { @tasks. push(f) }— параметрfтэйнтован для(BackgroundTasks, "add")) — закрывает буквальную форму вопроса владельца 2026-07-25 (bg.add(|| { log.push(...) } )), которую (1) сама по себе не видит (значение поступает вaddПАРАМЕТРОМ, не литералом).
Гейт на WRITE-сайтах (три формы, все ведут к flag_boundary_captures):
.push/.append/.add/.insert/.push_back/.push_front/.set-
мутатор на тэйнтованном поле; присваивание @field = v/obj.field = v;
конструктор Type { field: v, .. }. Владелец поля резолвится через
@field (новое CapState.current_receiver_type, заполняется в
walk_fn_body из FnDecl.receiver) или через ScopeBinding.ty bare-
Ident-ресивера. Метод-call-сайт для пре-пасса (2) — новая ветка в
ExprKind::Call (twin уже существующей ветки для free-fn Ident-
вызовов): резолвит тип ресивера, матчит self.sig.method_overloads по
арности (D84-конвенция — та же, что уже применяется к free-fn overload’ам).
№242§3 (escaping вообще). Router-регистрация (router.get(path, handler) ≡ @routes.push((path, handler))) — тот же write-сайт-класс.
Ответ на «как отличить убегающее от локального» (риск ложняков на
map/filter): escaping ⇔ значение ЗАПИСАНО в тэйнтованное поле; локальное
(map/filter/fold) ⇔ никогда не пишется в storage — течёт прямо в
синхронный вызов внутри тела HOF, которое само НЕ является границей. Ни
один пре-пасс не может пометить поле как тэйнтованное через синхронный
HOF-путь — структурная невозможность ложняка, не эвристика.
Находка (codegen, НЕ чекер, filed НЕ fixed — channel-first): прямой
вызов @field() bare fn-типизированного поля ВНУТРИ spawn{}-тела метода
— pre-existing emit_c CC-FAIL (undeclared identifier 'nova_self',
спавненный closure не захватывает receiver в этом пути); родственный,
Vec[fn()->()]-поле через for-петлю — linker-ошибка undefined symbol: nova_fn_t (родня уже известного [M-vec-iter-fn-newtype-next-option- mismatch]). Оба — впервые вскрыты этим окном (шаблон «self-field вызван
внутри spawn метода», видимо, не встречался в корпусе раньше).
[M-spawn-self-field-call-nova-self-undeclared] в backlog-followups.md;
НЕ фикшено (легаси emit_c). Обходной путь — читать поле в ro-локаль
ДО spawn (ro handler = @field; spawn { handler() }) — codegen-safe
(обычный closure-capture-в-spawn, уже повсеместный в корпусе) И
ПОДХВАТЫВАЕТСЯ пре-пассом (1) наравне с for-петлёй — не костыль, а
идиоматичная форма.
Осталось честно открытым (не молчаливый пробел):
- Один хоп для метод-параметра (2-hop write→call); параметр, переданный ДАЛЬШЕ в третий метод — не отслеживается (тот же класс лимита, что уже принят для §3(а)/(в)).
- НЕ транзитивно между МЕТОДАМИ одного типа: поле, тэйнтованное вызовом
ЧЕРЕЗ ДРУГОЙ метод того же типа (не напрямую внутри границы) — не
подхватывается (симметрично тому, что
spawn_tainted_params_of_fnтоже не транзитивен через цепочку вызовов разных fn). - Ресивер неизвестного статического типа (generic-параметр/unresolved) — консервативный no-op.
- Возврат замыкания КАК РЕЗУЛЬТАТА функции (не через поле) — вне периметра V1 (не встречено как живой сайт при аудите фикстур этого окна).
Фикстуры (spec_tests/conformance/): neg/field_sink_mut_capture_bare_ field_neg.nv (bare fn-поле, двух-хоповая цепочка через wrapper-метод),
neg/field_sink_mut_capture_background_tasks_neg.nv (BackgroundTasks
дословно, Vec[fn()->()]-поле), field_sink_mut_capture_pos.nv
(#share-захват AtomicInt легален, map/filter НЕ ложнякует, Mutex-
SharedLog #share-vouch value-record легален).
Гейты: nova check std/src — 147/26/60 (байт-в-байт канон, без сдвига);
nova-polaris nova test src --strict-effects — 37/0/18 (байт-в-байт
канон); cargo build/--release — чисто.
D455. Исход отмены выражен ЗНАЧЕНИЕМ области: supervised(cancel:) даёт Outcome[T] (2026-08-10)
Status: accepted (решение владельца, 2026-08-10, вариант «C» из четырёх предложенных). Продолжает D449 («срок как ЗНАЧЕНИЕ, а не как эффект») и уточняет D75. Реализация — план 266, до тега v0.1.
Отклонение реализации от этого текста, временное (2026-08-11). Код в ветке плана 266 несёт имена
Finished/Aborted, а неDone/Cancelled. Причина — НЕ вкус: голый паттернDone(x)вmatchрезолвится по ИМЕНИ, а не по типу значения, и совпадает сOnceState.Done, захардкоженным в рантайме; проба дала молча неверный результат (Done(42)→0, чтение неинициализированной памяти). Это дефект компилятора — реестр 221.1 №567, К1, чинится до тега. Решение владельца 2026-08-11: баг компилятора не определяет именование языка — после фикса №567 имена ВОЗВРАЩАЮТСЯ кDone/Cancelled, как записано ниже. Отклонение зафиксировано здесь, чтобы временное не стало постоянным молча.
Что
Область с именованным аргументом cancel: возвращает не T, а исход:
ro out = supervised(cancel: tok) {
serve(listener) // тело вычисляет T
}
match out {
Done(v) => println("завершилась сама: ${v}")
Cancelled => println("отменена снаружи")
}
Тип результата зависит от НАЛИЧИЯ cancel:, и только от него:
| форма | тип результата |
|---|---|
supervised { … } | T — как сейчас |
supervised(timeout: …) { … } | T; истечение срока — Fail[TimeoutError] (D408) |
supervised(timeout: …, on_timeout: h) { … } | T; срок переведён в значение обработчиком (D449) |
supervised(cancel: tok) { … } | Outcome[T] = Done(T) | Cancelled |
Прецедент формозависимости уже принят: on_timeout: меняет поведение области
самим своим присутствием (D449). Здесь то же правило, применённое к cancel:.
Почему
- Сегодня исход не выражен вовсе. Отмена ничего не бросает (в отличие от
срока), а значение области при отмене неотличимо от значения при успешном
завершении. Единственный способ узнать правду — вспомнить и вызвать
tok.is_cancelled()ПОСЛЕ области (06-concurrency.md:1275, «читает флаг без yield, не throws»). Это инвариант на честном слове: ничто не заставляет вызывающего спросить, и ничто не краснеет, если он забыл. - Из этого следует невозможность проверить корректное завершение. Тест
graceful shutdown сегодня может утверждать только «программа не упала».
Утверждать «область была отменена и ресурсы закрыты» не на чем — и потому
записи №554 (перехватывается только
SIGINT) и фаза Ф.2 плана 265 (сигналы как эффект) упираются в отсутствие наблюдаемого исхода, а не в сами сигналы. - Согласовано с уже принятым. D449 постановил: истечение срока выражается ЗНАЧЕНИЕМ, а не эффектом. Отмена — событие того же рода (штатное завершение по внешней воле), и выражать её иначе значило бы держать два разных ответа на один вопрос «чем кончилась область».
- Платит только тот, кто попросил. Без
cancel:тип прежний, ни одна существующая строка не меняется. Это отличает решение от «пусть отмена бросаетFail[Cancelled]»: там платили бы все, включая тех, кто отмену не заказывал.
Что отвергнуто и почему
on_cancel:по симметрии сon_timeout:. Обработчику нечего перехватывать: отмена не бросает. Форма выродилась бы в «выполни это при отмене», то есть в очистку, — а очистка у нас уже есть и ровно одна:defer/@cleanup/consume. Два способа на одно не самообеспечиваются (довод D410, применён в D452).- Отмена бросает
Fail[Cancelled](путь KotlinCancellationException, SwiftCancellationError). Штатное завершение сервиса начало бы выглядеть отказом;Fail, который положено ловить всегда, перестал бы быть сигналом беды. Плата ложится на весь код, а не на того, кто заказал отмену. - Оставить
tok.is_cancelled()единственным ответом. Это статус-кво, то есть инвариант на честном слове; см. «Почему» §1. - Отмена как событие управления (продолжение после области не выполняется). Ничего не добавляет в типы, но и не оставляет вызывающему места сказать «если отменили — вот что делать»; для сервиса, которому надо дожать соединения и сообщить об этом, этого мало.
Открыто (решается реализацией, но НЕ молча)
- Сочетание с
Result. Если тело области уже возвращаетResult[T, E], тип становитсяOutcome[Result[T, E]]. Не вкладывать ли иначе — вопрос формы, ответ обязан быть записан амендментом, а не выбран реализацией. - Имя типа и вариантов.
Outcome[T]/Done/Cancelled— рабочее предложение. Если в прелюдии уже занято близкое имя, назвать иначе и записать. - Сочетание
cancel:сtimeout:в одной области. Тогда исходов три: завершилась, отменена, истёк срок. Форма — за амендментом.
Прецеденты
Rust: отменённость видна в ЗНАЧЕНИИ (JoinError::is_cancelled), а не в
исключении. Go: ctx.Err() — тоже значение, но требует опроса, то есть ровно
наш нынешний недостаток. Kotlin/Swift: исключение — путь, который мы отвергли
выше. D449 — наш собственный прецедент «срок как значение».