@@ -70,6 +70,7 @@ const DISTRIBUTED_MAX_INFLIGHT_PER_OWNER =
7070 Number . parseInt ( env . IVM_DISTRIBUTED_MAX_INFLIGHT_PER_OWNER ) ||
7171 MAX_ACTIVE_PER_OWNER + MAX_QUEUED_PER_OWNER
7272const DISTRIBUTED_LEASE_MIN_TTL_MS = Number . parseInt ( env . IVM_DISTRIBUTED_LEASE_MIN_TTL_MS ) || 120000
73+ const MAX_EXECUTIONS_PER_WORKER = Number . parseInt ( env . IVM_MAX_EXECUTIONS_PER_WORKER ) || 500
7374const DISTRIBUTED_KEY_PREFIX = 'ivm:fair:v1:owner'
7475const LEASE_REDIS_DEADLINE_MS = 200
7576const QUEUE_RETRY_DELAY_MS = 1000
@@ -89,6 +90,8 @@ interface WorkerInfo {
8990 pendingExecutions : Map < number , PendingExecution >
9091 idleTimeout : ReturnType < typeof setTimeout > | null
9192 id : number
93+ lifetimeExecutions : number
94+ retiring : boolean
9295}
9396
9497interface QueuedExecution {
@@ -538,8 +541,20 @@ function handleWorkerMessage(workerId: number, message: unknown) {
538541 owner . activeExecutions = Math . max ( 0 , owner . activeExecutions - 1 )
539542 maybeCleanupOwner ( owner . ownerKey )
540543 }
544+ workerInfo ! . lifetimeExecutions ++
545+ if ( workerInfo ! . lifetimeExecutions >= MAX_EXECUTIONS_PER_WORKER && ! workerInfo ! . retiring ) {
546+ workerInfo ! . retiring = true
547+ logger . info ( 'Worker marked for retirement' , {
548+ workerId,
549+ lifetimeExecutions : workerInfo ! . lifetimeExecutions ,
550+ } )
551+ }
552+ if ( workerInfo ! . retiring && workerInfo ! . activeExecutions === 0 ) {
553+ cleanupWorker ( workerId )
554+ } else {
555+ resetWorkerIdleTimeout ( workerId )
556+ }
541557 pending . resolve ( msg . result as IsolatedVMExecutionResult )
542- resetWorkerIdleTimeout ( workerId )
543558 drainQueue ( )
544559 }
545560 return
@@ -679,6 +694,8 @@ function spawnWorker(): Promise<WorkerInfo> {
679694 pendingExecutions : new Map ( ) ,
680695 idleTimeout : null ,
681696 id : workerId ,
697+ lifetimeExecutions : 0 ,
698+ retiring : false ,
682699 }
683700
684701 workerInfo . readyPromise = new Promise < void > ( ( resolve , reject ) => {
@@ -710,7 +727,8 @@ function spawnWorker(): Promise<WorkerInfo> {
710727
711728 import ( 'node:child_process' )
712729 . then ( ( { spawn } ) => {
713- const proc = spawn ( 'node' , [ workerPath ] , {
730+ // Required for isolated-vm on Node.js 20+ (issue #377)
731+ const proc = spawn ( 'node' , [ '--no-node-snapshot' , workerPath ] , {
714732 stdio : [ 'ignore' , 'pipe' , 'pipe' , 'ipc' ] ,
715733 serialization : 'json' ,
716734 } )
@@ -801,6 +819,7 @@ function selectWorker(): WorkerInfo | null {
801819 let best : WorkerInfo | null = null
802820 for ( const w of workers . values ( ) ) {
803821 if ( ! w . ready ) continue
822+ if ( w . retiring ) continue
804823 if ( w . activeExecutions >= MAX_PER_WORKER ) continue
805824 if ( ! best || w . activeExecutions < best . activeExecutions ) {
806825 best = w
@@ -818,7 +837,8 @@ async function acquireWorker(): Promise<WorkerInfo | null> {
818837 const existing = selectWorker ( )
819838 if ( existing ) return existing
820839
821- const currentPoolSize = workers . size + spawnInProgress
840+ const activeWorkerCount = [ ...workers . values ( ) ] . filter ( ( w ) => ! w . retiring ) . length
841+ const currentPoolSize = activeWorkerCount + spawnInProgress
822842 if ( currentPoolSize < POOL_SIZE ) {
823843 try {
824844 return await spawnWorker ( )
@@ -850,12 +870,24 @@ function dispatchToWorker(
850870 totalActiveExecutions --
851871 ownerState . activeExecutions = Math . max ( 0 , ownerState . activeExecutions - 1 )
852872 maybeCleanupOwner ( ownerState . ownerKey )
873+ workerInfo . lifetimeExecutions ++
874+ if ( workerInfo . lifetimeExecutions >= MAX_EXECUTIONS_PER_WORKER && ! workerInfo . retiring ) {
875+ workerInfo . retiring = true
876+ logger . info ( 'Worker marked for retirement' , {
877+ workerId : workerInfo . id ,
878+ lifetimeExecutions : workerInfo . lifetimeExecutions ,
879+ } )
880+ }
853881 resolve ( {
854882 result : null ,
855883 stdout : '' ,
856884 error : { message : `Execution timed out after ${ req . timeoutMs } ms` , name : 'TimeoutError' } ,
857885 } )
858- resetWorkerIdleTimeout ( workerInfo . id )
886+ if ( workerInfo . retiring && workerInfo . activeExecutions === 0 ) {
887+ cleanupWorker ( workerInfo . id )
888+ } else {
889+ resetWorkerIdleTimeout ( workerInfo . id )
890+ }
859891 drainQueue ( )
860892 } , req . timeoutMs + 1000 )
861893
@@ -878,7 +910,11 @@ function dispatchToWorker(
878910 stdout : '' ,
879911 error : { message : 'Code execution failed to start. Please try again.' , name : 'Error' } ,
880912 } )
881- resetWorkerIdleTimeout ( workerInfo . id )
913+ if ( workerInfo . retiring && workerInfo . activeExecutions === 0 ) {
914+ cleanupWorker ( workerInfo . id )
915+ } else {
916+ resetWorkerIdleTimeout ( workerInfo . id )
917+ }
882918 // Defer to break synchronous recursion: drainQueue → dispatchToWorker → catch → drainQueue
883919 queueMicrotask ( ( ) => drainQueue ( ) )
884920 }
@@ -952,7 +988,8 @@ function drainQueue() {
952988 while ( queueLength ( ) > 0 && totalActiveExecutions < MAX_CONCURRENT ) {
953989 const worker = selectWorker ( )
954990 if ( ! worker ) {
955- const currentPoolSize = workers . size + spawnInProgress
991+ const activeWorkerCount = [ ...workers . values ( ) ] . filter ( ( w ) => ! w . retiring ) . length
992+ const currentPoolSize = activeWorkerCount + spawnInProgress
956993 if ( currentPoolSize < POOL_SIZE ) {
957994 spawnWorker ( )
958995 . then ( ( ) => drainQueue ( ) )
0 commit comments