Skip to content

fix(live): add null checks for video and replay settings in live ending handlers#7536

Open
lawrence3699 wants to merge 1 commit intoChocobozzz:developfrom
lawrence3699:fix/live-replay-null-checks
Open

fix(live): add null checks for video and replay settings in live ending handlers#7536
lawrence3699 wants to merge 1 commit intoChocobozzz:developfrom
lawrence3699:fix/live-replay-null-checks

Conversation

@lawrence3699
Copy link
Copy Markdown

Problem

Was reading through video-live-ending.ts and noticed an inconsistency in how database load results are handled. The main processVideoLiveEnding function correctly guards against missing records at line 65:

const video = await VideoModel.loadWithThumbnails(payload.videoId)
const live = await VideoLiveModel.loadByVideoId(payload.videoId)
const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId)

if (!video || !live || !liveSession) {
  logError()
  return
}

But the two private functions it calls — saveReplayToExternalVideo and replaceLiveByReplay — both re-load data from the database without equivalent null checks:

  • saveReplayToExternalVideo calls VideoModel.loadFull(id) and VideoLiveReplaySettingModel.load(id) at lines 130–131, then immediately accesses liveVideo.name and replaySettings.privacy without checking for null.
  • replaceLiveByReplay does the same at lines 256–257, then accesses videoWithFiles.getHLSPlaylist() and replaySettings.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 TypeError when it tries to access properties on null. 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') or TypeError: 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.ts
    • saveReplayToExternalVideo: guard liveVideo and replaySettings after load
    • replaceLiveByReplay: guard videoWithFiles and replaySettings after load

…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.
Copilot AI review requested due to automatic review settings April 2, 2026 14:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...) and VideoLiveReplaySettingModel.load(...) in saveReplayToExternalVideo.
  • Add null guards after re-loading VideoModel.loadFull(...) and VideoLiveReplaySettingModel.load(...) in replaceLiveByReplay.
  • 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()
)
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
)
)
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())
}

Copilot uses AI. Check for mistakes.
liveSession.replaySettingId,
lTags()
)
return
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants