Expand description
inkferro-napi: napi-rs v3 FFI bridge.
§M3-E: the production render path
InkRoot owns the persistent DOM arena, the per-frame FrameWriter
transport, the JS transform dispatcher, and an internal cursor_dirty gate.
commit mutates the arena (decode → dom::apply); render_frame reads it,
styles it through the core entry, and diffs it through the writer.
§Field ownership
- the
Arena(inkferro_core::dom) — persisted acrosscommit()calls; per ADR-3 the layout engine is rebuilt fresh each render from the arena (render_styled→build_layout_engine), so there is NO incremental engine-sync state here —commitisdecode_ops -> dom::apply, full stop; - the
FrameWriter(inkferro_rt) — the live diff baseline, advanced ONLY by interactive (diff) andnon-interactiverenders.debug/screen-readerrenders deliberately route through a THROWAWAY writer so they never touch it (seerender_frame); - the JS transform-dispatch
FunctionRef— the pattern proven in M3-0; root_id— the arena does not designate a root, soInkRootrecords which id is theKind::Root. The JS reconciler allocates id 0 as the root (matching the core corpus + M3-A seam tests), so it defaults to 0;cursor_dirty+cursor_position— ink’ssetCursorPositionstate (log-update.ts:163-165).set_cursor(pos)stores the position and flips the dirty gate;render_frameresolves the ACTIVE cursor (active = cursor_dirty ? cursor_position : None, inkgetActiveCursor), passes it intoFrameParams.cursorfor the rt to compose the cursor escape bytes, then resetscursor_dirty = falseafter each frame (ink resetscursorDirtyatop every render). A stale position thus never persists across non-dirty frames (#41/M3-K3).
§FunctionRef storage (JS callback that outlives the call that supplied it)
The JS transform dispatcher arrives as Function<'_, FnArgs<(u32, String, u32)>, String>,
a scope-bound handle valid only for the duration of new(). To call it from
a later method, immediately create_ref() it into a
FunctionRef<FnArgs<(u32, String, u32)>, String> and store THAT in the struct.
FunctionRef is Send + Sync and holds a real napi reference (refcount 1), so
it survives across calls and across turns. Never store Function<'_> (its
lifetime is the call scope) and never store Env.
§borrow_back call shape (per-call, no leak by construction)
Inside a method holding env: Env, re-materialize the callable with
self.dispatcher.borrow_back(&env)? — a plain napi_get_reference_value, it
creates NO new reference, so calling it 10k times cannot leak. Then
f.call(FnArgs((id, line, index))) — the FnArgs tuple is spread as THREE
positional JS arguments (id, line, index), NOT one array/tuple. index is
the per-write local 0-based line index (usize → u32) the core walk
computes (ink output.ts lines.entries()).
§Transform dispatch + the error channel (Transformer returns String)
The core walk reads a node’s own transform through a
TransformAccessor:
Fn(u32) -> Option<Box<dyn Fn(&str, usize) -> String + 'a>>. The boxed
closure’s signature is infallible (-> String, not -> Result), so a JS
throw inside the dispatcher has no return-type channel to surface through.
We bridge it with an out-of-band error cell. The accessor mints, for every
node whose has_transform flag is set, a closure capturing the node id, a
borrow of self.dispatcher (lifetime 'a), env (Copy), and a
&RefCell<Option<Error>>. Each invocation:
- short-circuits (returns its input untouched) if the cell already holds an error — once one dispatcher throws we stop firing further JS calls for the rest of the walk;
- otherwise
borrow_backs the dispatcher and calls it; onOk(s)returnss, onErr(e)storesein the cell and returns the input untouched (a sentinel — the half-built string is discarded, never observed).
After the walk, render_frame inspects the cell: a Some(err) becomes the
method’s Err return BEFORE any FrameWriter mutation. This is the
throw-discard guarantee, realized by ordering (the M3-0 template): the whole
styled frame is built into LOCALS by render_styled, and FrameWriter is
mutated only on the success path after the cell is confirmed clean — so a
thrown render leaves the writer’s diff baseline EXACTLY as the prior frame left
it, and the next render_frame emits the bytes it would have emitted had the
failed call never happened.
Mutating core’s Transformer to return Result was rejected: it would change
render_styled/render_to_string and risk corpus bytes. The error cell keeps
core untouched (zero core edits in this task).
§Reentrancy (an EXPLICIT rendering guard is required — observed, not assumed)
render_frame and commit take &mut self, and render_frame holds that
&mut across the dispatcher call. The hypothesis going in was that napi-rs v3
borrow-checks &mut self reentrancy and turns a re-entrant call into a
catchable throw. The probe disproved it. With no guard, a transform
dispatcher that re-enters:
render_frame→ the host process SEGFAULTS (observed exit 139 on Node) — napi-rs hands the reentrant call a second, aliasing&mut self;commit→ returns without throwing, but the inner&mut self.arenaaliases the outer render’s&self.arena— undefined behavior that merely happened not to crash.
Both are memory-unsafety. The fix is the rendering: bool field: every
render_frame/commit entry reads it FIRST (before touching any other field)
and, if set, returns a catchable typed error (reentrancy_error). The outer
render_frame sets it via an RAII RenderingGuard that clears it on every
exit (return / Err / panic). Post-guard, the SAME probe shows a clean
catchable throw on BOTH Node and Bun (exit 0); the smoke suite pins this. The
outer render still completes — the dispatcher catches the reentry error and
returns normally — so the guard rejects only the reentrant call, not the frame
that triggered it.
§Transactional commit (decode-then-apply, with an id bound)
commit decodes the WHOLE op buffer first. decode_ops is all-or-nothing: on
a truncated buffer / unknown opcode / bad tag it returns a typed DecodeError
and the arena is never touched, so a rejected commit leaves prior DOM state
intact for free. Only a fully-decoded Vec<Op> reaches dom::apply.
Decode validates record structure but NOT id magnitude. The arena trusts
its caller’s ids: Op::Create is the only op that grows the backing vec
(Arena::insert -> Vec::resize_with(id + 1)), and Node is ~700 B, so a
structurally-valid Create{id: 0xFFFFFFFF} would request ~2.95 TB and abort
the host process — an UNCATCHABLE allocation failure, not a JS throw. commit
is the trust boundary that turns untrusted JS bytes into arena calls, so it
enforces an id ceiling (MAX_NODE_ID) on every id-bearing op BEFORE apply,
rejecting the whole buffer (no mutation) with a catchable InvalidArg error.
§Lifecycle without a Rust finalizer (Bun-safe subset)
No External, no custom finalize/Drop-based cleanup, no threadsafe
function. JS drives lifecycle: it allocates u32 ids and calls free(id) (the
Free op) when it detaches a node. This is the napi subset Bun’s N-API shim
covers.
BUILD caveat: @napi-rs/cli reads its napi config block from a
package.json in the CWD, not from --manifest-path. Build from
__test__/ (it carries the config block + the CLI devDep).
Structs§
- Cursor
Pos - A cursor position handed to
InkRoot::set_cursor. Mirrors ink’sCursorPosition(cursor-helpers.ts:3-6,{x, y}).Noneat the call site mirrorssetCursorPosition(undefined). - Frame
Result - The result of one
render_frame.bytesis the exact transport payload the JSInkclass (the sole stream writer, M3-K1) writes verbatim; the rest are queryable side outputs. - InkRoot
- The real
InkRoot: owns the persistent DOM arena, the per-frame transport writer, the JS transform dispatcher, and the cursor-dirty gate. - Input
Event - A high-level input event from
InkRoot::push_input, mirroring core’sInputEvent. Thekinddiscriminant selects which payload is present:Key⇒ (key,input);Paste⇒ (paste). - Key
- The JS-facing key object — a 1:1 mirror of ink’s
Keytype (use-input.ts:9-124), NOT the lower-levelparseKeypressoutput. napi-rs renames each snake_case field to the camelCase ink name automatically (up_arrow→upArrow, …);returnis a Rust keyword so it is the raw identifierr#return, which napi still emits asreturn. The FIELD-NAME-EXACT covenant (M3-F) is asserted by the spike viaObject.keys. - Rect
- A node’s computed layout, returned by
InkRoot::measure. Field set extends ink’s measure result toleft/topforuseBoxMetrics(M3-J).left/topare signed (negative margins can place a node outside its parent — matching the coreRect’s signedx/y);width/heightare unsigned terminal cells. - Render
Opts - Per-render geometry (
render_frame’soptsarg).colsis the terminal width handed to the core layout;rowsis the viewport height fed to write_frame’s fullscreen/clear decisions.
Enums§
- Input
Event Kind - The discriminant of an
InputEvent— mirrors core’sInputEventenum arms. napi has no native tagged-union for#[napi(object)], so the arm is carried as this string enum plus the optional payload fields. - Render
Mode - The render mode selector (
render_frame’smodearg).