Context
PR #1768 ("Wire requirements/objectives/skippable through ChallengeBlock") carries a session-holder refcounting fix in ChallengeBlock (module-level activeSessionHolders / activeConnectingInstances maps, claimSession/releaseSession, hasOtherSessionHolders/hasOtherConnectingInstances) that closes a worse, already-verified bug: without it, one ChallengeBlock's Cancel/Skip could disconnect a Coda sandbox session that a different, still-live ChallengeBlock was legitimately using — killing another learner's active session outright.
That fix is a real improvement and should not block on this issue. But round-14 review of #1768 (finding F17) identified a narrower defect it introduces, filed here separately per that review's recommendation.
Root cause
In src/components/interactive-tutorial/challenge-block.tsx, handleStart's only guard on the continuation after await terminalCtx.openTerminal(vmOpts) is cancelRequestedRef.current, which is set only by handleCancel. There is no mounted/liveness guard.
const nextSessionId = await terminalCtx.openTerminal(vmOpts);
if (cancelRequestedRef.current) {
...
return;
}
if (nextSessionId) {
claimSession(nextSessionId); // <-- registers instanceId into activeSessionHolders
...
runSetup(nextSessionId); // <-- re-claims + runs real exec calls against the sandbox
}
If the ChallengeBlock instance unmounts while openTerminal() is still pending (e.g. the learner navigates to a different guide/step before a slow VM provision resolves, without clicking Cancel first), the unmount cleanup effect only runs releaseSession():
useEffect(() => {
return () => {
releaseSession();
};
}, [releaseSession]);
At the moment of unmount, heldSessionIdRef.current is still null (nothing has been claimed yet — claimSession hasn't run), so releaseSession() has nothing to undo for this in-flight request. It does not stop the pending handleStart continuation — React does not cancel in-flight promises on unmount.
When openTerminal() eventually resolves (the VM finishes provisioning), the dead closure's continuation still runs: cancelRequestedRef.current is false (Cancel was never clicked), so it unconditionally calls claimSession(nextSessionId) — which inserts the unmounted instance's instanceId into the module-level activeSessionHolders map for that session — and then runSetup(nextSessionId), which actually executes setup commands against the sandbox. Nothing will ever call unregisterSessionHolder for that (sessionId, instanceId) pair again, because the component's single unmount cleanup already fired before this registration happened.
hasOtherSessionHolders(sessionId, instanceId) / hasOtherConnectingInstances(instanceId) — the functions this round's fix added specifically so handleCancel can tell whether another live block still needs the shared session — then see this phantom holder as a legitimate second owner. A later, actually live ChallengeBlock sharing that same Coda session can have its own legitimate Cancel/Skip silently skip the terminalCtx.disconnect() call it should make (isOwner && !hasOther becomes false), leaving that sandbox VM running rather than being torn down when the learner intended to end it.
No test in challenge-block.test.tsx exercises unmount while openTerminal() is still pending — the existing unmount() calls in the suite (e.g. the counter-reset test) unmount only after the component has already settled into a stable state, never mid-await.
Reproduction scenario
- Learner A opens a guide, reaches a
ChallengeBlock in coda mode, clicks Start challenge. openTerminal() is dispatched and is slow (VM provisioning takes, say, 10-20s).
- Before it resolves, Learner A navigates away from that step/guide (without clicking Cancel) — the
ChallengeBlock unmounts.
openTerminal() eventually resolves with a session id. The dead component's handleStart continuation runs anyway, calls claimSession(nextSessionId) (registering a phantom, never-released holder) and runSetup(nextSessionId).
- Learner A (or a different learner sharing the same underlying terminal session, per the shared-session model this fix targets) later opens a different
ChallengeBlock that reuses the same live Coda session, then clicks Cancel or Skip intending to tear it down.
handleCancel calls hasOtherSessionHolders(liveSessionId, instanceId), which returns true because of the phantom holder from step 3 — so terminalCtx.disconnect() is skipped. The Cancel/Skip action silently no-ops on the backend disconnect, leaving the Coda sandbox VM running under a control the UI told the learner had ended.
Required fix
Per the round-14 review recommendation:
- Track liveness — a mounted ref (e.g.
isMountedRef), or an AbortController wired through openTerminal — and check it in handleStart's post-await continuation before calling claimSession/runSetup. If not live, do not register a holder and do not run setup.
- On unmount, if this instance was still awaiting
openTerminal() (i.e. handleStart was in flight and had not yet claimed a session), run the same hasOtherSessionHolders/hasOtherConnectingInstances-gated disconnect logic that handleCancel performs today — not just the local bookkeeping releaseSession() currently does — so an instance that dies mid-provision still relinquishes (and, if it turns out to be the sole owner, disconnects) the session it was about to claim.
Required test coverage
Add to src/components/interactive-tutorial/challenge-block.test.tsx:
- Unmount a
ChallengeBlock while its openTerminal() promise is still pending, then resolve it — assert activeSessionHolders/activeConnectingInstances end up with no holder registered for the dead instance (exported test helper resetSessionHoldersForTest already exists for setup/teardown; a similar accessor may be needed to assert on the maps, or assert indirectly via a second block's Cancel behaving correctly afterward).
- A second, live
ChallengeBlock sharing the same session must still successfully disconnect() on Cancel/Skip after a sibling block unmounted mid-provision — i.e. the phantom-holder regression this issue describes must not reproduce.
Severity / priority
P2 — bounded resource waste and a real cross-block correctness bug, not an emergency.
Verified against the Coda backend (grafana/grafana-coda, server/src/vms.js + pool-manager.js): every VM is granted a hard expires_at (default 30 min, env-configurable via VM_LIFETIME_MINUTES/MAX_VM_LIFETIME_MINUTES), swept by a cron reaper (destroyExpiredVMs) that runs every ~1 minute regardless of frontend behavior, with an independent guest-side self-shutdown backstop (~40 min from boot) in case the reaper itself is down. So this bug cannot leak a sandbox VM indefinitely — the worst case is roughly 30-40 minutes of wasted/billed compute per occurrence, plus a silently-broken Cancel/Skip for whoever legitimately owns that shared session during that same bounded window.
This should be fixed in a follow-up soon after #1768 merges (it's a real, easily-triggered gap — "navigate away before Start finishes" is an ordinary learner action, not an edge case), but it should not block #1768, which fixes a strictly worse bug (killing a different learner's live session outright, with no bound at all on user-visible impact).
Reference
Introduced by the session-holder refcounting fix in #1768. Relevant code: src/components/interactive-tutorial/challenge-block.tsx (handleStart, handleCancel, claimSession/releaseSession, module-level activeSessionHolders/activeConnectingInstances).
Context
PR #1768 ("Wire requirements/objectives/skippable through ChallengeBlock") carries a session-holder refcounting fix in
ChallengeBlock(module-levelactiveSessionHolders/activeConnectingInstancesmaps,claimSession/releaseSession,hasOtherSessionHolders/hasOtherConnectingInstances) that closes a worse, already-verified bug: without it, one ChallengeBlock's Cancel/Skip could disconnect a Coda sandbox session that a different, still-live ChallengeBlock was legitimately using — killing another learner's active session outright.That fix is a real improvement and should not block on this issue. But round-14 review of #1768 (finding F17) identified a narrower defect it introduces, filed here separately per that review's recommendation.
Root cause
In
src/components/interactive-tutorial/challenge-block.tsx,handleStart's only guard on the continuation afterawait terminalCtx.openTerminal(vmOpts)iscancelRequestedRef.current, which is set only byhandleCancel. There is no mounted/liveness guard.If the
ChallengeBlockinstance unmounts whileopenTerminal()is still pending (e.g. the learner navigates to a different guide/step before a slow VM provision resolves, without clicking Cancel first), the unmount cleanup effect only runsreleaseSession():At the moment of unmount,
heldSessionIdRef.currentis stillnull(nothing has been claimed yet —claimSessionhasn't run), soreleaseSession()has nothing to undo for this in-flight request. It does not stop the pendinghandleStartcontinuation — React does not cancel in-flight promises on unmount.When
openTerminal()eventually resolves (the VM finishes provisioning), the dead closure's continuation still runs:cancelRequestedRef.currentisfalse(Cancel was never clicked), so it unconditionally callsclaimSession(nextSessionId)— which inserts the unmounted instance'sinstanceIdinto the module-levelactiveSessionHoldersmap for that session — and thenrunSetup(nextSessionId), which actually executes setup commands against the sandbox. Nothing will ever callunregisterSessionHolderfor that(sessionId, instanceId)pair again, because the component's single unmount cleanup already fired before this registration happened.hasOtherSessionHolders(sessionId, instanceId)/hasOtherConnectingInstances(instanceId)— the functions this round's fix added specifically sohandleCancelcan tell whether another live block still needs the shared session — then see this phantom holder as a legitimate second owner. A later, actually live ChallengeBlock sharing that same Coda session can have its own legitimate Cancel/Skip silently skip theterminalCtx.disconnect()call it should make (isOwner && !hasOtherbecomes false), leaving that sandbox VM running rather than being torn down when the learner intended to end it.No test in
challenge-block.test.tsxexercises unmount whileopenTerminal()is still pending — the existingunmount()calls in the suite (e.g. the counter-reset test) unmount only after the component has already settled into a stable state, never mid-await.Reproduction scenario
ChallengeBlockincodamode, clicks Start challenge.openTerminal()is dispatched and is slow (VM provisioning takes, say, 10-20s).ChallengeBlockunmounts.openTerminal()eventually resolves with a session id. The dead component'shandleStartcontinuation runs anyway, callsclaimSession(nextSessionId)(registering a phantom, never-released holder) andrunSetup(nextSessionId).ChallengeBlockthat reuses the same live Coda session, then clicks Cancel or Skip intending to tear it down.handleCancelcallshasOtherSessionHolders(liveSessionId, instanceId), which returnstruebecause of the phantom holder from step 3 — soterminalCtx.disconnect()is skipped. The Cancel/Skip action silently no-ops on the backend disconnect, leaving the Coda sandbox VM running under a control the UI told the learner had ended.Required fix
Per the round-14 review recommendation:
isMountedRef), or anAbortControllerwired throughopenTerminal— and check it inhandleStart's post-await continuation before callingclaimSession/runSetup. If not live, do not register a holder and do not run setup.openTerminal()(i.e.handleStartwas in flight and had not yet claimed a session), run the samehasOtherSessionHolders/hasOtherConnectingInstances-gated disconnect logic thathandleCancelperforms today — not just the local bookkeepingreleaseSession()currently does — so an instance that dies mid-provision still relinquishes (and, if it turns out to be the sole owner, disconnects) the session it was about to claim.Required test coverage
Add to
src/components/interactive-tutorial/challenge-block.test.tsx:ChallengeBlockwhile itsopenTerminal()promise is still pending, then resolve it — assertactiveSessionHolders/activeConnectingInstancesend up with no holder registered for the dead instance (exported test helperresetSessionHoldersForTestalready exists for setup/teardown; a similar accessor may be needed to assert on the maps, or assert indirectly via a second block's Cancel behaving correctly afterward).ChallengeBlocksharing the same session must still successfullydisconnect()on Cancel/Skip after a sibling block unmounted mid-provision — i.e. the phantom-holder regression this issue describes must not reproduce.Severity / priority
P2 — bounded resource waste and a real cross-block correctness bug, not an emergency.
Verified against the Coda backend (
grafana/grafana-coda,server/src/vms.js+pool-manager.js): every VM is granted a hardexpires_at(default 30 min, env-configurable viaVM_LIFETIME_MINUTES/MAX_VM_LIFETIME_MINUTES), swept by a cron reaper (destroyExpiredVMs) that runs every ~1 minute regardless of frontend behavior, with an independent guest-side self-shutdown backstop (~40 min from boot) in case the reaper itself is down. So this bug cannot leak a sandbox VM indefinitely — the worst case is roughly 30-40 minutes of wasted/billed compute per occurrence, plus a silently-broken Cancel/Skip for whoever legitimately owns that shared session during that same bounded window.This should be fixed in a follow-up soon after #1768 merges (it's a real, easily-triggered gap — "navigate away before Start finishes" is an ordinary learner action, not an edge case), but it should not block #1768, which fixes a strictly worse bug (killing a different learner's live session outright, with no bound at all on user-visible impact).
Reference
Introduced by the session-holder refcounting fix in #1768. Relevant code:
src/components/interactive-tutorial/challenge-block.tsx(handleStart,handleCancel,claimSession/releaseSession, module-levelactiveSessionHolders/activeConnectingInstances).