Skip to main content

Crate inkferro_napi

Crate inkferro_napi 

Source
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 across commit() calls; per ADR-3 the layout engine is rebuilt fresh each render from the arena (render_styledbuild_layout_engine), so there is NO incremental engine-sync state here — commit is decode_ops -> dom::apply, full stop;
  • the FrameWriter (inkferro_rt) — the live diff baseline, advanced ONLY by interactive (diff) and non-interactive renders. debug / screen-reader renders deliberately route through a THROWAWAY writer so they never touch it (see render_frame);
  • the JS transform-dispatch FunctionRef — the pattern proven in M3-0;
  • root_id — the arena does not designate a root, so InkRoot records which id is the Kind::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’s setCursorPosition state (log-update.ts:163-165). set_cursor(pos) stores the position and flips the dirty gate; render_frame resolves the ACTIVE cursor (active = cursor_dirty ? cursor_position : None, ink getActiveCursor), passes it into FrameParams.cursor for the rt to compose the cursor escape bytes, then resets cursor_dirty = false after each frame (ink resets cursorDirty atop 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 (usizeu32) 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:

  1. 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;
  2. otherwise borrow_backs the dispatcher and calls it; on Ok(s) returns s, on Err(e) stores e in 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.arena aliases 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§

CursorPos
A cursor position handed to InkRoot::set_cursor. Mirrors ink’s CursorPosition (cursor-helpers.ts:3-6, {x, y}). None at the call site mirrors setCursorPosition(undefined).
FrameResult
The result of one render_frame. bytes is the exact transport payload the JS Ink class (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.
InputEvent
A high-level input event from InkRoot::push_input, mirroring core’s InputEvent. The kind discriminant selects which payload is present: Key ⇒ (key, input); Paste ⇒ (paste).
Key
The JS-facing key object — a 1:1 mirror of ink’s Key type (use-input.ts:9-124), NOT the lower-level parseKeypress output. napi-rs renames each snake_case field to the camelCase ink name automatically (up_arrowupArrow, …); return is a Rust keyword so it is the raw identifier r#return, which napi still emits as return. The FIELD-NAME-EXACT covenant (M3-F) is asserted by the spike via Object.keys.
Rect
A node’s computed layout, returned by InkRoot::measure. Field set extends ink’s measure result to left/top for useBoxMetrics (M3-J). left/top are signed (negative margins can place a node outside its parent — matching the core Rect’s signed x/y); width/ height are unsigned terminal cells.
RenderOpts
Per-render geometry (render_frame’s opts arg). cols is the terminal width handed to the core layout; rows is the viewport height fed to write_frame’s fullscreen/clear decisions.

Enums§

InputEventKind
The discriminant of an InputEvent — mirrors core’s InputEvent enum arms. napi has no native tagged-union for #[napi(object)], so the arm is carried as this string enum plus the optional payload fields.
RenderMode
The render mode selector (render_frame’s mode arg).