@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
- package/dist/cli-adapters/pty-transport.d.ts +2 -1
- package/dist/commands/process-lifecycle.d.ts +43 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +12 -1
- package/dist/index.js +470 -147
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +479 -156
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/cli-adapters/cli-state-engine.ts +204 -3
- package/src/cli-adapters/provider-cli-adapter.ts +39 -5
- package/src/cli-adapters/pty-transport.d.ts +2 -1
- package/src/cli-adapters/pty-transport.ts +1 -1
- package/src/cli-adapters/session-host-transport.ts +8 -3
- package/src/commands/process-lifecycle.ts +248 -0
- package/src/commands/upgrade-helper.ts +29 -68
- package/src/commands/windows-atomic-upgrade.ts +96 -4
- package/src/session-host/managed-host.ts +34 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execFileSync, type ExecFileSyncOptions } from 'child_process';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface ProcessLifecycleOptions {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
execFileSync?: typeof import('child_process').execFileSync;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OwnedProcessInfo {
|
|
10
|
+
pid: number;
|
|
11
|
+
commandLine: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function defaultExecFileSync(): typeof import('child_process').execFileSync {
|
|
15
|
+
return execFileSync;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getWindowsProcessCommandLine(
|
|
19
|
+
pid: number,
|
|
20
|
+
exec: typeof import('child_process').execFileSync,
|
|
21
|
+
): string | null {
|
|
22
|
+
const pidFilter = `ProcessId=${pid}`;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const psOut = exec('powershell.exe', [
|
|
26
|
+
'-NoProfile',
|
|
27
|
+
'-NonInteractive',
|
|
28
|
+
'-ExecutionPolicy', 'Bypass',
|
|
29
|
+
'-Command',
|
|
30
|
+
`(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
|
|
31
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
32
|
+
const text = String(psOut).trim();
|
|
33
|
+
if (text) return text;
|
|
34
|
+
} catch {
|
|
35
|
+
// fall through to wmic fallback
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const wmicOut = exec('wmic', [
|
|
40
|
+
'process', 'where', pidFilter, 'get', 'CommandLine',
|
|
41
|
+
], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
42
|
+
const text = String(wmicOut).trim();
|
|
43
|
+
if (text) return text;
|
|
44
|
+
} catch {
|
|
45
|
+
// noop
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getProcessCommandLine(
|
|
52
|
+
pid: number,
|
|
53
|
+
options: ProcessLifecycleOptions = {},
|
|
54
|
+
): string | null {
|
|
55
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
56
|
+
const exec = options.execFileSync ?? defaultExecFileSync();
|
|
57
|
+
const platform = options.platform ?? process.platform;
|
|
58
|
+
|
|
59
|
+
if (platform === 'win32') {
|
|
60
|
+
return getWindowsProcessCommandLine(pid, exec);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const text = String(exec('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
timeout: 3000,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
68
|
+
})).trim();
|
|
69
|
+
return text || null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract the script argument (the token after the node executable) from a
|
|
77
|
+
* command line. Handles both quoted and unquoted executables/scripts.
|
|
78
|
+
*/
|
|
79
|
+
export function parseNodeScriptPath(commandLine: string | null): string | null {
|
|
80
|
+
if (!commandLine) return null;
|
|
81
|
+
let rest = commandLine.trim();
|
|
82
|
+
|
|
83
|
+
// Skip the leading executable token.
|
|
84
|
+
if (rest.startsWith('"')) {
|
|
85
|
+
const end = rest.indexOf('"', 1);
|
|
86
|
+
if (end === -1) return null;
|
|
87
|
+
rest = rest.slice(end + 1).trim();
|
|
88
|
+
} else {
|
|
89
|
+
const idx = rest.search(/\s/);
|
|
90
|
+
if (idx === -1) return null;
|
|
91
|
+
rest = rest.slice(idx + 1).trim();
|
|
92
|
+
}
|
|
93
|
+
if (!rest) return null;
|
|
94
|
+
|
|
95
|
+
if (rest.startsWith('"')) {
|
|
96
|
+
const end = rest.indexOf('"', 1);
|
|
97
|
+
return end === -1 ? rest.slice(1) : rest.slice(1, end);
|
|
98
|
+
}
|
|
99
|
+
const idx = rest.search(/\s/);
|
|
100
|
+
return idx === -1 ? rest : rest.slice(0, idx);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeWindowsPath(value: string): string {
|
|
104
|
+
return value.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isCommandLineUnderPrefix(commandLine: string | null, prefix: string): boolean {
|
|
108
|
+
if (!commandLine) return false;
|
|
109
|
+
const needle = normalizeWindowsPath(prefix);
|
|
110
|
+
// Command-line paths may quote Windows separators as either single or
|
|
111
|
+
// doubled backslashes; collapse doubles before matching.
|
|
112
|
+
const haystack = commandLine.split('\\\\').join('\\').toLowerCase();
|
|
113
|
+
return haystack.includes(needle);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function killProcess(pid: number, options: ProcessLifecycleOptions = {}): boolean {
|
|
117
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
118
|
+
const exec = options.execFileSync ?? defaultExecFileSync();
|
|
119
|
+
const platform = options.platform ?? process.platform;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (platform === 'win32') {
|
|
123
|
+
exec('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
windowsHide: true,
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
process.kill(pid, 'SIGTERM');
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
while (Date.now() - start < timeoutMs) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid, 0);
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
142
|
+
} catch {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* List Node processes whose command line places them under any of the supplied
|
|
151
|
+
* prefixes. Windows-only — the versioned-prefix lifecycle this supports does not
|
|
152
|
+
* exist on POSIX, so the helper is a no-op there.
|
|
153
|
+
*/
|
|
154
|
+
export function listOwnedNodeProcesses(options: {
|
|
155
|
+
prefixes: readonly string[];
|
|
156
|
+
excludePids?: readonly number[];
|
|
157
|
+
markers?: readonly string[];
|
|
158
|
+
} & ProcessLifecycleOptions): OwnedProcessInfo[] {
|
|
159
|
+
const platform = options.platform ?? process.platform;
|
|
160
|
+
if (platform !== 'win32') return [];
|
|
161
|
+
|
|
162
|
+
const exec = options.execFileSync ?? defaultExecFileSync();
|
|
163
|
+
const prefixes = options.prefixes.map((p) => normalizeWindowsPath(p));
|
|
164
|
+
const exclude = new Set((options.excludePids ?? []).filter((n) => Number.isFinite(n) && n > 0));
|
|
165
|
+
// Command-line paths use Windows separators; normalize marker separators the
|
|
166
|
+
// same way so a marker like "dist/cli/index.js" matches "dist\cli\index.js".
|
|
167
|
+
const markers = (options.markers ?? []).map((m) => m.toLowerCase().replace(/\//g, '\\'));
|
|
168
|
+
|
|
169
|
+
let pids: number[] = [];
|
|
170
|
+
try {
|
|
171
|
+
const out = String(exec('powershell.exe', [
|
|
172
|
+
'-NoProfile',
|
|
173
|
+
'-NonInteractive',
|
|
174
|
+
'-ExecutionPolicy', 'Bypass',
|
|
175
|
+
'-Command',
|
|
176
|
+
'Get-Process node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id | ConvertTo-Json -Compress',
|
|
177
|
+
], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
|
|
178
|
+
if (out) {
|
|
179
|
+
const parsed = JSON.parse(out);
|
|
180
|
+
pids = Array.isArray(parsed) ? parsed : [parsed];
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const results: OwnedProcessInfo[] = [];
|
|
187
|
+
for (const pid of pids) {
|
|
188
|
+
if (!Number.isFinite(pid) || pid <= 0 || exclude.has(pid)) continue;
|
|
189
|
+
const commandLine = getProcessCommandLine(pid, { platform, execFileSync: exec });
|
|
190
|
+
if (!commandLine) continue;
|
|
191
|
+
const lower = commandLine.toLowerCase();
|
|
192
|
+
const underPrefix = prefixes.some((prefix) => lower.includes(prefix));
|
|
193
|
+
if (!underPrefix) continue;
|
|
194
|
+
if (markers.length > 0 && !markers.some((marker) => lower.includes(marker))) continue;
|
|
195
|
+
results.push({ pid, commandLine });
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function stopOwnedProcesses(options: {
|
|
201
|
+
processes: readonly OwnedProcessInfo[];
|
|
202
|
+
waitMs?: number;
|
|
203
|
+
} & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
|
|
204
|
+
const waitMs = options.waitMs ?? 15_000;
|
|
205
|
+
const killed = new Set<number>();
|
|
206
|
+
|
|
207
|
+
for (const p of options.processes) {
|
|
208
|
+
if (killProcess(p.pid, options)) killed.add(p.pid);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const survivors: OwnedProcessInfo[] = [];
|
|
212
|
+
for (const p of options.processes) {
|
|
213
|
+
if (!killed.has(p.pid)) {
|
|
214
|
+
survivors.push(p);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const exited = await waitForPidExit(p.pid, waitMs);
|
|
218
|
+
if (!exited) survivors.push(p);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A process counts as stopped only when it left the process table; a failed
|
|
222
|
+
// kill or a post-kill timeout both land it in `survivors`.
|
|
223
|
+
return { stopped: options.processes.length - survivors.length, survivors };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function stopOwnedProcessesForPrefixes(options: {
|
|
227
|
+
prefixes: readonly string[];
|
|
228
|
+
excludePids?: readonly number[];
|
|
229
|
+
markers?: readonly string[];
|
|
230
|
+
waitMs?: number;
|
|
231
|
+
log?: (message: string) => void;
|
|
232
|
+
} & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
|
|
233
|
+
const processes = listOwnedNodeProcesses(options);
|
|
234
|
+
if (processes.length === 0) return { stopped: 0, survivors: [] };
|
|
235
|
+
|
|
236
|
+
options.log?.(`Stopping ${processes.length} owned process(es) under prefixes: ${options.prefixes.join(', ')}`);
|
|
237
|
+
const result = await stopOwnedProcesses({
|
|
238
|
+
processes,
|
|
239
|
+
waitMs: options.waitMs,
|
|
240
|
+
platform: options.platform,
|
|
241
|
+
execFileSync: options.execFileSync,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (result.survivors.length > 0) {
|
|
245
|
+
options.log?.(`Could not stop ${result.survivors.length} owned process(es): ${result.survivors.map((s) => s.pid).join(', ')}`);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
@@ -4,11 +4,18 @@ import * as fs from 'fs';
|
|
|
4
4
|
import * as os from 'os';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import {
|
|
7
|
+
ADHDEV_OWNED_MARKERS,
|
|
7
8
|
createDefaultWindowsAtomicHooks,
|
|
8
9
|
findPortableNode22,
|
|
9
10
|
performWindowsAtomicUpgrade,
|
|
10
11
|
resolveWindowsInstallerLayout,
|
|
11
12
|
} from './windows-atomic-upgrade.js';
|
|
13
|
+
import {
|
|
14
|
+
getProcessCommandLine,
|
|
15
|
+
killProcess,
|
|
16
|
+
stopOwnedProcessesForPrefixes,
|
|
17
|
+
waitForPidExit,
|
|
18
|
+
} from './process-lifecycle.js';
|
|
12
19
|
|
|
13
20
|
const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
|
|
14
21
|
|
|
@@ -235,77 +242,11 @@ export function execNpmCommandSync(
|
|
|
235
242
|
);
|
|
236
243
|
}
|
|
237
244
|
|
|
238
|
-
function killPid(pid: number): boolean {
|
|
239
|
-
try {
|
|
240
|
-
if (process.platform === 'win32') {
|
|
241
|
-
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
242
|
-
} else {
|
|
243
|
-
process.kill(pid, 'SIGTERM');
|
|
244
|
-
}
|
|
245
|
-
return true;
|
|
246
|
-
} catch {
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function getWindowsProcessCommandLine(pid: number): string | null {
|
|
252
|
-
const pidFilter = `ProcessId=${pid}`;
|
|
253
|
-
try {
|
|
254
|
-
const psOut = execFileSync('powershell.exe', [
|
|
255
|
-
'-NoProfile',
|
|
256
|
-
'-NonInteractive',
|
|
257
|
-
'-ExecutionPolicy', 'Bypass',
|
|
258
|
-
'-Command',
|
|
259
|
-
`(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
|
|
260
|
-
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
|
|
261
|
-
if (psOut) return psOut;
|
|
262
|
-
} catch {
|
|
263
|
-
// fall through to wmic fallback
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const wmicOut = execFileSync('wmic', [
|
|
268
|
-
'process', 'where', pidFilter, 'get', 'CommandLine',
|
|
269
|
-
], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
|
|
270
|
-
if (wmicOut) return wmicOut;
|
|
271
|
-
} catch {
|
|
272
|
-
// noop
|
|
273
|
-
}
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function getProcessCommandLine(pid: number): string | null {
|
|
278
|
-
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
279
|
-
if (process.platform === 'win32') return getWindowsProcessCommandLine(pid);
|
|
280
|
-
try {
|
|
281
|
-
const text = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
282
|
-
encoding: 'utf8',
|
|
283
|
-
timeout: 3000,
|
|
284
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
285
|
-
}).trim();
|
|
286
|
-
return text || null;
|
|
287
|
-
} catch {
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
245
|
function isManagedSessionHostPid(pid: number): boolean {
|
|
293
246
|
const commandLine = getProcessCommandLine(pid);
|
|
294
247
|
return !!commandLine && /session-host-daemon/i.test(commandLine);
|
|
295
248
|
}
|
|
296
249
|
|
|
297
|
-
async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
|
|
298
|
-
const start = Date.now();
|
|
299
|
-
while (Date.now() - start < timeoutMs) {
|
|
300
|
-
try {
|
|
301
|
-
process.kill(pid, 0);
|
|
302
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
303
|
-
} catch {
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
250
|
export async function stopSessionHostProcesses(appName: string): Promise<void> {
|
|
310
251
|
const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
|
|
311
252
|
let killedPid: number | null = null;
|
|
@@ -313,7 +254,7 @@ export async function stopSessionHostProcesses(appName: string): Promise<void> {
|
|
|
313
254
|
if (fs.existsSync(pidFile)) {
|
|
314
255
|
const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
|
315
256
|
if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
|
|
316
|
-
if (
|
|
257
|
+
if (killProcess(pid)) killedPid = pid;
|
|
317
258
|
}
|
|
318
259
|
}
|
|
319
260
|
} catch {
|
|
@@ -430,7 +371,7 @@ export async function stopForeignNativeAddonHolders(
|
|
|
430
371
|
appendUpgradeLog(
|
|
431
372
|
`Foreign native-addon holder found: pid ${holder.pid}${holder.commandLine ? ` — ${holder.commandLine}` : ''}`,
|
|
432
373
|
);
|
|
433
|
-
const killed =
|
|
374
|
+
const killed = killProcess(holder.pid);
|
|
434
375
|
if (killed) {
|
|
435
376
|
await waitForPidExit(holder.pid, 15000);
|
|
436
377
|
appendUpgradeLog(`Terminated foreign native-addon holder pid ${holder.pid}`);
|
|
@@ -600,11 +541,31 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
|
|
|
600
541
|
throw new Error(`portable Node.js 22 npm CLI is missing: ${npmCliPath}`);
|
|
601
542
|
}
|
|
602
543
|
appendUpgradeLog(`Installer-managed pointer layout detected; active prefix will remain untouched: ${windowsInstallerLayout.activePrefix}`);
|
|
544
|
+
|
|
545
|
+
// Terminate any ADHDev-owned process still executing from the current active
|
|
546
|
+
// prefix or the legacy stable shim tree before activation. The parent daemon
|
|
547
|
+
// and this helper itself are excluded: the parent is already exiting, and the
|
|
548
|
+
// helper must survive to complete the upgrade.
|
|
549
|
+
const upgradePids = [process.pid, payload.parentPid].filter((n): n is number => Number.isFinite(n) && n > 0);
|
|
550
|
+
const preStop = await stopOwnedProcessesForPrefixes({
|
|
551
|
+
prefixes: [windowsInstallerLayout.activePrefix, windowsInstallerLayout.stablePrefix],
|
|
552
|
+
excludePids: upgradePids,
|
|
553
|
+
markers: Array.from(ADHDEV_OWNED_MARKERS),
|
|
554
|
+
waitMs: 15_000,
|
|
555
|
+
log: appendUpgradeLog,
|
|
556
|
+
});
|
|
557
|
+
if (preStop.survivors.length > 0) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`Cannot upgrade: owned processes still running under current prefix: ${preStop.survivors.map((s) => s.pid).join(', ')}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
603
563
|
await performWindowsAtomicUpgrade({
|
|
604
564
|
layout: windowsInstallerLayout,
|
|
605
565
|
packageName: payload.packageName,
|
|
606
566
|
targetVersion: payload.targetVersion,
|
|
607
567
|
portableNode,
|
|
568
|
+
excludePids: upgradePids,
|
|
608
569
|
hooks: createDefaultWindowsAtomicHooks({
|
|
609
570
|
packageName: payload.packageName,
|
|
610
571
|
targetVersion: payload.targetVersion,
|
|
@@ -2,11 +2,21 @@ import { execFileSync, spawn, spawnSync, type ChildProcess } from 'child_process
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as http from 'http';
|
|
4
4
|
import * as path from 'path';
|
|
5
|
+
import { stopOwnedProcessesForPrefixes } from './process-lifecycle.js';
|
|
5
6
|
|
|
6
7
|
const POINTER_NAME = '.adhdev-current';
|
|
7
8
|
const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
|
|
8
9
|
const DEFAULT_HEALTH_PORT = 19222;
|
|
9
10
|
|
|
11
|
+
// Markers that identify a Node process as ADHDev-owned when its command line
|
|
12
|
+
// also lives under a versioned install prefix. This deliberately excludes
|
|
13
|
+
// arbitrary user scripts that happen to be located under ~/.adhdev.
|
|
14
|
+
export const ADHDEV_OWNED_MARKERS = [
|
|
15
|
+
'session-host-daemon',
|
|
16
|
+
'node_modules/adhdev',
|
|
17
|
+
'node_modules/@adhdev/daemon-standalone',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
10
20
|
export interface WindowsInstallerLayout {
|
|
11
21
|
homeDir: string;
|
|
12
22
|
installRoot: string;
|
|
@@ -22,7 +32,7 @@ export interface WindowsAtomicUpgradeHooks {
|
|
|
22
32
|
restartOld: (portableNode: string) => void;
|
|
23
33
|
waitForHealth: (pid: number, targetVersion: string) => Promise<boolean>;
|
|
24
34
|
stopProcess: (pid: number) => void;
|
|
25
|
-
cleanup: (layout: WindowsInstallerLayout, activePrefix: string) => void
|
|
35
|
+
cleanup: (layout: WindowsInstallerLayout, activePrefix: string) => void | Promise<void>;
|
|
26
36
|
log: (message: string) => void;
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -32,6 +42,8 @@ export interface WindowsAtomicUpgradeOptions {
|
|
|
32
42
|
targetVersion: string;
|
|
33
43
|
portableNode: string;
|
|
34
44
|
hooks: WindowsAtomicUpgradeHooks;
|
|
45
|
+
/** PIDs that must never be terminated during prefix sweeps (helper + parent daemon). */
|
|
46
|
+
excludePids?: number[];
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export interface WindowsAtomicUpgradeResult {
|
|
@@ -252,6 +264,25 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
|
|
|
252
264
|
validateStagedCli(portableNode, stagedCliEntry, targetVersion);
|
|
253
265
|
hooks.log(`Validated staged CLI and portable Node 22 shims in ${stagedPrefix}`);
|
|
254
266
|
|
|
267
|
+
// Stop every ADHDev-owned process still executing from the current active
|
|
268
|
+
// prefix or the legacy stable shim tree before moving the pointer. A stale
|
|
269
|
+
// session-host left running here keeps node-pty's native addon mapped from
|
|
270
|
+
// the old tree and resolves lazy requires against a deleted prefix after
|
|
271
|
+
// activation. Survivors block activation so the pointer is never corrupted.
|
|
272
|
+
const excludedPids = new Set([process.pid, ...(options.excludePids ?? [])].filter((n) => Number.isFinite(n) && n > 0));
|
|
273
|
+
const preStop = await stopOwnedProcessesForPrefixes({
|
|
274
|
+
prefixes: [layout.activePrefix, layout.stablePrefix],
|
|
275
|
+
excludePids: Array.from(excludedPids),
|
|
276
|
+
markers: Array.from(ADHDEV_OWNED_MARKERS),
|
|
277
|
+
waitMs: 15_000,
|
|
278
|
+
log: hooks.log,
|
|
279
|
+
});
|
|
280
|
+
if (preStop.survivors.length > 0) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`Cannot activate ${versionName}: ${preStop.survivors.length} owned process(es) still running under the current prefix`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
255
286
|
activated = true;
|
|
256
287
|
publishStableShimsAndPointer(layout, versionName);
|
|
257
288
|
hooks.log(`Atomically activated ${versionName}`);
|
|
@@ -261,7 +292,7 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
|
|
|
261
292
|
throw new Error('replacement daemon did not pass the health/version gate');
|
|
262
293
|
}
|
|
263
294
|
hooks.log(`Replacement daemon pid ${daemonPid} passed health for ${targetVersion}`);
|
|
264
|
-
hooks.cleanup(layout, stagedPrefix);
|
|
295
|
+
await hooks.cleanup(layout, stagedPrefix);
|
|
265
296
|
return { stagedPrefix, stagedCliEntry, daemonPid };
|
|
266
297
|
} catch (error) {
|
|
267
298
|
if (restarted?.pid) hooks.stopProcess(restarted.pid);
|
|
@@ -274,7 +305,7 @@ export async function performWindowsAtomicUpgrade(options: WindowsAtomicUpgradeO
|
|
|
274
305
|
}
|
|
275
306
|
}
|
|
276
307
|
try { hooks.restartOld(portableNode); } catch { hooks.log('Failed to restart the previous daemon during rollback'); }
|
|
277
|
-
try { hooks.cleanup(layout, layout.activePrefix); } catch { /* failure path must preserve original error */ }
|
|
308
|
+
try { await hooks.cleanup(layout, layout.activePrefix); } catch { /* failure path must preserve original error */ }
|
|
278
309
|
throw error;
|
|
279
310
|
}
|
|
280
311
|
}
|
|
@@ -353,7 +384,14 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
353
384
|
stopProcess: (pid) => {
|
|
354
385
|
try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { /* noop */ }
|
|
355
386
|
},
|
|
356
|
-
cleanup: (layout, activePrefix) =>
|
|
387
|
+
cleanup: async (layout, activePrefix) => cleanupInactivePrefixesWithGuard({
|
|
388
|
+
layout,
|
|
389
|
+
activePrefix,
|
|
390
|
+
excludePids: [process.pid],
|
|
391
|
+
markers: Array.from(ADHDEV_OWNED_MARKERS),
|
|
392
|
+
waitMs: 15_000,
|
|
393
|
+
log: options.log,
|
|
394
|
+
}),
|
|
357
395
|
log: options.log,
|
|
358
396
|
};
|
|
359
397
|
}
|
|
@@ -383,3 +421,57 @@ export function boundedCleanupInactivePrefixes(
|
|
|
383
421
|
], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
|
|
384
422
|
if (result.error || result.status !== 0) log('Bounded inactive-prefix cleanup was incomplete; future updates will retry');
|
|
385
423
|
}
|
|
424
|
+
|
|
425
|
+
export async function cleanupInactivePrefixesWithGuard(options: {
|
|
426
|
+
layout: WindowsInstallerLayout;
|
|
427
|
+
activePrefix: string;
|
|
428
|
+
excludePids?: number[];
|
|
429
|
+
markers?: readonly string[];
|
|
430
|
+
waitMs?: number;
|
|
431
|
+
log?: (message: string) => void;
|
|
432
|
+
}): Promise<void> {
|
|
433
|
+
const { layout, activePrefix, log } = options;
|
|
434
|
+
let candidates: string[] = [];
|
|
435
|
+
try {
|
|
436
|
+
candidates = fs.readdirSync(layout.installRoot, { withFileTypes: true })
|
|
437
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('version-'))
|
|
438
|
+
.map((entry) => path.join(layout.installRoot, entry.name))
|
|
439
|
+
.filter((entry) => normalizeForCompare(entry) !== normalizeForCompare(activePrefix))
|
|
440
|
+
.sort()
|
|
441
|
+
.slice(0, 8);
|
|
442
|
+
} catch {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (candidates.length === 0) return;
|
|
446
|
+
|
|
447
|
+
for (const candidate of candidates) {
|
|
448
|
+
const stopResult = await stopOwnedProcessesForPrefixes({
|
|
449
|
+
prefixes: [candidate],
|
|
450
|
+
excludePids: options.excludePids,
|
|
451
|
+
markers: options.markers,
|
|
452
|
+
waitMs: options.waitMs ?? 15_000,
|
|
453
|
+
log,
|
|
454
|
+
});
|
|
455
|
+
if (stopResult.survivors.length > 0) {
|
|
456
|
+
log?.(`Skipping cleanup of ${candidate}: ${stopResult.survivors.length} owned process(es) could not be stopped`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
removeInactivePrefix(candidate, log);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function removeInactivePrefix(target: string, log?: (message: string) => void): void {
|
|
464
|
+
try {
|
|
465
|
+
const escaped = quotePowerShellLiteral(target);
|
|
466
|
+
const script = `if (Test-Path -LiteralPath ${escaped}) { Remove-Item -LiteralPath ${escaped} -Recurse -Force }`;
|
|
467
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
|
468
|
+
const result = spawnSync('powershell.exe', [
|
|
469
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded,
|
|
470
|
+
], { timeout: 5000, windowsHide: true, stdio: 'ignore' });
|
|
471
|
+
if (result.error || result.status !== 0) {
|
|
472
|
+
log?.(`Failed to remove inactive prefix ${target}: ${result.error?.message || `exit ${result.status}`}`);
|
|
473
|
+
}
|
|
474
|
+
} catch (error: any) {
|
|
475
|
+
log?.(`Failed to remove inactive prefix ${target}: ${error?.message || String(error)}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
type SessionHostEndpoint,
|
|
9
9
|
type SessionHostRequestType,
|
|
10
10
|
} from '@adhdev/session-host-core';
|
|
11
|
+
import { getProcessCommandLine, parseNodeScriptPath } from '../commands/process-lifecycle.js';
|
|
12
|
+
import { LOG } from '../logging/logger.js';
|
|
11
13
|
import { ensureSessionHostReady as ensureSharedSessionHostReady } from './runtime-support.js';
|
|
12
14
|
import { DEFAULT_SESSION_HOST_READY_TIMEOUT_MS } from '../runtime-defaults.js';
|
|
13
15
|
|
|
@@ -97,6 +99,16 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
|
|
|
97
99
|
return require.resolve('@adhdev/session-host-daemon');
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
function pathsEquivalent(left: string, right: string): boolean {
|
|
103
|
+
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getRunningSessionHostScriptPath(pid: number): string | null {
|
|
107
|
+
const commandLine = getProcessCommandLine(pid);
|
|
108
|
+
if (!commandLine || !/session-host-daemon/i.test(commandLine)) return null;
|
|
109
|
+
return parseNodeScriptPath(commandLine);
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
function getPidFile(): string {
|
|
101
113
|
return path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
|
|
102
114
|
}
|
|
@@ -178,6 +190,28 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
|
|
|
178
190
|
|
|
179
191
|
async function ensureReady(): Promise<SessionHostEndpoint> {
|
|
180
192
|
options.beforeEnsureReady?.();
|
|
193
|
+
|
|
194
|
+
// Defensive guard: on Windows the daemon may have been restarted from a
|
|
195
|
+
// new versioned prefix while a session-host from the old prefix is still
|
|
196
|
+
// running. Because the old host is reachable on the same socket endpoint,
|
|
197
|
+
// `ensureSharedSessionHostReady` would reuse it and lazy requires would
|
|
198
|
+
// resolve against the deleted tree. Detect the mismatch and stop the stale
|
|
199
|
+
// host before it can be reused; a matching or unreadable host is left alone.
|
|
200
|
+
if (process.platform === 'win32') {
|
|
201
|
+
const existingPid = getPid();
|
|
202
|
+
if (existingPid !== null) {
|
|
203
|
+
const runningPath = getRunningSessionHostScriptPath(existingPid);
|
|
204
|
+
const currentEntry = resolveEntry();
|
|
205
|
+
if (runningPath && !pathsEquivalent(runningPath, currentEntry)) {
|
|
206
|
+
LOG.warn(
|
|
207
|
+
'SessionHost',
|
|
208
|
+
`Detected stale host pid ${existingPid} running from ${runningPath}; restarting from ${currentEntry}`,
|
|
209
|
+
);
|
|
210
|
+
stopManagedSessionHostProcess();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
181
215
|
try {
|
|
182
216
|
return await ensureSharedSessionHostReady({
|
|
183
217
|
appName,
|