Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
fc8f8511dThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* ONE process may spawn, hand off, or stop the daemon at a time.
*
* Three things used to do it with no coordination: `harness start` (the desktop app re-runs it every
* few seconds whenever the daemon looks down), the daemon's own self-update handoff, and
* `harness update`. The control port is fixed, so two spawners in the same second is a coin toss for
* which child binds — and the loser, having already written its pid, either left `adapter.pid`
* naming a corpse or deleted the file that named the winner. That is the orphan on :18473 that
* `harness stop` cannot see and every later `harness start` trips over.
*
* Same shape as the registry lock (a directory created with mkdir + an `owner.json` written O_EXCL,
* reclaimed only when the owning process is provably gone), with three differences it needs:
* - waiting is ASYNC — the daemon takes this lock on its own event loop during a handoff, and the
* registry's `Atomics.wait` would freeze it;
* - it is RE-ENTRANT within a process — `harness update` calls `launch()`, `harness start` stages
* the bundle and then calls `launch()`, and the updater's tick runs the whole handoff — each of
* those is one critical section, not two;
* - it is released on `process.exit`, because the CLI exits from a dozen places inside the section.
*
* There is deliberately NO age-based staleness. A holder is a holder for as long as its process is
* alive — a handoff supervises the new build for up to a minute and a download on a slow link can
* take longer — and the only thing an age bound would buy is letting a second spawner in while the
* first is still working, which is the bug this exists to prevent. The escape hatch is
* `harness stop`, which waits its turn and then proceeds regardless.
*/
import {
closeSync,
constants,
fsyncSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
} from 'fs'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { env } from '../config/env.js'
import { lockOwnerAlive, processStartMarker } from './processLiveness.js'
import { secureStateDirectory } from './secureState.js'
/** `login` is a forced sign-in: the daemon is stopped and the session on disk is about to change
* hands, so nothing may start a daemon — on the OLD session — until the new one is written. */
export type SpawnLockPurpose = 'start' | 'update' | 'handoff' | 'stop' | 'login'
export interface SpawnLockOwner {
pid: number
startMarker: string
token: string
purpose: SpawnLockPurpose
since: number
}
export const SPAWN_LOCK_DIR = join(env.ADAPTER_DATA_DIR, 'adapter.spawn.lock')
const OWNER_FILE = 'owner.json'
const POLL_MS = 100
export const SPAWN_LOCK_WAIT_MS = 45_000
/** A directory with no owner.json is a crash between mkdir and the O_EXCL write — a window of
* microseconds. Anything older than this with no owner is debris, not a lock. */
const OWNERLESS_STALE_MS = 5_000
/**
* The lock could not be taken. `owner` names a live holder when there is one; `reason` is set instead
* when the lock cannot be taken for a reason waiting will not fix — something that is not a lock this
* CLI made sits at the lock path — so the message can tell a human what to remove.
*/
export class SpawnLockBusyError extends Error {
constructor(readonly owner: SpawnLockOwner | null, readonly reason?: string) {
super(
reason ?? (owner ? `daemon spawn lock is held (${describeSpawnLockOwner(owner)})` : 'daemon spawn lock is busy'),
)
this.name = 'SpawnLockBusyError'
}
}
/** What to say about a failed acquire, for a command that has to explain itself and move on. */
export function describeSpawnLockFailure(error: unknown): string {
if (error instanceof SpawnLockBusyError) {
if (error.reason) return error.reason
return error.owner ? `held — the daemon is ${describeSpawnLockOwner(error.owner)}` : 'held by an unknown process'
}
return `unusable (${error instanceof Error ? error.message : String(error)})`
}
export function describeSpawnLockOwner(owner: SpawnLockOwner): string {
const verb: Record<SpawnLockPurpose, string> = {
start: 'being started', update: 'being updated', handoff: 'restarting for an update', stop: 'being stopped',
login: 'being signed in',
}
const secs = Math.max(0, Math.round((Date.now() - owner.since) / 1000))
return `${verb[owner.purpose]} by pid ${owner.pid} for ${secs}s`
}
/**
* The same failure for a person who is not debugging anything — the desktop's sign-in screen shows
* this verbatim. No pid, no lock, no seconds: what Harness is still doing on this computer, so that
* "try again in a moment" reads as advice rather than a shrug. Something that is not a lock at the
* lock path is not going to clear itself; that case sends them to the terminal, where the technical
* line (`describeSpawnLockFailure`) says what to remove.
*/
export function describeSpawnLockBusyPlainly(error: SpawnLockBusyError): string {
if (error.reason) return 'Harness cannot sign in on this computer right now. Run `harness login --force` in a terminal to see why.'
const still: Record<SpawnLockPurpose, string> = {
start: 'Harness is still starting on this computer',
update: 'Harness is still updating on this computer',
handoff: 'Harness is still restarting on this computer',
stop: 'Harness is still shutting down on this computer',
login: 'Another sign-in is already in progress on this computer',
}
return `${error.owner ? still[error.owner.purpose] : 'Harness is busy on this computer'}. Try again in a moment.`
}
let held: { token: string; purpose: SpawnLockPurpose; depth: number } | null = null
let exitHookInstalled = false
function ownerPath(): string { return join(SPAWN_LOCK_DIR, OWNER_FILE) }
function uid(): number | null {
return typeof process.getuid === 'function' ? process.getuid() : null
}
/**
* Read the owner record, refusing anything an attacker could have planted (a symlink, another
* account's directory, a loose mode). Returns null when there is no readable owner — which callers
* must NOT take as "free": it is also what a lock mid-creation looks like.
*/
export function readSpawnLockOwner(): SpawnLockOwner | null {
const me = uid()
try {
const dir = lstatSync(SPAWN_LOCK_DIR)
if (!dir.isDirectory() || dir.isSymbolicLink() || (me !== null && dir.uid !== me) || (dir.mode & 0o777) !== 0o700) {
throw new Error(`daemon spawn lock ${SPAWN_LOCK_DIR} has an unsafe owner, mode, or type`)
}
const file = lstatSync(ownerPath())
if (!file.isFile() || file.isSymbolicLink() || (me !== null && file.uid !== me) || (file.mode & 0o777) !== 0o600) {
throw new Error(`daemon spawn lock owner ${ownerPath()} has an unsafe owner, mode, or type`)
}
const raw = JSON.parse(readFileSync(ownerPath(), 'utf8')) as Partial<Record<keyof SpawnLockOwner, unknown>>
const pid = Number(raw.pid)
if (!Number.isSafeInteger(pid) || pid <= 0 || typeof raw.token !== 'string' || !raw.token) return null
return {
pid,
startMarker: typeof raw.startMarker === 'string' ? raw.startMarker : '',
token: raw.token,
purpose: isPurpose(raw.purpose) ? raw.purpose : 'start',
since: Number.isFinite(Number(raw.since)) ? Number(raw.since) : 0,
}
} catch (error) {
if (error instanceof Error && error.message.startsWith('daemon spawn lock')) throw error
return null
}
}
function isPurpose(value: unknown): value is SpawnLockPurpose {
return value === 'start' || value === 'update' || value === 'handoff' || value === 'stop' || value === 'login'
}
/** Create the lock for this process. Returns the token, or null when someone else holds it. */
function tryCreate(purpose: SpawnLockPurpose): string | null {
secureStateDirectory(env.ADAPTER_DATA_DIR)
const token = randomUUID()
let created = false
try {
mkdirSync(SPAWN_LOCK_DIR, { mode: 0o700 })
created = true
const fd = openSync(ownerPath(), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
try {
const owner: SpawnLockOwner = {
pid: process.pid, startMarker: processStartMarker(process.pid) ?? '', token, purpose, since: Date.now(),
}
writeFileSync(fd, JSON.stringify(owner))
fsyncSync(fd)
} finally { closeSync(fd) }
return token
} catch (error) {
if (created) releaseOwnedBy(token)
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
return null
}
}
function releaseOwnedBy(token: string): void {
try {
const saved = JSON.parse(readFileSync(ownerPath(), 'utf8')) as { token?: unknown }
if (saved.token === token) rmSync(SPAWN_LOCK_DIR, { recursive: true, force: true })
} catch {
// A directory we created but never got to write an owner into is ours to remove; anything else
// belongs to someone.
try {
lstatSync(ownerPath())
} catch (probe) {
if ((probe as NodeJS.ErrnoException).code === 'ENOENT') rmSync(SPAWN_LOCK_DIR, { recursive: true, force: true })
}
}
}
/** Remove a lock whose owner is gone — re-read first so a lock that changed hands meanwhile survives. */
function reclaimIfStale(owner: SpawnLockOwner): boolean {
if (lockOwnerAlive(owner.pid, owner.startMarker)) return false
try {
const current = readSpawnLockOwner()
if (current && current.pid === owner.pid && current.startMarker === owner.startMarker
&& current.token === owner.token && !lockOwnerAlive(owner.pid, owner.startMarker)) {
rmSync(SPAWN_LOCK_DIR, { recursive: true, force: true })
return true
}
} catch { /* changed or vanished under us; let the loop look again */ }
return false
}
function reclaimIfOwnerless(): boolean {
try {
const dir = lstatSync(SPAWN_LOCK_DIR)
try { lstatSync(ownerPath()); return false } catch { /* no owner file */ }
if (Date.now() - dir.mtimeMs > OWNERLESS_STALE_MS) {
rmSync(SPAWN_LOCK_DIR, { recursive: true, force: true })
return true
}
} catch { /* gone already */ }
return false
}
function releaseSync(): void {
const mine = held
held = null
if (mine) releaseOwnedBy(mine.token)
}
function installExitHook(): void {
if (exitHookInstalled) return
exitHookInstalled = true
process.once('exit', releaseSync)
}
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
/**
* Take the lock, waiting up to `waitMs` for a live holder to finish. Resolves to the release
* function. Re-entrant: a process that already holds it gets a nested handle immediately, and the
* lock is dropped when the outermost handle releases.
*
* `onWaiting` fires once, the first time a live holder is seen, so a command can say who it is
* waiting for rather than sitting silent for most of a minute.
*/
export async function acquireSpawnLock(
purpose: SpawnLockPurpose,
opts: { waitMs?: number; onWaiting?: (owner: SpawnLockOwner) => void } = {},
): Promise<() => void> {
if (held) {
held.depth += 1
return release
}
const waitMs = opts.waitMs ?? SPAWN_LOCK_WAIT_MS
const deadline = Date.now() + waitMs
let announced = false
for (;;) {
const token = tryCreate(purpose)
if (token) {
held = { token, purpose, depth: 1 }
installExitHook()
return release
}
let owner: SpawnLockOwner | null
try {
owner = readSpawnLockOwner()
} catch (error) {
// A symlink, another account's directory, a plain file where the directory should be: not a
// lock this CLI made, and not one waiting will ever turn into one. Say what is there, now —
// 45 seconds of silence before the same answer helps nobody.
throw new SpawnLockBusyError(
null,
`not a lock this CLI made: ${SPAWN_LOCK_DIR} — remove it by hand (${error instanceof Error ? error.message : error})`,
)
}
if (owner ? reclaimIfStale(owner) : reclaimIfOwnerless()) continue
if (owner && !announced) {
announced = true
opts.onWaiting?.(owner)
}
if (Date.now() >= deadline) throw new SpawnLockBusyError(owner)
await sleep(POLL_MS)
}
}
function release(): void {
if (!held) return
held.depth -= 1
if (held.depth > 0) return
releaseSync()
}
export async function withSpawnLock<T>(
purpose: SpawnLockPurpose,
fn: () => Promise<T>,
opts: { waitMs?: number; onWaiting?: (owner: SpawnLockOwner) => void } = {},
): Promise<T> {
const done = await acquireSpawnLock(purpose, opts)
try {
return await fn()
} finally {
done()
}
}