Loading files
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
let dataDir = ''
async function loadLock() {
vi.resetModules()
process.env.ADAPTER_DATA_DIR = dataDir
return import('./daemonSpawnLock.js')
}
const lockDir = () => join(dataDir, 'adapter.spawn.lock')
const ownerFile = () => join(lockDir(), 'owner.json')
/** A real process that stays alive until killed — a lock owner whose liveness is not faked. */
function sleeper(): ReturnType<typeof spawn> {
return spawn(process.execPath, ['-e', 'setTimeout(() => {}, 100000)'], { stdio: 'ignore' })
}
const waitFor = async (predicate: () => boolean, ms = 3000): Promise<void> => {
const deadline = Date.now() + ms
while (!predicate()) {
if (Date.now() > deadline) throw new Error('condition not met in time')
await new Promise((r) => setTimeout(r, 20))
}
}
describe('daemon spawn lock', () => {
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'adapter-spawn-lock-'))
})
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true })
delete process.env.ADAPTER_DATA_DIR
})
it('acquires, records this process as the owner, and releases', async () => {
const lock = await loadLock()
const release = await lock.acquireSpawnLock('start')
expect(existsSync(ownerFile())).toBe(true)
const owner = lock.readSpawnLockOwner()
expect(owner?.pid).toBe(process.pid)
expect(owner?.purpose).toBe('start')
release()
expect(existsSync(lockDir())).toBe(false)
expect(lock.readSpawnLockOwner()).toBeNull()
})
it('is re-entrant within one process and drops the lock only with the outermost release', async () => {
const lock = await loadLock()
const outer = await lock.acquireSpawnLock('update')
const inner = await lock.acquireSpawnLock('start') // update → launch
expect(lock.readSpawnLockOwner()?.purpose).toBe('update') // the outer section's identity holds
inner()
expect(existsSync(lockDir())).toBe(true)
outer()
expect(existsSync(lockDir())).toBe(false)
})
it('withSpawnLock releases on a throw', async () => {
const lock = await loadLock()
await expect(lock.withSpawnLock('handoff', async () => { throw new Error('boom') })).rejects.toThrow('boom')
expect(existsSync(lockDir())).toBe(false)
})
it('waits for a live holder and reports it once, then acquires when it lets go', async () => {
const lock = await loadLock()
const holder = sleeper()
await waitFor(() => !!holder.pid)
mkdirSync(lockDir(), { mode: 0o700 })
writeFileSync(ownerFile(), JSON.stringify({
pid: holder.pid, startMarker: '', token: 'held-by-child', purpose: 'handoff', since: Date.now(),
}), { mode: 0o600 })
const waiting: unknown[] = []
const acquired = lock.acquireSpawnLock('start', { waitMs: 5000, onWaiting: (o) => waiting.push(o) })
// Generous: every poll reads the holder's start marker through `ps`, which a loaded box can
// take a good fraction of a second over.
await waitFor(() => waiting.length > 0, 2000)
expect(waiting).toHaveLength(1)
expect((waiting[0] as { pid: number }).pid).toBe(holder.pid)
expect(readFileSync(ownerFile(), 'utf8')).toContain('held-by-child') // still theirs
holder.kill('SIGKILL')
const release = await acquired
expect(lock.readSpawnLockOwner()?.pid).toBe(process.pid)
release()
})
it('gives up with SpawnLockBusyError carrying the owner when the holder outlives the wait', async () => {
const lock = await loadLock()
const holder = sleeper()
await waitFor(() => !!holder.pid)
mkdirSync(lockDir(), { mode: 0o700 })
writeFileSync(ownerFile(), JSON.stringify({
pid: holder.pid, startMarker: '', token: 't', purpose: 'update', since: Date.now(),
}), { mode: 0o600 })
try {
await expect(lock.acquireSpawnLock('start', { waitMs: 300 })).rejects.toMatchObject({
name: 'SpawnLockBusyError',
owner: { pid: holder.pid, purpose: 'update' },
})
expect(existsSync(ownerFile())).toBe(true)
} finally {
holder.kill('SIGKILL')
}
})
it('reclaims a lock whose owner is dead', async () => {
const lock = await loadLock()
const dead = spawnSync(process.execPath, ['-e', 'process.exit(0)'])
expect(dead.status).toBe(0)
mkdirSync(lockDir(), { mode: 0o700 })
writeFileSync(ownerFile(), JSON.stringify({
pid: 999_999_999, startMarker: '', token: 'stale', purpose: 'start', since: Date.now() - 60_000,
}), { mode: 0o600 })
const release = await lock.acquireSpawnLock('start', { waitMs: 1000 })
expect(lock.readSpawnLockOwner()?.pid).toBe(process.pid)
release()
})
it('reclaims a lock whose pid has been reused by another process generation', async () => {
const lock = await loadLock()
mkdirSync(lockDir(), { mode: 0o700 })
writeFileSync(ownerFile(), JSON.stringify({
pid: process.pid, startMarker: 'different-process-generation', token: 'stale', purpose: 'start', since: Date.now(),
}), { mode: 0o600 })
const release = await lock.acquireSpawnLock('start', { waitMs: 1000 })
expect(lock.readSpawnLockOwner()?.token).not.toBe('stale')
release()
})
it('reclaims an ownerless directory once it is clearly debris, not one being created', async () => {
const lock = await loadLock()
mkdirSync(lockDir(), { mode: 0o700 })
// Fresh and ownerless: someone is between mkdir and the O_EXCL write — do not touch it yet.
await expect(lock.acquireSpawnLock('start', { waitMs: 250 })).rejects.toMatchObject({ name: 'SpawnLockBusyError', owner: null })
// Age it past the debris threshold.
const old = new Date(Date.now() - 10_000)
const { utimesSync } = await import('fs')
utimesSync(lockDir(), old, old)
const release = await lock.acquireSpawnLock('start', { waitMs: 1000 })
expect(lock.readSpawnLockOwner()?.pid).toBe(process.pid)
release()
})
it('refuses a lock directory with an unsafe mode rather than reading it', async () => {
const lock = await loadLock()
mkdirSync(lockDir(), { mode: 0o777 })
const { chmodSync } = await import('fs')
chmodSync(lockDir(), 0o777)
writeFileSync(ownerFile(), JSON.stringify({ pid: 1, startMarker: '', token: 't', purpose: 'start', since: 0 }), { mode: 0o600 })
expect(() => lock.readSpawnLockOwner()).toThrow(/unsafe/)
})
it('gives up AT ONCE, naming the path, when something that is not a lock sits at the lock path', async () => {
// A plain file — from a stray `touch`, a bad restore, whatever. Waiting 45s would never turn it
// into a directory, and a stop that could not get past it would be a stop that does not work.
const lock = await loadLock()
writeFileSync(lockDir(), 'not a directory')
const started = Date.now()
await expect(lock.acquireSpawnLock('start', { waitMs: 45_000 })).rejects.toMatchObject({
name: 'SpawnLockBusyError',
owner: null,
reason: expect.stringContaining('remove it by hand'),
})
expect(Date.now() - started).toBeLessThan(2_000)
expect(lock.describeSpawnLockFailure(new lock.SpawnLockBusyError(null, 'why'))).toBe('why')
})
it('tells a person what Harness is still doing, without a pid or a lock in the sentence', async () => {
// The desktop's sign-in screen shows this verbatim: the technical line is for stderr.
const lock = await loadLock()
const busy = (purpose: 'start' | 'update' | 'handoff' | 'stop' | 'login') =>
new lock.SpawnLockBusyError({ pid: 4242, startMarker: '', token: 't', purpose, since: Date.now() })
expect(lock.describeSpawnLockBusyPlainly(busy('update'))).toBe('Harness is still updating on this computer. Try again in a moment.')
expect(lock.describeSpawnLockBusyPlainly(busy('login'))).toBe('Another sign-in is already in progress on this computer. Try again in a moment.')
for (const purpose of ['start', 'handoff', 'stop'] as const) {
const said = lock.describeSpawnLockBusyPlainly(busy(purpose))
expect(said).toMatch(/^Harness is still .* on this computer\. Try again in a moment\.$/)
expect(said).not.toMatch(/4242|lock|pid/)
}
expect(lock.describeSpawnLockBusyPlainly(new lock.SpawnLockBusyError(null))).toBe('Harness is busy on this computer. Try again in a moment.')
// Not a lock at all is not going to clear itself: no "try again", a terminal instead.
expect(lock.describeSpawnLockBusyPlainly(new lock.SpawnLockBusyError(null, 'not a lock this CLI made: /x — remove it by hand')))
.toBe('Harness cannot sign in on this computer right now. Run `harness login --force` in a terminal to see why.')
})
it('serializes two real spawners racing for the lock', async () => {
// Each child takes the lock, appends "<pid> in", sleeps, appends "<pid> out". Serialized, the
// trace is in/out/in/out; a broken lock interleaves in/in.
const trace = join(dataDir, 'trace.log')
const script = join(dataDir, 'racer.mts') // .mts: the temp dir has no package.json, so .ts would run as CJS
const lockModule = join(process.cwd(), 'src', 'lib', 'daemonSpawnLock.ts')
writeFileSync(script, `
import { appendFileSync } from 'fs'
import { withSpawnLock } from ${JSON.stringify(lockModule)}
await withSpawnLock('start', async () => {
appendFileSync(${JSON.stringify(trace)}, process.pid + ' in\\n')
await new Promise((r) => setTimeout(r, 400))
appendFileSync(${JSON.stringify(trace)}, process.pid + ' out\\n')
})
`)
const tsx = join(process.cwd(), 'node_modules', '.bin', 'tsx')
const run = () => new Promise<number | null>((resolve) => {
const c = spawn(tsx, [script], { env: { ...process.env, ADAPTER_DATA_DIR: dataDir }, stdio: 'inherit' })
c.on('exit', (code) => resolve(code))
})
const codes = await Promise.all([run(), run(), run()])
expect(codes).toEqual([0, 0, 0])
const lines = readFileSync(trace, 'utf8').trim().split('\n')
expect(lines).toHaveLength(6)
for (let i = 0; i < lines.length; i += 2) {
const [inPid, inWord] = lines[i].split(' ')
const [outPid, outWord] = lines[i + 1].split(' ')
expect(inWord).toBe('in')
expect(outWord).toBe('out')
expect(outPid).toBe(inPid)
}
expect(existsSync(lockDir())).toBe(false)
}, 30_000)
})
bf00f95f7This file is pinned to the selected release. Download the original to open it in your design tool.