345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { spawn } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import { LocalBackendSpawnCoordinator, releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator'
|
|
|
|
const deferred = () => {
|
|
let resolve!: () => void
|
|
|
|
const promise = new Promise<void>(done => {
|
|
resolve = done
|
|
})
|
|
|
|
return { promise, resolve }
|
|
}
|
|
|
|
const flush = () => new Promise<void>(resolve => setImmediate(resolve))
|
|
|
|
test('100 concurrent local requests never hold more than the configured slots', async () => {
|
|
const limit = 12
|
|
const coordinator = new LocalBackendSpawnCoordinator(limit)
|
|
const gates = Array.from({ length: 100 }, deferred)
|
|
let active = 0
|
|
let maxActive = 0
|
|
|
|
const tasks = gates.map(async (gate, index) => {
|
|
const release = await coordinator.acquire(`profile-${index}`)
|
|
active += 1
|
|
maxActive = Math.max(maxActive, active)
|
|
|
|
await gate.promise
|
|
|
|
active -= 1
|
|
release()
|
|
})
|
|
|
|
await flush()
|
|
assert.equal(active, limit)
|
|
assert.equal(coordinator.activeCount, limit)
|
|
assert.equal(coordinator.queuedCount, 100 - limit)
|
|
|
|
for (let start = 0; start < gates.length; start += limit) {
|
|
for (const gate of gates.slice(start, start + limit)) {
|
|
gate.resolve()
|
|
}
|
|
|
|
await flush()
|
|
}
|
|
|
|
await Promise.all(tasks)
|
|
assert.equal(maxActive, limit)
|
|
assert.equal(coordinator.activeCount, 0)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
})
|
|
|
|
test('a queued start can be cancelled without waiting for an active backend', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const releaseFirst = await coordinator.acquire('first')
|
|
const queued = coordinator.request('cancelled')
|
|
|
|
assert.equal(coordinator.queuedCount, 1)
|
|
assert.equal(queued.cancel(), true)
|
|
await assert.rejects(queued.acquired, /cancelled while queued/)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
|
|
releaseFirst()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
})
|
|
|
|
test('cancelling an old same-key request never rejects a newer waiter', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const blocker = coordinator.request('blocker')
|
|
const releaseBlocker = await blocker.acquired
|
|
const old = coordinator.request('same-profile')
|
|
|
|
releaseBlocker()
|
|
const newer = coordinator.request('same-profile')
|
|
|
|
assert.equal(old.cancel(), false, 'the old request was already granted')
|
|
assert.equal(coordinator.queuedCount, 1, 'the newer same-key waiter must remain queued')
|
|
|
|
const releaseOld = await old.acquired
|
|
releaseOld()
|
|
const releaseNewer = await newer.acquired
|
|
releaseNewer()
|
|
|
|
assert.equal(coordinator.activeCount, 0)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
})
|
|
|
|
test('a queued start times out with a clear error and frees its queue position', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const releaseFirst = await coordinator.acquire('first')
|
|
const queued = coordinator.request('timed-out', { timeoutMs: 10 })
|
|
|
|
await assert.rejects(queued.acquired, /timed out while waiting for a free slot/)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
|
|
releaseFirst()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
})
|
|
|
|
test('100 real child processes never exceed twelve simultaneous local slots', async () => {
|
|
const limit = 12
|
|
const coordinator = new LocalBackendSpawnCoordinator(limit)
|
|
const livePids = new Set<number>()
|
|
const seenPids = new Set<number>()
|
|
let maxLive = 0
|
|
|
|
await Promise.all(
|
|
Array.from({ length: 100 }, async (_, index) => {
|
|
const release = await coordinator.acquire(`real-profile-${index}`)
|
|
|
|
try {
|
|
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 40)'], {
|
|
stdio: 'ignore'
|
|
})
|
|
|
|
assert.ok(child.pid)
|
|
livePids.add(child.pid)
|
|
seenPids.add(child.pid)
|
|
maxLive = Math.max(maxLive, livePids.size)
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
child.once('error', reject)
|
|
child.once('exit', code => {
|
|
if (code === 0) {
|
|
resolve()
|
|
} else {
|
|
reject(new Error(`child ${child.pid} exited with ${code}`))
|
|
}
|
|
})
|
|
})
|
|
|
|
livePids.delete(child.pid)
|
|
} finally {
|
|
release()
|
|
}
|
|
})
|
|
)
|
|
|
|
assert.equal(seenPids.size, 100)
|
|
assert.equal(maxLive, limit)
|
|
assert.equal(livePids.size, 0)
|
|
assert.equal(coordinator.activeCount, 0)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
})
|
|
|
|
test('failed start keeps its slot until the child has actually exited', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const childExit = deferred()
|
|
const releaseFailed = await coordinator.acquire('failed')
|
|
let successorEntered = false
|
|
|
|
const successor = coordinator.acquire('successor').then(release => {
|
|
successorEntered = true
|
|
|
|
return release
|
|
})
|
|
|
|
const cleanup = releaseLocalBackendSlotAfterExit(releaseFailed, () => childExit.promise)
|
|
await flush()
|
|
|
|
assert.equal(successorEntered, false)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 1)
|
|
|
|
childExit.resolve()
|
|
await cleanup
|
|
const releaseSuccessor = await successor
|
|
|
|
assert.equal(successorEntered, true)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
|
|
releaseSuccessor()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
})
|
|
|
|
test('a rejected wait keeps the slot occupied', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const releaseFailed = await coordinator.acquire('failed')
|
|
let successorEntered = false
|
|
|
|
const successor = coordinator.acquire('successor').then(release => {
|
|
successorEntered = true
|
|
|
|
return release
|
|
})
|
|
|
|
const cleanup = releaseLocalBackendSlotAfterExit(releaseFailed, async () => {
|
|
throw new Error('exit unproven')
|
|
})
|
|
|
|
await assert.rejects(cleanup, /exit unproven/)
|
|
await flush()
|
|
|
|
assert.equal(successorEntered, false)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 1)
|
|
|
|
releaseFailed()
|
|
const releaseSuccessor = await successor
|
|
assert.equal(successorEntered, true)
|
|
releaseSuccessor()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
})
|
|
|
|
test('an invalid timeout never enqueues a waiter', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const releaseFirst = await coordinator.acquire('first')
|
|
|
|
assert.throws(() => coordinator.request('invalid', { timeoutMs: 0 }), /timeout must be a positive number/)
|
|
assert.throws(() => coordinator.request('invalid', { timeoutMs: Number.NaN }), /timeout must be a positive number/)
|
|
assert.throws(() => coordinator.request('invalid', { timeoutMs: -5 }), /timeout must be a positive number/)
|
|
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
|
|
releaseFirst()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
})
|
|
|
|
test('a failed or repeated cleanup releases exactly one slot', async () => {
|
|
const coordinator = new LocalBackendSpawnCoordinator(1)
|
|
const releaseFirst = await coordinator.acquire('first')
|
|
let secondEntered = false
|
|
|
|
const second = coordinator.acquire('second').then(release => {
|
|
secondEntered = true
|
|
|
|
return release
|
|
})
|
|
|
|
await flush()
|
|
assert.equal(secondEntered, false)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 1)
|
|
|
|
releaseFirst()
|
|
releaseFirst()
|
|
const releaseSecond = await second
|
|
|
|
assert.equal(secondEntered, true)
|
|
assert.equal(coordinator.activeCount, 1)
|
|
assert.equal(coordinator.queuedCount, 0)
|
|
|
|
releaseSecond()
|
|
assert.equal(coordinator.activeCount, 0)
|
|
})
|
|
|
|
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
|
|
// hostage: the renderer has already reported "backend didn't come up", and
|
|
// every later click on that profile joins the stale wait instead of failing
|
|
// fast with a reason.
|
|
{
|
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
|
|
|
const withTimeoutSource = fs
|
|
.readFileSync(path.join(here, '..', 'src', 'lib', 'with-timeout.ts'), 'utf8')
|
|
.replace(/\r\n/g, '\n')
|
|
|
|
test('main.ts bounds the slot wait below the renderer backend-boot budget', () => {
|
|
const slotWait = Number(/const POOL_SLOT_WAIT_MS = ([\d_]+)/.exec(mainSource)?.[1]?.replace(/_/g, ''))
|
|
|
|
const bootBudget = Number(
|
|
/export const BACKEND_BOOT_WAIT_TIMEOUT_MS = ([\d_]+)/.exec(withTimeoutSource)?.[1]?.replace(/_/g, '')
|
|
)
|
|
|
|
assert.ok(Number.isFinite(slotWait) && slotWait > 0, 'POOL_SLOT_WAIT_MS must be a literal in main.ts')
|
|
assert.ok(Number.isFinite(bootBudget), 'BACKEND_BOOT_WAIT_TIMEOUT_MS must be a literal')
|
|
assert.ok(slotWait < bootBudget, `slot wait ${slotWait}ms must be below the boot budget ${bootBudget}ms`)
|
|
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\)/)
|
|
})
|
|
}
|