77
88const DEFAULT_BASE_URL = 'https://api.sandchest.com'
99const 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
1013const VALID_PROFILES = [ 'small' , 'medium' , 'large' ] as const
1114
1215export 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
3742export 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
167194async 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+
209244export 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