@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.
@@ -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
+ }