From 47f25e95d9d5ca09ce3d55e3526722d5d55fe7d4 Mon Sep 17 00:00:00 2001 From: Scott Draves Date: Thu, 18 Jun 2026 18:02:30 -0400 Subject: [PATCH] Fix end-of-dream busyloop and harden resume/settings against bad values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a dream ended with an empty cache and streaming disallowed (0 quota, fresh install), preflightNextDream(false) returned nullopt every frame and the player re-preflighted at 60fps with no way to advance — a CPU/log busyloop. The field trigger was a saved last_played_frame sitting on the clip's last frame, so the resumed dream "finished" on arrival and dropped straight into that non-streaming transition. - PlaylistManager::preflightNextDream: when nothing is cached and streaming was disallowed, stream the next dream as a last resort instead of returning nullopt. Cached content is still preferred; this only fires when the cache is empty. - Player::SetPlaylistAtDream: clamp the resume frame to [0, frames-1] using the dream's metadata frame count, replacing the bogus 24h@60fps ceiling that let past-end values through. A far-past-end seek otherwise decodes zero frames and wedges the decoder ("no frames available" spin); a value too large is parsed as a double and crashes the settings read (see below). - JSONStorage::GetOrSetValue: wrap the boost::json as_*() extraction in try/catch so a malformed or out-of-range setting (an integer too large for uint64 is parsed as a double) falls back to the default instead of throwing and terminating the process. Co-Authored-By: Claude Opus 4.8 (1M context) --- client_generic/Client/Player.cpp | 23 +++++++++++++++---- .../ContentDownloader/PlaylistManager.cpp | 20 +++++++++++++--- client_generic/TupleStorage/JSONStorage.cpp | 17 +++++++++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/client_generic/Client/Player.cpp b/client_generic/Client/Player.cpp index 4d77b7ef6..57fd76ede 100644 --- a/client_generic/Client/Player.cpp +++ b/client_generic/Client/Player.cpp @@ -1354,10 +1354,25 @@ bool CPlayer::SetPlaylistAtDream(const std::string& playlistUUID, const std::str int64_t seekFrame; seekFrame = (int64_t)g_Settings()->Get( "settings.content.last_played_frame", uint64_t{}); - // Guard against corrupted/wrapped values (e.g. uint32_t wrap from a previous VAAPI PTS bug). - // Any negative value or value implying > 24 hours of footage at 60fps is treated as invalid. - constexpr int64_t kMaxReasonableFrame = 24LL * 3600 * 60; // 24h @ 60fps - if (seekFrame < 0 || seekFrame > kMaxReasonableFrame) seekFrame = 0; + + // Clamp the resume position into the dream's valid range. A saved + // last_played_frame may be negative, stale, hand-edited, or past the clip's + // real end (the frame index can reach or overrun the end at high playback + // speeds). A negative seek or one well past EOF hangs the decoder — it decodes + // no frames and never starts — so bound it to [0, frames-1]. Landing on the + // last frame yields a single frame and the clip finishes immediately; the + // streaming fallback in preflightNextDream then advances to the next dream. + // The decoder's own frame count is unreliable for streamed clips, so use the + // metadata count. + const int64_t dreamFrames = optionalDream->dream ? (int64_t)optionalDream->dream->frames : 0; + if (seekFrame < 0) { + seekFrame = 0; + } else if (dreamFrames > 0 && seekFrame >= dreamFrames) { + g_Log->Warning("Resume frame %lld past end of %s (length %lld); clamping to last frame", + (long long)seekFrame, optionalDream->dream->uuid.c_str(), + (long long)dreamFrames); + seekFrame = dreamFrames - 1; + } // If we've reached here, the playlist is set and positioned at the correct dream // Now we can start playing this dream diff --git a/client_generic/ContentDownloader/PlaylistManager.cpp b/client_generic/ContentDownloader/PlaylistManager.cpp index 8557ede5b..6774f740b 100644 --- a/client_generic/ContentDownloader/PlaylistManager.cpp +++ b/client_generic/ContentDownloader/PlaylistManager.cpp @@ -863,9 +863,23 @@ std::optional PlaylistManager::preflightNext } } - // No cached dreams available at all - g_Log->Warning("Preflight : no cached dreams available and canStream=false"); - return std::nullopt; + // No cached dreams available at all. Returning nothing here makes the caller + // re-preflight every frame (a busy-loop) with no way to advance. Since the + // cache is empty, streaming the next dream is the only way to make progress, + // so fall back to it even though this is a canStream=false (prefer-cached) + // request. Cached content was preferred above; this only fires as a last + // resort (e.g. a fresh install with an exhausted download quota). + size_t streamPos = (m_currentPosition + 1) % m_playlist.size(); + const auto& streamEntry = m_playlist[streamPos]; + g_Log->Warning("Preflight : no cached dreams available; streaming next dream at position %zu", streamPos); + decision = { + streamPos, + TransitionType::StandardCrossfade, + m_cacheManager.getDream(streamEntry.uuid), + streamEntry.startKeyframe, + streamEntry.endKeyframe + }; + return decision; } const auto& firstEntry = m_playlist[0]; diff --git a/client_generic/TupleStorage/JSONStorage.cpp b/client_generic/TupleStorage/JSONStorage.cpp index 1ebdf9b1d..3b386dfcc 100644 --- a/client_generic/TupleStorage/JSONStorage.cpp +++ b/client_generic/TupleStorage/JSONStorage.cpp @@ -89,7 +89,22 @@ bool JSONStorage::GetOrSetValue( } } } - _targetValue = callback(currentValue); + // The callback extracts the value via boost::json as_*() accessors, + // which throw if the stored value isn't the expected kind — e.g. a + // hand-edited or out-of-range number (an integer too large for + // int64/uint64 is parsed as a double). Treat any such mismatch as + // "not found" so the caller falls back to its default instead of + // letting the exception escape and terminate the process. + try + { + _targetValue = callback(currentValue); + } + catch (const std::exception& e) + { + g_Log->Warning("JSONStorage: value for '%s' has unexpected type (%s); using default", + std::string(_entry).c_str(), e.what()); + return false; + } } else {