Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/skills/post-work-checks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ yarn validate:full # --fast + --android + --ios (skips unavailable platform

Equivalent: `./scripts/validate.sh --fast` (etc.)

### iOS unit tests (not covered by any tier)

`validate:ios` only builds the pod. The Objective-C++ XCTest suite in `apps/fabric-example/ios/FabricExampleTests/` (engine, session manager, notification manager, player, recorder) is run by neither CI nor `validate.sh`, so changes under `ios/audioapi/` must be exercised by hand:

```bash
cd apps/fabric-example/ios
xcodebuild test -workspace FabricExample.xcworkspace -scheme FabricExampleTests \
-destination 'platform=iOS Simulator,name=<an installed simulator>' \
-only-testing:FabricExampleTests/AudioEngineTests # omit to run everything
```

Check `xcrun simctl list devices available` first — an unavailable `-destination` makes xcodebuild print the device list and fail in a way that is easy to mistake for a passing run when its output is piped. Run `pod install` if the build reports the sandbox is out of sync; it can also rewrite prebuilt-pod checksums in the tracked `Podfile.lock`, which should not be committed with unrelated work.

Because these tests are never run automatically, they rot. Several test files hand-declare mirror copies of C++ classes (`IOSAudioPlayer` in `AudioPlayerTests.mm`, `IOSAudioRecorder` in `IOSAudioRecorderTests.mm`) to reach protected members; adding a pure virtual to a base class makes those mirrors abstract and breaks compilation of the whole target. Add the matching override to the mirror when changing `CommonPlayer` or `AudioRecorder`.

### Which tier to run

| Changed paths | Minimum validation |
Expand Down
7 changes: 7 additions & 0 deletions .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m

On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`.

**iOS refused restarts (`AudioEngine`):** iOS can reject an engine start that a route or configuration change triggered — typically `'!int'` / `560557684` (`AVAudioSessionErrorCodeCannotInterruptOthers`) while the device is locked or another app holds the session. Two invariants:

- **Never leave `state` at `Running` after a refused start.** `IOSAudioPlayer::isPlaying` and `IOSAudioRecorder` gate on `getState() == Running`, so a state that outlives a dead engine hides the failure from every consumer. `handleRefusedRestart` drops to `Paused`, sets `graphNeedsRebuild` (a refused start can leave the graph without an input node), and marks the restart pending.
- **Restart paths must not call each other.** `startEngine` rebuilds the graph inline rather than delegating to `rebuildAudioEngineAndResumeIfNeeded`, which would call `startEngine` back. That mutual recursion caused #1161/#1167 and was previously only muted by a flag.

Retries are scheduled with `dispatch_after` on the main queue and re-armed by `SystemNotificationManager` on foreground, route change, and interruption end. Because the retry block re-acquires the non-recursive engine mutex, it must be scheduled (never `dispatch_sync`) from under the lock, and each scheduling bumps a generation counter so an already-queued retry recognises itself as stale instead of racing a newer one.

**Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`).

**Graph Channel A producer self-drain:** `Graph::setProducerSelfDrain(true)` makes the JS/main producer drain Channel A after each enqueue. Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend). Before disabling for `start`/`resume`/`renderAudio`, call `processEvents()` once (still as sole consumer) so the bounded channel is empty, then disable, then start the audio/render consumer; re-enable if start/resume fails. After enabling, call `processEvents()` once to flush backlog (avoids `WAIT_ON_FULL` deadlock if the channel was already full).
Expand Down
174 changes: 172 additions & 2 deletions apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine {
XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL);
}

- (void)testOnInterruptionEndWithResumeFailureEndsIdle {
- (void)testOnInterruptionEndWithResumeFailureLeavesRestartPending {
[self attachSourceNodeToAudioEngine];

self.audioEngine.state = AudioEngineStateInterrupted;
Expand All @@ -648,7 +648,9 @@ - (void)testOnInterruptionEndWithResumeFailureEndsIdle {

[self.audioEngine onInterruptionEnd:true];

XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle);
XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused);
XCTAssertFalse([self.audioEngine isEngineRunning]);
XCTAssertTrue([self.audioEngine isRestartPending]);
}

- (void)testStartIfNecessaryReturnsFalseWhenGraphEmpty {
Expand Down Expand Up @@ -936,6 +938,174 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning {
XCTAssertFalse(self.audioEngine.graphNeedsRebuild);
}

- (NSError *)refusedStartError {
return [NSError errorWithDomain:@"AudioEngineTests"
code:560557684
userInfo:nil];
}

// Drives the engine into the state left behind by a configuration change whose restart
// the system refused, which is what a locked device or a session held by another
// application produces in practice.
- (void)makeRestartPendingWithRefusedStart {
[self attachSourceNodeToAudioEngine];
self.audioEngine.currentFakeAudioEngine.fakeRunning = YES;
self.audioEngine.state = AudioEngineStateRunning;
self.audioEngine.nextCreatedEngineStartError = [self refusedStartError];

[self.audioEngine restartAudioEngine];
}

- (void)testRestartAudioEngineReportsStoppedEngineWhenStartIsRefused {
[self makeRestartPendingWithRefusedStart];

XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused);
XCTAssertFalse([self.audioEngine isEngineRunning]);
XCTAssertTrue([self.audioEngine isRestartPending]);
XCTAssertTrue(self.audioEngine.graphNeedsRebuild);
}

- (void)testRetryPendingRestartIfNeededRestartsEngineOnceStartSucceeds {
[self makeRestartPendingWithRefusedStart];

[self.audioEngine retryPendingRestartIfNeeded];

XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
XCTAssertTrue([self.audioEngine isEngineRunning]);
XCTAssertFalse([self.audioEngine isRestartPending]);
XCTAssertFalse(self.audioEngine.graphNeedsRebuild);
}

- (void)testRetryPendingRestartIfNeededKeepsPendingWhileStartStaysRefused {
[self makeRestartPendingWithRefusedStart];
self.audioEngine.nextCreatedEngineStartError = [self refusedStartError];

[self.audioEngine retryPendingRestartIfNeeded];

XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused);
XCTAssertFalse([self.audioEngine isEngineRunning]);
XCTAssertTrue([self.audioEngine isRestartPending]);
}

- (void)testRetryPendingRestartIfNeededDoesNothingWithoutPendingRestart {
[self attachSourceNodeToAudioEngine];
FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine;

[self.audioEngine retryPendingRestartIfNeeded];

XCTAssertEqual(fakeEngine.startCallCount, 0);
XCTAssertEqual(self.sessionManager.ensureActiveCallCount, 0);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle);
}

- (void)testRetryPendingRestartIfNeededStandsDownWhenGraphWasTornDown {
[self makeRestartPendingWithRefusedStart];

NSString *sourceNodeId = self.audioEngine.sourceNodes.allKeys.firstObject;
[self.audioEngine detachSourceNodeWithId:sourceNodeId];
FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine;
NSInteger startCallCountBeforeRetry = fakeEngine.startCallCount;

[self.audioEngine retryPendingRestartIfNeeded];

XCTAssertFalse([self.audioEngine isRestartPending]);
XCTAssertEqual(fakeEngine.startCallCount, startCallCountBeforeRetry);
}

- (void)testStopIfNecessaryClearsPendingRestart {
[self makeRestartPendingWithRefusedStart];

[self.audioEngine stopIfNecessary];

XCTAssertFalse([self.audioEngine isRestartPending]);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle);
}

- (void)testPauseIfNecessaryClearsPendingRestart {
[self makeRestartPendingWithRefusedStart];

[self.audioEngine pauseIfNecessary];

XCTAssertFalse([self.audioEngine isRestartPending]);
XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused);
}

- (void)testRestartAudioEngineResumesEngineWhenRestartWasPending {
[self makeRestartPendingWithRefusedStart];

[self.audioEngine restartAudioEngine];

XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
XCTAssertTrue([self.audioEngine isEngineRunning]);
XCTAssertFalse([self.audioEngine isRestartPending]);
}

// A rebuild required while the state already says running used to be reached twice,
// because starting the engine delegated back to the rebuild-and-resume path which
// started the engine again. Counting the calls pins the single pass down.
- (void)testStartIfNecessaryRebuildsAndStartsExactlyOnceWhenStateIsRunning {
[self attachSourceNodeToAudioEngine];
FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine;
self.audioEngine.state = AudioEngineStateRunning;
self.audioEngine.graphNeedsRebuild = YES;

XCTAssertTrue([self.audioEngine startIfNecessary]);

FakeAudioEngine *newEngine = self.audioEngine.currentFakeAudioEngine;
XCTAssertNotEqual(newEngine, oldEngine);
XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL);
XCTAssertEqual(self.sessionManager.ensureActiveCallCount, 1);
XCTAssertEqual(newEngine.prepareCallCount, 1);
XCTAssertEqual(newEngine.startCallCount, 1);
XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
}

// A configuration change arriving while the engine is interrupted cannot start it, and the
// system does not always follow the interruption with an end notification. The engine has to
// remember that it is still meant to run, or nothing restarts it.
- (void)testRestartWhileInterruptedArmsPendingRestart {
[self attachSourceNodeToAudioEngine];
self.audioEngine.currentFakeAudioEngine.fakeRunning = YES;
self.audioEngine.state = AudioEngineStateInterrupted;

[self.audioEngine restartAudioEngine];

XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted);
XCTAssertFalse([self.audioEngine isEngineRunning]);
XCTAssertTrue([self.audioEngine isRestartPending]);
}

- (void)testRetryPendingRestartIfNeededResumesEngineInterruptedDuringRestart {
[self attachSourceNodeToAudioEngine];
self.audioEngine.currentFakeAudioEngine.fakeRunning = YES;
self.audioEngine.state = AudioEngineStateInterrupted;
[self.audioEngine restartAudioEngine];

[self.audioEngine retryPendingRestartIfNeeded];

XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning);
XCTAssertTrue([self.audioEngine isEngineRunning]);
XCTAssertFalse([self.audioEngine isRestartPending]);
}

- (void)testRestartWhilePausedDoesNotArmPendingRestart {
[self attachSourceNodeToAudioEngine];
self.audioEngine.state = AudioEngineStatePaused;

[self.audioEngine restartAudioEngine];

XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused);
XCTAssertFalse([self.audioEngine isRestartPending]);
}

- (void)testRestartWhileInterruptedWithoutGraphDoesNotArmPendingRestart {
self.audioEngine.state = AudioEngineStateInterrupted;

[self.audioEngine restartAudioEngine];

XCTAssertFalse([self.audioEngine isRestartPending]);
}

- (void)testConcurrentStartIfNecessaryDoesNotCrash {
[self attachSourceNodeToAudioEngine];
self.audioEngine.state = AudioEngineStateIdle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@

[[nodiscard]] bool isRunning() const override;

[[nodiscard]] double getBaseLatency() const override;
[[nodiscard]] double getOutputLatency() const override;

protected:
std::shared_ptr<DSPAudioBuffer> audioBuffer_;
NativeAudioPlayer *audioPlayer_;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
uint64_t callbackId) override;
void clearOnAudioReadyCallback() override;

[[nodiscard]] double getInputLatency() const override;

protected:
NativeAudioRecorder *nativeRecorder_;
};
Expand Down
6 changes: 3 additions & 3 deletions apps/fabric-example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2523,7 +2523,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e
hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64
RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a
Expand All @@ -2532,7 +2532,7 @@ SPEC CHECKSUMS:
React: 1b1536b9099195944034e65b1830f463caaa8390
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d
React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921
React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549
React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6
React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f
React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74
Expand Down Expand Up @@ -2596,7 +2596,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2
ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733
ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360
ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c
ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03
RNAudioAPI: 50957b72cc742b9aa1e05349be71b9db73c9cf74
RNAudioWorklets: ff0c53fd3c3bbffacb7dd3beb03ffe0ea9f1fd05
RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ typedef NS_ENUM(NSInteger, AudioEngineState) {
- (AudioEngineState)getState;
- (bool)isEngineRunning;

/// @brief Whether a restart refused by the system is still waiting to be retried.
- (bool)isRestartPending;

/// @brief Immediately re-attempts a restart that the system previously refused.
///
/// The operating system can reject an engine restart triggered by a route or
/// configuration change, most notably while the device is locked with an input node
/// attached, or while another application holds the audio session. Such a refusal
/// leaves the engine stopped with a restart pending, retried on a bounded backoff.
/// Callers use this method to retry as soon as conditions are known to have improved
/// (the application returned to the foreground, the route changed again) instead of
/// waiting for the next scheduled attempt, and to grant a fresh retry budget once the
/// scheduled ones are exhausted.
///
/// Does nothing when no restart is pending.
- (void)retryPendingRestartIfNeeded;

- (bool)startIfNecessary;
- (void)pauseIfNecessary;
- (void)stopIfNecessary;
Expand Down
Loading