Repository navigation
[sprites 1-3] onConnect: in-memory reattach before sprite resolution - #2005
Conversation
…g the Sprite
A tab-back inside the 30-min detached grace window had to wait out a full
sandbox resolution (resolve the agent-terminal row -> getSprite -> audit write)
before onConnect ever looked in sessionMap, even though the live in-memory
session it was about to reattach to made every bit of that work redundant. The
Sprites platform floor for a warm wake is 100-500ms (docs.sprites.dev); this was
seconds of our own orchestration stacked on top of it.
Now that the access decision is DB-only (1-2) and the session key derives from
the (scope, name) target without a Sprite (1-1), checkAuth hands the sprite half
back as an UNCALLED `resolveSandbox` thunk. onConnect runs the cheap access
check, looks the session up, and a new pure `planConnect({accessAllowed,
existingSession})` decides reattach / create / deny. The reattach path returns
without ever invoking the thunk: zero sprite SDK calls, no wake exec, no audit
row. The cold path calls it and behaves exactly as before.
A denied verdict never reaches the session lookup, so losing access can never be
shortcut by a still-live PTY.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
|
Warning Review limit reached
Next review available in: 57 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4f2163fc8e
ℹ️ 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".
…he access check
Reviewing the reorder surfaced a severe bug it would otherwise have shipped on
top of. `acquireCodeExecutionSlot` is a per-user counter (free: 1, pro: 2,
founder: 3, business: 5), and checkAuth reserved a slot on EVERY call — including
calls that start no PTY at all:
- Reattach: a free-tier user with one live session already holds the only slot,
so the tab-back's access check could not acquire a second and was denied
`concurrency_limit`. The fast path this PR exists to build was unreachable for
the most common tier.
- Re-auth: the 60s tick also calls checkAuth. At the limit it too failed to
acquire, and the tick reads a denial as a REVOKED authorization — so it tore
the session down. A free-tier PTY was killed ~60s after it opened.
Both are pre-existing on master; they are masked only because
CODE_EXECUTION_ENABLED is off. Neither path starts a PTY, so neither needs a slot.
The lazy split makes the fix natural: move acquireSlot into `resolveSandbox` (the
only path that starts a PTY) and surface `releaseSlot` on the sandbox SUCCESS
result, so there is no slot to release unless one was actually reserved. The
reattach path now takes no slot and releases none; the re-auth tick reserves
nothing and hands nothing back.
Also makes planConnect's decision load-bearing: it returns a discriminated union
carrying the narrowed access/session payload, so the handler executes the plan
instead of re-deriving the deny via a separate `!ok` check (the previous 'deny'
variant was never read in production).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
…e key The leaf requires that a double-mount not double-create. The sequential remount was already safe (the second connect finds the session via getByKey), but the genuinely CONCURRENT race was not — and this PR widens its window, because the cold path is exactly where a connect now spends seconds inside resolveSandbox. Two connects for one key both see an empty sessionMap, both openShell, and `setNew` silently overwrites: the winner's PTY is orphaned where nothing can reach it to kill it, and its concurrency slot is stranded for the life of the process. - Before setNew, re-check the key. If another cold connect claimed it while we were awaiting, discard OUR duplicate (kill the PTY, release the slot and hold) and join the winner instead — indistinguishable from a reattach client-side. - Guard endAgentTerminalSession's deleteByKey with an identity check, so the discarded PTY's late onExit cannot evict the live winner from the map. - Collapse the connect's slot release behind an idempotent wrapper: the slot is a bare counter, so a double release silently hands back capacity the connect never held, letting a user exceed their tier. - Extract attachToLiveSession, shared by the tab-back fast path and the race loser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
…lost race The discarded duplicate is killed the instant it opened, so it ran no billable window — its hold belongs back in the pool, exactly like the openShell-throw path. But killing the shell fires onExit asynchronously, and endAgentTerminalSession would then settle the very hold just released, double-handling it against a window that never happened. Clear connectedAt/holdId before the kill so no settle path can reach it, and release the hold explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
…-only) Review catch (codex, P2). Making the sandbox resolution lazy also, unintentionally, made the (scope, name) EXISTENCE check lazy — because the fused checkAuth only ever learned "this terminal still exists" as a side effect of resolving its sandbox. The 60s re-auth tick calls only the access half, so after the split it stopped noticing that a terminal's project, branch, or own row had been deleted: the orphaned PTY kept running against a scope that no longer existed. Restoring it naively would have re-broken the epic: resolveAgentTerminal fuses the cheap question (does the row exist — a couple of indexed reads) with the expensive one (where does its Sprite live — machineSandbox.acquire, which can RECONNECT OR RESUME a hibernated Sprite). Calling it from the access half would wake the Sprite on every re-auth tick and every tab-back. So split the question, not just the call site: - New `resolveAgentTerminalRow` in lib — resolveScopeKey + store.findByName, DB-only, provably Sprite-free (its tests pass `machineSandbox: undefined`). - The access half runs it eagerly, AFTER the read-only access gates, so a user without edit rights still learns nothing about which terminals exist. - The Sprite wake/read stays lazy in resolveSandbox. Re-auth once again tears down a terminal whose scope was deleted — now without waking anything to find out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
…the loser An adversarial review of b873841 found that my own race fix was destructive. `openPtyShell` with a non-null sessionId calls `sprite.attachSession(id)` — it connects to a SERVER-SIDE exec session. When a persisted streamSessionId exists, two racing cold connects both attach to the SAME remote session, and `PtyShell.kill()` is SIGKILL on the process, not a local socket close. So "discard the duplicate" SIGKILLed the very process the survivor was attached to: the guard turned a recoverable orphan into a guaranteed terminal kill, on exactly the warm-reattach path it was meant to protect. Serialize at the key instead, so a second PTY is never opened at all: - TerminalSessionMap gains trackCreate/pendingCreate. A cold connect claims the key before its first await; a concurrent connect for that key awaits the in-flight create and joins its session. Claim released on every exit (success, deny, throw), so a failed create can't wedge the key — and the joiner loops, in case another create queued behind a failure. - This dissolves the class: no duplicate PTY, so no kill; no double slot; no double hold; and no ambiguous session-id discovery from concurrent creates. Also fixed, from the same review: - Slot LEAKED when billing.gate throws: the slot was reserved but no session owned it yet, so a rejection escaped onConnect with the slot still held. activeByUser is a process-lifetime counter, so one transient blip permanently locked a free-tier user (limit 1) out of terminals on that replica. Now released before rethrowing. - discoverNewSessionId took the FIRST new tty session. One Sprite hosts every agent terminal on its machine, so a sibling terminal launching in the same window is indistinguishable from ours — persisting the sibling's id points this terminal's next cold connect at another terminal's PTY. Abstain unless exactly one appeared (the rule sprites-shell.ts's newTtySessionId already applies). - Re-auth checked the CREATOR's userId forever. A session outlives its creator, and any authorized user may reattach — so a viewer whose access was revoked mid-session kept receiving output and could keep typing, since the long-gone creator still passed. Sessions now carry viewerUserId, set on create and on every reattach, and the tick re-checks the user actually driving the PTY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
Pure whitespace — the create path gained a try/finally (releasing the session-key claim) without its body being re-indented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
The claim was only covered end-to-end through onConnect. Pin the primitive itself, including the two cases that make it safe rather than merely present: a REJECTED create still drops its claim (a failed create must never wedge the key forever), and a stale create settling late must not revoke a newer create's claim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
|
@coderabbitai review |
✅ Action performedReview finished.
|
… serialization CI failure was the coverage gate, not a test failure: all tests pass, but the new branches dropped apps/realtime branch coverage to 97.73% (< 98%). Short by 5 arms. Cover them with real, reachable behaviours rather than lowering the bar: - The idempotent slot release (the double-release guard the round-2 review flagged as untested): a re-auth teardown releases the slot, and the killed PTY's late onExit must be a no-op — asserted to release exactly once. - The idle reap hands its slot back (the other gap the review named). - settleAccruedWindow / startSettleHeartbeat error arms that the added denominator tipped over: a settle rejecting with a non-Error value, the re-hold gate doing the same, a heartbeat firing after the session was removed, and an overlapping heartbeat skipped while a settle is still in flight. Also refresh the endAgentTerminalSession deleteByKey comment: it referenced the "cold-path racer that lost", a scenario per-key serialization removed. The identity guard is now purely defensive (one session per key at a time), and the comment says so. Handler branch coverage 94.4% -> 98.02%; realtime gate green (exit 0). 641 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ
Why
Tab-back to an open terminal was slow for a reason that had nothing to do with the platform.
onConnectran the entire fusedcheckAuthchain — resolve the agent-terminal row →getSprite→ write a code-execution audit row → reserve a concurrency slot — and only then looked insessionMapto discover that a live in-memory session was sitting right there, ready to reattach.docs.sprites.dev/concepts/lifecycle puts a warm wake at 100–500ms and a cold one at 1–2s. That is the platform floor; everything we stacked above it on a tab-back was our own orchestration overhead.
What changed
checkAuthno longer resolves the Sprite (or reserves a slot) eagerly. It returns the cheap, DB-only access verdict (sessionKey,payerId) plus an uncalledresolveSandbox()thunk.onConnectruns the access check, looks up the live session (the key derives without a Sprite, per 1-1), and a pureplanConnect({ accessResult, existingSession })returns a discriminateddeny | reattach | createplan carrying the narrowed payload each branch needs. Only the cold path invokes the thunk.The audit row moved into
resolveSandboxwith it — it records a PTY actually being launched, and a reattach launches nothing.apps/realtime/src/terminal/agent-terminal-handler.ts—planConnect(pure), splitAgentTerminalCheckAuthResult/AgentTerminalSandboxResult, reorderedonConnect.apps/realtime/src/terminal/agent-terminal-access.ts—buildAgentTerminalCheckAuthreturns the lazy thunk, which owns slot reservation + release.AgentTerminalCheckAuthDepsis unchanged, so theindex.tswiring needed no edits.🐛 A severe pre-existing bug this surfaced (please read)
Reviewing the reorder turned up a bug that the reorder would otherwise have shipped on top of.
acquireCodeExecutionSlot(packages/lib/src/services/sandbox/quota.ts) is a per-user counter —free: 1,pro: 2,founder: 3,business: 5— andcheckAuthreserved a slot on every call, including calls that start no PTY:concurrency_limit. The fast path this PR exists to build was unreachable for the most common tier.checkAuth. At the limit it too failed to acquire a slot — and the tick reads any denial as a revoked authorization, so it calledteardownAgentTerminalSession. A free-tier PTY died ~60 seconds after opening.Both are pre-existing on
master; they are masked in production only becauseCODE_EXECUTION_ENABLEDis off. My handler tests missed them because they mockcheckAuth— the bug lives in the real composition.The fix falls out of the same lazy split: a slot bounds how many PTYs a user has running, so it is reserved inside
resolveSandbox(the only path that starts one), andreleaseSlotis surfaced on the sandbox success result — there is no slot to release unless one was actually reserved. Reattach takes none; re-auth reserves nothing and hands nothing back. Regression tests are at the access layer where the real slot logic lives.🐛 Second bug: concurrent cold connects (double-mount)
The leaf requires that a double-mount not double-create. The sequential remount was already safe (connect #2 finds the session via
getByKey). The genuinely concurrent race was not — and this PR widens its window, because the cold path is exactly where a connect now spends seconds insideresolveSandbox.My first fix was to let both open a PTY and then discard the loser. An adversarial review pass found that this was destructive, and I replaced it. Recording it here because the reason is subtle and worth a reviewer's attention:
The fix in place: serialize at the key, so a second PTY is never opened at all. A cold connect claims the key before its first await; a concurrent connect for that key awaits the in-flight create and joins its session.
TerminalSessionMapgainstrackCreate/pendingCreate.awaitsits between the join-loop's exit and the claim, so it is atomic w.r.t. the event loop and needs no lock.This dissolves the class rather than patching it: no duplicate PTY → no kill; no double slot; no double hold; no ambiguous session-id discovery.
🐛 Fourth/fifth/sixth: found by the same adversarial pass
billing.gatethrows. The slot was reserved but no session owned it yet, so a rejection escapedonConnectwith the slot still held.activeByUseris a process-lifetime counter — one transient billing/DB blip permanently locked a free-tier user (limit 1) out of terminals on that replica until restart. Now released before rethrowing.discoverNewSessionIdtook the FIRST new tty session. One Sprite hosts every agent terminal on its machine, so a sibling terminal launching in the same window is indistinguishable from ours — persisting the sibling's id points this terminal's next cold connect at another terminal's PTY. Now abstains unless exactly one appeared (the rulesprites-shell.ts'snewTtySessionIdalready applies).viewerUserId, set on create and on every reattach, and the tick re-checks whoever is actually driving the PTY.🐛 Third bug: re-auth stopped noticing a deleted scope (review catch — codex P2)
Making the sandbox resolution lazy also made the (scope, name) existence check lazy — because the fused
checkAuthonly ever learned "this terminal still exists" as a side effect of resolving its sandbox. The 60s re-auth tick calls only the access half, so after the split it stopped noticing that a terminal's project, branch, or own row had been deleted: the orphaned PTY kept running against a scope that no longer existed.Restoring the check naively would have re-broken the epic.
resolveAgentTerminalfuses two very differently-priced questions:resolveScopeKey+store.findByName: a couple of indexed reads, no Sprite)machineSandbox.acquire, which for machine/project scope can reconnect or resume a hibernated Sprite)Calling it from the access half would wake the Sprite on every re-auth tick and every tab-back. So I split the question, not just the call site:
resolveAgentTerminalRow(packages/lib/src/services/machines/agent-terminals.ts) — DB-only. Its tests passmachineSandbox: undefinedthroughout, which is the proof it cannot wake a Sprite: it is never handed one.Requirements
agent-terminal:ready(with scrollback) after the access check, ZERO sprite SDK callsresolveSandbox()is ever called. Test spies on the thunk and every sprite method (listSessions/spawn/createSession/attachSession/updateNetworkPolicy) and asserts none fired — plus that no slot was taken.openShell)planConnectreturns{ kind: 'deny' }for a denied verdict regardless ofexistingSession(pure test). In the shell a denied verdict never reaches the lookup. Handler test: an unauthorized socket getsagent-terminal:error, never:ready, and the victim's session stays bound to its original socket.getByKeyand reattaches —openShellcalled exactly once.setNew/getByKeysemantics preserved, no new locking.Tests
TDD:
planConnectis pure and tested with no mocks (ritewayassert({given, should, actual, expected})); the handler is tested with an injected spy sprite client and a realcreateTerminalSessionMap.tsc --noEmitclean, eslint clean. Noany.Notes for reviewers
checkAuthand never touches the thunk.🤖 Generated with Claude Code
https://claude.ai/code/session_01HnFmynYiN6bmrtibxdzLDJ