Skip to content

Surface and bound live view connect failures - #403

Closed
robertjamesprior wants to merge 1 commit into
mainfrom
hypeship/bound-live-view-connect-retries
Closed

robertjamesprior wants to merge 1 commit into
mainfrom
hypeship/bound-live-view-connect-retries

Conversation

@robertjamesprior

@robertjamesprior robertjamesprior commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The live view failed silently, and nothing downstream could see it. onMessage is
assigned straight to ws.onmessage, so a throw from createPeer or setRemoteOffer
was discarded: no emit('error'), no onDisconnected, no parent-frame message. The
signaling socket stayed open and healthy, so the client could not tell "peer
construction failed" from "still connecting."

  • Route the throw. onMessage wraps the handler, so a rejection reaches
    onDisconnected. Covers the whole handler, not just signal/provide.
  • Retry the transient failures. A transport close or a connect timeout is tagged
    where it happens and re-attempted up to MAX_CONNECT_ATTEMPTS. An unclassified
    failure — a peer-construction throw, an ICE failure, a server-side disconnect — is
    terminal on the first attempt, because a retry that reproduces it is not recovery.
  • Report the terminal state. On giving up, post KERNEL_CONNECTION_FAILED to the
    parent with the reason, attempt count, and ICE/signaling/socket state.

An unsupported browser is terminal too; it used to loop through a login that could never
succeed. Retried attempts post nothing, so an embedder that remounts on
KERNEL_CONNECTION_TIMEOUT does not retry on top of us — that event now marks the last
attempt rather than every attempt.

Validation on a real image

Hotpatch A/B in one live session: the deployed image
(the currently deployed headful image, serving app.416afd11.js) against a local
build of this branch (app.47e216f9.js, which also carries the stacked staged-bounds
change). The live view was embedded in an iframe on a local parent page so the
parent-frame messages are observable — the client only posts them when embedded. The
fault was injected before any page script ran: RTCPeerConnection construction throws,
which is the failing boundary from the field.

scenario baseline (fb437e9) patched
healthy connected 638ms, playing 1809ms, no failure events connected 526ms, playing 1591ms, no failure events
RTCPeerConnection throws KERNEL_CONNECTION_TIMEOUT 15373ms, reason connection timeout KERNEL_CONNECTION_FAILED 432ms, reason injected: RTCPeerConnection blocked, attempts 1

Healthy connects post nothing, so the change does not touch viewers that work. The
swallowed throw is reported at 0.4s with its cause, where the deployed client waited
15.4s and reported a generic timeout that gave no indication it was terminal.

A hotpatch validates runtime behavior of the bundle, not image construction or release
packaging, and the failure is injected at the RTCPeerConnection boundary rather than by
a real network fault.

Not in this PR

Terminal UI with one-click resume, one shared recovery heuristic with the dashboard
viewer, a frames-based gate, retrying ICE failed, and awaiting addIceCandidate.

Testing

32 pass, 0 fail in images/chromium-headful/client, including 10 new cases in
tests/connect-bound.test.ts. vue-cli-service lint clean on changed files. The staged
connect bounds this branch enables are validated separately in #415.


Note

Medium Risk
Changes embedded live-view WebRTC connect lifecycle and parent postMessage contract, which embedders may rely on for recovery—scoped to the headful client, not auth or data stores.

Overview
Replaces the single 15s connect watchdog with per-stage bounds (transport / signaling / media) so timeouts name where the handshake stalled instead of a generic “connection timeout.” Stage expirations post KERNEL_CONNECTION_TIMEOUT with that stage as reason; other terminal pre-connect failures post KERNEL_CONNECTION_FAILED with reasons like unsupported, transport, peer, or server, plus ICE/signaling/socket snapshot fields for embedders.

onMessage now catches handler rejections (e.g. RTCPeerConnection construction throws) so failures reach onDisconnected and the parent frame instead of hanging with an open signaling socket. Pre-connect disconnects are treated as a failed connect (one parent message, giveUp blocks further connect attempts); post-connect drops stay ordinary disconnects with no kernel failure event.

Server kicks set _failure = 'server' before disconnect. New connect-bound.test.ts covers stage transitions, single-event reporting, and give-up behavior.

Reviewed by Cursor Bugbot for commit 3397bfa. Bugbot is set up for automated code reviews on this repo. Configure here.

@robertjamesprior
robertjamesprior marked this pull request as ready for review September 19, 2026 00:20
@robertjamesprior

robertjamesprior commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Out of draft. Single commit on main, MERGEABLE, no dependencies (postParentMessage landed in #362). Fixed an off-by-one in the attempt count in f00dbc7: the bound incremented before comparing, so the payload reported 4 attempts against a message saying 3. Tests pass, CI green.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread images/chromium-headful/client/src/neko/base.ts
@robertjamesprior

Copy link
Copy Markdown
Contributor Author

Converted to draft while it is under review. The narrowed connect bound is stacked on top as #415 (draft), so the retry in this PR is not left as the only change on the connect path. No code change here.

A live view that could not start reported nothing to the parent frame until a
15s watchdog fired, and a throw from peer construction was discarded outright,
so an embedder could not tell a failed connect from a slow one.

Bound the connect stages separately -- transport 15s, signaling 3s, media 2s --
and report the failure to the parent frame with a reason an embedder can branch
on: KERNEL_CONNECTION_TIMEOUT when a bound expires, KERNEL_CONNECTION_FAILED when
the connect fails outright. One event per failure, both carrying the ICE,
signaling and socket state at the moment it happened.

The client does not retry. A second attempt against the same peer costs the
viewer time an embedder can spend on a new session, and the reason is what lets
it make that call.
@robertjamesprior
robertjamesprior force-pushed the hypeship/bound-live-view-connect-retries branch from ed29f17 to 3397bfa Compare September 25, 2026 21:21
@robertjamesprior
robertjamesprior deleted the hypeship/bound-live-view-connect-retries branch September 25, 2026 21:21
@robertjamesprior

Copy link
Copy Markdown
Contributor Author

Superseded by #417. Renaming this branch closed the PR, which was not intended — GitHub does not carry an open PR across a head-branch rename.

The content is not lost: #417 is this change and the stacked staged-bounds change (#415, now closed) collapsed into one commit on main. What changed in the collapse, beyond squashing:

  • Retries are removed. A second attempt against the same peer costs the viewer time an embedder can spend on a new session, and it delays the reason. The client reports the first failure.
  • One event per failure. The timeout path used to post KERNEL_CONNECTION_TIMEOUT and then KERNEL_CONNECTION_FAILED for the same connect; it now posts the timeout event only.
  • reason is a closed set (transport / signaling / media / peer / unsupported / server) instead of prose and the raw error message, so an embedder can branch on it. attempts is gone with the retry.

The bugbot finding on this PR — a rethrow from createPeer reaching onDisconnected and reporting nothing — is fixed in #417 by the same guard discussed in this thread: onDisconnected routes a pre-connect disconnect to the terminal report instead of tearing down silently.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3397bfa. Configure here.

if (event === EVENT.SIGNAL.OFFER) {
const { sdp } = payload as SignalOfferPayload
await this.setRemoteOffer(sdp)
this.armStage('media')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-offer rearms terminal media timeout

High Severity

signal/offer is a follow-up on an existing peer (createPeer only runs on signal/provide), but this path always calls armStage('media'). That starts a 2s timeout whose expiry is terminal: reportFailure sets _gaveUp and onDisconnected tears the session down. After ICE is already connected or checking, the state change that would clear the bound often never fires again, so a later offer — renegotiation, a resolution change — can kill a healthy live view and block any further connect() on this instance.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3397bfa. Configure here.

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