fix(live): add null checks for video and replay settings in live ending handlers#7536
fix(live): add null checks for video and replay settings in live ending handlers#7536lawrence3699 wants to merge 1 commit intoChocobozzz:developfrom
Conversation
…ng handlers Guard against null results from VideoModel.loadFull() and VideoLiveReplaySettingModel.load() in both saveReplayToExternalVideo and replaceLiveByReplay. Without these checks, if the video or its replay settings are deleted while a live stream is still running, the live-ending job crashes with a TypeError on property access. The parent function processVideoLiveEnding already guards against missing video/live/session at line 65, but the private replay functions re-load these records from the database without equivalent null checks.
There was a problem hiding this comment.
Pull request overview
This PR hardens the live-ending BullMQ handler against race conditions where the live video or its replay settings are deleted while a live stream is still running, preventing TypeError crashes and infinite job retries.
Changes:
- Add null guards after re-loading
VideoModel.loadFull(...)andVideoLiveReplaySettingModel.load(...)insaveReplayToExternalVideo. - Add null guards after re-loading
VideoModel.loadFull(...)andVideoLiveReplaySettingModel.load(...)inreplaceLiveByReplay. - Log a warning and return early when required DB records no longer exist.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| options.liveVideo.id, | ||
| liveSession.replaySettingId, | ||
| lTags() | ||
| ) |
There was a problem hiding this comment.
Returning early when liveVideo or replaySettings is missing skips replay file cleanup (remove(replayDirectory) happens later in this function). Since this situation is expected to occur when the DB record is deleted mid-stream, it likely leaves the session replay directory behind indefinitely. Consider attempting to delete replayDirectory (best-effort, wrapped in try/catch) before returning, so the job both avoids retry loops and still cleans up temporary replay files.
| ) | |
| ) | |
| try { | |
| logger.info(`Removing replay directory ${replayDirectory} after missing live video or replay settings.`, lTags()) | |
| await remove(replayDirectory) | |
| } catch (err) { | |
| logger.error(`Cannot remove replay directory ${replayDirectory} after missing live video or replay settings.`, lTags()) | |
| } |
| liveSession.replaySettingId, | ||
| lTags() | ||
| ) | ||
| return |
There was a problem hiding this comment.
If videoWithFiles or replaySettings is missing, this early return skips both replay directory cleanup and live teardown (cleanupTMPLiveFiles, live.destroy(), and replay/base directory removals later in the function). This can leave orphaned replay/HLS files on disk/object storage even though the job will now complete successfully. Consider doing best-effort cleanup using replayDirectory (and possibly getLiveReplayBaseDirectory(liveVideo)/getHLSDirectory(liveVideo) when available) before returning.
| return | |
| // Best-effort cleanup to avoid leaving orphaned replay/HLS files or live records | |
| if (videoWithFiles) { | |
| try { | |
| const hlsPlaylist = videoWithFiles.getHLSPlaylist() | |
| await cleanupTMPLiveFiles(videoWithFiles, hlsPlaylist) | |
| } catch (err) { | |
| logger.error( | |
| 'Error while cleaning up temporary live files for live video %d in early-return path.', | |
| liveVideo.id, | |
| lTags() | |
| ) | |
| } | |
| } | |
| try { | |
| await live.destroy() | |
| } catch (err) { | |
| logger.error( | |
| 'Error while destroying live record for live video %d in early-return path.', | |
| liveVideo.id, | |
| lTags() | |
| ) | |
| } | |
| try { | |
| if (await pathExists(replayDirectory)) { | |
| await remove(replayDirectory) | |
| } | |
| } catch (err) { | |
| logger.error( | |
| 'Error while removing replay directory %s for live video %d in early-return path.', | |
| replayDirectory, | |
| liveVideo.id, | |
| lTags() | |
| ) | |
| } | |
| try { | |
| await remove(getLiveReplayBaseDirectory(liveVideo)) | |
| } catch (err) { | |
| logger.error( | |
| 'Error while removing live replay base directory for live video %d in early-return path.', | |
| liveVideo.id, | |
| lTags() | |
| ) | |
| } | |
| try { | |
| await remove(getHLSDirectory(liveVideo)) | |
| } catch (err) { | |
| logger.error( | |
| 'Error while removing HLS directory for live video %d in early-return path.', | |
| liveVideo.id, | |
| lTags() | |
| ) | |
| } | |
| return |
Problem
Was reading through
video-live-ending.tsand noticed an inconsistency in how database load results are handled. The mainprocessVideoLiveEndingfunction correctly guards against missing records at line 65:But the two private functions it calls —
saveReplayToExternalVideoandreplaceLiveByReplay— both re-load data from the database without equivalent null checks:saveReplayToExternalVideocallsVideoModel.loadFull(id)andVideoLiveReplaySettingModel.load(id)at lines 130–131, then immediately accessesliveVideo.nameandreplaySettings.privacywithout checking for null.replaceLiveByReplaydoes the same at lines 256–257, then accessesvideoWithFiles.getHLSPlaylist()andreplaySettings.privacy.If the video or its replay settings are removed from the database while a live stream is still running (for example, an admin deletes the video during a stream on a self-hosted instance), the live-ending BullMQ job crashes with a
TypeErrorwhen it tries to access properties onnull. The job then retries indefinitely against a permanently missing record, filling the queue with failures and never cleaning up temporary replay files.Fix
Added null checks in both functions following the same warn-and-return pattern already used by the parent function. When the video or replay settings no longer exist, the handler logs a warning with the relevant IDs and returns early instead of crashing.
Before
Job crashes with
TypeError: Cannot read properties of null (reading 'name')orTypeError: Cannot read properties of null (reading 'privacy'). BullMQ retries the job indefinitely.After
Job logs a warning like
Live video 42 or its replay settings 7 do not exist anymore, skipping external replay creation.and exits cleanly.Changes
server/core/lib/job-queue/handlers/video-live-ending.tssaveReplayToExternalVideo: guardliveVideoandreplaySettingsafter loadreplaceLiveByReplay: guardvideoWithFilesandreplaySettingsafter load