diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c72292..28811de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on Keep a Changelog and the project follows Semantic Version ## [Unreleased] +### Bug Fixes + +- Node backend auto execution mode now falls back to inline for headless worker-ineligible runs, and worker environment checks reject empty `nativeShimModule` strings. + +### Tests + +- Added node backend regressions for auto-mode fallback selection and worker environment support checks. + ## [0.1.0-alpha.60] - 2026-03-14 ### Bug Fixes diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 5cc4c42e..c0e61ad4 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -69,7 +69,7 @@ Both formats are little-endian, 4-byte aligned, and versioned. Mismatched versio The Node/Bun backend supports three execution modes: -- **`"auto"`** (default): selects `"inline"` when `fpsCap <= 30`, otherwise `"worker"`. +- **`"auto"`** (default): selects `"inline"` when `fpsCap <= 30`; otherwise prefers `"worker"` and falls back to `"inline"` when no TTY or `nativeShimModule` is available. - **`"worker"`**: native engine runs on a dedicated worker thread. Main thread is never blocked by terminal I/O. - **`"inline"`**: engine runs on the main thread. Lower latency, but main thread blocks during I/O. diff --git a/docs/backend/node.md b/docs/backend/node.md index 8550f5d3..65c38c31 100644 --- a/docs/backend/node.md +++ b/docs/backend/node.md @@ -26,7 +26,7 @@ knobs aligned: - `maxEventBytes` is applied to both app parsing and backend transport buffers. - `fpsCap` is the single scheduling knob. -- `executionMode: "auto"` resolves to inline when `fpsCap <= 30`, worker otherwise. +- `executionMode: "auto"` resolves to inline when `fpsCap <= 30`, otherwise it prefers worker mode and falls back to inline for headless runs without a TTY or `nativeShimModule`. Development hot reload: @@ -40,7 +40,7 @@ Development hot reload: Execution mode details: -- `auto` (default): select inline for low-fps workloads (`fpsCap <= 30`), worker otherwise. +- `auto` (default): select inline for low-fps workloads (`fpsCap <= 30`); otherwise prefer worker mode and fall back to inline when no TTY or `nativeShimModule` is available. - `worker`: force worker-thread engine execution. With the real native addon this requires an interactive TTY; test harnesses can provide `nativeShimModule` instead. diff --git a/docs/backend/worker-model.md b/docs/backend/worker-model.md index 0700565d..7251ffe1 100644 --- a/docs/backend/worker-model.md +++ b/docs/backend/worker-model.md @@ -2,7 +2,7 @@ Rezi supports three backend execution modes via `config.executionMode`: -- `auto` (default): inline when `fpsCap <= 30`, worker otherwise +- `auto` (default): inline when `fpsCap <= 30`; otherwise prefer worker and fall back to inline when no TTY or `nativeShimModule` is available - `worker`: run native engine/polling on a worker thread - `inline`: run native engine inline on the main JS thread diff --git a/docs/packages/node.md b/docs/packages/node.md index b4ac6a34..a6c17180 100644 --- a/docs/packages/node.md +++ b/docs/packages/node.md @@ -25,7 +25,7 @@ bun add @rezi-ui/node Set `config.executionMode` on `createNodeApp(...)`: -- `auto` (default): inline when `fpsCap <= 30`, worker otherwise +- `auto` (default): inline when `fpsCap <= 30`; otherwise prefer worker and fall back to inline when no TTY or `nativeShimModule` is available - `worker`: always run the engine on a worker thread - `inline`: run the engine inline on the main JS thread diff --git a/packages/node/src/__tests__/config_guards.test.ts b/packages/node/src/__tests__/config_guards.test.ts index 30dd9904..e042b0ee 100644 --- a/packages/node/src/__tests__/config_guards.test.ts +++ b/packages/node/src/__tests__/config_guards.test.ts @@ -13,6 +13,7 @@ import { } from "@rezi-ui/core"; import { ZrUiError } from "@rezi-ui/core"; import { selectNodeBackendExecutionMode } from "../backend/nodeBackend.js"; +import { hasWorkerEnvironmentSupport } from "../backend/nodeBackend/executionMode.js"; import { createNodeApp, createNodeBackend } from "../index.js"; async function withTempDir(run: (dir: string) => Promise | T): Promise { @@ -204,6 +205,39 @@ test("config guard: worker mode with shim does not fallback", () => { }); }); +test("config guard: auto mode falls back to inline when worker prerequisites are unavailable", () => { + const selection = selectNodeBackendExecutionMode({ + requestedExecutionMode: "auto", + fpsCap: 60, + hasAnyTty: false, + }); + assert.deepEqual(selection, { + resolvedExecutionMode: "worker", + selectedExecutionMode: "inline", + fallbackReason: "worker backend requires a TTY or nativeShimModule in auto mode", + }); +}); + +test("config guard: explicit worker mode stays on worker without silent fallback", () => { + const selection = selectNodeBackendExecutionMode({ + requestedExecutionMode: "worker", + fpsCap: 60, + hasAnyTty: false, + }); + assert.deepEqual(selection, { + resolvedExecutionMode: "worker", + selectedExecutionMode: "worker", + fallbackReason: null, + }); +}); + +test("config guard: worker environment support rejects empty shim strings", () => { + assert.equal(hasWorkerEnvironmentSupport(undefined, false), false); + assert.equal(hasWorkerEnvironmentSupport("", false), false); + assert.equal(hasWorkerEnvironmentSupport("mock://native-shim", false), true); + assert.equal(hasWorkerEnvironmentSupport(undefined, true), true); +}); + test("createNodeApp constructs a compatible app/backend pair", () => { const app = createNodeApp({ initialState: { value: 0 }, diff --git a/packages/node/src/backend/nodeBackend/executionMode.ts b/packages/node/src/backend/nodeBackend/executionMode.ts index 48bb6c7f..1fefc4cd 100644 --- a/packages/node/src/backend/nodeBackend/executionMode.ts +++ b/packages/node/src/backend/nodeBackend/executionMode.ts @@ -37,10 +37,17 @@ export function hasInteractiveTty(): boolean { ); } +export function hasWorkerEnvironmentSupport( + nativeShimModule: string | undefined, + hasAnyTty: boolean, +): boolean { + return hasAnyTty || (typeof nativeShimModule === "string" && nativeShimModule.length > 0); +} + export function selectNodeBackendExecutionMode( input: NodeBackendExecutionModeSelectionInput, ): NodeBackendExecutionModeSelection { - const { requestedExecutionMode, fpsCap } = input; + const { requestedExecutionMode, fpsCap, hasAnyTty, nativeShimModule } = input; const resolvedExecutionMode: "worker" | "inline" = requestedExecutionMode === "inline" ? "inline" @@ -49,6 +56,17 @@ export function selectNodeBackendExecutionMode( : fpsCap <= 30 ? "inline" : "worker"; + if ( + requestedExecutionMode === "auto" && + resolvedExecutionMode === "worker" && + !hasWorkerEnvironmentSupport(nativeShimModule, hasAnyTty) + ) { + return { + resolvedExecutionMode, + selectedExecutionMode: "inline", + fallbackReason: "worker backend requires a TTY or nativeShimModule in auto mode", + }; + } return { resolvedExecutionMode, selectedExecutionMode: resolvedExecutionMode, @@ -57,8 +75,7 @@ export function selectNodeBackendExecutionMode( } export function assertWorkerEnvironmentSupported(nativeShimModule: string | undefined): void { - if (nativeShimModule !== undefined) return; - if (hasInteractiveTty()) return; + if (hasWorkerEnvironmentSupport(nativeShimModule, hasInteractiveTty())) return; throw new ZrUiError( "ZRUI_BACKEND_ERROR", 'Worker backend requires a TTY when using @rezi-ui/native. Use `executionMode: "inline"` for headless runs or pass `nativeShimModule` in test harnesses.', diff --git a/packages/node/src/backend/nodeBackend/shared.ts b/packages/node/src/backend/nodeBackend/shared.ts index 0149ea9a..e1b26d9e 100644 --- a/packages/node/src/backend/nodeBackend/shared.ts +++ b/packages/node/src/backend/nodeBackend/shared.ts @@ -3,7 +3,8 @@ import type { DebugBackend, RuntimeBackend } from "@rezi-ui/core"; export type NodeBackendConfig = Readonly<{ /** * Runtime execution mode: - * - "auto": pick inline only for very low fps caps (<=30), worker otherwise + * - "auto": pick inline for very low fps caps (<=30); otherwise prefer worker + * and fall back to inline when no TTY/native shim is available * - "worker": worker-thread engine ownership * - "inline": single-thread inline backend (no worker-hop transport) */