Skip to content

fix(playback): make session ownership survive cancellation on both clients - #178

Merged
RXWatcher merged 5 commits into
Silo-Server:mainfrom
RXWatcher:pr/session-ownership
Aug 6, 2026
Merged

fix(playback): make session ownership survive cancellation on both clients#178
RXWatcher merged 5 commits into
Silo-Server:mainfrom
RXWatcher:pr/session-ownership

Conversation

@RXWatcher

@RXWatcher RXWatcher commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Every defect here is the same shape: something proves it owns a playback session, suspends, and never re-checks — or hands ownership on without moving the token that names it. The result is a transcode nobody remembers, holding a server stream slot until it expires, or a teardown that stops the wrong session and lets the right one run on.

PlaybackSessionManager

An allocation lease. After startPlaybackV3 returns a session id, every branch still suspends before it publishes that id or stops it — and a cancellation in between left the id owned by nobody: the manager never published it, and callers only learn it when the function returns. The lease is armed the moment the response decodes and cleared only where responsibility genuinely moves.

That includes across the nested legacy-engine replan. Being installed as the active attempt is not the same as being findable: nothing outside the call has the id yet, and the replan suspends twice more before reaching cleanup of its own.

Ownership released without holding the lock. The internal replan paths clear activeVideoAttempt or remove the staged handle before their own suspending stop, and they run from ViewModel recovery jobs that exit and content replacement cancel. Releases now register under the caller's lock and are issued once it is released — awaiting a 60s-timeout request under videoAttemptMutex would serialise every start, replan and content reset behind a dying session's teardown.

Each release is tagged with a claim so one caller cannot take another's work, in-flight releases are counted rather than flagged, and the orphan ledger is bounded: only a discharged stop removes an entry, so a server that keeps failing this call grew it without limit.

Lifecycle and both ViewModels

The ownership token is snapshotted rather than derivedReconnecting and Failed deliberately keep owning their session, so reconstructing the token from the restored state erased ownership for exactly the states meant to survive an outage.

Phone exit and onCleared go behind the one-shot teardown gate; both clients read the retained token ahead of UI state, because every adoption path takes ownership before it publishes; adoption is conditional with cancellation-safe candidate cleanup; and seek recovery deliberately does not stop on refusal, because a re-anchor is validated to reuse the base session — that id names the session still playing.

reportPosition now requires the caller to name its session. null means "I own no session", not "skip the check": both exit paths clear the UI session id while player callbacks are still draining.

Review

Nine Codex rounds. The last was against this base after rebasing — which is where the real defects have consistently been — and it found the replan lease boundary above. Fixing that introduced a second hazard in the same change (a successful replan would have had the finally stop a live session), closed in the same commit.

Full suite green on all four modules with --rerun-tasks. Not device-verified.

🤖 Generated with Claude Code

https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW

Summary by CodeRabbit

  • Bug Fixes
    • Improved playback session stability during screen changes, recovery, seeking, and subtitle updates.
    • Prevented stale playback updates from affecting a newer active session.
    • Ensured replaced or cancelled playback sessions are stopped and cleaned up reliably.
    • Improved handling of temporary playback failures and disconnected sessions.
    • Prevented exiting one player screen from stopping playback belonging to a replacement screen.
    • Strengthened position reporting so updates apply only to the correct active session.

RXWatcher and others added 4 commits August 6, 2026 10:09
…ients

Every defect here is the same shape: something proves it owns a playback
session, suspends, and never re-checks — or hands ownership on without
moving the token that names it. The result is a transcode nobody
remembers, holding a server stream slot until it expires, or a teardown
that stops the wrong session and lets the right one run on.

PlaybackSessionManager
- Lease every server-allocated session from the moment the start response
  decodes. Each branch of startVideoSessionV3 still suspends before it
  publishes that id or stops it, and a cancellation in between left the id
  owned by nobody: the manager never published it, and callers only learn
  it when the function returns. The lease clears only where responsibility
  genuinely moves — publication, or a stop the server acknowledged — and
  the finally releases whatever is still held, uncancellably.
- Give the internal replan the same discipline. Those paths clear
  activeVideoAttempt or remove the staged handle before their own
  suspending stop, and they run from ViewModel recovery jobs that exit and
  content replacement cancel. stopRetainingFailureLocked registers the id
  under the caller's lock and queues the stop; drainPendingOwnershipReleases
  issues it once the lock is released, still awaited by the caller that
  queued it. Awaiting a 60s-timeout request under videoAttemptMutex would
  have serialised every start, replan and content reset behind a dying
  session's teardown.
- Tag queued releases with a claim, so one caller cannot take another's
  work — returning before its own stop ran while an unrelated caller blocks
  on someone else's teardown.
- Count in-flight releases rather than flagging them, and exclude queued
  and in-flight ids from the orphan drain, so two paths cannot stop one
  session concurrently.
- Bound the orphan ledger. Only a discharged stop removes an entry, so a
  server that keeps failing this call while playback produces new sessions
  grew it without limit and made every content reset retry a larger set.
- Treat a typed session-missing 404 as discharged; a bare 404 proves
  nothing about the session and kept the id queued forever.
- abandonActiveVideoSessionIfCurrent: the unconditional variant stops even
  when it failed to disown, and stopSession's predecessor branch then
  clears a newer pending publication and stops its replacement. Release on
  a dispatched scope widened that window enough to matter.

PlaybackSessionLifecycle
- Restore the ownership token totally on rollback. Assigning it only when
  the snapshot was Active left a rolled-back first deferred adoption naming
  the discarded replacement.

Phone and TV ViewModels
- Put phone's exit and onCleared behind the one-shot PlaybackTeardownGate.
  Naming the session is necessary but not sufficient: the second stop
  passes the ownership guard because the first cleared the owner, and bumps
  stopEpoch on its way through, superseding a screen that has acquired its
  start epoch but not yet adopted. TV has been behind this gate since
  auto-advance broke on exactly that race.
- Read the retained token before UI state on both clients. Every adoption
  path takes lifecycle ownership before it publishes, so reading UI first
  inside that gap named the predecessor, the lifecycle rightly refused, and
  the gate stopped onCleared retrying. The token now moves with ownership
  in both directions — forward at each adoption and at load publication,
  back to the predecessor on either TV rollback path.
- Port TV's replan abandonment protocol to phone, and give both clients
  conditional adoption with cancellation-safe candidate cleanup: a
  cancellation while awaiting the lifecycle mutex throws before isCurrent
  runs, and runCatching in an already-cancelled coroutine releases nothing.
- Seek recovery deliberately does NOT stop on refusal, on either client.
  seekReanchorMismatch rejects any response whose session id differs from
  the active attempt, so a re-anchor always reuses the base session — the
  id names the session still playing, and refusal normally means a newer
  seek was queued that needs it as its base.
- Guard TV's onCleared teardown with a non-null session id. A null
  expectedSessionId disables the ownership guard entirely, and that
  callback is delayed behind subtitle settlement.
- Release abandoned sessions on the manager's own scope rather than
  viewModelScope.launch(NonCancellable), which severs structured
  concurrency to produce a coroutine nothing can await or observe.

Reviewed file-by-file with Codex over seven passes; two of these defects
were introduced by earlier attempts at the others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Follow-up to 104aacc, correcting a regression that commit introduced and
two claims its message made that the code did not support.

PlaybackSessionLifecycle
- 104aacc made restoreActiveSessionSnapshot assign lastAdoptedSessionId
  "totally", deriving it from the restored state. That fixed one end and
  broke the other. SessionState carries a session id only while Active,
  but Reconnecting and Failed deliberately keep owning theirs — that is
  the whole reason the token exists separately from the state. Deriving
  therefore erased ownership for exactly the states meant to survive an
  outage: a rollback restoring a predecessor as Reconnecting cleared its
  token, and stop() then found no id to name, leaving that transcode to
  run until the server expired it.
  The snapshot now captures the token alongside the state and restores
  what it captured, which is correct at both ends.

PlaybackSessionManager
- Re-trim the orphan ledger when the drain path clears its in-flight
  marker, not only when a queued release completes. Trimming skips
  in-flight ids, so whichever path clears the last marker has to re-apply
  the cap; otherwise a burst drained from the orphan path left the ledger
  over its bound until unrelated future activity happened to trim.
- Queue a release before registering it as an orphan. Registration trims,
  and trimming protects only ids already queued or in flight, so with a
  full ledger of protected entries the session being queued could be the
  single evictable entry and be dropped before its stop had begun.

Corrections to 104aacc's message, which overclaimed:
- "restore the ownership token totally" was wrong for the owning
  non-Active states, as above.
- "so two paths cannot stop one session concurrently" was false. The
  count keeps the in-flight marker honest across an overlap it explicitly
  supports; it does not prevent the second request. That is tolerable
  only because stopping an already-stopped session is harmless, and the
  comment now says so.

Found by an eighth Codex pass over the committed work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
reportPosition wrote process-global fields, and the 10s reporter loop
pairs those with whichever session is Active when it next fires. A final
callback from an outgoing player — arriving after the next screen has
adopted — therefore flushed the previous episode's position under the new
episode's id. That is the "resume jumped to the last episode's time"
report.

The caller now names the session it believes produced the sample, and the
lifecycle drops anything that does not match the session it owns.

null means "I own no session", NOT "skip the check". That distinction is
the fix: both exit paths clear the UI session id while player callbacks
are still draining, so treating null as permission would have left the
original corruption path wide open. A caller without a session may write
only while the lifecycle owns none either — which is exactly downloaded
and local playback, whose resume position is persisted separately and
does not depend on this path.

Making the parameter required broke eleven test call sites, which is the
useful kind of breakage: those tests were reporting without saying what
they owned. They now go through a helper that names the owned session,
the same way the players do.

Verified against outage recovery (ownership survives Reconnecting and
Failed, so samples keep landing), the adoption-to-publication gap, the
exit window, cast, and Watch Together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Being installed as the active attempt is not the same as being findable.
In the legacy-engine ReplanRequired branch the lease was released the
moment the session became the active attempt, but nothing outside this
call has the id at that point, and the nested replan then suspends twice
— finishContentReset, then its own mutex — before reaching any cleanup of
its own. A cancellation in that window left the session installed,
unknown to every caller, and running until the server expired it.

The lease now stays armed across that call, and the branches after it
decide its fate: abandoned ids keep the lease until their stop is
acknowledged, and a successful publication clears it because the manager
owns the outcome. That last branch is new and load-bearing — without it,
holding the lease longer would have made the finally stop a session that
is playing, which is worse than the leak it closes.

Also narrows the trim comment: it protects ids the release queue knows
about, not every stop in flight, since committed cleanup and the direct
retaining-stop helpers issue unmarked ones.

Found by Codex reviewing the rebased series against current main — the
same rebase-time review that has caught the real defects all day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@RXWatcher, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bbf1123-3172-41a8-a27f-b604d9cde734

📥 Commits

Reviewing files that changed from the base of the PR and between af64943 and 3803dc5.

📒 Files selected for processing (1)
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
📝 Walkthrough

Walkthrough

Playback session ownership now survives lifecycle snapshots, rejects stale position callbacks, and coordinates session cleanup through leases, release claims, bounded orphan tracking, and session-qualified teardown across mobile and TV players.

Changes

Playback session ownership

Layer / File(s) Summary
Lifecycle ownership contract
android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt, android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt
Snapshots restore the explicit adopted session ID. reportPosition requires and validates an expected session ID. Lifecycle tests use ownership-aware reporting.
Lease and orphan release coordination
android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt, android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt
Session leases remain active through cancellation and replanning. Cleanup uses release claims, bounded orphan tracking, retry handling, and typed missing-session discharge.
Mobile adoption and teardown
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt, androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt
Recovery and adoption paths qualify session ownership. Exit and clear cleanup use retained session tokens and PlaybackTeardownGate. Position reports include the expected session ID.
TV adoption and teardown
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt
The TV player records ownership before publication, restores it on rollback, qualifies recovery and seek adoption, and uses the retained token for teardown and position reporting.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related PRs

Suggested reviewers: quick104

Sequence Diagram(s)

sequenceDiagram
  participant PlayerViewModel
  participant PlaybackSessionLifecycle
  participant PlaybackSessionManager
  participant PlaybackTeardownGate
  participant PlaybackSessionRepository

  PlayerViewModel->>PlaybackSessionLifecycle: adopt replacement session
  PlaybackSessionLifecycle-->>PlayerViewModel: accept or reject ownership
  PlayerViewModel->>PlaybackTeardownGate: request session-qualified teardown
  PlaybackTeardownGate->>PlaybackSessionManager: stop retained session
  PlaybackSessionManager->>PlaybackSessionRepository: stop or retry session
  PlaybackSessionRepository-->>PlaybackSessionManager: success or missing-session response
  PlaybackSessionManager-->>PlaybackTeardownGate: discharge or retain orphan
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving playback session ownership across cancellation on both clients.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt (2)

1184-1188: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log the failure and let CancellationException propagate.

runCatching discards every Throwable, including CancellationException. Two consequences follow.

A failed abandonment leaves the session running until the server expires it, and nothing records that it happened. stopAsync in PlaybackSessionLifecycle logs the equivalent failure with Log.w, and scheduleRegisteredCommittedSessionCleanup handles cancellation with an explicit catch. This path is the only one that stays silent.

Swallowing CancellationException also makes a cancelled job complete normally. sessionCleanupScope is long-lived in production, so this matters mainly when a test injects a structured scope.

♻️ Proposed change
     fun abandonActiveVideoSessionAsync(sessionId: String) {
         sessionCleanupScope.launch {
-            runCatching { abandonActiveVideoSessionIfCurrent(sessionId) }
+            try {
+                abandonActiveVideoSessionIfCurrent(sessionId)
+            } catch (cancellation: CancellationException) {
+                throw cancellation
+            } catch (t: Throwable) {
+                Log.w(TAG, "async abandon failed for $sessionId", t)
+            }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt`
around lines 1184 - 1188, Update abandonActiveVideoSessionAsync to replace
runCatching with explicit exception handling around
abandonActiveVideoSessionIfCurrent: log non-cancellation failures with Log.w
using the same context as PlaybackSessionLifecycle.stopAsync, and rethrow
CancellationException so cancellation propagates through sessionCleanupScope.

1594-1606: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename these helpers to the Locked convention and drop the vestigial suspend modifiers.

Line 1605 and Line 1624 now call stopRetainingFailureLocked, which is not a suspending function. After this change neither helper performs suspending work, so the suspend modifiers no longer describe them. stopCandidateSessionsIfUnowned inherits the same state.

The naming matters more. This file uses a Locked suffix to mark functions that require videoAttemptMutex. All three helpers now require it: they call stopRetainingFailureLocked and they read stagedVideoReplans and activeVideoAttempt. Every current caller holds the mutex, so the code is correct today. The names no longer signal the requirement, and pendingOwnershipReleases and orphanedSessionIds are plain collections that a future unlocked caller would corrupt.

♻️ Proposed rename
-    private suspend fun stopCandidateSessionIfUnowned(
+    private fun stopCandidateSessionIfUnownedLocked(
         activeSessionId: String?,
         candidateSessionId: String?,
     ) {
-    private suspend fun stopCandidateSessionsIfUnowned(
+    private fun stopCandidateSessionsIfUnownedLocked(
         activeSessionId: String?,
         vararg candidateSessionIds: String?,
     ) {
         candidateSessionIds.filterNotNull().distinct()
-            .forEach { stopCandidateSessionIfUnowned(activeSessionId, it) }
+            .forEach { stopCandidateSessionIfUnownedLocked(activeSessionId, it) }
     }
-    private suspend fun stopImmediateFailureSessions(
+    private fun stopImmediateFailureSessionsLocked(
         activeSessionId: String,
         candidateSessionIds: List<String?>,
         stopActiveSession: Boolean,
     ) {
         if (stopActiveSession) {
             stopRetainingFailureLocked(activeSessionId)
         }
         candidateSessionIds.filterNotNull().distinct()
             .filter { it != activeSessionId }
-            .forEach { stopCandidateSessionIfUnowned(activeSessionId = null, it) }
+            .forEach { stopCandidateSessionIfUnownedLocked(activeSessionId = null, it) }
     }

Update the call sites at Lines 871, 895, 960, 975, 990, 1000, 1011, 1026, and 1069 accordingly.

Also applies to: 1616-1629

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt`
around lines 1594 - 1606, Rename stopCandidateSessionIfUnowned and
stopCandidateSessionsIfUnowned to their Locked-suffixed forms, and apply the
same Locked naming convention to the third related helper covering the lines
around 1616-1629. Remove suspend from all three helpers, then update every
listed call site and any other references to use the new names while preserving
their existing mutex-protected behavior.
android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt (1)

1025-1041: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add one focused test for the rejection path of reportPosition.

reportOwnedPosition only exercises the accept path. Every changed call site reports while the state is Active, so no test proves that a foreign session ID is rejected. That guard is the new behavior of this layer, and a regression would restore the cross-episode progress write.

Two cases are worth pinning:

  • A sample carrying a stale session ID does not change the reported position.
  • A sample carrying null is rejected while the lifecycle still owns a session.

Note also that the helper derives the ID from SessionState.Active only. While the lifecycle is Reconnecting or Failed it passes null, which production rejects because the token survives those states. No current test calls it in those states.

As per coding guidelines: "Add focused tests for shared logic only when behavior is critical or high risk".

💚 Proposed test for the rejection path
`@Test`
fun `reportPosition ignores samples from a session the lifecycle does not own`() = runTest {
    val sessionMgr = FakeSessionManager()
    val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope)

    lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-current"))
    lifecycle.reportOwnedPosition(positionSec = 20.0, durationSec = 100.0, isPaused = false)

    // A late callback from the outgoing player, and a caller claiming no session.
    lifecycle.reportPosition(90.0, 100.0, isPaused = false, expectedSessionId = "sess-previous")
    lifecycle.reportPosition(95.0, 100.0, isPaused = false, expectedSessionId = null)

    advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100)

    assertEquals("sess-current", sessionMgr.lastProgressSessionId)
    assertEquals(20.0, sessionMgr.lastProgressPosition)

    lifecycle.stop()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt`
around lines 1025 - 1041, Add a focused test near the existing
PlaybackSessionLifecycle tests that adopts an active session, records an
accepted position through reportOwnedPosition, then calls reportPosition with a
stale session ID and with null. Advance past the progress reporting interval and
assert the session manager still contains the owned session ID and original
position, then stop the lifecycle.

Source: Coding guidelines

androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt (1)

2859-2861: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Record the id of the adopted ready.session.

ready.session.sessionId is the id passed to sessionLifecycle.adoptActiveSessionIfCurrent; it matches the session later assigned to UI state. Use that id here instead of nullable playback.sessionId so retainedOwnedSessionId cannot keep the predecessor’s name while the lifecycle owns the replaced session.

♻️ Proposed change
         if (lifecycleAdopted) {
-            playback.sessionId?.let { retainedOwnedSessionId = it }
+            retainedOwnedSessionId = ready.session.sessionId
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt`
around lines 2859 - 2861, Update the lifecycleAdopted branch in the relevant
PlayerViewModel flow to assign retainedOwnedSessionId from the adopted
ready.session.sessionId value passed to
sessionLifecycle.adoptActiveSessionIfCurrent, rather than nullable
playback.sessionId; preserve the existing behavior when adoption does not occur.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt`:
- Around line 4799-4804: Resolve the teardown session ID inside the
post-settlement callback in invalidateAndSettleAsync, using the current
lastAdoptedSessionId after rollback rather than the onCleared() snapshot
teardownSessionId. Pass that resolved ID to lifecycleTeardown.stopDetached only
when it is non-null, so rolled-back subtitle adoption tears down the predecessor
session and preserves the ownership guard.

---

Nitpick comments:
In
`@android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt`:
- Around line 1184-1188: Update abandonActiveVideoSessionAsync to replace
runCatching with explicit exception handling around
abandonActiveVideoSessionIfCurrent: log non-cancellation failures with Log.w
using the same context as PlaybackSessionLifecycle.stopAsync, and rethrow
CancellationException so cancellation propagates through sessionCleanupScope.
- Around line 1594-1606: Rename stopCandidateSessionIfUnowned and
stopCandidateSessionsIfUnowned to their Locked-suffixed forms, and apply the
same Locked naming convention to the third related helper covering the lines
around 1616-1629. Remove suspend from all three helpers, then update every
listed call site and any other references to use the new names while preserving
their existing mutex-protected behavior.

In
`@android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt`:
- Around line 1025-1041: Add a focused test near the existing
PlaybackSessionLifecycle tests that adopts an active session, records an
accepted position through reportOwnedPosition, then calls reportPosition with a
stale session ID and with null. Advance past the progress reporting interval and
assert the session manager still contains the owned session ID and original
position, then stop the lifecycle.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt`:
- Around line 2859-2861: Update the lifecycleAdopted branch in the relevant
PlayerViewModel flow to assign retainedOwnedSessionId from the adopted
ready.session.sessionId value passed to
sessionLifecycle.adoptActiveSessionIfCurrent, rather than nullable
playback.sessionId; preserve the existing behavior when adoption does not occur.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a92c8544-395d-41cd-bd6b-1d54dafdd713

📥 Commits

Reviewing files that changed from the base of the PR and between ad6e9bb and af64943.

📒 Files selected for processing (7)
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycle.kt
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackSessionManager.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionLifecycleTest.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackSessionManagerStagedReplanTest.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModel.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt

onCleared captured exitSessionId before invalidateAndSettleAsync, whose
callback runs once settlement completes. Settlement can roll a subtitle
publication back, and that rollback hands ownership to the predecessor —
so the captured value named the discarded replacement, the ownership
guard refused it, and the one-shot gate was already consumed. The
predecessor's session was left running until the server expired it.

Reading the token inside the callback gets the owner as it stands after
settlement, which is the only point at which it is settled.

This is the read-site counterpart to the rollback fix in this series: the
token moves back on rollback, so anything snapshotted before settlement
is stale by construction.

Found by CodeRabbit on Silo-Server#178.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
@RXWatcher

Copy link
Copy Markdown
Contributor Author

Good catch — fixed.

You're right that the capture is stale by construction: settlement can roll a subtitle publication back, and that rollback returns ownership to the predecessor, so a value read before the callback names the discarded replacement. The guard then refuses it while the one-shot gate has already been consumed, and the predecessor's session runs on until the server expires it.

exitSessionId is now read inside the callback. Kept the null guard as well — a screen that never owned a session has nothing to tear down, and an unqualified stop would disable the ownership check entirely.

This is the read-site counterpart to a fix already in this series: the token is moved back to the predecessor on rollback, so anything snapshotted before settlement was going to be wrong.

Full suite green on all four modules.

@RXWatcher
RXWatcher merged commit b52eb87 into Silo-Server:main Aug 6, 2026
2 checks passed
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.

1 participant