fix(agent): give every start_desktop its own IPC id so it stops self-colliding (#3107) - #3194
Conversation
…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>
Deploying breeze with
|
| 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 |
#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>
|
Review run: Findings: 8 raised across two rounds → all 8 addressed; 0 outstanding. Round 1 (3 consequential):
Round 2 (3 defects, all in the round-1 fix itself — 2 reverted rather than patched, in
Tests: CI: the Status: review-clean, awaiting maintainer merge. Not merged and #3107 left open per hand-off rules. |
…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>
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_desktopis deliberately exempt from the heartbeat's command dedup (#434), because the viewer legitimately re-invokes the samecommandIdacross 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 iddesk-<sessionID>, so the second was rejected bySession.SendCommand'sErrDuplicateCommandguard. The #434 exemption and the duplicate guard were in direct conflict.The caller then compounded it in two ways:
startDesktopOnSessionreturnedhelperDied = truefor everySendCommanderror, so the duplicate-id rejection was misclassified as helper death.attempt=1andattempt=2share 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 anatomic.Uint64—desk-<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.ErrDuplicateCommandwas also the thing accidentally serializing concurrent starts; with unique ids both would reach the helper, andSessionManager.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 toldcompleted, and only one peer connection would be live. Worse, the first session'sOnConnectionStateChangeclosure captures the session id rather than the*Session, so its lateClosedcallback callsStopSession(sessionID)and kills the second session, firing a spuriousdesktop_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":ErrDuplicateCommandErrCommandTimeoutdonechannel and surfaces as session closed while waiting for response, not as a timeout. Terminal becausehelperSessionForTargetdoes no liveness or pong-age check and would hand the retry back the very same session.4. Spaced retries + operator-facing messages. Retries are separated by
desktopStartRetryBackoff(1s; avarso tests can shrink it, following the existingdesktopLeaseRenewEverypattern), selecting onstopChanso a pending retry never holds shutdown open.agentWswritesCommandResult.Errorintoremote_sessions.errorMessageand 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 baresessionbroker: command timed out. The terminal message carries the underlying cause instead of blaming a crash unconditionally, and theno capable helper availableexit carries it too.startDesktopOnDemand(the RDS path) picks up all of this for free — it sharesstartDesktopOnSessionand 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:desk-<sessionID>TestConcurrentStartDesktopOnSessionDoesNotSelfCollide, with exactly the reported errorduplicate in-flight command id: "desk-desktop-1"desktopStartLostHelper(err)→trueTestStartDesktopViaHelperDoesNotRetryWedgedHelperTestJoinOrRunDesktopStart*Plus
TestDesktopStartLostHelper(table-driven over the classification matrix, bare and wrapped sentinels),TestNextDesktopStartCommandIDIsUniquePerInvocation,TestStartDesktopViaHelperSpacesRetriesAndReportsRealError,TestJoinOrRunDesktopStartReleasesOnPanic, andTestJoinOrRunDesktopStartNeverRunsTwoAtOnce— 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,gofmtclean on the touched files.Deliberately out of scope
Left as follow-ups, noted so they are not lost:
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 compensatingdesktop_stopwas implemented and then removed: the helper processes stop offStartSession'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.rememberDesktopOwneris still only reached on success, so a genuinedesktop_peer_disconnectedafter a failed start is dropped as "non-owned session". Pairs naturally with the item above.HelperLifecycleManager's 30sreconcileInterval, so a genuinely crashed helper can't be replaced inside the loop. Aligning the wait or kickingkickChis a lifecycle-manager change with its own risk profile.Also untouched by design: the adjacent
ipc/protocol.goConn.Sendseq/HMAC concern (#3007 / #3178). On this base that critical section is already held underc.mu, and this PR goes nowhere near the send path.Closes #3107
🤖 Generated with Claude Code