Hazard pointers / QSBR cannot make a hot-swappable dylib unload safe — the timeout fallback unmaps code that a stalled thread is executing in
Источник:
dev/fera-research/docs/oc5/hazard-pointers-cannot-allow-dylib-unmap.md(read-only; сгенерировано scripts/sync-docs.mjs)
Hazard pointers / QSBR cannot make a hot-swappable dylib unload safe — the timeout fallback unmaps code that a stalled thread is executing in
Section titled “Hazard pointers / QSBR cannot make a hot-swappable dylib unload safe — the timeout fallback unmaps code that a stalled thread is executing in”- kind: risk
- area: modules / concurrency / architecture
- severity: high
- evidence:
~/dev/fera/project-design/context/OQ.md:878-880(OQ-024 final formulation, nylon-ring fork intent (d)) — “pin the Rust compiler version so our own modules compile without#[repr(C)]— a fixed rustc version makes the Rust ABI stable between our modules. Manifest consequence: module metadata must carry the required rustc version; the host checks it at load”OQ.md:871-872(OQ-024, Reclamation) — “Big-module unloads = Hazard Pointers … No guard ever held across.await. Timeout + HP fallback if a grace period stalls.”~/dev/fera/project-design/context/ADR.md:17-18(ADR-001) — “hot-swappable native modules (dynamic libraries)”~/dev/fera/project-design/context/TECHSPEC.md:125-127(§7) — required guarantee: “graceful unload draining in-flight calls”~/dev/fera/project-design/context/OQ.md:809-812— blocking work “sanakirja disk IO, media decode” goes tospawn_blocking/ dedicated OS threads~/dev/fera/project-design/context/OQ.md:802-805— “Given the very large dylib count (ADR-020 struct-per-dll) … an optimized EBR table (the pointer/epoch tables must be tuned for hundreds of modules)”~/dev/fera/chroix-core/src/lib.rs:283-286,:317-321,:470-473—assert!panic guards reachable in the frame path (seeno-panic-containment-at-dylib-boundary.md)~/dev/fera/project-design/context/ADR.md:811-813(ADR-020, an Accepted ADR) asserts the opposite: “Relink on hot-swap uses ERB (epoch-based reclamation) — new pointer table in a new epoch, old epoch drains before free (see OQ-021 for the deferred design pass).” Draining an epoch before freeing does not makedlclosesafe.~/dev/fera/project-design/context/OPTIM-IDEAS-4-oq24.md:100-102already states this conclusion correctly: “если отсутствие живого plugin-кода доказать нельзя, DLL держится до завершения процесса либо plugin запускается в отдельном процессе. Это означает: EBR или HP не делаютdlcloseбезопасным. Они защищают данные, а не код, который может быть выгружен.”~/dev/fera/project-design/context/OQ.md:741— “(ERB, see OQ-021 notes) → relink → TTL starts → kill stragglers at …” — the same unmap-after-TTL assumption in the OQ-021 resolution
Substrate
Section titled “Substrate”Corroboration. oc1 reached this defect independently from
OPTIM-IDEAS-4-oq24.md:102(finding 009) while I arrived fromOQ.md:871-872. Two independent arrivals, from two different documents, onto the same defect. The quoted passage above is the corpus already stating the correct answer — the finding is not that nobody noticed, it is that the correct answer never propagated into the decisions that need it: ADR-020:811-813 (Accepted) still asserts epoch-drain-then-free is sufficient, and OQ.md:871-872 specifies a timeout-based unmap anyway.
OQ-024 specifies a reclamation scheme that is internally coherent for heap objects and quietly incoherent for code. The distinction matters, and nothing in the corpus draws it.
Hazard pointers and QSBR protect an object’s memory from being reclaimed while a reader might touch it. They say nothing about the ability to unmap the text and rodata segment out from under an executing thread. Those are different problems with different mechanisms:
- Heap object retired → HP/QSBR defers the free. Safe.
- Dylib
.text/.dataunmapped while a thread is inside it → SIGSEGV, immediately, unrecoverably. No hazard pointer anywhere in the system can prevent it, because the hazard pointer protects the value, and the value lives in a mapping that is being destroyed.
OQ-024 commits to unload (“plugin unloading” is a named first-class
goal; ADR-001 makes hot-swapping the point of the architecture) and
specifies the guard as “Timeout + HP fallback if a grace period
stalls” (OQ.md:862). That fallback is the bug. A timeout-based
unload is a declaration that if a reader has not finished in time, we
proceed anyway. If the reader has not finished because it is blocked,
the module gets unmapped under it.
The corpus names the exact stall condition that triggers it
Section titled “The corpus names the exact stall condition that triggers it”OQ-024 plans to move sanakirja disk IO and media decode to
spawn_blocking and dedicated OS threads (OQ.md:809-812). A thread in
spawn_blocking doing a long disk read, a decode, or waiting on a
contended lock is exactly a “grace period stall”. Those threads are the
intended home of the slow half of the work — so this is not an exotic
path, it is the designed path.
And the timeout does not have to fire on a blocked thread at all. The
cheaper failure is the panicking one: a panic inside a critical section
(a) propagates out through the guard, and if the surrounding frame has
already crossed into a catch_unwind at the module boundary, the guard
may be dropped on a different executor thread than the one that
published it. QSBR’s whole invariant is per-thread quiescent state;
dropping a guard on the wrong thread corrupts it. HP’s whole invariant is
that the publishing thread re-validates after publishing; a panic
between publish and validate leaves a published hazard with no owner, and
the scanner will treat the object as live forever (a leak) or — if the
guard is force-released — as free (a use-after-free).
The ABI pinning that “solves” the UB does not do what it claims
Section titled “The ABI pinning that “solves” the UB does not do what it claims”OQ.md:866 is the decision that removes the cross-ABI unwind UB for
first-party modules: pin rustc, compile without #[repr(C)], carry the
required rustc version in module metadata, host checks it at load.
The check is a metadata comparison. The manifest asserts “built with
rustc X”; the host compares the assertion to its own version. Nothing
proves the dylib was actually produced by rustc X. Combined with ADR-004
— hash mismatch is a non-blocking warning and the module loads anyway
(ADR.md:96-115, and QA.md:121-124 mandates a test that the load
proceeds) — a hand-built or tampered module that merely claims the
right rustc version passes the check and loads.
So the stated mechanism for eliminating the panic-strategy UB class is a self-reported string, on the one tier that has no sandbox. The real guarantee would come from the compiler agreeing to emit the artifact, not from the artifact describing itself.
“No guard ever held across .await” is a rule with no enforcement
Section titled ““No guard ever held across .await” is a rule with no enforcement”OQ.md:862 states it as an invariant. It is unenforceable as stated: a
guard held in a struct field, across a ?, or in any function someone
later adds an .await to, violates it silently. The compiler does not
check it, and there is no lint for it in this ecosystem (no clippy.toml
lint, no #[must_not_suspend] equivalent exists in Rust today). Given
the codebase’s own commitment — OQ-024 mandates “reproducible
benchmark … pre-registered-falsifiers style” — an unenforced invariant
that decides memory safety is the wrong place to save the tooling effort.
Options (tradeoffs)
Section titled “Options (tradeoffs)”- Never unmap. Keep every loaded dylib mapped for process lifetime; reclaim only state, not code. Hot-swap then means “new calls go to the new module”, not “the old module leaves the address space”. Cost: the memory win that motivated the reclamation work is largely gone. But note OQ-024’s actual stated goal is CPU/energy/wake-ups — code residency costs some RSS and nothing in the wake-up budget.
- Refcount the mapping, defer unmap to process exit. Same as 1 but honest: accept monotonic code growth, document it as the cost of hot swap. Cheapest correct answer.
- Process-isolate the unloadable tier. Each unloadable module in its own process; unmap = kill the process, which is safe by construction. Cost: IPC on the hot path — which is precisely what ADR-005 spends five ADRs amortizing away. Only defensible for cold axes, not the render path.
- Make the unload wait unconditionally — drop the timeout, require
all in-flight calls to drain. Correct, but converts a stall into a
hang, and with
spawn_blockingdisk IO in the picture a hang is a real user-visible failure. - Trust the guard discipline and keep the timeout. Only safe if the “no guard across await” and “no panic through a critical section” invariants are enforced, which today they are not.
Proposed solution
Section titled “Proposed solution”- Split the two guarantees explicitly in OQ-024, because they are currently one sentence and mean different things: object reclamation (HP/QSBR, as designed — correct) and code unloading (needs a different mechanism entirely). Say which one the timeout applies to.
- Drop the “Timeout + HP fallback” for code unmap, or state explicitly that the timeout applies only to retiring objects and that module text is never unmapped while the process lives (option 2). This is the one-line change that removes the use-after-free.
- Replace the metadata rustc check with something the compiler guarantees: a build-time fingerprint the host cannot forge by assertion (e.g. verify the artifact’s embedded rustc version metadata rather than the manifest’s claim), or drop the claim and accept the Rust-ABI risk for the first-party tier explicitly. Today the corpus claims a guarantee it does not have.
- Give “no guard held across
.await” a mechanical home — a wrapper type whoseDroprecords the drop-thread id, a debug assertion in the actor-loop quiescent path, and a test that panics inside a critical section and asserts the reclamation tables stay consistent. - Connect to the panic finding:
no-panic-containment-at-dylib-boundary.mdargues for catching panics at the module boundary. That is correct and it makes this finding worse, not better — a caught panic is precisely the “grace period stalled” condition the timeout is written to handle. The two fixes must land together, or the containment fix turns a panic into a use-after-free.