Skip to content

fix(bot): music voice reconnect races — stale-song revert + stacked join/leave - #1950

Closed
LucasSantana-Dev wants to merge 4 commits into
mainfrom
fix/1948-1949-music-voice-reconnect-races
Closed

LucasSantana-Dev wants to merge 4 commits into
mainfrom
fix/1948-1949-music-voice-reconnect-races

Conversation

@LucasSantana-Dev

@LucasSantana-Dev LucasSantana-Dev commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Guard the connection event's session-snapshot restore against an active intentional-stop (kick) flag, so a stale pre-kick track can't override a freshly issued /play.
  • Add a per-guild in-flight recovery lock shared by checkAndRecover and the orphan-session monitor, so a flaky connection can't trigger both reconnect paths at once and produce stacked join/leave cycles.

Found via a multi-agent deep-debug session tracing two user-reported symptoms.

Closes #1948
Closes #1949

Test plan

  • npx jest src/utils/music/watchdog.spec.ts src/handlers/player/lifecycleHandlers.spec.ts — 29 passed
  • Full monorepo type:check (ran via pre-commit hook) — clean
  • Manual repro in a test guild: kick bot mid-song, /play a different song, confirm it doesn't revert
  • Manual repro: force a flaky voice connection, confirm no stacked join/leave

Summary by cubic

Prevents stale tracks from overriding a new /play after a kick and stops duplicate voice recoveries that caused repeated join/leave loops.

  • Bug Fixes
    • Guard snapshot restore on connection when an intentional stop (kick) is active, and add a per‑guild recovery lock shared by checkAndRecover and the orphan monitor to stop stacked join/leave; tests cover both fixes and verify the lock releases to avoid stuck recoveries.

Written for commit 3be30e9. Summary will update on new commits.

Review in cubic

The connection event handler restored the guild's saved music
snapshot unconditionally, including right after a kick was flagged
as an intentional stop. That let a stale snapshot (the pre-kick
track) get replayed a few seconds into a freshly issued /play,
reverting playback to the old song.

Fixes #1948
checkAndRecover (armed on a 25s timer, and called directly from the
disconnect event) and the orphan session monitor (a separate 60s
interval) could both act on the same guild at once with no
coordination. On a flaky connection this produced multiple stacked
rejoin/connect cycles, visible as the bot repeatedly joining and
leaving the voice channel.

Adds a per-guild in-flight lock both paths check before acting.

Fixes #1949
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 5 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 609ceba1-7bef-47bf-93d2-c4a11d9d61b8

📥 Commits

Reviewing files that changed from the base of the PR and between 03a0444 and 3be30e9.

📒 Files selected for processing (4)
  • packages/bot/src/handlers/player/lifecycleHandlers.spec.ts
  • packages/bot/src/handlers/player/lifecycleHandlers.ts
  • packages/bot/src/utils/music/watchdog.spec.ts
  • packages/bot/src/utils/music/watchdog.ts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Failed to generate code suggestions for PR

Three new cases, each verified to fail against the pre-fix source
before the fix commits landed:
- connection handler skips restoreSnapshot when isIntentionalStop
  is true (#1948)
- checkAndRecover called twice concurrently for the same guild only
  drives one recovery (#1949)
- recoverOrphanSession skips a guild already locked by an in-flight
  checkAndRecover (#1949)

@graphify-labs graphify-labs 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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR adds concurrency guarding to the MusicWatchdogService. It introduces a recoveringGuilds set used in both checkAndRecover and recoverOrphanSession to prevent those two recovery paths from running simultaneously for the same guild, with early-return/no-op behavior when a recovery is already in progress. The orphan-recovery logic is also restructured into a try/finally block so the guild is cleared from the set afterward. Separately, the lifecycle handler now checks musicWatchdogService.isIntentionalStop(...) as an additional condition before triggering session restore. There are also minor formatting-only changes in the constructor. Note: I'm describing intent and surface area only, not asserting correctness.

Worth a look

  • recoveringGuilds not cleared on early return before try block in checkAndRecover — packages/bot/src/utils/music/watchdog.ts:160 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 43 functions depend on the 27 functions this change touches.

Health — grade A; 6 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):

  • setupTrackHandlers() — 4 callers, 6 callees (medium)
  • handlePlayerSkip() — 2 callers, 11 callees (medium)
  • setupLifecycleHandlers() — 3 callers, 7 callees (medium)
  • handlePlayerStart() — 2 callers, 9 callees (medium)
  • handlePlayerFinish() — 2 callers, 8 callees (medium)
  • handleQueueExhaustion() — 3 callers, 5 callees (medium)

Verification — 43 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 41 function(s) in the blast radius were not formally verified this run

@graphify-labs graphify-labs 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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR modifies the music watchdog service and player lifecycle handlers to address concurrent/duplicate recovery scenarios (referencing issues #1948 and #1949). It adds a recoveringGuilds lock set shared between checkAndRecover and recoverOrphanSession to prevent both paths from running for the same guild at once, and it wraps the orphan-session recovery logic in a try/finally to release that lock. It also updates the connection lifecycle handler to skip snapshot restoration when the guild is flagged as an intentional stop. Surface area includes the watchdog service internals, the lifecycle connection handler, and corresponding new/updated test cases and mocks.

No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 60 functions depend on the 44 functions this change touches.

Health — grade A; 6 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):

  • setupTrackHandlers() — 4 callers, 6 callees (medium)
  • handlePlayerSkip() — 2 callers, 11 callees (medium)
  • setupLifecycleHandlers() — 3 callers, 7 callees (medium)
  • handlePlayerStart() — 2 callers, 9 callees (medium)
  • handlePlayerFinish() — 2 callers, 8 callees (medium)
  • handleQueueExhaustion() — 3 callers, 5 callees (medium)

Verification — 60 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 58 function(s) in the blast radius were not formally verified this run

cubic-dev-ai[bot]
cubic-dev-ai Bot previously approved these changes Aug 5, 2026

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 2 files (changes from recent commits).

Auto-approved: Fixes race conditions with a stale snapshot restore guard and a per-guild recovery lock. Both changes are defensive, test-covered, and preserve existing behavior.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai 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.

1 issue found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/bot/src/handlers/player/lifecycleHandlers.ts">

<violation number="1" location="packages/bot/src/handlers/player/lifecycleHandlers.ts:52">
P1: A kick that happens after this one-time check can still let the in-flight snapshot restore enqueue the stale song after `/play` has queued a replacement. The handler starts `restoreSnapshot()` immediately after this condition and only aborts its controller when the 2-second deadline wins; `markIntentionalStop()` does not cancel that controller, and the restore loop only rechecks its abort signal before `queue.addTrack()`. Passing a stop-linked cancellation signal (and/or rechecking the intentional-stop generation before each add, with rollback) would make the kick guard cover the async restore window.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if (ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED) {
if (
ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED &&
!musicWatchdogService.isIntentionalStop(queue.guild.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A kick that happens after this one-time check can still let the in-flight snapshot restore enqueue the stale song after /play has queued a replacement. The handler starts restoreSnapshot() immediately after this condition and only aborts its controller when the 2-second deadline wins; markIntentionalStop() does not cancel that controller, and the restore loop only rechecks its abort signal before queue.addTrack(). Passing a stop-linked cancellation signal (and/or rechecking the intentional-stop generation before each add, with rollback) would make the kick guard cover the async restore window.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bot/src/handlers/player/lifecycleHandlers.ts, line 52:

<comment>A kick that happens after this one-time check can still let the in-flight snapshot restore enqueue the stale song after `/play` has queued a replacement. The handler starts `restoreSnapshot()` immediately after this condition and only aborts its controller when the 2-second deadline wins; `markIntentionalStop()` does not cancel that controller, and the restore loop only rechecks its abort signal before `queue.addTrack()`. Passing a stop-linked cancellation signal (and/or rechecking the intentional-stop generation before each add, with rollback) would make the kick guard cover the async restore window.</comment>

<file context>
@@ -47,7 +47,10 @@ export const setupLifecycleHandlers = (player: {
-        if (ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED) {
+        if (
+            ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED &&
+            !musicWatchdogService.isIntentionalStop(queue.guild.id)
+        ) {
             const metadata = queue.metadata as QueueMetadata | undefined
</file context>

@cubic-dev-ai
cubic-dev-ai Bot dismissed their stale review August 5, 2026 20:41

Dismissed because Cubic found issues in a newer review.

Mutation testing (Stryker, --mutate on watchdog.ts +
lifecycleHandlers.ts) found the finally-block lock release in both
checkAndRecover and recoverOrphanSession had no coverage proving it
actually runs. If the lock never released, a guild would be
permanently stuck unable to recover after the first attempt.

Two new tests call the recovery path twice sequentially and assert
the second call is not blocked. Both target mutants now killed
(confirmed via reports/mutation/mutation.html).

Overall file scores (watchdog.ts 47.9%, lifecycleHandlers.ts 59.3%)
are below the repo's mutation-gate threshold — the remaining
survivors are on pre-existing logging/env-parsing code, out of scope
for this PR. Not added to stryker.conf.json's gated mutate array.
@github-actions github-actions Bot added size/l and removed size/m labels Aug 5, 2026
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai 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.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@graphify-labs graphify-labs 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.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR adds a per-guild recovery lock (recoveringGuilds set) to MusicWatchdogService, guarding both checkAndRecover and the orphan session recovery path so they don't run concurrently for the same guild, with the lock released in a finally block. It also updates setupLifecycleHandlers to skip session snapshot restoration when the guild is flagged as an intentional stop. New tests cover the intentional-stop restore skip, concurrent/overlapping recovery behavior, lock release after completion, and cross-path (checkAndRecover vs orphan monitor) contention. There's also a minor formatting change to the constructor's timeoutMs initialization.

No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 60 functions depend on the 44 functions this change touches.

Health — grade A; 6 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):

  • setupTrackHandlers() — 4 callers, 6 callees (medium)
  • handlePlayerSkip() — 2 callers, 11 callees (medium)
  • setupLifecycleHandlers() — 3 callers, 7 callees (medium)
  • handlePlayerStart() — 2 callers, 9 callees (medium)
  • handlePlayerFinish() — 2 callers, 8 callees (medium)
  • handleQueueExhaustion() — 3 callers, 5 callees (medium)

Verification — 60 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 58 function(s) in the blast radius were not formally verified this run

LucasSantana-Dev added a commit that referenced this pull request Aug 10, 2026
PR #1950 (open since 2026-08-05) already fixes #1948 and #1949 with an
equivalent implementation. Adopted its version of lifecycleHandlers.ts
and watchdog.ts wholesale instead of shipping a competing duplicate, and
kept only the emptyQueue suppression guard on top, which #1950 does not
touch. Once #1950 merges, this PR's diff on those two files should be
empty and only the clear.ts + emptyQueue fix for #1957 remains.
LucasSantana-Dev added a commit that referenced this pull request Aug 10, 2026
)

## Summary

Fixes #1959 — the required Security check has failed on every PR since
2026-08-05 (5+ days), regardless of what's changed.

**Root cause:** `undici` was override-pinned to exactly `7.28.0`, itself
inside the current advisory range (`7.0.0-7.28.0`, fixed at `7.29.0`);
`ip-address` had no ceiling clearing its vulnerable range (`<=10.3.0`).
Both predate this PR, confirmed via `git stash` + `node
scripts/audit-gate.mjs` against clean `main`.

**Fix:**
- Bumped `undici` to `^7.29.0` and `ip-address` to `>=10.3.1` in root
`overrides`.
- `packages/frontend` had its own direct `undici@7.28.0` devDependency,
independent of the root override — bumped it too. (Root cause of a
detour: a bare `overrides` entry alone doesn't reliably force-resolve a
hoisted version when a workspace has a conflicting direct pin in this
repo — same shape as the documented piscina fix in 10b68e6. The direct
pin wins; bump it in place rather than fighting the override.)
- The `react-router` advisory (previously accepted with an exit
condition tied to a v8 migration, #1878) cleared on its own during the
lockfile regen — `react-router-dom` now resolves to `7.18.2`, past the
vulnerable range, via ordinary semver resolution. Removed the now-stale
`ACCEPTED` entries per the gate's own hygiene enforcement, closed #1878
as moot.

**A detour worth flagging:** mid-fix I hit what looked like
nondeterministic package resolution (a `busboy` module-not-found, then
an unrelated `jsdom` one) even across repeated clean reinstalls. Root
cause was local npm cache corruption (`npm cache verify` reported
garbage-collecting 94 stale entries) — unrelated to this fix, resolved
with `npm cache clean --force` + reinstall. Mentioning in case it recurs
for someone else.

## Test plan
- [x] `npm ci` (exact lockfile install, no re-resolution — same install
mode as this repo's own CI) succeeds clean
- [x] `node scripts/audit-gate.mjs` — zero findings
- [x] `npx tsc --noEmit` clean across all 4 workspaces
- [x] Full test suite green: shared 1404, bot 3212 (+1 skipped), backend
1352, frontend 1038 — 8006 tests total
- [x] `npm run lint` — no new findings (pre-existing warnings in
`.agents/browser-automation/*.js` untouched)

## Follow-up
Once this merges, #1950 and #1958 (both currently blocked on this same
check) should pass once rebased.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Unblocks the failing Security check by bumping vulnerable packages and
removing stale audit-gate overrides. Fixes #1959.

- **Dependencies**
- Override `undici` to `^7.29.0`; bump frontend devDependency to
`^7.29.0`.
  - Set `ip-address` to `>=10.3.1`.
  - Add `fast-uri` override `^3.1.5` (cap to 3.x to match `ajv`).
- Bump frontend vendor-state gzip limit to 26.5 KB after `axios` minor
update increased bundle size slightly.

- **Bug Fixes**
- Clear `ACCEPTED` entries for `react-router`/`react-router-dom` now
that resolved versions are past advisories.
- Root cause was `undici@7.28.0` and missing `ip-address` ceiling
keeping installs in vulnerable ranges.
  - Security gate now passes on `npm ci`; CI is unblocked.

<sup>Written for commit 153c909.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/LucasSantana-Dev/Lucky/pull/1960?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
@LucasSantana-Dev

Copy link
Copy Markdown
Owner Author

Superseded by #1998 (merged), which already ships the per-guild recovery lock (with stale-lock TTL, actually more robust than this branch's plain Set) plus the isIntentionalStop/isReplenishSuppressed guards this PR proposes. Closing as redundant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant