Skip to content

Commit 1422ed4

Browse files
quickbeardclaude
andcommitted
fix(sandbox): give the health probe a deadline so startup cannot hang
pollSandboxHealth awaits each probe in turn, so one request that opened and never answered stalled every later attempt: the sandbox stayed 'starting' until the 60s start timeout, minutes later, despite the guest being healthy seconds in. Reproduced roughly one run in four. Give each probe a 2s deadline and thread the poll's abort signal through so cancelling the race also cancels the in-flight request. All three providers share the helper and the defect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e493f2d commit 1422ed4

6 files changed

Lines changed: 71 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,21 @@ file is the human-readable summary of what changed and why.
116116
costing no space until the guest writes something. On macOS the clone never
117117
happened: the copy fell back to writing the whole 8 GB image out byte for
118118
byte, filling in the empty space the image leaves sparse. Creating or
119-
resetting a sandbox spent that time writing gigabytes, then the guest booted
120-
while the disk was still catching up and had to compete for it — which is why
121-
a sandbox's first start could crawl and sometimes gave up with "sandbox
122-
server did not become healthy" before finishing. The clone now happens for
123-
real: creation is immediate, a sandbox costs roughly nothing on disk until it
124-
is used, and first start takes about as long as every start after it.
119+
resetting a sandbox spent that time writing gigabytes to disk. The clone now
120+
happens for real: creation is immediate, and a sandbox costs roughly nothing
121+
on disk until it is used.
125122
([#293](https://github.com/quickbeard/codev-code/pull/293))
123+
- **Starting a sandbox no longer hangs for minutes** — a sandbox would
124+
sometimes sit on "starting" for five minutes and then give up with "sandbox
125+
server did not become healthy", even though the guest inside it had finished
126+
booting seconds in. While waiting for the guest, the app checks its health
127+
over and over, one check at a time. Those checks had no deadline, so when one
128+
connected to a guest that was still opening its port and then got no answer
129+
at all, that single check waited forever and every check behind it queued up
130+
behind it — the sandbox was ready, and nothing was left to notice. Each check
131+
now gives up after two seconds and the next one carries on, so a stalled
132+
connection costs a moment instead of the whole startup.
133+
([#294](https://github.com/quickbeard/codev-code/pull/294))
126134
- **Work sessions reach the model on WSL's mirrored networking** — on Windows,
127135
a sandbox's model traffic crosses from the WSL guest to a relay on the
128136
Windows host, and the guest found the host by assuming WSL's default NAT

‎packages/codev-sandbox/src/boot.test.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from "bun:test"
2-
import { generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv } from "./boot"
2+
import { checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv } from "./boot"
33

44
test("boot env mirrors the WSL sidecar contract and carries the server address", () => {
55
expect(
@@ -62,3 +62,42 @@ test("health polling returns once the server responds", async () => {
6262
)
6363
expect(checks).toBe(3)
6464
})
65+
66+
// pollSandboxHealth awaits each probe in turn, so a request that never answers
67+
// used to stall the whole loop: the sandbox stayed "starting" until the start
68+
// timeout gave up, minutes later, even though the guest had come up seconds in.
69+
// A probe must always settle.
70+
test("health probe gives up on a connection that never answers", async () => {
71+
const started = Date.now()
72+
const healthy = await checkSandboxHealth(
73+
"http://127.0.0.1:1",
74+
{ username: "codev", password: "secret" },
75+
// Opens and then hangs, the way an accepted socket to a guest that is not
76+
// listening yet can: no response, no error.
77+
((_url: string, init?: RequestInit) =>
78+
new Promise((_resolve, reject) => {
79+
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")))
80+
})) as unknown as typeof fetch,
81+
)
82+
83+
expect(healthy).toBe(false)
84+
expect(Date.now() - started).toBeLessThan(10_000)
85+
})
86+
87+
// The caller's signal must cancel an in-flight probe too, so aborting the poll
88+
// does not leave a request outliving the start it belonged to.
89+
test("health probe aborts with the caller's signal", async () => {
90+
const controller = new AbortController()
91+
const probe = checkSandboxHealth(
92+
"http://127.0.0.1:1",
93+
{ username: "codev", password: "secret" },
94+
((_url: string, init?: RequestInit) =>
95+
new Promise((_resolve, reject) => {
96+
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")))
97+
})) as unknown as typeof fetch,
98+
controller.signal,
99+
)
100+
controller.abort()
101+
102+
expect(await probe).toBe(false)
103+
})

‎packages/codev-sandbox/src/boot.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,27 @@ export function sandboxBootEnv(
5656
// Absolute guest path of the launcher baked into every image.
5757
export const SANDBOX_GUEST_LAUNCHER = "/opt/codev-guest/server-launch.mjs"
5858

59+
// A probe must not be able to outlive its usefulness. pollSandboxHealth awaits
60+
// each attempt in turn, so one request that opens and then never answers stalls
61+
// every later attempt forever — the sandbox never comes up, and the start
62+
// timeout is the only thing left to end it. That happens in practice: the
63+
// forwarder accepts the TCP connection and dials through to the guest while its
64+
// server is still binding the port, and the connection can be left hanging
65+
// rather than refused. A healthy guest answers in single-digit milliseconds, so
66+
// a short deadline costs a live probe nothing and turns a permanent hang into
67+
// one skipped attempt.
68+
const HEALTH_REQUEST_TIMEOUT_MS = 2_000
69+
5970
export async function checkSandboxHealth(
6071
url: string,
6172
credentials: SandboxCredentials,
6273
fetchImpl: typeof fetch = fetch,
74+
signal?: AbortSignal,
6375
) {
76+
const deadline = AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS)
6477
const response = await fetchImpl(`${url}/global/health`, {
6578
headers: { authorization: `Basic ${btoa(`${credentials.username}:${credentials.password}`)}` },
79+
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
6680
}).catch(() => null)
6781
return !!response?.ok
6882
}

‎packages/codev-sandbox/src/bwrap/provider.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi
185185
const abort = new AbortController()
186186
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
187187
const outcome = await Promise.race([
188-
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl), abort.signal).then(
188+
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl, abort.signal), abort.signal).then(
189189
() => "healthy" as const,
190190
),
191191
exited,

‎packages/codev-sandbox/src/vz/provider.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
249249
const url = `http://127.0.0.1:${forwarder.port}`
250250
const abort = new AbortController()
251251
const healthOutcome = await Promise.race([
252-
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl), abort.signal).then(
252+
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl, abort.signal), abort.signal).then(
253253
() => "healthy" as const,
254254
),
255255
helperError,

‎packages/codev-sandbox/src/wsl/provider.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
386386
const abort = new AbortController()
387387
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
388388
const outcome = await Promise.race([
389-
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl), abort.signal).then(
389+
pollSandboxHealth(() => checkSandboxHealth(url, credentials, fetchImpl, abort.signal), abort.signal).then(
390390
() => "healthy" as const,
391391
),
392392
exited,

0 commit comments

Comments
 (0)