Skip to content

fix(agent): give every start_desktop its own IPC id so it stops self-colliding (#3107) - #3194

Merged
ToddHebebrand merged 4 commits into
mainfrom
fix/3107-start-desktop-ipc-id-collision
Aug 7, 2026
Merged

fix(agent): give every start_desktop its own IPC id so it stops self-colliding (#3107)#3194
ToddHebebrand merged 4 commits into
mainfrom
fix/3107-start-desktop-ipc-id-collision

Conversation

@ToddHebebrand

@ToddHebebrand ToddHebebrand commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

A remote-desktop start could fail with desktop start failed after 2 attempts (helper keeps crashing) while the helper was healthy and had never crashed.

start_desktop is deliberately exempt from the heartbeat's command dedup (#434), because the viewer legitimately re-invokes the same commandId across reconnects and the command can arrive over both the WebSocket and the heartbeat response. So two invocations for one desktop session can be in flight at once — but every invocation derived the same IPC correlation id desk-<sessionID>, so the second was rejected by Session.SendCommand's ErrDuplicateCommand guard. The #434 exemption and the duplicate guard were in direct conflict.

The caller then compounded it in two ways:

  • startDesktopOnSession returned helperDied = true for every SendCommand error, so the duplicate-id rejection was misclassified as helper death.
  • The retry loop had no backoff, so both attempts burned in the same millisecond against the same still-healthy helper — exactly the reported log signature, where attempt=1 and attempt=2 share a timestamp and an error.

Changes

All in agent/internal/heartbeat/handlers_desktop_helper.go.

1. Unique IPC id per invocation. nextDesktopStartCommandID() numbers each start off an atomic.Uint64desk-<sessionID>-<n>. The desktop session id stays in the string so helper-side logs remain greppable. This resolves the #434-vs-duplicate-guard conflict, and as a bonus fixes a latent mis-correlation: with the shared id, a response from a timed-out attempt could be matched to a later attempt's pending entry and pass the response validator, handing the viewer an SDP answer for a peer connection that had already been torn down.

2. Single-flight per desktop session (joinOrRunDesktopStart). This is load-bearing, not a nicety. ErrDuplicateCommand was also the thing accidentally serializing concurrent starts; with unique ids both would reach the helper, and SessionManager.StartSession (remote/desktop/session_webrtc.go:41-58) unconditionally stops every existing session before registering the new one. So the second start would tear down the first, both callers would be told completed, and only one peer connection would be live. Worse, the first session's OnConnectionStateChange closure captures the session id rather than the *Session, so its late Closed callback calls StopSession(sessionID) and kills the second session, firing a spurious desktop_peer_disconnected. A joiner now takes the leader's result instead — "join or defer to it", as the issue asked.

3. Honest failure classification. desktopStartLostHelper() replaces the blanket "any error means the helper died":

Error Classified as Why
ErrDuplicateCommand not a death, terminal Another start is in flight against a live helper. Nothing crashed and a retry cannot help. Unreachable after changes 1-2 — kept as a guard against a future caller reintroducing a shared id.
ErrCommandTimeout not a death, terminal A helper that actually dies closes the session's done channel and surfaces as session closed while waiting for response, not as a timeout. Terminal because helperSessionForTarget does no liveness or pong-age check and would hand the retry back the very same session.
everything else helper lost, retry Failed socket write, session closed under us — a real transport failure. The default stays conservative.

4. Spaced retries + operator-facing messages. Retries are separated by desktopStartRetryBackoff (1s; a var so tests can shrink it, following the existing desktopLeaseRenewEvery pattern), selecting on stopChan so a pending retry never holds shutdown open. agentWs writes CommandResult.Error into remote_sessions.errorMessage and the viewer displays it, so both sentinels are now wrapped with a real message plus the helper/windows session ids (keeping %w) instead of surfacing as bare sessionbroker: command timed out. The terminal message carries the underlying cause instead of blaming a crash unconditionally, and the no capable helper available exit carries it too.

startDesktopOnDemand (the RDS path) picks up all of this for free — it shares startDesktopOnSession and runs inside the same single-flight.

Tests

New agent/internal/heartbeat/handlers_desktop_helper_collision_test.go. All three fix parts are mutation-verified — reverting each one fails the suite:

Mutation Caught by
unique id → shared desk-<sessionID> TestConcurrentStartDesktopOnSessionDoesNotSelfCollide, with exactly the reported error duplicate in-flight command id: "desk-desktop-1"
desktopStartLostHelper(err)true TestStartDesktopViaHelperDoesNotRetryWedgedHelper
single-flight bypassed all four TestJoinOrRunDesktopStart*

Plus TestDesktopStartLostHelper (table-driven over the classification matrix, bare and wrapped sentinels), TestNextDesktopStartCommandIDIsUniquePerInvocation, TestStartDesktopViaHelperSpacesRetriesAndReportsRealError, TestJoinOrRunDesktopStartReleasesOnPanic, and TestJoinOrRunDesktopStartNeverRunsTwoAtOnce — which asserts the invariant no scheduling order can break (at most one start executing per session) rather than a count that could flake.

Verification: go test -race ./... green across the whole agent module; the new tests run 8/8 clean repeatedly; go build ./..., go vet, gofmt clean on the touched files.

Deliberately out of scope

Left as follow-ups, noted so they are not lost:

  • Reaping a start whose outcome was never proven. SendCommand's timeout does not cancel helper-side work, so a timed-out start may still bring up a capture that nothing here owns. A compensating desktop_stop was implemented and then removed: the helper processes stop off StartSession's mutex, so a stop queued behind a wedged helper can land after the viewer's next start and silently kill a session the daemon believes is live — the same class of bug as this issue. Doing it safely needs a helper-side start generation so a stale stop cannot match a newer session. Meanwhile the orphan is self-limiting: the viewer never gets the answer, ICE never completes, and the peer connection tears itself down on the 15s failed timeout.
  • Ownership on the failure pathrememberDesktopOwner is still only reached on success, so a genuine desktop_peer_disconnected after a failed start is dropped as "non-owned session". Pairs naturally with the item above.
  • Retry window vs. respawn cadence — the retry still cannot outlast HelperLifecycleManager's 30s reconcileInterval, so a genuinely crashed helper can't be replaced inside the loop. Aligning the wait or kicking kickCh is a lifecycle-manager change with its own risk profile.

Also untouched by design: the adjacent ipc/protocol.go Conn.Send seq/HMAC concern (#3007 / #3178). On this base that critical section is already held under c.mu, and this PR goes nowhere near the send path.

Closes #3107

🤖 Generated with Claude Code

…colliding (#3107)

start_desktop is deliberately exempt from the heartbeat's command dedup
(#434) so the viewer can re-invoke the same commandId across reconnects,
and the command can arrive over both the WebSocket and the heartbeat
response. Two invocations for one desktop session could therefore be in
flight at once — but every invocation derived the same IPC correlation id
"desk-<sessionID>", so the second was rejected by Session.SendCommand's
ErrDuplicateCommand guard. The #434 exemption and the duplicate guard were
in direct conflict.

The caller then compounded it: startDesktopOnSession returned helperDied
for EVERY SendCommand error, so a duplicate-id rejection was reported as a
helper crash, and the retry loop had no backoff, so both attempts burned in
the same millisecond against the same healthy helper. The user saw
"desktop start failed after 2 attempts (helper keeps crashing)" while the
helper was fine and had never crashed.

Three changes:

- nextDesktopStartCommandID() numbers each invocation
  ("desk-<sessionID>-<n>", atomic counter), so concurrent starts can no
  longer self-collide. The desktop session id stays in the string to keep
  helper-side logs greppable.
- desktopStartLostHelper() classifies the failure instead of assuming
  death. ErrDuplicateCommand and ErrCommandTimeout are both raised by a
  session that is still connected and are no longer counted as a crash;
  everything else (failed socket write, session closed under us) keeps the
  retry. Duplicate is unreachable now, kept as a guard against a future
  caller reintroducing a shared id.
- Retries are spaced by desktopStartRetryBackoff (1s, a var so tests can
  shrink it) and abort on stopChan so shutdown is not held open. The
  terminal message now carries the real underlying error rather than
  blaming a crash unconditionally.

Not touched here, tracked on the issue as follow-ups: aligning the retry
window with HelperLifecycleManager's 30s reconcileInterval (or kicking it),
and recording desktop ownership on the failure path so a genuine
desktop_peer_disconnected is not dropped.

TestConcurrentStartDesktopOnSessionDoesNotSelfCollide fails on the parent
commit with exactly the reported error ("duplicate in-flight command id:
\"desk-desktop-1\"").

Closes #3107

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7e362e9
Status: ✅  Deploy successful!
Preview URL: https://370cd882.breeze-9te.pages.dev
Branch Preview URL: https://fix-3107-start-desktop-ipc-i.breeze-9te.pages.dev

View logs

Todd Hebebrand and others added 2 commits August 6, 2026 14:00
#3107)

Addresses the PR review on #3194.

The unique IPC id fixed the self-collision, but ErrDuplicateCommand had
also been the thing accidentally SERIALIZING two concurrent starts for one
desktop session. With unique ids both now reach the helper, and
SessionManager.StartSession (remote/desktop/session_webrtc.go:41-58)
unconditionally stops EVERY existing session before registering the new
one — so the second start tore down the first, both callers were told
"completed", and only one peer connection was live. Worse, the first
session's OnConnectionStateChange closure captures the session ID rather
than the *Session, so its late Closed callback calls StopSession(sessionID)
and kills the SECOND session, firing a spurious desktop_peer_disconnected.
That would have been a worse outcome than the bug being fixed, in the exact
scenario the bug report describes.

joinOrRunDesktopStart now collapses concurrent starts per desktop session:
same offer joins the leader and takes its answer (the #3107 case — one
command delivered over both the WS and the heartbeat response); a different
offer is a real renegotiation, so it waits its turn and runs its own start
rather than being answered with the leader's SDP. Released via defer so a
panic in the run func cannot wedge the session forever. This also removes
the on-demand path's lease churn, where a losing concurrent start released
the winner's still-live leases.

Also from review:

- ErrCommandTimeout and ErrDuplicateCommand no longer reach the technician
  as bare internal sentinels. agentWs writes CommandResult.Error into
  remote_sessions.errorMessage and the viewer displays it, so "sessionbroker:
  command timed out" was the on-screen failure reason for the most likely
  terminal case. Both are now wrapped with an operator-facing message and
  the helper/windows session ids, keeping %w so errors.Is still fires.
- A timed-out start is no longer abandoned. SendCommand's timeout does not
  cancel helper-side work, so the helper could still bring up a live capture
  that nothing owned — and since rememberDesktopOwner never ran, a later
  stop_desktop reported false success while the helper kept capturing. The
  retry used to reap that orphan implicitly (the next StartSession stops all
  existing sessions); reapUnprovenDesktopStart now does it explicitly.
- The "no capable helper available" exit carries lastError, so the reason
  the first helper went away is not lost to a log line.
- Corrected the ErrCommandTimeout rationale comment: a timeout is NOT proof
  of liveness (a frozen helper holds its socket to the broker's 45s
  keepaliveTimeout, which outlasts the 30s command budget). The decision is
  still right, but for a different reason — helperSessionForTarget does no
  liveness check and would hand the retry the same session.

Tests: the review found that reverting the classifier at its call site
passed the whole package. desktopStartCommandTimeout is now a shrinkable var
and TestStartDesktopViaHelperDoesNotRetryWedgedHelper pins the wiring — both
that mutant and a single-flight-removed mutant now fail. Replaced
TestStartDesktopViaHelperDoesNotRetryDuplicateCommand, which exercised the
resp.Error branch rather than the behaviour in its name. The concurrency
invariant test asserts "at most one start runs at a time", which no
scheduling order can flake. Fixed a t.Fatalf reachable from a non-test
goroutine in the shutdown test.

go test -race ./... green across the agent module; new tests 10/10 clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e-flight (#3107)

Addresses the second review round on #3194, which found three defects in
the guard added by the previous commit.

1. reapUnprovenDesktopStart is REMOVED. It escaped the very single-flight it
   sat inside: the goroutine outlived run(), so the leader's defer dropped
   the in-flight entry while the compensating desktop_stop was still
   unconsumed on the wire. The helper processes stop off StartSession's
   mutex, so a stop queued behind a wedged helper could land after the
   viewer's next start and silently kill a session the daemon believed was
   live and had reported "completed" — the same class of bug this issue is
   about, reintroduced through the stop channel. Reaping the orphan needs a
   helper-side start generation so a stale stop cannot match a newer
   session; that belongs with the deferred ownership-on-failure work.

   Not reaping is the safer trade: the orphan is self-limiting, because the
   viewer never receives the answer, ICE never completes, and the peer
   connection tears itself down on the 15s failed timeout. A bounded,
   self-healing orphan beats an unbounded silent kill.

2. The different-offer "wait then take your own turn" branch is REMOVED; the
   single-flight is now a plain join. Deferring a caller to a second turn
   was deterministically wrong on RDS hosts: leases are keyed per desktop
   session (desktopLeaseOpID) and share one owner entry, so the leader's
   failure path released the lease out from under the deferred caller, which
   then ran leaseless and hit the silent hold == nil return in
   startDesktopLeaseRenewal — a live-looking stream whose helper is reaped
   about two minutes in. A concurrent start carrying a DIFFERENT offer for
   one desktop session is not reachable in practice anyway (the viewer
   retries the same offer under the same commandId), and answering it with
   the leader's SDP fails bounded rather than silently. This also removes
   the turn loop and its cap.

3. A panic in run() no longer hands joiners a zero-valued CommandResult
   (Status "", no Error), which upstream would have submitted to the API as
   a command result with no failure text. call.result is pre-set to a
   failure and overwritten on the normal path.

Mutation-verified on the final tree: reverting the unique id, bypassing
desktopStartLostHelper, and removing the single-flight each fail the suite.
go test -race ./... green across the agent module; the new tests are 8/8
clean over repeated runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /pr-review-toolkit:review-pr — round 1: code-reviewer, pr-test-analyzer, silent-failure-hunter (all three in parallel). Round 2: code-reviewer, scoped to the round-1 fix delta only (a new concurrency primitive in agent code justified one re-review).

Findings: 8 raised across two rounds → all 8 addressed; 0 outstanding.

Round 1 (3 consequential):

  • Critical — the fix unblocked a worse bug. ErrDuplicateCommand was also what accidentally serialized concurrent starts. With unique ids both reach the helper, and SessionManager.StartSession stops every existing session before registering the new one, so start chore(deps): bump node from 20-alpine to 25-alpine in /apps/web #2 tears down start chore(deps): bump node from 20-alpine to 25-alpine in /apps/api #1 while both report completed — and chore(deps): bump node from 20-alpine to 25-alpine in /apps/api #1's OnConnectionStateChange closure captures the session id, so its late Closed callback kills chore(deps): bump node from 20-alpine to 25-alpine in /apps/web #2. Fixed by adding joinOrRunDesktopStart (single-flight per desktop session) in 8ec8ad8. Verified the helper-side claim directly against remote/desktop/session_webrtc.go before acting on it.
  • Critical — the classifier wasn't pinned. Mutating desktopStartLostHelper(err)true at the call site passed the entire package, and TestStartDesktopViaHelperDoesNotRetryDuplicateCommand exercised the resp.Error branch rather than the behaviour in its name. Extracted desktopStartCommandTimeout as a shrinkable var and replaced that test with TestStartDesktopViaHelperDoesNotRetryWedgedHelper, which kills the mutant.
  • High — raw sentinels reached the technician. agentWs writes CommandResult.Error into remote_sessions.errorMessage and the viewer shows it, so the most likely terminal case displayed sessionbroker: command timed out. Both sentinels now carry an operator-facing message plus the helper/windows session ids, keeping %w.

Round 2 (3 defects, all in the round-1 fix itself — 2 reverted rather than patched, in 0e48e38):

  • Critical — the orphan reap escaped its own single-flight and could land a stale desktop_stop after the viewer's next start, silently killing a session the daemon believed live. Removed rather than patched: the reap fires exactly when the helper is wedged, which is exactly when the stop sits queued, so a synchronous variant wouldn't close the window either. The orphan is self-limiting (ICE never completes → 15s failed timeout tears it down), so a bounded self-healing orphan beats an unbounded silent kill. Follow-up needs a helper-side start generation.
  • Important — the different-offer "take your own turn" branch was deterministically wrong on RDS. Leases are keyed per desktop session and share one owner, so the leader's failure path released the lease out from under the deferred caller, which then streamed unleased and got its helper reaped ~2 min in. Removed — a concurrent different-offer start for one desktop session isn't reachable in practice, and this also deleted the turn loop and its cap.
  • Important — a panic in run() handed joiners a zero-valued CommandResult (Status "", no error text). call.result is now pre-set to a failure.

Tests: go test -race ./... green across the whole agent module. All three fix parts are mutation-verified — reverting the unique id, bypassing the classifier, or removing the single-flight each fails the suite. New tests 8/8 clean over repeated runs; the concurrency test asserts "at most one start executing per session", an invariant no scheduling order can flake. go build ./..., go vet, gofmt clean on touched files.

CI: the pull_request trigger did not fire for this branch — gh pr checks showed only a Cloudflare Pages entry, which reads green but means CI never ran. Dispatched by hand: run 31127460219 (workflow_dispatch), which picked up the full board including Test Agent, Test Agent (race), and Lint Agent (Go). It was still queued at hand-off — please confirm it went green before merging rather than trusting the PR check list.

Status: review-clean, awaiting maintainer merge. Not merged and #3107 left open per hand-off rules.

@ToddHebebrand ToddHebebrand reopened this Aug 7, 2026
ToddHebebrand added a commit that referenced this pull request Aug 7, 2026
…ivy on all open PRs (#3212)

## Why

**GHSA-5p4m-2wfm-xmqj / CVE-2026-59870** — quadratic CPU consumption in
js-yaml's `!!omap` resolution (3.x and 4.x), rated **HIGH**, fixed in
**4.3.1** / 3.15.1.

The advisory entered Trivy's vulnerability DB at ~02:25 UTC on
2026-08-07. From that moment every PR whose scan resolved after the DB
refresh went red on **both** `Trivy Filesystem Scan` and `Trivy Image
Scan` (Web image). PRs scanned before the refresh are still green, which
is why the failure looked selective rather than global — it is not. This
blocks all six remaining v0.104 PRs (#3184, #3185, #3186, #3194, #3195,
#3196) and will block every future PR and main until it lands.

These are genuine scan-step failures, not the GitHub Actions outage that
hit the earlier batch — the jobs fail at `Run blocking Trivy filesystem
scan` / `Scan Web image`, with every prior step green.

## What

One `pnpm.overrides` entry. js-yaml 4.3.0 arrives transitively through
the Astro / expressive-code docs toolchain; nothing in the repo depends
on it directly.

The override is **upper-bounded to `<5.0.0`**. Without that bound it
resolves to 5.2.2, a major-version jump for `@astrojs/markdown-remark`,
`@astrojs/starlight` and `@expressive-code/core`. Bounding it keeps the
change a 4.3.0 → 4.3.1 patch bump, matching the convention already used
for `undici` and `@babel/core`.

The tree's other two js-yaml copies need no action: **3.15.1** is
already the fixed 3.x release named in the advisory, and **5.2.2** is
unaffected.

## Note on the diff

The lockfile carries one hunk unrelated to js-yaml: `anymatch@3.1.3`'s
picomatch pin moves 4.0.5 → 4.0.4. That is pre-existing drift between
`package.json` and the committed lockfile on main which a fresh resolve
normalizes — not something this change introduces. Left as the resolver
produced it rather than hand-editing the lockfile into an inconsistent
state.

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ToddHebebrand
ToddHebebrand merged commit 786a17a into main Aug 7, 2026
55 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/3107-start-desktop-ipc-id-collision branch August 7, 2026 16:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant