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
35 changes: 28 additions & 7 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppError } from './utils/errors.ts';
import type { CommandFlags } from './core/dispatch.ts';
import { runCmdDetached } from './utils/exec.ts';
import { findProjectRoot, readVersion } from './utils/version.ts';
import { stopProcessForTakeover } from './utils/process-identity.ts';

export type DaemonRequest = {
token: string;
Expand All @@ -19,12 +20,20 @@ export type DaemonResponse =
| { ok: true; data?: Record<string, unknown> }
| { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } };

type DaemonInfo = { port: number; token: string; pid: number; version?: string };
type DaemonInfo = {
port: number;
token: string;
pid: number;
version?: string;
processStartTime?: string;
};

const baseDir = path.join(os.homedir(), '.agent-device');
const infoPath = path.join(baseDir, 'daemon.json');
const REQUEST_TIMEOUT_MS = resolveRequestTimeoutMs();
const REQUEST_TIMEOUT_MS = resolveDaemonRequestTimeoutMs();
const DAEMON_STARTUP_TIMEOUT_MS = 5000;
const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000;
const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000;

export async function sendToDaemon(req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> {
const info = await ensureDaemon();
Expand All @@ -38,6 +47,7 @@ async function ensureDaemon(): Promise<DaemonInfo> {
const existingReachable = existing ? await canConnect(existing) : false;
if (existing && existing.version === localVersion && existingReachable) return existing;
if (existing && (existing.version !== localVersion || !existingReachable)) {
await stopDaemonProcessForTakeover(existing);
removeDaemonInfo();
}

Expand All @@ -56,12 +66,23 @@ async function ensureDaemon(): Promise<DaemonInfo> {
});
}

async function stopDaemonProcessForTakeover(info: DaemonInfo): Promise<void> {
await stopProcessForTakeover(info.pid, {
termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS,
killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS,
expectedStartTime: info.processStartTime,
});
}

function readDaemonInfo(): DaemonInfo | null {
if (!fs.existsSync(infoPath)) return null;
try {
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8')) as DaemonInfo;
if (!data.port || !data.token) return null;
return data;
return {
...data,
pid: Number.isInteger(data.pid) && data.pid > 0 ? data.pid : 0,
};
} catch {
return null;
}
Expand Down Expand Up @@ -142,10 +163,10 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
});
}

function resolveRequestTimeoutMs(): number {
const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
if (!raw) return 60000;
export function resolveDaemonRequestTimeoutMs(raw: string | undefined = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS): number {
// iOS physical-device runner startup/build can exceed 60s, so use a safer default for daemon RPCs.
if (!raw) return 180000;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return 60000;
if (!Number.isFinite(parsed)) return 180000;
return Math.max(1000, Math.floor(parsed));
}
108 changes: 99 additions & 9 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
import { isCommandSupportedOnDevice } from './core/capabilities.ts';
import { asAppError, AppError } from './utils/errors.ts';
import { readVersion } from './utils/version.ts';
import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
import { stopAllIosRunnerSessions } from './platforms/ios/runner-client.ts';
import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
import { SessionStore } from './daemon/session-store.ts';
import { contextFromFlags as contextFromFlagsWithLog, type DaemonCommandContext } from './daemon/context.ts';
Expand All @@ -18,16 +18,30 @@ import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
import {
isAgentDeviceDaemonProcess,
readProcessStartTime,
} from './utils/process-identity.ts';

const baseDir = path.join(os.homedir(), '.agent-device');
const infoPath = path.join(baseDir, 'daemon.json');
const lockPath = path.join(baseDir, 'daemon.lock');
const logPath = path.join(baseDir, 'daemon.log');
const sessionsDir = path.join(baseDir, 'sessions');
const sessionStore = new SessionStore(sessionsDir);
const version = readVersion();
const token = crypto.randomBytes(24).toString('hex');
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);

type DaemonLockInfo = {
pid: number;
version: string;
startedAt: number;
processStartTime?: string;
};

const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined;

function contextFromFlags(
flags: CommandFlags | undefined,
appBundleId?: string,
Expand Down Expand Up @@ -122,7 +136,7 @@ function writeInfo(port: number): void {
fs.writeFileSync(logPath, '');
fs.writeFileSync(
infoPath,
JSON.stringify({ port, token, pid: process.pid, version }, null, 2),
JSON.stringify({ port, token, pid: process.pid, version, processStartTime: daemonProcessStartTime }, null, 2),
{
mode: 0o600,
},
Expand All @@ -133,7 +147,73 @@ function removeInfo(): void {
if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath);
}

function readLockInfo(): DaemonLockInfo | null {
if (!fs.existsSync(lockPath)) return null;
try {
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as DaemonLockInfo;
if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
return parsed;
} catch {
return null;
}
}

function acquireDaemonLock(): boolean {
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true });
const lockData: DaemonLockInfo = {
pid: process.pid,
version,
startedAt: Date.now(),
processStartTime: daemonProcessStartTime,
};
const payload = JSON.stringify(lockData, null, 2);

const tryWriteLock = (): boolean => {
try {
fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 });
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false;
throw err;
}
};

if (tryWriteLock()) return true;
const existing = readLockInfo();
if (
existing?.pid
&& existing.pid !== process.pid
&& isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)
) {
return false;
}
// Best-effort stale-lock cleanup: another process may win the race between unlink and re-create.
// We rely on the subsequent write with `wx` to enforce single-writer semantics.
try {
fs.unlinkSync(lockPath);
} catch {
// ignore
}
return tryWriteLock();
}

function releaseDaemonLock(): void {
const existing = readLockInfo();
if (existing && existing.pid !== process.pid) return;
try {
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
} catch {
// ignore
}
}

function start(): void {
if (!acquireDaemonLock()) {
process.stderr.write('Daemon lock is held by another process; exiting.\n');
process.exit(0);
return;
}

const server = net.createServer((socket) => {
let buffer = '';
socket.setEncoding('utf8');
Expand Down Expand Up @@ -172,18 +252,28 @@ function start(): void {
}
});

let shuttingDown = false;
const closeServer = async (): Promise<void> => {
await new Promise<void>((resolve) => {
try {
server.close(() => resolve());
} catch {
resolve();
}
});
};
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
await closeServer();
const sessionsToStop = sessionStore.toArray();
for (const session of sessionsToStop) {
if (session.device.platform === 'ios') {
await stopIosRunnerSession(session.device.id);
}
sessionStore.writeSessionLog(session);
}
server.close(() => {
removeInfo();
process.exit(0);
});
await stopAllIosRunnerSessions();
removeInfo();
releaseDaemonLock();
process.exit(0);
};

process.on('SIGINT', () => {
Expand Down
Loading
Loading