@xenon-device-management/xenon 1.11.1 → 1.11.2
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/lib/package.json +1 -1
- package/lib/src/device-managers/android/AndroidStreamService.js +56 -3
- package/lib/src/device-managers/android/androidStreamReuse.js +90 -0
- package/lib/src/services/VideoPipelineService.js +45 -9
- package/lib/src/services/recording/RecordingOrchestrator.js +139 -47
- package/lib/src/services/recording/annotation-render.js +6 -54
- package/lib/src/services/recording/probeDuration.js +149 -0
- package/lib/test/unit/android-stream-reuse-service.spec.js +10 -1
- package/lib/test/unit/android-stream-reuse.spec.js +102 -4
- package/lib/test/unit/android-stream-stall-eof.spec.js +152 -0
- package/lib/test/unit/probe-duration.spec.js +50 -0
- package/lib/test/unit/recording-source-ended.spec.js +187 -0
- package/lib/test/unit/video-pipeline-exit.spec.js +99 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
package/lib/package.json
CHANGED
|
@@ -128,6 +128,29 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
128
128
|
shouldIdleCapture(session) {
|
|
129
129
|
return session.status === 'running' && session.viewerCount === 0;
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Log a stall once per episode, whichever path notices it first.
|
|
133
|
+
*
|
|
134
|
+
* Both the capture loop's catch and the MJPEG writer call this. The writer
|
|
135
|
+
* matters most: ending responses on a stall drops the viewer count to zero,
|
|
136
|
+
* which makes `shouldIdleCapture` true, which stops the loop attempting
|
|
137
|
+
* further captures — so the catch may never run again. A live cable-pull run
|
|
138
|
+
* hit exactly that and logged nothing at all.
|
|
139
|
+
*
|
|
140
|
+
* Returns whether this call did the logging, so the caller can decide what to
|
|
141
|
+
* say instead.
|
|
142
|
+
*/
|
|
143
|
+
announceStallOnce(session, now, lastError) {
|
|
144
|
+
if (!session.captureHealth.takeStallAnnouncement(now))
|
|
145
|
+
return false;
|
|
146
|
+
const seconds = Math.round(session.captureHealth.failingForMs(now) / 1000);
|
|
147
|
+
logger_1.default.warn(`[${session.udid}] Device has not answered screencap for ${seconds}s ` +
|
|
148
|
+
`(${session.captureHealth.consecutiveFailures} consecutive failures` +
|
|
149
|
+
`${lastError ? `, last: ${lastError}` : ''}). Stream clients are being disconnected ` +
|
|
150
|
+
'rather than served a frozen frame; the stream restarts on the next request once the ' +
|
|
151
|
+
'device recovers.');
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
131
154
|
captureLoop(udid, session) {
|
|
132
155
|
return __awaiter(this, void 0, void 0, function* () {
|
|
133
156
|
logger_1.default.info(`[${udid}] Background capture loop started.`);
|
|
@@ -143,8 +166,11 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
143
166
|
yield new Promise((r) => setTimeout(r, 150));
|
|
144
167
|
continue;
|
|
145
168
|
}
|
|
169
|
+
// Hoisted out of the try so the catch can date a failure from when the
|
|
170
|
+
// attempt began: a 15s ADB timeout means the device had already been
|
|
171
|
+
// silent for 15s by the time we reach the catch.
|
|
172
|
+
const startTime = Date.now();
|
|
146
173
|
try {
|
|
147
|
-
const startTime = Date.now();
|
|
148
174
|
// High-Speed Binary Snapshot
|
|
149
175
|
const screenshot = yield DeviceLockManager_1.deviceLock.acquire(udid, () => __awaiter(this, void 0, void 0, function* () {
|
|
150
176
|
return yield new Promise((resolve, reject) => {
|
|
@@ -197,6 +223,7 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
197
223
|
logger_1.default.info(`[${udid}] First Android frame successfully captured and converted.`);
|
|
198
224
|
}
|
|
199
225
|
session.latestFrameTimestamp = Date.now();
|
|
226
|
+
session.captureHealth.recordSuccess();
|
|
200
227
|
}
|
|
201
228
|
}
|
|
202
229
|
else {
|
|
@@ -207,7 +234,11 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
207
234
|
yield new Promise((r) => setTimeout(r, delay));
|
|
208
235
|
}
|
|
209
236
|
catch (e) {
|
|
210
|
-
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const { consecutive } = session.captureHealth.recordFailure(startTime, now);
|
|
239
|
+
if (!this.announceStallOnce(session, now, e.message)) {
|
|
240
|
+
logger_1.default.debug(`[${udid}] Capture failure (${consecutive} consecutive): ${e.message}`);
|
|
241
|
+
}
|
|
211
242
|
yield new Promise((r) => setTimeout(r, 1000));
|
|
212
243
|
}
|
|
213
244
|
}
|
|
@@ -228,6 +259,7 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
228
259
|
status: existing.status,
|
|
229
260
|
serverListening: ((_a = existing.server) === null || _a === void 0 ? void 0 : _a.listening) === true,
|
|
230
261
|
hasFrame: existing.latestFrame !== undefined,
|
|
262
|
+
captureStalled: existing.captureHealth.isStalled(Date.now()),
|
|
231
263
|
});
|
|
232
264
|
if (decision.reuse) {
|
|
233
265
|
if (existing)
|
|
@@ -263,6 +295,7 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
263
295
|
viewerCount: 0,
|
|
264
296
|
adbPath,
|
|
265
297
|
adbHostArgs,
|
|
298
|
+
captureHealth: new androidStreamReuse_1.CaptureHealth(),
|
|
266
299
|
};
|
|
267
300
|
this.sessions.set(udid, candidate);
|
|
268
301
|
try {
|
|
@@ -433,6 +466,26 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
433
466
|
!res.writable) {
|
|
434
467
|
return;
|
|
435
468
|
}
|
|
469
|
+
// The device has gone silent, so `latestFrame` is now a still image.
|
|
470
|
+
// End the response instead of rewriting it forever: ffmpeg finalises
|
|
471
|
+
// the mp4 with the real footage it captured, and browser clients fall
|
|
472
|
+
// into their normal stream-retry — which re-enters startStream, whose
|
|
473
|
+
// health check restarts the stream. Serving on would have produced a
|
|
474
|
+
// recording that looks valid and is a photograph (issue #200).
|
|
475
|
+
const nowMs = Date.now();
|
|
476
|
+
if (session.captureHealth.isStalled(nowMs)) {
|
|
477
|
+
// Announce from here too: this path is often the only one that gets
|
|
478
|
+
// to notice, because the disconnect below is what idles the capture
|
|
479
|
+
// loop in the first place.
|
|
480
|
+
this.announceStallOnce(session, nowMs);
|
|
481
|
+
try {
|
|
482
|
+
res.end();
|
|
483
|
+
}
|
|
484
|
+
catch (_a) {
|
|
485
|
+
/* ignore */
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
436
489
|
if (session.latestFrame) {
|
|
437
490
|
try {
|
|
438
491
|
res.write('--BoundaryString\r\n');
|
|
@@ -441,7 +494,7 @@ let AndroidStreamService = class AndroidStreamService {
|
|
|
441
494
|
res.write(session.latestFrame);
|
|
442
495
|
res.write('\r\n');
|
|
443
496
|
}
|
|
444
|
-
catch (
|
|
497
|
+
catch (_b) {
|
|
445
498
|
return;
|
|
446
499
|
}
|
|
447
500
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* maps its live session onto the view below.
|
|
21
21
|
*/
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.CaptureHealth = exports.CAPTURE_STALL_MS = void 0;
|
|
23
24
|
exports.decideAndroidStreamReuse = decideAndroidStreamReuse;
|
|
24
25
|
function decideAndroidStreamReuse(session) {
|
|
25
26
|
if (!session)
|
|
@@ -34,5 +35,94 @@ function decideAndroidStreamReuse(session) {
|
|
|
34
35
|
return { reuse: false, reason: 'server-closed' };
|
|
35
36
|
if (!session.hasFrame)
|
|
36
37
|
return { reuse: false, reason: 'no-frame' };
|
|
38
|
+
// Ordered after hasFrame: "never captured anything" and "captured, then went
|
|
39
|
+
// silent" are different problems, and the reason ends up in a log line.
|
|
40
|
+
if (session.captureStalled)
|
|
41
|
+
return { reuse: false, reason: 'capture-stalled' };
|
|
37
42
|
return { reuse: true };
|
|
38
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* How long the device may go without producing a frame before anything we still
|
|
46
|
+
* hold is treated as a still image rather than a live feed.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately generous. A single failed `screencap`, a device briefly held by
|
|
49
|
+
* the interaction lock, or one 15s ADB timeout must not tear down a live
|
|
50
|
+
* preview or an in-flight recording — the cure for that would be the
|
|
51
|
+
* over-eager-restart failure mode #198 was careful to avoid.
|
|
52
|
+
*/
|
|
53
|
+
exports.CAPTURE_STALL_MS = 10000;
|
|
54
|
+
/**
|
|
55
|
+
* Tracks whether the device is still answering `screencap`.
|
|
56
|
+
*
|
|
57
|
+
* Why this exists (issue #200): the capture loop swallows capture errors and
|
|
58
|
+
* loops on `while (status === 'running' || status === 'starting')`, so a device
|
|
59
|
+
* that goes away — unplugged, adb killed, reboot — never changes the session's
|
|
60
|
+
* status. `latestFrame` is only ever assigned, never cleared, so the MJPEG
|
|
61
|
+
* server happily rewrote the last good JPEG every 60ms: a frozen preview, and a
|
|
62
|
+
* recording that ffprobe calls healthy but which is a still photograph. That is
|
|
63
|
+
* worse than the 0-byte failure of #194/#196, because it looks valid.
|
|
64
|
+
*
|
|
65
|
+
* The measure is time-since-the-device-last-answered, not a failure count: a
|
|
66
|
+
* fast `ADB Exit 1` and a 15s `ADB Timeout` are wildly different amounts of
|
|
67
|
+
* frozen video for the same count. It is also immune to the idle path —
|
|
68
|
+
* `shouldIdleCapture` skips capture entirely when nobody is watching, so an
|
|
69
|
+
* idle-but-healthy session records neither successes nor failures and simply
|
|
70
|
+
* stays in whatever state it was left in.
|
|
71
|
+
*/
|
|
72
|
+
class CaptureHealth {
|
|
73
|
+
constructor() {
|
|
74
|
+
this.consecutive = 0;
|
|
75
|
+
this.announced = false;
|
|
76
|
+
}
|
|
77
|
+
/** A frame was captured — the device is answering. */
|
|
78
|
+
recordSuccess() {
|
|
79
|
+
this.failingSince = undefined;
|
|
80
|
+
this.consecutive = 0;
|
|
81
|
+
this.announced = false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A capture attempt threw.
|
|
85
|
+
*
|
|
86
|
+
* `attemptStartedAt` (not `now`) dates the stall, because a 15s ADB timeout
|
|
87
|
+
* means the device had already been silent for 15s by the time we got here.
|
|
88
|
+
*/
|
|
89
|
+
recordFailure(attemptStartedAt, now) {
|
|
90
|
+
if (this.failingSince === undefined)
|
|
91
|
+
this.failingSince = attemptStartedAt;
|
|
92
|
+
this.consecutive += 1;
|
|
93
|
+
return { consecutive: this.consecutive, stalled: this.isStalled(now) };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Claim the right to announce this stall episode: true for the first caller
|
|
97
|
+
* that observes the session stalled, false for every caller after it until a
|
|
98
|
+
* successful frame resets the episode.
|
|
99
|
+
*
|
|
100
|
+
* Why a claim rather than a flag computed inside `recordFailure`: ending
|
|
101
|
+
* client responses on a stall drops `viewerCount` to 0, `shouldIdleCapture`
|
|
102
|
+
* then stops the capture loop attempting anything more, and so the failure
|
|
103
|
+
* that would have crossed the threshold never happens — the catch never gets
|
|
104
|
+
* to warn. A live cable-pull run showed exactly that: 10 failures spanning
|
|
105
|
+
* 9.214s against a 10s threshold, then silence, so a device that had vanished
|
|
106
|
+
* produced no warning at all. Either path can claim it now, and neither
|
|
107
|
+
* double-logs.
|
|
108
|
+
*/
|
|
109
|
+
takeStallAnnouncement(now) {
|
|
110
|
+
if (!this.isStalled(now) || this.announced)
|
|
111
|
+
return false;
|
|
112
|
+
this.announced = true;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** Consecutive failed capture attempts in the current episode, for logging. */
|
|
116
|
+
get consecutiveFailures() {
|
|
117
|
+
return this.consecutive;
|
|
118
|
+
}
|
|
119
|
+
/** Capture has been failing long enough that any cached frame is a still. */
|
|
120
|
+
isStalled(now) {
|
|
121
|
+
return this.failingSince !== undefined && now - this.failingSince >= exports.CAPTURE_STALL_MS;
|
|
122
|
+
}
|
|
123
|
+
/** How long the device has been silent, for logging. 0 when healthy. */
|
|
124
|
+
failingForMs(now) {
|
|
125
|
+
return this.failingSince === undefined ? 0 : now - this.failingSince;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
exports.CaptureHealth = CaptureHealth;
|
|
@@ -380,26 +380,60 @@ let VideoPipelineService = class VideoPipelineService {
|
|
|
380
380
|
logger_1.default.error(`[VideoPipeline] FFMPEG Error [${sessionId}]: ${msg}`);
|
|
381
381
|
}
|
|
382
382
|
});
|
|
383
|
-
ffmpegProc.on('exit', (code) =>
|
|
384
|
-
if (code !== 0 && code !== null) {
|
|
385
|
-
logger_1.default.warn(`[VideoPipeline] FFMPEG for ${sessionId} exited with code ${code}`);
|
|
386
|
-
}
|
|
387
|
-
this.activeRecordings.delete(sessionId);
|
|
388
|
-
});
|
|
383
|
+
ffmpegProc.on('exit', (code) => this.handleProcessExit(sessionId, code, options.onExit));
|
|
389
384
|
this.activeRecordings.set(sessionId, ffmpegProc);
|
|
390
385
|
this.recordingPaths.set(sessionId, outputPath);
|
|
391
386
|
});
|
|
392
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* A recording's ffmpeg exited. Reports it to `onExit` only when nobody asked
|
|
390
|
+
* for it.
|
|
391
|
+
*
|
|
392
|
+
* `stopRecording` drops the handle *before* awaiting the exit, so a still
|
|
393
|
+
* present entry is the signal that this exit was not requested — the source
|
|
394
|
+
* ended or ffmpeg died. Getting that backwards would run the caller's
|
|
395
|
+
* source-ended path on every ordinary Stop as well.
|
|
396
|
+
*
|
|
397
|
+
* Split out from the listener so the decision is testable without spawning a
|
|
398
|
+
* process.
|
|
399
|
+
*/
|
|
400
|
+
handleProcessExit(sessionId, code, onExit) {
|
|
401
|
+
var _a;
|
|
402
|
+
if (code !== 0 && code !== null) {
|
|
403
|
+
logger_1.default.warn(`[VideoPipeline] FFMPEG for ${sessionId} exited with code ${code}`);
|
|
404
|
+
}
|
|
405
|
+
const unrequested = this.activeRecordings.delete(sessionId);
|
|
406
|
+
if (!unrequested || !onExit)
|
|
407
|
+
return;
|
|
408
|
+
logger_1.default.info(`[VideoPipeline] FFMPEG for ${sessionId} exited on its own (${code === null ? 'signal' : `code ${code}`})`);
|
|
409
|
+
try {
|
|
410
|
+
onExit(code);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
logger_1.default.warn(`[VideoPipeline] onExit handler threw for ${sessionId}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
393
416
|
/**
|
|
394
417
|
* Stop recording and return the relative asset path
|
|
395
418
|
*/
|
|
396
419
|
stopRecording(sessionId) {
|
|
397
420
|
return __awaiter(this, void 0, void 0, function* () {
|
|
398
|
-
var _a;
|
|
421
|
+
var _a, _b;
|
|
399
422
|
const proc = this.activeRecordings.get(sessionId);
|
|
400
423
|
const recordedPath = this.recordingPaths.get(sessionId);
|
|
401
424
|
if (!proc) {
|
|
402
425
|
logger_1.default.info(`[VideoPipeline] No active recording process for ${sessionId}, returning stored path if any.`);
|
|
426
|
+
// ffmpeg already exited on its own. The file still needs the faststart
|
|
427
|
+
// remux the normal stop path performs, or it stays a fragmented mp4 that
|
|
428
|
+
// some players will not scrub.
|
|
429
|
+
if (recordedPath) {
|
|
430
|
+
try {
|
|
431
|
+
yield remuxToStandardMp4(recordedPath);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
logger_1.default.warn(`[VideoPipeline] Remux failed for ${sessionId} (leaving fMP4): ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
403
437
|
const relativePath = recordedPath
|
|
404
438
|
? path_1.default.relative(config_1.config.sessionAssetsPath, recordedPath)
|
|
405
439
|
: null;
|
|
@@ -407,15 +441,17 @@ let VideoPipelineService = class VideoPipelineService {
|
|
|
407
441
|
return relativePath;
|
|
408
442
|
}
|
|
409
443
|
logger_1.default.info(`[VideoPipeline] Stopping recording for ${sessionId}`);
|
|
410
|
-
|
|
444
|
+
// Drop the handle before awaiting: the exit that follows is one we asked
|
|
445
|
+
// for, and the exit listener must not report it as a source-initiated end.
|
|
411
446
|
this.activeRecordings.delete(sessionId);
|
|
447
|
+
yield waitForFfmpegExit(proc, `recording ${sessionId}`);
|
|
412
448
|
this.recordingPaths.delete(sessionId);
|
|
413
449
|
if (recordedPath) {
|
|
414
450
|
try {
|
|
415
451
|
yield remuxToStandardMp4(recordedPath);
|
|
416
452
|
}
|
|
417
453
|
catch (err) {
|
|
418
|
-
logger_1.default.warn(`[VideoPipeline] Remux failed for ${sessionId} (leaving fMP4): ${(
|
|
454
|
+
logger_1.default.warn(`[VideoPipeline] Remux failed for ${sessionId} (leaving fMP4): ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
|
|
419
455
|
}
|
|
420
456
|
}
|
|
421
457
|
return recordedPath ? path_1.default.relative(config_1.config.sessionAssetsPath, recordedPath) : null;
|
|
@@ -69,6 +69,7 @@ const device_service_1 = require("../../data-service/device-service");
|
|
|
69
69
|
const device_store_1 = require("../../data-service/device-store");
|
|
70
70
|
const manualLock_1 = require("./manualLock");
|
|
71
71
|
const ensureMjpegForRecording_1 = require("./ensureMjpegForRecording");
|
|
72
|
+
const probeDuration_1 = require("./probeDuration");
|
|
72
73
|
const attributes_1 = require("../telemetry/attributes");
|
|
73
74
|
const logger_1 = __importDefault(require("../../logger"));
|
|
74
75
|
const ArtifactStore_1 = require("../artifacts/ArtifactStore");
|
|
@@ -127,7 +128,13 @@ class RecordingError extends Error {
|
|
|
127
128
|
exports.RecordingError = RecordingError;
|
|
128
129
|
let RecordingOrchestrator = class RecordingOrchestrator {
|
|
129
130
|
constructor(deps = {}) {
|
|
130
|
-
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
131
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
132
|
+
/**
|
|
133
|
+
* Recording ids currently being closed out. Both the user-initiated stop and
|
|
134
|
+
* an ffmpeg self-exit finalize rows, and they can land at the same instant —
|
|
135
|
+
* this keeps exactly one of them doing it.
|
|
136
|
+
*/
|
|
137
|
+
this.finalizing = new Set();
|
|
131
138
|
this._deps = {
|
|
132
139
|
busyPrecheck: (_a = deps.busyPrecheck) !== null && _a !== void 0 ? _a : typedi_1.Container.get(busy_precheck_1.BusyPrecheck),
|
|
133
140
|
store: (_b = deps.store) !== null && _b !== void 0 ? _b : typedi_1.Container.get(recording_store_1.RecordingStore),
|
|
@@ -138,6 +145,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
138
145
|
this.blockDeviceFn = (_f = deps.blockDeviceFn) !== null && _f !== void 0 ? _f : device_service_1.blockDevice;
|
|
139
146
|
this.unblockDeviceFn = (_g = deps.unblockDeviceFn) !== null && _g !== void 0 ? _g : device_service_1.unblockDevice;
|
|
140
147
|
this.ensureMjpegPortFn = (_h = deps.ensureMjpegPortFn) !== null && _h !== void 0 ? _h : ensureMjpegForRecording_1.ensureMjpegForRecording;
|
|
148
|
+
this.probeDurationMsFn = (_j = deps.probeDurationMsFn) !== null && _j !== void 0 ? _j : probeDuration_1.probeVideoDurationMs;
|
|
141
149
|
}
|
|
142
150
|
get busyPrecheck() {
|
|
143
151
|
return this._deps.busyPrecheck;
|
|
@@ -265,6 +273,9 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
265
273
|
udid,
|
|
266
274
|
outputPath: filePath,
|
|
267
275
|
mjpegPort,
|
|
276
|
+
// ffmpeg can end before anyone presses Stop — most often because the
|
|
277
|
+
// device stopped answering and the MJPEG server disconnected it.
|
|
278
|
+
onExit: (code) => void this.handleSourceEnded(id, code),
|
|
268
279
|
});
|
|
269
280
|
recordings.push({ id, udid, status: 'RECORDING' });
|
|
270
281
|
}
|
|
@@ -341,6 +352,125 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
341
352
|
return { groupId, recordings, startedAt, compositeEnabled };
|
|
342
353
|
});
|
|
343
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Close out one recording: stop its ffmpeg, size the file, decide the terminal
|
|
357
|
+
* status, persist it, release the gate slot and the device lock, and record
|
|
358
|
+
* the metrics. Shared by the user-initiated stop path and by
|
|
359
|
+
* handleSourceEnded, so the two can never drift.
|
|
360
|
+
*/
|
|
361
|
+
finalizeRecording(r_1) {
|
|
362
|
+
return __awaiter(this, arguments, void 0, function* (r, opts = {}) {
|
|
363
|
+
var _a, _b, _c, _d, _e;
|
|
364
|
+
// Both entry points land here and this is where the instruments are used;
|
|
365
|
+
// handleSourceEnded has no reason to know they are lazily created.
|
|
366
|
+
ensureOtelInstruments();
|
|
367
|
+
let status = 'STOPPED';
|
|
368
|
+
let failReason;
|
|
369
|
+
try {
|
|
370
|
+
yield this.videoPipeline.stopRecording(r.id);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
status = 'FAILED';
|
|
374
|
+
failReason = (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : 'ffmpeg_stop_failed';
|
|
375
|
+
}
|
|
376
|
+
// How long the video actually is, not how long the session was open. Those
|
|
377
|
+
// agree only while capture keeps up: a recording whose device died at 26s
|
|
378
|
+
// and was stopped 5 minutes later reported 5m36s for a 35s file (#204).
|
|
379
|
+
// Wall-clock is not lost — started_at and ended_at are both persisted, so
|
|
380
|
+
// it stays derivable, while the media duration is not recoverable later
|
|
381
|
+
// without re-reading the file. Probed after stopRecording so the faststart
|
|
382
|
+
// remux has happened and the header carries a duration; falls back to
|
|
383
|
+
// wall-clock whenever the probe can't answer.
|
|
384
|
+
const wallClockMs = r.started_at ? Date.now() - new Date(r.started_at).getTime() : undefined;
|
|
385
|
+
let durationMs = wallClockMs;
|
|
386
|
+
try {
|
|
387
|
+
const probed = yield this.probeDurationMsFn(r.file_path);
|
|
388
|
+
if (probed !== undefined)
|
|
389
|
+
durationMs = probed;
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
recLog.debug(`duration probe failed for ${r.id}, using wall-clock: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`);
|
|
393
|
+
}
|
|
394
|
+
let sizeBytes;
|
|
395
|
+
try {
|
|
396
|
+
sizeBytes = fs.statSync(r.file_path).size;
|
|
397
|
+
}
|
|
398
|
+
catch (_f) {
|
|
399
|
+
// File may not exist on FAIL; leave size undefined.
|
|
400
|
+
}
|
|
401
|
+
if (status === 'STOPPED' && (sizeBytes === undefined || sizeBytes < MIN_PLAYABLE_MP4_BYTES)) {
|
|
402
|
+
status = 'FAILED';
|
|
403
|
+
failReason = 'empty_or_corrupt_mp4';
|
|
404
|
+
recLog.warn(`Recording ${r.id} for ${r.device_udid} produced unusable file ` +
|
|
405
|
+
`(size=${sizeBytes !== null && sizeBytes !== void 0 ? sizeBytes : 'missing'} bytes)`);
|
|
406
|
+
}
|
|
407
|
+
// A recording whose source ended still produced valid video — just less of
|
|
408
|
+
// it than was asked for. Keeping it STOPPED leaves the mp4 downloadable;
|
|
409
|
+
// recording why it ended keeps "this is shorter than you expected" visible
|
|
410
|
+
// in the data rather than only in the log.
|
|
411
|
+
if (opts.endedBySource && status === 'STOPPED') {
|
|
412
|
+
failReason = 'source_ended';
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
yield this.store.finalize(r.id, { status, durationMs, sizeBytes, failReason });
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
recLog.warn(`finalize failed for ${r.id}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
419
|
+
}
|
|
420
|
+
this.gate.release(r.id);
|
|
421
|
+
yield this.releaseLockIfNotInheritedFromMosaic(r.device_udid, (_c = r.device_host) !== null && _c !== void 0 ? _c : '127.0.0.1');
|
|
422
|
+
if (durationMs !== undefined) {
|
|
423
|
+
durationHistogram.record(durationMs, {
|
|
424
|
+
outcome: status === 'STOPPED' ? attributes_1.OUTCOME.SUCCESS : attributes_1.OUTCOME.FAILURE,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (status === 'FAILED') {
|
|
428
|
+
failuresCounter.add(1, { fail_reason: failReason !== null && failReason !== void 0 ? failReason : 'ffmpeg_stop_failed' });
|
|
429
|
+
}
|
|
430
|
+
// Fix E: warm the annotated-mp4 cache off the critical path so a later
|
|
431
|
+
// Download is served from cache instead of an on-demand burn-in. Only
|
|
432
|
+
// for finalized recordings that actually carry annotations.
|
|
433
|
+
if (status === 'STOPPED' && ((_e = (_d = r.annotations) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0) > 0) {
|
|
434
|
+
this.prewarmAnnotatedRender(r.id);
|
|
435
|
+
}
|
|
436
|
+
return { id: r.id, udid: r.device_udid, status, durationMs, sizeBytes };
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The ffmpeg for one recording exited without us asking it to.
|
|
441
|
+
*
|
|
442
|
+
* The usual cause is the source ending: the device stopped answering, so the
|
|
443
|
+
* MJPEG server disconnected its clients (see AndroidStreamService and issue
|
|
444
|
+
* #200) and ffmpeg saw end-of-input. The mp4 is valid, just shorter than
|
|
445
|
+
* requested. Nothing else reconciles the row, so without this it sits at
|
|
446
|
+
* RECORDING — no ended_at, no duration, no size — until a manual Stop or
|
|
447
|
+
* until recoverOnBoot marks it FAILED on the next server restart, which is
|
|
448
|
+
* both late and wrong for a recording that ended cleanly. See issue #203.
|
|
449
|
+
*/
|
|
450
|
+
handleSourceEnded(recordingId, code) {
|
|
451
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
452
|
+
var _a;
|
|
453
|
+
if (this.finalizing.has(recordingId))
|
|
454
|
+
return; // a stop is already on it
|
|
455
|
+
this.finalizing.add(recordingId);
|
|
456
|
+
try {
|
|
457
|
+
const r = yield this.store.findById(recordingId);
|
|
458
|
+
if (!r || r.status !== 'RECORDING')
|
|
459
|
+
return;
|
|
460
|
+
recLog.warn(`Recording ${recordingId} for ${r.device_udid}: ffmpeg exited on its own ` +
|
|
461
|
+
`(${code === null ? 'signal' : `code ${code}`}); finalizing it here rather than ` +
|
|
462
|
+
'leaving the row open until someone presses Stop.');
|
|
463
|
+
const one = yield this.finalizeRecording(r, { endedBySource: true });
|
|
464
|
+
this.eventMgr.emitRecordingStopped({ groupId: r.group_id, recordings: [one] });
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
recLog.warn(`source-ended finalize failed for ${recordingId}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
this.finalizing.delete(recordingId);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
344
474
|
/**
|
|
345
475
|
* Stop all recordings in a group. Each recording is finalized independently
|
|
346
476
|
* (one ffmpeg failure does not abort the others), the gate slot is released,
|
|
@@ -348,7 +478,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
348
478
|
*/
|
|
349
479
|
stop(groupId) {
|
|
350
480
|
return __awaiter(this, void 0, void 0, function* () {
|
|
351
|
-
var _a, _b
|
|
481
|
+
var _a, _b;
|
|
352
482
|
ensureOtelInstruments();
|
|
353
483
|
const span = api_1.trace.getTracer('xenon.recording').startSpan('xenon.recording.stop', {
|
|
354
484
|
attributes: { [attributes_1.ATTR.RECORDING_GROUP_ID]: groupId },
|
|
@@ -367,52 +497,14 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
367
497
|
const recordings = yield this.store.listGroup(groupId);
|
|
368
498
|
const out = [];
|
|
369
499
|
for (const r of recordings) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
yield this.videoPipeline.stopRecording(r.id);
|
|
374
|
-
}
|
|
375
|
-
catch (err) {
|
|
376
|
-
status = 'FAILED';
|
|
377
|
-
failReason = (_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : 'ffmpeg_stop_failed';
|
|
378
|
-
}
|
|
379
|
-
const durationMs = r.started_at ? Date.now() - new Date(r.started_at).getTime() : undefined;
|
|
380
|
-
let sizeBytes;
|
|
381
|
-
try {
|
|
382
|
-
sizeBytes = fs.statSync(r.file_path).size;
|
|
383
|
-
}
|
|
384
|
-
catch (_g) {
|
|
385
|
-
// File may not exist on FAIL; leave size undefined.
|
|
386
|
-
}
|
|
387
|
-
if (status === 'STOPPED' &&
|
|
388
|
-
(sizeBytes === undefined || sizeBytes < MIN_PLAYABLE_MP4_BYTES)) {
|
|
389
|
-
status = 'FAILED';
|
|
390
|
-
failReason = 'empty_or_corrupt_mp4';
|
|
391
|
-
recLog.warn(`Recording ${r.id} for ${r.device_udid} produced unusable file ` +
|
|
392
|
-
`(size=${sizeBytes !== null && sizeBytes !== void 0 ? sizeBytes : 'missing'} bytes)`);
|
|
393
|
-
}
|
|
500
|
+
// Claim it so an ffmpeg exit landing mid-stop doesn't finalize the same
|
|
501
|
+
// row from handleSourceEnded at the same time.
|
|
502
|
+
this.finalizing.add(r.id);
|
|
394
503
|
try {
|
|
395
|
-
yield this.
|
|
396
|
-
}
|
|
397
|
-
catch (err) {
|
|
398
|
-
recLog.warn(`finalize failed for ${r.id}: ${err === null || err === void 0 ? void 0 : err.message}`);
|
|
399
|
-
}
|
|
400
|
-
this.gate.release(r.id);
|
|
401
|
-
yield this.releaseLockIfNotInheritedFromMosaic(r.device_udid, (_c = r.device_host) !== null && _c !== void 0 ? _c : '127.0.0.1');
|
|
402
|
-
if (durationMs !== undefined) {
|
|
403
|
-
durationHistogram.record(durationMs, {
|
|
404
|
-
outcome: status === 'STOPPED' ? attributes_1.OUTCOME.SUCCESS : attributes_1.OUTCOME.FAILURE,
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
if (status === 'FAILED') {
|
|
408
|
-
failuresCounter.add(1, { fail_reason: failReason !== null && failReason !== void 0 ? failReason : 'ffmpeg_stop_failed' });
|
|
504
|
+
out.push(yield this.finalizeRecording(r));
|
|
409
505
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// Download is served from cache instead of an on-demand burn-in. Only
|
|
413
|
-
// for finalized recordings that actually carry annotations.
|
|
414
|
-
if (status === 'STOPPED' && ((_e = (_d = r.annotations) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0) > 0) {
|
|
415
|
-
this.prewarmAnnotatedRender(r.id);
|
|
506
|
+
finally {
|
|
507
|
+
this.finalizing.delete(r.id);
|
|
416
508
|
}
|
|
417
509
|
}
|
|
418
510
|
this.eventMgr.emitRecordingStopped({ groupId, recordings: out });
|
|
@@ -421,7 +513,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
421
513
|
}
|
|
422
514
|
catch (err) {
|
|
423
515
|
span.recordException(err);
|
|
424
|
-
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (
|
|
516
|
+
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : 'unknown' });
|
|
425
517
|
throw err;
|
|
426
518
|
}
|
|
427
519
|
finally {
|
|
@@ -61,6 +61,7 @@ const fs = __importStar(require("fs"));
|
|
|
61
61
|
const path = __importStar(require("path"));
|
|
62
62
|
const recording_store_1 = require("./recording-store");
|
|
63
63
|
const ffmpegPath_1 = require("../../helpers/ffmpegPath");
|
|
64
|
+
const probeDuration_1 = require("./probeDuration");
|
|
64
65
|
const logger_1 = __importDefault(require("../../logger"));
|
|
65
66
|
const renderLog = logger_1.default.scope('AnnotationRender');
|
|
66
67
|
// All ffmpeg spawns go through the bundled binary (resolveFfmpegPath) — a bare
|
|
@@ -332,63 +333,14 @@ let AnnotationRenderService = class AnnotationRenderService {
|
|
|
332
333
|
return Math.max(0, durationSec - LATE_ANNOTATION_MARGIN_SEC);
|
|
333
334
|
}
|
|
334
335
|
/**
|
|
335
|
-
* Probe a video's duration (seconds)
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
336
|
+
* Probe a video's duration (seconds). Lives in probeDuration.ts now — the
|
|
337
|
+
* recording-stop path needs the same number to persist a real duration
|
|
338
|
+
* instead of wall-clock (issue #204), and two copies of a two-stage ffmpeg
|
|
339
|
+
* probe would drift.
|
|
339
340
|
*/
|
|
340
341
|
probeDurationSec(filePath) {
|
|
341
342
|
return __awaiter(this, void 0, void 0, function* () {
|
|
342
|
-
|
|
343
|
-
if (fromHeader !== undefined)
|
|
344
|
-
return fromHeader;
|
|
345
|
-
return this.ffmpegDecodeDurationSec(filePath);
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
/** Duration from ffmpeg's "Duration: HH:MM:SS.ss" header line (fast path). */
|
|
349
|
-
ffmpegHeaderDurationSec(filePath) {
|
|
350
|
-
return new Promise((resolve) => {
|
|
351
|
-
var _a;
|
|
352
|
-
try {
|
|
353
|
-
// `ffmpeg -i <file>` with no output prints stream info then exits
|
|
354
|
-
// non-zero; we only want the Duration line on stderr.
|
|
355
|
-
const p = this.spawnFfmpeg(['-hide_banner', '-i', filePath], `probe:${path.basename(filePath)}`);
|
|
356
|
-
let stderr = '';
|
|
357
|
-
(_a = p.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (d) => (stderr += d.toString()));
|
|
358
|
-
p.on('error', () => resolve(undefined));
|
|
359
|
-
p.on('close', () => {
|
|
360
|
-
const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
|
361
|
-
if (!m)
|
|
362
|
-
return resolve(undefined);
|
|
363
|
-
const sec = Number(m[1]) * 3600 + Number(m[2]) * 60 + parseFloat(m[3]);
|
|
364
|
-
resolve(Number.isFinite(sec) && sec > 0 ? sec : undefined);
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
catch (_b) {
|
|
368
|
-
resolve(undefined);
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
/** Duration by decoding to null — reliable for fragmented mp4 with no header. */
|
|
373
|
-
ffmpegDecodeDurationSec(filePath) {
|
|
374
|
-
return new Promise((resolve) => {
|
|
375
|
-
var _a;
|
|
376
|
-
try {
|
|
377
|
-
const p = this.spawnFfmpeg(['-hide_banner', '-nostats', '-i', filePath, '-f', 'null', '-progress', 'pipe:1', '-'], `probe-decode:${path.basename(filePath)}`);
|
|
378
|
-
let out = '';
|
|
379
|
-
(_a = p.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (d) => (out += d.toString()));
|
|
380
|
-
p.on('error', () => resolve(undefined));
|
|
381
|
-
p.on('close', () => {
|
|
382
|
-
const matches = [...out.matchAll(/out_time_us=(\d+)/g)];
|
|
383
|
-
if (!matches.length)
|
|
384
|
-
return resolve(undefined);
|
|
385
|
-
const sec = Number(matches[matches.length - 1][1]) / 1e6;
|
|
386
|
-
resolve(Number.isFinite(sec) && sec > 0 ? sec : undefined);
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
catch (_b) {
|
|
390
|
-
resolve(undefined);
|
|
391
|
-
}
|
|
343
|
+
return (0, probeDuration_1.probeVideoDurationSec)(filePath);
|
|
392
344
|
});
|
|
393
345
|
}
|
|
394
346
|
sanitizeColor(c) {
|