@livedesk/client 0.1.199 → 0.1.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/livedesk-client.js +134 -28
- package/package.json +5 -5
- package/src/runtime/agent-process-lifecycle.js +550 -90
package/bin/livedesk-client.js
CHANGED
|
@@ -11,7 +11,13 @@ import net from 'node:net';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import { formatClientVersionBanner, readClientPackageVersion } from './client-version.js';
|
|
13
13
|
import { createClientRuntimeServer } from '../src/runtime/client-runtime-server.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createWindowsProcessTreeTracker,
|
|
16
|
+
drainOwnedWindowsAgentTree,
|
|
17
|
+
drainOwnedUnixAgentTree,
|
|
18
|
+
installAgentTerminationHandlers,
|
|
19
|
+
signalAgentTree
|
|
20
|
+
} from '../src/runtime/agent-process-lifecycle.js';
|
|
15
21
|
import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
|
|
16
22
|
import {
|
|
17
23
|
inspectLinuxVideoAcceleration,
|
|
@@ -67,6 +73,22 @@ let networkChangeMonitor = null;
|
|
|
67
73
|
const sessionRefreshesInFlight = new WeakMap();
|
|
68
74
|
installAgentTerminationHandlers({ getAgentProcess: () => activeAgentProcess });
|
|
69
75
|
|
|
76
|
+
function requestActiveAgentStop(signal = 'SIGTERM') {
|
|
77
|
+
const child = activeAgentProcess;
|
|
78
|
+
if (!child) return false;
|
|
79
|
+
if (process.platform === 'win32') {
|
|
80
|
+
// Internal reconnect/slot/role restarts use the same immutable
|
|
81
|
+
// PID+CreationDate tree contract as unexpected exit and terminal
|
|
82
|
+
// shutdown. Store the shared proof promise so no replacement can race.
|
|
83
|
+
if (!child.livedeskTreeStopPromise) {
|
|
84
|
+
child.livedeskTreeStopPromise = drainOwnedWindowsAgentTree(child);
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
signalAgentTree(child, signal);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
export function requestLocalDiscoveryWake(reason = 'local-trigger') {
|
|
71
93
|
const previous = discoveryWakeController;
|
|
72
94
|
discoveryWakeController = new AbortController();
|
|
@@ -2783,7 +2805,7 @@ async function startConnectionChoiceServer(supabase, options = {}) {
|
|
|
2783
2805
|
res.end(JSON.stringify({ ok: true, restarting: true, role: 'hub', roleVersion: nextRoleVersion }));
|
|
2784
2806
|
setTimeout(() => {
|
|
2785
2807
|
try {
|
|
2786
|
-
|
|
2808
|
+
requestActiveAgentStop();
|
|
2787
2809
|
} catch {
|
|
2788
2810
|
}
|
|
2789
2811
|
try {
|
|
@@ -3080,7 +3102,7 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3080
3102
|
writeLocalRoleCache(role, options.deviceId, roleVersion);
|
|
3081
3103
|
roleRestartRequest = { role, requestedAt: new Date().toISOString() };
|
|
3082
3104
|
requestLocalDiscoveryWake('role-change');
|
|
3083
|
-
try {
|
|
3105
|
+
try { requestActiveAgentStop(); } catch { /* the lifecycle loop observes the role request */ }
|
|
3084
3106
|
return { ok: true, restarting: true, role, roleVersion };
|
|
3085
3107
|
},
|
|
3086
3108
|
videoAcceleration: linuxVideoAccelerationStatus,
|
|
@@ -3098,7 +3120,7 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3098
3120
|
requestedAt: new Date().toISOString()
|
|
3099
3121
|
};
|
|
3100
3122
|
requestLocalDiscoveryWake('linux-video-acceleration-ready');
|
|
3101
|
-
try {
|
|
3123
|
+
try { requestActiveAgentStop(); } catch { /* the lifecycle loop restarts with the new ffmpeg preference */ }
|
|
3102
3124
|
},
|
|
3103
3125
|
assignSlot: async request => {
|
|
3104
3126
|
const result = await requestHubSlotAssignment(request);
|
|
@@ -3113,20 +3135,20 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3113
3135
|
};
|
|
3114
3136
|
requestLocalDiscoveryWake('slot-updated');
|
|
3115
3137
|
setTimeout(() => {
|
|
3116
|
-
try {
|
|
3138
|
+
try { requestActiveAgentStop(); } catch { /* the lifecycle loop restarts with the saved slot */ }
|
|
3117
3139
|
}, 150);
|
|
3118
3140
|
return { ...result, slotNumber, saved: true, restarting: true };
|
|
3119
3141
|
},
|
|
3120
3142
|
onRestart: () => process.kill(process.pid, 'SIGTERM'),
|
|
3121
3143
|
onShutdown: () => process.kill(process.pid, 'SIGTERM'),
|
|
3122
|
-
onDisconnect: () =>
|
|
3144
|
+
onDisconnect: () => requestActiveAgentStop(),
|
|
3123
3145
|
// Ending the current Agent causes the unified lifecycle loop to
|
|
3124
3146
|
// discard its old manager/pair token and start Supabase discovery
|
|
3125
3147
|
// again. Leaving this empty made the Reconnect button a success
|
|
3126
3148
|
// response with no actual transport work.
|
|
3127
3149
|
onReconnect: () => {
|
|
3128
3150
|
requestLocalDiscoveryWake('manual-reconnect');
|
|
3129
|
-
|
|
3151
|
+
requestActiveAgentStop();
|
|
3130
3152
|
}
|
|
3131
3153
|
});
|
|
3132
3154
|
await connectionPage.start();
|
|
@@ -4103,6 +4125,7 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
|
|
|
4103
4125
|
restartVerified: false
|
|
4104
4126
|
});
|
|
4105
4127
|
const ownsProcessGroup = process.platform !== 'win32';
|
|
4128
|
+
const agentSpawnedAtMs = Date.now();
|
|
4106
4129
|
const child = spawn(command, args, {
|
|
4107
4130
|
env,
|
|
4108
4131
|
stdio: 'inherit',
|
|
@@ -4113,24 +4136,78 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
|
|
|
4113
4136
|
windowsHide: true
|
|
4114
4137
|
});
|
|
4115
4138
|
child.livedeskOwnsProcessGroup = ownsProcessGroup;
|
|
4139
|
+
child.livedeskWindowsTreeTracker = createWindowsProcessTreeTracker(child, {
|
|
4140
|
+
spawnedAtMs: agentSpawnedAtMs
|
|
4141
|
+
});
|
|
4116
4142
|
activeAgentProcess = child;
|
|
4117
4143
|
if (typeof onStart === 'function') {
|
|
4118
4144
|
onStart(child);
|
|
4119
4145
|
}
|
|
4120
4146
|
|
|
4121
|
-
return new Promise(resolve => {
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
if (activeAgentProcess === child) {
|
|
4128
|
-
activeAgentProcess = null;
|
|
4129
|
-
}
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4147
|
+
return new Promise(resolve => {
|
|
4148
|
+
let settling = false;
|
|
4149
|
+
child.once('error', error => {
|
|
4150
|
+
if (settling) return;
|
|
4151
|
+
settling = true;
|
|
4152
|
+
child.livedeskWindowsTreeTracker?.stop?.();
|
|
4153
|
+
if (activeAgentProcess === child) {
|
|
4154
|
+
activeAgentProcess = null;
|
|
4155
|
+
}
|
|
4156
|
+
console.error(`Failed to launch ${command}: ${error.message}`);
|
|
4157
|
+
resolve({ code: 1, signal: null });
|
|
4158
|
+
});
|
|
4159
|
+
child.once('exit', (code, signal) => {
|
|
4160
|
+
if (settling) return;
|
|
4161
|
+
settling = true;
|
|
4162
|
+
child.livedeskWindowsTreeTracker?.markRootExited?.();
|
|
4163
|
+
// The Unix Agent is a dedicated process-group leader. Its exit
|
|
4164
|
+
// event proves only that the leader stopped; SCK/FFmpeg capture
|
|
4165
|
+
// descendants can still own that PGID. Keep active ownership and
|
|
4166
|
+
// do not let the lifecycle loop launch a replacement until the
|
|
4167
|
+
// complete group has drained.
|
|
4168
|
+
void (async () => {
|
|
4169
|
+
try {
|
|
4170
|
+
if (process.platform === 'win32') {
|
|
4171
|
+
if (!child.livedeskTreeStopPromise) {
|
|
4172
|
+
child.livedeskTreeStopPromise = drainOwnedWindowsAgentTree(child);
|
|
4173
|
+
}
|
|
4174
|
+
const windowsTreeStop = await child.livedeskTreeStopPromise;
|
|
4175
|
+
if (windowsTreeStop?.ok === false) {
|
|
4176
|
+
throw new Error(
|
|
4177
|
+
`exact Windows Agent tree drain failed: `
|
|
4178
|
+
+ `${windowsTreeStop.error?.message || 'bounded immutable-tree cleanup failed'}`
|
|
4179
|
+
);
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
await drainOwnedUnixAgentTree(child);
|
|
4183
|
+
} catch (error) {
|
|
4184
|
+
console.error(
|
|
4185
|
+
`[LiveDesk Client] Could not drain exited Agent tree pid=${child.pid || 'unknown'}: `
|
|
4186
|
+
+ `${error?.message || error}`
|
|
4187
|
+
);
|
|
4188
|
+
// Block this lifecycle iteration from spawning a
|
|
4189
|
+
// replacement, but settle explicitly so the launcher
|
|
4190
|
+
// terminates instead of hanging forever.
|
|
4191
|
+
child.livedeskWindowsTreeTracker?.stop?.();
|
|
4192
|
+
if (activeAgentProcess === child) {
|
|
4193
|
+
activeAgentProcess = null;
|
|
4194
|
+
}
|
|
4195
|
+
resolve({
|
|
4196
|
+
code: 1,
|
|
4197
|
+
signal: null,
|
|
4198
|
+
lifecycleFailure: String(error?.message || error)
|
|
4199
|
+
});
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
child.livedeskWindowsTreeTracker?.stop?.();
|
|
4203
|
+
if (activeAgentProcess === child) {
|
|
4204
|
+
activeAgentProcess = null;
|
|
4205
|
+
}
|
|
4206
|
+
resolve({ code: code ?? 0, signal });
|
|
4207
|
+
})();
|
|
4208
|
+
});
|
|
4209
|
+
});
|
|
4210
|
+
}
|
|
4134
4211
|
|
|
4135
4212
|
function shouldUseFast(parsed) {
|
|
4136
4213
|
if (parsed.engine === 'node') {
|
|
@@ -4286,9 +4363,36 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4286
4363
|
pid: child.pid,
|
|
4287
4364
|
startedAt: new Date().toISOString(),
|
|
4288
4365
|
state: 'running'
|
|
4289
|
-
});
|
|
4290
|
-
});
|
|
4291
|
-
}
|
|
4366
|
+
});
|
|
4367
|
+
});
|
|
4368
|
+
}
|
|
4369
|
+
// Contract: once the owned Agent exits, the local runtime must stop
|
|
4370
|
+
// advertising its old PID before any restart or Hub rediscovery.
|
|
4371
|
+
// Keeping a dead PID makes status diagnostics lie and causes manual
|
|
4372
|
+
// reconnect to target a process that no longer exists.
|
|
4373
|
+
prepared.connectionPage?.update({
|
|
4374
|
+
agent: {
|
|
4375
|
+
requestedEngine: prepared.engine,
|
|
4376
|
+
state: 'stopped',
|
|
4377
|
+
pid: 0,
|
|
4378
|
+
stoppedAt: new Date().toISOString(),
|
|
4379
|
+
exitCode: Number.isInteger(result?.code) ? result.code : null,
|
|
4380
|
+
exitSignal: result?.signal || ''
|
|
4381
|
+
}
|
|
4382
|
+
});
|
|
4383
|
+
if (result?.lifecycleFailure) {
|
|
4384
|
+
prepared.connectionPage?.update({
|
|
4385
|
+
agent: {
|
|
4386
|
+
requestedEngine: prepared.engine,
|
|
4387
|
+
state: 'failed',
|
|
4388
|
+
pid: 0,
|
|
4389
|
+
error: result.lifecycleFailure
|
|
4390
|
+
},
|
|
4391
|
+
message: 'LiveDesk stopped because the previous Windows capture tree could not be drained safely.'
|
|
4392
|
+
});
|
|
4393
|
+
connectionPage?.close?.();
|
|
4394
|
+
throw new Error(result.lifecycleFailure);
|
|
4395
|
+
}
|
|
4292
4396
|
const requestedRoleRestart = connectionPage?.getRoleRestartRequest?.();
|
|
4293
4397
|
if (requestedRoleRestart?.role) {
|
|
4294
4398
|
if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
|
|
@@ -4302,7 +4406,8 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4302
4406
|
connectionPage?.update({
|
|
4303
4407
|
agent: {
|
|
4304
4408
|
requestedEngine: prepared.engine,
|
|
4305
|
-
state: 'reconnecting'
|
|
4409
|
+
state: 'reconnecting',
|
|
4410
|
+
pid: 0
|
|
4306
4411
|
},
|
|
4307
4412
|
message: restart.reason === 'slot-updated'
|
|
4308
4413
|
? `Slot ${String(restart.slotNumber || prepared.slot || '').padStart(3, '0')} saved. Restarting the LiveDesk Agent.`
|
|
@@ -4331,10 +4436,11 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
|
|
|
4331
4436
|
? 'LiveDesk Hub pair token changed. Refreshing Hub discovery now.'
|
|
4332
4437
|
: 'LiveDesk Hub connection ended. Looking for the current Hub now.');
|
|
4333
4438
|
connectionPage?.update({
|
|
4334
|
-
agent: {
|
|
4335
|
-
requestedEngine: prepared.engine,
|
|
4336
|
-
state: 'reconnecting'
|
|
4337
|
-
|
|
4439
|
+
agent: {
|
|
4440
|
+
requestedEngine: prepared.engine,
|
|
4441
|
+
state: 'reconnecting',
|
|
4442
|
+
pid: 0
|
|
4443
|
+
},
|
|
4338
4444
|
manager: '',
|
|
4339
4445
|
message: 'The previous Hub is unavailable. LiveDesk is finding the current Hub automatically.'
|
|
4340
4446
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.200",
|
|
4
4
|
"description": "LiveDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
"ws": "^8.18.3"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
45
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
47
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
44
|
+
"@livedesk/fast-linux-x64": "0.1.407",
|
|
45
|
+
"@livedesk/fast-osx-arm64": "0.1.407",
|
|
46
|
+
"@livedesk/fast-osx-x64": "0.1.407",
|
|
47
|
+
"@livedesk/fast-win-x64": "0.1.407"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
function normalizeWindowsProcessRecord(value) {
|
|
8
|
+
const pid = Number(value?.pid ?? value?.ProcessId ?? 0);
|
|
9
|
+
const parentPid = Number(value?.parentPid ?? value?.ParentProcessId ?? 0);
|
|
10
|
+
const startMarker = String(value?.startMarker ?? value?.CreationDate ?? '').trim();
|
|
11
|
+
const startOrder = String(value?.startOrder ?? value?.CreationUtcTicks ?? '').trim();
|
|
12
|
+
if (!Number.isInteger(pid)
|
|
13
|
+
|| pid <= 1
|
|
14
|
+
|| !startMarker
|
|
15
|
+
|| !/^\d+$/.test(startOrder)) return null;
|
|
16
|
+
return { pid, parentPid, startMarker, startOrder };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function windowsRecordKey(record) {
|
|
20
|
+
return `${Number(record?.pid || 0)}:${String(record?.startOrder || '')}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameWindowsProcess(left, right) {
|
|
24
|
+
return Number(left?.pid || 0) === Number(right?.pid || 0)
|
|
25
|
+
&& String(left?.startOrder || '') === String(right?.startOrder || '')
|
|
26
|
+
&& String(left?.startMarker || '') === String(right?.startMarker || '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function windowsTicksForUnixMilliseconds(milliseconds) {
|
|
30
|
+
return BigInt(Math.trunc(Number(milliseconds) || 0)) * 10_000n + 621_355_968_000_000_000n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function windowsRecordIsWithinLifetime(record, spawnedAtMs, exitedAtMs = null) {
|
|
34
|
+
if (!record?.startOrder) return false;
|
|
35
|
+
try {
|
|
36
|
+
const order = BigInt(record.startOrder);
|
|
37
|
+
// spawnedAtMs is recorded immediately before spawn(). A small 250ms
|
|
38
|
+
// tolerance covers clock conversion/scheduling without admitting an
|
|
39
|
+
// old process whose stale ParentProcessId happens to equal the Agent.
|
|
40
|
+
const lower = windowsTicksForUnixMilliseconds(
|
|
41
|
+
Math.max(0, spawnedAtMs - WINDOWS_SPAWN_CLOCK_TOLERANCE_MS)
|
|
16
42
|
);
|
|
43
|
+
const upper = exitedAtMs === null
|
|
44
|
+
? windowsTicksForUnixMilliseconds(Date.now() + 5000)
|
|
45
|
+
: windowsTicksForUnixMilliseconds(exitedAtMs);
|
|
46
|
+
return order >= lower && order <= upper;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runExecFile(command, args, options = {}, execFileImpl = execFile) {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
execFileImpl(command, args, options, (error, stdout, stderr) => {
|
|
55
|
+
resolve({
|
|
56
|
+
ok: !error,
|
|
57
|
+
error: error || null,
|
|
58
|
+
stdout: String(stdout || '').trim(),
|
|
59
|
+
stderr: String(stderr || '').trim()
|
|
60
|
+
});
|
|
61
|
+
});
|
|
17
62
|
});
|
|
18
63
|
}
|
|
19
64
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Returns immutable PID + CreationDate records only. Command lines are
|
|
67
|
+
* intentionally excluded so no globally named ffmpeg process can become a
|
|
68
|
+
* termination target.
|
|
69
|
+
*/
|
|
70
|
+
export async function queryWindowsProcessTable({
|
|
71
|
+
execFileImpl = execFile
|
|
72
|
+
} = {}) {
|
|
73
|
+
const script = [
|
|
74
|
+
'$ErrorActionPreference = "Stop";',
|
|
75
|
+
'@(Get-CimInstance Win32_Process | ForEach-Object {',
|
|
76
|
+
' $creation = [string]$_.CreationDate;',
|
|
77
|
+
' $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
78
|
+
' [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationDate = $creation; CreationUtcTicks = $ticks }',
|
|
79
|
+
'}) | ConvertTo-Json -Compress'
|
|
80
|
+
].join(' ');
|
|
81
|
+
const result = await runExecFile(
|
|
82
|
+
'powershell.exe',
|
|
83
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
84
|
+
{ windowsHide: true, timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024 },
|
|
85
|
+
execFileImpl
|
|
86
|
+
);
|
|
87
|
+
if (!result.ok || !result.stdout) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Windows process-tree snapshot failed: `
|
|
90
|
+
+ `${result.stderr || result.error?.message || 'CIM query returned no process records'}`
|
|
91
|
+
);
|
|
27
92
|
}
|
|
93
|
+
let parsed;
|
|
28
94
|
try {
|
|
29
|
-
|
|
30
|
-
return true;
|
|
95
|
+
parsed = JSON.parse(result.stdout);
|
|
31
96
|
} catch (error) {
|
|
32
|
-
|
|
97
|
+
throw new Error(`Windows process-tree snapshot returned invalid JSON: ${error?.message || error}`);
|
|
98
|
+
}
|
|
99
|
+
return (Array.isArray(parsed) ? parsed : [parsed])
|
|
100
|
+
.map(normalizeWindowsProcessRecord)
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Rechecks CreationDate inside the same PowerShell process immediately before
|
|
106
|
+
* taskkill. PID reuse therefore becomes a harmless identity mismatch instead
|
|
107
|
+
* of terminating the replacement process.
|
|
108
|
+
*/
|
|
109
|
+
export async function terminateExactWindowsProcessTree(record, {
|
|
110
|
+
includeTree = true,
|
|
111
|
+
execFileImpl = execFile
|
|
112
|
+
} = {}) {
|
|
113
|
+
const target = normalizeWindowsProcessRecord(record);
|
|
114
|
+
if (!target) {
|
|
115
|
+
return { ok: false, error: new Error('An immutable Windows process record is required.') };
|
|
33
116
|
}
|
|
117
|
+
const expectedStartTicks = target.startOrder;
|
|
118
|
+
const treeArgument = includeTree ? ', "/T"' : '';
|
|
119
|
+
const script = [
|
|
120
|
+
'$ErrorActionPreference = "Stop";',
|
|
121
|
+
`$targetPid = ${target.pid};`,
|
|
122
|
+
`$expectedStartTicks = '${expectedStartTicks}';`,
|
|
123
|
+
'$target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -ErrorAction SilentlyContinue | Select-Object -First 1;',
|
|
124
|
+
'if (-not $target) { exit 0 };',
|
|
125
|
+
'$targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" };',
|
|
126
|
+
'if ($targetStartTicks -ne $expectedStartTicks) { exit 0 };',
|
|
127
|
+
`$arguments = @("/PID", [string]$targetPid${treeArgument}, "/F");`,
|
|
128
|
+
'$taskkill = Start-Process -FilePath "taskkill.exe" -ArgumentList $arguments -NoNewWindow -Wait -PassThru;',
|
|
129
|
+
'exit $taskkill.ExitCode'
|
|
130
|
+
].join(' ');
|
|
131
|
+
return runExecFile(
|
|
132
|
+
'powershell.exe',
|
|
133
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
134
|
+
{ windowsHide: true, timeout: 15000, killSignal: 'SIGKILL' },
|
|
135
|
+
execFileImpl
|
|
136
|
+
);
|
|
34
137
|
}
|
|
35
138
|
|
|
36
|
-
|
|
37
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Tracks the exact Agent root and only descendants connected through Windows
|
|
141
|
+
* ParentProcessId records. The default 10-second cadence is deliberately low
|
|
142
|
+
* overhead; refreshes are serialized and the timer is unref'd. A final
|
|
143
|
+
* snapshot covers normal direct FFmpeg children even between timer samples.
|
|
144
|
+
*/
|
|
145
|
+
export function createWindowsProcessTreeTracker(child, {
|
|
146
|
+
platform = process.platform,
|
|
147
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
148
|
+
snapshotIntervalMs = DEFAULT_WINDOWS_TREE_SNAPSHOT_MS,
|
|
149
|
+
spawnedAtMs = Date.now(),
|
|
150
|
+
setIntervalImpl = setInterval,
|
|
151
|
+
clearIntervalImpl = clearInterval
|
|
152
|
+
} = {}) {
|
|
153
|
+
const rootPid = Number(child?.pid || 0);
|
|
154
|
+
const enabled = platform === 'win32' && Number.isInteger(rootPid) && rootPid > 1;
|
|
155
|
+
const tracked = new Map();
|
|
156
|
+
let rootRecord = null;
|
|
157
|
+
let lastSnapshot = [];
|
|
158
|
+
let lastError = null;
|
|
159
|
+
let inFlight = null;
|
|
160
|
+
let stopped = false;
|
|
161
|
+
let timer = null;
|
|
162
|
+
let rootExitedAtMs = null;
|
|
163
|
+
let rootCapturedBeforeExit = false;
|
|
164
|
+
let lastSnapshotHadRootPid = false;
|
|
165
|
+
let successfulSnapshotAfterExit = false;
|
|
166
|
+
let ambiguousRootReuse = false;
|
|
167
|
+
|
|
168
|
+
const mergeSnapshot = snapshot => {
|
|
169
|
+
const records = (Array.isArray(snapshot) ? snapshot : [])
|
|
170
|
+
.map(normalizeWindowsProcessRecord)
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
lastSnapshot = records;
|
|
173
|
+
if (rootExitedAtMs !== null) successfulSnapshotAfterExit = true;
|
|
174
|
+
const currentByPid = new Map(records.map(record => [record.pid, record]));
|
|
175
|
+
const currentRoot = currentByPid.get(rootPid) || null;
|
|
176
|
+
lastSnapshotHadRootPid = Boolean(currentRoot);
|
|
177
|
+
if (rootRecord && currentRoot && !sameWindowsProcess(currentRoot, rootRecord)) {
|
|
178
|
+
ambiguousRootReuse = true;
|
|
179
|
+
}
|
|
180
|
+
if (!rootRecord
|
|
181
|
+
&& currentRoot
|
|
182
|
+
&& windowsRecordIsWithinLifetime(currentRoot, spawnedAtMs, rootExitedAtMs)) {
|
|
183
|
+
rootCapturedBeforeExit = rootExitedAtMs === null;
|
|
184
|
+
rootRecord = {
|
|
185
|
+
...currentRoot,
|
|
186
|
+
depth: 0,
|
|
187
|
+
targetable: rootCapturedBeforeExit
|
|
188
|
+
};
|
|
189
|
+
if (rootRecord.targetable) {
|
|
190
|
+
tracked.set(windowsRecordKey(rootRecord), rootRecord);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const exactCurrentByPid = new Map();
|
|
195
|
+
if (rootRecord && currentRoot && sameWindowsProcess(currentRoot, rootRecord)) {
|
|
196
|
+
exactCurrentByPid.set(rootPid, rootRecord);
|
|
197
|
+
}
|
|
198
|
+
for (const record of tracked.values()) {
|
|
199
|
+
const current = currentByPid.get(record.pid);
|
|
200
|
+
if (current && sameWindowsProcess(current, record)) {
|
|
201
|
+
exactCurrentByPid.set(record.pid, record);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// If the root PID is absent, Windows retains ParentProcessId on its
|
|
206
|
+
// surviving direct children. If the PID has already been reused, do
|
|
207
|
+
// not infer any new lineage through that ambiguous numeric PID.
|
|
208
|
+
const rootAbsent = !currentRoot;
|
|
209
|
+
let changed = true;
|
|
210
|
+
while (changed) {
|
|
211
|
+
changed = false;
|
|
212
|
+
for (const record of records) {
|
|
213
|
+
if (record.pid === rootPid || tracked.has(windowsRecordKey(record))) continue;
|
|
214
|
+
if (!windowsRecordIsWithinLifetime(record, spawnedAtMs, rootExitedAtMs)) continue;
|
|
215
|
+
let parent = exactCurrentByPid.get(record.parentPid) || null;
|
|
216
|
+
if (!parent
|
|
217
|
+
&& rootExitedAtMs !== null
|
|
218
|
+
&& rootAbsent
|
|
219
|
+
&& record.parentPid === rootPid) {
|
|
220
|
+
parent = rootRecord || { pid: rootPid, depth: 0 };
|
|
221
|
+
}
|
|
222
|
+
if (!parent) continue;
|
|
223
|
+
const descendant = { ...record, depth: Number(parent.depth || 0) + 1 };
|
|
224
|
+
tracked.set(windowsRecordKey(descendant), descendant);
|
|
225
|
+
exactCurrentByPid.set(descendant.pid, descendant);
|
|
226
|
+
changed = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const refresh = async () => {
|
|
232
|
+
if (!enabled) return { ok: true, records: [] };
|
|
233
|
+
if (inFlight) return inFlight;
|
|
234
|
+
inFlight = Promise.resolve()
|
|
235
|
+
.then(() => queryProcessTableImpl())
|
|
236
|
+
.then(snapshot => {
|
|
237
|
+
mergeSnapshot(snapshot);
|
|
238
|
+
lastError = null;
|
|
239
|
+
return { ok: true, records: lastSnapshot };
|
|
240
|
+
})
|
|
241
|
+
.catch(error => {
|
|
242
|
+
lastError = error;
|
|
243
|
+
return { ok: false, error, records: lastSnapshot };
|
|
244
|
+
})
|
|
245
|
+
.finally(() => {
|
|
246
|
+
inFlight = null;
|
|
247
|
+
});
|
|
248
|
+
return inFlight;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const ready = enabled ? refresh() : Promise.resolve({ ok: true, records: [] });
|
|
252
|
+
if (enabled) {
|
|
253
|
+
const boundedInterval = Math.max(5000, Number(snapshotIntervalMs) || DEFAULT_WINDOWS_TREE_SNAPSHOT_MS);
|
|
254
|
+
timer = setIntervalImpl(() => {
|
|
255
|
+
if (!stopped) void refresh();
|
|
256
|
+
}, boundedInterval);
|
|
257
|
+
timer?.unref?.();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
enabled,
|
|
262
|
+
ready,
|
|
263
|
+
refresh,
|
|
264
|
+
stop() {
|
|
265
|
+
stopped = true;
|
|
266
|
+
if (timer) clearIntervalImpl(timer);
|
|
267
|
+
timer = null;
|
|
268
|
+
},
|
|
269
|
+
getRootRecord: () => rootRecord ? { ...rootRecord } : null,
|
|
270
|
+
markRootExited(exitedAtMs = Date.now()) {
|
|
271
|
+
if (rootExitedAtMs === null) {
|
|
272
|
+
rootExitedAtMs = Number(exitedAtMs) || Date.now();
|
|
273
|
+
successfulSnapshotAfterExit = false;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
hasSafeFinalSnapshot() {
|
|
277
|
+
return rootCapturedBeforeExit
|
|
278
|
+
|| (rootExitedAtMs !== null && successfulSnapshotAfterExit && !lastSnapshotHadRootPid);
|
|
279
|
+
},
|
|
280
|
+
hasAmbiguousRootReuse: () => ambiguousRootReuse,
|
|
281
|
+
getTrackedRecords: () => [...tracked.values()].map(record => ({ ...record })),
|
|
282
|
+
getCurrentExactRecords() {
|
|
283
|
+
const currentByPid = new Map(lastSnapshot.map(record => [record.pid, record]));
|
|
284
|
+
return [...tracked.values()]
|
|
285
|
+
.filter(record => sameWindowsProcess(currentByPid.get(record.pid), record))
|
|
286
|
+
.map(record => ({ ...record }));
|
|
287
|
+
},
|
|
288
|
+
getLastError: () => lastError
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bounded exact-tree drain used by internal restarts, unexpected exits, and
|
|
294
|
+
* terminal shutdown. A nonzero taskkill is not itself failure: each attempt is
|
|
295
|
+
* followed by a fresh immutable snapshot, and an already-gone tree succeeds.
|
|
296
|
+
*/
|
|
297
|
+
export async function drainOwnedWindowsAgentTree(child, {
|
|
298
|
+
platform = process.platform,
|
|
299
|
+
queryProcessTableImpl = queryWindowsProcessTable,
|
|
300
|
+
terminateExactTreeImpl = terminateExactWindowsProcessTree,
|
|
301
|
+
maxAttempts = 3,
|
|
302
|
+
retryMs = 100,
|
|
303
|
+
waitImpl = waitMilliseconds,
|
|
304
|
+
reportTerminationFailure = message => console.error(message)
|
|
305
|
+
} = {}) {
|
|
306
|
+
if (platform !== 'win32') return { ok: true, remaining: [] };
|
|
307
|
+
if (child?.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise;
|
|
308
|
+
|
|
309
|
+
child.livedeskWindowsDrainPromise = (async () => {
|
|
310
|
+
const tracker = child?.livedeskWindowsTreeTracker
|
|
311
|
+
|| createWindowsProcessTreeTracker(child, { platform, queryProcessTableImpl });
|
|
312
|
+
child.livedeskWindowsTreeTracker = tracker;
|
|
313
|
+
if (child?.exitCode !== null && child?.exitCode !== undefined) {
|
|
314
|
+
tracker.markRootExited?.();
|
|
315
|
+
}
|
|
316
|
+
tracker.stop();
|
|
317
|
+
await tracker.ready;
|
|
318
|
+
const attempts = Math.max(1, Number(maxAttempts) || 3);
|
|
319
|
+
let identityRefresh = await tracker.refresh();
|
|
320
|
+
let identityAttempt = 1;
|
|
321
|
+
let rootRecord = tracker.getRootRecord();
|
|
322
|
+
while ((!rootRecord || rootRecord.targetable === false)
|
|
323
|
+
&& !tracker.hasSafeFinalSnapshot?.()
|
|
324
|
+
&& identityAttempt < attempts) {
|
|
325
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
326
|
+
identityAttempt += 1;
|
|
327
|
+
identityRefresh = await tracker.refresh();
|
|
328
|
+
rootRecord = tracker.getRootRecord();
|
|
329
|
+
}
|
|
330
|
+
if ((!rootRecord || rootRecord.targetable === false)
|
|
331
|
+
&& !tracker.hasSafeFinalSnapshot?.()) {
|
|
332
|
+
const error = identityRefresh?.error || tracker.getLastError()
|
|
333
|
+
|| new Error(
|
|
334
|
+
`Agent pid=${Number(child?.pid || 0)} could not establish an immutable root identity `
|
|
335
|
+
+ 'or a safe root-absent exit snapshot.'
|
|
336
|
+
);
|
|
337
|
+
return { ok: false, error, remaining: [] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let lastError = null;
|
|
341
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
342
|
+
const refresh = await tracker.refresh();
|
|
343
|
+
if (!refresh.ok) {
|
|
344
|
+
lastError = refresh.error;
|
|
345
|
+
} else {
|
|
346
|
+
const remaining = tracker.getCurrentExactRecords();
|
|
347
|
+
if (remaining.length === 0
|
|
348
|
+
&& tracker.hasSafeFinalSnapshot?.()
|
|
349
|
+
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
350
|
+
return { ok: true, remaining: [] };
|
|
351
|
+
}
|
|
352
|
+
const ordered = [...remaining].sort((left, right) => (
|
|
353
|
+
Number(left.depth || 0) - Number(right.depth || 0)
|
|
354
|
+
));
|
|
355
|
+
for (const record of ordered) {
|
|
356
|
+
const termination = await terminateExactTreeImpl(record, { includeTree: true });
|
|
357
|
+
if (termination?.ok === false) lastError = termination.error || lastError;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (attempt < attempts) {
|
|
361
|
+
await waitImpl(Math.max(25, Number(retryMs) || 100));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const finalRefresh = await tracker.refresh();
|
|
366
|
+
const remaining = finalRefresh.ok ? tracker.getCurrentExactRecords() : tracker.getTrackedRecords();
|
|
367
|
+
if (finalRefresh.ok
|
|
368
|
+
&& remaining.length === 0
|
|
369
|
+
&& tracker.hasSafeFinalSnapshot?.()
|
|
370
|
+
&& !tracker.hasAmbiguousRootReuse?.()) {
|
|
371
|
+
return { ok: true, remaining: [] };
|
|
372
|
+
}
|
|
373
|
+
const error = finalRefresh.error || lastError || new Error(
|
|
374
|
+
tracker.hasAmbiguousRootReuse?.()
|
|
375
|
+
? `Agent pid=${Number(child?.pid || 0)} was reused before exact descendant cleanup completed.`
|
|
376
|
+
: `Exact Windows Agent tree retained pid(s): ${remaining.map(record => record.pid).join(', ')}`
|
|
377
|
+
);
|
|
378
|
+
reportTerminationFailure(
|
|
379
|
+
`[LiveDesk Client] Exact Windows Agent tree could not be drained after `
|
|
380
|
+
+ `${attempts} bounded attempt(s): ${error?.message || error}`
|
|
381
|
+
);
|
|
382
|
+
return { ok: false, error, remaining };
|
|
383
|
+
})();
|
|
384
|
+
return child.livedeskWindowsDrainPromise;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|