Skip to content

refactor(native): split bridge modules and merge worker teardown fix#255

Merged
RtlZeroMemory merged 7 commits intomainfrom
codex/refactor-native-bridge-modules
Mar 6, 2026
Merged

refactor(native): split bridge modules and merge worker teardown fix#255
RtlZeroMemory merged 7 commits intomainfrom
codex/refactor-native-bridge-modules

Conversation

@RtlZeroMemory
Copy link
Owner

@RtlZeroMemory RtlZeroMemory commented Mar 5, 2026

Summary

  • split the native bridge into dedicated modules for config parsing, debug APIs, FFI bindings, engine registry, and native-focused tests
  • preserve the existing N-API surface while shrinking packages/native/src/lib.rs
  • merge PR Fix native worker-thread teardown crash #254 into the refactor branch and carry its worker-thread teardown fixes into the modularized structure

Validation

  • npm run build
  • npm run lint
  • cargo test
  • npm run build:native
  • npm run test:native:smoke
  • node scripts/run-tests.mjs --filter "config_guards|worker_integration"

Summary by CodeRabbit

  • New Features

    • Added comprehensive debugging API with engine debug enable, disable, query, and statistics retrieval capabilities.
    • Introduced debug payload export and reset functionality.
  • Refactor

    • Reorganized internal module structure for better maintainability and safety.
    • Enhanced engine lifecycle and configuration validation.
  • Tests

    • Added extensive test coverage for rendering, debugging, and platform integration.

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 53910053-03ea-44d3-92df-206155986cdd

📥 Commits

Reviewing files that changed from the base of the PR and between e7e1021 and 5eafe9c.

📒 Files selected for processing (5)
  • packages/native/scripts/smoke.mjs
  • packages/native/src/config.rs
  • packages/native/src/debug.rs
  • packages/native/src/tests.rs
  • packages/node/src/__tests__/worker_integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/node/src/tests/worker_integration.test.ts

📝 Walkthrough

Walkthrough

Comprehensive refactor of native bindings introducing modular architecture with dedicated modules for FFI definitions, configuration validation, debugging API, and thread-safe engine registry. Adds module pinning for lifetime safety, restructures engine lifecycle management, and exposes new debug and capability query functions to JavaScript.

Changes

Cohort / File(s) Summary
Native FFI Layer
packages/native/src/ffi.rs
New comprehensive C FFI bindings defining 23 structs (engine config, runtime state, debugging, metrics, rendering) and 40+ unsafe extern "C" functions for engine lifecycle, frame buffering, rendering, diffing, event handling, and debug facilities.
Configuration System
packages/native/src/config.rs
New validation and parsing module providing strict JS-FFI binding layer with key-mapping constants, unknown-key rejection, nested object validation, and helpers for extracting/applying numeric, boolean, and object fields to FFI structs.
Debug API
packages/native/src/debug.rs
New debugging module exposing 7 public functions (enable/disable/query/get_payload/get_stats/export/reset) with two data structures (DebugStats, DebugQueryResult) bridging native engine state queries to JavaScript via N-API, including 64-bit BigInt conversion and input validation.
Engine Registry
packages/native/src/registry.rs
New thread-safe engine registry using OnceLock/Mutex/HashMap with per-engine ownership tracking, active-call counter via RAII EngineGuard, and condition-variable-based idle waiting for safe engine lifecycle management across threads.
Main Module Refactor
packages/native/src/lib.rs
Modularization of monolithic code into config/debug/ffi/registry modules; adds platform-specific module pinning via dladdr (Linux/macOS) and GetModuleHandleExW (Windows); introduces TerminalCaps/EngineMetrics as public API structures; routes engine creation/destruction through registry; adds standardized error handling and BigInt conversion helpers; adds module initialization hook.
Test Coverage
packages/native/src/tests.rs
New comprehensive Rust test harness with 20+ tests exercising framebuffer FFI, link/state behavior, clipping/painting, layout validation, cursor re-anchoring, debug parsing, and style transitions; validates FFI structure sizes/offsets and success/error paths.
TypeScript Declarations
packages/native/index.d.ts
Added public API surface for debugging with new DebugStats and DebugQueryResult interfaces and 7 engineDebug* function declarations; removed duplicate declarations previously present later in file (reorganization without semantic change).
Smoke Tests & Worker Integration
packages/native/scripts/smoke.mjs, packages/node/src/__tests__/worker_integration.test.ts
Updated cross-thread error expectations: enginePostUserEvent from wrong thread now returns ZR_ERR_INVALID_ARGUMENT instead of ZR_OK; added non-object plat validation test; refactored exit promise handling in worker tests.
Build Script
packages/native/build.rs
Formatting and indentation adjustments only; no functional or control-flow changes.

Sequence Diagram

sequenceDiagram
    participant JS as JavaScript
    participant NAPI as N-API Binding
    participant REG as Engine Registry
    participant CFG as Config Validator
    participant DBG as Debug Module
    participant FFI as C FFI Layer

    rect rgba(100, 200, 255, 0.5)
    Note over JS,FFI: Engine Creation Flow
    JS->>NAPI: engineCreate(config)
    NAPI->>CFG: apply_create_cfg_strict(config)
    CFG->>CFG: validate_known_keys + extract fields
    CFG->>FFI: zr_engine_config_default + apply values
    NAPI->>FFI: engine_create(config)
    FFI-->>NAPI: *mut zr_engine_t
    NAPI->>REG: register_engine(engine_ptr)
    REG->>REG: allocate engine_id, create EngineSlot
    REG-->>NAPI: engine_id
    NAPI-->>JS: engine_id
    end

    rect rgba(200, 100, 255, 0.5)
    Note over JS,FFI: Debug Query Flow
    JS->>NAPI: engineDebugQuery(engine_id, query)
    NAPI->>REG: get_engine_guard(engine_id)
    REG->>REG: validate ownership, increment active_calls
    REG-->>NAPI: EngineGuard
    NAPI->>DBG: apply_debug_query(query)
    DBG->>DBG: parse_debug_query fields
    NAPI->>FFI: engine_debug_query(config)
    FFI-->>NAPI: zr_debug_query_result_t
    NAPI->>DBG: convert to DebugQueryResult (BigInt conversion)
    DBG-->>NAPI: DebugQueryResult
    NAPI-->>JS: DebugQueryResult
    Note over REG: EngineGuard drops, active_calls--
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A registry springs forth, engines safely pinned,
Debug queries dance through threads of thread-local wind,
Config whispers strict rules, FFI dreams take flight,
Module pinning holds steady—no more crashes in the night!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: modularization of native bridge code (config, debug, FFI, registry) and integration of a worker teardown fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/refactor-native-bridge-modules

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/node/src/backend/nodeBackend.ts (1)

356-369: ⚠️ Potential issue | 🟠 Major

Auto mode can now pick an unrunnable worker path in headless environments.

With hasAnyTty/nativeShimModule no longer considered here, executionMode: "auto" frequently resolves to "worker" and then fails at startup via assertWorkerEnvironmentSupported, turning mode selection into a deferred runtime error.

🔧 Proposed fix
 export function selectNodeBackendExecutionMode(
   input: NodeBackendExecutionModeSelectionInput,
 ): NodeBackendExecutionModeSelection {
-  const { requestedExecutionMode, fpsCap } = input;
+  const { requestedExecutionMode, fpsCap, hasAnyTty, nativeShimModule } = input;
   const resolvedExecutionMode: "worker" | "inline" =
     requestedExecutionMode === "inline"
       ? "inline"
       : requestedExecutionMode === "worker"
         ? "worker"
         : fpsCap <= 30
           ? "inline"
           : "worker";
+
+  if (
+    requestedExecutionMode === "auto" &&
+    resolvedExecutionMode === "worker" &&
+    nativeShimModule === undefined &&
+    !hasAnyTty
+  ) {
+    return {
+      resolvedExecutionMode,
+      selectedExecutionMode: "inline",
+      fallbackReason: "worker-requires-tty",
+    };
+  }
+
   return {
     resolvedExecutionMode,
     selectedExecutionMode: resolvedExecutionMode,
     fallbackReason: null,
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/node/src/backend/nodeBackend.ts` around lines 356 - 369, When
resolving executionMode for the "auto" path, the current logic can pick "worker"
even in headless environments; change the resolution so that when
requestedExecutionMode is neither "inline" nor "worker" you only choose "worker"
if the runtime supports workers (check the same runtime flags used by
assertWorkerEnvironmentSupported, e.g., hasAnyTty and nativeShimModule or an
existing isWorkerSupported helper); otherwise pick "inline" and set
fallbackReason accordingly. Update the block that computes
resolvedExecutionMode/selectedExecutionMode (the variables
requestedExecutionMode, fpsCap, resolvedExecutionMode) to include this
environment support check so auto never defers to an unrunnable worker path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/native/src/config.rs`:
- Around line 235-237: The code currently masks a parsed u32 into a u8
(js_u32(...) -> dst.requested_color_mode = (v & 0xFF) as u8) which silently
accepts out-of-range values; change this to validate the parsed value before
assignment: after obtaining v from js_u32 for
"requestedColorMode"/"requested_color_mode", check that v <= u8::MAX and return
a proper parse/validation error if it exceeds that range, otherwise cast to u8
and assign to dst.requested_color_mode; reference the js_u32 call and
dst.requested_color_mode field to locate where to add the range check and error
return.
- Around line 185-195: The js_obj function currently calls coerce_to_object
which boxes primitives; change it to reject non-object JS values by checking
v.get_type()?==ValueType::Object and only then calling v.coerce_to_object(); if
the type is not Object, skip/continue (so primitives and other types are not
accepted as valid objects for strict parsing of limits/plat). Ensure this check
is applied in the js_obj function before attempting coerce_to_object for both
primary and alias lookup paths.

In `@packages/native/src/debug.rs`:
- Around line 74-80: The Number-to-u64 conversion in the ValueType::Number
branch currently accepts fractional values and unsafe JS integers; update the
logic in the ValueType::Number handling (around value.coerce_to_number(),
number.get_double(), and the float → u64 return) to first verify the double is
an exact integer (no fractional part) and within the JavaScript safe integer
range (<= 9007199254740991 and >= 0). If the value is out of the safe range,
reject here and require using the BigInt path (e.g., handle ValueType::BigInt or
coerce_to_bigint()) instead; if the double is non-finite, negative, fractional,
or > JS_SAFE_INTEGER, return Err(()) rather than truncating, otherwise safely
cast to u64.

In `@packages/native/src/lib.rs`:
- Around line 386-404: enginePostUserEvent currently calls
ffi::engine_post_user_event without the is_owner_thread() guard other mutating
entry points use; either restore the same thread-owner enforcement or add
explicit documentation why cross-thread calls are safe. Update
engine_post_user_event/enginePostUserEvent to call is_owner_thread() (same
pattern used by engineSubmitDrawlist, enginePresent, engineSetConfig) and return
the same error code when not owner, or add a clear doc comment on
engine_post_user_event and the FFI declaration (ffi::engine_post_user_event)
explaining thread-safety guarantees and why get_engine_guard plus calling from
other threads is safe, referencing these symbols so the rationale is
discoverable.

In `@packages/native/src/registry.rs`:
- Around line 39-50: The wait_for_idle() method uses active_calls_mu,
active_calls_cv and the active_calls AtomicUsize, but notifications are
currently sent without holding active_calls_mu which can cause a lost-wakeup;
update the Drop implementation that decrements active_calls so it first locks
active_calls_mu (using the same Mutex used by wait_for_idle), decrement
active_calls while holding that guard, then call
active_calls_cv.notify_all()/notify_one() while still holding the guard (or
immediately after releasing if your CV requires), ensuring the decrement and
notify are performed under the same mutex to satisfy the condition-variable
contract.

---

Outside diff comments:
In `@packages/node/src/backend/nodeBackend.ts`:
- Around line 356-369: When resolving executionMode for the "auto" path, the
current logic can pick "worker" even in headless environments; change the
resolution so that when requestedExecutionMode is neither "inline" nor "worker"
you only choose "worker" if the runtime supports workers (check the same runtime
flags used by assertWorkerEnvironmentSupported, e.g., hasAnyTty and
nativeShimModule or an existing isWorkerSupported helper); otherwise pick
"inline" and set fallbackReason accordingly. Update the block that computes
resolvedExecutionMode/selectedExecutionMode (the variables
requestedExecutionMode, fpsCap, resolvedExecutionMode) to include this
environment support check so auto never defers to an unrunnable worker path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3805425a-5400-4f05-9915-255499b049b1

📥 Commits

Reviewing files that changed from the base of the PR and between 2b19421 and 96a1e9f.

📒 Files selected for processing (14)
  • packages/native/build.rs
  • packages/native/index.d.ts
  • packages/native/loader.cjs
  • packages/native/scripts/smoke-worker.mjs
  • packages/native/scripts/smoke.mjs
  • packages/native/src/config.rs
  • packages/native/src/debug.rs
  • packages/native/src/ffi.rs
  • packages/native/src/lib.rs
  • packages/native/src/registry.rs
  • packages/native/src/tests.rs
  • packages/node/src/__tests__/config_guards.test.ts
  • packages/node/src/__tests__/worker_integration.test.ts
  • packages/node/src/backend/nodeBackend.ts
💤 Files with no reviewable changes (1)
  • packages/native/loader.cjs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/native/src/config.rs (1)

238-240: ⚠️ Potential issue | 🟠 Major

Do not silently truncate requestedColorMode.

Line 239 masks with & 0xFF, which converts out-of-range inputs into unrelated modes. This should reject invalid ranges instead.

💡 Proposed fix
 fn apply_plat(dst: &mut ffi::plat_config_t, obj: &JsObject) -> ParseResult<()> {
     if let Some(v) = js_u32(obj, "requestedColorMode", "requested_color_mode")? {
-        dst.requested_color_mode = (v & 0xFF) as u8;
+        if v > u8::MAX as u32 {
+            return Err(());
+        }
+        dst.requested_color_mode = v as u8;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/native/src/config.rs` around lines 238 - 240, The code is silently
truncating out-of-range `requestedColorMode` values by masking with `& 0xFF`
instead of rejecting them; replace the mask with an explicit range check after
reading `v` (from `js_u32(obj, "requestedColorMode", "requested_color_mode")?`)
and return an error if `v` > u8::MAX, otherwise assign `dst.requested_color_mode
= v as u8`. Ensure the function returns a descriptive error (not silently
adjusting the value) so callers know the input was invalid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/native/scripts/smoke.mjs`:
- Around line 75-97: The assertWorkerLoadExitCleanly function can hang because
worker.once("exit", ...) is added only after the message promise resolves;
register the exit listener earlier or capture exit code inside the initial
Promise that waits for the "message" so the exit event can't be missed. Update
the Promise that creates loadResult (in assertWorkerLoadExitCleanly) to also
listen for "exit" (and store/resolve the exit code or reject if non-zero) or
attach the worker.once("exit", ...) before awaiting loadResult, and ensure all
listeners (onExit, onError, onMessage) are removed consistently when any of them
fires.

In `@packages/native/src/config.rs`:
- Around line 137-152: The js_u32 function currently allows non-number types to
be coerced (e.g., strings/bools/null); update js_u32 to mirror the strict
pattern used by js_u8_bool and js_obj by first checking v.get_type() and
rejecting any type other than ValueType::Undefined or ValueType::Number (i.e.,
continue on Undefined, return Err(()) on any other type), then safely call
v.coerce_to_number() / get_double() and validate the finite, non-negative,
integer, <= u32::MAX constraints before returning Some(f as u32); keep the
existing alias/primary loop and error mapping behavior.

---

Duplicate comments:
In `@packages/native/src/config.rs`:
- Around line 238-240: The code is silently truncating out-of-range
`requestedColorMode` values by masking with `& 0xFF` instead of rejecting them;
replace the mask with an explicit range check after reading `v` (from
`js_u32(obj, "requestedColorMode", "requested_color_mode")?`) and return an
error if `v` > u8::MAX, otherwise assign `dst.requested_color_mode = v as u8`.
Ensure the function returns a descriptive error (not silently adjusting the
value) so callers know the input was invalid.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66b4c434-1f65-40df-82cb-be5ad0094a62

📥 Commits

Reviewing files that changed from the base of the PR and between 96a1e9f and 301c4b8.

📒 Files selected for processing (4)
  • packages/native/scripts/smoke.mjs
  • packages/native/src/config.rs
  • packages/native/src/lib.rs
  • packages/native/src/registry.rs

@RtlZeroMemory RtlZeroMemory merged commit df8fcef into main Mar 6, 2026
29 checks passed
@RtlZeroMemory RtlZeroMemory deleted the codex/refactor-native-bridge-modules branch March 6, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant