livedesk 0.1.444 → 0.1.445

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,51 +1,411 @@
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 terminateWindowsProcessTree(processId) {
6
- return new Promise(resolve => {
7
- execFile(
8
- 'taskkill.exe',
9
- ['/PID', String(processId), '/T', '/F'],
10
- {
11
- windowsHide: true,
12
- timeout: 10000,
13
- killSignal: 'SIGKILL'
14
- },
15
- error => resolve({ ok: !error, error: error || null })
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
- function isOwnedUnixProcessGroupAlive(child, platform, signalProcess) {
21
- const processId = Number(child?.pid || 0);
22
- if (platform === 'win32'
23
- || child?.livedeskOwnsProcessGroup !== true
24
- || !Number.isInteger(processId)
25
- || processId <= 1) {
26
- return false;
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
- signalProcess(-processId, 0);
30
- return true;
95
+ parsed = JSON.parse(result.stdout);
31
96
  } catch (error) {
32
- return error?.code === 'EPERM';
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
- function isProcessIdAlive(processId, signalProcess) {
37
- if (!Number.isInteger(processId) || processId <= 1) {
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
+ export function isOwnedUnixProcessGroupAlive(child, platform = process.platform, signalProcess = (pid, signal) => process.kill(pid, signal)) {
388
+ const processId = Number(child?.pid || 0);
389
+ if (platform === 'win32'
390
+ || child?.livedeskOwnsProcessGroup !== true
391
+ || !Number.isInteger(processId)
392
+ || processId <= 1) {
38
393
  return false;
39
394
  }
40
395
  try {
41
- signalProcess(processId, 0);
396
+ signalProcess(-processId, 0);
42
397
  return true;
43
398
  } catch (error) {
44
399
  return error?.code === 'EPERM';
45
400
  }
46
401
  }
47
402
 
48
- function signalAgentTree(child, signal, platform, signalProcess) {
403
+ export function signalAgentTree(
404
+ child,
405
+ signal,
406
+ platform = process.platform,
407
+ signalProcess = (pid, requestedSignal) => process.kill(pid, requestedSignal)
408
+ ) {
49
409
  const processId = Number(child?.pid || 0);
50
410
  if (platform !== 'win32'
51
411
  && child?.livedeskOwnsProcessGroup === true
@@ -61,15 +421,102 @@ function signalAgentTree(child, signal, platform, signalProcess) {
61
421
  child.kill(signal);
62
422
  }
63
423
 
424
+ function waitMilliseconds(milliseconds) {
425
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
426
+ }
427
+
428
+ /**
429
+ * Contract:
430
+ * - Applies only to a Unix Agent that this launcher spawned as a dedicated
431
+ * detached process-group leader.
432
+ * - Resolves only after the owned PGID no longer exists.
433
+ * - Never searches for, or signals, ffmpeg/SCK processes by global name.
434
+ * - A surviving capture descendant keeps the original PGID alive, so group
435
+ * signaling drains it without having to identify that descendant globally.
436
+ */
437
+ export async function drainOwnedUnixAgentTree(child, {
438
+ platform = process.platform,
439
+ signalProcess = (pid, signal) => process.kill(pid, signal),
440
+ gracefulSignal = 'SIGTERM',
441
+ gracefulTimeoutMs = 1000,
442
+ hardRetryMs = 1000,
443
+ pollMs = 50,
444
+ reportTerminationFailure = message => console.error(message)
445
+ } = {}) {
446
+ const processId = Number(child?.pid || 0);
447
+ const ownsUnixGroup = platform !== 'win32'
448
+ && child?.livedeskOwnsProcessGroup === true
449
+ && Number.isInteger(processId)
450
+ && processId > 1;
451
+ if (!ownsUnixGroup || !isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
452
+ return;
453
+ }
454
+
455
+ const signalOwnedGroup = signal => {
456
+ try {
457
+ signalProcess(-processId, signal);
458
+ return true;
459
+ } catch (error) {
460
+ if (error?.code === 'ESRCH') return false;
461
+ throw error;
462
+ }
463
+ };
464
+ try {
465
+ signalOwnedGroup(gracefulSignal);
466
+ } catch (error) {
467
+ reportTerminationFailure(
468
+ `[LiveDesk Client] Exited Agent group pid=${processId} rejected ${gracefulSignal} `
469
+ + `(${error?.message || error}); forced cleanup will continue.`
470
+ );
471
+ }
472
+
473
+ const gracefulDeadline = Date.now() + Math.max(100, Number(gracefulTimeoutMs) || 1000);
474
+ const boundedPollMs = Math.max(10, Number(pollMs) || 50);
475
+ while (Date.now() < gracefulDeadline) {
476
+ if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
477
+ await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, gracefulDeadline - Date.now())));
478
+ }
479
+
480
+ let failureReported = false;
481
+ const retryMs = Math.max(100, Number(hardRetryMs) || 1000);
482
+ while (isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) {
483
+ try {
484
+ signalOwnedGroup('SIGKILL');
485
+ } catch (error) {
486
+ if (!failureReported) {
487
+ failureReported = true;
488
+ reportTerminationFailure(
489
+ `[LiveDesk Client] Exited Agent group pid=${processId} rejected SIGKILL `
490
+ + `(${error?.message || error}). The launcher will not start a replacement until the group drains.`
491
+ );
492
+ }
493
+ }
494
+ if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
495
+ if (!failureReported) {
496
+ failureReported = true;
497
+ reportTerminationFailure(
498
+ `[LiveDesk Client] Exited Agent group pid=${processId} still has capture descendants after SIGKILL. `
499
+ + 'The launcher will not start a replacement until the group drains.'
500
+ );
501
+ }
502
+ const retryDeadline = Date.now() + retryMs;
503
+ while (Date.now() < retryDeadline) {
504
+ if (!isOwnedUnixProcessGroupAlive(child, platform, signalProcess)) return;
505
+ await waitMilliseconds(Math.min(boundedPollMs, Math.max(1, retryDeadline - Date.now())));
506
+ }
507
+ }
508
+ }
509
+
64
510
  export function installAgentTerminationHandlers({
65
511
  hostProcess = process,
66
512
  getAgentProcess,
67
513
  shutdownTimeoutMs = 8000,
68
514
  platform = process.platform,
69
515
  signalProcess = (pid, signal) => process.kill(pid, signal),
70
- terminateWindowsTree = terminateWindowsProcessTree,
516
+ drainWindowsTree = drainOwnedWindowsAgentTree,
71
517
  windowsTerminateAttemptLimit = 3,
72
518
  windowsTerminateRetryMs = 100,
519
+ unixTerminateRetryMs = 1000,
73
520
  reportTerminationFailure = message => console.error(message),
74
521
  exitProcess = code => hostProcess.exit(code)
75
522
  } = {}) {
@@ -85,7 +532,19 @@ export function installAgentTerminationHandlers({
85
532
  terminating = true;
86
533
  const exitCode = SIGNAL_EXIT_CODES[signal] || 1;
87
534
  const child = getAgentProcess();
88
- if (!child || child.exitCode !== null) {
535
+ if (!child) {
536
+ exitProcess(exitCode);
537
+ return;
538
+ }
539
+ const exitedUnixLeaderStillOwnsGroup = platform !== 'win32'
540
+ && child?.livedeskOwnsProcessGroup === true
541
+ && child.exitCode !== null
542
+ && isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
543
+ // Windows must still drain immutable recorded descendants when
544
+ // Ctrl+C arrives after the Agent leader has already exited.
545
+ if (platform !== 'win32'
546
+ && child.exitCode !== null
547
+ && !exitedUnixLeaderStillOwnsGroup) {
89
548
  exitProcess(exitCode);
90
549
  return;
91
550
  }
@@ -93,15 +552,13 @@ export function installAgentTerminationHandlers({
93
552
  let finished = false;
94
553
  let poll = null;
95
554
  let timeout = null;
96
- let hardStop = null;
97
- let windowsRetryTimer = null;
555
+ let unixRetryTimer = null;
98
556
  const finish = () => {
99
557
  if (finished) return;
100
558
  finished = true;
101
559
  if (poll) clearInterval(poll);
102
560
  if (timeout) clearTimeout(timeout);
103
- if (hardStop) clearTimeout(hardStop);
104
- if (windowsRetryTimer) clearTimeout(windowsRetryTimer);
561
+ if (unixRetryTimer) clearTimeout(unixRetryTimer);
105
562
  exitProcess(exitCode);
106
563
  };
107
564
  const processId = Number(child?.pid || 0);
@@ -110,58 +567,29 @@ export function installAgentTerminationHandlers({
110
567
  finish();
111
568
  return;
112
569
  }
113
- // On Windows child.kill() only gives us the Agent leader's
114
- // lifecycle. It does not provide an authoritative contract
115
- // that FFmpeg descendants have stopped. Keep the launcher
116
- // alive until taskkill has completed against this exact,
117
- // launcher-owned process tree. Never search for or terminate
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 = () => {
124
- if (finished) return;
125
- attempt += 1;
126
- let treeTermination;
127
- try {
128
- treeTermination = terminateWindowsTree(processId);
129
- } catch (error) {
130
- treeTermination = { ok: false, error };
131
- }
132
- Promise.resolve(treeTermination)
133
- .catch(error => ({ ok: false, error }))
134
- .then(result => {
135
- if (finished) return;
136
- if (result?.ok !== false) {
137
- finish();
138
- return;
139
- }
140
- if (!isProcessIdAlive(processId, signalProcess)) {
141
- finish();
142
- return;
143
- }
144
-
145
- const burstExhausted = attempt >= attemptLimit;
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
- }
158
- windowsRetryTimer = setTimeout(
159
- terminateOwnedTree,
160
- burstExhausted ? Math.max(1000, retryMs) : retryMs
570
+ // Use the same immutable PID + CreationDate drain as internal
571
+ // restarts and unexpected Agent exits. The promise is stored
572
+ // on the child so spawnAgent cannot race ahead and launch a
573
+ // replacement while terminal shutdown is still draining.
574
+ if (!child.livedeskTreeStopPromise) {
575
+ child.livedeskTreeStopPromise = Promise.resolve().then(() => drainWindowsTree(child, {
576
+ platform,
577
+ maxAttempts: windowsTerminateAttemptLimit,
578
+ retryMs: windowsTerminateRetryMs,
579
+ reportTerminationFailure
580
+ }));
581
+ }
582
+ child.livedeskTreeStopPromise
583
+ .catch(error => ({ ok: false, error }))
584
+ .then(result => {
585
+ if (result?.ok === false) {
586
+ reportTerminationFailure(
587
+ `[LiveDesk Client] Terminal shutdown preserved an undrained exact Windows Agent tree: `
588
+ + `${result.error?.message || 'bounded drain failed'}`
161
589
  );
162
- });
163
- };
164
- terminateOwnedTree();
590
+ }
591
+ finish();
592
+ });
165
593
  return;
166
594
  }
167
595
  const finishWhenTreeStops = () => {
@@ -170,28 +598,60 @@ export function installAgentTerminationHandlers({
170
598
  const childAlive = child.exitCode === null && child.signalCode == null;
171
599
  const groupAlive = ownsUnixGroup
172
600
  && isOwnedUnixProcessGroupAlive(child, platform, signalProcess);
173
- if (!childAlive && !groupAlive) {
601
+ // A missing owned process group proves that both its Agent
602
+ // leader and capture descendants are gone even if Node has not
603
+ // delivered the child exit event yet.
604
+ if (ownsUnixGroup ? !groupAlive : !childAlive) {
174
605
  finish();
175
606
  }
176
607
  };
177
608
  child.once('exit', finishWhenTreeStops);
178
609
  try {
179
610
  signalAgentTree(child, signal, platform, signalProcess);
180
- } catch {
181
- finish();
182
- return;
611
+ } catch (error) {
612
+ reportTerminationFailure(
613
+ `[LiveDesk Client] Agent tree pid=${processId || 'unknown'} could not receive ${signal} `
614
+ + `(${error?.message || error}). Forced cleanup will continue.`
615
+ );
183
616
  }
184
617
  poll = setInterval(finishWhenTreeStops, 50);
185
- timeout = setTimeout(() => {
618
+ const retryMs = Math.max(100, Number(unixTerminateRetryMs) || 1000);
619
+ let hardFailureReported = false;
620
+ const terminateOwnedUnixTree = () => {
621
+ if (finished) return;
622
+ finishWhenTreeStops();
623
+ if (finished) return;
186
624
  try {
187
625
  signalAgentTree(child, 'SIGKILL', platform, signalProcess);
188
- } catch {
626
+ } catch (error) {
627
+ if (!hardFailureReported) {
628
+ hardFailureReported = true;
629
+ reportTerminationFailure(
630
+ `[LiveDesk Client] Agent tree pid=${processId || 'unknown'} rejected SIGKILL `
631
+ + `(${error?.message || error}). The launcher will remain alive and retry.`
632
+ );
633
+ }
189
634
  }
190
- hardStop = setTimeout(finish, 250);
635
+ finishWhenTreeStops();
636
+ if (finished) return;
637
+ if (!hardFailureReported) {
638
+ hardFailureReported = true;
639
+ reportTerminationFailure(
640
+ `[LiveDesk Client] Agent tree pid=${processId || 'unknown'} is still alive after SIGKILL. `
641
+ + 'The launcher will remain alive and keep retrying so capture descendants are not orphaned.'
642
+ );
643
+ }
644
+ unixRetryTimer = setTimeout(terminateOwnedUnixTree, retryMs);
645
+ };
646
+ timeout = setTimeout(() => {
647
+ terminateOwnedUnixTree();
191
648
  }, Math.max(100, Number(shutdownTimeoutMs) || 3000));
192
649
  };
193
650
  handlers.set(signal, handler);
194
- hostProcess.once(signal, handler);
651
+ // Keep the listener installed while cleanup is in progress. A second
652
+ // Ctrl+C must not restore Node's default immediate exit and orphan the
653
+ // process group that the first signal is still draining.
654
+ hostProcess.on(signal, handler);
195
655
  }
196
656
 
197
657
  return () => {