diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0e5ec73363f32..f124456f56696 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -280,7 +280,7 @@ import { localRouteFallbackProfiles, undialedSshRouteSeeds } from './plugin-profile-routes' -import { selectPoolEvictions } from './pool-eviction' +import { selectPoolEvictions, selectSlotDisplacementVictim } from './pool-eviction' import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits' import { LocalBackendSpawnCoordinator, @@ -12249,6 +12249,31 @@ function evictLruPoolBackends(keep) { } } +// Demand displacement for the hard spawn cap (#102163). evictLruPoolBackends() +// only evicts backends idle past the keepalive window, so a fast A→B→C→D +// switch (every holder still fresh) converges nothing and the 4th spawn queued +// behind full slots until its 30s ticket expired. A real profile switch +// outranks background pressure: stop the stalest RUNNING backend and wait for +// its slot, so the coordinator hands it to the waiter below instead of timing +// out. Starting/queued entries are never victims (preempting a mid-boot spawn +// just moves the error to an earlier click); with no running victim this is a +// no-op and genuine capacity pressure still surfaces the queue timeout. +// ponytail: single displacement per spawn; a many-way concurrent boot storm +// with zero running backends still funnels through the queue timeout — widen +// to a drain loop only if that path is ever observed in the wild. +async function displaceStalestPoolBackendForSlot(excludeKey) { + const victim = selectSlotDisplacementVictim(backendPool.entries(), excludeKey) + + if (victim === null) { + return false + } + + rememberLog(`Profile backend "${excludeKey}" displacing stalest pool backend "${victim}" for a free local slot`) + await stopPoolBackend(victim) + + return true +} + function startPoolIdleReaper() { if (poolIdleReaper) { return @@ -12386,6 +12411,9 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po rememberLog( `Profile backend "${profile}" waiting for a free local slot (${localBackendSpawnCoordinator.activeCount}/${poolMaxBackends()} busy, ${localBackendSpawnCoordinator.queuedCount} queued)` ) + // Demand displacement (#102163): converge the pool now instead of riding + // the queue to its timeout — see displaceStalestPoolBackendForSlot. + await displaceStalestPoolBackendForSlot(poolKey) } entry.releaseLocalBackendSlot = await spawnRequest.acquired diff --git a/apps/desktop/electron/pool-eviction.test.ts b/apps/desktop/electron/pool-eviction.test.ts index 4ad3c3fc0def3..073de6b93cbeb 100644 --- a/apps/desktop/electron/pool-eviction.test.ts +++ b/apps/desktop/electron/pool-eviction.test.ts @@ -151,3 +151,14 @@ test('#95189: a backend genuinely idle for minutes IS evicted (#95189 long-windo // keep=1, idle is over the cap AND past the fresh window → evicted. assert.deepEqual(selectPoolEvictions(entries, 1, NOW, FRESH_MS), ['idle']) }) + +test('slot displacement picks stalest running backend, never self or starters', async () => { + const { selectSlotDisplacementVictim } = await import('./pool-eviction') + const entries: [string, ReturnType][] = [ + ['a', spawned(9_000)], + ['b', spawned(3_000)], + ['d', spawned(0)] + ] + assert.equal(selectSlotDisplacementVictim(entries, 'd'), 'a') + assert.equal(selectSlotDisplacementVictim([['d', spawned(0)]], 'd'), null) +}) diff --git a/apps/desktop/electron/pool-eviction.ts b/apps/desktop/electron/pool-eviction.ts index 628a3311993db..cc7a48b1c8700 100644 --- a/apps/desktop/electron/pool-eviction.ts +++ b/apps/desktop/electron/pool-eviction.ts @@ -56,3 +56,38 @@ export function selectPoolEvictions( return evictions } + +/** + * Demand displacement victim for the hard local-backend spawn cap (#102163). + * + * selectPoolEvictions() spares keepalive-fresh backends, so a fast A→B→C→D + * profile switch converges nothing and the 4th spawn queued behind full + * slots until its ticket expired ("timed out while waiting for a free + * slot"). A real profile switch outranks background pressure: displace the + * stalest RUNNING backend (its session persists; reopening respawns) so the + * pool converges instead of erroring. Only entries holding a child process + * free a coordinator slot — starting/queued/descriptor entries are never + * victims, nor is the incoming spawn itself (`exclude`). + */ +export function selectSlotDisplacementVictim( + entries: Iterable<[K, PoolEvictionEntry]>, + exclude: K +): K | null { + let victim: K | null = null + let victimAt = Number.POSITIVE_INFINITY + + for (const [key, entry] of entries) { + if (key === exclude || !entry.process) { + continue + } + + const at = typeof entry.lastActiveAt === 'number' ? entry.lastActiveAt : 0 + + if (at < victimAt) { + victim = key + victimAt = at + } + } + + return victim +}