diff --git a/apps/desktop/electron/pool-spawn-coordinator.test.ts b/apps/desktop/electron/pool-spawn-coordinator.test.ts index 4b1fc2beb7..66f41f7b60 100644 --- a/apps/desktop/electron/pool-spawn-coordinator.test.ts +++ b/apps/desktop/electron/pool-spawn-coordinator.test.ts @@ -269,6 +269,56 @@ test('a failed or repeated cleanup releases exactly one slot', async () => { }) +test('raising the limit at runtime drains queued waiters into the new slots', async () => { + const coordinator = new LocalBackendSpawnCoordinator(1) + const first = await coordinator.acquire('a') + const queuedB = coordinator.request('b') + const queuedC = coordinator.request('c') + await flush() + assert.equal(coordinator.activeCount, 1) + assert.equal(coordinator.queuedCount, 2) + + coordinator.setLimit(2) + const releaseB = await queuedB.acquired + assert.equal(coordinator.activeCount, 2) + assert.equal(coordinator.queuedCount, 1) + + first() + const releaseC = await queuedC.acquired + assert.equal(coordinator.activeCount, 2) + releaseB() + releaseC() + assert.equal(coordinator.activeCount, 0) +}) + +test('lowering the limit never revokes granted slots; new requests queue until under cap', async () => { + const coordinator = new LocalBackendSpawnCoordinator(3) + const releases = await Promise.all(['a', 'b', 'c'].map(key => coordinator.acquire(key))) + coordinator.setLimit(1) + assert.equal(coordinator.activeCount, 3, 'granted slots stay granted') + + const queued = coordinator.request('d') + await flush() + assert.equal(coordinator.queuedCount, 1) + + releases[0]() + releases[1]() + await flush() + assert.equal(coordinator.queuedCount, 1, 'still over the new cap of 1') + + releases[2]() + const releaseD = await queued.acquired + assert.equal(coordinator.activeCount, 1) + releaseD() +}) + +test('setLimit rejects a non-positive or fractional cap', () => { + const coordinator = new LocalBackendSpawnCoordinator(2) + assert.throws(() => coordinator.setLimit(0), RangeError) + assert.throws(() => coordinator.setLimit(1.5), RangeError) + assert.equal(coordinator.limit, 2) +}) + // ── main.ts wiring ────────────────────────────────────────────────────────── // The coordinator is only as good as the timeout main.ts hands it. A queued // ticket that outlives the renderer's backend-boot budget holds the pool key @@ -296,4 +346,12 @@ test('a failed or repeated cleanup releases exactly one slot', async () => { assert.match(mainSource, /localBackendSpawnCoordinator\.request\(poolKey, \{ timeoutMs: POOL_SLOT_WAIT_MS \}\)/) assert.doesNotMatch(mainSource, /request\(poolKey, \{ timeoutMs: POOL_IDLE_MS \}\)/) }) + + test('main.ts pushes the live pool max into the coordinator when the preference changes', () => { + // Pool sizing is a live device preference (#92581); the hard cap must + // follow it, otherwise raising the max in Settings would leave spawns + // queued behind the launch-time value. + assert.match(mainSource, /new LocalBackendSpawnCoordinator\(poolLimits\.maxBackends\)/) + assert.match(mainSource, /localBackendSpawnCoordinator\.setLimit\(poolLimits\.maxBackends\)/) + }) } diff --git a/apps/desktop/electron/pool-spawn-coordinator.ts b/apps/desktop/electron/pool-spawn-coordinator.ts index f7c234041d..8e565ed133 100644 --- a/apps/desktop/electron/pool-spawn-coordinator.ts +++ b/apps/desktop/electron/pool-spawn-coordinator.ts @@ -27,7 +27,7 @@ export async function releaseLocalBackendSlotAfterExit( * the child exits or the start fails. Remote descriptors never call request(). */ export class LocalBackendSpawnCoordinator { - readonly #limit: number + #limit: number #active = 0 #queue: Waiter[] = [] @@ -43,6 +43,25 @@ export class LocalBackendSpawnCoordinator { return this.#active } + get limit(): number { + return this.#limit + } + + /** + * Adopt a new cap at runtime (the pool size is a live device preference). + * Raising it drains waiters into the newly freed slots immediately; lowering + * it never revokes a granted slot — the running backends simply stay over + * the cap until they exit, and LRU eviction (main.ts) converges the pool. + */ + setLimit(limit: number): void { + if (!Number.isInteger(limit) || limit < 1) { + throw new RangeError('Local backend spawn limit must be a positive integer.') + } + + this.#limit = limit + this.#drain() + } + get queuedCount(): number { return this.#queue.length } @@ -119,12 +138,16 @@ export class LocalBackendSpawnCoordinator { released = true this.#active -= 1 - const next = this.#queue.shift() + this.#drain() + } + } - if (next) { - this.#clearTimer(next) - next.resolve(this.#grant()) - } + /** Hand free slots to queued waiters while under the (possibly lowered) cap. */ + #drain(): void { + while (this.#active < this.#limit && this.#queue.length > 0) { + const next = this.#queue.shift()! + this.#clearTimer(next) + next.resolve(this.#grant()) } } }