Repository navigation
Expand file tree
/
Copy pathdaemon-process.ts
More file actions
165 lines (154 loc) · 5.69 KB
/
Copy pathdaemon-process.ts
File metadata and controls
165 lines (154 loc) · 5.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import {
isProcessAlive,
readHostProcessIdentityObservations,
readProcessCommand,
readProcessStartTime,
type OwnerIdentity,
} from '@agent-device/host-kit/process';
import { sleep } from '@agent-device/host-kit/retry';
const DAEMON_COMMAND_PATTERNS = [
/\/dist\/src\/daemon\.js($|[\s"'])/,
/\/dist\/src\/internal\/daemon\.js($|[\s"'])/,
/\/src\/daemon\.ts($|[\s"'])/,
];
/**
* Identity is the daemon entry path, never the checkout's directory name: a
* git worktree is named after its branch, so a `agent-device` substring gate
* classified every worktree daemon as "not ours" (#1545). The pid always
* comes from our own daemon.json/daemon.lock alongside the processStartTime
* recorded with it. Missing identity must fail closed so a stale PID cannot
* be authorized by the path match alone.
*/
export function isAgentDeviceDaemonCommand(command: string): boolean {
const normalized = command.toLowerCase().replaceAll('\\', '/');
return DAEMON_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isAgentDeviceDaemonProcess(
pid: number,
expectedStartTime: string | undefined,
): boolean {
if (!expectedStartTime) return false;
if (!isProcessAlive(pid)) return false;
const actualStartTime = readProcessStartTime(pid);
if (!actualStartTime || actualStartTime !== expectedStartTime) return false;
const command = readProcessCommand(pid);
if (!command) return false;
return isAgentDeviceDaemonCommand(command);
}
function trySignalProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ESRCH' || code === 'EPERM') return false;
throw error;
}
}
/** A daemon pinned to one process lifetime, never a bare pid. */
export type DaemonProcessIdentity = Readonly<{
pid: number;
startTime: string;
}>;
export type DaemonExitWait = {
/** The process terminated, or the host handed its pid to a different process. */
exited: boolean;
elapsedMs: number;
};
const DAEMON_EXIT_POLL_MS = 100;
/**
* Confirms termination from a released pid, a verified zombie, or a different
* readable process lifetime. An unreadable process observation is not proof.
*/
export async function waitForDaemonExit(
identity: DaemonProcessIdentity,
options: { timeoutMs: number; pollMs?: number },
): Promise<DaemonExitWait> {
const startedAt = Date.now();
const deadline = startedAt + options.timeoutMs;
const pollMs = options.pollMs ?? DAEMON_EXIT_POLL_MS;
const hasExited = (): boolean => {
if (!isProcessAlive(identity.pid)) return true;
const observed = readHostProcessIdentityObservations([identity.pid]).get(identity.pid);
return Boolean(
observed?.state.startsWith('Z') ||
(observed?.startTime && observed.startTime !== identity.startTime),
);
};
let exited = hasExited();
while (!exited && Date.now() < deadline) {
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())));
exited = hasExited();
}
return { exited, elapsedMs: Date.now() - startedAt };
}
export type DaemonTerminationResult =
/** A released bare PID, without lifetime proof or metadata cleanup authority. */
| Readonly<{ status: 'not-running' }>
| Readonly<{
status: 'exited';
identity: DaemonProcessIdentity;
mode: 'already-exited' | 'graceful' | 'forced';
}>
| Readonly<{
status: 'retained';
reason: 'missing-start-time' | 'identity-unverified' | 'signal-failed' | 'exit-timeout';
signal?: NodeJS.Signals;
}>;
/** Owns every signal and exit wait for one observed daemon lifetime. */
export async function stopDaemonProcess(
observed: Readonly<OwnerIdentity>,
options: {
mode: 'graceful' | 'force';
termTimeoutMs: number;
killTimeoutMs: number;
},
): Promise<DaemonTerminationResult> {
if (!observed.startTime?.trim()) {
if (!isProcessAlive(observed.pid)) return { status: 'not-running' };
return { status: 'retained', reason: 'missing-start-time' };
}
const identity: DaemonProcessIdentity = { pid: observed.pid, startTime: observed.startTime };
if ((await waitForDaemonExit(identity, { timeoutMs: 0 })).exited) {
return { status: 'exited', identity, mode: 'already-exited' };
}
let previousSignal: 'SIGTERM' | undefined;
if (options.mode === 'graceful') {
const result = await signalAndWaitForDaemonExit(identity, {
signal: 'SIGTERM',
timeoutMs: options.termTimeoutMs,
});
if (result.status !== 'survived') return result;
previousSignal = 'SIGTERM';
}
const result = await signalAndWaitForDaemonExit(identity, {
signal: 'SIGKILL',
timeoutMs: options.killTimeoutMs,
previousSignal,
});
return result.status === 'survived' ? { status: 'retained', reason: 'exit-timeout' } : result;
}
async function signalAndWaitForDaemonExit(
identity: DaemonProcessIdentity,
options: { signal: 'SIGTERM' | 'SIGKILL'; timeoutMs: number; previousSignal?: 'SIGTERM' },
): Promise<DaemonTerminationResult | Readonly<{ status: 'survived' }>> {
const verified = isAgentDeviceDaemonProcess(identity.pid, identity.startTime);
const signaled = verified && trySignalProcess(identity.pid, options.signal);
const mode = signaled
? options.signal === 'SIGTERM'
? 'graceful'
: 'forced'
: options.previousSignal
? 'graceful'
: 'already-exited';
const wait = await waitForDaemonExit(identity, {
timeoutMs: mode === 'already-exited' ? 0 : options.timeoutMs,
});
if (wait.exited) return { status: 'exited', identity, mode };
if (signaled) return { status: 'survived' };
return {
status: 'retained',
signal: options.signal,
reason: verified ? 'signal-failed' : 'identity-unverified',
};
}