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 ofcatch_unwindanywhere 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 inDirtyMask(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 ofcatch_unwind,panic =,abortin either
Substrate
Section titled “Substrate”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=abortandcatch_unwindat a boundary are mutually exclusive by construction: withpanic=abortthere 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=unwindloading apanic=abortmodule (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 anylon-ringmodule 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 returningNonepanics. 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) — anunreachable!()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.
Options (tradeoffs)
Section titled “Options (tradeoffs)”panic=unwind+catch_unwindat 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 containabort(), SIGSEGV, or stack overflow — all of which remain fatal, and stack overflow is the realistic one for a recursive layout engine.panic=aborteverywhere + 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.- 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.
- Make the panic strategy a checked build contract. Declare
panic = "abort"(orunwind) in the root workspace and add a CI check that every member crate agrees, plus a#[panic_handler]-freeextern "C"shim thatcatch_unwinds before returning across the ABI. Cheap, removes the UB class outright.
Proposed solution
Section titled “Proposed solution”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 buildfor both strategies, or a metadata check), so the two-tier load pipelines cannot silently disagree. - Replace the three
assert!cell guards with aResult/CellBudgeterror, or raiseCELL_BUDGETto 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 aResultor an explicit “cell path must resolve” invariant that is checked indebugand returns a sentinel in release. A user-supplied read closure must not be able to abort the process. - Replace
unreachable!()atdomain/chat.rs:264with 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.