Skip to content

fix(ws): say what the server supports before the snapshot that waits on herdr (#2257) - #2268

Merged
devswha merged 7 commits into
mainfrom
fix/capabilities-before-snapshot
Oct 28, 2026
Merged

devswha merged 7 commits into
mainfrom
fix/capabilities-before-snapshot

Conversation

@devswha

@devswha devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Closes #2257

Problem

A send from Chat right after a reconnect failed with "Not sent: this PC has not said yet what it supports. Send it again." whenever the connection's first snapshot was late. The client waits 2 s for that snapshot because it is the only frame that lists the server's features (#1805, #1808). The guard keeps a message from being typed raw into a bridge that takes submit (#1591).

server/core/socket.ts sends the snapshot only after clientSnapshot() resolves, and that call waits on herdr's session.snapshot RPC (10 s timeout), the OmO/GJC refresh and the completion tracker. When that read fails, the connection gets only an error frame and never learns its features.

Measured on this PC (details in #2257):

  • An isolated server from main with 31 idle panes delivers its snapshot within 0.25 s: cold, warm, a burst of 10, an event loop blocked 1 s every 1.5 s, and +32 CPU burners. The socket itself is not slow.
  • The maintainer's main test app, which restarts after every merge, logged session.snapshot timed out after 10000ms ten seconds after a restart. Every browser that reconnected then waited at least 10 s.
  • With this change the features frame is the first frame of every connection, at about 0 ms after open, in all nine scenarios.

Change

  • shared/protocol.ts: a new server frame, { type: "features"; features; connection; herdr_lifetime? }.
  • server/core/socket.ts: open() sends it synchronously for a local connection, before awaiting the snapshot. It needs no herdr call. Features depend only on the server's version, and herdr_lifetime comes from the same socket stat as the snapshot's (server/finishes.ts). A relayed connection (/ws?machine_id=) forwards the remote PC's own frames unchanged.
  • src/lib/ws.ts: the client learns its features from the first trusted features frame or the snapshot. The guard is unchanged: nothing is sent until the features are known, and after 2 s without them the send is refused capabilities_unknown. Watch and chat-interest replays run once per connection. By default no features frame is trusted (trustFeatures).
  • server/collector.ts, server/core/status.ts: the frame names a herdr only while the server's lifecycle subscription to herdr is open, and only for the herdr whose identity was read before that connection was attempted and still reads the same when it answers. A socket file alone proves nothing: a herdr that crashed can leave its file behind.
  • src/lib/machineSession.ts: trusts a features frame only when its herdr_lifetime names the herdr this page already proved from an earlier snapshot of that PC (paneLifetimes.generation). Only then do the connection's pending scope and connection id open on it. The herdr-lifetime fence still runs on the snapshot. When the frame names another herdr, or none (a replaced or unproven herdr, or a first page load), the page waits for the snapshot as before, so no send leaves for a pane ID a replaced herdr may have given out again.

The common case, a server restart in front of the same herdr, gets the early path. Older servers send no features frame, and their behaviour is exactly as before. Nothing is auto-sent after a disconnect.

The design follows a capabilities handshake ahead of the state dump: what a peer supports is told before the costly state, as in LSP's initialize response or the Codex app server's initialize response. It differs in that the early answer is used only for a herdr the page already knows.

Tests

Each new test fails on origin/main (or, for the trust rule, on this PR's first commit) and passes here:

  • src/lib/ws.test.ts:
    • a send and an Insert go out once a trusted features frame came, with the snapshot still held;
    • an untrusted features frame changes nothing (capabilities_unknown, nothing sent);
    • replays run once per connection.
  • server/core/socket.test.ts: the features frame, with its connection id and herdr lifetime, precedes a held snapshot, and is still sent when the snapshot read fails.
  • server/collector.test.ts: the lifecycle subscription names the herdr that answered it until it closes, names none when the socket was replaced between the attempt and the answer, and none after a stop.
  • server/core/status.test.ts: the named herdr equals the snapshot's herdr_lifetime, and is named only while that herdr's subscription is open under the same socket.
  • src/lib/machineSession.test.ts:
    • a features frame naming the known herdr opens the scope, and a queued receipt in that window stays live through the snapshot, with nothing offered for recovery;
    • one naming another herdr, or none, leaves everything to the snapshot.
  • scripts/ui-regression.ts: on a reconnect, holds both frames and releases only the features frame. The message is sent and shows as a live queued row while the snapshot is still held. The full script passes (76 checks).
  • bun run check fast: green on the head commit (workflow syntax, generated types and themes, typecheck, build, unit tests). WS contract suites (submit, terminal-insert, ping, watch, secret, conversation-push) on the isolated herdr: 117 pass, 0 fail. The full ui-regression script also passes on the head commit (76 checks).

Review

Adversarial reviews (omo-native-code-reviewer and Codex Sol 6.1 fast xhigh), each on a worktree pinned to the reviewed commit:

  • 953e1f0: both blocked on one root cause, rated HIGH by omo and MEDIUM by Codex. The pending scope opened on the features frame before the herdr-lifetime fence ran on the snapshot, so after a herdr replacement a queued send could be both live and offered as unsent text. Fixed in fc09eab: the frame counts only for the herdr the page already proved.
  • fc09eab: Codex PASS. omo PASS with a reproduced MEDIUM: a crashed herdr leaves its socket file behind, so the frame could name a dead herdr. Fixed in bad1bf3: only a herdr answering on the open lifecycle subscription is named.
  • bad1bf3: omo PASS (LOWs fixed in 2568418). Codex BLOCK with a reproduced MEDIUM: the identity was read at the acknowledgement, not bound to the connection that answered. Fixed in 588bd5d: the identity is read before each attempt and must be unchanged when the answer arrives.
  • 588bd5d: Codex PASS, no findings (10 of 10 adversarial liveness cases, Unix and Windows identity readers).

Each fix came with a test that fails on the commit before it. CodeRabbit: requested on this PR. If it skips or is rate-limited, the findings and resolutions above stand in and are repeated in a PR comment.

Notes

  • REMOTE_BUNDLE_VERSION is not raised here: per docs/development.md the release PR raises it for server and shared changes.
  • LOW, not fixed:
    • Windows (argued, not run on the VM): herdr's identity there is its pid:start marker. If a replacement herdr answered on its pipe before rewriting that marker, the frame would name the old herdr for that window, as the snapshot already does on main, since both read the same marker. Once the marker is rewritten, the frame names no herdr until the next resubscribe, and pages wait for the snapshot.
    • The server's remote-PC observer (server/machines.ts observe) forwards the new frame to every browser's /api/machines/events stream, like role-ack. There it cancels one roster poll in flight per reconnect, and the roster ignores it otherwise.
    • Local browser checks ran with a temp root under /tmp. With the lane's temp root under ~, ui-regression's New-folder case failed: the dialog shows the folder as ~/..., while the test expects an absolute path. That code path is not touched here, and the case passes with a /tmp root.
  • Contract change (WS): waits for a real review.

…on herdr (#2257)

A send right after a reconnect was refused with capabilities_unknown when
the connection's first snapshot came later than 2 s: the snapshot was the
only frame listing the server's features, and it waits on herdr's
session.snapshot RPC (10 s after a restart, or never when the read fails).

The server now sends a small features frame (features and connection id)
synchronously on open, before the snapshot. The client learns its features
from the first of the two frames and keeps the guard: nothing is sent
before they are known, and an older server behaves as before. The
connection's pending scope opens on that same first frame, so a queued
send that leaves before the snapshot keeps its live row.

Closes #2257
…knows (#2257)

Review of the first commit reproduced a double send: the connection's
pending scope opened on the features frame while the herdr-lifetime fence
still ran on the snapshot, so after a herdr replacement a queued send made
in that window stayed live in the pane's queue and was also offered as
unsent text to send again.

The features frame now names the herdr the server reads (herdr_lifetime,
from a stat of herdr's socket, no herdr call). A page acts on it before
the snapshot only when it names the herdr this page already proved;
otherwise sends, receipts and the scope wait for the snapshot as before.
A server restart in front of the same herdr, the common case, keeps the
early path.
Re-review of the previous commit reproduced a MEDIUM: the features frame
named herdr from a stat of its socket file, and a herdr that crashed can
leave that file behind. A page reconnecting then could trust a dead
herdr's name and send before the snapshot, with the replacement herdr's
reused pane IDs.

The status collector now says when its lifecycle subscription to herdr
starts and closes, and the server names a herdr in the features frame only
while that subscription is open under the same socket identity. Without
it the frame names none and the page waits for the snapshot.
A subscription closed by its caller reports no close, so stopping the
collector left the last herdr named as live. Also tidies the hub fake's
doc comment and drops a test assertion that could not fail.
…empt (#2257)

Re-review reproduced a MEDIUM: the herdr identity was read when the
lifecycle subscription's acknowledgement arrived, not bound to the
connection that answered, so herdr A's acknowledgement could certify a
replacement B whose socket file had taken the path meanwhile.

The collector now reads herdr's identity before each connection attempt
and, on the acknowledgement, names it only if the socket still reads the
same; a close, a refusal or a stop names none.
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between fec98dc and 588bd5d.

Files selected for processing: 15
  • changelog.d/features-before-snapshot.md
  • scripts/ui-regression.ts
  • server/collector.test.ts
  • server/collector.ts
  • server/core/server-core.ts
  • server/core/socket.test.ts
  • server/core/socket.ts
  • server/core/status.fake.ts
  • server/core/status.test.ts
  • server/core/status.ts
  • shared/protocol.ts
  • src/lib/machineSession.test.ts
  • src/lib/machineSession.ts
  • src/lib/ws.test.ts
  • src/lib/ws.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 0 remain after this review.

📝 Summary

Summary by CodeRabbit

  • New Features
    • Connections now receive supported capabilities before the initial snapshot, allowing compatible clients to recognize available features sooner.
    • Older servers continue to provide capabilities through the snapshot.
  • Bug Fixes
    • After reconnecting, messages and other actions can wait briefly for capability information instead of failing immediately when it has not arrived. Untrusted or unavailable capability information is still handled safely, and existing capability checks continue to apply.

The server now sends a features frame before the first snapshot. Clients can use that frame when its Herdr lifetime matches known state, allowing capability discovery and session setup before the snapshot arrives. Older servers continue to provide features through the snapshot.

Changes: Feature discovery and reconnect handling

Layer / File(s) Summary
Track the live Herdr lifetime
server/collector.ts, server/collector.test.ts, server/core/status.ts, server/core/status.test.ts, server/core/status.fake.ts
The collector reports the identity of a live lifecycle subscription. The status hub exposes a Herdr lifetime only while that identity matches the current socket identity.
Send features before the snapshot
shared/protocol.ts, server/core/server-core.ts, server/core/socket.ts, server/core/socket.test.ts
The protocol adds a features frame. The server sends it with the connection ID and available Herdr lifetime before requesting the snapshot.
Trust feature frames for known sessions
src/lib/machineSession.ts, src/lib/machineSession.test.ts
MachineSession accepts early features only when their Herdr lifetime matches the known generation. A trusted frame can establish session scope and connection before the snapshot.
Use discovered capabilities before the snapshot
src/lib/ws.ts, src/lib/ws.test.ts, scripts/ui-regression.ts, changelog.d/features-before-snapshot.md
The WebSocket client uses trusted feature frames for capability discovery, waits for discovery before relevant operations, and restores watches once per connection. Tests cover feature timing and reconnect behavior; the changelog records the change.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Severity of issue fixed: Medium

Possibly related PRs

  • devswha/ovis#1805: Changes the send flow when capability information is not yet available.
  • devswha/ovis#1808: Defines the capabilities_unknown result after waiting for capability discovery.
  • devswha/ovis#1853: Adds terminal-insert capability handling to the server feature list and client snapshot wait.

Suggested reviewers: noobear

Merge Risk: Minimal · previous review 588bd

No actionable issue remains established for this change; it is ready for normal merge checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: the server sends supported capabilities before the herdr-dependent snapshot.
Description check Passed The description explains the problem, implementation, compatibility behavior, risks, tests, and validation results, including WS contract coverage. It uses a "Tests" section instead of the template's …
Linked Issues check Passed Issue #2257 requires early feature discovery for delayed or failed snapshots, retention of the send guard, and unchanged behavior with older servers. server/core/socket.ts sends a features frame b…
Out of Scope Changes check Passed The changed protocol, server lifecycle tracking, client trust checks, session scope handling, regression script, tests, and changelog support the reconnect capability-discovery objective in #2257. The…
Full details: Description check

Explanation

The description explains the problem, implementation, compatibility behavior, risks, tests, and validation results, including WS contract coverage. It uses a "Tests" section instead of the template's "Validation" heading, but the required validation information is present.

Full details: Linked Issues check

Explanation

Issue #2257 requires early feature discovery for delayed or failed snapshots, retention of the send guard, and unchanged behavior with older servers. server/core/socket.ts sends a features frame before awaiting clientSnapshot(), including the connection ID and optional live herdr lifetime. src/lib/ws.ts accepts trusted feature frames and keeps the 2-second capabilities_unknown guard. src/lib/machineSession.ts trusts the frame only for a herdr lifetime already proved by an earlier snapshot. The reported socket, client, lifecycle, session, regression, and check tests cover the required paths.

Full details: Out of Scope Changes check

Explanation

The changed protocol, server lifecycle tracking, client trust checks, session scope handling, regression script, tests, and changelog support the reconnect capability-discovery objective in #2257. The reported remote roster forwarding and Windows marker concern do not demonstrate an unrelated change or a separate feature. No unrelated code change is established by the reviewed summary or focused source evidence.

Full details: Docstring Coverage

Explanation

Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 5 functions across 14 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Review findings and resolutions (omo-native-code-reviewer + Codex Sol 6.1 fast xhigh)

CodeRabbit was rate-limited when this PR opened. These are the adversarial reviews this branch went through. Each ran read-only on a worktree pinned to the reviewed commit.

Reviewed Reviewer Verdict Finding Resolution
953e1f0 omo BLOCK HIGH (reproduced): the pending scope opened on the features frame, but the herdr-lifetime fence ran on the snapshot. After a herdr replacement, a queued send was live and also offered as unsent text, so it could be sent twice. fc09eab: a page acts on the features frame only when it names the herdr this page already proved. Otherwise it waits for the snapshot.
953e1f0 Codex BLOCK MEDIUM (reproduced, real socket + pending handlers): the same root cause; a later sent receipt left only the recovery copy. fc09eab (Codex's repro: nothing sent for a replaced herdr; the same herdr settles with no recovery copy)
953e1f0 omo — MEDIUM (argued): a send before the herdr is proven may reach a reused pane ID. fc09eab (same rule)
fc09eab Codex PASS none —
fc09eab omo PASS MEDIUM (reproduced): the frame named herdr from a socket-file stat, and a crashed herdr leaves its file behind. bad1bf3: a herdr is named only while the server's lifecycle subscription to it is open.
bad1bf3 omo PASS LOW: collector.stop() kept the name; a test assertion could not fail. 2568418
bad1bf3 Codex BLOCK MEDIUM (reproduced, Unix and Windows identity): the identity was read at the acknowledgement, not bound to the connection that answered, so A's acknowledgement could certify B. 588bd5d: the identity is read before each connection attempt and named only if unchanged at the acknowledgement. Codex's fixture: 4/4 pass, was 1/4.
588bd5d Codex PASS none (10/10 adversarial liveness cases) —

Each fix came with a test that fails on the commit before it.

Open LOWs, not fixed (also listed in the PR body):

  • The server's remote-PC observer forwards the new frame to the roster stream, which cancels one roster poll in flight per reconnect.
  • The Windows marker timing is argued only (not run on the VM).

Final verification on 588bd5d: bun run check fast green, the full scripts/ui-regression.ts (76 checks), and the WS contract suites (submit, terminal-insert, ping, watch, secret, conversation-push): 117 pass, 0 fail.

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The inactive-attach legacy-mount scenario imitates an older bridge by
removing "watch" from the snapshot's features. With this branch the server
lists its features in a frame of its own first, so the page still learned
"watch" from it and watched where an older bridge cannot. An older bridge
sends no features frame at all: the fixture now drops it in legacy mode.

Reproduced locally: ui-contexts-regression on 588bd5d's fixture fails
legacy-mount (1 !== 0) as in CI; chat-size-regression passes with this.
@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Fast checks on b705dd8 (run 40713549863, attempt 2) failed on one unit test of 8255: server/prompt.test.ts › "rechecks the live OmO process before answering a cached-looking widget" (card() returned null after 52 ms).

This PR touches nothing under server/prompt*. b705dd8 only changes a browser-test fixture on top of 588bd5d, whose Fast checks passed. Reproduction on this PC:

  • That test alone, 5 runs each: origin/main 4cc61dc 5/5 pass; this branch merged with origin/main 5/5 pass.
  • The whole server/prompt.test.ts: 392 pass, 0 fail on both.
  • bun run check fast on b705dd8 locally: green.

So this reads as a timing flake under CI's parallel unit load. I'll rerun the failed job once when the run completes.

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Attempt 2 of run 40713549863 on b705dd8: Fast checks passed on rerun (the server/prompt.test.ts flake above). "Browser regression (other scripts)" hit its 50-minute timeout-minutes while its retries ran. Two scripts failed both attempts, and both look like runner load rather than this change:

  • composer-fit-demo-regression.ts:278: setInputFiles: Timeout 30000ms exceeded on the hidden file input. This is the known load-sensitive upload timeout, and the script runs on the demo transport, so no server or WS handshake is involved.
  • file-viewer-regression.ts: "no task held the page for a second" saw 1,192 ms and then 1,244 ms long tasks while drawing an 8 MB file. That is CPU time on the runner, not WS frames.

Three other scripts failed once and passed on retry (application-paging, chat-virtual-browser-qa, agent-skill-menu-demo). The same job passed on 588bd5d, whose application code is identical: b705dd8 only changes the chat-size test fixture. The merge pass's attempt 3 is rerunning it.

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Attempt 3 of "Browser regression (other scripts)" on b705dd8 hit the 50-minute timeout-minutes again, with no script failing or going flaky: 83 of 89 scripts finished. The 6 still running at the limit are all demo-transport scripts (composer-fit, sidebar-activity, sidebar-declutter, sidebar-filter, pane-mute, workspace-touch-reorder), and none of them touches the server or the WS handshake.

This is CI-wide, not this PR: main's own run on 0ca9c08 timed out in the same job (cancelled at 16:53 UTC after 51 minutes), and fix/browser-regression-hang is in flight. Every other required check is green on b705dd8: Fast checks, Integration tests, Browser regression (ui-regression) and Native Windows install. This lane will not rerun the job; it waits for the merge pass to rerun it once the browser hang fix lands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Deploying ovis with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9c739bd
Status: ✅  Deploy successful!
Preview URL: https://b7f428b8.ovis.pages.dev
Branch Preview URL: https://fix-capabilities-before-snap.ovis.pages.dev

View logs

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Integration tests on 9c739bd (run 40736088806): one of 846 tests failed. server/herdr/seam.test.ts › "accepts largest supported pane and snapshot replies" hit herdr pane.selection.read timed out after 30000ms (58 s in all).

That test drives the herdr client against a fake node:net peer that answers with multi-megabyte reply lines, a reply-size ceiling check. It starts no Ovis server, collector, status hub or WebSocket, so nothing this PR changes is on its path; the timeout is the runner being slow to move those bytes. Fast checks and Native Windows install are green on this head. I'll rerun the failed job once when the run completes.

@devswha

devswha commented Oct 28, 2026

Copy link
Copy Markdown
Owner Author

Merging 9c739bd (b705dd8 with main merged in, no code change of its own). A WebSocket contract change, so it got a real review; Codex was at capacity, so an omo reviewer on gpt-6.1-sol and an independent second reviewer reviewed it; both pass with no findings. The early frame carries only the feature names, this connection's id and an optional hashed herdr identity, all of which a watching device already got in the snapshot; relayed sockets pass it through MachineRelay unchanged (interact and read-only, byte for byte). The client trusts it only for the herdr generation the page already observed for that PC, so first load and an unproven or replaced herdr keep snapshot-only discovery. Against the real client socket, 10 orderings (features then no snapshot, duplicated, after the snapshot, then a snapshot error, lost, an old server, distrust, wrong identity, a reconnect mid-way, a legacy snapshot) keep the #1591 guard: no message is ever typed raw into a bridge that takes submit. Main's client ignores the new frame. On real servers with isolated herdr sessions, main reproduced #2257 (a reconnect send with the snapshot held or failed returned capabilities_unknown); this head sent through submit while the snapshot was still held, a paired watch device stayed observe and was refused read_only, and a remote PC through the real MachineRelay worked the same. Remote PCs keep snapshot discovery until their bundle is updated; the next release's REMOTE_BUNDLE_VERSION bump carries it. Not filed: the replay test's name covers chat-interest replay but it exercises watches only (src/lib/ws.test.ts ~790-804). b705dd8 (test only) fixes the browser regression's older-bridge fixture, which removed 'watch' from the snapshot but still let the new features frame through; an older bridge sends no features frame, so the fixture now drops it too.

@devswha
devswha merged commit 358e39b into main Oct 28, 2026
27 of 29 checks passed
@devswha
devswha deleted the fix/capabilities-before-snapshot branch October 28, 2026 17:55
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.

Chat: a send right after a reconnect fails while the first snapshot is late (capabilities_unknown)

1 participant