fix(runtime): release the bootstrap latch when the post-create read throws

The `tabsByWorktree` row read that decides between release and park sat outside
the try. Measured with `useAppStore.getState` throwing once after a successful
create: the dispatch rejects with the latch still in `creating`, and
`releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame` only ever clears
`awaiting-mirror` — so no mirror frame can rescue it and every later dispatch
for that workspace returns false without creating, until environment teardown.
This commit is contained in:
Neil
2026-09-10 17:51:31 -07:00
parent 58fac71ba0
commit 1095e360c0
2 changed files with 66 additions and 11 deletions
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { dispatchWebRuntimeInitialTerminalBootstrap } from './web-runtime-initial-terminal-bootstrap-dispatch'
import {
isWebRuntimeInitialTerminalBootstrapInFlight,
releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame,
resetWebRuntimeInitialTerminalBootstrapForTests
} from './web-runtime-initial-terminal-bootstrap'
const createTerminal = vi.hoisted(() => vi.fn())
vi.mock('./web-runtime-session', () => ({ createWebRuntimeSessionTerminal: createTerminal }))
// What this pins: a throw AFTER the create resolves must not leave the latch in `creating`.
// `releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame` only ever clears `awaiting-mirror`, so
// a stranded `creating` claim survives every later mirror frame and makes the workspace refuse to
// bootstrap a terminal for the rest of the environment's life.
const ENV_ID = 'env-bootstrap-dispatch'
const WORKTREE_ID = 'repo-1::/w/one'
describe('dispatchWebRuntimeInitialTerminalBootstrap', () => {
beforeEach(() => {
resetWebRuntimeInitialTerminalBootstrapForTests()
createTerminal.mockReset()
})
afterEach(() => {
resetWebRuntimeInitialTerminalBootstrapForTests()
vi.restoreAllMocks()
})
it('releases the latch when the post-create store read throws', async () => {
createTerminal.mockResolvedValue({ status: 'created' })
const getState = vi.spyOn(useAppStore, 'getState').mockImplementation(() => {
throw new Error('store read blew up')
})
await expect(dispatchWebRuntimeInitialTerminalBootstrap(ENV_ID, WORKTREE_ID)).rejects.toThrow(
'store read blew up'
)
getState.mockRestore()
expect(isWebRuntimeInitialTerminalBootstrapInFlight(ENV_ID, WORKTREE_ID)).toBe(false)
// A mirror frame cannot rescue a stranded `creating` claim, so the latch had to release itself.
releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame(ENV_ID, WORKTREE_ID)
expect(isWebRuntimeInitialTerminalBootstrapInFlight(ENV_ID, WORKTREE_ID)).toBe(false)
createTerminal.mockResolvedValue({ status: 'created' })
await dispatchWebRuntimeInitialTerminalBootstrap(ENV_ID, WORKTREE_ID)
expect(createTerminal).toHaveBeenCalledTimes(2)
})
})
@@ -31,22 +31,25 @@ export async function dispatchWebRuntimeInitialTerminalBootstrap(
return false
}
let outcome: WebRuntimeTerminalCreateOutcome
// Why the row read is inside the try too: a throw between the create and the latch decision
// left `creating` held forever — the mirror-frame release only ever clears `awaiting-mirror`,
// so every later dispatch for that workspace refused without creating until teardown.
try {
outcome = await createWebRuntimeSessionTerminal({ worktreeId, environmentId, activate: true })
// Why check the outcome: the create reports RPC and network failures as `{ status: 'failed' }`
// rather than throwing, so the catch below never sees them. Both arms report the same way.
if (outcome.status === 'failed') {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
return false
}
if (Object.hasOwn(useAppStore.getState().tabsByWorktree, worktreeId)) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
} else {
markWebRuntimeInitialTerminalBootstrapAwaitingMirror(environmentId, worktreeId)
}
} catch (error) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
throw error
}
// Why check the outcome: the create reports RPC and network failures as `{ status: 'failed' }`
// rather than throwing, so the catch above never sees them. Both arms report the same way.
if (outcome.status === 'failed') {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
return false
}
if (Object.hasOwn(useAppStore.getState().tabsByWorktree, worktreeId)) {
endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)
} else {
markWebRuntimeInitialTerminalBootstrapAwaitingMirror(environmentId, worktreeId)
}
return true
}