Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/backend/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/backend/worker-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/packages/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions packages/node/src/__tests__/config_guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(run: (dir: string) => Promise<T> | T): Promise<T> {
Expand Down Expand Up @@ -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 },
Expand Down
23 changes: 20 additions & 3 deletions packages/node/src/backend/nodeBackend/executionMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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.',
Expand Down
3 changes: 2 additions & 1 deletion packages/node/src/backend/nodeBackend/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
Loading