@@ -9,15 +9,20 @@ vi.mock("./gateway-ws.js", () => {
99 const instances : FakeWs [ ] = [ ] ;
1010 class FakeWs {
1111 readonly closeCount = { n : 0 } ;
12+ private connected = false ;
1213 constructor ( public readonly cfg : unknown ) {
1314 instances . push ( this ) ;
1415 }
1516 async connect ( ) : Promise < void > {
16- /* noop */
17+ this . connected = true ;
1718 }
1819 async close ( ) : Promise < void > {
20+ this . connected = false ;
1921 this . closeCount . n += 1 ;
2022 }
23+ isConnected ( ) : boolean {
24+ return this . connected ;
25+ }
2126 async abort ( ) : Promise < void > {
2227 /* noop */
2328 }
@@ -413,6 +418,39 @@ describe("SessionContainerPool.warmForAgent", () => {
413418 expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( true ) ;
414419 await pool . shutdown ( ) ;
415420 } ) ;
421+
422+ it ( "does not exceed maxWarmContainers when different agents warm concurrently" , async ( ) => {
423+ const { pool, runtime } = makePool ( { maxWarmContainers : 1 } ) ;
424+ runtime . readyDelayMs = 25 ;
425+
426+ await Promise . all ( [
427+ pool . warmForAgent ( "agt_a" , baseSpawnOptions ( "warm-a" ) ) ,
428+ pool . warmForAgent ( "agt_b" , baseSpawnOptions ( "warm-b" ) ) ,
429+ ] ) ;
430+
431+ expect ( runtime . calls . filter ( ( c ) => c . kind === "spawn" ) ) . toHaveLength ( 1 ) ;
432+ await pool . shutdown ( ) ;
433+ } ) ;
434+
435+ it ( "cancels an inflight warm when dropWarmForAgent is called" , async ( ) => {
436+ const { pool, runtime } = makePool ( ) ;
437+ runtime . readyDelayMs = 25 ;
438+
439+ const warming = pool . warmForAgent ( "agt_x" , baseSpawnOptions ( ) ) ;
440+ await new Promise ( ( r ) => setImmediate ( r ) ) ;
441+ await pool . dropWarmForAgent ( "agt_x" ) ;
442+ await warming ;
443+
444+ const c = await pool . acquireForSession ( {
445+ sessionId : "ses_after_drop" ,
446+ spawnOptions : baseSpawnOptions ( ) ,
447+ agentId : "agt_x" ,
448+ } ) ;
449+
450+ expect ( c . id ) . toBe ( "cnt_2" ) ;
451+ expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( true ) ;
452+ await pool . shutdown ( ) ;
453+ } ) ;
416454} ) ;
417455
418456describe ( "SessionContainerPool.evictSession" , ( ) => {
@@ -501,6 +539,29 @@ describe("SessionContainerPool.reapIdle", () => {
501539 await pool . shutdown ( ) ;
502540 } ) ;
503541
542+ it ( "keeps sticky sessions alive when shouldReapSession returns false" , async ( ) => {
543+ const { pool, runtime } = makePool ( {
544+ idleTimeoutMs : 50 ,
545+ shouldReapSession : ( id ) => id === "ses_ephemeral" ,
546+ } ) ;
547+ await pool . acquireForSession ( {
548+ sessionId : "ses_sticky" ,
549+ spawnOptions : baseSpawnOptions ( ) ,
550+ } ) ;
551+ await pool . acquireForSession ( {
552+ sessionId : "ses_ephemeral" ,
553+ spawnOptions : baseSpawnOptions ( ) ,
554+ } ) ;
555+ await new Promise ( ( r ) => setTimeout ( r , 80 ) ) ;
556+ // @ts -expect-error — private reapIdle
557+ await pool . reapIdle ( ) ;
558+ const sessionsLeft = pool . snapshot ( ) . map ( ( e ) => e . sessionId ) ;
559+ expect ( sessionsLeft ) . toEqual ( [ "ses_sticky" ] ) ;
560+ expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( false ) ;
561+ expect ( runtime . stopped . has ( "cnt_2" ) ) . toBe ( true ) ;
562+ await pool . shutdown ( ) ;
563+ } ) ;
564+
504565 it ( "reaps warm containers past warmIdleTimeoutMs" , async ( ) => {
505566 const { pool, runtime } = makePool ( {
506567 idleTimeoutMs : 10 * 60_000 ,
@@ -534,6 +595,18 @@ describe("SessionContainerPool.shutdown", () => {
534595 expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( true ) ;
535596 expect ( runtime . stopped . has ( "cnt_2" ) ) . toBe ( true ) ;
536597 } ) ;
598+
599+ it ( "waits for inflight warm boots and stops them during shutdown" , async ( ) => {
600+ const { pool, runtime } = makePool ( ) ;
601+ runtime . readyDelayMs = 25 ;
602+
603+ const warming = pool . warmForAgent ( "agt_x" , baseSpawnOptions ( ) ) ;
604+ await new Promise ( ( r ) => setImmediate ( r ) ) ;
605+ await pool . shutdown ( ) ;
606+ await warming ;
607+
608+ expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( true ) ;
609+ } ) ;
537610} ) ;
538611
539612// --------------------------------------------------------------------
@@ -774,4 +847,50 @@ describe("SessionContainerPool — networking: limited", () => {
774847 expect ( spawnsAfter - spawnsBefore ) . toBe ( 2 ) ;
775848 await pool . shutdown ( ) ;
776849 } ) ;
850+
851+ it ( "falls back to a cold spawn when a claimed warm container is no longer ready" , async ( ) => {
852+ const { pool, runtime } = makePool ( ) ;
853+ await pool . warmForAgent ( "agt_x" , baseSpawnOptions ( ) ) ;
854+ const baseWaitForReady = runtime . waitForReady . bind ( runtime ) ;
855+ runtime . waitForReady = async ( container , timeoutMs ) => {
856+ if ( container . id === "cnt_1" ) {
857+ throw new Error ( "warm container is dead" ) ;
858+ }
859+ return baseWaitForReady ( container , timeoutMs ) ;
860+ } ;
861+
862+ const c = await pool . acquireForSession ( {
863+ sessionId : "ses_claim" ,
864+ spawnOptions : baseSpawnOptions ( ) ,
865+ agentId : "agt_x" ,
866+ } ) ;
867+
868+ expect ( c . id ) . toBe ( "cnt_2" ) ;
869+ expect ( runtime . stopped . has ( "cnt_1" ) ) . toBe ( true ) ;
870+ await pool . shutdown ( ) ;
871+ } ) ;
872+
873+ it ( "cold-respawns when the existing active container fails the reuse probe" , async ( ) => {
874+ const { pool, runtime } = makePool ( ) ;
875+ const first = await pool . acquireForSession ( {
876+ sessionId : "ses_live" ,
877+ spawnOptions : baseSpawnOptions ( ) ,
878+ } ) ;
879+ const baseWaitForReady = runtime . waitForReady . bind ( runtime ) ;
880+ runtime . waitForReady = async ( container , timeoutMs ) => {
881+ if ( container . id === first . id ) {
882+ throw new Error ( "active container is dead" ) ;
883+ }
884+ return baseWaitForReady ( container , timeoutMs ) ;
885+ } ;
886+
887+ const second = await pool . acquireForSession ( {
888+ sessionId : "ses_live" ,
889+ spawnOptions : baseSpawnOptions ( ) ,
890+ } ) ;
891+
892+ expect ( second . id ) . toBe ( "cnt_2" ) ;
893+ expect ( runtime . stopped . has ( first . id ) ) . toBe ( true ) ;
894+ await pool . shutdown ( ) ;
895+ } ) ;
777896} ) ;
0 commit comments