Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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 to spawn_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 (see no-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 make dlclose safe.
    • ~/dev/fera/project-design/context/OPTIM-IDEAS-4-oq24.md:100-102 already 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

Corroboration. oc1 reached this defect independently from OPTIM-IDEAS-4-oq24.md:102 (finding 009) while I arrived from OQ.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/.data unmapped 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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_blocking disk IO in the picture a hang is a real user-visible failure.
  5. 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.
  • 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 whose Drop records 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.md argues 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.