Skip to content

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

No panic-containment policy at the native dylib boundary, and panic=abort is proposed in the same corpus

Источник: dev/fera-research/docs/oc5/no-panic-containment-at-dylib-boundary.md (read-only; сгенерировано scripts/sync-docs.mjs)

No panic-containment policy at the native dylib boundary, and panic=abort is proposed in the same corpus

Section titled “No panic-containment policy at the native dylib boundary, and panic=abort is proposed in the same corpus”
  • kind: risk
  • area: modules / trust / architecture
  • severity: high
  • evidence:
    • ~/dev/fera/project-design/context/ADR.md:39-62 (ADR-002 — native dylib modules, “No sandbox”, trusted-by-design)
    • ~/dev/fera/project-design/context/ADR.md:17 (ADR-001 — “hot-swappable native modules (dynamic libraries)”)
    • ~/dev/fera/project-design/context/ADR.md:282-283 (ADR-029 — “three native dylib axes (Engine / Feature / Native)”)
    • ~/dev/fera/project-design/context/OPTIM-IDEAS-3-research.md:101 — “LTO + strip + panic=abort + opt-level=z / s”
    • ~/dev/fera/project-design/context/conversation.md:331 — the only mention of catch_unwind anywhere in the corpus
    • ~/dev/fera/chroix-core/src/lib.rs:283-286, :317-321, :470-473 — assert! panic guards
    • ~/dev/fera/chroix-core/src/lib.rs:287, :329 — .expect("cell path resolves in state")
    • ~/dev/fera/fera/core/crates/domain/src/chat.rs:264 — unreachable!() in a shipping path
    • ~/dev/fera/chroix-core/src/lib.rs:192-195 — CELL_BUDGET = 128 (“one bit per cell in DirtyMask (u128)”)
    • ~/dev/fera/project-design/context/OQ.md:146 — frozen scene: “Chat list size | 500 chats (30 with unread)”
    • ~/dev/fera/project-design/context/TECHSPEC.md, ADR.md — zero occurrences of catch_unwind, panic =, abort in either

ADR-002 makes the first-party tier native dylibs with no sandbox and three dylib axes are load-bearing (ADR-001, ADR-029). That makes the process boundary the only boundary there is. Neither the ADRs nor the TECHSPEC define what happens when a module panics.

The mitigation that exists is one clause in a transcript, not a decision. conversation.md:331:

если протокольный код упадёт в панику, тянет весь процесс (но это решается catch_unwind на границе).

This is about the protocol thread pool, not the dylib boundary, it is not an ADR, and no code implements it. Grepping TECHSPEC.md and ADR.md for catch_unwind / panic / abort returns nothing.

The corpus also proposes the exact opposite policy. OPTIM-IDEAS-3-research.md:101, in the “aggressive optimizations” section, recommends panic=abort for binary size. That is not a style preference here — it is a hard conflict:

  • panic=abort and catch_unwind at a boundary are mutually exclusive by construction: with panic=abort there is no unwind to catch, so every module panic becomes a process abort.
  • Unwinding across a dynamic-library boundary between Rust code compiled with different panic strategies is undefined behaviour, not merely “worse error handling”. A host built panic=unwind loading a panic=abort module (or a module whose transitive dependency graph differs) is in the UB regime the moment a panic fires.
  • extern "C"-exported entry points abort on unwind by design, which is what a nylon-ring module ABI most likely looks like.

So the design corpus contains a size optimization that silently converts “a bad module crashes the chat window” into “a bad module corrupts the process”, for a saving measured in a few hundred KiB.

The panics are reachable, and one is reachable from the frozen scene

Section titled “The panics are reachable, and one is reachable from the frozen scene”

CELL_BUDGET = 128 (chroix-core/src/lib.rs:192-195) is one bit per cell in a u128 DirtyMask. Past it, register_cell / register_keyed_cell / frame_tagged panic (:283-286, :317-321, :470-473).

The frozen benchmark scene (OQ.md:146, OQ-006) specifies a 500-chat chat list. Keyed cells are documented as “created on demand when a key appears in a collection (the Fera message-list case, OQ-6 resolved)” (chroix-core/src/lib.rs:302-305) — i.e. one cell per entry in the collection. If the keyed collection is the chat list, the frozen scene needs 500 live cells against a 128 budget: 3.9x over, and the guard is assert!, not a degradation path.

The escape hatch does not close the hole: sweep only retires keyed cells whose keys vanish (chroix-core/src/lib.rs:339-352), and it runs on the slow lane — so the live key set can cross 128 between sweeps. Plain (non-keyed) cells are never swept (chroix-core/src/lib.rs:347-349).

Two more reachable panics in the same area:

  • .expect("cell path resolves in state") (:287, :329) — a cell read returning None panics. The read closures are user callbacks, so the panic trigger is caller data, not internal state.
  • unreachable!("next_local_id is never zero") (fera/.../domain/chat.rs:264) — an unreachable!() in a non-test shipping path. Panics, not an error.

None of these are Result-typed. Under ADR-002’s dylib architecture each one is an application-wide abort with no recovery, which is the concrete instance of the class the corpus never addresses.

  1. panic=unwind + catch_unwind at every module entry point. Converts an abort into a loadable-module error. Cost: unwinding across a dylib boundary is itself only sound when both sides agree on the panic strategy — so it must be a build-time invariant, not a convention (see option 4). Also does not contain abort(), SIGSEGV, or stack overflow — all of which remain fatal, and stack overflow is the realistic one for a recursive layout engine.
  2. panic=abort everywhere + treat any module panic as “host dies”, and say so. Honest and cheap, but it makes a bug in a third-party-adjacent first-party module a remote kill of the client for any user who installs it. Given “community modding is a stated goal” (ADR-002 Context), the blast radius is user-controlled.
  3. Run each dylib axis in its own process, not its own address space. The only option that actually contains arbitrary native failure (segv, abort, overflow, OOM). Cost: IPC, which ADR-005 works hard to amortize away — directly conflicts with the per-call dylib boundary ADR-005 assumes, and needs its own perf numbers.
  4. Make the panic strategy a checked build contract. Declare panic = "abort" (or unwind) in the root workspace and add a CI check that every member crate agrees, plus a #[panic_handler]-free extern "C" shim that catch_unwinds before returning across the ABI. Cheap, removes the UB class outright.

Option 4 now, Option 3 as the documented v2 direction, and delete the panic=abort line from OPTIM-IDEAS-3-research.md:101 (or annotate it “native module axes excluded”).

Concretely:

  • Add an ADR (or an ADR amendment to ADR-002 — it is currently a consequence nobody wrote down) that states: what happens when a native module panics. Minimum: catch_unwind at the module ABI shim, module marked unloadable, error surfaced in the UI, host survives.
  • Set the panic strategy explicitly in the workspace manifest and assert it in CI (cargo build for both strategies, or a metadata check), so the two-tier load pipelines cannot silently disagree.
  • Replace the three assert! cell guards with a Result/CellBudget error, or raise CELL_BUDGET to match the frozen scene. Right now a frozen, sanctioned benchmark panics the render loop — that is a scene/code contradiction, not a hypothetical.
  • Convert .expect("cell path resolves in state") to a Result or an explicit “cell path must resolve” invariant that is checked in debug and returns a sentinel in release. A user-supplied read closure must not be able to abort the process.
  • Replace unreachable!() at domain/chat.rs:264 with a typed error — it is on the message-send path, which is attacker-influenceable via message volume.

Ask before merging: is the module ABI extern "C" or a Rust-ABI trait object? The answer decides between option 1 and option 3, and no document in the corpus states it.