Skip to content

Commit eaa4a99

Browse files
committed
Add timeout handling and artifact/status waits
1 parent 38e661c commit eaa4a99

2 files changed

Lines changed: 127 additions & 19 deletions

File tree

packages/admin-cli/src/sandbox-smoke.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('resolveSandboxSmokeOptions', () => {
1313
expect(resolved.baseUrl).toBe('https://admin.example.com')
1414
expect(resolved.profile).toBe('small')
1515
expect(resolved.ttlSeconds).toBe(600)
16+
expect(resolved.timeoutMs).toBe(120_000)
1617
} finally {
1718
if (originalApiKey === undefined) {
1819
delete process.env['SANDCHEST_API_KEY']
@@ -27,6 +28,17 @@ describe('resolveSandboxSmokeOptions', () => {
2728
resolveSandboxSmokeOptions({ apiKey: 'sk_test', profile: 'xlarge' as never }),
2829
).toThrow("Invalid profile 'xlarge'")
2930
})
31+
32+
test('uses explicit timeout override', () => {
33+
const resolved = resolveSandboxSmokeOptions({ apiKey: 'sk_test', timeoutMs: 45_000 })
34+
expect(resolved.timeoutMs).toBe(45_000)
35+
})
36+
37+
test('rejects invalid timeout values', () => {
38+
expect(() => resolveSandboxSmokeOptions({ apiKey: 'sk_test', timeoutMs: 0 })).toThrow(
39+
'timeoutMs must be a positive integer',
40+
)
41+
})
3042
})
3143

3244
describe('SandboxSmokeTracker', () => {

packages/admin-cli/src/sandbox-smoke.ts

Lines changed: 115 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77

88
const DEFAULT_BASE_URL = 'https://api.sandchest.com'
99
const DEFAULT_TTL_SECONDS = 600
10+
const DEFAULT_TIMEOUT_MS = 120_000
11+
const DEFAULT_STOP_SETTLE_TIMEOUT_MS = 30_000
12+
const DEFAULT_ARTIFACT_SETTLE_TIMEOUT_MS = 30_000
1013
const VALID_PROFILES = ['small', 'medium', 'large'] as const
1114

1215
export type SmokeProfile = (typeof VALID_PROFILES)[number]
@@ -23,6 +26,7 @@ export interface SandboxSmokeOptions {
2326
image?: string | undefined
2427
profile?: SmokeProfile | undefined
2528
ttlSeconds?: number | undefined
29+
timeoutMs?: number | undefined
2630
logger?: SandboxSmokeLogger | undefined
2731
}
2832

@@ -32,6 +36,7 @@ export interface ResolvedSandboxSmokeOptions {
3236
image?: string | undefined
3337
profile: SmokeProfile
3438
ttlSeconds: number
39+
timeoutMs: number
3540
}
3641

3742
export interface SandboxSmokeCheckResult {
@@ -150,18 +155,40 @@ async function waitForArtifact(
150155
sandbox: Sandbox,
151156
expectedName: string,
152157
timeoutMs: number,
153-
): Promise<boolean> {
158+
): Promise<{ found: boolean; artifactNames: string[] }> {
154159
const deadline = Date.now() + timeoutMs
160+
let artifactNames: string[] = []
155161

156162
while (Date.now() < deadline) {
157163
const artifacts = await sandbox.artifacts.list()
158-
if (artifacts.some((artifact: { name: string }) => artifact.name === expectedName)) {
159-
return true
164+
artifactNames = artifacts.map((artifact: { name: string }) => artifact.name)
165+
if (artifactNames.includes(expectedName)) {
166+
return { found: true, artifactNames }
160167
}
161168
await sleep(250)
162169
}
163170

164-
return false
171+
return { found: false, artifactNames }
172+
}
173+
174+
async function waitForSandboxStatus(
175+
baseUrl: string,
176+
apiKey: string,
177+
sandboxId: string,
178+
expectedStatuses: string[],
179+
timeoutMs: number,
180+
): Promise<SandboxStateSnapshot | null> {
181+
const deadline = Date.now() + timeoutMs
182+
183+
while (Date.now() < deadline) {
184+
const snapshot = await fetchSandboxState(baseUrl, apiKey, sandboxId)
185+
if (snapshot && expectedStatuses.includes(snapshot.status)) {
186+
return snapshot
187+
}
188+
await sleep(500)
189+
}
190+
191+
return fetchSandboxState(baseUrl, apiKey, sandboxId)
165192
}
166193

167194
async function measureCheck(
@@ -206,6 +233,14 @@ function normalizeTtlSeconds(ttlSeconds: number | undefined): number {
206233
return value
207234
}
208235

236+
function normalizeTimeoutMs(timeoutMs: number | undefined): number {
237+
const value = timeoutMs ?? DEFAULT_TIMEOUT_MS
238+
if (!Number.isInteger(value) || value < 1) {
239+
throw new Error('timeoutMs must be a positive integer')
240+
}
241+
return value
242+
}
243+
209244
export function resolveSandboxSmokeOptions(
210245
options: Partial<SandboxSmokeOptions>,
211246
defaults?: { baseUrl?: string | undefined },
@@ -221,6 +256,7 @@ export function resolveSandboxSmokeOptions(
221256
image: options.image,
222257
profile: normalizeProfile(options.profile),
223258
ttlSeconds: normalizeTtlSeconds(options.ttlSeconds),
259+
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
224260
}
225261
}
226262

@@ -288,13 +324,14 @@ export async function runSandboxSmokeTest(
288324
const client = new Sandchest({
289325
apiKey: resolved.apiKey,
290326
baseUrl: resolved.baseUrl,
291-
timeout: 60_000,
327+
timeout: resolved.timeoutMs,
292328
retries: 1,
293329
})
294330
const tracker = new SandboxSmokeTracker()
295331
const checks: SandboxSmokeCheckResult[] = []
296332
const runId = makeRunId()
297333
const sharedPath = `/tmp/${runId}.txt`
334+
const artifactPath = `/tmp/${runId}.artifact.txt`
298335
const sessionPath = `/tmp/${runId}.session.txt`
299336
const fileContents = `smoke:${runId}`
300337

@@ -452,33 +489,92 @@ export async function runSandboxSmokeTest(
452489
fetched.status === 'stopping' || fetched.status === 'stopped',
453490
`Expected fetched fork to be stopping or stopped, got ${fetched.status}`,
454491
)
455-
}),
456-
)
457492

458-
checks.push(
459-
await measureCheck('collect artifacts on stop', logger, async () => {
460-
await sandbox.fs.write(sharedPath, fileContents)
461-
const artifactReadback = await sandbox.fs.read(sharedPath)
493+
const settled = await waitForSandboxStatus(
494+
resolved.baseUrl,
495+
resolved.apiKey,
496+
fork.id,
497+
['stopped'],
498+
DEFAULT_STOP_SETTLE_TIMEOUT_MS,
499+
)
462500
assert(
463-
artifactReadback === fileContents,
464-
'artifact file could not be refreshed before stop',
501+
settled?.status === 'stopped',
502+
`Expected fork to reach stopped before continuing, got ${formatSandboxState(settled)}`,
465503
)
504+
}),
505+
)
466506

507+
checks.push(
508+
await measureCheck('stop root', logger, async () => {
467509
await sandbox.stop()
468510
assert(
469511
sandbox.status === 'stopping' || sandbox.status === 'stopped',
470512
`Expected root sandbox to be stopping or stopped, got ${sandbox.status}`,
471513
)
472514

473-
const fetched = await client.get(sandbox.id)
515+
const settled = await fetchSandboxState(
516+
resolved.baseUrl,
517+
resolved.apiKey,
518+
sandbox.id,
519+
)
474520
assert(
475-
fetched.status === 'stopping' || fetched.status === 'stopped',
476-
`Expected fetched root sandbox to be stopping or stopped, got ${fetched.status}`,
521+
settled?.status === 'stopping' || settled?.status === 'stopped',
522+
`Expected root sandbox to be stopping or stopped, got ${formatSandboxState(settled)}`,
477523
)
524+
}),
525+
)
526+
527+
checks.push(
528+
await measureCheck('collect artifacts on stop', logger, async () => {
529+
const artifactSandbox = await client.create({
530+
image: resolved.image,
531+
profile: resolved.profile,
532+
ttlSeconds: resolved.ttlSeconds,
533+
})
534+
tracker.trackSandbox('artifact-stop', artifactSandbox)
535+
536+
await artifactSandbox.fs.write(artifactPath, fileContents)
537+
const registered = await artifactSandbox.artifacts.register([artifactPath])
538+
assert(registered.registered >= 1, 'artifact-stop sandbox registration returned zero artifacts')
478539

479-
const expectedArtifactName = sharedPath.split('/').filter(Boolean).pop() ?? sharedPath
480-
const foundArtifact = await waitForArtifact(sandbox, expectedArtifactName, 15_000)
481-
assert(foundArtifact, `artifact list did not include ${expectedArtifactName}`)
540+
await artifactSandbox.stop()
541+
assert(
542+
artifactSandbox.status === 'stopping' || artifactSandbox.status === 'stopped',
543+
`Expected artifact sandbox to be stopping or stopped, got ${artifactSandbox.status}`,
544+
)
545+
546+
const settled = await waitForSandboxStatus(
547+
resolved.baseUrl,
548+
resolved.apiKey,
549+
artifactSandbox.id,
550+
['stopped'],
551+
DEFAULT_STOP_SETTLE_TIMEOUT_MS,
552+
)
553+
assert(
554+
settled?.status === 'stopped',
555+
`Expected artifact sandbox to reach stopped, got ${formatSandboxState(settled)}`,
556+
)
557+
558+
const expectedArtifactName = artifactPath.split('/').filter(Boolean).pop() ?? artifactPath
559+
const artifactResult = await waitForArtifact(
560+
artifactSandbox,
561+
expectedArtifactName,
562+
DEFAULT_ARTIFACT_SETTLE_TIMEOUT_MS,
563+
)
564+
if (!artifactResult.found) {
565+
const snapshot = await fetchSandboxState(
566+
resolved.baseUrl,
567+
resolved.apiKey,
568+
artifactSandbox.id,
569+
)
570+
const visibleArtifacts =
571+
artifactResult.artifactNames.length > 0
572+
? artifactResult.artifactNames.join(', ')
573+
: 'none'
574+
throw new Error(
575+
`artifact list did not include ${expectedArtifactName}; visible artifacts: ${visibleArtifacts}; ${formatSandboxState(snapshot)}`,
576+
)
577+
}
482578
}),
483579
)
484580
} catch (error) {

0 commit comments

Comments
 (0)