Skip to content

fix: destroy AudioContext on resume timeout for iOS recovery#16

Merged
idvorkin merged 4 commits into
idvorkin:mainfrom
idvorkin-ai-tools:fix/destroy-context-on-resume-timeout
Dec 15, 2025
Merged

fix: destroy AudioContext on resume timeout for iOS recovery#16
idvorkin merged 4 commits into
idvorkin:mainfrom
idvorkin-ai-tools:fix/destroy-context-on-resume-timeout

Conversation

@idvorkin-ai-tools

@idvorkin-ai-tools idvorkin-ai-tools commented Dec 15, 2025

Copy link
Copy Markdown

Summary

  • Key fix: Destroy and recreate AudioContext on ANY resume failure (timeout or error)
  • Previously only destroyed on specific error messages like "Failed to start audio device"
  • iOS Safari can get stuck where resume() times out but context stays suspended forever
  • Now: timeout → destroy context → next gesture gets fresh context

Changes

  • Extract attemptResume() DRY helper (was copy-pasted 6 times)
  • Add statechange listener from Howler.js PR #1770
  • Always destroyContext() on resume failure
  • Reduce code by 66 lines through refactoring
  • Fix test mocks (addEventListener, createBuffer)
  • Add global canvas mock to suppress jsdom warning

Test plan

  • All 42 tests pass
  • Build succeeds
  • Test on iOS Safari - tap Test Sound, background app, return, verify audio recovers

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved audio context state management and resumption handling for more reliable playback.
  • Chores

    • Updated test infrastructure and mocking mechanisms.
    • Added diagnostic recording helpers for session analytics.

✏️ Tip: You can customize this high-level summary in your review settings.

iOS Safari can get stuck in a state where resume() times out but the
context stays suspended. Previously we only destroyed the context on
specific error messages - now we destroy on ANY resume failure.

Changes:
- Extract attemptResume() DRY helper for all resume paths
- Always destroyContext() on resume failure (not just specific errors)
- Add statechange listener (from Howler.js PR #1770)
- Reduce code by 66 lines through refactoring
- Fix test mock to include addEventListener, createBuffer
- Add global canvas mock to suppress jsdom warning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Dec 15, 2025

Copy link
Copy Markdown

Warning

Rate limit exceeded

@idvorkin-ai-tools has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 2 minutes and 50 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 69ae2cd and e6a0835.

📒 Files selected for processing (2)
  • src/App.test.tsx (1 hunks)
  • src/services/audioService.ts (1 hunks)

Walkthrough

The pull request refactors AudioContext resume logic into a centralized attemptResume() method with state change listeners, introduces new recording helper exports for session analytics, updates test scaffolding to mock AudioContext event listeners and buffer methods, and changes test expectations to reflect context destruction behavior.

Changes

Cohort / File(s) Summary
Core resume logic refactoring
src/services/audioService.ts
Consolidates scattered resume logic into attemptResume(trigger) and setupStateChangeListener() methods; routes visibility, gesture, playBeep, and testSound paths through centralized flow; adds RESUME_TIMEOUT_MS constant; changes unlockPromise type from Promise<void> to Promise<boolean>; introduces audioUnlocked state flag for tracking successful unlocks
Session recording API
src/services/audioService.ts
Adds five new exported functions for recording audio lifecycle events: recordAudioPlaySkipped, recordAudioResuming, recordAudioResumed, recordAudioResumeFailed, and recordAudioError
Test scaffolding updates
src/services/audioService.test.ts
Extends MockAudioContext with event listener tracking map and addEventListener mock; adds createBuffer/createBufferSource mocks for silent buffer warmup; updates "testSound timeout" test case expectation from timeout message to destroyed state message
jsdom setup
src/test/setup.ts
Adds global mock for HTMLCanvasElement.getContext to suppress jsdom WebGL warnings

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Primary complexity drivers:
    • New centralized attemptResume() logic handling multiple AudioContext states (running, suspended, interrupted) with timeout and context destruction on failure
    • State change listener setup introduces event-driven control flow requiring verification of listener registration and cleanup
    • Type signature change (unlockPromise) and new audioUnlocked flag with semantic usage across state transitions need careful validation
    • New public recording API surface expands the module's interface and may require tracing usage patterns to consumers
    • Test expectations modified; verify that changed assertion (timeout → destroyed state) reflects intended behavior accurately

Possibly related PRs

Poem

🐰 Hops and bounds through code so tangled,
Resume flows now cleanly angled,
State listeners wake the sleeping sound,
Recording breadcrumbs all around!
No more duplication here,
Just harmony, loud and clear. 🎵

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: destroy AudioContext on resume timeout for iOS recovery' directly aligns with the main objective of the PR, which is to destroy and recreate the AudioContext on resume failure to address iOS Safari issues.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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 and usage tips.

@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: 0

🧹 Nitpick comments (1)
src/services/audioService.test.ts (1)

14-14: Consider exposing a method to emit events for more thorough testing.

The private listeners Map tracks event handlers but there's no mechanism to trigger them in tests. This could be useful for testing the statechange listener behavior.

 class MockAudioContext {
 	state: "suspended" | "running" | "closed" = "suspended";
 	currentTime = 0;
 	destination = {};
 	private listeners: Map<string, Function[]> = new Map();
+
+	// Helper to simulate events in tests
+	emitEvent(event: string): void {
+		this.listeners.get(event)?.forEach(handler => handler());
+	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7dd9c62 and 69ae2cd.

📒 Files selected for processing (3)
  • src/services/audioService.test.ts (5 hunks)
  • src/services/audioService.ts (9 hunks)
  • src/test/setup.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
src/services/audioService.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Global AudioContext and iOS unlock mechanisms should be implemented in src/services/audioService.ts

Files:

  • src/services/audioService.ts
🧠 Learnings (5)
📓 Common learnings
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Refer to tech/ios-audio-workaround.md for iOS Safari audio implementation details including AudioContext states, unlock mechanisms, timeout handling, and known quirks
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Applies to src/services/audioService.ts : Global AudioContext and iOS unlock mechanisms should be implemented in `src/services/audioService.ts`
📚 Learning: 2025-12-15T00:48:06.238Z
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Applies to src/services/audioService.ts : Global AudioContext and iOS unlock mechanisms should be implemented in `src/services/audioService.ts`

Applied to files:

  • src/services/audioService.ts
  • src/test/setup.ts
  • src/services/audioService.test.ts
📚 Learning: 2025-12-15T00:48:06.238Z
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Refer to tech/ios-audio-workaround.md for iOS Safari audio implementation details including AudioContext states, unlock mechanisms, timeout handling, and known quirks

Applied to files:

  • src/services/audioService.ts
  • src/services/audioService.test.ts
📚 Learning: 2025-12-15T00:48:06.238Z
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Applies to src/services/pwaDebugServices.ts : Session recording and PWA debug functionality should be implemented in `src/services/pwaDebugServices.ts`

Applied to files:

  • src/services/audioService.ts
📚 Learning: 2025-12-15T00:48:06.238Z
Learnt from: CR
Repo: idvorkin/igor-timer PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-15T00:48:06.238Z
Learning: Session recordings should capture user clicks/interactions, audio state changes, console errors, and environment info (browser, device)

Applied to files:

  • src/services/audioService.ts
🔇 Additional comments (12)
src/test/setup.ts (1)

3-4: LGTM!

Simple and effective mock to suppress jsdom WebGL warnings. Returning null correctly simulates an unavailable rendering context.

src/services/audioService.test.ts (3)

26-31: LGTM!

Clean implementation of event listener tracking that properly handles multiple handlers per event type.


48-55: LGTM!

Minimal but sufficient mocks for the silent buffer warmup functionality. Correctly provides the required interface for createBuffer and createBufferSource.


202-220: LGTM!

Test correctly updated to reflect the new behavior where context destruction occurs on resume timeout. The assertion on result.error containing "destroyed" aligns with the testSound() implementation returning "Context state: destroyed".

src/services/audioService.ts (8)

41-42: LGTM!

Good choice to extract the timeout value as a named constant. 3 seconds is a reasonable timeout for iOS Safari resume operations.


85-108: LGTM!

Clean extraction of session recording helpers. Aligns with the coding guidelines about capturing audio state changes in session recordings. Based on learnings, this follows the pattern of recording user interactions and audio state changes.


159-202: Well-structured centralized resume logic.

The attemptResume() method cleanly consolidates the resume flow with proper state checks and error handling. The destruction on failure aligns with the PR objective for iOS recovery.

One observation: playSilentBuffer() is called before resume() at line 186. Per iOS audio workaround patterns, the silent buffer playback typically happens to "warm up" the context, but calling it while still suspended might be a no-op on some platforms. Consider verifying this order matches your iOS testing results.


207-216: LGTM!

Good implementation of the statechange listener pattern from Howler.js PR #1770. The audioUnlocked guard correctly prevents premature resume attempts before user gesture.


221-243: LGTM!

Clean refactoring of visibility and gesture listeners to use the centralized attemptResume() method. The passive: true option on gesture listeners is appropriate for performance.


248-264: LGTM!

Good refactoring of ensureRunning() to use the centralized attemptResume(). The shared promise pattern correctly prevents race conditions, and getContext() on line 263 ensures a fresh context is returned if the previous one was destroyed.


269-297: LGTM!

Clean refactoring of playBeep() to use attemptResume(). The fire-and-forget .then() pattern is appropriate for a non-async method, and the null-safe check on line 289 correctly handles the case where context was destroyed.


343-370: LGTM!

testSound() correctly handles the context destruction case, returning "destroyed" state when the context is null after a failed resume attempt. The diagnostic recording at each stage is helpful for debugging.

AI+idvorkin and others added 2 commits December 15, 2025 01:55
Improvements for code review:

1. **Fixed timer leak**: withTimeout now clears timer on success
2. **Removed dead code**: Exported helper functions were unused
3. **Better naming**: audioUnlocked → hasUnlockedBefore (clearer intent)
4. **Consolidated flags**: 3 listener flags → 1 listenersAttached
5. **DRY helpers**: getErrorMessage() extracts error messages
6. **Clear sections**: Code organized with section headers
7. **Documented design decisions**: Why playBeep skips vs waits
8. **Improved JSDoc**: All public methods documented with examples
9. **Cleaner event types**: Renamed for consistency (test_played → test_success)

Structure:
- Types & Constants (top)
- Helper functions (pure, testable)
- AudioService class with clear sections:
  - Context Lifecycle
  - Resume Logic (core attemptResume method)
  - Event Listeners
  - Playback
  - Public API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@idvorkin idvorkin merged commit 754a77e into idvorkin:main Dec 15, 2025
1 check 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.

2 participants