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
142 changes: 142 additions & 0 deletions packages/daemon-contracts/src/daemon-registration-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
const processProbe = vi.hoisted(() => ({ startTimeUnreadable: false }));
vi.mock('@agent-device/host-kit/process', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/host-kit/process')>();
return {
...actual,
readProcessStartTime: (pid: number) =>
processProbe.startTimeUnreadable ? null : actual.readProcessStartTime(pid),
};
});

import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import { resolveDaemonPaths } from './daemon-resolution.ts';
import { readDaemonStatus } from './daemon-registration.ts';

const FAILED = {
provider: 'broken',
source: 'store',
package: '@example/broken',
version: '1.0.0',
state: 'failed',
error: {
code: 'INVALID_ARGS',
message: 'Plugin must export a default factory',
reason: 'invalid_plugin',
},
credentials: { variables: [], byBackend: {} },
};
const ACTIVE = {
provider: 'browserstack',
source: 'builtin',
state: 'active',
credentials: { variables: ['BROWSERSTACK_USERNAME'], byBackend: { 'ios-instance': ['A'] } },
};

function publish(stateDir: string, record: Record<string, unknown>): void {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(path.join(stateDir, 'daemon.json'), JSON.stringify(record));
}

test('a state dir without a daemon record reports no running daemon', () => {
const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-daemon-status-'));
expect(readDaemonStatus(paths)).toEqual({
state: 'stopped',
stateDir: paths.baseDir,
});
});

test('a live registered daemon reports its identity and the plugin inventory it published', () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-status-');
const owner = readCurrentOwnerIdentity();
publish(stateDir, {
token: 't',
port: 1,
pid: owner.pid,
processStartTime: owner.startTime,
version: '0.21.30',
transport: 'http',
plugins: [ACTIVE, FAILED, { state: 'unknown' }],
});

expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({
state: 'running',
stateDir,
pid: owner.pid,
version: '0.21.30',
transport: 'http',
plugins: [ACTIVE, FAILED, { state: 'unknown' }],
});
});

test('a record whose process is gone or was replaced is not a running daemon', () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-status-');
const owner = readCurrentOwnerIdentity();
publish(stateDir, {
token: 't',
port: 1,
pid: owner.pid,
processStartTime: 'Thu Jan 1 00:00:00 1970',
plugins: [ACTIVE],
});
expect(readDaemonStatus(resolveDaemonPaths(stateDir)).state).toBe('stopped');
publish(stateDir, {
token: 't',
port: 1,
pid: 2_147_483_000,
processStartTime: owner.startTime,
plugins: [ACTIVE],
});
expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toMatchObject({
state: 'stopped',
});
});

test('a live process whose start time cannot be read still counts as the running daemon', () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-status-');
const owner = readCurrentOwnerIdentity();
publish(stateDir, {
token: 't',
port: 1,
pid: owner.pid,
processStartTime: owner.startTime,
plugins: [ACTIVE],
});
processProbe.startTimeUnreadable = true;
try {
expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toMatchObject({
state: 'running',
plugins: [ACTIVE],
});
} finally {
processProbe.startTimeUnreadable = false;
}
});

test('text that is not a daemon record is invalid, not a stopped daemon', () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-status-');
for (const text of ['{ not json', '[]', 'null']) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(path.join(stateDir, 'daemon.json'), text);
expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({ state: 'invalid', stateDir });
}
});

test('a record that exists but cannot be read is unreadable, not a stopped daemon', () => {
const stateDir = mkdtempForTestSync('agent-device-daemon-status-');
publish(stateDir, { token: 't', port: 1, pid: 1, processStartTime: 'x' });
const infoPath = path.join(stateDir, 'daemon.json');
fs.chmodSync(infoPath, 0o000);
try {
if (process.getuid?.() === 0) return;
expect(readDaemonStatus(resolveDaemonPaths(stateDir))).toEqual({
state: 'unreadable',
stateDir,
});
} finally {
fs.chmodSync(infoPath, 0o600);
}
});
109 changes: 88 additions & 21 deletions packages/daemon-contracts/src/daemon-registration.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import fs from 'node:fs';
import type { ProcessLockInspection } from '@agent-device/host-kit/file';
import {
isProcessAlive,
ownerIdentityDiffers,
isProcessPid,
ownerIdentityMatches,
readProcessStartTime,
type OwnerIdentity,
} from '@agent-device/host-kit/process';
import { resolveDaemonPaths } from './daemon-resolution.ts';
import {
readDaemonRecord,
readRecordedDaemonTransport,
resolveDaemonPaths,
type DaemonPaths,
type DaemonServerMode,
} from './daemon-resolution.ts';

/**
* The daemon identity published in a state dir's `daemon.json`. It is the only
Expand Down Expand Up @@ -34,32 +41,29 @@ export function processLockHoldsDaemonIdentity(
/** The raw record's identity. A pid of `null` is a file that names no owner, not owner zero. */
type ParsedRegistration = { pid: number | null; startTime: string | null };

type DaemonRecord = Readonly<Record<string, unknown>>;

/**
* Whether `daemon.json` registers a daemon: a projection of the one record reader that adds the
* identity, so a record that names no owner is `ownerless` however it came to.
*/
function readRegistration(
infoPath: string,
):
| Readonly<{ status: 'registered'; identity: OwnerIdentity }>
| Readonly<{ status: 'registered'; identity: OwnerIdentity; record: DaemonRecord }>
| Readonly<{ status: 'absent' | 'unreadable' | 'ownerless' }> {
let raw: string;
try {
raw = fs.readFileSync(infoPath, 'utf8');
} catch (error) {
// Only a path the host reports as gone proves there is no record. A permission error, an I/O
// fault, or a read this process lost leaves a file present whose contents are unknown, which no
// caller may read as "someone removed it".
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === 'ENOENT' || code === 'ENOTDIR'
? { status: 'absent' }
: { status: 'unreadable' };
}
let parsed: ParsedRegistration;
try {
parsed = parseRegistration(JSON.parse(raw) as { pid?: unknown; processStartTime?: unknown });
} catch {
return { status: 'ownerless' };
const read = readDaemonRecord(infoPath);
if (read.status !== 'present') {
return { status: read.status === 'invalid' ? 'ownerless' : read.status };
}
const parsed = parseRegistration(read.record);
return parsed.pid === null
? { status: 'ownerless' }
: { status: 'registered', identity: { pid: parsed.pid, startTime: parsed.startTime } };
: {
status: 'registered',
identity: { pid: parsed.pid, startTime: parsed.startTime },
record: read.record,
};
}

function parseRegistration(parsed: {
Expand Down Expand Up @@ -110,6 +114,69 @@ export function readRegisteredDaemonOwnership(
return { state: 'unproven' };
}

/** What `daemon.json` says about the daemon serving a state dir, checked against the process. */
export type DaemonStatus = Readonly<{
/**
* `unreadable`: the record exists but cannot be read; `invalid`: it is not a record. Either way
* whether a daemon runs is unknown.
*/
state: 'running' | 'stopped' | 'unreadable' | 'invalid';
stateDir: string;
pid?: number;
version?: string;
transport?: DaemonServerMode;
/**
* The provider plugin inventory the running daemon published, as recorded; `readPluginInventory`
* validates it where it is shown, so this module stays out of the inventory vocabulary's closure.
*/
plugins?: unknown;
}>;

/** Whether the record exists but cannot say if a daemon runs: it is `unreadable` or `invalid`. */
export function isUnusableRecord(status: Pick<DaemonStatus, 'state'>): boolean {
return status.state === 'unreadable' || status.state === 'invalid';
}

/**
* Reads the daemon's published record without contacting it. The record counts while its pid names
* a live process, unless a readable start time differs from the recorded one: a record a crashed
* daemon left behind, or whose pid was reused, is `stopped`, and a failed start-time read does not
* end the record.
*/
export function readDaemonStatus(paths: DaemonPaths): DaemonStatus {
const base = { stateDir: paths.baseDir } as const;
const read = readDaemonRecord(paths.infoPath);
if (read.status !== 'present') {
return read.status === 'absent'
? { state: 'stopped', ...base }
: { state: read.status, ...base };
}
const { record } = read;
const parsed = parseRegistration(record);
if (parsed.pid === null) return { state: 'stopped', ...base };
const identity = { pid: parsed.pid, startTime: parsed.startTime };
if (identity.startTime === null || !isProcessAlive(identity.pid)) {
return { state: 'stopped', ...base };
}
if (
ownerIdentityDiffers(identity, {
pid: identity.pid,
startTime: readProcessStartTime(identity.pid),
})
) {
return { state: 'stopped', ...base };
}
const transport = readRecordedDaemonTransport(record.transport);
return {
state: 'running',
stateDir: paths.baseDir,
pid: identity.pid,
...(typeof record.version === 'string' ? { version: record.version } : {}),
...(transport ? { transport } : {}),
plugins: record.plugins,
};
}

function readableStartTime(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
Expand Down
34 changes: 34 additions & 0 deletions packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,40 @@ function resolveRealPath(filePath: string): string {
}
}

/** What `daemon.json` holds: no file, a file that cannot be read, text that is not a record, or the record. */
export type DaemonRecordRead =
| Readonly<{ status: 'absent' | 'unreadable' | 'invalid' }>
| Readonly<{ status: 'present'; record: Readonly<Record<string, unknown>> }>;

/**
* The one reader of `daemon.json`; registration, status and the client's connection info are
* projections of it. Only a path the host reports as gone proves there is no record: a permission
* error or I/O fault leaves a file whose contents are unknown, which no caller may read as "someone
* removed it".
*/
export function readDaemonRecord(infoPath: string): DaemonRecordRead {
let raw: string;
try {
raw = fs.readFileSync(infoPath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
return { status: code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'unreadable' };
}
try {
const parsed: unknown = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? { status: 'present', record: parsed as Record<string, unknown> }
: { status: 'invalid' };
} catch {
return { status: 'invalid' };
}
}

/** The transport a daemon recorded in `daemon.json`; anything else is no recorded transport. */
export function readRecordedDaemonTransport(value: unknown): DaemonServerMode | undefined {
return value === 'socket' || value === 'http' || value === 'dual' ? value : undefined;
}

export function resolveDaemonServerMode(raw: string | undefined): DaemonServerMode {
const normalized = (raw ?? '').trim().toLowerCase();
if (normalized === 'http') return 'http';
Expand Down
Loading
Loading