@ctrl-spc/cs 0.7.14 → 0.7.15
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/README.md +50 -3
- package/dist/autostart.js +103 -122
- package/dist/companion-ui.js +34 -6
- package/dist/companion.js +59 -150
- package/dist/config.js +52 -1
- package/dist/daemon-lifecycle.js +548 -0
- package/dist/daemon-lock.js +149 -42
- package/dist/daemon-processes.js +756 -0
- package/dist/daemon.js +14 -46
- package/dist/darwin-coalition.js +340 -0
- package/dist/index.js +70 -74
- package/dist/login.js +5 -3
- package/dist/native/darwin-coalition +0 -0
- package/dist/native/darwin-coalition.build.json +1 -0
- package/dist/native/darwin-coalition.c +145 -0
- package/dist/orchestrator.js +620 -428
- package/dist/panel3/run.js +822 -520
- package/dist/panel3/spawn.js +59 -11
- package/dist/presence.js +183 -24
- package/dist/supabase.js +43 -9
- package/dist/win-shell.js +464 -1
- package/dist/windows-job.js +312 -0
- package/package.json +4 -3
package/dist/win-shell.js
CHANGED
|
@@ -29,7 +29,452 @@
|
|
|
29
29
|
* This module is where all three live, so a further caller cannot take one and
|
|
30
30
|
* miss the others.
|
|
31
31
|
*/
|
|
32
|
-
import { spawn } from 'node:child_process';
|
|
32
|
+
import { spawn, execFile } from 'node:child_process';
|
|
33
|
+
import { promisify } from 'node:util';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { rmSync, writeFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { ensureLifecycleDir } from './config.js';
|
|
39
|
+
import { windowsJobExecutable, windowsJobName, inspectWindowsJob, inspectWindowsProcess } from './windows-job.js';
|
|
40
|
+
import { spawnMacExecution, readMacExecution, inspectMacCoalition, inspectMacProcess, inspectMacProcesses, signalMacMember, signalMacProcess, unloadMacExecution, removeMacExecutionFiles } from './darwin-coalition.js';
|
|
41
|
+
export { windowsJobName, inspectWindowsJob } from './windows-job.js';
|
|
42
|
+
export { validMacCoalition } from './darwin-coalition.js';
|
|
43
|
+
const execute = promisify(execFile);
|
|
44
|
+
const ownedChildren = new WeakMap();
|
|
45
|
+
const posixGate = String.raw `
|
|
46
|
+
const fs = require('node:fs'); const {spawn} = require('node:child_process');
|
|
47
|
+
const file=process.argv[1]; const launch=JSON.parse(fs.readFileSync(file,'utf8')); fs.unlinkSync(file);
|
|
48
|
+
const byte=Buffer.alloc(1);
|
|
49
|
+
function gate() {
|
|
50
|
+
fs.read(0,byte,0,1,null,(error,count)=>{
|
|
51
|
+
if(error && (error.code==='EAGAIN'||error.code==='EINTR')) { setTimeout(gate,10); return; }
|
|
52
|
+
if(error) { console.error('Execution gate could not read its grant.'); process.exitCode=1; return; }
|
|
53
|
+
if(count!==1 || byte[0]!==1) return;
|
|
54
|
+
const child=spawn(launch.bin,launch.args,{cwd:launch.cwd,shell:launch.shell,stdio:[0,1,2]});
|
|
55
|
+
child.on('error',(error)=>{ console.error('The harness could not start ('+(error.code || 'unknown error')+').'); process.exitCode=1; });
|
|
56
|
+
child.on('exit',(code,signal)=>{ process.exitCode=code ?? (signal ? 1 : 0); });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
gate();
|
|
60
|
+
`;
|
|
61
|
+
/** Native executable argv quoting; .cmd arguments have already been escaped by
|
|
62
|
+
* windowsSafeSpawn and retain the existing cmd.exe /d /s /c path. */
|
|
63
|
+
function windowsArgument(value) {
|
|
64
|
+
if (value && !/[\s"]/.test(value))
|
|
65
|
+
return value;
|
|
66
|
+
return '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1') + '"';
|
|
67
|
+
}
|
|
68
|
+
/** Reserve the id durably before calling this. A gated helper cannot create a
|
|
69
|
+
* harness before register persists its OS boundary and releases the grant. */
|
|
70
|
+
export function spawnOwnedProcess(bin, args, options, reservationId) {
|
|
71
|
+
const name = windowsJobName(reservationId);
|
|
72
|
+
const file = join(ensureLifecycleDir(), `owned-launch-${reservationId}.json`);
|
|
73
|
+
if (process.platform === 'darwin') {
|
|
74
|
+
const result = spawnMacExecution(bin, args, options, reservationId, file);
|
|
75
|
+
const state = { macReady: result.ready, released: false, failed: false };
|
|
76
|
+
ownedChildren.set(result.child, state);
|
|
77
|
+
result.child.once('error', () => { state.failed = true; });
|
|
78
|
+
result.child.once('close', () => { rmSync(file, { force: true }); removeMacExecutionFiles(result.ready); });
|
|
79
|
+
return result.child;
|
|
80
|
+
}
|
|
81
|
+
let executable, argv;
|
|
82
|
+
if (process.platform === 'win32') {
|
|
83
|
+
executable = windowsJobExecutable();
|
|
84
|
+
const shell = options.shell ? (typeof options.shell === 'string' ? options.shell : process.env.ComSpec || join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe')) : null;
|
|
85
|
+
const application = shell || bin;
|
|
86
|
+
// The executable path is parsed once, before the npm shim reparses its
|
|
87
|
+
// arguments. cross-spawn's escapeCommand uses one caret pass here.
|
|
88
|
+
const shellBin = bin.replace(/([()\][%!^"`<>&|;,\s*?])/g, '^$1');
|
|
89
|
+
const command = shell ? `${windowsArgument(application)} /d /s /c "${[shellBin, ...args].join(' ')}"` : [bin, ...args].map(windowsArgument).join(' ');
|
|
90
|
+
writeFileSync(file, JSON.stringify({ application, command, cwd: options.cwd?.toString() ?? process.cwd() }), { flag: 'wx', mode: 0o600 });
|
|
91
|
+
argv = ['run', name, file];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
executable = process.execPath;
|
|
95
|
+
argv = ['-e', posixGate, file];
|
|
96
|
+
writeFileSync(file, JSON.stringify({ bin, args, cwd: options.cwd?.toString() ?? process.cwd(), shell: options.shell ?? false }), { flag: 'wx', mode: 0o600 });
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const child = spawn(executable, argv, { ...options, shell: false, detached: process.platform !== 'win32', windowsHide: true });
|
|
100
|
+
const state = { ...(process.platform === 'win32' ? { job: name } : {}), released: false, failed: false };
|
|
101
|
+
ownedChildren.set(child, state);
|
|
102
|
+
child.once('error', () => { state.failed = true; rmSync(file, { force: true }); });
|
|
103
|
+
child.once('close', () => { rmSync(file, { force: true }); });
|
|
104
|
+
return child;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
rmSync(file, { force: true });
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** null is proven pre-grant closure, never an OS-inspection failure. */
|
|
112
|
+
export async function getOwnedProcessIdentity(child, deadline = Date.now() + 5000) {
|
|
113
|
+
const gate = ownedChildren.get(child);
|
|
114
|
+
if (!gate)
|
|
115
|
+
return child.pid ? inspectProcess(child.pid, deadline) : null;
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
const identity = child.pid ? await inspectProcess(child.pid, deadline) : null;
|
|
118
|
+
const job = gate.job ? await inspectWindowsJob(gate.job, deadline) : null;
|
|
119
|
+
if (gate.macReady) {
|
|
120
|
+
gate.macCoalition ??= readMacExecution(gate.macReady) ?? undefined;
|
|
121
|
+
if (identity && gate.macCoalition) {
|
|
122
|
+
await inspectMacCoalition(gate.macCoalition, deadline);
|
|
123
|
+
gate.identity = { ...identity, macCoalition: gate.macCoalition };
|
|
124
|
+
return gate.identity;
|
|
125
|
+
}
|
|
126
|
+
if (!identity && !gate.released && (child.exitCode !== null || child.signalCode !== null || gate.failed))
|
|
127
|
+
return null;
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (identity && (!gate.job || job?.exists))
|
|
132
|
+
return { ...identity, ...(gate.job ? { windowsJob: gate.job } : {}) };
|
|
133
|
+
if (!identity && !gate.released && (child.exitCode !== null || child.signalCode !== null || gate.failed) && !job?.exists)
|
|
134
|
+
return null;
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
136
|
+
}
|
|
137
|
+
throw new Error('The harness execution boundary could not be verified before its deadline.');
|
|
138
|
+
}
|
|
139
|
+
export function releaseOwnedProcess(child) {
|
|
140
|
+
const gate = ownedChildren.get(child);
|
|
141
|
+
if (!gate || gate.released)
|
|
142
|
+
return;
|
|
143
|
+
if (!child.stdin || child.stdin.destroyed || child.exitCode !== null || child.signalCode !== null || gate.failed)
|
|
144
|
+
throw new Error('The execution gate closed before work was admitted.');
|
|
145
|
+
gate.released = true;
|
|
146
|
+
child.stdin.write(Buffer.from([1]));
|
|
147
|
+
}
|
|
148
|
+
function remaining(deadline) {
|
|
149
|
+
const ms = deadline - Date.now();
|
|
150
|
+
if (ms <= 0)
|
|
151
|
+
throw new Error('Service operation exceeded its deadline.');
|
|
152
|
+
return Math.min(ms, 5000);
|
|
153
|
+
}
|
|
154
|
+
export function processIdentityMatches(a, b) {
|
|
155
|
+
// exec and process.title may change argv without ending this process lifetime.
|
|
156
|
+
// The command hash records initial provenance; it is not a death signal.
|
|
157
|
+
// macProcessVersion changes on exec. It is an atomic signal token, not the
|
|
158
|
+
// process lifetime; native birth retains microsecond precision across exec.
|
|
159
|
+
return a.pid === b.pid && a.birth === b.birth && a.owner === b.owner;
|
|
160
|
+
}
|
|
161
|
+
/** Raw command lines are hashed here; they can contain harness credentials. */
|
|
162
|
+
async function processTable(pid, deadline, handover) {
|
|
163
|
+
if (process.platform === 'win32') {
|
|
164
|
+
const pids = pid === null ? null : [...new Set(Array.isArray(pid) ? pid : [pid])];
|
|
165
|
+
if (pids && (!pids.length || pids.some((id) => !Number.isSafeInteger(id) || id <= 0 || id > 0xffffffff)))
|
|
166
|
+
throw new Error('Invalid Windows process identity.');
|
|
167
|
+
const filter = handover ? ` -Filter \"ProcessId <> $PID AND CommandLine like '%--lifecycle-handover=${handover}%'\"` : pids === null ? '' : ` -Filter '${pids.map((id) => `ProcessId=${id}`).join(' OR ')}'`;
|
|
168
|
+
const script = `$ErrorActionPreference='Stop'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; @(Get-CimInstance Win32_Process${filter} | ForEach-Object { $p=$_; $o=Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid; if ($o.ReturnValue -eq 0 -and $o.Sid -eq $sid) { [pscustomobject]@{pid=[int]$p.ProcessId;ppid=[int]$p.ParentProcessId;owner=$o.Sid;birth=$p.CreationDate.ToUniversalTime().ToString('O');command=$p.CommandLine} } }) | ConvertTo-Json -Compress`;
|
|
169
|
+
const { stdout } = await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
170
|
+
windowsHide: true, timeout: remaining(deadline), maxBuffer: 8 * 1024 * 1024,
|
|
171
|
+
});
|
|
172
|
+
const parsed = JSON.parse(stdout.trim() || '[]');
|
|
173
|
+
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
174
|
+
return rows.map((raw) => {
|
|
175
|
+
const r = raw;
|
|
176
|
+
if (!Number.isInteger(r.pid) || !Number.isInteger(r.ppid) || typeof r.owner !== 'string' || typeof r.birth !== 'string' || typeof r.command !== 'string') {
|
|
177
|
+
throw new Error('Windows process identity is unavailable.');
|
|
178
|
+
}
|
|
179
|
+
return { pid: r.pid, ppid: r.ppid, owner: r.owner, birth: r.birth, commandHash: createHash('sha256').update(r.command).digest('hex') };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
let stdout;
|
|
183
|
+
try {
|
|
184
|
+
({ stdout } = await execute('/bin/ps', [pid === null ? '-axo' : '-o', 'pid=,ppid=,pgid=,uid=,lstart=,command=', ...(pid === null ? [] : ['-p', String(pid)])], {
|
|
185
|
+
timeout: remaining(deadline), maxBuffer: 8 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' },
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (typeof pid === 'number' && error.code === 1 && !processIsAlive(pid))
|
|
190
|
+
return [];
|
|
191
|
+
throw new Error('Could not verify the operating system process identity.', { cause: error });
|
|
192
|
+
}
|
|
193
|
+
return stdout.split('\n').filter((line) => line.trim() && (!handover || line.includes('--lifecycle-handover=' + handover))).map((line) => {
|
|
194
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\d+\s+\d\d:\d\d:\d\d\s+\d{4})\s+(.+)$/);
|
|
195
|
+
if (!match)
|
|
196
|
+
throw new Error('Operating system returned an unreadable process identity.');
|
|
197
|
+
return { pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), owner: match[4], birth: match[5].replace(/\s+/g, ' '), commandHash: createHash('sha256').update(match[6]).digest('hex') };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** Non-secret handover identity closes the spawn-before-PID-recording gap. */
|
|
201
|
+
export async function findHandoverProcesses(nonce, deadline) {
|
|
202
|
+
if (!/^[\da-f-]{36}$/i.test(nonce))
|
|
203
|
+
throw new Error('Invalid handover identity.');
|
|
204
|
+
const found = await processTable(null, deadline, nonce);
|
|
205
|
+
if (process.platform !== 'darwin')
|
|
206
|
+
return found;
|
|
207
|
+
const precise = [];
|
|
208
|
+
for (const candidate of found) {
|
|
209
|
+
const current = await inspectMacProcess(candidate.pid, deadline);
|
|
210
|
+
if (current)
|
|
211
|
+
precise.push({ ...current, commandHash: candidate.commandHash });
|
|
212
|
+
}
|
|
213
|
+
return precise;
|
|
214
|
+
}
|
|
215
|
+
export async function inspectProcess(pid, deadline = Date.now() + 5000) {
|
|
216
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
217
|
+
throw new Error('Invalid process identity.');
|
|
218
|
+
// The native query distinguishes permission failure from ESRCH and excludes
|
|
219
|
+
// zombies: an unreaped PID is no longer executing, even if kill(pid, 0) sees it.
|
|
220
|
+
if (process.platform === 'darwin')
|
|
221
|
+
return inspectMacProcess(pid, deadline);
|
|
222
|
+
const found = (await processTable(pid, deadline)).find((row) => row.pid === pid);
|
|
223
|
+
if (found)
|
|
224
|
+
return found;
|
|
225
|
+
if (processIsAlive(pid))
|
|
226
|
+
throw new Error(`Process ${pid} exists but its ownership could not be verified.`);
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
/** Follow only recorded lifetimes; a reused parent cannot acquire older children. */
|
|
230
|
+
export function ownedProcessTree(root, rows, known = []) {
|
|
231
|
+
const current = rows.find((row) => row.pid === root.pid);
|
|
232
|
+
const seeds = new Map([root, ...known].map((row) => [row.pid, row]));
|
|
233
|
+
const found = new Map();
|
|
234
|
+
for (const seed of seeds.values()) {
|
|
235
|
+
const alive = rows.find((row) => processIdentityMatches(seed, row));
|
|
236
|
+
if (alive)
|
|
237
|
+
found.set(alive.pid, alive);
|
|
238
|
+
}
|
|
239
|
+
const born = (row) => {
|
|
240
|
+
const time = Date.parse(row.birth);
|
|
241
|
+
if (!Number.isFinite(time))
|
|
242
|
+
throw new Error('Process creation time is unavailable.');
|
|
243
|
+
return time;
|
|
244
|
+
};
|
|
245
|
+
// Dedicated POSIX process groups outlive their original shell wrapper.
|
|
246
|
+
if (root.pgid === root.pid) {
|
|
247
|
+
for (const row of rows)
|
|
248
|
+
if (row.owner === root.owner && row.pgid === root.pgid && born(row) >= born(root)
|
|
249
|
+
&& (!current || processIdentityMatches(root, current) || born(row) < born(current)))
|
|
250
|
+
found.set(row.pid, row);
|
|
251
|
+
}
|
|
252
|
+
let changed = true;
|
|
253
|
+
while (changed) {
|
|
254
|
+
changed = false;
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
if (row.owner !== root.owner || found.has(row.pid))
|
|
257
|
+
continue;
|
|
258
|
+
const parent = found.get(row.ppid) ?? seeds.get(row.ppid);
|
|
259
|
+
if (!parent || born(row) < born(parent))
|
|
260
|
+
continue;
|
|
261
|
+
const replacement = rows.find((candidate) => candidate.pid === parent.pid);
|
|
262
|
+
if (replacement && !processIdentityMatches(parent, replacement) && born(row) >= born(replacement))
|
|
263
|
+
continue;
|
|
264
|
+
found.set(row.pid, row);
|
|
265
|
+
changed = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [...found.values()];
|
|
269
|
+
}
|
|
270
|
+
export async function inspectProcessTree(root, deadline = Date.now() + 5000, known = []) {
|
|
271
|
+
if (process.platform === 'darwin' && root.macCoalition) {
|
|
272
|
+
const members = await inspectMacCoalition(root.macCoalition, deadline);
|
|
273
|
+
const bridge = await inspectProcess(root.pid, deadline);
|
|
274
|
+
const liveBridge = bridge && processIdentityMatches(root, bridge) ? { ...bridge, macCoalition: root.macCoalition } : null;
|
|
275
|
+
if (!members.length && !liveBridge) {
|
|
276
|
+
await unloadMacExecution(root.macCoalition, deadline);
|
|
277
|
+
// No job may be queued to launch again when empty is accepted.
|
|
278
|
+
if ((await inspectMacCoalition(root.macCoalition, deadline)).length)
|
|
279
|
+
throw new Error('Owned Mac execution is still closing. Retry the command.');
|
|
280
|
+
}
|
|
281
|
+
return [...(liveBridge ? [liveBridge] : []), ...members];
|
|
282
|
+
}
|
|
283
|
+
if (process.platform === 'win32' && root.windowsJob) {
|
|
284
|
+
const job = await inspectWindowsJob(root.windowsJob, deadline);
|
|
285
|
+
// Inspect only the kernel Job's members and its helper. Querying every
|
|
286
|
+
// desktop process's owner can consume the whole local recovery deadline.
|
|
287
|
+
const rows = await processTable([root.pid, ...job.pids], deadline);
|
|
288
|
+
const helper = rows.find((row) => processIdentityMatches(root, row));
|
|
289
|
+
if (!job.exists) {
|
|
290
|
+
// The helper may have closed its empty job just before returning. Its
|
|
291
|
+
// verified remaining lifetime still counts as owned work until it exits.
|
|
292
|
+
return helper ? [{ ...helper, windowsJob: root.windowsJob }] : [];
|
|
293
|
+
}
|
|
294
|
+
const latest = await inspectWindowsJob(root.windowsJob, deadline);
|
|
295
|
+
const members = rows.filter((row) => latest.pids.includes(row.pid));
|
|
296
|
+
if (members.some((row) => row.owner !== root.owner))
|
|
297
|
+
throw new Error('Windows execution ownership changed.');
|
|
298
|
+
// Membership can change during the OS table read. A newly observed member
|
|
299
|
+
// without a verified lifetime is unknown, never successful closure.
|
|
300
|
+
if (latest.pids.some((pid) => !members.some((row) => row.pid === pid)))
|
|
301
|
+
throw new Error('Windows execution membership changed during inspection. Retry the command.');
|
|
302
|
+
return [...(helper ? [{ ...helper, windowsJob: root.windowsJob }] : []), ...members];
|
|
303
|
+
}
|
|
304
|
+
if (process.platform === 'win32')
|
|
305
|
+
throw new Error('This execution has no durable Windows boundary. Its closure cannot be verified.');
|
|
306
|
+
return ownedProcessTree(root, process.platform === 'darwin' ? await inspectMacProcesses(deadline) : await processTable(null, deadline), known);
|
|
307
|
+
}
|
|
308
|
+
/** The controller first records interruption intent, then ends only this owner. */
|
|
309
|
+
export async function terminateOwnedRoot(root, deadline) {
|
|
310
|
+
if (process.platform === 'darwin')
|
|
311
|
+
return signalMacProcess(root, deadline);
|
|
312
|
+
if (process.platform === 'win32') {
|
|
313
|
+
await inspectWindowsProcess(root, deadline, true);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const current = await inspectProcess(root.pid, deadline);
|
|
317
|
+
if (!current || !processIdentityMatches(root, current))
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
process.kill(root.pid, 'SIGKILL');
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
const after = await inspectProcess(root.pid, deadline);
|
|
324
|
+
if (after && processIdentityMatches(root, after))
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function signalOwnedProcess(identity, force, deadline) {
|
|
329
|
+
if (process.platform === 'darwin') {
|
|
330
|
+
if (!force)
|
|
331
|
+
throw new Error('Owned Mac execution requires explicit force to interrupt.');
|
|
332
|
+
return signalMacProcess(identity, deadline);
|
|
333
|
+
}
|
|
334
|
+
if (process.platform === 'win32') {
|
|
335
|
+
if (!force)
|
|
336
|
+
throw new Error('Windows owned execution requires explicit force to interrupt.');
|
|
337
|
+
await inspectWindowsProcess(identity, deadline, true);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const actual = await inspectProcess(identity.pid, deadline);
|
|
341
|
+
if (!actual || !processIdentityMatches(identity, actual))
|
|
342
|
+
return;
|
|
343
|
+
try {
|
|
344
|
+
process.kill(identity.pid, force ? 'SIGKILL' : 'SIGTERM');
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
const after = await inspectProcess(identity.pid, deadline);
|
|
348
|
+
if (after && processIdentityMatches(identity, after))
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** Only use for recorded owned roots. Absence and inspection failure differ. */
|
|
353
|
+
export async function terminateOwnedTree(root, { force, deadline, known = [] }) {
|
|
354
|
+
if (process.platform === 'darwin' && root.macCoalition) {
|
|
355
|
+
if (!force)
|
|
356
|
+
throw new Error('Owned Mac execution requires explicit force to interrupt.');
|
|
357
|
+
// Remove the only launch trigger first. AbandonProcessGroup keeps bootout
|
|
358
|
+
// from making any broader, unverified process-group signal on our behalf.
|
|
359
|
+
await unloadMacExecution(root.macCoalition, deadline);
|
|
360
|
+
for (const member of await inspectMacCoalition(root.macCoalition, deadline))
|
|
361
|
+
await signalMacMember(member, deadline, 'TERM');
|
|
362
|
+
// One bounded grace period lets normal signal handlers save and close.
|
|
363
|
+
// Its deadline never resets as descendants fork or membership changes.
|
|
364
|
+
const gracefulUntil = Math.min(deadline, Date.now() + 500);
|
|
365
|
+
while (Date.now() < gracefulUntil) {
|
|
366
|
+
if (!(await inspectMacCoalition(root.macCoalition, deadline)).length)
|
|
367
|
+
break;
|
|
368
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, gracefulUntil - Date.now())));
|
|
369
|
+
}
|
|
370
|
+
while (Date.now() < deadline) {
|
|
371
|
+
for (const member of await inspectMacCoalition(root.macCoalition, deadline))
|
|
372
|
+
await signalMacMember(member, deadline);
|
|
373
|
+
await signalOwnedProcess(root, true, deadline);
|
|
374
|
+
if (!(await inspectProcessTree(root, deadline)).length)
|
|
375
|
+
return;
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
377
|
+
}
|
|
378
|
+
throw new Error('Owned Mac execution is still present. No replacement can start.');
|
|
379
|
+
}
|
|
380
|
+
if (process.platform === 'win32' && root.windowsJob) {
|
|
381
|
+
if (!force)
|
|
382
|
+
throw new Error('Windows owned execution requires explicit force to interrupt.');
|
|
383
|
+
await inspectWindowsJob(root.windowsJob, deadline, true);
|
|
384
|
+
// A helper still waiting for a grant owns an empty job and must also exit.
|
|
385
|
+
await signalOwnedProcess(root, true, deadline);
|
|
386
|
+
while (Date.now() < deadline) {
|
|
387
|
+
if (!(await inspectProcessTree(root, deadline)).length) {
|
|
388
|
+
remaining(deadline);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
392
|
+
}
|
|
393
|
+
throw new Error('Owned execution is still present. No replacement can start.');
|
|
394
|
+
}
|
|
395
|
+
const owned = new Map(known.map((identity) => [identity.pid, identity]));
|
|
396
|
+
owned.set(root.pid, root);
|
|
397
|
+
for (const identity of await inspectProcessTree(root, deadline, known))
|
|
398
|
+
owned.set(identity.pid, identity);
|
|
399
|
+
if (!force && process.platform === 'win32')
|
|
400
|
+
throw new Error('Windows owned execution requires explicit force to interrupt.');
|
|
401
|
+
const current = await inspectProcess(root.pid, deadline);
|
|
402
|
+
if (current && processIdentityMatches(root, current)) {
|
|
403
|
+
if (process.platform === 'win32') {
|
|
404
|
+
await signalOwnedProcess(root, true, deadline);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// Spawning paths create dedicated groups; never signal a caller's group.
|
|
408
|
+
for (const identity of [...owned.values()].reverse()) {
|
|
409
|
+
await signalOwnedProcess(identity, false, deadline);
|
|
410
|
+
}
|
|
411
|
+
if (force)
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(500, remaining(deadline))));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
while (Date.now() < deadline) {
|
|
416
|
+
// A child can be created after the first snapshot while its wrapper is
|
|
417
|
+
// being stopped. The dedicated group remains observable after that wrapper
|
|
418
|
+
// exits; do not conclude closure from the original PID list alone.
|
|
419
|
+
for (const identity of await inspectProcessTree(root, deadline, [...owned.values()]))
|
|
420
|
+
owned.set(identity.pid, identity);
|
|
421
|
+
let surviving = false;
|
|
422
|
+
for (const identity of owned.values()) {
|
|
423
|
+
const actual = await inspectProcess(identity.pid, deadline);
|
|
424
|
+
if (!actual || !processIdentityMatches(identity, actual))
|
|
425
|
+
continue;
|
|
426
|
+
surviving = true;
|
|
427
|
+
for (const child of await inspectProcessTree(actual, deadline))
|
|
428
|
+
owned.set(child.pid, child);
|
|
429
|
+
await signalOwnedProcess(actual, force, deadline);
|
|
430
|
+
}
|
|
431
|
+
if (!surviving)
|
|
432
|
+
return;
|
|
433
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
434
|
+
}
|
|
435
|
+
throw new Error('Owned execution is still present. No replacement can start.');
|
|
436
|
+
}
|
|
437
|
+
export async function osBootIdentity(deadline = Date.now() + 5000) {
|
|
438
|
+
if (process.platform === 'darwin') {
|
|
439
|
+
const { stdout } = await execute('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { timeout: remaining(deadline) });
|
|
440
|
+
if (!/^[\da-f-]{36}$/i.test(stdout.trim()))
|
|
441
|
+
throw new Error('Mac boot identity is unavailable.');
|
|
442
|
+
return stdout.trim();
|
|
443
|
+
}
|
|
444
|
+
if (process.platform === 'win32') {
|
|
445
|
+
const { stdout } = await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('O')"], { windowsHide: true, timeout: remaining(deadline) });
|
|
446
|
+
if (!Number.isFinite(Date.parse(stdout.trim())))
|
|
447
|
+
throw new Error('Windows boot identity is unavailable.');
|
|
448
|
+
return stdout.trim();
|
|
449
|
+
}
|
|
450
|
+
return (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim();
|
|
451
|
+
}
|
|
452
|
+
export async function loopbackListenerPid(port, deadline) {
|
|
453
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
454
|
+
throw new Error('Invalid local listener port.');
|
|
455
|
+
try {
|
|
456
|
+
if (process.platform === 'win32') {
|
|
457
|
+
const { stdout } = await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `@(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalAddress -eq '127.0.0.1' } | Select-Object -ExpandProperty OwningProcess -Unique) | ConvertTo-Json -Compress`], { windowsHide: true, timeout: remaining(deadline) });
|
|
458
|
+
const value = JSON.parse(stdout.trim() || '[]');
|
|
459
|
+
const pids = Array.isArray(value) ? value : [value];
|
|
460
|
+
if (pids.length === 0)
|
|
461
|
+
return null;
|
|
462
|
+
if (pids.length !== 1 || !Number.isInteger(pids[0]))
|
|
463
|
+
throw new Error('Listener ownership is ambiguous.');
|
|
464
|
+
return pids[0];
|
|
465
|
+
}
|
|
466
|
+
const { stdout } = await execute('/usr/sbin/lsof', ['-nP', '-a', '-iTCP:' + port, '-sTCP:LISTEN', '-Fp'], { timeout: remaining(deadline) });
|
|
467
|
+
const pids = [...new Set(stdout.split('\n').filter((s) => /^p\d+$/.test(s)).map((s) => Number(s.slice(1))))];
|
|
468
|
+
if (pids.length !== 1)
|
|
469
|
+
throw new Error('Listener ownership is ambiguous.');
|
|
470
|
+
return pids[0];
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (error.code === 1)
|
|
474
|
+
return null;
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
33
478
|
/** Does this binary have to go through `cmd.exe` to run at all? Scoped to the
|
|
34
479
|
* binary rather than to the platform: a real `.exe` on Windows runs directly,
|
|
35
480
|
* and putting it through a shell would re-parse its argv for nothing. */
|
|
@@ -122,6 +567,24 @@ export function killTree(child, { platform = process.platform, spawnProcess = sp
|
|
|
122
567
|
/* Already gone. `close` still fires and reports the outcome. */
|
|
123
568
|
}
|
|
124
569
|
};
|
|
570
|
+
if (ownedChildren.has(child)) {
|
|
571
|
+
const owned = ownedChildren.get(child);
|
|
572
|
+
if (platform === 'darwin' && owned.identity?.macCoalition) {
|
|
573
|
+
void (async () => {
|
|
574
|
+
// Preserve the registered bridge lifetime even after its PID is reused.
|
|
575
|
+
await terminateOwnedTree(owned.identity, { force: true, deadline: Date.now() + 30000 });
|
|
576
|
+
})().catch(() => { console.error('The owned Mac execution could not be stopped. Run cs stop --force to retry.'); });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (platform !== 'win32' && typeof child.pid === 'number' && child.exitCode === null && child.signalCode === null) {
|
|
580
|
+
try {
|
|
581
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
catch { /* The native handle below confirms root closure. */ }
|
|
585
|
+
}
|
|
586
|
+
return direct();
|
|
587
|
+
}
|
|
125
588
|
if (platform !== 'win32' || typeof child.pid !== 'number')
|
|
126
589
|
return direct();
|
|
127
590
|
try {
|