@@ -85,6 +85,35 @@ test('an allocation refused for its own flags leaves the create it joined alone'
8585 }
8686} ) ;
8787
88+ /**
89+ * A new allocation publishes its entry before it can await, and its own flags are the create's first
90+ * step, so a call this provider cannot honour still bills nothing and never reaches preparation.
91+ */
92+ test ( 'an allocation refused for its own flags bills nothing before it reaches the provider' , async ( ) => {
93+ const hub = startWebDriverHub ( ) ;
94+ const preparation = startProviderPreparation ( ) ;
95+ const runtime = makeRuntime ( {
96+ prepareSession : preparation . prepareSession ,
97+ profileFields : refuseAllProfileFields ( 'webdriver-test' ) ,
98+ } ) ;
99+ const lease = makeLease ( ) ;
100+
101+ try {
102+ globalThis . fetch = hub . fetch ;
103+ assertRejectedWith (
104+ await settle ( ( ) =>
105+ runtime . leaseLifecycle . allocate ! ( lease , { flags : { providerApp : 'b' } } as never ) ,
106+ ) ,
107+ 'INVALID_ARGS' ,
108+ { flags : [ '--provider-app' ] } ,
109+ ) ;
110+ assert . deepEqual ( hub . created , [ ] , 'a refused allocation never asks the hub to bill a device' ) ;
111+ assert . equal ( preparation . started , 0 , 'a refused allocation never reaches the provider' ) ;
112+ } finally {
113+ await runtime . shutdown ( ) ;
114+ }
115+ } ) ;
116+
88117/**
89118 * A release that landed mid-creation found no published session and answered that the lease held
90119 * nothing, so the hub session that creation went on to make had no route left to stop it. The caller
@@ -281,6 +310,162 @@ test('a repeated allocation cannot answer that a shut down lease is alive', asyn
281310 }
282311} ) ;
283312
313+ /**
314+ * An allocation reaches its first await before provider preparation begins, and a release issued in
315+ * that moment used to find nothing holding the lease and answer that it had released it. The create that
316+ * went on to run then billed a session no route was left to stop. The lease has to belong to the
317+ * allocation from the moment it is asked for, so the release ends up taking it and disposing what the
318+ * create rents.
319+ */
320+ test ( 'a release issued before provider preparation begins disposes the session the allocation rents' , async ( ) => {
321+ const hub = startWebDriverHub ( ) ;
322+ const preparation = startProviderPreparation ( ) ;
323+ const runtime = makeRuntime ( { prepareSession : preparation . prepareSession } ) ;
324+ const lease = makeLease ( ) ;
325+
326+ try {
327+ globalThis . fetch = hub . fetch ;
328+ // Both calls are issued in one synchronous step, so the release reaches the manager while the
329+ // allocation is at its first await and provider preparation has not answered.
330+ const allocating = settle ( ( ) => runtime . leaseLifecycle . allocate ! ( lease ) ) ;
331+ const releasing = settle ( ( ) => runtime . leaseLifecycle . release ! ( lease ) ) ;
332+ assert . deepEqual ( hub . created , [ ] , 'nothing may be billed before preparation answers' ) ;
333+ // The allocation has reached provider preparation, which is still held.
334+ await preparation . waitFor ( 1 ) ;
335+ await preparation . answerHeld ( ) ;
336+
337+ assert . deepEqual ( await releasing , {
338+ status : 'resolved' ,
339+ value : { provider : 'webdriver-test' , providerSessionId : 'session-1' } ,
340+ } ) ;
341+ assertRejectedWith ( await allocating , 'provider_session_released_during_allocation' , {
342+ leaseId : lease . leaseId ,
343+ providerSessionId : 'session-1' ,
344+ cleanup : 'confirmed' ,
345+ } ) ;
346+ // The session the create rented is gone: a release that reported success leaves no billed session.
347+ assert . deepEqual ( hub . deleted , [ 'session-1' ] ) ;
348+ } finally {
349+ await preparation . answerHeld ( ) ;
350+ await runtime . shutdown ( ) ;
351+ }
352+ } ) ;
353+
354+ /**
355+ * Ending a session ends the billed resource and hands back what the provider prepared; the artifact
356+ * lookup that follows is only what the release envelope reports. A plugin's own artifact callback has
357+ * no bound of its own, so it may not be what keeps a lease from being allocated again, what keeps a
358+ * second release from answering, or what a daemon close waits through.
359+ */
360+ test ( 'an artifact callback that never answers holds neither the lease nor shutdown' , async ( ) => {
361+ const hub = startWebDriverHub ( ) ;
362+ globalThis . fetch = hub . fetch ;
363+ let answerArtifacts : ( ( ) => void ) | undefined ;
364+ let lookupStarted = false ;
365+ const runtime = makeRuntime ( {
366+ providerCallbackTimeoutMs : 40 ,
367+ listArtifacts : async ( ) => {
368+ lookupStarted = true ;
369+ await new Promise < void > ( ( ready ) => {
370+ answerArtifacts = ready ;
371+ } ) ;
372+ return { provider : 'webdriver-test' , status : 'ready' , cloudArtifacts : [ ] } ;
373+ } ,
374+ } ) ;
375+ const lease = makeLease ( ) ;
376+
377+ try {
378+ await runtime . leaseLifecycle . allocate ! ( lease ) ;
379+ const releasing = settle ( ( ) => runtime . leaseLifecycle . release ! ( lease ) ) ;
380+ // The lookup starts only once the hub has ended the session and the provider's cleanup has answered,
381+ // so its having started is the moment the billed work is finished.
382+ await untilSettled ( ( ) => lookupStarted ) ;
383+ assert . deepEqual ( hub . deleted , [ 'session-1' ] ) ;
384+ await schedulerYield ( ) ;
385+
386+ // A re-allocation during the hanging lookup is the lease's own business, not the lookup's.
387+ const reallocated = settle ( ( ) => runtime . leaseLifecycle . allocate ! ( lease ) ) ;
388+ assertResolvedWith ( await reallocated , 'session-2' , describe ( await reallocated ) ) ;
389+ const shuttingDown = settle ( ( ) => runtime . shutdown ( ) ) ;
390+ assert . deepEqual (
391+ await shuttingDown ,
392+ { status : 'resolved' , value : undefined } ,
393+ 'shutdown may not wait on an optional artifact lookup' ,
394+ ) ;
395+
396+ const result = await releasing ;
397+ assert . equal ( result . status , 'resolved' , describe ( result ) ) ;
398+ const value = ( result as Readonly < { value : Record < string , unknown > | undefined } > ) . value ;
399+ assert . equal ( value ?. providerSessionId , 'session-1' ) ;
400+ // What the lookup could not answer for becomes an unavailable report, the way its failures do.
401+ assert . equal (
402+ ( value ?. cloudArtifacts as Record < string , unknown > | undefined ) ?. status ,
403+ 'unavailable' ,
404+ ) ;
405+ assert . deepEqual ( hub . created , [ 'session-1' , 'session-2' ] ) ;
406+ assert . deepEqual ( hub . deleted , [ 'session-1' , 'session-2' ] ) ;
407+ } finally {
408+ answerArtifacts ?.( ) ;
409+ await runtime . shutdown ( ) ;
410+ }
411+ } ) ;
412+
413+ /**
414+ * A provider's own cleanup is the half of disposal the hub's `DELETE` does not cover, and a custom one
415+ * has no bound either. Once its budget runs out this manager cannot learn whether the resource was
416+ * handed back, so it says so and lets the lease be used again rather than holding it forever.
417+ */
418+ test ( 'a provider cleanup that outlives its budget ends the disposal as uncertain' , async ( ) => {
419+ const hub = startWebDriverHub ( ) ;
420+ globalThis . fetch = hub . fetch ;
421+ let answerCleanup : ( ( ) => void ) | undefined ;
422+ let cleanupCalls = 0 ;
423+ const runtime = makeRuntime ( {
424+ providerCallbackTimeoutMs : 40 ,
425+ prepareSession : async ( { base } ) => ( {
426+ ...base ,
427+ cleanup : async ( ) => {
428+ cleanupCalls += 1 ;
429+ if ( cleanupCalls > 1 ) return undefined ;
430+ await new Promise < void > ( ( ready ) => {
431+ answerCleanup = ready ;
432+ } ) ;
433+ return undefined ;
434+ } ,
435+ } ) ,
436+ } ) ;
437+ const lease = makeLease ( ) ;
438+
439+ try {
440+ await runtime . leaseLifecycle . allocate ! ( lease ) ;
441+ const releasing = settle ( ( ) => runtime . leaseLifecycle . release ! ( lease ) ) ;
442+ await untilSettled ( ( ) => hub . deleted . includes ( 'session-1' ) ) ;
443+
444+ const result = await releasing ;
445+ assert . equal ( result . status , 'resolved' , describe ( result ) ) ;
446+ const value = ( result as Readonly < { value : Record < string , unknown > | undefined } > ) . value ;
447+ assert . deepEqual ( warningCodes ( value ?. warnings ) , [ 'PROVIDER_CLEANUP_FAILED' ] ) ;
448+ assert . match ( String ( warningMessages ( value ?. warnings ) [ 0 ] ) , / 4 0 m s / ) ;
449+
450+ // The lease is the next caller's again, and a later session this manager can end still ends cleanly.
451+ assertResolvedWith (
452+ await settle ( ( ) => runtime . leaseLifecycle . allocate ! ( lease ) ) ,
453+ 'session-2' ,
454+ 'a cleanup whose outcome is unknown may not keep the lease' ,
455+ ) ;
456+ assert . deepEqual (
457+ await settle ( ( ) => runtime . shutdown ( ) ) ,
458+ { status : 'resolved' , value : undefined } ,
459+ 'only a session the provider would not end may fail a close' ,
460+ ) ;
461+ assert . deepEqual ( hub . created , [ 'session-1' , 'session-2' ] ) ;
462+ assert . deepEqual ( hub . deleted , [ 'session-1' , 'session-2' ] ) ;
463+ } finally {
464+ answerCleanup ?.( ) ;
465+ await runtime . shutdown ( ) ;
466+ }
467+ } ) ;
468+
284469/**
285470 * A hub that will not end a session leaves it billing, so the release that was told to end it names the
286471 * session and the provider's refusal, exactly as it does for a session it was handed ready-made.
@@ -484,12 +669,30 @@ async function schedulerYield(): Promise<void> {
484669 } ) ;
485670}
486671
672+ /**
673+ * Waits for the manager to reach `condition` — the hub having been asked to delete a session, say — so a
674+ * test can name the moment after the billed work is done and before an optional callback has answered.
675+ */
676+ async function untilSettled ( condition : ( ) => boolean , withinMs = 2_000 ) : Promise < void > {
677+ for ( let waited = 0 ; ! condition ( ) && waited < withinMs ; waited += 5 ) {
678+ await new Promise < void > ( ( ready ) => {
679+ setTimeout ( ready , 5 ) ;
680+ } ) ;
681+ }
682+ assert . ok ( condition ( ) , 'the manager never reached the step the test was waiting for' ) ;
683+ }
684+
487685/** The allocation envelope names the hub session the lease now holds. */
488686function assertResolvedWith (
489687 outcome : Settled < Record < string , unknown > | undefined > ,
490688 sessionId : string ,
689+ expectation ?: string ,
491690) : void {
492- assert . equal ( outcome . status , 'resolved' , `expected ${ sessionId } , got ${ describe ( outcome ) } ` ) ;
691+ assert . equal (
692+ outcome . status ,
693+ 'resolved' ,
694+ `${ expectation ?? `expected ${ sessionId } ` } , got ${ describe ( outcome ) } ` ,
695+ ) ;
493696 assert . equal (
494697 ( outcome as Readonly < { value : Record < string , unknown > | undefined } > ) . value ?. sessionId ,
495698 sessionId ,
0 commit comments