Skip to content
Merged
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
44 changes: 41 additions & 3 deletions docs/adr/0030-process-lock-exclusion.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ guard cannot constrain legacy code after its final check. The support boundary t
requires deployment control; host-kit does not claim to detect or evict every legacy user.

Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and
refuse legacy files rather than automatically reclaiming them. Its startup tests must cover
an already-running older daemon and concurrent old/new startup. This does not expand the
host-kit mixed-protocol support contract.
refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an
exclusive file; a hardened daemon creates a directory at that same path. The old acquisition
cannot unlink a directory, and the new acquisition retains an existing file.

The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real
children using the c237027737 legacy acquisition and the current owner. They cover an older
daemon already running, a hardened owner waiting to publish metadata, and concurrent startup.
The client refuses an older registration before signaling or changing it. This proves the daemon
cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still
require the deployment controls above.

## Recovering retained legacy daemon state

Stop every daemon and client using the affected state directory, including older versions,
and verify that those processes have exited. Keep them stopped throughout recovery.
Only then remove the legacy `daemon.lock` file and any retained `daemon.reclaim.lock` guard
from that directory. Preserve the other state, logs and repair evidence.

Start one supported version using that directory and prevent older clients or daemons from
returning. If every user cannot be identified and stopped, retain the files and use a separate
state directory. This procedure applies to daemon registration; shared build and cache locks
require the same confirmation for every process using their own paths.

## Registration operations

[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected
metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout
reset and manual stop await its result. Abandoned recovery uses the same protected retirement
sequence without signaling a live process. Daemon publication and shutdown use functions bound
to their acquired claim.

```mermaid
flowchart LR
C[Client lifecycle and timeout] --> R[Shared retirement]
M[Manual stop] --> R
P[Abandoned recovery and pruning] --> R
R --> L[Acquired registration claim]
D[Daemon startup and shutdown] --> O[Functions bound to own claim]
O --> L
L --> F[Protected metadata and reports]
```
10 changes: 10 additions & 0 deletions packages/host-kit/src/internal/owner-identity-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,13 @@ test('a missing entry in a completed process snapshot stays fail-closed without
assert.equal(mockIsProcessZombie.mock.calls.length, 0);
assert.equal(mockReadProcessStartTime.mock.calls.length, 0);
});

test('a pid outside the native range is unknown without a liveness probe', () => {
assert.equal(
classifyOwnerLiveness({ owner: { pid: 2_147_483_648, startTime: 'start-a' } }),
'unknown',
);
assert.equal(mockIsProcessAlive.mock.calls.length, 0);
assert.equal(mockIsProcessZombie.mock.calls.length, 0);
assert.equal(mockReadProcessStartTime.mock.calls.length, 0);
});
11 changes: 10 additions & 1 deletion packages/host-kit/src/internal/owner-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export type OwnerIdentity = {
startTime: string | null;
};

/** A process id accepted by Node's native signal API. */
export function isProcessPid(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 0x7fff_ffff;
}

export type OwnerLiveness =
| 'live'
| 'owner-process-dead'
Expand Down Expand Up @@ -75,6 +80,7 @@ export function classifyOwnerLivenessFromObservation(
observation?: HostProcessIdentityObservation | null,
): OwnerLiveness {
const { owner, stateDir } = params;
if (!isProcessPid(owner.pid)) return 'unknown';
if (!isProcessAlive(owner.pid)) return 'owner-process-dead';
if (observation !== undefined ? observation?.state.startsWith('Z') : isProcessZombie(owner.pid)) {
return 'owner-process-dead';
Expand All @@ -88,7 +94,10 @@ export function classifyOwnerLivenessFromObservation(
return 'owner-process-reused';
}
}
if (!stateDir) return 'live';
return stateDir ? classifyOwnerStateDirectory(stateDir) : 'live';
}

function classifyOwnerStateDirectory(stateDir: string): OwnerLiveness {
try {
fs.statSync(stateDir);
return 'live';
Expand Down
50 changes: 49 additions & 1 deletion packages/host-kit/src/internal/process-lock.fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import fs from 'node:fs';
import path from 'node:path';
import { vi } from 'vitest';
import { readProcessStartTime } from './host-process.ts';
import type { ProcessLockOwner } from './process-lock.ts';
import type { ProcessLockOwner, ProcessLockOwnerRecord } from './process-lock.ts';

export function writeLockOwnerFixture(
lockDirPath: string,
owner: ProcessLockOwner | ProcessLockOwnerRecord,
): void {
fs.mkdirSync(lockDirPath);
fs.writeFileSync(path.join(lockDirPath, 'owner.json'), JSON.stringify(owner));
}

export function writeDeadLockFixture(lockDirPath: string, acquiredAtMs = Date.now()): void {
writeLockOwnerFixture(lockDirPath, { pid: 999_999_999, startTime: null, acquiredAtMs });
}

export function failUnlinkForPath(filePath: string, error: Error) {
const unlink = fs.unlinkSync;
return vi.spyOn(fs, 'unlinkSync').mockImplementation((target) => {
if (String(target) === filePath) throw error;
return unlink(target);
});
}

export function currentProcessOwner(): ProcessLockOwner {
return {
Expand Down Expand Up @@ -41,3 +62,30 @@ export function onFirstGuardOpen(mutexPath: string, action: () => void) {
}) as typeof fs.openSync);
return { fired: () => fired, restore: () => spy.mockRestore() };
}

export const UNINFORMATIVE_OWNER_RECORDS = [
'{ pid: ',
'null',
'"999999999"',
'{"pid":"999999999","startTime":null,"acquiredAtMs":1}',
'{"pid":0,"startTime":null,"acquiredAtMs":1}',
'{"pid":999999999,"startTime":7,"acquiredAtMs":1}',
'{"pid":999999999,"startTime":null}',
] as const;

function failRenameForPath(filePath: string, error: Error) {
const rename = fs.renameSync;
return vi.spyOn(fs, 'renameSync').mockImplementation((source, destination) => {
if (String(destination) === filePath) throw error;
return rename(source, destination);
});
}

export function failLockOwnerPublication(lockDirPath: string, releaseFails: boolean) {
const primary = Object.assign(new Error('publication failed'), { code: 'EIO' });
const releaseError = Object.assign(new Error('guard unlink refused'), { code: 'EPERM' });
const renameSpy = failRenameForPath(path.join(lockDirPath, 'owner.json'), primary);
const guardPath = lockDirPath.replace(/\.lock$/, '.reclaim.lock');
const unlinkSpy = releaseFails ? failUnlinkForPath(guardPath, releaseError) : undefined;
return { primary, renameSpy, unlinkSpy, guardPath };
}
Loading
Loading