Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/electron/pool-eviction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof spawned>][] = [
['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)
})
35 changes: 35 additions & 0 deletions apps/desktop/electron/pool-eviction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,38 @@ export function selectPoolEvictions<K>(

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<K>(
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
}
Loading