@xenon-device-management/xenon 1.11.1 → 1.12.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenon-device-management/xenon",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Xenon - Intelligent Mobile Infrastructure. A self-healing device orchestration platform for Appium.",
5
5
  "main": "./lib/src/index.js",
6
6
  "exports": {
package/lib/schema.json CHANGED
@@ -224,6 +224,21 @@
224
224
  "default": true,
225
225
  "description": "When true, the cleanup job also deletes session video recordings and screenshots from disk (not just DB rows)."
226
226
  },
227
+ "recordingCleanupDays": {
228
+ "type": "number",
229
+ "default": 30,
230
+ "description": "Live Devices recordings older than this many days are purged (DB row + files) by the cleanup job. Runs on `buildCleanupSchedule`."
231
+ },
232
+ "recordingCleanupMaxCount": {
233
+ "type": "number",
234
+ "default": 100,
235
+ "description": "Maximum number of recordings to retain. Oldest-first eviction beyond this cap regardless of `recordingCleanupDays`."
236
+ },
237
+ "recordingFailedCleanupDays": {
238
+ "type": "number",
239
+ "default": 2,
240
+ "description": "Failed recordings hold no playable file, so they are purged on this much shorter window than `recordingCleanupDays`."
241
+ },
227
242
  "sessionHeartbeatIntervalMs": {
228
243
  "type": "number",
229
244
  "default": 30000,
@@ -290,6 +305,9 @@
290
305
  "buildCleanupMaxCount",
291
306
  "buildCleanupSchedule",
292
307
  "deleteBuildAssets",
308
+ "recordingCleanupDays",
309
+ "recordingCleanupMaxCount",
310
+ "recordingFailedCleanupDays",
293
311
  "sessionHeartbeatIntervalMs",
294
312
  "enableJsonLogging"
295
313
  ],
@@ -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
- logger_1.default.debug(`[${udid}] Capture failure: ${e.message}`);
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 (_a) {
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;
@@ -46,6 +46,9 @@ exports.DefaultPluginArgs = {
46
46
  buildCleanupMaxCount: 100,
47
47
  buildCleanupSchedule: '0 0 * * *',
48
48
  deleteBuildAssets: true,
49
+ recordingCleanupDays: 30,
50
+ recordingCleanupMaxCount: 100,
51
+ recordingFailedCleanupDays: 2,
49
52
  sessionHeartbeatIntervalMs: 30000,
50
53
  enableJsonLogging: false,
51
54
  autowait: {
@@ -25,17 +25,22 @@ const logger_1 = __importDefault(require("../logger"));
25
25
  const fs_1 = __importDefault(require("fs"));
26
26
  const path_1 = __importDefault(require("path"));
27
27
  const config_1 = require("../config");
28
+ const recordingCleanup_1 = require("./recording/recordingCleanup");
29
+ /** Subdirectory holding composite (multi-device) outputs, keyed by group id. */
30
+ const GROUPS_DIR = '_groups';
28
31
  let CleanupService = class CleanupService {
29
32
  constructor() {
30
33
  this.log = logger_1.default.scope('CleanupService');
31
34
  }
32
35
  /**
33
- * Orchestrates build + session cleanup based on retention policy.
36
+ * Orchestrates build + session + recording cleanup based on retention policy.
34
37
  *
35
- * Two phases:
38
+ * Phases:
36
39
  * 1. Purge old builds (by age AND count cap) together with their sessions.
37
40
  * 2. Sweep orphan sessions (no build_id) older than the retention window —
38
41
  * otherwise ad-hoc sessions accumulate forever.
42
+ * 3. Purge expired Live Devices recordings and sweep unreachable recording
43
+ * directories (issue #209) — the one asset class nothing used to remove.
39
44
  */
40
45
  runCleanup(pluginArgs) {
41
46
  return __awaiter(this, void 0, void 0, function* () {
@@ -84,6 +89,9 @@ let CleanupService = class CleanupService {
84
89
  // this, ad-hoc sessions (WebDriver runs with no build capability) are
85
90
  // never cleaned up — their assets accumulate on disk forever.
86
91
  yield this.purgeOrphanSessions(expirationDate, deleteBuildAssets);
92
+ // 4. Recordings have their own windows — a failed recording holds no
93
+ // playable file, so it need not linger as long as real footage.
94
+ yield this.purgeRecordings(pluginArgs);
87
95
  this.log.info('✅ Build cleanup completed successfully.');
88
96
  }
89
97
  catch (err) {
@@ -91,6 +99,119 @@ let CleanupService = class CleanupService {
91
99
  }
92
100
  });
93
101
  }
102
+ /**
103
+ * Retention for Live Devices recordings (issue #209).
104
+ *
105
+ * Two halves, in order — expiring rows first means their directories are
106
+ * unreachable by the time the sweep runs, so both are reclaimed in one pass:
107
+ * a. delete rows past the age/count policy, and their files;
108
+ * b. remove directories no surviving row points into.
109
+ *
110
+ * Bookmarks and annotations cascade on the row delete, so no child cleanup
111
+ * is needed here.
112
+ */
113
+ purgeRecordings(pluginArgs) {
114
+ return __awaiter(this, void 0, void 0, function* () {
115
+ const { recordingCleanupDays = 30, recordingCleanupMaxCount = 100, recordingFailedCleanupDays = 2, } = pluginArgs;
116
+ const rows = yield prisma_1.prisma.recording.findMany({
117
+ select: { id: true, status: true, started_at: true, file_path: true, group_id: true },
118
+ });
119
+ const expiredIds = (0, recordingCleanup_1.selectExpiredRecordings)({
120
+ rows,
121
+ now: Date.now(),
122
+ days: recordingCleanupDays,
123
+ maxCount: recordingCleanupMaxCount,
124
+ failedDays: recordingFailedCleanupDays,
125
+ });
126
+ if (expiredIds.length > 0) {
127
+ const expired = new Set(expiredIds);
128
+ for (const r of rows) {
129
+ if (expired.has(r.id) && r.file_path)
130
+ this.unlinkSilently(r.file_path);
131
+ }
132
+ yield prisma_1.prisma.recording.deleteMany({ where: { id: { in: expiredIds } } });
133
+ this.log.info(`Purged ${expiredIds.length} expired recording(s) ` +
134
+ `(retention ${recordingCleanupDays}d / max ${recordingCleanupMaxCount}, ` +
135
+ `failed ${recordingFailedCleanupDays}d).`);
136
+ }
137
+ else {
138
+ this.log.info('No recordings past their retention window.');
139
+ }
140
+ this.sweepOrphanRecordingDirs(rows.filter((r) => !expiredIds.includes(r.id)), rows.length);
141
+ });
142
+ }
143
+ /**
144
+ * Remove recording directories nothing in the DB can reach.
145
+ *
146
+ * Reachability is decided by `file_path`, never by the directory name — see
147
+ * selectOrphanDirectories for why the name is not a safe key.
148
+ */
149
+ sweepOrphanRecordingDirs(survivors, totalRowsRead) {
150
+ const base = config_1.config.recordingsAssetsPath;
151
+ let deviceDirs = [];
152
+ let groupDirs = [];
153
+ try {
154
+ if (!fs_1.default.existsSync(base))
155
+ return;
156
+ deviceDirs = fs_1.default
157
+ .readdirSync(base, { withFileTypes: true })
158
+ .filter((e) => e.isDirectory() && e.name !== GROUPS_DIR)
159
+ .map((e) => path_1.default.join(base, e.name));
160
+ const groupsBase = path_1.default.join(base, GROUPS_DIR);
161
+ if (fs_1.default.existsSync(groupsBase)) {
162
+ groupDirs = fs_1.default
163
+ .readdirSync(groupsBase, { withFileTypes: true })
164
+ .filter((e) => e.isDirectory())
165
+ .map((e) => path_1.default.join(groupsBase, e.name));
166
+ }
167
+ }
168
+ catch (err) {
169
+ this.log.warn(`Could not list recording directories: ${err.message}`);
170
+ return;
171
+ }
172
+ if (!(0, recordingCleanup_1.isRecordingSweepSafe)({
173
+ rowCount: totalRowsRead,
174
+ dirCount: deviceDirs.length + groupDirs.length,
175
+ })) {
176
+ this.log.warn(`Refusing to sweep ${deviceDirs.length + groupDirs.length} recording directories: the ` +
177
+ 'Recording table read returned no rows. That is what a failed or mis-scoped query ' +
178
+ 'looks like, and sweeping on it would delete the whole tree. Sweep manually if the ' +
179
+ 'table really is empty.');
180
+ return;
181
+ }
182
+ const orphans = (0, recordingCleanup_1.selectOrphanDirectories)({
183
+ deviceDirs,
184
+ groupDirs,
185
+ livePaths: survivors.map((r) => r.file_path).filter((p) => !!p),
186
+ liveGroupIds: survivors.map((r) => r.group_id).filter((g) => !!g),
187
+ });
188
+ let removed = 0;
189
+ for (const dir of orphans) {
190
+ if (this.removeRecordingDirectory(dir))
191
+ removed++;
192
+ }
193
+ if (removed > 0) {
194
+ this.log.info(`Swept ${removed} unreachable recording director${removed === 1 ? 'y' : 'ies'}.`);
195
+ }
196
+ }
197
+ /** rm -rf a recording directory, refusing anything outside the tree. */
198
+ removeRecordingDirectory(dir) {
199
+ const base = path_1.default.resolve(config_1.config.recordingsAssetsPath);
200
+ const target = path_1.default.resolve(dir);
201
+ if (!target.startsWith(base + path_1.default.sep)) {
202
+ this.log.warn(`Refusing to remove recording dir outside the tree: ${dir}`);
203
+ return false;
204
+ }
205
+ try {
206
+ fs_1.default.rmSync(target, { recursive: true, force: true });
207
+ this.log.debug(`Removed orphan recording directory: ${target}`);
208
+ return true;
209
+ }
210
+ catch (err) {
211
+ this.log.warn(`Failed to remove recording dir ${target}: ${err.message}`);
212
+ return false;
213
+ }
214
+ }
94
215
  /**
95
216
  * Completely purges a build, its sessions, and all associated metadata/assets.
96
217
  */
@@ -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
- yield waitForFfmpegExit(proc, `recording ${sessionId}`);
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): ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
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;