@livedesk/client 0.1.239 → 0.1.241
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 +20 -20
- package/THIRD_PARTY_NOTICES.md +26 -26
- package/bin/client-version.js +23 -23
- package/bin/livedesk-client-fast.js +22 -22
- package/bin/livedesk-client-node.js +66 -66
- package/bin/livedesk-client-update-bootstrap.cjs +319 -319
- package/bin/livedesk-client.js +1 -1
- package/package.json +6 -6
- package/src/runtime/agent-process-lifecycle.js +860 -860
- package/src/runtime/client-runtime-server.js +10 -10
- package/src/runtime/hub-wake-listener.js +159 -159
- package/src/runtime/linux-video-acceleration.js +390 -390
- package/src/runtime/windows-owned-process-manifest.js +402 -402
- package/tests/client-version.test.mjs +27 -27
|
@@ -1,860 +1,860 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
const SIGNAL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143 });
|
|
4
|
-
const WINDOWS_SPAWN_CLOCK_TOLERANCE_MS = 250;
|
|
5
|
-
|
|
6
|
-
function normalizeWindowsProcessRecord(value) {
|
|
7
|
-
const pid = Number(value?.pid ?? value?.ProcessId ?? 0);
|
|
8
|
-
const parentPid = Number(value?.parentPid ?? value?.ParentProcessId ?? 0);
|
|
9
|
-
const startMarker = String(value?.startMarker ?? value?.CreationDate ?? '').trim();
|
|
10
|
-
const startOrder = String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim();
|
|
11
|
-
if (!Number.isInteger(pid)
|
|
12
|
-
|| pid <= 1
|
|
13
|
-
|| !startMarker
|
|
14
|
-
|| !/^\d+$/.test(startOrder)) return null;
|
|
15
|
-
return { pid, parentPid, startMarker, startOrder };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function windowsRecordKey(record) {
|
|
19
|
-
return `${Number(record?.pid || 0)}:${String(record?.startOrder || '')}`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function sameWindowsProcess(left, right) {
|
|
23
|
-
return Number(left?.pid || 0) === Number(right?.pid || 0)
|
|
24
|
-
&& String(left?.startOrder || '') === String(right?.startOrder || '')
|
|
25
|
-
&& String(left?.startMarker || '') === String(right?.startMarker || '');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function windowsTicksForUnixMilliseconds(milliseconds) {
|
|
29
|
-
return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function unixMillisecondsForWindowsTicks(ticks) {
|
|
33
|
-
try {
|
|
34
|
-
return Number((BigInt(ticks) - 621_355_968_000_000_000n) / 10_000n);
|
|
35
|
-
} catch {
|
|
36
|
-
return 0;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
|
|
41
|
-
if (!record?.startOrder) return false;
|
|
42
|
-
try {
|
|
43
|
-
const order = BigInt(record.startOrder);
|
|
44
|
-
// spawnedAtMs is recorded immediately before spawn(). A small 250ms
|
|
45
|
-
// tolerance covers clock conversion/scheduling without admitting an
|
|
46
|
-
// old process whose stale ParentProcessId happens to equal the Agent.
|
|
47
|
-
const lower = windowsTicksForUnixMilliseconds(
|
|
48
|
-
Math.max(0, spawnedAtMs - WINDOWS_SPAWN_CLOCK_TOLERANCE_MS)
|
|
49
|
-
);
|
|
50
|
-
const upper = exitedAtMs === null
|
|
51
|
-
? windowsTicksForUnixMilliseconds(Date.now() + 5000)
|
|
52
|
-
: windowsTicksForUnixMilliseconds(exitedAtMs);
|
|
53
|
-
return order >= lower && order <= upper;
|
|
54
|
-
} catch {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function runExecFile(command, args, options = {}, execFileImpl = execFile) {
|
|
60
|
-
return new Promise(resolve => {
|
|
61
|
-
execFileImpl(command, args, options, (error, stdout, stderr) => {
|
|
62
|
-
resolve({
|
|
63
|
-
ok: !error,
|
|
64
|
-
error: error || null,
|
|
65
|
-
stdout: String(stdout || '').trim(),
|
|
66
|
-
stderr: String(stderr || '').trim()
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Returns immutable PID + CreationDate records only. Command lines are
|
|
74
|
-
* intentionally excluded so no globally named ffmpeg process can become a
|
|
75
|
-
* termination target.
|
|
76
|
-
*/
|
|
77
|
-
export async function queryWindowsProcessTable({
|
|
78
|
-
execFileImpl = execFile
|
|
79
|
-
} = {}) {
|
|
80
|
-
const script = [
|
|
81
|
-
'$ErrorActionPreference = "Stop";',
|
|
82
|
-
'@(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
83
|
-
' $creation = [string]$_.CreationDate;',
|
|
84
|
-
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
85
|
-
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
86
|
-
'}) | ConvertTo-Json -Compress'
|
|
87
|
-
].join(' ');
|
|
88
|
-
const result = await runExecFile(
|
|
89
|
-
'powershell.exe',
|
|
90
|
-
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
91
|
-
{ windowsHide: true, timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 },
|
|
92
|
-
execFileImpl
|
|
93
|
-
);
|
|
94
|
-
if (!result.ok || !result.stdout) {
|
|
95
|
-
throw new Error(
|
|
96
|
-
`Windows process-tree snapshot failed: `
|
|
97
|
-
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
let parsed;
|
|
101
|
-
try {
|
|
102
|
-
parsed = JSON.parse(result.stdout);
|
|
103
|
-
} catch (error) {
|
|
104
|
-
throw new Error(`Windows process-tree snapshot returned invalid JSON: ${error?.message || error}`);
|
|
105
|
-
}
|
|
106
|
-
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
107
|
-
.map(normalizeWindowsProcessRecord)
|
|
108
|
-
.filter(Boolean);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Captures only the named immutable identities. Normal Agent startup uses this
|
|
113
|
-
* for the Agent root plus its launcher owner instead of enumerating every
|
|
114
|
-
* process on the computer. Full-table discovery remains a terminal/recovery
|
|
115
|
-
* operation where descendants actually need to be found.
|
|
116
|
-
*/
|
|
117
|
-
export async function queryWindowsProcessIdentities(processIds, {
|
|
118
|
-
execFileImpl = execFile
|
|
119
|
-
} = {}) {
|
|
120
|
-
const ids = [...new Set((Array.isArray(processIds) ? processIds : [processIds])
|
|
121
|
-
.map(value => Number(value || 0))
|
|
122
|
-
.filter(value => Number.isInteger(value) && value > 1))]
|
|
123
|
-
.slice(0, 8);
|
|
124
|
-
if (ids.length === 0) return [];
|
|
125
|
-
const filter = ids.map(pid => `ProcessId = ${pid}`).join(' OR ');
|
|
126
|
-
const script = [
|
|
127
|
-
'$ErrorActionPreference = "Stop";',
|
|
128
|
-
`@(Get-CimInstance Win32_Process -Filter "${filter}" -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {`,
|
|
129
|
-
' $creation = [string]$_.CreationDate;',
|
|
130
|
-
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
131
|
-
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
132
|
-
'}) | ConvertTo-Json -Compress'
|
|
133
|
-
].join(' ');
|
|
134
|
-
const result = await runExecFile(
|
|
135
|
-
'powershell.exe',
|
|
136
|
-
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
137
|
-
{ windowsHide: true, timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 },
|
|
138
|
-
execFileImpl
|
|
139
|
-
);
|
|
140
|
-
if (!result.ok || !result.stdout) {
|
|
141
|
-
throw new Error(
|
|
142
|
-
`Windows process identity query failed: `
|
|
143
|
-
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
let parsed;
|
|
147
|
-
try {
|
|
148
|
-
parsed = JSON.parse(result.stdout);
|
|
149
|
-
} catch (error) {
|
|
150
|
-
throw new Error(`Windows process identity query returned invalid JSON: ${error?.message || error}`);
|
|
151
|
-
}
|
|
152
|
-
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
153
|
-
.map(normalizeWindowsProcessRecord)
|
|
154
|
-
.filter(Boolean);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Rechecks CreationDate and force-stops the single PID inside the same
|
|
159
|
-
* PowerShell process. PID reuse therefore becomes a harmless identity mismatch
|
|
160
|
-
* instead of terminating the replacement process.
|
|
161
|
-
*/
|
|
162
|
-
export async function terminateExactWindowsProcessTree(recordOrRecords, {
|
|
163
|
-
execFileImpl = execFile
|
|
164
|
-
} = {}) {
|
|
165
|
-
const targets = (Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords])
|
|
166
|
-
.map(normalizeWindowsProcessRecord)
|
|
167
|
-
.filter(Boolean);
|
|
168
|
-
if (targets.length === 0) {
|
|
169
|
-
return { ok: false, error: new Error('At least one immutable Windows process record is required.') };
|
|
170
|
-
}
|
|
171
|
-
const targetsJson = JSON.stringify(targets.map(target => ({
|
|
172
|
-
pid: target.pid,
|
|
173
|
-
startOrder: target.startOrder
|
|
174
|
-
}))).replaceAll("'", "''");
|
|
175
|
-
const script = [
|
|
176
|
-
'$ErrorActionPreference = "Stop";',
|
|
177
|
-
`$targets = ConvertFrom-Json -InputObject '${targetsJson}';`,
|
|
178
|
-
'$failures = [System.Collections.Generic.List[string]]::new();',
|
|
179
|
-
'foreach ($item in $targets) {',
|
|
180
|
-
' $targetPid = [int]$item.pid;',
|
|
181
|
-
' $expectedStartTicks = [string]$item.startOrder;',
|
|
182
|
-
' try {',
|
|
183
|
-
' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
184
|
-
' if (-not $target) { continue };',
|
|
185
|
-
' $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
186
|
-
' if ($targetStartTicks -ne $expectedStartTicks) { continue };',
|
|
187
|
-
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
188
|
-
' } catch {',
|
|
189
|
-
' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
|
|
190
|
-
' }',
|
|
191
|
-
'}',
|
|
192
|
-
'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
|
|
193
|
-
'exit 0;'
|
|
194
|
-
].join(' ');
|
|
195
|
-
return runExecFile(
|
|
196
|
-
'powershell.exe',
|
|
197
|
-
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
198
|
-
{ windowsHide: true, timeout: 15000, killSignal: 'SIGKILL' },
|
|
199
|
-
execFileImpl
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Tracks the exact Agent root and only descendants connected through Windows
|
|
205
|
-
* ParentProcessId records. Startup queries only the Agent and launcher
|
|
206
|
-
* identities. Full process-table snapshots are serialized and request-driven
|
|
207
|
-
* by terminal cleanup/recovery; a healthy connected Client owns no recurring
|
|
208
|
-
* PowerShell/CIM timer.
|
|
209
|
-
*/
|
|
210
|
-
export function createWindowsProcessTreeTracker(child, {
|
|
211
|
-
platform = process.platform,
|
|
212
|
-
queryProcessTableImpl = queryWindowsProcessTable,
|
|
213
|
-
queryInitialProcessRecordsImpl = null,
|
|
214
|
-
spawnedAtMs = Date.now(),
|
|
215
|
-
ownerPid = process.pid,
|
|
216
|
-
initialRetryDelaysMs = [25, 75, 150],
|
|
217
|
-
waitImpl = waitMilliseconds,
|
|
218
|
-
publishTrackedRecords = null,
|
|
219
|
-
reportPublisherError = error => console.warn(
|
|
220
|
-
`[LiveDesk Client] Windows owned-process manifest warning: ${error?.message || error}`
|
|
221
|
-
)
|
|
222
|
-
} = {}) {
|
|
223
|
-
const rootPid = Number(child?.pid || 0);
|
|
224
|
-
const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
|
|
225
|
-
const tracked = new Map();
|
|
226
|
-
let rootRecord = null;
|
|
227
|
-
let lastSnapshot = [];
|
|
228
|
-
let lastError = null;
|
|
229
|
-
let inFlight = null;
|
|
230
|
-
let stopped = false;
|
|
231
|
-
let rootExitedAtMs = null;
|
|
232
|
-
let rootCapturedBeforeExit = false;
|
|
233
|
-
let lastSnapshotHadRootPid = false;
|
|
234
|
-
let successfulSnapshotAfterExit = false;
|
|
235
|
-
let ambiguousRootReuse = false;
|
|
236
|
-
let lastPublisherError = null;
|
|
237
|
-
|
|
238
|
-
const mergeSnapshot = snapshot => {
|
|
239
|
-
const records = (Array.isArray(snapshot) ? snapshot : [])
|
|
240
|
-
.map(normalizeWindowsProcessRecord)
|
|
241
|
-
.filter(Boolean);
|
|
242
|
-
lastSnapshot = records;
|
|
243
|
-
const currentByPid = new Map(records.map(record => [record.pid, record]));
|
|
244
|
-
const currentRoot = currentByPid.get(rootPid) || null;
|
|
245
|
-
lastSnapshotHadRootPid = Boolean(currentRoot);
|
|
246
|
-
const rootWasReused = Boolean(
|
|
247
|
-
rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)
|
|
248
|
-
);
|
|
249
|
-
if (rootWasReused) {
|
|
250
|
-
ambiguousRootReuse = true;
|
|
251
|
-
}
|
|
252
|
-
if (rootRecord && rootExitedAtMs === null && (!currentRoot || rootWasReused)) {
|
|
253
|
-
// The exit event can trail the first CIM snapshot that proves the
|
|
254
|
-
// exact root is gone. Use that proof immediately so a surviving
|
|
255
|
-
// child first observed in this same snapshot is still owned.
|
|
256
|
-
// For PID reuse, cap the old lifetime just before the replacement
|
|
257
|
-
// CreationDate so replacement-root children remain excluded.
|
|
258
|
-
rootExitedAtMs = rootWasReused
|
|
259
|
-
? unixMillisecondsForWindowsTicks(currentRoot.startOrder) - 1
|
|
260
|
-
: Date.now();
|
|
261
|
-
}
|
|
262
|
-
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
263
|
-
if (!rootRecord
|
|
264
|
-
&& currentRoot
|
|
265
|
-
&& windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
|
|
266
|
-
rootCapturedBeforeExit = rootExitedAtMs === null;
|
|
267
|
-
rootRecord = {
|
|
268
|
-
...currentRoot,
|
|
269
|
-
depth: 0,
|
|
270
|
-
targetable: rootCapturedBeforeExit
|
|
271
|
-
};
|
|
272
|
-
if (rootRecord.targetable) {
|
|
273
|
-
tracked.set(windowsRecordKey(rootRecord), rootRecord);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const exactCurrentByPid = new Map();
|
|
278
|
-
if (rootRecord && currentRoot && sameWindowsProcess(currentRoot, rootRecord)) {
|
|
279
|
-
exactCurrentByPid.set(rootPid, rootRecord);
|
|
280
|
-
}
|
|
281
|
-
for (const record of tracked.values()) {
|
|
282
|
-
const current = currentByPid.get(record.pid);
|
|
283
|
-
if (current && sameWindowsProcess(current, record)) {
|
|
284
|
-
exactCurrentByPid.set(record.pid, record);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Windows retains ParentProcessId on surviving direct children after
|
|
289
|
-
// the root exits. A reused root PID is also safe to bridge only for a
|
|
290
|
-
// direct child whose immutable creation time is inside the original
|
|
291
|
-
// root lifetime; children created by the replacement root are rejected
|
|
292
|
-
// by windowsRecordIsWithinLifetime.
|
|
293
|
-
const rootAbsent = !currentRoot;
|
|
294
|
-
let changed = true;
|
|
295
|
-
while (changed) {
|
|
296
|
-
changed = false;
|
|
297
|
-
for (const record of records) {
|
|
298
|
-
if (record.pid === rootPid || tracked.has(windowsRecordKey(record))) continue;
|
|
299
|
-
if (!windowsRecordIsWithinLifetime(record, spawnedAtMs, rootExitedAtMs)) continue;
|
|
300
|
-
let parent = exactCurrentByPid.get(record.parentPid) || null;
|
|
301
|
-
if (!parent
|
|
302
|
-
&& rootExitedAtMs !== null
|
|
303
|
-
&& (rootAbsent || rootWasReused)
|
|
304
|
-
&& record.parentPid === rootPid) {
|
|
305
|
-
parent = rootRecord || { pid: rootPid, depth: 0 };
|
|
306
|
-
}
|
|
307
|
-
if (!parent) continue;
|
|
308
|
-
const descendant = { ...record, depth: Number(parent.depth || 0) + 1 };
|
|
309
|
-
tracked.set(windowsRecordKey(descendant), descendant);
|
|
310
|
-
exactCurrentByPid.set(descendant.pid, descendant);
|
|
311
|
-
changed = true;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
const initialQuery = typeof queryInitialProcessRecordsImpl === 'function'
|
|
317
|
-
? queryInitialProcessRecordsImpl
|
|
318
|
-
: queryProcessTableImpl === queryWindowsProcessTable
|
|
319
|
-
? () => queryWindowsProcessIdentities([rootPid, ownerPid])
|
|
320
|
-
: queryProcessTableImpl;
|
|
321
|
-
const boundedInitialRetryDelays = (
|
|
322
|
-
Array.isArray(initialRetryDelaysMs) ? initialRetryDelaysMs : []
|
|
323
|
-
)
|
|
324
|
-
.slice(0, 5)
|
|
325
|
-
.map(value => Math.min(1000, Math.max(1, Number(value) || 1)));
|
|
326
|
-
|
|
327
|
-
const refresh = async ({ initial = false } = {}) => {
|
|
328
|
-
if (!enabled) return { ok: true, records: [] };
|
|
329
|
-
if (inFlight) return inFlight;
|
|
330
|
-
inFlight = Promise.resolve()
|
|
331
|
-
.then(() => (initial ? initialQuery() : queryProcessTableImpl()))
|
|
332
|
-
.then(async snapshot => {
|
|
333
|
-
mergeSnapshot(snapshot);
|
|
334
|
-
lastError = null;
|
|
335
|
-
if (typeof publishTrackedRecords === 'function') {
|
|
336
|
-
try {
|
|
337
|
-
await publishTrackedRecords(
|
|
338
|
-
[...tracked.values()].map(record => ({ ...record })),
|
|
339
|
-
{
|
|
340
|
-
rootPid,
|
|
341
|
-
rootRecord: rootRecord ? { ...rootRecord } : null,
|
|
342
|
-
snapshot: lastSnapshot.map(record => ({ ...record })),
|
|
343
|
-
terminal: stopped
|
|
344
|
-
}
|
|
345
|
-
);
|
|
346
|
-
lastPublisherError = null;
|
|
347
|
-
} catch (error) {
|
|
348
|
-
// Manifest publication must never break the in-memory
|
|
349
|
-
// cleanup proof. Keep tracking, surface diagnostics,
|
|
350
|
-
// and retry on the next requested/terminal refresh.
|
|
351
|
-
lastPublisherError = error;
|
|
352
|
-
try { reportPublisherError(error); } catch { /* diagnostics cannot own lifecycle */ }
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return {
|
|
356
|
-
ok: true,
|
|
357
|
-
records: lastSnapshot,
|
|
358
|
-
publisherError: lastPublisherError
|
|
359
|
-
};
|
|
360
|
-
})
|
|
361
|
-
.catch(error => {
|
|
362
|
-
lastError = error;
|
|
363
|
-
return { ok: false, error, records: lastSnapshot };
|
|
364
|
-
})
|
|
365
|
-
.finally(() => {
|
|
366
|
-
inFlight = null;
|
|
367
|
-
});
|
|
368
|
-
return inFlight;
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
const ready = enabled
|
|
372
|
-
? (async () => {
|
|
373
|
-
let result = await refresh({ initial: true });
|
|
374
|
-
for (const retryDelayMs of boundedInitialRetryDelays) {
|
|
375
|
-
if (rootRecord || stopped) break;
|
|
376
|
-
// A just-spawned PID can briefly lag its CIM identity. Retry
|
|
377
|
-
// only the exact root/launcher filter; never compensate with
|
|
378
|
-
// a healthy-state full-table timer.
|
|
379
|
-
await waitImpl(retryDelayMs);
|
|
380
|
-
result = await refresh({ initial: true });
|
|
381
|
-
}
|
|
382
|
-
return result;
|
|
383
|
-
})()
|
|
384
|
-
: Promise.resolve({ ok: true, records: [] });
|
|
385
|
-
|
|
386
|
-
return {
|
|
387
|
-
enabled,
|
|
388
|
-
ready,
|
|
389
|
-
refresh,
|
|
390
|
-
stop() {
|
|
391
|
-
stopped = true;
|
|
392
|
-
},
|
|
393
|
-
getRootRecord: () => rootRecord ? { ...rootRecord } : null,
|
|
394
|
-
markRootExited(exitedAtMs = Date.now()) {
|
|
395
|
-
if (rootExitedAtMs === null) {
|
|
396
|
-
rootExitedAtMs = Number(exitedAtMs) || Date.now();
|
|
397
|
-
successfulSnapshotAfterExit = false;
|
|
398
|
-
}
|
|
399
|
-
},
|
|
400
|
-
hasSafeFinalSnapshot() {
|
|
401
|
-
return rootCapturedBeforeExit
|
|
402
|
-
|| (rootExitedAtMs !== null && successfulSnapshotAfterExit && !lastSnapshotHadRootPid);
|
|
403
|
-
},
|
|
404
|
-
hasAmbiguousRootReuse: () => ambiguousRootReuse,
|
|
405
|
-
getTrackedRecords: () => [...tracked.values()].map(record => ({ ...record })),
|
|
406
|
-
getCurrentExactRecords() {
|
|
407
|
-
const currentByPid = new Map(lastSnapshot.map(record => [record.pid, record]));
|
|
408
|
-
return [...tracked.values()]
|
|
409
|
-
.filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
|
|
410
|
-
.map(record => ({ ...record }));
|
|
411
|
-
},
|
|
412
|
-
getLastError: () => lastError,
|
|
413
|
-
getLastPublisherError: () => lastPublisherError
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Bounded exact-tree drain used by internal restarts, unexpected exits, and
|
|
419
|
-
* terminal shutdown. A nonzero taskkill is not itself failure: each attempt is
|
|
420
|
-
* followed by a fresh immutable snapshot, and an already-gone tree succeeds.
|
|
421
|
-
*/
|
|
422
|
-
export async function drainOwnedWindowsAgentTree(child, {
|
|
423
|
-
platform = process.platform,
|
|
424
|
-
queryProcessTableImpl = queryWindowsProcessTable,
|
|
425
|
-
terminateExactTreeImpl = terminateExactWindowsProcessTree,
|
|
426
|
-
maxAttempts = 3,
|
|
427
|
-
retryMs = 100,
|
|
428
|
-
waitImpl = waitMilliseconds,
|
|
429
|
-
reportTerminationFailure = message => console.error(message)
|
|
430
|
-
} = {}) {
|
|
431
|
-
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
432
|
-
if (child?.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise;
|
|
433
|
-
|
|
434
|
-
child.livedeskWindowsDrainPromise = (async () => {
|
|
435
|
-
const tracker = child?.livedeskWindowsTreeTracker
|
|
436
|
-
|| createWindowsProcessTreeTracker(child, { platform, queryProcessTableImpl });
|
|
437
|
-
child.livedeskWindowsTreeTracker = tracker;
|
|
438
|
-
if (child?.exitCode !== null && child?.exitCode !== undefined) {
|
|
439
|
-
tracker.markRootExited?.();
|
|
440
|
-
}
|
|
441
|
-
tracker.stop();
|
|
442
|
-
await tracker.ready;
|
|
443
|
-
const attempts = Math.max(1, Number(maxAttempts) || 3);
|
|
444
|
-
let identityRefresh = await tracker.refresh();
|
|
445
|
-
let identityAttempt = 1;
|
|
446
|
-
let rootRecord = tracker.getRootRecord();
|
|
447
|
-
while ((!rootRecord || rootRecord.targetable === false)
|
|
448
|
-
&& !tracker.hasSafeFinalSnapshot?.()
|
|
449
|
-
&& identityAttempt < attempts) {
|
|
450
|
-
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
451
|
-
identityAttempt += 1;
|
|
452
|
-
identityRefresh = await tracker.refresh();
|
|
453
|
-
rootRecord = tracker.getRootRecord();
|
|
454
|
-
}
|
|
455
|
-
if ((!rootRecord || rootRecord.targetable === false)
|
|
456
|
-
&& !tracker.hasSafeFinalSnapshot?.()) {
|
|
457
|
-
const error = identityRefresh?.error || tracker.getLastError()
|
|
458
|
-
|| new Error(
|
|
459
|
-
`Agent pid=${Number(child?.pid || 0)} could not establish an immutable root identity `
|
|
460
|
-
+ 'or a safe root-absent exit snapshot.'
|
|
461
|
-
);
|
|
462
|
-
return { ok: false, error, remaining: [] };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
let lastError = null;
|
|
466
|
-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
467
|
-
const refresh = await tracker.refresh();
|
|
468
|
-
if (!refresh.ok) {
|
|
469
|
-
lastError = refresh.error;
|
|
470
|
-
} else {
|
|
471
|
-
const remaining = tracker.getCurrentExactRecords();
|
|
472
|
-
if (remaining.length === 0
|
|
473
|
-
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
474
|
-
return { ok: true, remaining: [] };
|
|
475
|
-
}
|
|
476
|
-
const ordered = [...remaining].sort((left, right) => (
|
|
477
|
-
Number(right.depth || 0) - Number(left.depth || 0)
|
|
478
|
-
));
|
|
479
|
-
// One PowerShell invocation receives the descendant-first
|
|
480
|
-
// immutable snapshot, then rechecks every PID CreationDate
|
|
481
|
-
// immediately before its own Stop-Process. No taskkill /T
|
|
482
|
-
// traversal can follow an unverified stale PPID edge.
|
|
483
|
-
const termination = await terminateExactTreeImpl(ordered, { includeTree: false });
|
|
484
|
-
if (termination?.ok === false) lastError = termination.error || lastError;
|
|
485
|
-
}
|
|
486
|
-
if (attempt < attempts) {
|
|
487
|
-
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
const finalRefresh = await tracker.refresh();
|
|
492
|
-
const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
|
|
493
|
-
if (finalRefresh.ok
|
|
494
|
-
&& remaining.length === 0
|
|
495
|
-
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
496
|
-
return { ok: true, remaining: [] };
|
|
497
|
-
}
|
|
498
|
-
const error = finalRefresh.error || lastError || new Error(
|
|
499
|
-
tracker.hasAmbiguousRootReuse?.()
|
|
500
|
-
? `Agent pid=${Number(child?.pid || 0)} was reused before exact descendant cleanup completed.`
|
|
501
|
-
: `Exact Windows Agent tree retained pid(s): ${remaining.map(record => record.pid).join(', ')}`
|
|
502
|
-
);
|
|
503
|
-
reportTerminationFailure(
|
|
504
|
-
`[LiveDesk Client] Exact Windows Agent tree could not be drained after `
|
|
505
|
-
+ `${attempts} bounded attempt(s): ${error?.message || error}`
|
|
506
|
-
);
|
|
507
|
-
return { ok: false, error, remaining };
|
|
508
|
-
})();
|
|
509
|
-
return child.livedeskWindowsDrainPromise;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Fail-closed wrapper for lifecycle replacement and terminal shutdown.
|
|
514
|
-
* A bounded drain attempt can fail because CIM/taskkill is temporarily busy;
|
|
515
|
-
* that must never let the launcher exit and orphan an owned FFmpeg descendant.
|
|
516
|
-
*/
|
|
517
|
-
export async function drainOwnedWindowsAgentTreeUntilStopped(child, {
|
|
518
|
-
platform = process.platform,
|
|
519
|
-
retryForeverMs = 1000,
|
|
520
|
-
waitImpl = waitMilliseconds,
|
|
521
|
-
reportTerminationFailure = message => console.error(message),
|
|
522
|
-
...drainOptions
|
|
523
|
-
} = {}) {
|
|
524
|
-
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
525
|
-
if (child?.livedeskWindowsDrainUntilStoppedPromise) {
|
|
526
|
-
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
child.livedeskWindowsDrainUntilStoppedPromise = (async () => {
|
|
530
|
-
let attempt = 0;
|
|
531
|
-
while (true) {
|
|
532
|
-
attempt += 1;
|
|
533
|
-
// drainOwnedWindowsAgentTree memoizes one bounded proof attempt.
|
|
534
|
-
// Clear only that completed attempt; the immutable tracker remains
|
|
535
|
-
// attached to the exact child and is refreshed on every retry.
|
|
536
|
-
child.livedeskWindowsDrainPromise = null;
|
|
537
|
-
let result;
|
|
538
|
-
try {
|
|
539
|
-
result = await drainOwnedWindowsAgentTree(child, {
|
|
540
|
-
platform,
|
|
541
|
-
waitImpl,
|
|
542
|
-
reportTerminationFailure,
|
|
543
|
-
...drainOptions
|
|
544
|
-
});
|
|
545
|
-
} catch (error) {
|
|
546
|
-
result = { ok: false, error, remaining: [] };
|
|
547
|
-
}
|
|
548
|
-
if (result?.ok !== false) {
|
|
549
|
-
return result;
|
|
550
|
-
}
|
|
551
|
-
reportTerminationFailure(
|
|
552
|
-
`[LiveDesk Client] Exact Windows Agent tree is not drained yet `
|
|
553
|
-
+ `(attempt ${attempt}); the launcher will remain alive and retry.`
|
|
554
|
-
);
|
|
555
|
-
await waitImpl(Math.max(100, Number(retryForeverMs) || 1000));
|
|
556
|
-
}
|
|
557
|
-
})();
|
|
558
|
-
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
export function isOwnedUnixProcessGroupAlive(child, platform = process.platform, signalProcess = (pid, signal) => process.kill(pid, signal)) {
|
|
562
|
-
const processId = Number(child?.pid || 0);
|
|
563
|
-
if (platform === 'win32'
|
|
564
|
-
|| child?.livedeskOwnsProcessGroup !== true
|
|
565
|
-
|| !Number.isInteger(processId)
|
|
566
|
-
|| processId <= 1) {
|
|
567
|
-
return false;
|
|
568
|
-
}
|
|
569
|
-
try {
|
|
570
|
-
signalProcess(-processId, 0);
|
|
571
|
-
return true;
|
|
572
|
-
} catch (error) {
|
|
573
|
-
return error?.code === 'EPERM';
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
export function signalAgentTree(
|
|
578
|
-
child,
|
|
579
|
-
signal,
|
|
580
|
-
platform = process.platform,
|
|
581
|
-
signalProcess = (pid, requestedSignal) => process.kill(pid, requestedSignal)
|
|
582
|
-
) {
|
|
583
|
-
const processId = Number(child?.pid || 0);
|
|
584
|
-
if (platform !== 'win32'
|
|
585
|
-
&& child?.livedeskOwnsProcessGroup === true
|
|
586
|
-
&& Number.isInteger(processId)
|
|
587
|
-
&& processId > 1) {
|
|
588
|
-
try {
|
|
589
|
-
signalProcess(-processId, signal);
|
|
590
|
-
return;
|
|
591
|
-
} catch (error) {
|
|
592
|
-
if (error?.code !== 'ESRCH') throw error;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
child.kill(signal);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function waitMilliseconds(milliseconds) {
|
|
599
|
-
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Contract:
|
|
604
|
-
* - Applies only to a Unix Agent that this launcher spawned as a dedicated
|
|
605
|
-
* detached process-group leader.
|
|
606
|
-
* - Resolves only after the owned PGID no longer exists.
|
|
607
|
-
* - Never searches for, or signals, ffmpeg/SCK processes by global name.
|
|
608
|
-
* - A surviving capture descendant keeps the original PGID alive, so group
|
|
609
|
-
* signaling drains it without having to identify that descendant globally.
|
|
610
|
-
*/
|
|
611
|
-
export async function drainOwnedUnixAgentTree(child, {
|
|
612
|
-
platform = process.platform,
|
|
613
|
-
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
614
|
-
gracefulSignal = 'SIGTERM',
|
|
615
|
-
gracefulTimeoutMs = 1000,
|
|
616
|
-
hardRetryMs = 1000,
|
|
617
|
-
pollMs = 50,
|
|
618
|
-
reportTerminationFailure = message => console.error(message)
|
|
619
|
-
} = {}) {
|
|
620
|
-
const processId = Number(child?.pid || 0);
|
|
621
|
-
const ownsUnixGroup = platform !== 'win32'
|
|
622
|
-
&& child?.livedeskOwnsProcessGroup === true
|
|
623
|
-
&& Number.isInteger(processId)
|
|
624
|
-
&& processId > 1;
|
|
625
|
-
if (!ownsUnixGroup || !isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const signalOwnedGroup = signal => {
|
|
630
|
-
try {
|
|
631
|
-
signalProcess(-processId, signal);
|
|
632
|
-
return true;
|
|
633
|
-
} catch (error) {
|
|
634
|
-
if (error?.code === 'ESRCH') return false;
|
|
635
|
-
throw error;
|
|
636
|
-
}
|
|
637
|
-
};
|
|
638
|
-
try {
|
|
639
|
-
signalOwnedGroup(gracefulSignal);
|
|
640
|
-
} catch (error) {
|
|
641
|
-
reportTerminationFailure(
|
|
642
|
-
`[LiveDesk Client] Exited Agent group pid=${processId} rejected ${gracefulSignal} `
|
|
643
|
-
+ `(${error?.message || error}); forced cleanup will continue.`
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const gracefulDeadline = Date.now() + Math.max(100, Number(gracefulTimeoutMs) || 1000);
|
|
648
|
-
const boundedPollMs = Math.max(10, Number(pollMs) || 50);
|
|
649
|
-
while (Date.now() < gracefulDeadline) {
|
|
650
|
-
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
651
|
-
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, gracefulDeadline - Date.now())));
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
let failureReported = false;
|
|
655
|
-
const retryMs = Math.max(100, Number(hardRetryMs) || 1000);
|
|
656
|
-
while (isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
657
|
-
try {
|
|
658
|
-
signalOwnedGroup('SIGKILL');
|
|
659
|
-
} catch (error) {
|
|
660
|
-
if (!failureReported) {
|
|
661
|
-
failureReported = true;
|
|
662
|
-
reportTerminationFailure(
|
|
663
|
-
`[LiveDesk Client] Exited Agent group pid=${processId} rejected SIGKILL `
|
|
664
|
-
+ `(${error?.message || error}). The launcher will not start a replacement until the group drains.`
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
669
|
-
if (!failureReported) {
|
|
670
|
-
failureReported = true;
|
|
671
|
-
reportTerminationFailure(
|
|
672
|
-
`[LiveDesk Client] Exited Agent group pid=${processId} still has capture descendants after SIGKILL. `
|
|
673
|
-
+ 'The launcher will not start a replacement until the group drains.'
|
|
674
|
-
);
|
|
675
|
-
}
|
|
676
|
-
const retryDeadline = Date.now() + retryMs;
|
|
677
|
-
while (Date.now() < retryDeadline) {
|
|
678
|
-
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
679
|
-
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, retryDeadline - Date.now())));
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
export function installAgentTerminationHandlers({
|
|
685
|
-
hostProcess = process,
|
|
686
|
-
getAgentProcess,
|
|
687
|
-
shutdownTimeoutMs = 8000,
|
|
688
|
-
platform = process.platform,
|
|
689
|
-
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
690
|
-
drainWindowsTree = drainOwnedWindowsAgentTreeUntilStopped,
|
|
691
|
-
windowsTerminateAttemptLimit = 3,
|
|
692
|
-
windowsTerminateRetryMs = 100,
|
|
693
|
-
windowsRecoveryRetryMs = 1000,
|
|
694
|
-
unixTerminateRetryMs = 1000,
|
|
695
|
-
reportTerminationFailure = message => console.error(message),
|
|
696
|
-
exitProcess = code => hostProcess.exit(code)
|
|
697
|
-
} = {}) {
|
|
698
|
-
if (typeof getAgentProcess !== 'function') {
|
|
699
|
-
throw new TypeError('getAgentProcess is required.');
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
let terminating = false;
|
|
703
|
-
const handlers = new Map();
|
|
704
|
-
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
705
|
-
const handler = () => {
|
|
706
|
-
if (terminating) return;
|
|
707
|
-
terminating = true;
|
|
708
|
-
const exitCode = SIGNAL_EXIT_CODES[signal] || 1;
|
|
709
|
-
const child = getAgentProcess();
|
|
710
|
-
if (!child) {
|
|
711
|
-
exitProcess(exitCode);
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
const exitedUnixLeaderStillOwnsGroup = platform !== 'win32'
|
|
715
|
-
&& child?.livedeskOwnsProcessGroup === true
|
|
716
|
-
&& child.exitCode !== null
|
|
717
|
-
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
718
|
-
// Windows must still drain immutable recorded descendants when
|
|
719
|
-
// Ctrl+C arrives after the Agent leader has already exited.
|
|
720
|
-
if (platform !== 'win32'
|
|
721
|
-
&& child.exitCode !== null
|
|
722
|
-
&& !exitedUnixLeaderStillOwnsGroup) {
|
|
723
|
-
exitProcess(exitCode);
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
let finished = false;
|
|
728
|
-
let poll = null;
|
|
729
|
-
let timeout = null;
|
|
730
|
-
let unixRetryTimer = null;
|
|
731
|
-
let windowsRetryTimer = null;
|
|
732
|
-
const finish = () => {
|
|
733
|
-
if (finished) return;
|
|
734
|
-
finished = true;
|
|
735
|
-
if (poll) clearInterval(poll);
|
|
736
|
-
if (timeout) clearTimeout(timeout);
|
|
737
|
-
if (unixRetryTimer) clearTimeout(unixRetryTimer);
|
|
738
|
-
if (windowsRetryTimer) clearTimeout(windowsRetryTimer);
|
|
739
|
-
exitProcess(exitCode);
|
|
740
|
-
};
|
|
741
|
-
const processId = Number(child?.pid || 0);
|
|
742
|
-
if (platform === 'win32') {
|
|
743
|
-
if (!Number.isInteger(processId) || processId <= 1) {
|
|
744
|
-
finish();
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
// Use the same immutable PID + CreationDate drain as internal
|
|
748
|
-
// restarts and unexpected Agent exits. The promise is stored
|
|
749
|
-
// on the child so spawnAgent cannot race ahead and launch a
|
|
750
|
-
// replacement while terminal shutdown is still draining.
|
|
751
|
-
const drainUntilStopped = () => {
|
|
752
|
-
if (finished) return;
|
|
753
|
-
if (!child.livedeskTreeStopPromise) {
|
|
754
|
-
child.livedeskTreeStopPromise = Promise.resolve().then(() => drainWindowsTree(child, {
|
|
755
|
-
platform,
|
|
756
|
-
maxAttempts: windowsTerminateAttemptLimit,
|
|
757
|
-
retryMs: windowsTerminateRetryMs,
|
|
758
|
-
retryForeverMs: windowsRecoveryRetryMs,
|
|
759
|
-
reportTerminationFailure
|
|
760
|
-
}));
|
|
761
|
-
}
|
|
762
|
-
child.livedeskTreeStopPromise
|
|
763
|
-
.catch(error => ({ ok: false, error }))
|
|
764
|
-
.then(result => {
|
|
765
|
-
if (result?.ok !== false) {
|
|
766
|
-
finish();
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
reportTerminationFailure(
|
|
770
|
-
`[LiveDesk Client] Terminal shutdown retained an exact Windows Agent tree: `
|
|
771
|
-
+ `${result.error?.message || 'bounded drain failed'}. Retrying before exit.`
|
|
772
|
-
);
|
|
773
|
-
child.livedeskTreeStopPromise = null;
|
|
774
|
-
child.livedeskWindowsDrainPromise = null;
|
|
775
|
-
windowsRetryTimer = setTimeout(
|
|
776
|
-
drainUntilStopped,
|
|
777
|
-
Math.max(100, Number(windowsRecoveryRetryMs) || 1000)
|
|
778
|
-
);
|
|
779
|
-
});
|
|
780
|
-
};
|
|
781
|
-
drainUntilStopped();
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
const finishWhenTreeStops = () => {
|
|
785
|
-
const ownsUnixGroup = child?.livedeskOwnsProcessGroup === true
|
|
786
|
-
&& Number(child?.pid || 0) > 1;
|
|
787
|
-
const childAlive = child.exitCode === null && child.signalCode == null;
|
|
788
|
-
const groupAlive = ownsUnixGroup
|
|
789
|
-
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
790
|
-
// A missing owned process group proves that both its Agent
|
|
791
|
-
// leader and capture descendants are gone even if Node has not
|
|
792
|
-
// delivered the child exit event yet.
|
|
793
|
-
if (ownsUnixGroup ? !groupAlive : !childAlive) {
|
|
794
|
-
finish();
|
|
795
|
-
}
|
|
796
|
-
};
|
|
797
|
-
child.once('exit', finishWhenTreeStops);
|
|
798
|
-
try {
|
|
799
|
-
signalAgentTree(child, signal, platform, signalProcess);
|
|
800
|
-
} catch (error) {
|
|
801
|
-
reportTerminationFailure(
|
|
802
|
-
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} could not receive ${signal} `
|
|
803
|
-
+ `(${error?.message || error}). Forced cleanup will continue.`
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
poll = setInterval(finishWhenTreeStops, 50);
|
|
807
|
-
const retryMs = Math.max(100, Number(unixTerminateRetryMs) || 1000);
|
|
808
|
-
let hardFailureReported = false;
|
|
809
|
-
const terminateOwnedUnixTree = () => {
|
|
810
|
-
if (finished) return;
|
|
811
|
-
finishWhenTreeStops();
|
|
812
|
-
if (finished) return;
|
|
813
|
-
try {
|
|
814
|
-
signalAgentTree(child, 'SIGKILL', platform, signalProcess);
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if (!hardFailureReported) {
|
|
817
|
-
hardFailureReported = true;
|
|
818
|
-
reportTerminationFailure(
|
|
819
|
-
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} rejected SIGKILL `
|
|
820
|
-
+ `(${error?.message || error}). The launcher will remain alive and retry.`
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
finishWhenTreeStops();
|
|
825
|
-
if (finished) return;
|
|
826
|
-
if (!hardFailureReported) {
|
|
827
|
-
hardFailureReported = true;
|
|
828
|
-
reportTerminationFailure(
|
|
829
|
-
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} is still alive after SIGKILL. `
|
|
830
|
-
+ 'The launcher will remain alive and keep retrying so capture descendants are not orphaned.'
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
unixRetryTimer = setTimeout(terminateOwnedUnixTree, retryMs);
|
|
834
|
-
};
|
|
835
|
-
timeout = setTimeout(() => {
|
|
836
|
-
terminateOwnedUnixTree();
|
|
837
|
-
}, Math.max(100, Number(shutdownTimeoutMs) || 3000));
|
|
838
|
-
};
|
|
839
|
-
handlers.set(signal, handler);
|
|
840
|
-
// Keep the listener installed while cleanup is in progress. A second
|
|
841
|
-
// Ctrl+C must not restore Node's default immediate exit and orphan the
|
|
842
|
-
// process group that the first signal is still draining.
|
|
843
|
-
hostProcess.on(signal, handler);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
const dispose = () => {
|
|
847
|
-
for (const [signal, handler] of handlers) {
|
|
848
|
-
hostProcess.removeListener(signal, handler);
|
|
849
|
-
}
|
|
850
|
-
};
|
|
851
|
-
// Windows process.kill(pid, 'SIGTERM') can terminate a process without
|
|
852
|
-
// dispatching its JavaScript SIGTERM listener. Replacement startup must
|
|
853
|
-
// enter this exact cleanup state machine directly instead of simulating an
|
|
854
|
-
// operating-system signal.
|
|
855
|
-
dispose.requestTermination = (signal = 'SIGTERM') => {
|
|
856
|
-
const normalizedSignal = signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM';
|
|
857
|
-
handlers.get(normalizedSignal)?.();
|
|
858
|
-
};
|
|
859
|
-
return dispose;
|
|
860
|
-
}
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const SIGNAL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143 });
|
|
4
|
+
const WINDOWS_SPAWN_CLOCK_TOLERANCE_MS = 250;
|
|
5
|
+
|
|
6
|
+
function normalizeWindowsProcessRecord(value) {
|
|
7
|
+
const pid = Number(value?.pid ?? value?.ProcessId ?? 0);
|
|
8
|
+
const parentPid = Number(value?.parentPid ?? value?.ParentProcessId ?? 0);
|
|
9
|
+
const startMarker = String(value?.startMarker ?? value?.CreationDate ?? '').trim();
|
|
10
|
+
const startOrder = String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim();
|
|
11
|
+
if (!Number.isInteger(pid)
|
|
12
|
+
|| pid <= 1
|
|
13
|
+
|| !startMarker
|
|
14
|
+
|| !/^\d+$/.test(startOrder)) return null;
|
|
15
|
+
return { pid, parentPid, startMarker, startOrder };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function windowsRecordKey(record) {
|
|
19
|
+
return `${Number(record?.pid || 0)}:${String(record?.startOrder || '')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sameWindowsProcess(left, right) {
|
|
23
|
+
return Number(left?.pid || 0) === Number(right?.pid || 0)
|
|
24
|
+
&& String(left?.startOrder || '') === String(right?.startOrder || '')
|
|
25
|
+
&& String(left?.startMarker || '') === String(right?.startMarker || '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function windowsTicksForUnixMilliseconds(milliseconds) {
|
|
29
|
+
return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unixMillisecondsForWindowsTicks(ticks) {
|
|
33
|
+
try {
|
|
34
|
+
return Number((BigInt(ticks) - 621_355_968_000_000_000n) / 10_000n);
|
|
35
|
+
} catch {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
|
|
41
|
+
if (!record?.startOrder) return false;
|
|
42
|
+
try {
|
|
43
|
+
const order = BigInt(record.startOrder);
|
|
44
|
+
// spawnedAtMs is recorded immediately before spawn(). A small 250ms
|
|
45
|
+
// tolerance covers clock conversion/scheduling without admitting an
|
|
46
|
+
// old process whose stale ParentProcessId happens to equal the Agent.
|
|
47
|
+
const lower = windowsTicksForUnixMilliseconds(
|
|
48
|
+
Math.max(0, spawnedAtMs - WINDOWS_SPAWN_CLOCK_TOLERANCE_MS)
|
|
49
|
+
);
|
|
50
|
+
const upper = exitedAtMs === null
|
|
51
|
+
? windowsTicksForUnixMilliseconds(Date.now() + 5000)
|
|
52
|
+
: windowsTicksForUnixMilliseconds(exitedAtMs);
|
|
53
|
+
return order >= lower && order <= upper;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function runExecFile(command, args, options = {}, execFileImpl = execFile) {
|
|
60
|
+
return new Promise(resolve => {
|
|
61
|
+
execFileImpl(command, args, options, (error, stdout, stderr) => {
|
|
62
|
+
resolve({
|
|
63
|
+
ok: !error,
|
|
64
|
+
error: error || null,
|
|
65
|
+
stdout: String(stdout || '').trim(),
|
|
66
|
+
stderr: String(stderr || '').trim()
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns immutable PID + CreationDate records only. Command lines are
|
|
74
|
+
* intentionally excluded so no globally named ffmpeg process can become a
|
|
75
|
+
* termination target.
|
|
76
|
+
*/
|
|
77
|
+
export async function queryWindowsProcessTable({
|
|
78
|
+
execFileImpl = execFile
|
|
79
|
+
} = {}) {
|
|
80
|
+
const script = [
|
|
81
|
+
'$ErrorActionPreference = "Stop";',
|
|
82
|
+
'@(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {',
|
|
83
|
+
' $creation = [string]$_.CreationDate;',
|
|
84
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
85
|
+
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
86
|
+
'}) | ConvertTo-Json -Compress'
|
|
87
|
+
].join(' ');
|
|
88
|
+
const result = await runExecFile(
|
|
89
|
+
'powershell.exe',
|
|
90
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
91
|
+
{ windowsHide: true, timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 },
|
|
92
|
+
execFileImpl
|
|
93
|
+
);
|
|
94
|
+
if (!result.ok || !result.stdout) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Windows process-tree snapshot failed: `
|
|
97
|
+
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(result.stdout);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`Windows process-tree snapshot returned invalid JSON: ${error?.message || error}`);
|
|
105
|
+
}
|
|
106
|
+
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
107
|
+
.map(normalizeWindowsProcessRecord)
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Captures only the named immutable identities. Normal Agent startup uses this
|
|
113
|
+
* for the Agent root plus its launcher owner instead of enumerating every
|
|
114
|
+
* process on the computer. Full-table discovery remains a terminal/recovery
|
|
115
|
+
* operation where descendants actually need to be found.
|
|
116
|
+
*/
|
|
117
|
+
export async function queryWindowsProcessIdentities(processIds, {
|
|
118
|
+
execFileImpl = execFile
|
|
119
|
+
} = {}) {
|
|
120
|
+
const ids = [...new Set((Array.isArray(processIds) ? processIds : [processIds])
|
|
121
|
+
.map(value => Number(value || 0))
|
|
122
|
+
.filter(value => Number.isInteger(value) && value > 1))]
|
|
123
|
+
.slice(0, 8);
|
|
124
|
+
if (ids.length === 0) return [];
|
|
125
|
+
const filter = ids.map(pid => `ProcessId = ${pid}`).join(' OR ');
|
|
126
|
+
const script = [
|
|
127
|
+
'$ErrorActionPreference = "Stop";',
|
|
128
|
+
`@(Get-CimInstance Win32_Process -Filter "${filter}" -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object {`,
|
|
129
|
+
' $creation = [string]$_.CreationDate;',
|
|
130
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
131
|
+
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
132
|
+
'}) | ConvertTo-Json -Compress'
|
|
133
|
+
].join(' ');
|
|
134
|
+
const result = await runExecFile(
|
|
135
|
+
'powershell.exe',
|
|
136
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
137
|
+
{ windowsHide: true, timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 },
|
|
138
|
+
execFileImpl
|
|
139
|
+
);
|
|
140
|
+
if (!result.ok || !result.stdout) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Windows process identity query failed: `
|
|
143
|
+
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(result.stdout);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new Error(`Windows process identity query returned invalid JSON: ${error?.message || error}`);
|
|
151
|
+
}
|
|
152
|
+
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
153
|
+
.map(normalizeWindowsProcessRecord)
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Rechecks CreationDate and force-stops the single PID inside the same
|
|
159
|
+
* PowerShell process. PID reuse therefore becomes a harmless identity mismatch
|
|
160
|
+
* instead of terminating the replacement process.
|
|
161
|
+
*/
|
|
162
|
+
export async function terminateExactWindowsProcessTree(recordOrRecords, {
|
|
163
|
+
execFileImpl = execFile
|
|
164
|
+
} = {}) {
|
|
165
|
+
const targets = (Array.isArray(recordOrRecords) ? recordOrRecords : [recordOrRecords])
|
|
166
|
+
.map(normalizeWindowsProcessRecord)
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
if (targets.length === 0) {
|
|
169
|
+
return { ok: false, error: new Error('At least one immutable Windows process record is required.') };
|
|
170
|
+
}
|
|
171
|
+
const targetsJson = JSON.stringify(targets.map(target => ({
|
|
172
|
+
pid: target.pid,
|
|
173
|
+
startOrder: target.startOrder
|
|
174
|
+
}))).replaceAll("'", "''");
|
|
175
|
+
const script = [
|
|
176
|
+
'$ErrorActionPreference = "Stop";',
|
|
177
|
+
`$targets = ConvertFrom-Json -InputObject '${targetsJson}';`,
|
|
178
|
+
'$failures = [System.Collections.Generic.List[string]]::new();',
|
|
179
|
+
'foreach ($item in $targets) {',
|
|
180
|
+
' $targetPid = [int]$item.pid;',
|
|
181
|
+
' $expectedStartTicks = [string]$item.startOrder;',
|
|
182
|
+
' try {',
|
|
183
|
+
' $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
184
|
+
' if (-not $target) { continue };',
|
|
185
|
+
' $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
186
|
+
' if ($targetStartTicks -ne $expectedStartTicks) { continue };',
|
|
187
|
+
' Stop-Process -Id $targetPid -Force -ErrorAction Stop;',
|
|
188
|
+
' } catch {',
|
|
189
|
+
' $failures.Add("pid=$targetPid $($_.Exception.Message)");',
|
|
190
|
+
' }',
|
|
191
|
+
'}',
|
|
192
|
+
'if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 };',
|
|
193
|
+
'exit 0;'
|
|
194
|
+
].join(' ');
|
|
195
|
+
return runExecFile(
|
|
196
|
+
'powershell.exe',
|
|
197
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
198
|
+
{ windowsHide: true, timeout: 15000, killSignal: 'SIGKILL' },
|
|
199
|
+
execFileImpl
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Tracks the exact Agent root and only descendants connected through Windows
|
|
205
|
+
* ParentProcessId records. Startup queries only the Agent and launcher
|
|
206
|
+
* identities. Full process-table snapshots are serialized and request-driven
|
|
207
|
+
* by terminal cleanup/recovery; a healthy connected Client owns no recurring
|
|
208
|
+
* PowerShell/CIM timer.
|
|
209
|
+
*/
|
|
210
|
+
export function createWindowsProcessTreeTracker(child, {
|
|
211
|
+
platform = process.platform,
|
|
212
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
213
|
+
queryInitialProcessRecordsImpl = null,
|
|
214
|
+
spawnedAtMs = Date.now(),
|
|
215
|
+
ownerPid = process.pid,
|
|
216
|
+
initialRetryDelaysMs = [25, 75, 150],
|
|
217
|
+
waitImpl = waitMilliseconds,
|
|
218
|
+
publishTrackedRecords = null,
|
|
219
|
+
reportPublisherError = error => console.warn(
|
|
220
|
+
`[LiveDesk Client] Windows owned-process manifest warning: ${error?.message || error}`
|
|
221
|
+
)
|
|
222
|
+
} = {}) {
|
|
223
|
+
const rootPid = Number(child?.pid || 0);
|
|
224
|
+
const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
|
|
225
|
+
const tracked = new Map();
|
|
226
|
+
let rootRecord = null;
|
|
227
|
+
let lastSnapshot = [];
|
|
228
|
+
let lastError = null;
|
|
229
|
+
let inFlight = null;
|
|
230
|
+
let stopped = false;
|
|
231
|
+
let rootExitedAtMs = null;
|
|
232
|
+
let rootCapturedBeforeExit = false;
|
|
233
|
+
let lastSnapshotHadRootPid = false;
|
|
234
|
+
let successfulSnapshotAfterExit = false;
|
|
235
|
+
let ambiguousRootReuse = false;
|
|
236
|
+
let lastPublisherError = null;
|
|
237
|
+
|
|
238
|
+
const mergeSnapshot = snapshot => {
|
|
239
|
+
const records = (Array.isArray(snapshot) ? snapshot : [])
|
|
240
|
+
.map(normalizeWindowsProcessRecord)
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
lastSnapshot = records;
|
|
243
|
+
const currentByPid = new Map(records.map(record => [record.pid, record]));
|
|
244
|
+
const currentRoot = currentByPid.get(rootPid) || null;
|
|
245
|
+
lastSnapshotHadRootPid = Boolean(currentRoot);
|
|
246
|
+
const rootWasReused = Boolean(
|
|
247
|
+
rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)
|
|
248
|
+
);
|
|
249
|
+
if (rootWasReused) {
|
|
250
|
+
ambiguousRootReuse = true;
|
|
251
|
+
}
|
|
252
|
+
if (rootRecord && rootExitedAtMs === null && (!currentRoot || rootWasReused)) {
|
|
253
|
+
// The exit event can trail the first CIM snapshot that proves the
|
|
254
|
+
// exact root is gone. Use that proof immediately so a surviving
|
|
255
|
+
// child first observed in this same snapshot is still owned.
|
|
256
|
+
// For PID reuse, cap the old lifetime just before the replacement
|
|
257
|
+
// CreationDate so replacement-root children remain excluded.
|
|
258
|
+
rootExitedAtMs = rootWasReused
|
|
259
|
+
? unixMillisecondsForWindowsTicks(currentRoot.startOrder) - 1
|
|
260
|
+
: Date.now();
|
|
261
|
+
}
|
|
262
|
+
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
263
|
+
if (!rootRecord
|
|
264
|
+
&& currentRoot
|
|
265
|
+
&& windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
|
|
266
|
+
rootCapturedBeforeExit = rootExitedAtMs === null;
|
|
267
|
+
rootRecord = {
|
|
268
|
+
...currentRoot,
|
|
269
|
+
depth: 0,
|
|
270
|
+
targetable: rootCapturedBeforeExit
|
|
271
|
+
};
|
|
272
|
+
if (rootRecord.targetable) {
|
|
273
|
+
tracked.set(windowsRecordKey(rootRecord), rootRecord);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const exactCurrentByPid = new Map();
|
|
278
|
+
if (rootRecord && currentRoot && sameWindowsProcess(currentRoot, rootRecord)) {
|
|
279
|
+
exactCurrentByPid.set(rootPid, rootRecord);
|
|
280
|
+
}
|
|
281
|
+
for (const record of tracked.values()) {
|
|
282
|
+
const current = currentByPid.get(record.pid);
|
|
283
|
+
if (current && sameWindowsProcess(current, record)) {
|
|
284
|
+
exactCurrentByPid.set(record.pid, record);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Windows retains ParentProcessId on surviving direct children after
|
|
289
|
+
// the root exits. A reused root PID is also safe to bridge only for a
|
|
290
|
+
// direct child whose immutable creation time is inside the original
|
|
291
|
+
// root lifetime; children created by the replacement root are rejected
|
|
292
|
+
// by windowsRecordIsWithinLifetime.
|
|
293
|
+
const rootAbsent = !currentRoot;
|
|
294
|
+
let changed = true;
|
|
295
|
+
while (changed) {
|
|
296
|
+
changed = false;
|
|
297
|
+
for (const record of records) {
|
|
298
|
+
if (record.pid === rootPid || tracked.has(windowsRecordKey(record))) continue;
|
|
299
|
+
if (!windowsRecordIsWithinLifetime(record, spawnedAtMs, rootExitedAtMs)) continue;
|
|
300
|
+
let parent = exactCurrentByPid.get(record.parentPid) || null;
|
|
301
|
+
if (!parent
|
|
302
|
+
&& rootExitedAtMs !== null
|
|
303
|
+
&& (rootAbsent || rootWasReused)
|
|
304
|
+
&& record.parentPid === rootPid) {
|
|
305
|
+
parent = rootRecord || { pid: rootPid, depth: 0 };
|
|
306
|
+
}
|
|
307
|
+
if (!parent) continue;
|
|
308
|
+
const descendant = { ...record, depth: Number(parent.depth || 0) + 1 };
|
|
309
|
+
tracked.set(windowsRecordKey(descendant), descendant);
|
|
310
|
+
exactCurrentByPid.set(descendant.pid, descendant);
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const initialQuery = typeof queryInitialProcessRecordsImpl === 'function'
|
|
317
|
+
? queryInitialProcessRecordsImpl
|
|
318
|
+
: queryProcessTableImpl === queryWindowsProcessTable
|
|
319
|
+
? () => queryWindowsProcessIdentities([rootPid, ownerPid])
|
|
320
|
+
: queryProcessTableImpl;
|
|
321
|
+
const boundedInitialRetryDelays = (
|
|
322
|
+
Array.isArray(initialRetryDelaysMs) ? initialRetryDelaysMs : []
|
|
323
|
+
)
|
|
324
|
+
.slice(0, 5)
|
|
325
|
+
.map(value => Math.min(1000, Math.max(1, Number(value) || 1)));
|
|
326
|
+
|
|
327
|
+
const refresh = async ({ initial = false } = {}) => {
|
|
328
|
+
if (!enabled) return { ok: true, records: [] };
|
|
329
|
+
if (inFlight) return inFlight;
|
|
330
|
+
inFlight = Promise.resolve()
|
|
331
|
+
.then(() => (initial ? initialQuery() : queryProcessTableImpl()))
|
|
332
|
+
.then(async snapshot => {
|
|
333
|
+
mergeSnapshot(snapshot);
|
|
334
|
+
lastError = null;
|
|
335
|
+
if (typeof publishTrackedRecords === 'function') {
|
|
336
|
+
try {
|
|
337
|
+
await publishTrackedRecords(
|
|
338
|
+
[...tracked.values()].map(record => ({ ...record })),
|
|
339
|
+
{
|
|
340
|
+
rootPid,
|
|
341
|
+
rootRecord: rootRecord ? { ...rootRecord } : null,
|
|
342
|
+
snapshot: lastSnapshot.map(record => ({ ...record })),
|
|
343
|
+
terminal: stopped
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
lastPublisherError = null;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// Manifest publication must never break the in-memory
|
|
349
|
+
// cleanup proof. Keep tracking, surface diagnostics,
|
|
350
|
+
// and retry on the next requested/terminal refresh.
|
|
351
|
+
lastPublisherError = error;
|
|
352
|
+
try { reportPublisherError(error); } catch { /* diagnostics cannot own lifecycle */ }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
records: lastSnapshot,
|
|
358
|
+
publisherError: lastPublisherError
|
|
359
|
+
};
|
|
360
|
+
})
|
|
361
|
+
.catch(error => {
|
|
362
|
+
lastError = error;
|
|
363
|
+
return { ok: false, error, records: lastSnapshot };
|
|
364
|
+
})
|
|
365
|
+
.finally(() => {
|
|
366
|
+
inFlight = null;
|
|
367
|
+
});
|
|
368
|
+
return inFlight;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const ready = enabled
|
|
372
|
+
? (async () => {
|
|
373
|
+
let result = await refresh({ initial: true });
|
|
374
|
+
for (const retryDelayMs of boundedInitialRetryDelays) {
|
|
375
|
+
if (rootRecord || stopped) break;
|
|
376
|
+
// A just-spawned PID can briefly lag its CIM identity. Retry
|
|
377
|
+
// only the exact root/launcher filter; never compensate with
|
|
378
|
+
// a healthy-state full-table timer.
|
|
379
|
+
await waitImpl(retryDelayMs);
|
|
380
|
+
result = await refresh({ initial: true });
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
})()
|
|
384
|
+
: Promise.resolve({ ok: true, records: [] });
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
enabled,
|
|
388
|
+
ready,
|
|
389
|
+
refresh,
|
|
390
|
+
stop() {
|
|
391
|
+
stopped = true;
|
|
392
|
+
},
|
|
393
|
+
getRootRecord: () => rootRecord ? { ...rootRecord } : null,
|
|
394
|
+
markRootExited(exitedAtMs = Date.now()) {
|
|
395
|
+
if (rootExitedAtMs === null) {
|
|
396
|
+
rootExitedAtMs = Number(exitedAtMs) || Date.now();
|
|
397
|
+
successfulSnapshotAfterExit = false;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
hasSafeFinalSnapshot() {
|
|
401
|
+
return rootCapturedBeforeExit
|
|
402
|
+
|| (rootExitedAtMs !== null && successfulSnapshotAfterExit && !lastSnapshotHadRootPid);
|
|
403
|
+
},
|
|
404
|
+
hasAmbiguousRootReuse: () => ambiguousRootReuse,
|
|
405
|
+
getTrackedRecords: () => [...tracked.values()].map(record => ({ ...record })),
|
|
406
|
+
getCurrentExactRecords() {
|
|
407
|
+
const currentByPid = new Map(lastSnapshot.map(record => [record.pid, record]));
|
|
408
|
+
return [...tracked.values()]
|
|
409
|
+
.filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
|
|
410
|
+
.map(record => ({ ...record }));
|
|
411
|
+
},
|
|
412
|
+
getLastError: () => lastError,
|
|
413
|
+
getLastPublisherError: () => lastPublisherError
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Bounded exact-tree drain used by internal restarts, unexpected exits, and
|
|
419
|
+
* terminal shutdown. A nonzero taskkill is not itself failure: each attempt is
|
|
420
|
+
* followed by a fresh immutable snapshot, and an already-gone tree succeeds.
|
|
421
|
+
*/
|
|
422
|
+
export async function drainOwnedWindowsAgentTree(child, {
|
|
423
|
+
platform = process.platform,
|
|
424
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
425
|
+
terminateExactTreeImpl = terminateExactWindowsProcessTree,
|
|
426
|
+
maxAttempts = 3,
|
|
427
|
+
retryMs = 100,
|
|
428
|
+
waitImpl = waitMilliseconds,
|
|
429
|
+
reportTerminationFailure = message => console.error(message)
|
|
430
|
+
} = {}) {
|
|
431
|
+
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
432
|
+
if (child?.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise;
|
|
433
|
+
|
|
434
|
+
child.livedeskWindowsDrainPromise = (async () => {
|
|
435
|
+
const tracker = child?.livedeskWindowsTreeTracker
|
|
436
|
+
|| createWindowsProcessTreeTracker(child, { platform, queryProcessTableImpl });
|
|
437
|
+
child.livedeskWindowsTreeTracker = tracker;
|
|
438
|
+
if (child?.exitCode !== null && child?.exitCode !== undefined) {
|
|
439
|
+
tracker.markRootExited?.();
|
|
440
|
+
}
|
|
441
|
+
tracker.stop();
|
|
442
|
+
await tracker.ready;
|
|
443
|
+
const attempts = Math.max(1, Number(maxAttempts) || 3);
|
|
444
|
+
let identityRefresh = await tracker.refresh();
|
|
445
|
+
let identityAttempt = 1;
|
|
446
|
+
let rootRecord = tracker.getRootRecord();
|
|
447
|
+
while ((!rootRecord || rootRecord.targetable === false)
|
|
448
|
+
&& !tracker.hasSafeFinalSnapshot?.()
|
|
449
|
+
&& identityAttempt < attempts) {
|
|
450
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
451
|
+
identityAttempt += 1;
|
|
452
|
+
identityRefresh = await tracker.refresh();
|
|
453
|
+
rootRecord = tracker.getRootRecord();
|
|
454
|
+
}
|
|
455
|
+
if ((!rootRecord || rootRecord.targetable === false)
|
|
456
|
+
&& !tracker.hasSafeFinalSnapshot?.()) {
|
|
457
|
+
const error = identityRefresh?.error || tracker.getLastError()
|
|
458
|
+
|| new Error(
|
|
459
|
+
`Agent pid=${Number(child?.pid || 0)} could not establish an immutable root identity `
|
|
460
|
+
+ 'or a safe root-absent exit snapshot.'
|
|
461
|
+
);
|
|
462
|
+
return { ok: false, error, remaining: [] };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let lastError = null;
|
|
466
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
467
|
+
const refresh = await tracker.refresh();
|
|
468
|
+
if (!refresh.ok) {
|
|
469
|
+
lastError = refresh.error;
|
|
470
|
+
} else {
|
|
471
|
+
const remaining = tracker.getCurrentExactRecords();
|
|
472
|
+
if (remaining.length === 0
|
|
473
|
+
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
474
|
+
return { ok: true, remaining: [] };
|
|
475
|
+
}
|
|
476
|
+
const ordered = [...remaining].sort((left, right) => (
|
|
477
|
+
Number(right.depth || 0) - Number(left.depth || 0)
|
|
478
|
+
));
|
|
479
|
+
// One PowerShell invocation receives the descendant-first
|
|
480
|
+
// immutable snapshot, then rechecks every PID CreationDate
|
|
481
|
+
// immediately before its own Stop-Process. No taskkill /T
|
|
482
|
+
// traversal can follow an unverified stale PPID edge.
|
|
483
|
+
const termination = await terminateExactTreeImpl(ordered, { includeTree: false });
|
|
484
|
+
if (termination?.ok === false) lastError = termination.error || lastError;
|
|
485
|
+
}
|
|
486
|
+
if (attempt < attempts) {
|
|
487
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const finalRefresh = await tracker.refresh();
|
|
492
|
+
const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
|
|
493
|
+
if (finalRefresh.ok
|
|
494
|
+
&& remaining.length === 0
|
|
495
|
+
&& tracker.hasSafeFinalSnapshot?.()) {
|
|
496
|
+
return { ok: true, remaining: [] };
|
|
497
|
+
}
|
|
498
|
+
const error = finalRefresh.error || lastError || new Error(
|
|
499
|
+
tracker.hasAmbiguousRootReuse?.()
|
|
500
|
+
? `Agent pid=${Number(child?.pid || 0)} was reused before exact descendant cleanup completed.`
|
|
501
|
+
: `Exact Windows Agent tree retained pid(s): ${remaining.map(record => record.pid).join(', ')}`
|
|
502
|
+
);
|
|
503
|
+
reportTerminationFailure(
|
|
504
|
+
`[LiveDesk Client] Exact Windows Agent tree could not be drained after `
|
|
505
|
+
+ `${attempts} bounded attempt(s): ${error?.message || error}`
|
|
506
|
+
);
|
|
507
|
+
return { ok: false, error, remaining };
|
|
508
|
+
})();
|
|
509
|
+
return child.livedeskWindowsDrainPromise;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Fail-closed wrapper for lifecycle replacement and terminal shutdown.
|
|
514
|
+
* A bounded drain attempt can fail because CIM/taskkill is temporarily busy;
|
|
515
|
+
* that must never let the launcher exit and orphan an owned FFmpeg descendant.
|
|
516
|
+
*/
|
|
517
|
+
export async function drainOwnedWindowsAgentTreeUntilStopped(child, {
|
|
518
|
+
platform = process.platform,
|
|
519
|
+
retryForeverMs = 1000,
|
|
520
|
+
waitImpl = waitMilliseconds,
|
|
521
|
+
reportTerminationFailure = message => console.error(message),
|
|
522
|
+
...drainOptions
|
|
523
|
+
} = {}) {
|
|
524
|
+
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
525
|
+
if (child?.livedeskWindowsDrainUntilStoppedPromise) {
|
|
526
|
+
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
child.livedeskWindowsDrainUntilStoppedPromise = (async () => {
|
|
530
|
+
let attempt = 0;
|
|
531
|
+
while (true) {
|
|
532
|
+
attempt += 1;
|
|
533
|
+
// drainOwnedWindowsAgentTree memoizes one bounded proof attempt.
|
|
534
|
+
// Clear only that completed attempt; the immutable tracker remains
|
|
535
|
+
// attached to the exact child and is refreshed on every retry.
|
|
536
|
+
child.livedeskWindowsDrainPromise = null;
|
|
537
|
+
let result;
|
|
538
|
+
try {
|
|
539
|
+
result = await drainOwnedWindowsAgentTree(child, {
|
|
540
|
+
platform,
|
|
541
|
+
waitImpl,
|
|
542
|
+
reportTerminationFailure,
|
|
543
|
+
...drainOptions
|
|
544
|
+
});
|
|
545
|
+
} catch (error) {
|
|
546
|
+
result = { ok: false, error, remaining: [] };
|
|
547
|
+
}
|
|
548
|
+
if (result?.ok !== false) {
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
reportTerminationFailure(
|
|
552
|
+
`[LiveDesk Client] Exact Windows Agent tree is not drained yet `
|
|
553
|
+
+ `(attempt ${attempt}); the launcher will remain alive and retry.`
|
|
554
|
+
);
|
|
555
|
+
await waitImpl(Math.max(100, Number(retryForeverMs) || 1000));
|
|
556
|
+
}
|
|
557
|
+
})();
|
|
558
|
+
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function isOwnedUnixProcessGroupAlive(child, platform = process.platform, signalProcess = (pid, signal) => process.kill(pid, signal)) {
|
|
562
|
+
const processId = Number(child?.pid || 0);
|
|
563
|
+
if (platform === 'win32'
|
|
564
|
+
|| child?.livedeskOwnsProcessGroup !== true
|
|
565
|
+
|| !Number.isInteger(processId)
|
|
566
|
+
|| processId <= 1) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
signalProcess(-processId, 0);
|
|
571
|
+
return true;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
return error?.code === 'EPERM';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function signalAgentTree(
|
|
578
|
+
child,
|
|
579
|
+
signal,
|
|
580
|
+
platform = process.platform,
|
|
581
|
+
signalProcess = (pid, requestedSignal) => process.kill(pid, requestedSignal)
|
|
582
|
+
) {
|
|
583
|
+
const processId = Number(child?.pid || 0);
|
|
584
|
+
if (platform !== 'win32'
|
|
585
|
+
&& child?.livedeskOwnsProcessGroup === true
|
|
586
|
+
&& Number.isInteger(processId)
|
|
587
|
+
&& processId > 1) {
|
|
588
|
+
try {
|
|
589
|
+
signalProcess(-processId, signal);
|
|
590
|
+
return;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
child.kill(signal);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function waitMilliseconds(milliseconds) {
|
|
599
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Contract:
|
|
604
|
+
* - Applies only to a Unix Agent that this launcher spawned as a dedicated
|
|
605
|
+
* detached process-group leader.
|
|
606
|
+
* - Resolves only after the owned PGID no longer exists.
|
|
607
|
+
* - Never searches for, or signals, ffmpeg/SCK processes by global name.
|
|
608
|
+
* - A surviving capture descendant keeps the original PGID alive, so group
|
|
609
|
+
* signaling drains it without having to identify that descendant globally.
|
|
610
|
+
*/
|
|
611
|
+
export async function drainOwnedUnixAgentTree(child, {
|
|
612
|
+
platform = process.platform,
|
|
613
|
+
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
614
|
+
gracefulSignal = 'SIGTERM',
|
|
615
|
+
gracefulTimeoutMs = 1000,
|
|
616
|
+
hardRetryMs = 1000,
|
|
617
|
+
pollMs = 50,
|
|
618
|
+
reportTerminationFailure = message => console.error(message)
|
|
619
|
+
} = {}) {
|
|
620
|
+
const processId = Number(child?.pid || 0);
|
|
621
|
+
const ownsUnixGroup = platform !== 'win32'
|
|
622
|
+
&& child?.livedeskOwnsProcessGroup === true
|
|
623
|
+
&& Number.isInteger(processId)
|
|
624
|
+
&& processId > 1;
|
|
625
|
+
if (!ownsUnixGroup || !isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const signalOwnedGroup = signal => {
|
|
630
|
+
try {
|
|
631
|
+
signalProcess(-processId, signal);
|
|
632
|
+
return true;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error?.code === 'ESRCH') return false;
|
|
635
|
+
throw error;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
signalOwnedGroup(gracefulSignal);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
reportTerminationFailure(
|
|
642
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} rejected ${gracefulSignal} `
|
|
643
|
+
+ `(${error?.message || error}); forced cleanup will continue.`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const gracefulDeadline = Date.now() + Math.max(100, Number(gracefulTimeoutMs) || 1000);
|
|
648
|
+
const boundedPollMs = Math.max(10, Number(pollMs) || 50);
|
|
649
|
+
while (Date.now() < gracefulDeadline) {
|
|
650
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
651
|
+
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, gracefulDeadline - Date.now())));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let failureReported = false;
|
|
655
|
+
const retryMs = Math.max(100, Number(hardRetryMs) || 1000);
|
|
656
|
+
while (isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
657
|
+
try {
|
|
658
|
+
signalOwnedGroup('SIGKILL');
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (!failureReported) {
|
|
661
|
+
failureReported = true;
|
|
662
|
+
reportTerminationFailure(
|
|
663
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} rejected SIGKILL `
|
|
664
|
+
+ `(${error?.message || error}). The launcher will not start a replacement until the group drains.`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
669
|
+
if (!failureReported) {
|
|
670
|
+
failureReported = true;
|
|
671
|
+
reportTerminationFailure(
|
|
672
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} still has capture descendants after SIGKILL. `
|
|
673
|
+
+ 'The launcher will not start a replacement until the group drains.'
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const retryDeadline = Date.now() + retryMs;
|
|
677
|
+
while (Date.now() < retryDeadline) {
|
|
678
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
679
|
+
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, retryDeadline - Date.now())));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function installAgentTerminationHandlers({
|
|
685
|
+
hostProcess = process,
|
|
686
|
+
getAgentProcess,
|
|
687
|
+
shutdownTimeoutMs = 8000,
|
|
688
|
+
platform = process.platform,
|
|
689
|
+
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
690
|
+
drainWindowsTree = drainOwnedWindowsAgentTreeUntilStopped,
|
|
691
|
+
windowsTerminateAttemptLimit = 3,
|
|
692
|
+
windowsTerminateRetryMs = 100,
|
|
693
|
+
windowsRecoveryRetryMs = 1000,
|
|
694
|
+
unixTerminateRetryMs = 1000,
|
|
695
|
+
reportTerminationFailure = message => console.error(message),
|
|
696
|
+
exitProcess = code => hostProcess.exit(code)
|
|
697
|
+
} = {}) {
|
|
698
|
+
if (typeof getAgentProcess !== 'function') {
|
|
699
|
+
throw new TypeError('getAgentProcess is required.');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let terminating = false;
|
|
703
|
+
const handlers = new Map();
|
|
704
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
705
|
+
const handler = () => {
|
|
706
|
+
if (terminating) return;
|
|
707
|
+
terminating = true;
|
|
708
|
+
const exitCode = SIGNAL_EXIT_CODES[signal] || 1;
|
|
709
|
+
const child = getAgentProcess();
|
|
710
|
+
if (!child) {
|
|
711
|
+
exitProcess(exitCode);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const exitedUnixLeaderStillOwnsGroup = platform !== 'win32'
|
|
715
|
+
&& child?.livedeskOwnsProcessGroup === true
|
|
716
|
+
&& child.exitCode !== null
|
|
717
|
+
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
718
|
+
// Windows must still drain immutable recorded descendants when
|
|
719
|
+
// Ctrl+C arrives after the Agent leader has already exited.
|
|
720
|
+
if (platform !== 'win32'
|
|
721
|
+
&& child.exitCode !== null
|
|
722
|
+
&& !exitedUnixLeaderStillOwnsGroup) {
|
|
723
|
+
exitProcess(exitCode);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let finished = false;
|
|
728
|
+
let poll = null;
|
|
729
|
+
let timeout = null;
|
|
730
|
+
let unixRetryTimer = null;
|
|
731
|
+
let windowsRetryTimer = null;
|
|
732
|
+
const finish = () => {
|
|
733
|
+
if (finished) return;
|
|
734
|
+
finished = true;
|
|
735
|
+
if (poll) clearInterval(poll);
|
|
736
|
+
if (timeout) clearTimeout(timeout);
|
|
737
|
+
if (unixRetryTimer) clearTimeout(unixRetryTimer);
|
|
738
|
+
if (windowsRetryTimer) clearTimeout(windowsRetryTimer);
|
|
739
|
+
exitProcess(exitCode);
|
|
740
|
+
};
|
|
741
|
+
const processId = Number(child?.pid || 0);
|
|
742
|
+
if (platform === 'win32') {
|
|
743
|
+
if (!Number.isInteger(processId) || processId <= 1) {
|
|
744
|
+
finish();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Use the same immutable PID + CreationDate drain as internal
|
|
748
|
+
// restarts and unexpected Agent exits. The promise is stored
|
|
749
|
+
// on the child so spawnAgent cannot race ahead and launch a
|
|
750
|
+
// replacement while terminal shutdown is still draining.
|
|
751
|
+
const drainUntilStopped = () => {
|
|
752
|
+
if (finished) return;
|
|
753
|
+
if (!child.livedeskTreeStopPromise) {
|
|
754
|
+
child.livedeskTreeStopPromise = Promise.resolve().then(() => drainWindowsTree(child, {
|
|
755
|
+
platform,
|
|
756
|
+
maxAttempts: windowsTerminateAttemptLimit,
|
|
757
|
+
retryMs: windowsTerminateRetryMs,
|
|
758
|
+
retryForeverMs: windowsRecoveryRetryMs,
|
|
759
|
+
reportTerminationFailure
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
child.livedeskTreeStopPromise
|
|
763
|
+
.catch(error => ({ ok: false, error }))
|
|
764
|
+
.then(result => {
|
|
765
|
+
if (result?.ok !== false) {
|
|
766
|
+
finish();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
reportTerminationFailure(
|
|
770
|
+
`[LiveDesk Client] Terminal shutdown retained an exact Windows Agent tree: `
|
|
771
|
+
+ `${result.error?.message || 'bounded drain failed'}. Retrying before exit.`
|
|
772
|
+
);
|
|
773
|
+
child.livedeskTreeStopPromise = null;
|
|
774
|
+
child.livedeskWindowsDrainPromise = null;
|
|
775
|
+
windowsRetryTimer = setTimeout(
|
|
776
|
+
drainUntilStopped,
|
|
777
|
+
Math.max(100, Number(windowsRecoveryRetryMs) || 1000)
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
drainUntilStopped();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const finishWhenTreeStops = () => {
|
|
785
|
+
const ownsUnixGroup = child?.livedeskOwnsProcessGroup === true
|
|
786
|
+
&& Number(child?.pid || 0) > 1;
|
|
787
|
+
const childAlive = child.exitCode === null && child.signalCode == null;
|
|
788
|
+
const groupAlive = ownsUnixGroup
|
|
789
|
+
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
790
|
+
// A missing owned process group proves that both its Agent
|
|
791
|
+
// leader and capture descendants are gone even if Node has not
|
|
792
|
+
// delivered the child exit event yet.
|
|
793
|
+
if (ownsUnixGroup ? !groupAlive : !childAlive) {
|
|
794
|
+
finish();
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
child.once('exit', finishWhenTreeStops);
|
|
798
|
+
try {
|
|
799
|
+
signalAgentTree(child, signal, platform, signalProcess);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
reportTerminationFailure(
|
|
802
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} could not receive ${signal} `
|
|
803
|
+
+ `(${error?.message || error}). Forced cleanup will continue.`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
poll = setInterval(finishWhenTreeStops, 50);
|
|
807
|
+
const retryMs = Math.max(100, Number(unixTerminateRetryMs) || 1000);
|
|
808
|
+
let hardFailureReported = false;
|
|
809
|
+
const terminateOwnedUnixTree = () => {
|
|
810
|
+
if (finished) return;
|
|
811
|
+
finishWhenTreeStops();
|
|
812
|
+
if (finished) return;
|
|
813
|
+
try {
|
|
814
|
+
signalAgentTree(child, 'SIGKILL', platform, signalProcess);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (!hardFailureReported) {
|
|
817
|
+
hardFailureReported = true;
|
|
818
|
+
reportTerminationFailure(
|
|
819
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} rejected SIGKILL `
|
|
820
|
+
+ `(${error?.message || error}). The launcher will remain alive and retry.`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
finishWhenTreeStops();
|
|
825
|
+
if (finished) return;
|
|
826
|
+
if (!hardFailureReported) {
|
|
827
|
+
hardFailureReported = true;
|
|
828
|
+
reportTerminationFailure(
|
|
829
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} is still alive after SIGKILL. `
|
|
830
|
+
+ 'The launcher will remain alive and keep retrying so capture descendants are not orphaned.'
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
unixRetryTimer = setTimeout(terminateOwnedUnixTree, retryMs);
|
|
834
|
+
};
|
|
835
|
+
timeout = setTimeout(() => {
|
|
836
|
+
terminateOwnedUnixTree();
|
|
837
|
+
}, Math.max(100, Number(shutdownTimeoutMs) || 3000));
|
|
838
|
+
};
|
|
839
|
+
handlers.set(signal, handler);
|
|
840
|
+
// Keep the listener installed while cleanup is in progress. A second
|
|
841
|
+
// Ctrl+C must not restore Node's default immediate exit and orphan the
|
|
842
|
+
// process group that the first signal is still draining.
|
|
843
|
+
hostProcess.on(signal, handler);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const dispose = () => {
|
|
847
|
+
for (const [signal, handler] of handlers) {
|
|
848
|
+
hostProcess.removeListener(signal, handler);
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
// Windows process.kill(pid, 'SIGTERM') can terminate a process without
|
|
852
|
+
// dispatching its JavaScript SIGTERM listener. Replacement startup must
|
|
853
|
+
// enter this exact cleanup state machine directly instead of simulating an
|
|
854
|
+
// operating-system signal.
|
|
855
|
+
dispose.requestTermination = (signal = 'SIGTERM') => {
|
|
856
|
+
const normalizedSignal = signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM';
|
|
857
|
+
handlers.get(normalizedSignal)?.();
|
|
858
|
+
};
|
|
859
|
+
return dispose;
|
|
860
|
+
}
|