livedesk 0.1.446 → 0.1.447
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.js +899 -309
- package/bootstrap/legacy-client-update.js +22 -20
- package/bootstrap/runtime-lock.js +233 -28
- package/bootstrap/runtime-shutdown.js +414 -0
- package/client/bin/livedesk-client-node.js +17 -4
- package/client/bin/livedesk-client.js +70 -4
- package/client/package.json +5 -5
- package/client/src/runtime/agent-process-lifecycle.js +113 -40
- package/client/src/runtime/windows-owned-process-manifest.js +235 -0
- package/electron/main.mjs +55 -38
- package/hub/package.json +1 -1
- package/hub/src/transport/relay-hub-control.js +48 -7
- package/package.json +6 -6
- package/runtime-core/src/role-transition-supervisor.js +20 -18
- package/runtime-core/src/windows-owned-process-tree.js +432 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
statSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
|
|
13
|
+
export const RUNTIME_SHUTDOWN_PROTOCOL_VERSION = 1;
|
|
14
|
+
export const DEFAULT_RUNTIME_SHUTDOWN_POLL_MS = 100;
|
|
15
|
+
export const DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS = 8_000;
|
|
16
|
+
export const DEFAULT_RUNTIME_SHUTDOWN_ACK_TIMEOUT_MS = 2_000;
|
|
17
|
+
export const DEFAULT_RUNTIME_SHUTDOWN_TTL_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
const REQUEST_FILE_NAME = 'livedesk-runtime-shutdown-request.json';
|
|
20
|
+
const ACK_FILE_NAME = 'livedesk-runtime-shutdown-ack.json';
|
|
21
|
+
const MAX_REQUEST_TTL_MS = 60_000;
|
|
22
|
+
const MAX_FUTURE_CLOCK_SKEW_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
function normalizeOwner(value) {
|
|
25
|
+
const owner = {
|
|
26
|
+
pid: Number(value?.pid),
|
|
27
|
+
ownerToken: String(value?.ownerToken || '').trim(),
|
|
28
|
+
ownerInstanceMarker: String(value?.ownerInstanceMarker || '').trim(),
|
|
29
|
+
ownerStartOrder: String(value?.ownerStartOrder || '').trim()
|
|
30
|
+
};
|
|
31
|
+
if (!Number.isInteger(owner.pid)
|
|
32
|
+
|| owner.pid <= 1
|
|
33
|
+
|| !/^[a-f0-9]{32}$/i.test(owner.ownerToken)
|
|
34
|
+
|| !owner.ownerInstanceMarker
|
|
35
|
+
|| !owner.ownerStartOrder) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return owner;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ownersMatch(leftValue, rightValue) {
|
|
42
|
+
const left = normalizeOwner(leftValue);
|
|
43
|
+
const right = normalizeOwner(rightValue);
|
|
44
|
+
return !!left
|
|
45
|
+
&& !!right
|
|
46
|
+
&& left.pid === right.pid
|
|
47
|
+
&& left.ownerToken === right.ownerToken
|
|
48
|
+
&& left.ownerInstanceMarker === right.ownerInstanceMarker
|
|
49
|
+
&& left.ownerStartOrder === right.ownerStartOrder;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJson(path) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fileRevision(path) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = statSync(path, { bigint: true });
|
|
63
|
+
return `${stat.mtimeNs}:${stat.size}`;
|
|
64
|
+
} catch {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeJsonAtomic(path, value) {
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
71
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
72
|
+
let published = false;
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
mode: 0o600,
|
|
77
|
+
flag: 'wx'
|
|
78
|
+
});
|
|
79
|
+
renameSync(temporaryPath, path);
|
|
80
|
+
published = true;
|
|
81
|
+
} finally {
|
|
82
|
+
if (!published) {
|
|
83
|
+
try { unlinkSync(temporaryPath); } catch { /* temporary file was never published */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isValidRequest(request, nowMs) {
|
|
89
|
+
const requestedAtMs = Date.parse(String(request?.requestedAt || ''));
|
|
90
|
+
const expiresAtMs = Date.parse(String(request?.expiresAt || ''));
|
|
91
|
+
return request?.protocolVersion === RUNTIME_SHUTDOWN_PROTOCOL_VERSION
|
|
92
|
+
&& /^[a-f0-9]{32,128}$/i.test(String(request?.requestId || ''))
|
|
93
|
+
&& !!normalizeOwner(request?.target)
|
|
94
|
+
&& Number.isFinite(requestedAtMs)
|
|
95
|
+
&& Number.isFinite(expiresAtMs)
|
|
96
|
+
&& requestedAtMs <= nowMs + MAX_FUTURE_CLOCK_SKEW_MS
|
|
97
|
+
&& expiresAtMs > nowMs
|
|
98
|
+
&& expiresAtMs > requestedAtMs
|
|
99
|
+
&& expiresAtMs - requestedAtMs <= MAX_REQUEST_TTL_MS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isMatchingAck(ack, request) {
|
|
103
|
+
return ack?.protocolVersion === RUNTIME_SHUTDOWN_PROTOCOL_VERSION
|
|
104
|
+
&& ack?.requestId === request.requestId
|
|
105
|
+
&& ownersMatch(ack?.target, request.target)
|
|
106
|
+
&& ['accepted', 'completed', 'failed'].includes(String(ack?.status || ''));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isSameOwnerDrainAck(ack, request, nowMs) {
|
|
110
|
+
const requestedAtMs = Date.parse(String(request?.requestedAt || ''));
|
|
111
|
+
const acceptedAtMs = Date.parse(String(ack?.acceptedAt || ''));
|
|
112
|
+
return ack?.protocolVersion === RUNTIME_SHUTDOWN_PROTOCOL_VERSION
|
|
113
|
+
&& ack?.requestId !== request.requestId
|
|
114
|
+
&& /^[a-f0-9]{32,128}$/i.test(String(ack?.requestId || ''))
|
|
115
|
+
&& ownersMatch(ack?.target, request.target)
|
|
116
|
+
&& ['accepted', 'completed', 'failed'].includes(String(ack?.status || ''))
|
|
117
|
+
&& Number.isFinite(requestedAtMs)
|
|
118
|
+
&& Number.isFinite(acceptedAtMs)
|
|
119
|
+
&& acceptedAtMs >= requestedAtMs - MAX_REQUEST_TTL_MS
|
|
120
|
+
&& acceptedAtMs <= nowMs + MAX_FUTURE_CLOCK_SKEW_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clampMilliseconds(value, fallback, minimum, maximum) {
|
|
124
|
+
const parsed = Number(value);
|
|
125
|
+
return Number.isFinite(parsed)
|
|
126
|
+
? Math.min(maximum, Math.max(minimum, Math.trunc(parsed)))
|
|
127
|
+
: fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wait(milliseconds) {
|
|
131
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function getRuntimeShutdownPaths(stateDir = join(os.homedir(), '.livedesk')) {
|
|
135
|
+
return {
|
|
136
|
+
requestPath: join(stateDir, REQUEST_FILE_NAME),
|
|
137
|
+
ackPath: join(stateDir, ACK_FILE_NAME)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Contract:
|
|
143
|
+
* - One watcher belongs to one immutable launcher-lock owner.
|
|
144
|
+
* - Only a non-expired request matching all exact owner fields is accepted.
|
|
145
|
+
* - ACK is published before cleanup starts so a replacement can distinguish
|
|
146
|
+
* cooperative shutdown from an unresponsive/stale launcher.
|
|
147
|
+
* - onShutdown is invoked at most once. Exact process-tree teardown remains the
|
|
148
|
+
* caller's fallback if the accepted owner does not actually exit.
|
|
149
|
+
*/
|
|
150
|
+
export function startRuntimeShutdownWatcher({
|
|
151
|
+
stateDir,
|
|
152
|
+
owner,
|
|
153
|
+
onShutdown,
|
|
154
|
+
pollMs = DEFAULT_RUNTIME_SHUTDOWN_POLL_MS,
|
|
155
|
+
now = () => Date.now(),
|
|
156
|
+
reportError = error => console.warn(
|
|
157
|
+
`[LiveDesk] Cooperative shutdown mailbox warning: ${error?.message || error}`
|
|
158
|
+
)
|
|
159
|
+
} = {}) {
|
|
160
|
+
const exactOwner = normalizeOwner(owner);
|
|
161
|
+
if (!exactOwner) {
|
|
162
|
+
throw new Error('A cooperative LiveDesk shutdown watcher requires an exact runtime-lock owner.');
|
|
163
|
+
}
|
|
164
|
+
if (typeof onShutdown !== 'function') {
|
|
165
|
+
throw new Error('A cooperative LiveDesk shutdown watcher requires an onShutdown handler.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const paths = getRuntimeShutdownPaths(stateDir);
|
|
169
|
+
const intervalMs = clampMilliseconds(pollMs, DEFAULT_RUNTIME_SHUTDOWN_POLL_MS, 25, 1_000);
|
|
170
|
+
let lastRequestRevision = '';
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let shutdownAccepted = false;
|
|
173
|
+
let polling = false;
|
|
174
|
+
const report = error => {
|
|
175
|
+
try { reportError(error); } catch { /* diagnostics cannot own runtime liveness */ }
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const publishAck = (request, status, details = {}) => {
|
|
179
|
+
writeJsonAtomic(paths.ackPath, {
|
|
180
|
+
protocolVersion: RUNTIME_SHUTDOWN_PROTOCOL_VERSION,
|
|
181
|
+
requestId: request.requestId,
|
|
182
|
+
target: exactOwner,
|
|
183
|
+
receiverPid: process.pid,
|
|
184
|
+
status,
|
|
185
|
+
acceptedAt: details.acceptedAt,
|
|
186
|
+
completedAt: details.completedAt,
|
|
187
|
+
error: details.error
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const pollNow = async () => {
|
|
192
|
+
if (stopped || shutdownAccepted || polling) return false;
|
|
193
|
+
polling = true;
|
|
194
|
+
try {
|
|
195
|
+
const revision = fileRevision(paths.requestPath);
|
|
196
|
+
if (!revision || revision === lastRequestRevision) return false;
|
|
197
|
+
lastRequestRevision = revision;
|
|
198
|
+
const request = readJson(paths.requestPath);
|
|
199
|
+
const observedAtMs = Number(now());
|
|
200
|
+
if (!isValidRequest(request, observedAtMs)
|
|
201
|
+
|| !ownersMatch(request.target, exactOwner)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const acceptedAt = new Date(observedAtMs).toISOString();
|
|
206
|
+
try {
|
|
207
|
+
// ACK ownership must become durable before cleanup starts. If the
|
|
208
|
+
// atomic write fails, leave the watcher live and retry this unchanged
|
|
209
|
+
// request on the next cadence instead of orphaning children.
|
|
210
|
+
publishAck(request, 'accepted', { acceptedAt });
|
|
211
|
+
} catch (error) {
|
|
212
|
+
lastRequestRevision = '';
|
|
213
|
+
report(error);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
shutdownAccepted = true;
|
|
217
|
+
clearInterval(interval);
|
|
218
|
+
try {
|
|
219
|
+
await onShutdown({
|
|
220
|
+
requestId: request.requestId,
|
|
221
|
+
reason: String(request.reason || 'replacement-launch'),
|
|
222
|
+
requesterPid: Number(request.requesterPid || 0),
|
|
223
|
+
requestedAt: request.requestedAt,
|
|
224
|
+
acceptedAt,
|
|
225
|
+
target: exactOwner
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
publishAck(request, 'completed', {
|
|
229
|
+
acceptedAt,
|
|
230
|
+
completedAt: new Date(Number(now())).toISOString()
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
report(error);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
try {
|
|
237
|
+
publishAck(request, 'failed', {
|
|
238
|
+
acceptedAt,
|
|
239
|
+
completedAt: new Date(Number(now())).toISOString(),
|
|
240
|
+
error: error instanceof Error ? error.message : String(error)
|
|
241
|
+
});
|
|
242
|
+
} catch (ackError) {
|
|
243
|
+
report(ackError);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
} finally {
|
|
248
|
+
polling = false;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const interval = setInterval(() => {
|
|
253
|
+
void pollNow().catch(report);
|
|
254
|
+
}, intervalMs);
|
|
255
|
+
interval.unref?.();
|
|
256
|
+
void pollNow().catch(report);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
paths,
|
|
260
|
+
pollNow,
|
|
261
|
+
stop() {
|
|
262
|
+
if (stopped) return;
|
|
263
|
+
stopped = true;
|
|
264
|
+
clearInterval(interval);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Requests graceful cleanup from an exact launcher generation. The request is
|
|
271
|
+
* bounded; callers must retain their exact PID/start-time teardown fallback.
|
|
272
|
+
*/
|
|
273
|
+
export async function requestCooperativeRuntimeShutdown({
|
|
274
|
+
stateDir,
|
|
275
|
+
target,
|
|
276
|
+
reason = 'replacement-launch',
|
|
277
|
+
timeoutMs = DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS,
|
|
278
|
+
ackTimeoutMs = DEFAULT_RUNTIME_SHUTDOWN_ACK_TIMEOUT_MS,
|
|
279
|
+
pollMs = DEFAULT_RUNTIME_SHUTDOWN_POLL_MS,
|
|
280
|
+
ttlMs = DEFAULT_RUNTIME_SHUTDOWN_TTL_MS,
|
|
281
|
+
isTargetAlive,
|
|
282
|
+
now = () => Date.now()
|
|
283
|
+
} = {}) {
|
|
284
|
+
const exactTarget = normalizeOwner(target);
|
|
285
|
+
if (!exactTarget) {
|
|
286
|
+
return {
|
|
287
|
+
requested: false,
|
|
288
|
+
acknowledged: false,
|
|
289
|
+
completed: false,
|
|
290
|
+
exited: false,
|
|
291
|
+
timedOut: false,
|
|
292
|
+
error: new Error('An exact runtime-lock owner is required for cooperative shutdown.')
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const boundedTimeoutMs = clampMilliseconds(
|
|
297
|
+
timeoutMs,
|
|
298
|
+
DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS,
|
|
299
|
+
100,
|
|
300
|
+
MAX_REQUEST_TTL_MS - 1_000
|
|
301
|
+
);
|
|
302
|
+
const boundedPollMs = clampMilliseconds(
|
|
303
|
+
pollMs,
|
|
304
|
+
DEFAULT_RUNTIME_SHUTDOWN_POLL_MS,
|
|
305
|
+
25,
|
|
306
|
+
1_000
|
|
307
|
+
);
|
|
308
|
+
const boundedAckTimeoutMs = Math.min(
|
|
309
|
+
boundedTimeoutMs,
|
|
310
|
+
clampMilliseconds(
|
|
311
|
+
ackTimeoutMs,
|
|
312
|
+
Math.min(DEFAULT_RUNTIME_SHUTDOWN_ACK_TIMEOUT_MS, boundedTimeoutMs),
|
|
313
|
+
100,
|
|
314
|
+
boundedTimeoutMs
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
const boundedTtlMs = clampMilliseconds(
|
|
318
|
+
ttlMs,
|
|
319
|
+
Math.max(DEFAULT_RUNTIME_SHUTDOWN_TTL_MS, boundedTimeoutMs + 2_000),
|
|
320
|
+
boundedTimeoutMs + 1_000,
|
|
321
|
+
MAX_REQUEST_TTL_MS
|
|
322
|
+
);
|
|
323
|
+
const paths = getRuntimeShutdownPaths(stateDir);
|
|
324
|
+
const requestedAtMs = Number(now());
|
|
325
|
+
const request = {
|
|
326
|
+
protocolVersion: RUNTIME_SHUTDOWN_PROTOCOL_VERSION,
|
|
327
|
+
requestId: randomBytes(24).toString('hex'),
|
|
328
|
+
target: exactTarget,
|
|
329
|
+
requesterPid: process.pid,
|
|
330
|
+
reason: String(reason || 'replacement-launch').slice(0, 200),
|
|
331
|
+
requestedAt: new Date(requestedAtMs).toISOString(),
|
|
332
|
+
expiresAt: new Date(requestedAtMs + boundedTtlMs).toISOString()
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
writeJsonAtomic(paths.requestPath, request);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return {
|
|
339
|
+
requested: false,
|
|
340
|
+
acknowledged: false,
|
|
341
|
+
completed: false,
|
|
342
|
+
exited: false,
|
|
343
|
+
timedOut: false,
|
|
344
|
+
requestId: request.requestId,
|
|
345
|
+
error
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const deadline = requestedAtMs + boundedTimeoutMs;
|
|
350
|
+
const ackDeadline = requestedAtMs + boundedAckTimeoutMs;
|
|
351
|
+
let acknowledged = false;
|
|
352
|
+
let completed = false;
|
|
353
|
+
let ackStatus = '';
|
|
354
|
+
let ackError = '';
|
|
355
|
+
let sharedOwnerAck = false;
|
|
356
|
+
while (Number(now()) < deadline) {
|
|
357
|
+
if (typeof isTargetAlive === 'function') {
|
|
358
|
+
try {
|
|
359
|
+
if (!await isTargetAlive(exactTarget)) {
|
|
360
|
+
return {
|
|
361
|
+
requested: true,
|
|
362
|
+
acknowledged,
|
|
363
|
+
completed,
|
|
364
|
+
exited: true,
|
|
365
|
+
timedOut: false,
|
|
366
|
+
requestId: request.requestId,
|
|
367
|
+
ackStatus,
|
|
368
|
+
ackError,
|
|
369
|
+
sharedOwnerAck
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
} catch {
|
|
373
|
+
// Exact fallback code owns process inspection errors. Keep waiting for
|
|
374
|
+
// either a valid ACK or its bounded deadline.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ack = readJson(paths.ackPath);
|
|
379
|
+
const observedAtMs = Number(now());
|
|
380
|
+
const exactRequestAck = isMatchingAck(ack, request);
|
|
381
|
+
const sameOwnerDrainAck = !exactRequestAck
|
|
382
|
+
&& isSameOwnerDrainAck(ack, request, observedAtMs);
|
|
383
|
+
if (exactRequestAck || sameOwnerDrainAck) {
|
|
384
|
+
acknowledged = true;
|
|
385
|
+
sharedOwnerAck = sameOwnerDrainAck;
|
|
386
|
+
ackStatus = String(ack.status);
|
|
387
|
+
ackError = String(ack.error || '');
|
|
388
|
+
completed = ackStatus === 'completed';
|
|
389
|
+
if (ackStatus === 'failed') break;
|
|
390
|
+
}
|
|
391
|
+
if (!acknowledged && Number(now()) >= ackDeadline) break;
|
|
392
|
+
await wait(Math.min(boundedPollMs, Math.max(1, deadline - Number(now()))));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let exited = false;
|
|
396
|
+
if (typeof isTargetAlive === 'function') {
|
|
397
|
+
try {
|
|
398
|
+
exited = !await isTargetAlive(exactTarget);
|
|
399
|
+
} catch {
|
|
400
|
+
exited = false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
requested: true,
|
|
405
|
+
acknowledged,
|
|
406
|
+
completed,
|
|
407
|
+
exited,
|
|
408
|
+
timedOut: !exited && ackStatus !== 'failed',
|
|
409
|
+
requestId: request.requestId,
|
|
410
|
+
ackStatus,
|
|
411
|
+
ackError,
|
|
412
|
+
sharedOwnerAck
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -1552,7 +1552,7 @@ function remotePolicyAllows(options, command) {
|
|
|
1552
1552
|
|
|
1553
1553
|
function buildClientUpdateBootstrapScript() {
|
|
1554
1554
|
return [
|
|
1555
|
-
'const { spawn } = require("node:child_process");',
|
|
1555
|
+
'const { execFile, spawn } = require("node:child_process");',
|
|
1556
1556
|
'const fs = require("node:fs");',
|
|
1557
1557
|
'const path = require("node:path");',
|
|
1558
1558
|
'const operationId = String(process.env.LIVEDESK_CLIENT_UPDATE_OPERATION_ID || "").trim();',
|
|
@@ -1569,15 +1569,28 @@ function buildClientUpdateBootstrapScript() {
|
|
|
1569
1569
|
'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));',
|
|
1570
1570
|
'const isAlive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
|
|
1571
1571
|
'const isGroupAlive = pid => { try { process.kill(-pid, 0); return true; } catch (error) { return error?.code === "EPERM"; } };',
|
|
1572
|
+
'const runWindowsPowerShell = script => new Promise((resolve, reject) => { execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, timeout: 15000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { reject(new Error(String(stderr || error.message || "PowerShell failed").trim())); return; } resolve(String(stdout || "").trim()); }); });',
|
|
1573
|
+
'const queryWindowsProcessTable = async () => { const stdout = await runWindowsPowerShell(`$ErrorActionPreference = "Stop"; $records = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { $ticks = if ($_.CreationDate) { [string]$_.CreationDate.ToUniversalTime().Ticks } else { "" }; [pscustomobject]@{ ProcessId = [int]$_.ProcessId; ParentProcessId = [int]$_.ParentProcessId; CreationUtcTicks = $ticks } }); [pscustomobject]@{ Records = $records } | ConvertTo-Json -Compress -Depth 4`); if (!stdout) throw new Error("Windows process snapshot returned no data"); const parsed = JSON.parse(stdout); const values = parsed?.Records == null ? [] : (Array.isArray(parsed.Records) ? parsed.Records : [parsed.Records]); return values.map(item => ({ pid: Number(item?.ProcessId || 0), parentPid: Number(item?.ParentProcessId || 0), startOrder: String(item?.CreationUtcTicks || "") })).filter(item => Number.isInteger(item.pid) && item.pid > 1 && /^\\d+$/.test(item.startOrder)); };',
|
|
1574
|
+
'const sameWindowsIdentity = (left, right) => Number(left?.pid || 0) === Number(right?.pid || 0) && String(left?.startOrder || "") === String(right?.startOrder || "");',
|
|
1575
|
+
'const captureWindowsIdentity = async pid => { const processId = Number(pid || 0); const deadline = Date.now() + 2500; let lastError = null; do { try { const snapshot = await queryWindowsProcessTable(); const identity = snapshot.find(item => item.pid === processId) || null; if (identity) return identity; } catch (error) { lastError = error; } if (Date.now() < deadline) await sleep(50); } while (Date.now() < deadline); throw new Error(`Could not capture immutable Windows CreationDate for pid=${processId}: ${lastError?.message || "process not visible"}`); };',
|
|
1576
|
+
'const windowsTicksAtMilliseconds = value => BigInt(Math.trunc(Number(value) || Date.now())) * 10000n + 621355968000000000n;',
|
|
1577
|
+
'const setWindowsRootLifetimeEnd = (child, ticks) => { const candidate = BigInt(ticks); const previous = child.livedeskWindowsRootLifetimeEndTicks; if (!previous || candidate < BigInt(previous)) child.livedeskWindowsRootLifetimeEndTicks = String(candidate); return BigInt(child.livedeskWindowsRootLifetimeEndTicks); };',
|
|
1578
|
+
'const mergeRootAbsentWindowsTree = (child, rootPid, snapshot) => { const tracked = child.livedeskWindowsTrackedRecords || (child.livedeskWindowsTrackedRecords = new Map()); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const exactCurrentByPid = new Map(); for (const record of tracked.values()) { const current = currentByPid.get(record.pid); if (sameWindowsIdentity(current, record)) exactCurrentByPid.set(record.pid, record); } const lowerBound = windowsTicksAtMilliseconds(Number(child.livedeskWindowsSpawnedAtMs || Date.now()) - 2000); const upperBound = child.livedeskWindowsRootLifetimeEndTicks ? BigInt(child.livedeskWindowsRootLifetimeEndTicks) : null; let changed = true; while (changed) { changed = false; for (const record of snapshot) { const key = `${record.pid}:${record.startOrder}`; if (record.pid === rootPid || tracked.has(key)) continue; let recordStart; try { recordStart = BigInt(record.startOrder); if (recordStart < lowerBound || (upperBound && recordStart > upperBound)) continue; } catch { continue; } const parent = record.parentPid === rootPid ? { depth: 0 } : exactCurrentByPid.get(record.parentPid); if (!parent) continue; const descendant = { ...record, depth: Number(parent.depth || 0) + 1 }; tracked.set(key, descendant); exactCurrentByPid.set(descendant.pid, descendant); changed = true; } } return tracked; };',
|
|
1579
|
+
'const mergeExactWindowsTree = (child, root, snapshot) => { const tracked = child.livedeskWindowsTrackedRecords || (child.livedeskWindowsTrackedRecords = new Map()); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const currentRoot = currentByPid.get(root.pid) || null; const rootIsExact = sameWindowsIdentity(currentRoot, root); const rootWasReused = Boolean(currentRoot && !rootIsExact); if (rootWasReused) setWindowsRootLifetimeEnd(child, BigInt(currentRoot.startOrder) - 1n); else if (!currentRoot && child.livedeskWindowsRootExitedAtMs) setWindowsRootLifetimeEnd(child, windowsTicksAtMilliseconds(child.livedeskWindowsRootExitedAtMs)); const rootLifetimeClosed = Boolean(child.livedeskWindowsRootLifetimeEndTicks); if (rootIsExact) tracked.set(`${root.pid}:${root.startOrder}`, { ...root, depth: 0 }); const exactCurrentByPid = new Map(); if (rootIsExact) exactCurrentByPid.set(root.pid, { ...root, depth: 0 }); for (const record of tracked.values()) { const current = currentByPid.get(record.pid); if (sameWindowsIdentity(current, record)) exactCurrentByPid.set(record.pid, record); } let changed = true; while (changed) { changed = false; for (const record of snapshot) { const key = `${record.pid}:${record.startOrder}`; if (record.pid === root.pid || tracked.has(key)) continue; let recordStart; try { recordStart = BigInt(record.startOrder); if (recordStart < BigInt(root.startOrder)) continue; if (rootLifetimeClosed && recordStart > BigInt(child.livedeskWindowsRootLifetimeEndTicks)) continue; } catch { continue; } let parent = exactCurrentByPid.get(record.parentPid) || null; if (!parent && !currentRoot && rootLifetimeClosed && record.parentPid === root.pid) parent = { ...root, depth: 0 }; if (!parent) continue; const descendant = { ...record, depth: Number(parent.depth || 0) + 1 }; tracked.set(key, descendant); exactCurrentByPid.set(descendant.pid, descendant); changed = true; } } return tracked; };',
|
|
1580
|
+
'const refreshTrackedWindowsTree = async (child, root = child.livedeskWindowsIdentity || null) => { const snapshot = await queryWindowsProcessTable(); if (root) mergeExactWindowsTree(child, root, snapshot); else mergeRootAbsentWindowsTree(child, Number(child.pid || 0), snapshot); return snapshot; };',
|
|
1581
|
+
'const stopExactWindowsRecords = async records => { if (!Array.isArray(records) || records.length === 0) return; const encoded = Buffer.from(JSON.stringify(records.map(record => ({ pid: record.pid, startOrder: record.startOrder }))), "utf8").toString("base64"); const script = `$ErrorActionPreference = "Stop"; $targetsJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${encoded}")); $targets = ConvertFrom-Json -InputObject $targetsJson; $failures = [System.Collections.Generic.List[string]]::new(); foreach ($item in $targets) { $targetPid = [int]$item.pid; $expectedStartTicks = [string]$item.startOrder; try { $target = Get-CimInstance Win32_Process -Filter "ProcessId = $targetPid" -Property ProcessId,CreationDate -ErrorAction SilentlyContinue | Select-Object -First 1; if (-not $target) { continue }; $targetStartTicks = if ($target.CreationDate) { [string]$target.CreationDate.ToUniversalTime().Ticks } else { "" }; if ($targetStartTicks -ne $expectedStartTicks) { continue }; Stop-Process -Id $targetPid -Force -ErrorAction Stop } catch { $failures.Add("pid=$targetPid $($_.Exception.Message)") } }; if ($failures.Count -gt 0) { [Console]::Error.WriteLine(($failures -join "; ")); exit 1 }`; await runWindowsPowerShell(script); };',
|
|
1582
|
+
'const trackWindowsChild = child => { if (process.platform !== "win32" || Number(child?.pid || 0) <= 1 || child.livedeskWindowsIdentityPromise) return; child.livedeskWindowsTrackedRecords = new Map(); child.livedeskWindowsSpawnedAtMs = Date.now(); child.livedeskWindowsRootExitedAtMs = null; child.livedeskWindowsTrackingStopped = false; child.once("exit", () => { if (!child.livedeskWindowsRootExitedAtMs) child.livedeskWindowsRootExitedAtMs = Date.now(); setWindowsRootLifetimeEnd(child, windowsTicksAtMilliseconds(child.livedeskWindowsRootExitedAtMs)); }); const refresh = () => { void refreshTrackedWindowsTree(child).then(() => { child.livedeskWindowsTrackingError = null; }).catch(error => { child.livedeskWindowsTrackingError = error; }); }; refresh(); child.livedeskWindowsTrackingTimer = setInterval(refresh, 250); child.livedeskWindowsTrackingTimer.unref?.(); child.livedeskWindowsIdentityPromise = (async () => { while (!child.livedeskWindowsTrackingStopped && !child.livedeskWindowsRootExitedAtMs) { try { const identity = await captureWindowsIdentity(child.pid); child.livedeskWindowsIdentity = identity; refresh(); return identity; } catch (error) { child.livedeskWindowsIdentityError = error; if (!child.livedeskWindowsTrackingStopped && !child.livedeskWindowsRootExitedAtMs) await sleep(250); } } return null; })(); };',
|
|
1583
|
+
'const stopWindowsTracking = child => { child.livedeskWindowsTrackingStopped = true; if (child?.livedeskWindowsTrackingTimer) clearInterval(child.livedeskWindowsTrackingTimer); child.livedeskWindowsTrackingTimer = null; };',
|
|
1584
|
+
'const terminateExactWindowsTrackedTree = (child, root) => { if (child.livedeskWindowsDrainPromise) return child.livedeskWindowsDrainPromise; child.livedeskWindowsDrainPromise = (async () => { let attempt = 0; let emptyPasses = 0; for (;;) { attempt += 1; try { const snapshot = await refreshTrackedWindowsTree(child, root); const currentByPid = new Map(snapshot.map(item => [item.pid, item])); const remaining = [...child.livedeskWindowsTrackedRecords.values()].filter(target => sameWindowsIdentity(currentByPid.get(target.pid), target)).sort((left, right) => Number(right.depth || 0) - Number(left.depth || 0)); if (remaining.length === 0) { emptyPasses += 1; if (emptyPasses >= 2) { stopWindowsTracking(child); return; } } else { emptyPasses = 0; await stopExactWindowsRecords(remaining); } child.livedeskWindowsTrackingError = null; } catch (error) { emptyPasses = 0; child.livedeskWindowsTrackingError = error; if (attempt === 1 || attempt % 10 === 0) process.stderr.write(`LiveDesk update cleanup is retrying exact Windows process-tree drain: ${error?.message || error}\\n`); } await sleep(Math.min(1000, 100 + (attempt * 50))); } })(); return child.livedeskWindowsDrainPromise; };',
|
|
1572
1585
|
'const stateLockPath = statePath + ".lock"; const lockWaitBuffer = new Int32Array(new SharedArrayBuffer(4));',
|
|
1573
1586
|
'const waitForStateLock = ms => Atomics.wait(lockWaitBuffer, 0, 0, Math.max(1, ms));',
|
|
1574
1587
|
'const removeAbandonedStateLock = () => { let owner = null; try { owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); } catch {} const ownerPid = Number(owner?.pid || 0); if (Number.isInteger(ownerPid) && ownerPid > 1) { if (isAlive(ownerPid)) return false; } else { try { if (Date.now() - fs.statSync(stateLockPath).mtimeMs < 5000) return false; } catch { return true; } } try { fs.rmSync(stateLockPath); return true; } catch { return false; } };',
|
|
1575
1588
|
'const withStateLock = callback => { fs.mkdirSync(path.dirname(stateLockPath), { recursive: true }); const deadline = Date.now() + 10000; let descriptor = null; let token = ""; while (descriptor === null) { try { descriptor = fs.openSync(stateLockPath, "wx", 0o600); token = process.pid + "-" + Date.now() + "-" + Math.random().toString(16).slice(2); fs.writeFileSync(descriptor, JSON.stringify({ pid: process.pid, token, acquiredAt: new Date().toISOString() }), "utf8"); } catch (error) { if (descriptor !== null) { try { fs.closeSync(descriptor); } catch {} descriptor = null; try { fs.rmSync(stateLockPath, { force: true }); } catch {} } if (error?.code !== "EEXIST") throw error; if (removeAbandonedStateLock()) continue; if (Date.now() >= deadline) throw new Error("Timed out waiting for the LiveDesk update state lock: " + stateLockPath); waitForStateLock(Math.min(25, Math.max(1, deadline - Date.now()))); } } try { return callback(); } finally { try { fs.closeSync(descriptor); } catch {} try { const owner = JSON.parse(fs.readFileSync(stateLockPath, "utf8")); if (owner?.token === token && Number(owner?.pid) === process.pid) fs.rmSync(stateLockPath, { force: true }); } catch {} } };',
|
|
1576
1589
|
'const writeFailure = (error, cancelRequested = false) => { try { withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown" || previous.stage === "connected" || previous.stage === "restored") return; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: cancelRequested || previous.cancelRequested === true, error: String(error?.message || error).slice(0, 4000), failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); }); } catch {} process.stderr.write("LiveDesk update handoff failed: " + (error?.message || error) + "\\n"); process.exitCode = 1; };',
|
|
1577
1590
|
'const writeCancellation = error => withStateLock(() => { const previous = readState(); if (previous.operationId && previous.operationId !== operationId) return "superseded"; if (previous.operationId !== operationId) return false; if (previous.stage === "preflight-ready" || previous.stage === "waiting-for-shutdown") return "ready"; if (previous.stage === "failed") return previous.cancelRequested === true ? "cancelled" : "failed"; const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "failed", targetProductVersion, restartVerified: false, cancelRequested: true, error: String(error?.message || error).slice(0, 4000), cancelledAt: now, failedAt: now, updatedAt: now }; const temporary = statePath + "." + process.pid + ".cancel.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); return "cancelled"; });',
|
|
1578
|
-
'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") {
|
|
1591
|
+
'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") { const root = child.livedeskWindowsIdentity || await child.livedeskWindowsIdentityPromise; if (!root && !child.livedeskWindowsRootExitedAtMs) { child.livedeskWindowsTrackingError = child.livedeskWindowsIdentityError || new Error(`No immutable Windows identity is available for pid=${pid}`); while (!child.livedeskWindowsRootExitedAtMs && !child.livedeskWindowsIdentity) await sleep(250); } await terminateExactWindowsTrackedTree(child, child.livedeskWindowsIdentity || root || null); return; } try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} } const gracefulDeadline = Date.now() + 2000; while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || isAlive(pid))) await sleep(100); if (isGroupAlive(pid) || isAlive(pid)) { try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } } };',
|
|
1579
1592
|
'const writeHandoffStarted = () => withStateLock(() => { if (deadlineExpired()) throw new Error("The absolute LiveDesk Client update deadline expired before handoff."); const previous = readState(); if (previous.operationId !== operationId) throw new Error("LiveDesk update handoff was superseded before exact-package launch."); const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "handoff-started", starterPid: process.pid, updateDeadlineEpochMs, restartVerified: false, error: "", updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff-started.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); });',
|
|
1580
|
-
'const monitorPreflight = child => { let settled = false; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const inspect = () => { const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed")
|
|
1593
|
+
'const monitorPreflight = child => { let settled = false; let draining = false; let drainPromise = null; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); stopWindowsTracking(child); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const drainAndFinish = (error, preserveFailure = false) => { if (settled) return Promise.resolve(); draining = true; if (!drainPromise) drainPromise = terminateTree(child).then(() => finish(error, preserveFailure)); return drainPromise; }; const inspect = () => { if (draining) return; const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed") void drainAndFinish(null, true); }; const cancelTimedOutHandoff = async () => { const error = new Error(deadlineExpired() ? "The absolute LiveDesk Client update deadline expired before exact-package preflight." : "Timed out waiting for exact-package update preflight."); draining = true; while (!settled) { let outcome; try { outcome = writeCancellation(error); } catch (lockError) { await drainAndFinish(lockError); return; } if (outcome === "ready") { draining = false; inspect(); return; } if (outcome === "failed" || outcome === "cancelled" || outcome === "superseded") { await drainAndFinish(null, true); return; } await sleep(25); } }; const poll = setInterval(inspect, 100); const timeoutMs = Math.max(1, Math.min(Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000)), updateDeadlineEpochMs - Date.now())); const timeout = setTimeout(() => { void cancelTimedOutHandoff().catch(error => { void drainAndFinish(error); }); }, timeoutMs); child.once("exit", (code, signal) => { inspect(); if (settled || draining) return; const error = new Error("Exact-package update supervisor exited before preflight (code=" + (code ?? "none") + ", signal=" + (signal || "none") + ")."); void drainAndFinish(error); }); inspect(); };',
|
|
1581
1594
|
'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk update neutral working directory is unavailable"); fs.mkdirSync(neutralCwd, { recursive: true }); const unexpectedEntry = fs.readdirSync(neutralCwd)[0]; if (unexpectedEntry) { const shadow = path.join(neutralCwd, unexpectedEntry); throw new Error("LiveDesk update neutral working directory is shadowed by " + shadow + ": " + neutralCwd); } };',
|
|
1582
1595
|
'if (!operationId || !versionPattern.test(targetProductVersion) || deadlineExpired()) { writeFailure(new Error("Invalid or expired LiveDesk exact-package update handoff."), true); } else { try { prepareNeutralCwd(); writeHandoffStarted();',
|
|
1583
1596
|
'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_workspaces = "false"; env.npm_config_include_workspace_root = "false";',
|
|
@@ -1587,7 +1600,7 @@ function buildClientUpdateBootstrapScript() {
|
|
|
1587
1600
|
'const args = ["-y", "--prefer-online", "--prefix", neutralCwd, "--workspaces=false", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];',
|
|
1588
1601
|
'const quoteCmd = value => "\\"" + String(value).replaceAll("%", "%%").replaceAll("\\"", "\\"\\"") + "\\"";',
|
|
1589
1602
|
'const invocation = npxCli ? { command: process.execPath, args: [npxCli, ...args] } : process.platform === "win32" ? { command: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", "call " + quoteCmd(npx) + " " + args.map(quoteCmd).join(" ")] } : { command: npx, args };',
|
|
1590
|
-
'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); child.once("error", writeFailure); child.once("spawn", () => monitorPreflight(child)); } catch (error) { writeFailure(error); } }',
|
|
1603
|
+
'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); trackWindowsChild(child); child.once("error", writeFailure); child.once("spawn", () => { trackWindowsChild(child); monitorPreflight(child); }); } catch (error) { writeFailure(error); } }',
|
|
1591
1604
|
'} catch (error) { writeFailure(error, deadlineExpired()); } }',
|
|
1592
1605
|
''
|
|
1593
1606
|
].join('\n');
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
installAgentTerminationHandlers,
|
|
19
19
|
signalAgentTree
|
|
20
20
|
} from '../src/runtime/agent-process-lifecycle.js';
|
|
21
|
+
import { writeWindowsOwnedProcessManifest } from '../src/runtime/windows-owned-process-manifest.js';
|
|
21
22
|
import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
|
|
22
23
|
import {
|
|
23
24
|
inspectLinuxVideoAcceleration,
|
|
@@ -73,7 +74,69 @@ let linuxVideoAccelerationStatus = null;
|
|
|
73
74
|
let discoveryWakeController = new AbortController();
|
|
74
75
|
let networkChangeMonitor = null;
|
|
75
76
|
const sessionRefreshesInFlight = new WeakMap();
|
|
76
|
-
installAgentTerminationHandlers({
|
|
77
|
+
const disposeAgentTerminationHandlers = installAgentTerminationHandlers({
|
|
78
|
+
getAgentProcess: () => activeAgentProcess
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function readWindowsManifestOwnerFromEnvironment(env = process.env) {
|
|
82
|
+
if (process.platform !== 'win32') return null;
|
|
83
|
+
const rawOwner = {
|
|
84
|
+
pid: String(env.LIVEDESK_RUNTIME_OWNER_PID || '').trim(),
|
|
85
|
+
ownerToken: String(env.LIVEDESK_RUNTIME_OWNER_TOKEN || '').trim(),
|
|
86
|
+
ownerInstanceMarker: String(env.LIVEDESK_RUNTIME_OWNER_INSTANCE_MARKER || '').trim(),
|
|
87
|
+
ownerStartOrder: String(env.LIVEDESK_RUNTIME_OWNER_START_ORDER || '').trim()
|
|
88
|
+
};
|
|
89
|
+
const hasAnyOwnerField = Object.values(rawOwner).some(Boolean);
|
|
90
|
+
if (!hasAnyOwnerField) return null;
|
|
91
|
+
const owner = {
|
|
92
|
+
...rawOwner,
|
|
93
|
+
pid: Number(rawOwner.pid)
|
|
94
|
+
};
|
|
95
|
+
if (owner.pid !== process.pid
|
|
96
|
+
|| !/^[a-f0-9]{32}$/i.test(owner.ownerToken)
|
|
97
|
+
|| !owner.ownerInstanceMarker.startsWith(`${process.pid}:`)
|
|
98
|
+
|| !/^\d+$/.test(owner.ownerStartOrder)) {
|
|
99
|
+
console.warn(
|
|
100
|
+
'[LiveDesk Client] Windows process-manifest publication is disabled because '
|
|
101
|
+
+ 'the unified launcher owner tuple is incomplete or does not name this exact process.'
|
|
102
|
+
);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return owner;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const WINDOWS_PROCESS_MANIFEST_OWNER = readWindowsManifestOwnerFromEnvironment();
|
|
109
|
+
|
|
110
|
+
function publishWindowsOwnedAgentRecords(records, {
|
|
111
|
+
rootRecord,
|
|
112
|
+
snapshot
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (!WINDOWS_PROCESS_MANIFEST_OWNER || !rootRecord || records.length === 0) return;
|
|
115
|
+
const ownerRecord = (Array.isArray(snapshot) ? snapshot : []).find(
|
|
116
|
+
record => Number(record?.pid || 0) === WINDOWS_PROCESS_MANIFEST_OWNER.pid
|
|
117
|
+
);
|
|
118
|
+
const exactOwnerGeneration = ownerRecord
|
|
119
|
+
&& ownerRecord.startOrder === WINDOWS_PROCESS_MANIFEST_OWNER.ownerStartOrder
|
|
120
|
+
&& `${ownerRecord.pid}:${ownerRecord.startMarker}`
|
|
121
|
+
=== WINDOWS_PROCESS_MANIFEST_OWNER.ownerInstanceMarker;
|
|
122
|
+
if (!exactOwnerGeneration) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'The Windows process snapshot did not confirm the unified launcher owner generation.'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const publication = writeWindowsOwnedProcessManifest({
|
|
128
|
+
stateDir: UNIFIED_CLIENT_STATE_DIR,
|
|
129
|
+
owner: WINDOWS_PROCESS_MANIFEST_OWNER,
|
|
130
|
+
records
|
|
131
|
+
});
|
|
132
|
+
if (!publication.ok) {
|
|
133
|
+
throw publication.error || new Error('Windows owned-process manifest publication failed.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function requestClientRuntimeShutdown(signal = 'SIGTERM') {
|
|
138
|
+
disposeAgentTerminationHandlers.requestTermination(signal);
|
|
139
|
+
}
|
|
77
140
|
|
|
78
141
|
function requestActiveAgentStop(signal = 'SIGTERM') {
|
|
79
142
|
const child = activeAgentProcess;
|
|
@@ -3233,8 +3296,8 @@ async function chooseClientConnection(supabase, options = {}) {
|
|
|
3233
3296
|
}, 150);
|
|
3234
3297
|
return { ...result, slotNumber, saved: true, restarting: true };
|
|
3235
3298
|
},
|
|
3236
|
-
onRestart: () =>
|
|
3237
|
-
onShutdown: () =>
|
|
3299
|
+
onRestart: () => requestClientRuntimeShutdown('SIGTERM'),
|
|
3300
|
+
onShutdown: () => requestClientRuntimeShutdown('SIGTERM'),
|
|
3238
3301
|
onDisconnect: () => requestActiveAgentStop(),
|
|
3239
3302
|
// Ending the current Agent causes the unified lifecycle loop to
|
|
3240
3303
|
// discard its old manager/pair token and start Supabase discovery
|
|
@@ -4308,7 +4371,10 @@ function spawnAgent(command, args, env = process.env, onStart = null) {
|
|
|
4308
4371
|
});
|
|
4309
4372
|
child.livedeskOwnsProcessGroup = ownsProcessGroup;
|
|
4310
4373
|
child.livedeskWindowsTreeTracker = createWindowsProcessTreeTracker(child, {
|
|
4311
|
-
spawnedAtMs: agentSpawnedAtMs
|
|
4374
|
+
spawnedAtMs: agentSpawnedAtMs,
|
|
4375
|
+
publishTrackedRecords: WINDOWS_PROCESS_MANIFEST_OWNER
|
|
4376
|
+
? publishWindowsOwnedAgentRecords
|
|
4377
|
+
: null
|
|
4312
4378
|
});
|
|
4313
4379
|
activeAgentProcess = child;
|
|
4314
4380
|
if (typeof onStart === 'function') {
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.202",
|
|
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.410",
|
|
45
|
+
"@livedesk/fast-osx-arm64": "0.1.410",
|
|
46
|
+
"@livedesk/fast-osx-x64": "0.1.410",
|
|
47
|
+
"@livedesk/fast-win-x64": "0.1.410"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|