@livedesk/client 0.1.199 → 0.1.201
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/bin/livedesk-client.js +426 -125
- package/package.json +5 -5
- package/src/runtime/agent-process-lifecycle.js +600 -77
|
@@ -1,51 +1,460 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
|
|
3
3
|
const SIGNAL_EXIT_CODES = Object.freeze({ SIGINT: 130, SIGTERM: 143 });
|
|
4
|
+
const DEFAULT_WINDOWS_TREE_SNAPSHOT_MS = 10_000;
|
|
5
|
+
const WINDOWS_SPAWN_CLOCK_TOLERANCE_MS = 250;
|
|
4
6
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
function normalizeWindowsProcessRecord(value) {
|
|
8
|
+
const pid = Number(value?.pid ?? value?.ProcessId ?? 0);
|
|
9
|
+
const parentPid = Number(value?.parentPid ?? value?.ParentProcessId ?? 0);
|
|
10
|
+
const startMarker = String(value?.startMarker ?? value?.CreationDate ?? '').trim();
|
|
11
|
+
const startOrder = String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim();
|
|
12
|
+
if (!Number.isInteger(pid)
|
|
13
|
+
|| pid <= 1
|
|
14
|
+
|| !startMarker
|
|
15
|
+
|| !/^\d+$/.test(startOrder)) return null;
|
|
16
|
+
return { pid, parentPid, startMarker, startOrder };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function windowsRecordKey(record) {
|
|
20
|
+
return `${Number(record?.pid || 0)}:${String(record?.startOrder || '')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameWindowsProcess(left, right) {
|
|
24
|
+
return Number(left?.pid || 0) === Number(right?.pid || 0)
|
|
25
|
+
&& String(left?.startOrder || '') === String(right?.startOrder || '')
|
|
26
|
+
&& String(left?.startMarker || '') === String(right?.startMarker || '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function windowsTicksForUnixMilliseconds(milliseconds) {
|
|
30
|
+
return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
|
|
34
|
+
if (!record?.startOrder) return false;
|
|
35
|
+
try {
|
|
36
|
+
const order = BigInt(record.startOrder);
|
|
37
|
+
// spawnedAtMs is recorded immediately before spawn(). A small 250ms
|
|
38
|
+
// tolerance covers clock conversion/scheduling without admitting an
|
|
39
|
+
// old process whose stale ParentProcessId happens to equal the Agent.
|
|
40
|
+
const lower = windowsTicksForUnixMilliseconds(
|
|
41
|
+
Math.max(0, spawnedAtMs - WINDOWS_SPAWN_CLOCK_TOLERANCE_MS)
|
|
16
42
|
);
|
|
43
|
+
const upper = exitedAtMs === null
|
|
44
|
+
? windowsTicksForUnixMilliseconds(Date.now() + 5000)
|
|
45
|
+
: windowsTicksForUnixMilliseconds(exitedAtMs);
|
|
46
|
+
return order >= lower && order <= upper;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runExecFile(command, args, options = {}, execFileImpl = execFile) {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
execFileImpl(command, args, options, (error, stdout, stderr) => {
|
|
55
|
+
resolve({
|
|
56
|
+
ok: !error,
|
|
57
|
+
error: error || null,
|
|
58
|
+
stdout: String(stdout || '').trim(),
|
|
59
|
+
stderr: String(stderr || '').trim()
|
|
60
|
+
});
|
|
61
|
+
});
|
|
17
62
|
});
|
|
18
63
|
}
|
|
19
64
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Returns immutable PID + CreationDate records only. Command lines are
|
|
67
|
+
* intentionally excluded so no globally named ffmpeg process can become a
|
|
68
|
+
* termination target.
|
|
69
|
+
*/
|
|
70
|
+
export async function queryWindowsProcessTable({
|
|
71
|
+
execFileImpl = execFile
|
|
72
|
+
} = {}) {
|
|
73
|
+
const script = [
|
|
74
|
+
'$ErrorActionPreference = "Stop";',
|
|
75
|
+
'@(Get-CimInstance Win32_Process | ForEach-Object {',
|
|
76
|
+
' $creation = [string]$_.CreationDate;',
|
|
77
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
78
|
+
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
79
|
+
'}) | ConvertTo-Json -Compress'
|
|
80
|
+
].join(' ');
|
|
81
|
+
const result = await runExecFile(
|
|
82
|
+
'powershell.exe',
|
|
83
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
84
|
+
{ windowsHide: true, timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 },
|
|
85
|
+
execFileImpl
|
|
86
|
+
);
|
|
87
|
+
if (!result.ok || !result.stdout) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Windows process-tree snapshot failed: `
|
|
90
|
+
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
91
|
+
);
|
|
27
92
|
}
|
|
93
|
+
let parsed;
|
|
28
94
|
try {
|
|
29
|
-
|
|
30
|
-
return true;
|
|
95
|
+
parsed = JSON.parse(result.stdout);
|
|
31
96
|
} catch (error) {
|
|
32
|
-
|
|
97
|
+
throw new Error(`Windows process-tree snapshot returned invalid JSON: ${error?.message || error}`);
|
|
98
|
+
}
|
|
99
|
+
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
100
|
+
.map(normalizeWindowsProcessRecord)
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Rechecks CreationDate inside the same PowerShell process immediately before
|
|
106
|
+
* taskkill. PID reuse therefore becomes a harmless identity mismatch instead
|
|
107
|
+
* of terminating the replacement process.
|
|
108
|
+
*/
|
|
109
|
+
export async function terminateExactWindowsProcessTree(record, {
|
|
110
|
+
includeTree = true,
|
|
111
|
+
execFileImpl = execFile
|
|
112
|
+
} = {}) {
|
|
113
|
+
const target = normalizeWindowsProcessRecord(record);
|
|
114
|
+
if (!target) {
|
|
115
|
+
return { ok: false, error: new Error('An immutable Windows process record is required.') };
|
|
33
116
|
}
|
|
117
|
+
const expectedStartTicks = target.startOrder;
|
|
118
|
+
const treeArgument = includeTree ? ', "/T"' : '';
|
|
119
|
+
const script = [
|
|
120
|
+
'$ErrorActionPreference = "Stop";',
|
|
121
|
+
`$targetPid = ${target.pid};`,
|
|
122
|
+
`$expectedStartTicks = '${expectedStartTicks}';`,
|
|
123
|
+
'$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
124
|
+
'if (-not $target) { exit 0 };',
|
|
125
|
+
'$targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
126
|
+
'if ($targetStartTicks -ne $expectedStartTicks) { exit 0 };',
|
|
127
|
+
`$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
|
|
128
|
+
'$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
|
|
129
|
+
'exit $taskkill.ExitCode'
|
|
130
|
+
].join(' ');
|
|
131
|
+
return runExecFile(
|
|
132
|
+
'powershell.exe',
|
|
133
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
134
|
+
{ windowsHide: true, timeout: 15000, killSignal: 'SIGKILL' },
|
|
135
|
+
execFileImpl
|
|
136
|
+
);
|
|
34
137
|
}
|
|
35
138
|
|
|
36
|
-
|
|
37
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Tracks the exact Agent root and only descendants connected through Windows
|
|
141
|
+
* ParentProcessId records. The default 10-second cadence is deliberately low
|
|
142
|
+
* overhead; refreshes are serialized and the timer is unref'd. A final
|
|
143
|
+
* snapshot covers normal direct FFmpeg children even between timer samples.
|
|
144
|
+
*/
|
|
145
|
+
export function createWindowsProcessTreeTracker(child, {
|
|
146
|
+
platform = process.platform,
|
|
147
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
148
|
+
snapshotIntervalMs = DEFAULT_WINDOWS_TREE_SNAPSHOT_MS,
|
|
149
|
+
spawnedAtMs = Date.now(),
|
|
150
|
+
setIntervalImpl = setInterval,
|
|
151
|
+
clearIntervalImpl = clearInterval
|
|
152
|
+
} = {}) {
|
|
153
|
+
const rootPid = Number(child?.pid || 0);
|
|
154
|
+
const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
|
|
155
|
+
const tracked = new Map();
|
|
156
|
+
let rootRecord = null;
|
|
157
|
+
let lastSnapshot = [];
|
|
158
|
+
let lastError = null;
|
|
159
|
+
let inFlight = null;
|
|
160
|
+
let stopped = false;
|
|
161
|
+
let timer = null;
|
|
162
|
+
let rootExitedAtMs = null;
|
|
163
|
+
let rootCapturedBeforeExit = false;
|
|
164
|
+
let lastSnapshotHadRootPid = false;
|
|
165
|
+
let successfulSnapshotAfterExit = false;
|
|
166
|
+
let ambiguousRootReuse = false;
|
|
167
|
+
|
|
168
|
+
const mergeSnapshot = snapshot => {
|
|
169
|
+
const records = (Array.isArray(snapshot) ? snapshot : [])
|
|
170
|
+
.map(normalizeWindowsProcessRecord)
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
lastSnapshot = records;
|
|
173
|
+
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
174
|
+
const currentByPid = new Map(records.map(record => [record.pid, record]));
|
|
175
|
+
const currentRoot = currentByPid.get(rootPid) || null;
|
|
176
|
+
lastSnapshotHadRootPid = Boolean(currentRoot);
|
|
177
|
+
if (rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)) {
|
|
178
|
+
ambiguousRootReuse = true;
|
|
179
|
+
}
|
|
180
|
+
if (!rootRecord
|
|
181
|
+
&& currentRoot
|
|
182
|
+
&& windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
|
|
183
|
+
rootCapturedBeforeExit = rootExitedAtMs === null;
|
|
184
|
+
rootRecord = {
|
|
185
|
+
...currentRoot,
|
|
186
|
+
depth: 0,
|
|
187
|
+
targetable: rootCapturedBeforeExit
|
|
188
|
+
};
|
|
189
|
+
if (rootRecord.targetable) {
|
|
190
|
+
tracked.set(windowsRecordKey(rootRecord), rootRecord);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const exactCurrentByPid = new Map();
|
|
195
|
+
if (rootRecord && currentRoot && sameWindowsProcess(currentRoot, rootRecord)) {
|
|
196
|
+
exactCurrentByPid.set(rootPid, rootRecord);
|
|
197
|
+
}
|
|
198
|
+
for (const record of tracked.values()) {
|
|
199
|
+
const current = currentByPid.get(record.pid);
|
|
200
|
+
if (current && sameWindowsProcess(current, record)) {
|
|
201
|
+
exactCurrentByPid.set(record.pid, record);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// If the root PID is absent, Windows retains ParentProcessId on its
|
|
206
|
+
// surviving direct children. If the PID has already been reused, do
|
|
207
|
+
// not infer any new lineage through that ambiguous numeric PID.
|
|
208
|
+
const rootAbsent = !currentRoot;
|
|
209
|
+
let changed = true;
|
|
210
|
+
while (changed) {
|
|
211
|
+
changed = false;
|
|
212
|
+
for (const record of records) {
|
|
213
|
+
if (record.pid === rootPid || tracked.has(windowsRecordKey(record))) continue;
|
|
214
|
+
if (!windowsRecordIsWithinLifetime(record, spawnedAtMs, rootExitedAtMs)) continue;
|
|
215
|
+
let parent = exactCurrentByPid.get(record.parentPid) || null;
|
|
216
|
+
if (!parent
|
|
217
|
+
&& rootExitedAtMs !== null
|
|
218
|
+
&& rootAbsent
|
|
219
|
+
&& record.parentPid === rootPid) {
|
|
220
|
+
parent = rootRecord || { pid: rootPid, depth: 0 };
|
|
221
|
+
}
|
|
222
|
+
if (!parent) continue;
|
|
223
|
+
const descendant = { ...record, depth: Number(parent.depth || 0) + 1 };
|
|
224
|
+
tracked.set(windowsRecordKey(descendant), descendant);
|
|
225
|
+
exactCurrentByPid.set(descendant.pid, descendant);
|
|
226
|
+
changed = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const refresh = async () => {
|
|
232
|
+
if (!enabled) return { ok: true, records: [] };
|
|
233
|
+
if (inFlight) return inFlight;
|
|
234
|
+
inFlight = Promise.resolve()
|
|
235
|
+
.then(() => queryProcessTableImpl())
|
|
236
|
+
.then(snapshot => {
|
|
237
|
+
mergeSnapshot(snapshot);
|
|
238
|
+
lastError = null;
|
|
239
|
+
return { ok: true, records: lastSnapshot };
|
|
240
|
+
})
|
|
241
|
+
.catch(error => {
|
|
242
|
+
lastError = error;
|
|
243
|
+
return { ok: false, error, records: lastSnapshot };
|
|
244
|
+
})
|
|
245
|
+
.finally(() => {
|
|
246
|
+
inFlight = null;
|
|
247
|
+
});
|
|
248
|
+
return inFlight;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const ready = enabled ? refresh() : Promise.resolve({ ok: true, records: [] });
|
|
252
|
+
if (enabled) {
|
|
253
|
+
const boundedInterval = Math.max(5000, Number(snapshotIntervalMs) || DEFAULT_WINDOWS_TREE_SNAPSHOT_MS);
|
|
254
|
+
timer = setIntervalImpl(() => {
|
|
255
|
+
if (!stopped) void refresh();
|
|
256
|
+
}, boundedInterval);
|
|
257
|
+
timer?.unref?.();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
enabled,
|
|
262
|
+
ready,
|
|
263
|
+
refresh,
|
|
264
|
+
stop() {
|
|
265
|
+
stopped = true;
|
|
266
|
+
if (timer) clearIntervalImpl(timer);
|
|
267
|
+
timer = null;
|
|
268
|
+
},
|
|
269
|
+
getRootRecord: () => rootRecord ? { ...rootRecord } : null,
|
|
270
|
+
markRootExited(exitedAtMs = Date.now()) {
|
|
271
|
+
if (rootExitedAtMs === null) {
|
|
272
|
+
rootExitedAtMs = Number(exitedAtMs) || Date.now();
|
|
273
|
+
successfulSnapshotAfterExit = false;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
hasSafeFinalSnapshot() {
|
|
277
|
+
return rootCapturedBeforeExit
|
|
278
|
+
|| (rootExitedAtMs !== null && successfulSnapshotAfterExit && !lastSnapshotHadRootPid);
|
|
279
|
+
},
|
|
280
|
+
hasAmbiguousRootReuse: () => ambiguousRootReuse,
|
|
281
|
+
getTrackedRecords: () => [...tracked.values()].map(record => ({ ...record })),
|
|
282
|
+
getCurrentExactRecords() {
|
|
283
|
+
const currentByPid = new Map(lastSnapshot.map(record => [record.pid, record]));
|
|
284
|
+
return [...tracked.values()]
|
|
285
|
+
.filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
|
|
286
|
+
.map(record => ({ ...record }));
|
|
287
|
+
},
|
|
288
|
+
getLastError: () => lastError
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bounded exact-tree drain used by internal restarts, unexpected exits, and
|
|
294
|
+
* terminal shutdown. A nonzero taskkill is not itself failure: each attempt is
|
|
295
|
+
* followed by a fresh immutable snapshot, and an already-gone tree succeeds.
|
|
296
|
+
*/
|
|
297
|
+
export async function drainOwnedWindowsAgentTree(child, {
|
|
298
|
+
platform = process.platform,
|
|
299
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
300
|
+
terminateExactTreeImpl = terminateExactWindowsProcessTree,
|
|
301
|
+
maxAttempts = 3,
|
|
302
|
+
retryMs = 100,
|
|
303
|
+
waitImpl = waitMilliseconds,
|
|
304
|
+
reportTerminationFailure = message => console.error(message)
|
|
305
|
+
} = {}) {
|
|
306
|
+
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
307
|
+
if (child?.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise;
|
|
308
|
+
|
|
309
|
+
child.livedeskWindowsDrainPromise = (async () => {
|
|
310
|
+
const tracker = child?.livedeskWindowsTreeTracker
|
|
311
|
+
|| createWindowsProcessTreeTracker(child, { platform, queryProcessTableImpl });
|
|
312
|
+
child.livedeskWindowsTreeTracker = tracker;
|
|
313
|
+
if (child?.exitCode !== null && child?.exitCode !== undefined) {
|
|
314
|
+
tracker.markRootExited?.();
|
|
315
|
+
}
|
|
316
|
+
tracker.stop();
|
|
317
|
+
await tracker.ready;
|
|
318
|
+
const attempts = Math.max(1, Number(maxAttempts) || 3);
|
|
319
|
+
let identityRefresh = await tracker.refresh();
|
|
320
|
+
let identityAttempt = 1;
|
|
321
|
+
let rootRecord = tracker.getRootRecord();
|
|
322
|
+
while ((!rootRecord || rootRecord.targetable === false)
|
|
323
|
+
&& !tracker.hasSafeFinalSnapshot?.()
|
|
324
|
+
&& identityAttempt < attempts) {
|
|
325
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
326
|
+
identityAttempt += 1;
|
|
327
|
+
identityRefresh = await tracker.refresh();
|
|
328
|
+
rootRecord = tracker.getRootRecord();
|
|
329
|
+
}
|
|
330
|
+
if ((!rootRecord || rootRecord.targetable === false)
|
|
331
|
+
&& !tracker.hasSafeFinalSnapshot?.()) {
|
|
332
|
+
const error = identityRefresh?.error || tracker.getLastError()
|
|
333
|
+
|| new Error(
|
|
334
|
+
`Agent pid=${Number(child?.pid || 0)} could not establish an immutable root identity `
|
|
335
|
+
+ 'or a safe root-absent exit snapshot.'
|
|
336
|
+
);
|
|
337
|
+
return { ok: false, error, remaining: [] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let lastError = null;
|
|
341
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
342
|
+
const refresh = await tracker.refresh();
|
|
343
|
+
if (!refresh.ok) {
|
|
344
|
+
lastError = refresh.error;
|
|
345
|
+
} else {
|
|
346
|
+
const remaining = tracker.getCurrentExactRecords();
|
|
347
|
+
if (remaining.length === 0
|
|
348
|
+
&& tracker.hasSafeFinalSnapshot?.()
|
|
349
|
+
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
350
|
+
return { ok: true, remaining: [] };
|
|
351
|
+
}
|
|
352
|
+
const ordered = [...remaining].sort((left, right) => (
|
|
353
|
+
Number(left.depth || 0) - Number(right.depth || 0)
|
|
354
|
+
));
|
|
355
|
+
for (const record of ordered) {
|
|
356
|
+
const termination = await terminateExactTreeImpl(record, { includeTree: true });
|
|
357
|
+
if (termination?.ok === false) lastError = termination.error || lastError;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (attempt < attempts) {
|
|
361
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const finalRefresh = await tracker.refresh();
|
|
366
|
+
const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
|
|
367
|
+
if (finalRefresh.ok
|
|
368
|
+
&& remaining.length === 0
|
|
369
|
+
&& tracker.hasSafeFinalSnapshot?.()
|
|
370
|
+
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
371
|
+
return { ok: true, remaining: [] };
|
|
372
|
+
}
|
|
373
|
+
const error = finalRefresh.error || lastError || new Error(
|
|
374
|
+
tracker.hasAmbiguousRootReuse?.()
|
|
375
|
+
? `Agent pid=${Number(child?.pid || 0)} was reused before exact descendant cleanup completed.`
|
|
376
|
+
: `Exact Windows Agent tree retained pid(s): ${remaining.map(record => record.pid).join(', ')}`
|
|
377
|
+
);
|
|
378
|
+
reportTerminationFailure(
|
|
379
|
+
`[LiveDesk Client] Exact Windows Agent tree could not be drained after `
|
|
380
|
+
+ `${attempts} bounded attempt(s): ${error?.message || error}`
|
|
381
|
+
);
|
|
382
|
+
return { ok: false, error, remaining };
|
|
383
|
+
})();
|
|
384
|
+
return child.livedeskWindowsDrainPromise;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Fail-closed wrapper for lifecycle replacement and terminal shutdown.
|
|
389
|
+
* A bounded drain attempt can fail because CIM/taskkill is temporarily busy;
|
|
390
|
+
* that must never let the launcher exit and orphan an owned FFmpeg descendant.
|
|
391
|
+
*/
|
|
392
|
+
export async function drainOwnedWindowsAgentTreeUntilStopped(child, {
|
|
393
|
+
platform = process.platform,
|
|
394
|
+
retryForeverMs = 1000,
|
|
395
|
+
waitImpl = waitMilliseconds,
|
|
396
|
+
reportTerminationFailure = message => console.error(message),
|
|
397
|
+
...drainOptions
|
|
398
|
+
} = {}) {
|
|
399
|
+
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
400
|
+
if (child?.livedeskWindowsDrainUntilStoppedPromise) {
|
|
401
|
+
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
child.livedeskWindowsDrainUntilStoppedPromise = (async () => {
|
|
405
|
+
let attempt = 0;
|
|
406
|
+
while (true) {
|
|
407
|
+
attempt += 1;
|
|
408
|
+
// drainOwnedWindowsAgentTree memoizes one bounded proof attempt.
|
|
409
|
+
// Clear only that completed attempt; the immutable tracker remains
|
|
410
|
+
// attached to the exact child and is refreshed on every retry.
|
|
411
|
+
child.livedeskWindowsDrainPromise = null;
|
|
412
|
+
let result;
|
|
413
|
+
try {
|
|
414
|
+
result = await drainOwnedWindowsAgentTree(child, {
|
|
415
|
+
platform,
|
|
416
|
+
waitImpl,
|
|
417
|
+
reportTerminationFailure,
|
|
418
|
+
...drainOptions
|
|
419
|
+
});
|
|
420
|
+
} catch (error) {
|
|
421
|
+
result = { ok: false, error, remaining: [] };
|
|
422
|
+
}
|
|
423
|
+
if (result?.ok !== false) {
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
reportTerminationFailure(
|
|
427
|
+
`[LiveDesk Client] Exact Windows Agent tree is not drained yet `
|
|
428
|
+
+ `(attempt ${attempt}); the launcher will remain alive and retry.`
|
|
429
|
+
);
|
|
430
|
+
await waitImpl(Math.max(100, Number(retryForeverMs) || 1000));
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
433
|
+
return child.livedeskWindowsDrainUntilStoppedPromise;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function isOwnedUnixProcessGroupAlive(child, platform = process.platform, signalProcess = (pid, signal) => process.kill(pid, signal)) {
|
|
437
|
+
const processId = Number(child?.pid || 0);
|
|
438
|
+
if (platform === 'win32'
|
|
439
|
+
|| child?.livedeskOwnsProcessGroup !== true
|
|
440
|
+
|| !Number.isInteger(processId)
|
|
441
|
+
|| processId <= 1) {
|
|
38
442
|
return false;
|
|
39
443
|
}
|
|
40
444
|
try {
|
|
41
|
-
signalProcess(processId, 0);
|
|
445
|
+
signalProcess(-processId, 0);
|
|
42
446
|
return true;
|
|
43
447
|
} catch (error) {
|
|
44
448
|
return error?.code === 'EPERM';
|
|
45
449
|
}
|
|
46
450
|
}
|
|
47
451
|
|
|
48
|
-
function signalAgentTree(
|
|
452
|
+
export function signalAgentTree(
|
|
453
|
+
child,
|
|
454
|
+
signal,
|
|
455
|
+
platform = process.platform,
|
|
456
|
+
signalProcess = (pid, requestedSignal) => process.kill(pid, requestedSignal)
|
|
457
|
+
) {
|
|
49
458
|
const processId = Number(child?.pid || 0);
|
|
50
459
|
if (platform !== 'win32'
|
|
51
460
|
&& child?.livedeskOwnsProcessGroup === true
|
|
@@ -61,15 +470,102 @@ function signalAgentTree(child, signal, platform, signalProcess) {
|
|
|
61
470
|
child.kill(signal);
|
|
62
471
|
}
|
|
63
472
|
|
|
473
|
+
function waitMilliseconds(milliseconds) {
|
|
474
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Contract:
|
|
479
|
+
* - Applies only to a Unix Agent that this launcher spawned as a dedicated
|
|
480
|
+
* detached process-group leader.
|
|
481
|
+
* - Resolves only after the owned PGID no longer exists.
|
|
482
|
+
* - Never searches for, or signals, ffmpeg/SCK processes by global name.
|
|
483
|
+
* - A surviving capture descendant keeps the original PGID alive, so group
|
|
484
|
+
* signaling drains it without having to identify that descendant globally.
|
|
485
|
+
*/
|
|
486
|
+
export async function drainOwnedUnixAgentTree(child, {
|
|
487
|
+
platform = process.platform,
|
|
488
|
+
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
489
|
+
gracefulSignal = 'SIGTERM',
|
|
490
|
+
gracefulTimeoutMs = 1000,
|
|
491
|
+
hardRetryMs = 1000,
|
|
492
|
+
pollMs = 50,
|
|
493
|
+
reportTerminationFailure = message => console.error(message)
|
|
494
|
+
} = {}) {
|
|
495
|
+
const processId = Number(child?.pid || 0);
|
|
496
|
+
const ownsUnixGroup = platform !== 'win32'
|
|
497
|
+
&& child?.livedeskOwnsProcessGroup === true
|
|
498
|
+
&& Number.isInteger(processId)
|
|
499
|
+
&& processId > 1;
|
|
500
|
+
if (!ownsUnixGroup || !isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const signalOwnedGroup = signal => {
|
|
505
|
+
try {
|
|
506
|
+
signalProcess(-processId, signal);
|
|
507
|
+
return true;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error?.code === 'ESRCH') return false;
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
try {
|
|
514
|
+
signalOwnedGroup(gracefulSignal);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
reportTerminationFailure(
|
|
517
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} rejected ${gracefulSignal} `
|
|
518
|
+
+ `(${error?.message || error}); forced cleanup will continue.`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const gracefulDeadline = Date.now() + Math.max(100, Number(gracefulTimeoutMs) || 1000);
|
|
523
|
+
const boundedPollMs = Math.max(10, Number(pollMs) || 50);
|
|
524
|
+
while (Date.now() < gracefulDeadline) {
|
|
525
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
526
|
+
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, gracefulDeadline - Date.now())));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let failureReported = false;
|
|
530
|
+
const retryMs = Math.max(100, Number(hardRetryMs) || 1000);
|
|
531
|
+
while (isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
|
|
532
|
+
try {
|
|
533
|
+
signalOwnedGroup('SIGKILL');
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (!failureReported) {
|
|
536
|
+
failureReported = true;
|
|
537
|
+
reportTerminationFailure(
|
|
538
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} rejected SIGKILL `
|
|
539
|
+
+ `(${error?.message || error}). The launcher will not start a replacement until the group drains.`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
544
|
+
if (!failureReported) {
|
|
545
|
+
failureReported = true;
|
|
546
|
+
reportTerminationFailure(
|
|
547
|
+
`[LiveDesk Client] Exited Agent group pid=${processId} still has capture descendants after SIGKILL. `
|
|
548
|
+
+ 'The launcher will not start a replacement until the group drains.'
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
const retryDeadline = Date.now() + retryMs;
|
|
552
|
+
while (Date.now() < retryDeadline) {
|
|
553
|
+
if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
|
|
554
|
+
await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, retryDeadline - Date.now())));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
64
559
|
export function installAgentTerminationHandlers({
|
|
65
560
|
hostProcess = process,
|
|
66
561
|
getAgentProcess,
|
|
67
562
|
shutdownTimeoutMs = 8000,
|
|
68
563
|
platform = process.platform,
|
|
69
564
|
signalProcess = (pid, signal) => process.kill(pid, signal),
|
|
70
|
-
|
|
565
|
+
drainWindowsTree = drainOwnedWindowsAgentTreeUntilStopped,
|
|
71
566
|
windowsTerminateAttemptLimit = 3,
|
|
72
567
|
windowsTerminateRetryMs = 100,
|
|
568
|
+
unixTerminateRetryMs = 1000,
|
|
73
569
|
reportTerminationFailure = message => console.error(message),
|
|
74
570
|
exitProcess = code => hostProcess.exit(code)
|
|
75
571
|
} = {}) {
|
|
@@ -85,7 +581,19 @@ export function installAgentTerminationHandlers({
|
|
|
85
581
|
terminating = true;
|
|
86
582
|
const exitCode = SIGNAL_EXIT_CODES[signal] || 1;
|
|
87
583
|
const child = getAgentProcess();
|
|
88
|
-
if (!child
|
|
584
|
+
if (!child) {
|
|
585
|
+
exitProcess(exitCode);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const exitedUnixLeaderStillOwnsGroup = platform !== 'win32'
|
|
589
|
+
&& child?.livedeskOwnsProcessGroup === true
|
|
590
|
+
&& child.exitCode !== null
|
|
591
|
+
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
592
|
+
// Windows must still drain immutable recorded descendants when
|
|
593
|
+
// Ctrl+C arrives after the Agent leader has already exited.
|
|
594
|
+
if (platform !== 'win32'
|
|
595
|
+
&& child.exitCode !== null
|
|
596
|
+
&& !exitedUnixLeaderStillOwnsGroup) {
|
|
89
597
|
exitProcess(exitCode);
|
|
90
598
|
return;
|
|
91
599
|
}
|
|
@@ -93,14 +601,14 @@ export function installAgentTerminationHandlers({
|
|
|
93
601
|
let finished = false;
|
|
94
602
|
let poll = null;
|
|
95
603
|
let timeout = null;
|
|
96
|
-
let
|
|
604
|
+
let unixRetryTimer = null;
|
|
97
605
|
let windowsRetryTimer = null;
|
|
98
606
|
const finish = () => {
|
|
99
607
|
if (finished) return;
|
|
100
608
|
finished = true;
|
|
101
609
|
if (poll) clearInterval(poll);
|
|
102
610
|
if (timeout) clearTimeout(timeout);
|
|
103
|
-
if (
|
|
611
|
+
if (unixRetryTimer) clearTimeout(unixRetryTimer);
|
|
104
612
|
if (windowsRetryTimer) clearTimeout(windowsRetryTimer);
|
|
105
613
|
exitProcess(exitCode);
|
|
106
614
|
};
|
|
@@ -110,58 +618,41 @@ export function installAgentTerminationHandlers({
|
|
|
110
618
|
finish();
|
|
111
619
|
return;
|
|
112
620
|
}
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
// unrelated ffmpeg.exe processes globally.
|
|
119
|
-
const attemptLimit = Math.max(1, Number(windowsTerminateAttemptLimit) || 3);
|
|
120
|
-
const retryMs = Math.max(25, Number(windowsTerminateRetryMs) || 100);
|
|
121
|
-
let attempt = 0;
|
|
122
|
-
let failureReported = false;
|
|
123
|
-
const terminateOwnedTree = () => {
|
|
621
|
+
// Use the same immutable PID + CreationDate drain as internal
|
|
622
|
+
// restarts and unexpected Agent exits. The promise is stored
|
|
623
|
+
// on the child so spawnAgent cannot race ahead and launch a
|
|
624
|
+
// replacement while terminal shutdown is still draining.
|
|
625
|
+
const drainUntilStopped = () => {
|
|
124
626
|
if (finished) return;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
627
|
+
if (!child.livedeskTreeStopPromise) {
|
|
628
|
+
child.livedeskTreeStopPromise = Promise.resolve().then(() => drainWindowsTree(child, {
|
|
629
|
+
platform,
|
|
630
|
+
maxAttempts: windowsTerminateAttemptLimit,
|
|
631
|
+
retryMs: windowsTerminateRetryMs,
|
|
632
|
+
retryForeverMs: windowsTerminateRetryMs,
|
|
633
|
+
reportTerminationFailure
|
|
634
|
+
}));
|
|
131
635
|
}
|
|
132
|
-
|
|
636
|
+
child.livedeskTreeStopPromise
|
|
133
637
|
.catch(error => ({ ok: false, error }))
|
|
134
638
|
.then(result => {
|
|
135
|
-
if (finished) return;
|
|
136
639
|
if (result?.ok !== false) {
|
|
137
640
|
finish();
|
|
138
641
|
return;
|
|
139
642
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (burstExhausted && !failureReported) {
|
|
147
|
-
failureReported = true;
|
|
148
|
-
const reason = result?.error?.message || 'taskkill returned an error';
|
|
149
|
-
reportTerminationFailure(
|
|
150
|
-
`[LiveDesk Client] Agent tree pid=${processId} is still alive after `
|
|
151
|
-
+ `${attemptLimit} exact-PID taskkill attempts (${reason}). `
|
|
152
|
-
+ 'The launcher will remain alive and keep retrying so capture descendants are not orphaned.'
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
if (burstExhausted) {
|
|
156
|
-
attempt = 0;
|
|
157
|
-
}
|
|
643
|
+
reportTerminationFailure(
|
|
644
|
+
`[LiveDesk Client] Terminal shutdown retained an exact Windows Agent tree: `
|
|
645
|
+
+ `${result.error?.message || 'bounded drain failed'}. Retrying before exit.`
|
|
646
|
+
);
|
|
647
|
+
child.livedeskTreeStopPromise = null;
|
|
648
|
+
child.livedeskWindowsDrainPromise = null;
|
|
158
649
|
windowsRetryTimer = setTimeout(
|
|
159
|
-
|
|
160
|
-
|
|
650
|
+
drainUntilStopped,
|
|
651
|
+
Math.max(100, Number(windowsTerminateRetryMs) || 100)
|
|
161
652
|
);
|
|
162
653
|
});
|
|
163
654
|
};
|
|
164
|
-
|
|
655
|
+
drainUntilStopped();
|
|
165
656
|
return;
|
|
166
657
|
}
|
|
167
658
|
const finishWhenTreeStops = () => {
|
|
@@ -170,28 +661,60 @@ export function installAgentTerminationHandlers({
|
|
|
170
661
|
const childAlive = child.exitCode === null && child.signalCode == null;
|
|
171
662
|
const groupAlive = ownsUnixGroup
|
|
172
663
|
&& isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
|
|
173
|
-
|
|
664
|
+
// A missing owned process group proves that both its Agent
|
|
665
|
+
// leader and capture descendants are gone even if Node has not
|
|
666
|
+
// delivered the child exit event yet.
|
|
667
|
+
if (ownsUnixGroup ? !groupAlive : !childAlive) {
|
|
174
668
|
finish();
|
|
175
669
|
}
|
|
176
670
|
};
|
|
177
671
|
child.once('exit', finishWhenTreeStops);
|
|
178
672
|
try {
|
|
179
673
|
signalAgentTree(child, signal, platform, signalProcess);
|
|
180
|
-
} catch {
|
|
181
|
-
|
|
182
|
-
|
|
674
|
+
} catch (error) {
|
|
675
|
+
reportTerminationFailure(
|
|
676
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} could not receive ${signal} `
|
|
677
|
+
+ `(${error?.message || error}). Forced cleanup will continue.`
|
|
678
|
+
);
|
|
183
679
|
}
|
|
184
680
|
poll = setInterval(finishWhenTreeStops, 50);
|
|
185
|
-
|
|
681
|
+
const retryMs = Math.max(100, Number(unixTerminateRetryMs) || 1000);
|
|
682
|
+
let hardFailureReported = false;
|
|
683
|
+
const terminateOwnedUnixTree = () => {
|
|
684
|
+
if (finished) return;
|
|
685
|
+
finishWhenTreeStops();
|
|
686
|
+
if (finished) return;
|
|
186
687
|
try {
|
|
187
688
|
signalAgentTree(child, 'SIGKILL', platform, signalProcess);
|
|
188
|
-
} catch {
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (!hardFailureReported) {
|
|
691
|
+
hardFailureReported = true;
|
|
692
|
+
reportTerminationFailure(
|
|
693
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} rejected SIGKILL `
|
|
694
|
+
+ `(${error?.message || error}). The launcher will remain alive and retry.`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
189
697
|
}
|
|
190
|
-
|
|
698
|
+
finishWhenTreeStops();
|
|
699
|
+
if (finished) return;
|
|
700
|
+
if (!hardFailureReported) {
|
|
701
|
+
hardFailureReported = true;
|
|
702
|
+
reportTerminationFailure(
|
|
703
|
+
`[LiveDesk Client] Agent tree pid=${processId || 'unknown'} is still alive after SIGKILL. `
|
|
704
|
+
+ 'The launcher will remain alive and keep retrying so capture descendants are not orphaned.'
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
unixRetryTimer = setTimeout(terminateOwnedUnixTree, retryMs);
|
|
708
|
+
};
|
|
709
|
+
timeout = setTimeout(() => {
|
|
710
|
+
terminateOwnedUnixTree();
|
|
191
711
|
}, Math.max(100, Number(shutdownTimeoutMs) || 3000));
|
|
192
712
|
};
|
|
193
713
|
handlers.set(signal, handler);
|
|
194
|
-
|
|
714
|
+
// Keep the listener installed while cleanup is in progress. A second
|
|
715
|
+
// Ctrl+C must not restore Node's default immediate exit and orphan the
|
|
716
|
+
// process group that the first signal is still draining.
|
|
717
|
+
hostProcess.on(signal, handler);
|
|
195
718
|
}
|
|
196
719
|
|
|
197
720
|
return () => {
|