Skip to content

feat: add backend beginFrame zero-copy SAB frame path#213

Merged
RtlZeroMemory merged 2 commits intomainfrom
epic-sab-begin-frame
Feb 26, 2026
Merged

feat: add backend beginFrame zero-copy SAB frame path#213
RtlZeroMemory merged 2 commits intomainfrom
epic-sab-begin-frame

Conversation

@RtlZeroMemory
Copy link
Owner

@RtlZeroMemory RtlZeroMemory commented Feb 26, 2026

Summary

  • add additive core backend marker/types for optional beginFrame writers
  • add Node worker backend beginFrame capability for SAB slot writers with commit/abort and preserved ACK marker behavior
  • wire WidgetRenderer and RawRenderer to prefer beginFrame + builder.buildInto(...) when both backend + builder support it, with safe abort fallback paths
  • add core and node tests covering beginFrame commit path selection and SAB integration

Testing

  • npm run lint
  • npm run build
  • npm test

Summary by CodeRabbit

  • New Features

    • Added zero-copy backend frame writer for more efficient frame submission.
    • Exposed BACKEND_BEGIN_FRAME_MARKER plus new types BackendBeginFrame and BackendFrameWriter in the public API.
  • Stability / Error Handling

    • Improved handling and safe abort/cleanup when frame building or submission fails.
  • Tests

    • Added integration tests covering the new begin-frame write/commit flow.

@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 626e086 and 810a743.

📒 Files selected for processing (2)
  • packages/node/src/__tests__/worker_integration.test.ts
  • packages/node/src/backend/nodeBackend.ts

📝 Walkthrough

Walkthrough

Adds a backend begin-frame API and zero-copy frame writer path; renderers use beginFrame/commit/abort when available to build drawlists directly into backend-provided buffers. Node backend implements beginFrame with SAB slot management and tests exercise the new writer path.

Changes

Cohort / File(s) Summary
Backend API & Exports
packages/core/src/backend.ts, packages/core/src/index.ts
Add BACKEND_BEGIN_FRAME_MARKER, BackendFrameWriter and BackendBeginFrame types and re-export them from index.ts.
Renderers (integration of begin-frame)
packages/core/src/app/rawRenderer.ts, packages/core/src/app/widgetRenderer.ts
Conditionally obtain beginFrame() via backend marker and, when builder supports buildInto, build into provided buffer. Use commit(byteLen) or abort() on errors; fall back to existing build()/requestFrame path otherwise.
Node backend implementation
packages/node/src/backend/nodeBackend.ts
Implement reserveFrameSubmission() and beginFrame(minCapacity?) returning writer {buf, commit, abort}; integrate with SAB slot lifecycle, frame acceptance ACK, and expose via BACKEND_BEGIN_FRAME_MARKER. Adjust requestFrame to align with submission model.
Integration tests
packages/core/src/app/__tests__/widgetRenderer.integration.test.ts, packages/node/src/__tests__/worker_integration.test.ts
Add tests for begin-frame writer path, including createBuildIntoBuilder helper and end-to-end SAB beginFrame/commit/ack flows. Note: test helper and test case duplicated in two locations.

Sequence Diagram(s)

sequenceDiagram
    participant Renderer as Renderer (rawRenderer / widgetRenderer)
    participant Builder as Builder (buildInto)
    participant Backend as Backend (NodeBackend)
    participant SAB as SAB Transport

    Renderer->>Backend: beginFrame(minCapacity)
    activate Backend
    Backend->>Backend: reserveFrameSubmission()
    Backend->>SAB: acquireSlot()
    Backend-->>Renderer: BackendFrameWriter(buf, commit, abort)
    deactivate Backend

    Renderer->>Builder: buildInto(buf)
    alt buildInto succeeds
        Renderer->>Backend: frameWriter.commit(byteLen)
        activate Backend
        Backend->>SAB: publishSabFrame / Atomics.notify
        Backend-->>Renderer: commit Promise resolves (in-flight)
        deactivate Backend
    else buildInto fails
        Renderer->>Backend: frameWriter.abort()
        activate Backend
        Backend->>SAB: releaseSlot()
        Backend-->>Renderer: abort handled
        deactivate Backend
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hop into the buffer, buffer soft and near,
I write my drawlist bytes with none to spare or tear.
Commit me, abort me, slot is warm and bright—
SAB slots hum, a rabbit’s render flight. 🚀

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 change: adding a backend beginFrame mechanism for zero-copy SAB frame writing, which is the core feature introduced across all modified files.

✏️ 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 epic-sab-begin-frame

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.

Caution

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

⚠️ Outside diff range comments (2)
packages/core/src/app/widgetRenderer.ts (2)

3608-3701: ⚠️ Potential issue | 🔴 Critical

Ensure frame writer abort is guaranteed on every exceptional exit before commit.

After Line 3614, some throw paths can bypass abortFrameWriter() (for example, if buildInto throws at Line 3634, or if later logic throws before Line 3690), which can leak a reserved SAB slot.

Suggested hardening (wrap writer-owned section and always abort on failure)
-      const buildToken = perfMarkStart("drawlist_build");
-      const built: DrawlistBuildResult =
-        frameWriter === null
-          ? this.builder.build()
-          : (
-              this.builder as unknown as {
-                buildInto: (buf: Uint8Array) => DrawlistBuildResult;
-              }
-            ).buildInto(frameWriter.buf);
-      perfMarkEnd("drawlist_build", buildToken);
+      let built: DrawlistBuildResult;
+      const buildToken = perfMarkStart("drawlist_build");
+      try {
+        built =
+          frameWriter === null
+            ? this.builder.build()
+            : (
+                this.builder as unknown as {
+                  buildInto: (buf: Uint8Array) => DrawlistBuildResult;
+                }
+              ).buildInto(frameWriter.buf);
+      } catch (e: unknown) {
+        abortFrameWriter();
+        return { ok: false, code: "ZRUI_DRAWLIST_BUILD_ERROR", detail: describeThrown(e) };
+      } finally {
+        perfMarkEnd("drawlist_build", buildToken);
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 3608 - 3701, The
frameWriter allocated from beginFrame can be left reserved when exceptions (e.g.
from builder.buildInto or later user code) escape before the successful commit;
ensure the writer is always aborted on any exceptional exit by wrapping the
entire "writer-owned" region (from creation of frameWriter / call to buildInto
through the backend request/commit) in a try/finally that calls
abortFrameWriter() in the finally when the writer was not successfully
committed, and only clear frameWriter (set to null) after a successful commit
path (the branch that calls frameWriter.commit or backend.requestFrame).
Concretely, locate beginFrame, frameWriter, abortFrameWriter, the buildInto call
and the branch that does frameWriter.commit/this.backend.requestFrame and
restructure so the commit/return happens inside the try and abortFrameWriter()
is guaranteed in the finally for all throw paths; keep existing
abortFrameWriter() semantics and ensure frameWriter is nulled only after commit.

3608-3697: ⚠️ Potential issue | 🟡 Minor

Run the full test suite before merging this renderer change.

This is a renderer-path change and requires full test suite verification with npm test (or equivalently, node scripts/run-tests.mjs) per code-standards.md before committing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 3608 - 3697, This
change affects the renderer path (functions/variables: beginFrame, frameWriter,
abortFrameWriter, backend.requestFrame, frameWriter.commit) and must not be
merged until the full test suite is run; run the full test suite locally with
`npm test` or `node scripts/run-tests.mjs`, fix any failures that arise
(including unit, integration, and renderer-related tests), re-run tests until
all pass, and ensure CI also reports green before merging.
🧹 Nitpick comments (2)
packages/node/src/__tests__/worker_integration.test.ts (1)

765-778: Test-only buildInto shim is acceptable but note the error message.

The shim's error code "ZRDL_TOO_LARGE" with detail "target buffer too small for buildInto shim" is slightly misleading since it's the target that's too small, not the drawlist being too large. Since this is test-only code, it's acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/node/src/__tests__/worker_integration.test.ts` around lines 765 -
778, The test-only shim for builderWithInto.buildInto uses an error object whose
code and message are slightly misleading; update the shim in the
builderWithInto.buildInto assignment so the error.detail clearly states that the
target buffer is too small (e.g., "target buffer too small") and, if desired,
adjust the error.code from "ZRDL_TOO_LARGE" to a more accurate identifier
(reference builderWithInto.buildInto and the returned error object) so the error
reflects that the target buffer is undersized rather than the drawlist being too
large.
packages/node/src/backend/nodeBackend.ts (1)

1010-1018: Consider consistent error types for commit validation failures.

The commit method throws RangeError for invalid byteLen, while other backend errors use ZrUiError. This inconsistency is minor but worth noting. The current approach is acceptable since these are programming errors (invalid arguments) rather than runtime backend errors.

🤖 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 1010 - 1018, Replace
the thrown RangeError instances in NodeBackend's commit method with the
project's backend error type so error handling is consistent: change the two
checks that throw RangeError("NodeBackend: frame byteLen must be an integer")
and the RangeError with the out-of-range message to throw ZrUiError (preserving
the existing descriptive messages and including byteLen and slotBytes), ensuring
the validation in commit(byteLen: number) uses ZrUiError instead of RangeError.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 3608-3701: The frameWriter allocated from beginFrame can be left
reserved when exceptions (e.g. from builder.buildInto or later user code) escape
before the successful commit; ensure the writer is always aborted on any
exceptional exit by wrapping the entire "writer-owned" region (from creation of
frameWriter / call to buildInto through the backend request/commit) in a
try/finally that calls abortFrameWriter() in the finally when the writer was not
successfully committed, and only clear frameWriter (set to null) after a
successful commit path (the branch that calls frameWriter.commit or
backend.requestFrame). Concretely, locate beginFrame, frameWriter,
abortFrameWriter, the buildInto call and the branch that does
frameWriter.commit/this.backend.requestFrame and restructure so the
commit/return happens inside the try and abortFrameWriter() is guaranteed in the
finally for all throw paths; keep existing abortFrameWriter() semantics and
ensure frameWriter is nulled only after commit.
- Around line 3608-3697: This change affects the renderer path
(functions/variables: beginFrame, frameWriter, abortFrameWriter,
backend.requestFrame, frameWriter.commit) and must not be merged until the full
test suite is run; run the full test suite locally with `npm test` or `node
scripts/run-tests.mjs`, fix any failures that arise (including unit,
integration, and renderer-related tests), re-run tests until all pass, and
ensure CI also reports green before merging.

---

Nitpick comments:
In `@packages/node/src/__tests__/worker_integration.test.ts`:
- Around line 765-778: The test-only shim for builderWithInto.buildInto uses an
error object whose code and message are slightly misleading; update the shim in
the builderWithInto.buildInto assignment so the error.detail clearly states that
the target buffer is too small (e.g., "target buffer too small") and, if
desired, adjust the error.code from "ZRDL_TOO_LARGE" to a more accurate
identifier (reference builderWithInto.buildInto and the returned error object)
so the error reflects that the target buffer is undersized rather than the
drawlist being too large.

In `@packages/node/src/backend/nodeBackend.ts`:
- Around line 1010-1018: Replace the thrown RangeError instances in
NodeBackend's commit method with the project's backend error type so error
handling is consistent: change the two checks that throw
RangeError("NodeBackend: frame byteLen must be an integer") and the RangeError
with the out-of-range message to throw ZrUiError (preserving the existing
descriptive messages and including byteLen and slotBytes), ensuring the
validation in commit(byteLen: number) uses ZrUiError instead of RangeError.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f3900d4 and 626e086.

📒 Files selected for processing (7)
  • packages/core/src/app/__tests__/widgetRenderer.integration.test.ts
  • packages/core/src/app/rawRenderer.ts
  • packages/core/src/app/widgetRenderer.ts
  • packages/core/src/backend.ts
  • packages/core/src/index.ts
  • packages/node/src/__tests__/worker_integration.test.ts
  • packages/node/src/backend/nodeBackend.ts

@RtlZeroMemory RtlZeroMemory merged commit 3fdcbb0 into main Feb 26, 2026
26 of 30 checks passed
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 626e086573

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return null;
}

const slotIndex = acquireSabSlot(sabFrameTransport);

Choose a reason for hiding this comment

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

P1 Badge Do not reclaim READY SAB slots in beginFrame

Using acquireSabSlot() here can steal a slot in READY state, but beginFrame() does not publish a replacement header/token until commit() (and abort() never does). Under SAB contention (all slots occupied), the worker can still read the old published frame metadata and then fail in engineWorker.tick() because the slot is no longer READY with the expected token, which escalates to a backend fatal and rejects in-flight frames. This can happen if frame building takes time before commit() or when a reclaimed writer is aborted.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

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

Addressed in follow-up PR #214: beginFrame now only acquires FREE SAB slots (no READY-slot reclaim), plus a regression test for single-slot pressure.

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