livedesk 0.1.458 → 0.1.460
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 +52 -25
- package/client/package.json +5 -5
- package/diagnostics/livedesk-mode3-capture-smoke.mjs +183 -0
- package/diagnostics/livedesk-transport-physical-core.mjs +503 -0
- package/diagnostics/livedesk-transport-physical.mjs +508 -0
- package/hub/package.json +1 -1
- package/hub/src/remote-hub.js +813 -30
- package/hub/src/server.js +155 -5
- package/hub/src/transport/udp-hub-transport.js +110 -8
- package/hub/src/transport/udp-rendezvous.js +6 -2
- package/package.json +7 -7
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
REQUIRED_PHASES,
|
|
9
|
+
buildTransportPhysicalReport,
|
|
10
|
+
genericTransportPhysicalError,
|
|
11
|
+
isTransportPhysicalPhaseEvidence
|
|
12
|
+
} from './livedesk-transport-physical-core.mjs';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_HUB_URL = 'http://127.0.0.1:5179';
|
|
15
|
+
const DEFAULT_PHASE_TIMEOUT_MS = 15_000;
|
|
16
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
17
|
+
const HUB_COMMAND_TIMEOUT_MS = 8_000;
|
|
18
|
+
const CONTROL_REQUEST_TIMEOUT_MS = 10_000;
|
|
19
|
+
const START_REQUEST_TIMEOUT_MS = 15_000;
|
|
20
|
+
const CLEANUP_CONFIRM_TIMEOUT_MS = 20_000;
|
|
21
|
+
const CLEANUP_RETRY_DELAY_MS = 250;
|
|
22
|
+
const DEFAULT_TTL_MS = 150_000;
|
|
23
|
+
const MIN_TTL_MARGIN_MS = 20_000;
|
|
24
|
+
|
|
25
|
+
export function normalizeTransportPhysicalTiming(options = {}) {
|
|
26
|
+
const ttlMs = Math.max(15_000, Math.min(180_000, Number(options.ttlMs) || DEFAULT_TTL_MS));
|
|
27
|
+
const phaseTimeoutMs = Math.max(
|
|
28
|
+
3_000,
|
|
29
|
+
Math.min(15_000, Number(options.phaseTimeoutMs) || DEFAULT_PHASE_TIMEOUT_MS));
|
|
30
|
+
const requestTimeoutMs = Math.max(
|
|
31
|
+
500,
|
|
32
|
+
Math.min(10_000, Number(options.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS));
|
|
33
|
+
const acquireCommandBudgetMs = HUB_COMMAND_TIMEOUT_MS;
|
|
34
|
+
const transitionCommandBudgetMs = (REQUIRED_PHASES.length - 1) * HUB_COMMAND_TIMEOUT_MS;
|
|
35
|
+
const releaseCommandBudgetMs = HUB_COMMAND_TIMEOUT_MS;
|
|
36
|
+
const commandBudgetMs = acquireCommandBudgetMs
|
|
37
|
+
+ transitionCommandBudgetMs
|
|
38
|
+
+ releaseCommandBudgetMs;
|
|
39
|
+
const frameEvidenceBudgetMs = REQUIRED_PHASES.length * phaseTimeoutMs;
|
|
40
|
+
const requiredTtlMs = frameEvidenceBudgetMs + commandBudgetMs + MIN_TTL_MARGIN_MS;
|
|
41
|
+
if (requiredTtlMs > ttlMs) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`transport-physical timing budget invalid: ${REQUIRED_PHASES.length} frame evidence budgets, `
|
|
44
|
+
+ `acquire/transition/release command budgets, and ${MIN_TTL_MARGIN_MS}ms margin `
|
|
45
|
+
+ `require at least ${requiredTtlMs}ms TTL.`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
ttlMs,
|
|
49
|
+
phaseTimeoutMs,
|
|
50
|
+
requestTimeoutMs,
|
|
51
|
+
requiredTtlMs,
|
|
52
|
+
ttlMarginMs: MIN_TTL_MARGIN_MS,
|
|
53
|
+
frameEvidenceBudgetMs,
|
|
54
|
+
commandBudgetMs,
|
|
55
|
+
acquireCommandBudgetMs,
|
|
56
|
+
transitionCommandBudgetMs,
|
|
57
|
+
releaseCommandBudgetMs
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseTransportPhysicalArgs(argv) {
|
|
62
|
+
const args = {
|
|
63
|
+
hubUrl: DEFAULT_HUB_URL,
|
|
64
|
+
deviceId: '',
|
|
65
|
+
reportPath: '',
|
|
66
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
67
|
+
phaseTimeoutMs: DEFAULT_PHASE_TIMEOUT_MS,
|
|
68
|
+
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
|
|
69
|
+
};
|
|
70
|
+
const values = [...argv];
|
|
71
|
+
if (values[0] === 'diagnose' && values[1] === 'transport-physical') values.splice(0, 2);
|
|
72
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
73
|
+
const flag = values[index];
|
|
74
|
+
const next = values[index + 1];
|
|
75
|
+
if (flag === '--hub' && next) {
|
|
76
|
+
args.hubUrl = next;
|
|
77
|
+
index += 1;
|
|
78
|
+
} else if (flag === '--device' && next) {
|
|
79
|
+
args.deviceId = next;
|
|
80
|
+
index += 1;
|
|
81
|
+
} else if (flag === '--report' && next) {
|
|
82
|
+
args.reportPath = path.resolve(next);
|
|
83
|
+
index += 1;
|
|
84
|
+
} else if (flag === '--ttl' && next) {
|
|
85
|
+
args.ttlMs = Number(next);
|
|
86
|
+
index += 1;
|
|
87
|
+
} else if (flag === '--phase-timeout' && next) {
|
|
88
|
+
args.phaseTimeoutMs = Number(next);
|
|
89
|
+
index += 1;
|
|
90
|
+
} else if (flag === '--request-timeout' && next) {
|
|
91
|
+
args.requestTimeoutMs = Number(next);
|
|
92
|
+
index += 1;
|
|
93
|
+
} else if (flag === '--help' || flag === '-h') {
|
|
94
|
+
args.help = true;
|
|
95
|
+
} else {
|
|
96
|
+
throw new Error(`Unknown transport-physical option: ${flag}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
...args,
|
|
101
|
+
...normalizeTransportPhysicalTiming(args)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function assertLoopbackHubUrl(value) {
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = new URL(value);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error('transport-physical requires a valid localhost HTTP Hub URL');
|
|
111
|
+
}
|
|
112
|
+
if (parsed.protocol !== 'http:'
|
|
113
|
+
|| parsed.username
|
|
114
|
+
|| parsed.password
|
|
115
|
+
|| !['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) {
|
|
116
|
+
throw new Error('transport-physical requires a localhost HTTP Hub URL');
|
|
117
|
+
}
|
|
118
|
+
return parsed.origin;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function combineAbortSignals(signals = []) {
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const listeners = [];
|
|
124
|
+
let disposed = false;
|
|
125
|
+
const dispose = () => {
|
|
126
|
+
if (disposed) return;
|
|
127
|
+
disposed = true;
|
|
128
|
+
for (const [signal, listener] of listeners.splice(0)) {
|
|
129
|
+
signal.removeEventListener?.('abort', listener);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const abortFrom = () => {
|
|
133
|
+
if (!controller.signal.aborted) {
|
|
134
|
+
controller.abort(new Error('transport diagnostic aborted'));
|
|
135
|
+
}
|
|
136
|
+
dispose();
|
|
137
|
+
};
|
|
138
|
+
for (const signal of signals.filter(Boolean)) {
|
|
139
|
+
if (signal.aborted) {
|
|
140
|
+
abortFrom();
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
const listener = () => abortFrom();
|
|
144
|
+
signal.addEventListener?.('abort', listener, { once: true });
|
|
145
|
+
listeners.push([signal, listener]);
|
|
146
|
+
}
|
|
147
|
+
return { signal: controller.signal, dispose };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function abortableDelay(delayMs, signal) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
let settled = false;
|
|
153
|
+
let timer;
|
|
154
|
+
const finish = (callback, value) => {
|
|
155
|
+
if (settled) return;
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
159
|
+
callback(value);
|
|
160
|
+
};
|
|
161
|
+
const onAbort = () => finish(
|
|
162
|
+
reject,
|
|
163
|
+
signal?.reason instanceof Error
|
|
164
|
+
? signal.reason
|
|
165
|
+
: new Error('transport diagnostic aborted'));
|
|
166
|
+
if (signal?.aborted) {
|
|
167
|
+
onAbort();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
171
|
+
timer = setTimeout(() => finish(resolve), Math.max(0, Number(delayMs) || 0));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function requestJson(
|
|
176
|
+
url,
|
|
177
|
+
{
|
|
178
|
+
method = 'GET',
|
|
179
|
+
token = '',
|
|
180
|
+
body,
|
|
181
|
+
signal,
|
|
182
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS
|
|
183
|
+
} = {}
|
|
184
|
+
) {
|
|
185
|
+
const timeoutController = new AbortController();
|
|
186
|
+
const timeout = setTimeout(
|
|
187
|
+
() => timeoutController.abort(new Error('Hub request timed out.')),
|
|
188
|
+
Math.max(1, Number(requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS));
|
|
189
|
+
const combined = combineAbortSignals([signal, timeoutController.signal]);
|
|
190
|
+
try {
|
|
191
|
+
const response = await fetch(url, {
|
|
192
|
+
method,
|
|
193
|
+
signal: combined.signal,
|
|
194
|
+
headers: {
|
|
195
|
+
accept: 'application/json',
|
|
196
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
197
|
+
...(token ? { 'x-livedesk-diagnostic-token': token } : {})
|
|
198
|
+
},
|
|
199
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
200
|
+
});
|
|
201
|
+
const payload = await response.json().catch(() => ({}));
|
|
202
|
+
if (!response.ok || payload?.ok === false) {
|
|
203
|
+
throw new Error(genericTransportPhysicalError(
|
|
204
|
+
payload?.error,
|
|
205
|
+
`Hub request failed with status ${response.status}.`));
|
|
206
|
+
}
|
|
207
|
+
return payload;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (timeoutController.signal.aborted && !signal?.aborted) {
|
|
210
|
+
throw new Error('Hub request timed out.');
|
|
211
|
+
}
|
|
212
|
+
if (signal?.aborted || combined.signal.aborted) {
|
|
213
|
+
throw new Error('transport diagnostic aborted');
|
|
214
|
+
}
|
|
215
|
+
throw new Error(genericTransportPhysicalError(error, 'Hub request failed.'));
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
combined.dispose();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function hasConfirmedTransportPhysicalRelease(snapshot) {
|
|
223
|
+
return snapshot?.state === 'completed'
|
|
224
|
+
&& Array.isArray(snapshot?.cleanup)
|
|
225
|
+
&& snapshot.cleanup.some(entry => entry?.state === 'agent-released');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function cleanupTransportPhysicalLease({
|
|
229
|
+
hubUrl,
|
|
230
|
+
leaseId,
|
|
231
|
+
token,
|
|
232
|
+
reason = 'runner-failed',
|
|
233
|
+
initialSnapshot = null,
|
|
234
|
+
cleanupTimeoutMs = CLEANUP_CONFIRM_TIMEOUT_MS,
|
|
235
|
+
requestTimeoutMs = CONTROL_REQUEST_TIMEOUT_MS,
|
|
236
|
+
retryDelayMs = CLEANUP_RETRY_DELAY_MS
|
|
237
|
+
}) {
|
|
238
|
+
const deadline = Date.now() + Math.max(1, Number(cleanupTimeoutMs) || CLEANUP_CONFIRM_TIMEOUT_MS);
|
|
239
|
+
let latest = initialSnapshot;
|
|
240
|
+
let lastError = null;
|
|
241
|
+
let attempts = 0;
|
|
242
|
+
while (Date.now() < deadline) {
|
|
243
|
+
attempts += 1;
|
|
244
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
245
|
+
try {
|
|
246
|
+
latest = await requestJson(
|
|
247
|
+
`${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}`,
|
|
248
|
+
{
|
|
249
|
+
method: 'DELETE',
|
|
250
|
+
token,
|
|
251
|
+
body: { reason },
|
|
252
|
+
requestTimeoutMs: Math.min(
|
|
253
|
+
Math.max(1, Number(requestTimeoutMs) || CONTROL_REQUEST_TIMEOUT_MS),
|
|
254
|
+
remainingMs)
|
|
255
|
+
});
|
|
256
|
+
lastError = null;
|
|
257
|
+
if (hasConfirmedTransportPhysicalRelease(latest)) {
|
|
258
|
+
return { confirmed: true, snapshot: latest, attempts, error: '' };
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
lastError = error;
|
|
262
|
+
}
|
|
263
|
+
const retryRemainingMs = deadline - Date.now();
|
|
264
|
+
if (retryRemainingMs <= 0) break;
|
|
265
|
+
await abortableDelay(Math.min(
|
|
266
|
+
Math.max(1, Number(retryDelayMs) || CLEANUP_RETRY_DELAY_MS),
|
|
267
|
+
retryRemainingMs));
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
confirmed: false,
|
|
271
|
+
snapshot: latest,
|
|
272
|
+
attempts,
|
|
273
|
+
error: genericTransportPhysicalError(
|
|
274
|
+
latest ? latest.error : lastError,
|
|
275
|
+
'Hub did not confirm Agent diagnostic release.')
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function chooseDevice(hubUrl, requestedDeviceId, signal, requestTimeoutMs) {
|
|
280
|
+
if (requestedDeviceId) return requestedDeviceId;
|
|
281
|
+
const response = await requestJson(`${hubUrl}/api/remote/devices`, {
|
|
282
|
+
signal,
|
|
283
|
+
requestTimeoutMs
|
|
284
|
+
});
|
|
285
|
+
const candidates = Array.isArray(response?.devices) ? response.devices : [];
|
|
286
|
+
const candidate = candidates.find(device =>
|
|
287
|
+
device?.connected === true
|
|
288
|
+
&& device?.latestLiveFrame?.currentGenerationVerified === true
|
|
289
|
+
&& String(device?.latestLiveFrame?.mimeType || '').toLowerCase() === 'video/h264'
|
|
290
|
+
&& String(device?.latestLiveFrame?.frameTransportActive || '').toLowerCase() === 'direct');
|
|
291
|
+
if (!candidate?.deviceId) {
|
|
292
|
+
throw new Error('No connected Client has a current Direct H.264 Wall/Control stream.');
|
|
293
|
+
}
|
|
294
|
+
return candidate.deviceId;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function waitForPhase(
|
|
298
|
+
hubUrl,
|
|
299
|
+
leaseId,
|
|
300
|
+
token,
|
|
301
|
+
phase,
|
|
302
|
+
deadline,
|
|
303
|
+
signal,
|
|
304
|
+
requestTimeoutMs
|
|
305
|
+
) {
|
|
306
|
+
let latest = null;
|
|
307
|
+
while (Date.now() < deadline) {
|
|
308
|
+
signal?.throwIfAborted?.();
|
|
309
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
310
|
+
latest = await requestJson(
|
|
311
|
+
`${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}`,
|
|
312
|
+
{
|
|
313
|
+
token,
|
|
314
|
+
signal,
|
|
315
|
+
requestTimeoutMs: Math.min(requestTimeoutMs, remainingMs)
|
|
316
|
+
});
|
|
317
|
+
if (latest.frames?.some(frame => isTransportPhysicalPhaseEvidence(frame, phase))) {
|
|
318
|
+
return latest;
|
|
319
|
+
}
|
|
320
|
+
if (latest.state !== 'active') {
|
|
321
|
+
throw new Error(genericTransportPhysicalError(
|
|
322
|
+
latest.error,
|
|
323
|
+
`transport diagnostic stopped during ${phase}`));
|
|
324
|
+
}
|
|
325
|
+
await abortableDelay(Math.min(200, Math.max(1, deadline - Date.now())), signal);
|
|
326
|
+
}
|
|
327
|
+
throw new Error(`Timed out waiting for a received ${phase} H.264 SPS+PPS+IDR.`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function writeStableJson(reportPath, report) {
|
|
331
|
+
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
|
332
|
+
const temporaryPath = `${reportPath}.${process.pid}.tmp`;
|
|
333
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(report, null, 2)}\n`, {
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
mode: 0o600
|
|
336
|
+
});
|
|
337
|
+
await fs.rename(temporaryPath, reportPath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function defaultReportPath() {
|
|
341
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
342
|
+
return path.join(os.homedir(), '.livedesk', 'diagnostics', `transport-physical-${stamp}.json`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function runTransportPhysicalDiagnostic(options = {}) {
|
|
346
|
+
const hubUrl = assertLoopbackHubUrl(options.hubUrl || DEFAULT_HUB_URL);
|
|
347
|
+
const timing = normalizeTransportPhysicalTiming(options);
|
|
348
|
+
const lifecycleController = new AbortController();
|
|
349
|
+
const combined = combineAbortSignals([lifecycleController.signal, options.signal]);
|
|
350
|
+
const signal = combined.signal;
|
|
351
|
+
const reportPath = options.reportPath || defaultReportPath();
|
|
352
|
+
let deviceId = '';
|
|
353
|
+
let leaseId = '';
|
|
354
|
+
let token = '';
|
|
355
|
+
let latest = null;
|
|
356
|
+
let completed = false;
|
|
357
|
+
let runError = null;
|
|
358
|
+
|
|
359
|
+
const stopForSignal = () => {
|
|
360
|
+
if (!lifecycleController.signal.aborted) {
|
|
361
|
+
lifecycleController.abort(new Error('transport diagnostic interrupted'));
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
process.once('SIGINT', stopForSignal);
|
|
365
|
+
process.once('SIGTERM', stopForSignal);
|
|
366
|
+
try {
|
|
367
|
+
deviceId = await chooseDevice(
|
|
368
|
+
hubUrl,
|
|
369
|
+
options.deviceId || '',
|
|
370
|
+
signal,
|
|
371
|
+
timing.requestTimeoutMs);
|
|
372
|
+
const started = await requestJson(`${hubUrl}/api/remote/diagnostics/transport/start`, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
body: { deviceId, ttlMs: timing.ttlMs },
|
|
375
|
+
signal,
|
|
376
|
+
requestTimeoutMs: Math.max(timing.requestTimeoutMs, START_REQUEST_TIMEOUT_MS)
|
|
377
|
+
});
|
|
378
|
+
leaseId = started.leaseId;
|
|
379
|
+
token = started.token;
|
|
380
|
+
if (!leaseId || !token) throw new Error('Hub did not issue a diagnostic lease.');
|
|
381
|
+
const actualLeaseMs = Date.parse(started.expiresAt || '') - Date.parse(started.startedAt || '');
|
|
382
|
+
if (!Number.isFinite(actualLeaseMs) || actualLeaseMs < timing.requiredTtlMs) {
|
|
383
|
+
throw new Error('Hub diagnostic lease is shorter than the four-phase timing contract.');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let frameEvidenceDeadline = Date.now() + timing.phaseTimeoutMs;
|
|
387
|
+
latest = await waitForPhase(
|
|
388
|
+
hubUrl,
|
|
389
|
+
leaseId,
|
|
390
|
+
token,
|
|
391
|
+
REQUIRED_PHASES[0],
|
|
392
|
+
frameEvidenceDeadline,
|
|
393
|
+
signal,
|
|
394
|
+
timing.requestTimeoutMs);
|
|
395
|
+
for (const phase of REQUIRED_PHASES.slice(1)) {
|
|
396
|
+
await requestJson(
|
|
397
|
+
`${hubUrl}/api/remote/diagnostics/transport/${encodeURIComponent(leaseId)}/phase`,
|
|
398
|
+
{
|
|
399
|
+
method: 'POST',
|
|
400
|
+
token,
|
|
401
|
+
body: { phase },
|
|
402
|
+
signal,
|
|
403
|
+
requestTimeoutMs: Math.max(timing.requestTimeoutMs, CONTROL_REQUEST_TIMEOUT_MS)
|
|
404
|
+
});
|
|
405
|
+
frameEvidenceDeadline = Date.now() + timing.phaseTimeoutMs;
|
|
406
|
+
latest = await waitForPhase(
|
|
407
|
+
hubUrl,
|
|
408
|
+
leaseId,
|
|
409
|
+
token,
|
|
410
|
+
phase,
|
|
411
|
+
frameEvidenceDeadline,
|
|
412
|
+
signal,
|
|
413
|
+
timing.requestTimeoutMs);
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
runError = error;
|
|
417
|
+
} finally {
|
|
418
|
+
if (leaseId && token) {
|
|
419
|
+
const cleanup = await cleanupTransportPhysicalLease({
|
|
420
|
+
hubUrl,
|
|
421
|
+
leaseId,
|
|
422
|
+
token,
|
|
423
|
+
reason: runError
|
|
424
|
+
? (signal.aborted ? 'runner-aborted' : 'runner-failed')
|
|
425
|
+
: 'runner-complete',
|
|
426
|
+
initialSnapshot: latest,
|
|
427
|
+
requestTimeoutMs: Math.max(timing.requestTimeoutMs, CONTROL_REQUEST_TIMEOUT_MS)
|
|
428
|
+
});
|
|
429
|
+
latest = cleanup.snapshot || latest;
|
|
430
|
+
completed = cleanup.confirmed;
|
|
431
|
+
if (!completed && !runError) {
|
|
432
|
+
runError = new Error(cleanup.error || 'Hub did not confirm diagnostic lease cleanup.');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
process.removeListener('SIGINT', stopForSignal);
|
|
436
|
+
process.removeListener('SIGTERM', stopForSignal);
|
|
437
|
+
combined.dispose();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (runError) {
|
|
441
|
+
latest = {
|
|
442
|
+
...(latest || {}),
|
|
443
|
+
ok: false,
|
|
444
|
+
state: latest?.state === 'completed' ? latest.state : 'failed',
|
|
445
|
+
error: genericTransportPhysicalError(runError)
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const report = buildTransportPhysicalReport(latest || {}, { hubUrl, deviceId });
|
|
449
|
+
await writeStableJson(reportPath, report);
|
|
450
|
+
if (runError) {
|
|
451
|
+
const failure = new Error(
|
|
452
|
+
`${genericTransportPhysicalError(runError)} (redacted report: ${reportPath})`);
|
|
453
|
+
failure.cause = runError;
|
|
454
|
+
throw failure;
|
|
455
|
+
}
|
|
456
|
+
return { report, reportPath };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function printHelp() {
|
|
460
|
+
console.log([
|
|
461
|
+
'Usage: livedesk diagnose transport-physical [options]',
|
|
462
|
+
'',
|
|
463
|
+
' --device <id> Connected Client device ID (auto-selects a Direct H.264 stream)',
|
|
464
|
+
' --hub <localhost-url> Hub UI origin (default http://127.0.0.1:5179)',
|
|
465
|
+
' --ttl <ms> Fail-closed diagnostic lease TTL (default 150000)',
|
|
466
|
+
' --phase-timeout <ms> Per-path SPS+PPS+IDR timeout (3000-15000)',
|
|
467
|
+
' --request-timeout <ms> Poll timeout (500-10000; control/start use safe minimums)',
|
|
468
|
+
' --report <path> Redacted JSON report path',
|
|
469
|
+
'',
|
|
470
|
+
`Contract: ${REQUIRED_PHASES.length} frame budgets + acquire + `
|
|
471
|
+
+ `${REQUIRED_PHASES.length - 1} transitions + release command budgets `
|
|
472
|
+
+ `+ ${MIN_TTL_MARGIN_MS}ms margin <= TTL.`
|
|
473
|
+
].join('\n'));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function runTransportPhysicalDiagnosticCli(argv = []) {
|
|
477
|
+
const args = parseTransportPhysicalArgs(argv);
|
|
478
|
+
if (args.help) {
|
|
479
|
+
printHelp();
|
|
480
|
+
return { status: 'help' };
|
|
481
|
+
}
|
|
482
|
+
const { report, reportPath } = await runTransportPhysicalDiagnostic(args);
|
|
483
|
+
console.log(`[LiveDesk] transport-physical ${report.result.status}: ${report.result.summary}`);
|
|
484
|
+
console.log(`[LiveDesk] redacted report: ${reportPath}`);
|
|
485
|
+
if (report.result.status !== 'passed') process.exitCode = 2;
|
|
486
|
+
return { status: report.result.status, report, reportPath };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const isMain = process.argv[1]
|
|
490
|
+
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
491
|
+
if (isMain) {
|
|
492
|
+
runTransportPhysicalDiagnosticCli(process.argv.slice(2))
|
|
493
|
+
.catch(error => {
|
|
494
|
+
console.error(
|
|
495
|
+
`[LiveDesk] transport-physical failed: ${genericTransportPhysicalError(error)}`);
|
|
496
|
+
process.exitCode = 1;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export {
|
|
501
|
+
DEFAULT_PHASE_TIMEOUT_MS,
|
|
502
|
+
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
503
|
+
DEFAULT_TTL_MS,
|
|
504
|
+
CLEANUP_CONFIRM_TIMEOUT_MS,
|
|
505
|
+
CONTROL_REQUEST_TIMEOUT_MS,
|
|
506
|
+
HUB_COMMAND_TIMEOUT_MS,
|
|
507
|
+
MIN_TTL_MARGIN_MS
|
|
508
|
+
};
|