fix(audio): guard CEF stream lifecycle
- commit
- 5f3e0e86edd1f0fc18f2c24eee1950bce6a5a928
- author
- brice · · 2026-07-14 15:52 UTC
- committer
- brice · · 2026-07-14 17:20 UTC
- parent
- b6a9d2e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +201 -101
M internal/infrastructure/audio/pipewire/output.go +3 -4
| @@ -198,10 +198,9 @@ func copyPacket(packet *audioPacket, samples [][]float32) { | |||
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | 200 | func (a *playerStreamAdapter) recordDrop() { | |
| 201 | - | n := a.dropCount.Add(1) | |
| 202 | - | if (n == 1 || n&(n-1) == 0) && a.ctx != nil { | |
| 203 | - | logging.FromContext(a.ctx).Warn().Uint64("total_drops", n).Msg("pipewire: dropped audio packet (buffer full)") | |
| 204 | - | } | |
| 201 | + | // Write runs on CEF's real-time callback thread. Diagnostics must stay | |
| 202 | + | // allocation-free and must not synchronously emit logs from that path. | |
| 203 | + | a.dropCount.Add(1) | |
| 205 | 204 | } | |
| 206 | 205 | ||
| 207 | 206 | // fill is the PlayerCallbacks.Fill function. It performs the only post-handoff | |
| @@ -198,10 +198,9 @@ func copyPacket(packet *audioPacket, samples [][]float32) { | |||||
| 198 | } | 198 | } | ||
| 199 | 199 | ||||
| 200 | func (a *playerStreamAdapter) recordDrop() { | 200 | func (a *playerStreamAdapter) recordDrop() { | ||
| 201 | - | n := a.dropCount.Add(1) | 201 | + | // Write runs on CEF's real-time callback thread. Diagnostics must stay |
| 202 | - | if (n == 1 || n&(n-1) == 0) && a.ctx != nil { | 202 | + | // allocation-free and must not synchronously emit logs from that path. |
| 203 | - | logging.FromContext(a.ctx).Warn().Uint64("total_drops", n).Msg("pipewire: dropped audio packet (buffer full)") | 203 | + | a.dropCount.Add(1) |
| 204 | - | } | |||
| 205 | } | 204 | } | ||
| 206 | 205 | ||||
| 207 | // fill is the PlayerCallbacks.Fill function. It performs the only post-handoff | 206 | // fill is the PlayerCallbacks.Fill function. It performs the only post-handoff | ||
M internal/infrastructure/cef/webview.go +53 -11
| @@ -250,16 +250,25 @@ type WebView struct { | |||
| 250 | 250 | inputDiagDeltaXSum int64 | |
| 251 | 251 | inputDiagDeltaYSum int64 | |
| 252 | 252 | ||
| 253 | - | // Audio output factory and active stream. | |
| 254 | - | audioOutputFactory port.AudioOutputFactory | |
| 255 | - | audioStreamMu sync.Mutex | |
| 256 | - | activeAudioStream port.AudioOutputStream | |
| 253 | + | // Audio output lifecycle. Packet callbacks load audioStream directly: its | |
| 254 | + | // immutable handle is atomically published and never requires a mutex. | |
| 255 | + | audioOutputFactory port.AudioOutputFactory | |
| 256 | + | audioStreamMu sync.Mutex | |
| 257 | + | audioStreamGeneration atomic.Uint64 | |
| 258 | + | audioStream atomic.Pointer[audioStreamHandle] | |
| 257 | 259 | ||
| 258 | 260 | // Audio instrumentation counters (diagnostic only). | |
| 259 | 261 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | |
| 260 | 262 | audioWriteCount atomic.Uint64 // successful Write calls to stream | |
| 261 | 263 | } | |
| 262 | 264 | ||
| 265 | + | // audioStreamHandle is immutable after publication so packet callbacks can | |
| 266 | + | // safely snapshot it without coordinating with lifecycle operations. | |
| 267 | + | type audioStreamHandle struct { | |
| 268 | + | stream port.AudioOutputStream | |
| 269 | + | generation uint64 | |
| 270 | + | } | |
| 271 | + | ||
| 263 | 272 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | |
| 264 | 273 | // deferred until the GL area has a non-zero size. | |
| 265 | 274 | type pendingBrowserCreate struct { | |
| @@ -2331,17 +2340,50 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||
| 2331 | 2340 | return pc | |
| 2332 | 2341 | } | |
| 2333 | 2342 | ||
| 2334 | - | // closeAudioStream detaches then closes the active output stream. Detaching | |
| 2335 | - | // under the lock keeps packet snapshots consistent without holding the lock | |
| 2336 | - | // while PipeWire shutdown runs; the port guarantees Close/Write safety. | |
| 2343 | + | // beginAudioStreamStart invalidates any prior stream before creating a new | |
| 2344 | + | // one. A stream created for the returned generation may only be published if | |
| 2345 | + | // that generation is still current. | |
| 2346 | + | func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) { | |
| 2347 | + | wv.audioStreamMu.Lock() | |
| 2348 | + | defer wv.audioStreamMu.Unlock() | |
| 2349 | + | if wv.destroyed.Load() { | |
| 2350 | + | return 0, nil, false | |
| 2351 | + | } | |
| 2352 | + | generation := wv.audioStreamGeneration.Add(1) | |
| 2353 | + | previous := wv.audioStream.Swap(nil) | |
| 2354 | + | wv.audioPlaying.Store(false) | |
| 2355 | + | return generation, previous, true | |
| 2356 | + | } | |
| 2357 | + | ||
| 2358 | + | // publishAudioStream publishes stream only if no Stop, Destroy, or newer Start | |
| 2359 | + | // invalidated its creation generation. A rejected stream is owned by the | |
| 2360 | + | // caller and must be closed by it. | |
| 2361 | + | func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) { | |
| 2362 | + | wv.audioStreamMu.Lock() | |
| 2363 | + | defer wv.audioStreamMu.Unlock() | |
| 2364 | + | if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation { | |
| 2365 | + | return nil, false | |
| 2366 | + | } | |
| 2367 | + | previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation}) | |
| 2368 | + | wv.audioPlaying.Store(true) | |
| 2369 | + | return previous, true | |
| 2370 | + | } | |
| 2371 | + | ||
| 2372 | + | // closeAudioStream invalidates creation in progress, atomically detaches the | |
| 2373 | + | // published handle, then closes it outside the lifecycle mutex. The port | |
| 2374 | + | // contract permits Close concurrently with a packet Write. | |
| 2337 | 2375 | func (wv *WebView) closeAudioStream() { | |
| 2338 | 2376 | wv.audioStreamMu.Lock() | |
| 2339 | - | stream := wv.activeAudioStream | |
| 2340 | - | wv.activeAudioStream = nil | |
| 2377 | + | wv.audioStreamGeneration.Add(1) | |
| 2378 | + | stream := wv.audioStream.Swap(nil) | |
| 2379 | + | wv.audioPlaying.Store(false) | |
| 2341 | 2380 | wv.audioStreamMu.Unlock() | |
| 2381 | + | wv.closeDetachedAudioStream(stream) | |
| 2382 | + | } | |
| 2342 | 2383 | ||
| 2343 | - | if stream != nil { | |
| 2344 | - | if err := stream.Close(); err != nil && wv.ctx != nil { | |
| 2384 | + | func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) { | |
| 2385 | + | if handle != nil && handle.stream != nil { | |
| 2386 | + | if err := handle.stream.Close(); err != nil && wv.ctx != nil { | |
| 2345 | 2387 | logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream") | |
| 2346 | 2388 | } | |
| 2347 | 2389 | } | |
| @@ -250,16 +250,25 @@ type WebView struct { | |||||
| 250 | inputDiagDeltaXSum int64 | 250 | inputDiagDeltaXSum int64 | ||
| 251 | inputDiagDeltaYSum int64 | 251 | inputDiagDeltaYSum int64 | ||
| 252 | 252 | ||||
| 253 | - | // Audio output factory and active stream. | 253 | + | // Audio output lifecycle. Packet callbacks load audioStream directly: its |
| 254 | - | audioOutputFactory port.AudioOutputFactory | 254 | + | // immutable handle is atomically published and never requires a mutex. |
| 255 | - | audioStreamMu sync.Mutex | 255 | + | audioOutputFactory port.AudioOutputFactory |
| 256 | - | activeAudioStream port.AudioOutputStream | 256 | + | audioStreamMu sync.Mutex |
| 257 | + | audioStreamGeneration atomic.Uint64 | |||
| 258 | + | audioStream atomic.Pointer[audioStreamHandle] | |||
| 257 | 259 | ||||
| 258 | // Audio instrumentation counters (diagnostic only). | 260 | // Audio instrumentation counters (diagnostic only). | ||
| 259 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | 261 | audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls | ||
| 260 | audioWriteCount atomic.Uint64 // successful Write calls to stream | 262 | audioWriteCount atomic.Uint64 // successful Write calls to stream | ||
| 261 | } | 263 | } | ||
| 262 | 264 | ||||
| 265 | + | // audioStreamHandle is immutable after publication so packet callbacks can | |||
| 266 | + | // safely snapshot it without coordinating with lifecycle operations. | |||
| 267 | + | type audioStreamHandle struct { | |||
| 268 | + | stream port.AudioOutputStream | |||
| 269 | + | generation uint64 | |||
| 270 | + | } | |||
| 271 | + | ||||
| 263 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | 272 | // pendingBrowserCreate holds the parameters needed to create a CEF browser, | ||
| 264 | // deferred until the GL area has a non-zero size. | 273 | // deferred until the GL area has a non-zero size. | ||
| 265 | type pendingBrowserCreate struct { | 274 | type pendingBrowserCreate struct { | ||
| @@ -2331,17 +2340,50 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||||
| 2331 | return pc | 2340 | return pc | ||
| 2332 | } | 2341 | } | ||
| 2333 | 2342 | ||||
| 2334 | - | // closeAudioStream detaches then closes the active output stream. Detaching | 2343 | + | // beginAudioStreamStart invalidates any prior stream before creating a new |
| 2335 | - | // under the lock keeps packet snapshots consistent without holding the lock | 2344 | + | // one. A stream created for the returned generation may only be published if |
| 2336 | - | // while PipeWire shutdown runs; the port guarantees Close/Write safety. | 2345 | + | // that generation is still current. |
| 2346 | + | func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) { | |||
| 2347 | + | wv.audioStreamMu.Lock() | |||
| 2348 | + | defer wv.audioStreamMu.Unlock() | |||
| 2349 | + | if wv.destroyed.Load() { | |||
| 2350 | + | return 0, nil, false | |||
| 2351 | + | } | |||
| 2352 | + | generation := wv.audioStreamGeneration.Add(1) | |||
| 2353 | + | previous := wv.audioStream.Swap(nil) | |||
| 2354 | + | wv.audioPlaying.Store(false) | |||
| 2355 | + | return generation, previous, true | |||
| 2356 | + | } | |||
| 2357 | + | ||||
| 2358 | + | // publishAudioStream publishes stream only if no Stop, Destroy, or newer Start | |||
| 2359 | + | // invalidated its creation generation. A rejected stream is owned by the | |||
| 2360 | + | // caller and must be closed by it. | |||
| 2361 | + | func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) { | |||
| 2362 | + | wv.audioStreamMu.Lock() | |||
| 2363 | + | defer wv.audioStreamMu.Unlock() | |||
| 2364 | + | if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation { | |||
| 2365 | + | return nil, false | |||
| 2366 | + | } | |||
| 2367 | + | previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation}) | |||
| 2368 | + | wv.audioPlaying.Store(true) | |||
| 2369 | + | return previous, true | |||
| 2370 | + | } | |||
| 2371 | + | ||||
| 2372 | + | // closeAudioStream invalidates creation in progress, atomically detaches the | |||
| 2373 | + | // published handle, then closes it outside the lifecycle mutex. The port | |||
| 2374 | + | // contract permits Close concurrently with a packet Write. | |||
| 2337 | func (wv *WebView) closeAudioStream() { | 2375 | func (wv *WebView) closeAudioStream() { | ||
| 2338 | wv.audioStreamMu.Lock() | 2376 | wv.audioStreamMu.Lock() | ||
| 2339 | - | stream := wv.activeAudioStream | 2377 | + | wv.audioStreamGeneration.Add(1) |
| 2340 | - | wv.activeAudioStream = nil | 2378 | + | stream := wv.audioStream.Swap(nil) |
| 2379 | + | wv.audioPlaying.Store(false) | |||
| 2341 | wv.audioStreamMu.Unlock() | 2380 | wv.audioStreamMu.Unlock() | ||
| 2381 | + | wv.closeDetachedAudioStream(stream) | |||
| 2382 | + | } | |||
| 2342 | 2383 | ||||
| 2343 | - | if stream != nil { | 2384 | + | func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) { |
| 2344 | - | if err := stream.Close(); err != nil && wv.ctx != nil { | 2385 | + | if handle != nil && handle.stream != nil { |
| 2386 | + | if err := handle.stream.Close(); err != nil && wv.ctx != nil { | |||
| 2345 | logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream") | 2387 | logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream") | ||
| 2346 | } | 2388 | } | ||
| 2347 | } | 2389 | } | ||
M internal/infrastructure/cef/webview_audio_test.go +118 -33
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "sync/atomic" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) { | |||
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | 86 | // Verify the fields exist and can be set | |
| 86 | - | wv.activeAudioStream = &mockAudioStream{} | |
| 87 | + | setAudioStream(wv, &mockAudioStream{}) | |
| 87 | 88 | ||
| 88 | 89 | assert.NotNil(t, wv.audioOutputFactory) | |
| 89 | - | assert.NotNil(t, wv.activeAudioStream) | |
| 90 | + | assert.NotNil(t, activeAudioStream(wv)) | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | 93 | // mockAudioStream is a test double for port.AudioOutputStream | |
| @@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error { | |||
| 110 | 111 | // Verify mock implements the interface | |
| 111 | 112 | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | |
| 112 | 113 | ||
| 114 | + | func setAudioStream(wv *WebView, stream port.AudioOutputStream) { | |
| 115 | + | wv.audioStream.Store(&audioStreamHandle{stream: stream, generation: wv.audioStreamGeneration.Load()}) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func activeAudioStream(wv *WebView) port.AudioOutputStream { | |
| 119 | + | handle := wv.audioStream.Load() | |
| 120 | + | if handle == nil { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | return handle.stream | |
| 124 | + | } | |
| 125 | + | ||
| 113 | 126 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | |
| 114 | 127 | // tests to verify that the caller holds a lock across the Write call. | |
| 115 | 128 | type racyMockAudioStream struct { | |
| @@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error { | |||
| 131 | 144 | ||
| 132 | 145 | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | |
| 133 | 146 | ||
| 147 | + | type countedAudioStream struct{ closes atomic.Int32 } | |
| 148 | + | ||
| 149 | + | func (*countedAudioStream) Write([][]float32) error { return nil } | |
| 150 | + | func (s *countedAudioStream) Close() error { | |
| 151 | + | s.closes.Add(1) | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | type controlledAudioFactory struct { | |
| 156 | + | entered chan struct{} | |
| 157 | + | release chan struct{} | |
| 158 | + | stream port.AudioOutputStream | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (f *controlledAudioFactory) NewStream(context.Context, port.AudioStreamFormat) (port.AudioOutputStream, error) { | |
| 162 | + | close(f.entered) | |
| 163 | + | <-f.release | |
| 164 | + | return f.stream, nil | |
| 165 | + | } | |
| 166 | + | ||
| 134 | 167 | // ============================================================================ | |
| 135 | 168 | // Task 6: Audio Stream Lifecycle Handling Tests | |
| 136 | 169 | // ============================================================================ | |
| @@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) { | |||
| 159 | 192 | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 160 | 193 | ||
| 161 | 194 | // Assert | |
| 162 | - | require.NotNil(t, wv.activeAudioStream) | |
| 163 | - | assert.Equal(t, mockStream, wv.activeAudioStream) | |
| 195 | + | require.NotNil(t, activeAudioStream(wv)) | |
| 196 | + | assert.Equal(t, mockStream, activeAudioStream(wv)) | |
| 164 | 197 | assert.True(t, wv.audioPlaying.Load()) | |
| 165 | 198 | ||
| 166 | 199 | // Verify factory was called with correct format | |
| @@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||
| 205 | 238 | wv := &WebView{ | |
| 206 | 239 | ctx: ctx, | |
| 207 | 240 | audioOutputFactory: mockFactory, | |
| 208 | - | activeAudioStream: oldStream, | |
| 209 | 241 | } | |
| 242 | + | setAudioStream(wv, oldStream) | |
| 210 | 243 | ||
| 211 | 244 | handlers := &handlerSet{wv: wv} | |
| 212 | 245 | ||
| @@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||
| 221 | 254 | ||
| 222 | 255 | // Assert | |
| 223 | 256 | assert.True(t, oldStream.closeCalled) | |
| 224 | - | assert.Equal(t, newStream, wv.activeAudioStream) | |
| 257 | + | assert.Equal(t, newStream, activeAudioStream(wv)) | |
| 225 | 258 | } | |
| 226 | 259 | ||
| 227 | 260 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | |
| @@ -251,7 +284,7 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi | |||
| 251 | 284 | // audioPlaying must be false when NewStream fails — if it stays true, | |
| 252 | 285 | // the UI/state layer incorrectly believes audio is active. | |
| 253 | 286 | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | |
| 254 | - | assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure") | |
| 287 | + | assert.Nil(t, activeAudioStream(wv), "activeAudioStream must be nil after NewStream failure") | |
| 255 | 288 | } | |
| 256 | 289 | ||
| 257 | 290 | // ============================================================================ | |
| @@ -265,9 +298,9 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { | |||
| 265 | 298 | mockStream := &mockAudioStream{} | |
| 266 | 299 | ||
| 267 | 300 | wv := &WebView{ | |
| 268 | - | ctx: ctx, | |
| 269 | - | activeAudioStream: mockStream, | |
| 301 | + | ctx: ctx, | |
| 270 | 302 | } | |
| 303 | + | setAudioStream(wv, mockStream) | |
| 271 | 304 | ||
| 272 | 305 | handlers := &handlerSet{wv: wv} | |
| 273 | 306 | ||
| @@ -296,8 +329,7 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { | |||
| 296 | 329 | // that packets are silently discarded when no stream is active. | |
| 297 | 330 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | |
| 298 | 331 | wv := &WebView{ | |
| 299 | - | ctx: context.Background(), | |
| 300 | - | activeAudioStream: nil, | |
| 332 | + | ctx: context.Background(), | |
| 301 | 333 | } | |
| 302 | 334 | ||
| 303 | 335 | handlers := &handlerSet{wv: wv} | |
| @@ -316,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) | |||
| 316 | 348 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | |
| 317 | 349 | // that nil data is handled gracefully. | |
| 318 | 350 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |
| 319 | - | mockStream := &mockAudioStream{} | |
| 320 | 351 | wv := &WebView{ | |
| 321 | - | ctx: context.Background(), | |
| 322 | - | activeAudioStream: mockStream, | |
| 352 | + | ctx: context.Background(), | |
| 323 | 353 | } | |
| 324 | 354 | ||
| 325 | 355 | handlers := &handlerSet{wv: wv} | |
| @@ -332,7 +362,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |||
| 332 | 362 | ||
| 333 | 363 | func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | |
| 334 | 364 | stream := &mockAudioStream{} | |
| 335 | - | handlers := &handlerSet{wv: &WebView{ctx: context.Background(), activeAudioStream: stream}} | |
| 365 | + | wv := &WebView{ctx: context.Background()} | |
| 366 | + | setAudioStream(wv, stream) | |
| 367 | + | handlers := &handlerSet{wv: wv} | |
| 336 | 368 | data := [][]float32{make([]float32, 512), make([]float32, 512)} | |
| 337 | 369 | b.ReportAllocs() | |
| 338 | 370 | b.SetBytes(2 * 512 * 4) | |
| @@ -350,9 +382,9 @@ func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | |||
| 350 | 382 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |
| 351 | 383 | mockStream := &mockAudioStream{} | |
| 352 | 384 | wv := &WebView{ | |
| 353 | - | ctx: context.Background(), | |
| 354 | - | activeAudioStream: mockStream, | |
| 385 | + | ctx: context.Background(), | |
| 355 | 386 | } | |
| 387 | + | setAudioStream(wv, mockStream) | |
| 356 | 388 | wv.audioPlaying.Store(true) | |
| 357 | 389 | ||
| 358 | 390 | handlers := &handlerSet{wv: wv} | |
| @@ -362,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||
| 362 | 394 | ||
| 363 | 395 | // Assert | |
| 364 | 396 | assert.True(t, mockStream.closeCalled) | |
| 365 | - | assert.Nil(t, wv.activeAudioStream) | |
| 397 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 366 | 398 | assert.False(t, wv.audioPlaying.Load()) | |
| 367 | 399 | } | |
| 368 | 400 | ||
| @@ -370,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||
| 370 | 402 | // that stopping when no stream exists doesn't panic. | |
| 371 | 403 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 372 | 404 | wv := &WebView{ | |
| 373 | - | ctx: context.Background(), | |
| 374 | - | activeAudioStream: nil, | |
| 405 | + | ctx: context.Background(), | |
| 375 | 406 | } | |
| 376 | 407 | wv.audioPlaying.Store(true) | |
| 377 | 408 | ||
| @@ -390,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing. | |||
| 390 | 421 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |
| 391 | 422 | mockStream := &mockAudioStream{} | |
| 392 | 423 | wv := &WebView{ | |
| 393 | - | ctx: context.Background(), | |
| 394 | - | activeAudioStream: mockStream, | |
| 424 | + | ctx: context.Background(), | |
| 395 | 425 | } | |
| 426 | + | setAudioStream(wv, mockStream) | |
| 396 | 427 | wv.audioPlaying.Store(true) | |
| 397 | 428 | ||
| 398 | 429 | handlers := &handlerSet{wv: wv} | |
| @@ -402,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||
| 402 | 433 | ||
| 403 | 434 | // Assert | |
| 404 | 435 | assert.True(t, mockStream.closeCalled) | |
| 405 | - | assert.Nil(t, wv.activeAudioStream) | |
| 436 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 406 | 437 | assert.False(t, wv.audioPlaying.Load()) | |
| 407 | 438 | } | |
| 408 | 439 | ||
| @@ -410,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||
| 410 | 441 | // that error handling when no stream exists doesn't panic. | |
| 411 | 442 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | |
| 412 | 443 | wv := &WebView{ | |
| 413 | - | ctx: context.Background(), | |
| 414 | - | activeAudioStream: nil, | |
| 444 | + | ctx: context.Background(), | |
| 415 | 445 | } | |
| 416 | 446 | wv.audioPlaying.Store(true) | |
| 417 | 447 | ||
| @@ -464,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T | |||
| 464 | 494 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |
| 465 | 495 | mockStream := &mockAudioStream{} | |
| 466 | 496 | wv := &WebView{ | |
| 467 | - | ctx: context.Background(), | |
| 468 | - | activeAudioStream: mockStream, | |
| 497 | + | ctx: context.Background(), | |
| 469 | 498 | } | |
| 499 | + | setAudioStream(wv, mockStream) | |
| 470 | 500 | wv.audioPlaying.Store(true) | |
| 471 | 501 | ||
| 472 | 502 | // Act — Destroy must close the audio stream as part of teardown. | |
| @@ -474,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||
| 474 | 504 | ||
| 475 | 505 | // Assert | |
| 476 | 506 | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | |
| 477 | - | assert.Nil(t, wv.activeAudioStream) | |
| 507 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 478 | 508 | } | |
| 479 | 509 | ||
| 480 | 510 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | |
| @@ -566,6 +596,61 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) { | |||
| 566 | 596 | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | |
| 567 | 597 | } | |
| 568 | 598 | ||
| 599 | + | func TestHandlerSet_OnAudioStreamStarted_StopRejectsInFlightCreation(t *testing.T) { | |
| 600 | + | stream := &countedAudioStream{} | |
| 601 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |
| 602 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |
| 603 | + | handlers := &handlerSet{wv: wv} | |
| 604 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |
| 605 | + | done := make(chan struct{}) | |
| 606 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |
| 607 | + | <-factory.entered | |
| 608 | + | handlers.OnAudioStreamStopped(nil) | |
| 609 | + | close(factory.release) | |
| 610 | + | <-done | |
| 611 | + | ||
| 612 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 613 | + | assert.False(t, wv.audioPlaying.Load()) | |
| 614 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func TestHandlerSet_OnAudioStreamStarted_DestroyRejectsInFlightCreation(t *testing.T) { | |
| 618 | + | stream := &countedAudioStream{} | |
| 619 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |
| 620 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |
| 621 | + | handlers := &handlerSet{wv: wv} | |
| 622 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |
| 623 | + | done := make(chan struct{}) | |
| 624 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |
| 625 | + | <-factory.entered | |
| 626 | + | wv.Destroy() | |
| 627 | + | close(factory.release) | |
| 628 | + | <-done | |
| 629 | + | ||
| 630 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 631 | + | assert.True(t, wv.destroyed.Load()) | |
| 632 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | func TestHandlerSet_OnAudioStreamStarted_ReplacesAndClosesEachStreamOnce(t *testing.T) { | |
| 636 | + | old, first, second := &countedAudioStream{}, &countedAudioStream{}, &countedAudioStream{} | |
| 637 | + | factory := &mockAudioFactory{stream: first} | |
| 638 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |
| 639 | + | setAudioStream(wv, old) | |
| 640 | + | handlers := &handlerSet{wv: wv} | |
| 641 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |
| 642 | + | ||
| 643 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 644 | + | factory.stream = second | |
| 645 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |
| 646 | + | wv.closeAudioStream() | |
| 647 | + | ||
| 648 | + | assert.Equal(t, int32(1), old.closes.Load()) | |
| 649 | + | assert.Equal(t, int32(1), first.closes.Load()) | |
| 650 | + | assert.Equal(t, int32(1), second.closes.Load()) | |
| 651 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 652 | + | } | |
| 653 | + | ||
| 569 | 654 | // ============================================================================ | |
| 570 | 655 | // Race-window tests (write/close interleave) | |
| 571 | 656 | // ============================================================================ | |
| @@ -583,9 +668,9 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { | |||
| 583 | 668 | } | |
| 584 | 669 | ||
| 585 | 670 | wv := &WebView{ | |
| 586 | - | ctx: ctx, | |
| 587 | - | activeAudioStream: stream, | |
| 671 | + | ctx: ctx, | |
| 588 | 672 | } | |
| 673 | + | setAudioStream(wv, stream) | |
| 589 | 674 | handlers := &handlerSet{wv: wv} | |
| 590 | 675 | ||
| 591 | 676 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | |
| @@ -613,7 +698,7 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { | |||
| 613 | 698 | close(stream.doneCh) | |
| 614 | 699 | ||
| 615 | 700 | assert.True(t, stream.closeCalled) | |
| 616 | - | assert.Nil(t, wv.activeAudioStream) | |
| 701 | + | assert.Nil(t, activeAudioStream(wv)) | |
| 617 | 702 | } | |
| 618 | 703 | ||
| 619 | 704 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | |
| @@ -624,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) { | |||
| 624 | 709 | stream := &mockAudioStream{} | |
| 625 | 710 | ||
| 626 | 711 | wv := &WebView{ | |
| 627 | - | ctx: ctx, | |
| 628 | - | activeAudioStream: stream, | |
| 712 | + | ctx: ctx, | |
| 629 | 713 | } | |
| 714 | + | setAudioStream(wv, stream) | |
| 630 | 715 | handlers := &handlerSet{wv: wv} | |
| 631 | 716 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | |
| 632 | 717 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "sync/atomic" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) { | |||||
| 83 | } | 84 | } | ||
| 84 | 85 | ||||
| 85 | // Verify the fields exist and can be set | 86 | // Verify the fields exist and can be set | ||
| 86 | - | wv.activeAudioStream = &mockAudioStream{} | 87 | + | setAudioStream(wv, &mockAudioStream{}) |
| 87 | 88 | ||||
| 88 | assert.NotNil(t, wv.audioOutputFactory) | 89 | assert.NotNil(t, wv.audioOutputFactory) | ||
| 89 | - | assert.NotNil(t, wv.activeAudioStream) | 90 | + | assert.NotNil(t, activeAudioStream(wv)) |
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 92 | // mockAudioStream is a test double for port.AudioOutputStream | 93 | // mockAudioStream is a test double for port.AudioOutputStream | ||
| @@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error { | |||||
| 110 | // Verify mock implements the interface | 111 | // Verify mock implements the interface | ||
| 111 | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | 112 | var _ port.AudioOutputStream = (*mockAudioStream)(nil) | ||
| 112 | 113 | ||||
| 114 | + | func setAudioStream(wv *WebView, stream port.AudioOutputStream) { | |||
| 115 | + | wv.audioStream.Store(&audioStreamHandle{stream: stream, generation: wv.audioStreamGeneration.Load()}) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func activeAudioStream(wv *WebView) port.AudioOutputStream { | |||
| 119 | + | handle := wv.audioStream.Load() | |||
| 120 | + | if handle == nil { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | return handle.stream | |||
| 124 | + | } | |||
| 125 | + | ||||
| 113 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | 126 | // racyMockAudioStream blocks inside Write until doneCh is closed, allowing | ||
| 114 | // tests to verify that the caller holds a lock across the Write call. | 127 | // tests to verify that the caller holds a lock across the Write call. | ||
| 115 | type racyMockAudioStream struct { | 128 | type racyMockAudioStream struct { | ||
| @@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error { | |||||
| 131 | 144 | ||||
| 132 | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | 145 | var _ port.AudioOutputStream = (*racyMockAudioStream)(nil) | ||
| 133 | 146 | ||||
| 147 | + | type countedAudioStream struct{ closes atomic.Int32 } | |||
| 148 | + | ||||
| 149 | + | func (*countedAudioStream) Write([][]float32) error { return nil } | |||
| 150 | + | func (s *countedAudioStream) Close() error { | |||
| 151 | + | s.closes.Add(1) | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | type controlledAudioFactory struct { | |||
| 156 | + | entered chan struct{} | |||
| 157 | + | release chan struct{} | |||
| 158 | + | stream port.AudioOutputStream | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (f *controlledAudioFactory) NewStream(context.Context, port.AudioStreamFormat) (port.AudioOutputStream, error) { | |||
| 162 | + | close(f.entered) | |||
| 163 | + | <-f.release | |||
| 164 | + | return f.stream, nil | |||
| 165 | + | } | |||
| 166 | + | ||||
| 134 | // ============================================================================ | 167 | // ============================================================================ | ||
| 135 | // Task 6: Audio Stream Lifecycle Handling Tests | 168 | // Task 6: Audio Stream Lifecycle Handling Tests | ||
| 136 | // ============================================================================ | 169 | // ============================================================================ | ||
| @@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) { | |||||
| 159 | handlers.OnAudioStreamStarted(nil, params, 2) | 192 | handlers.OnAudioStreamStarted(nil, params, 2) | ||
| 160 | 193 | ||||
| 161 | // Assert | 194 | // Assert | ||
| 162 | - | require.NotNil(t, wv.activeAudioStream) | 195 | + | require.NotNil(t, activeAudioStream(wv)) |
| 163 | - | assert.Equal(t, mockStream, wv.activeAudioStream) | 196 | + | assert.Equal(t, mockStream, activeAudioStream(wv)) |
| 164 | assert.True(t, wv.audioPlaying.Load()) | 197 | assert.True(t, wv.audioPlaying.Load()) | ||
| 165 | 198 | ||||
| 166 | // Verify factory was called with correct format | 199 | // Verify factory was called with correct format | ||
| @@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||||
| 205 | wv := &WebView{ | 238 | wv := &WebView{ | ||
| 206 | ctx: ctx, | 239 | ctx: ctx, | ||
| 207 | audioOutputFactory: mockFactory, | 240 | audioOutputFactory: mockFactory, | ||
| 208 | - | activeAudioStream: oldStream, | |||
| 209 | } | 241 | } | ||
| 242 | + | setAudioStream(wv, oldStream) | |||
| 210 | 243 | ||||
| 211 | handlers := &handlerSet{wv: wv} | 244 | handlers := &handlerSet{wv: wv} | ||
| 212 | 245 | ||||
| @@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) { | |||||
| 221 | 254 | ||||
| 222 | // Assert | 255 | // Assert | ||
| 223 | assert.True(t, oldStream.closeCalled) | 256 | assert.True(t, oldStream.closeCalled) | ||
| 224 | - | assert.Equal(t, newStream, wv.activeAudioStream) | 257 | + | assert.Equal(t, newStream, activeAudioStream(wv)) |
| 225 | } | 258 | } | ||
| 226 | 259 | ||||
| 227 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | 260 | // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies | ||
| @@ -251,7 +284,7 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi | |||||
| 251 | // audioPlaying must be false when NewStream fails — if it stays true, | 284 | // audioPlaying must be false when NewStream fails — if it stays true, | ||
| 252 | // the UI/state layer incorrectly believes audio is active. | 285 | // the UI/state layer incorrectly believes audio is active. | ||
| 253 | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | 286 | assert.False(t, wv.audioPlaying.Load(), "audioPlaying must be false after NewStream failure") | ||
| 254 | - | assert.Nil(t, wv.activeAudioStream, "activeAudioStream must be nil after NewStream failure") | 287 | + | assert.Nil(t, activeAudioStream(wv), "activeAudioStream must be nil after NewStream failure") |
| 255 | } | 288 | } | ||
| 256 | 289 | ||||
| 257 | // ============================================================================ | 290 | // ============================================================================ | ||
| @@ -265,9 +298,9 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { | |||||
| 265 | mockStream := &mockAudioStream{} | 298 | mockStream := &mockAudioStream{} | ||
| 266 | 299 | ||||
| 267 | wv := &WebView{ | 300 | wv := &WebView{ | ||
| 268 | - | ctx: ctx, | 301 | + | ctx: ctx, |
| 269 | - | activeAudioStream: mockStream, | |||
| 270 | } | 302 | } | ||
| 303 | + | setAudioStream(wv, mockStream) | |||
| 271 | 304 | ||||
| 272 | handlers := &handlerSet{wv: wv} | 305 | handlers := &handlerSet{wv: wv} | ||
| 273 | 306 | ||||
| @@ -296,8 +329,7 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) { | |||||
| 296 | // that packets are silently discarded when no stream is active. | 329 | // that packets are silently discarded when no stream is active. | ||
| 297 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | 330 | func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) { | ||
| 298 | wv := &WebView{ | 331 | wv := &WebView{ | ||
| 299 | - | ctx: context.Background(), | 332 | + | ctx: context.Background(), |
| 300 | - | activeAudioStream: nil, | |||
| 301 | } | 333 | } | ||
| 302 | 334 | ||||
| 303 | handlers := &handlerSet{wv: wv} | 335 | handlers := &handlerSet{wv: wv} | ||
| @@ -316,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) | |||||
| 316 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | 348 | // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies | ||
| 317 | // that nil data is handled gracefully. | 349 | // that nil data is handled gracefully. | ||
| 318 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | 350 | func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | ||
| 319 | - | mockStream := &mockAudioStream{} | |||
| 320 | wv := &WebView{ | 351 | wv := &WebView{ | ||
| 321 | - | ctx: context.Background(), | 352 | + | ctx: context.Background(), |
| 322 | - | activeAudioStream: mockStream, | |||
| 323 | } | 353 | } | ||
| 324 | 354 | ||||
| 325 | handlers := &handlerSet{wv: wv} | 355 | handlers := &handlerSet{wv: wv} | ||
| @@ -332,7 +362,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) { | |||||
| 332 | 362 | ||||
| 333 | func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | 363 | func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | ||
| 334 | stream := &mockAudioStream{} | 364 | stream := &mockAudioStream{} | ||
| 335 | - | handlers := &handlerSet{wv: &WebView{ctx: context.Background(), activeAudioStream: stream}} | 365 | + | wv := &WebView{ctx: context.Background()} |
| 366 | + | setAudioStream(wv, stream) | |||
| 367 | + | handlers := &handlerSet{wv: wv} | |||
| 336 | data := [][]float32{make([]float32, 512), make([]float32, 512)} | 368 | data := [][]float32{make([]float32, 512), make([]float32, 512)} | ||
| 337 | b.ReportAllocs() | 369 | b.ReportAllocs() | ||
| 338 | b.SetBytes(2 * 512 * 4) | 370 | b.SetBytes(2 * 512 * 4) | ||
| @@ -350,9 +382,9 @@ func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) { | |||||
| 350 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | 382 | func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | ||
| 351 | mockStream := &mockAudioStream{} | 383 | mockStream := &mockAudioStream{} | ||
| 352 | wv := &WebView{ | 384 | wv := &WebView{ | ||
| 353 | - | ctx: context.Background(), | 385 | + | ctx: context.Background(), |
| 354 | - | activeAudioStream: mockStream, | |||
| 355 | } | 386 | } | ||
| 387 | + | setAudioStream(wv, mockStream) | |||
| 356 | wv.audioPlaying.Store(true) | 388 | wv.audioPlaying.Store(true) | ||
| 357 | 389 | ||||
| 358 | handlers := &handlerSet{wv: wv} | 390 | handlers := &handlerSet{wv: wv} | ||
| @@ -362,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||||
| 362 | 394 | ||||
| 363 | // Assert | 395 | // Assert | ||
| 364 | assert.True(t, mockStream.closeCalled) | 396 | assert.True(t, mockStream.closeCalled) | ||
| 365 | - | assert.Nil(t, wv.activeAudioStream) | 397 | + | assert.Nil(t, activeAudioStream(wv)) |
| 366 | assert.False(t, wv.audioPlaying.Load()) | 398 | assert.False(t, wv.audioPlaying.Load()) | ||
| 367 | } | 399 | } | ||
| 368 | 400 | ||||
| @@ -370,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) { | |||||
| 370 | // that stopping when no stream exists doesn't panic. | 402 | // that stopping when no stream exists doesn't panic. | ||
| 371 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | 403 | func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) { | ||
| 372 | wv := &WebView{ | 404 | wv := &WebView{ | ||
| 373 | - | ctx: context.Background(), | 405 | + | ctx: context.Background(), |
| 374 | - | activeAudioStream: nil, | |||
| 375 | } | 406 | } | ||
| 376 | wv.audioPlaying.Store(true) | 407 | wv.audioPlaying.Store(true) | ||
| 377 | 408 | ||||
| @@ -390,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing. | |||||
| 390 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | 421 | func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | ||
| 391 | mockStream := &mockAudioStream{} | 422 | mockStream := &mockAudioStream{} | ||
| 392 | wv := &WebView{ | 423 | wv := &WebView{ | ||
| 393 | - | ctx: context.Background(), | 424 | + | ctx: context.Background(), |
| 394 | - | activeAudioStream: mockStream, | |||
| 395 | } | 425 | } | ||
| 426 | + | setAudioStream(wv, mockStream) | |||
| 396 | wv.audioPlaying.Store(true) | 427 | wv.audioPlaying.Store(true) | ||
| 397 | 428 | ||||
| 398 | handlers := &handlerSet{wv: wv} | 429 | handlers := &handlerSet{wv: wv} | ||
| @@ -402,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||||
| 402 | 433 | ||||
| 403 | // Assert | 434 | // Assert | ||
| 404 | assert.True(t, mockStream.closeCalled) | 435 | assert.True(t, mockStream.closeCalled) | ||
| 405 | - | assert.Nil(t, wv.activeAudioStream) | 436 | + | assert.Nil(t, activeAudioStream(wv)) |
| 406 | assert.False(t, wv.audioPlaying.Load()) | 437 | assert.False(t, wv.audioPlaying.Load()) | ||
| 407 | } | 438 | } | ||
| 408 | 439 | ||||
| @@ -410,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) { | |||||
| 410 | // that error handling when no stream exists doesn't panic. | 441 | // that error handling when no stream exists doesn't panic. | ||
| 411 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | 442 | func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) { | ||
| 412 | wv := &WebView{ | 443 | wv := &WebView{ | ||
| 413 | - | ctx: context.Background(), | 444 | + | ctx: context.Background(), |
| 414 | - | activeAudioStream: nil, | |||
| 415 | } | 445 | } | ||
| 416 | wv.audioPlaying.Store(true) | 446 | wv.audioPlaying.Store(true) | ||
| 417 | 447 | ||||
| @@ -464,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T | |||||
| 464 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | 494 | func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | ||
| 465 | mockStream := &mockAudioStream{} | 495 | mockStream := &mockAudioStream{} | ||
| 466 | wv := &WebView{ | 496 | wv := &WebView{ | ||
| 467 | - | ctx: context.Background(), | 497 | + | ctx: context.Background(), |
| 468 | - | activeAudioStream: mockStream, | |||
| 469 | } | 498 | } | ||
| 499 | + | setAudioStream(wv, mockStream) | |||
| 470 | wv.audioPlaying.Store(true) | 500 | wv.audioPlaying.Store(true) | ||
| 471 | 501 | ||||
| 472 | // Act — Destroy must close the audio stream as part of teardown. | 502 | // Act — Destroy must close the audio stream as part of teardown. | ||
| @@ -474,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||||
| 474 | 504 | ||||
| 475 | // Assert | 505 | // Assert | ||
| 476 | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | 506 | assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream") | ||
| 477 | - | assert.Nil(t, wv.activeAudioStream) | 507 | + | assert.Nil(t, activeAudioStream(wv)) |
| 478 | } | 508 | } | ||
| 479 | 509 | ||||
| 480 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | 510 | // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy | ||
| @@ -566,6 +596,61 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) { | |||||
| 566 | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | 596 | assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)") | ||
| 567 | } | 597 | } | ||
| 568 | 598 | ||||
| 599 | + | func TestHandlerSet_OnAudioStreamStarted_StopRejectsInFlightCreation(t *testing.T) { | |||
| 600 | + | stream := &countedAudioStream{} | |||
| 601 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |||
| 602 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 603 | + | handlers := &handlerSet{wv: wv} | |||
| 604 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 605 | + | done := make(chan struct{}) | |||
| 606 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |||
| 607 | + | <-factory.entered | |||
| 608 | + | handlers.OnAudioStreamStopped(nil) | |||
| 609 | + | close(factory.release) | |||
| 610 | + | <-done | |||
| 611 | + | ||||
| 612 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 613 | + | assert.False(t, wv.audioPlaying.Load()) | |||
| 614 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func TestHandlerSet_OnAudioStreamStarted_DestroyRejectsInFlightCreation(t *testing.T) { | |||
| 618 | + | stream := &countedAudioStream{} | |||
| 619 | + | factory := &controlledAudioFactory{entered: make(chan struct{}), release: make(chan struct{}), stream: stream} | |||
| 620 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 621 | + | handlers := &handlerSet{wv: wv} | |||
| 622 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 623 | + | done := make(chan struct{}) | |||
| 624 | + | go func() { handlers.OnAudioStreamStarted(nil, params, 2); close(done) }() | |||
| 625 | + | <-factory.entered | |||
| 626 | + | wv.Destroy() | |||
| 627 | + | close(factory.release) | |||
| 628 | + | <-done | |||
| 629 | + | ||||
| 630 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 631 | + | assert.True(t, wv.destroyed.Load()) | |||
| 632 | + | assert.Equal(t, int32(1), stream.closes.Load()) | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | func TestHandlerSet_OnAudioStreamStarted_ReplacesAndClosesEachStreamOnce(t *testing.T) { | |||
| 636 | + | old, first, second := &countedAudioStream{}, &countedAudioStream{}, &countedAudioStream{} | |||
| 637 | + | factory := &mockAudioFactory{stream: first} | |||
| 638 | + | wv := &WebView{ctx: context.Background(), audioOutputFactory: factory} | |||
| 639 | + | setAudioStream(wv, old) | |||
| 640 | + | handlers := &handlerSet{wv: wv} | |||
| 641 | + | params := &purecef.AudioParameters{SampleRate: 48000, FramesPerBuffer: 512} | |||
| 642 | + | ||||
| 643 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |||
| 644 | + | factory.stream = second | |||
| 645 | + | handlers.OnAudioStreamStarted(nil, params, 2) | |||
| 646 | + | wv.closeAudioStream() | |||
| 647 | + | ||||
| 648 | + | assert.Equal(t, int32(1), old.closes.Load()) | |||
| 649 | + | assert.Equal(t, int32(1), first.closes.Load()) | |||
| 650 | + | assert.Equal(t, int32(1), second.closes.Load()) | |||
| 651 | + | assert.Nil(t, activeAudioStream(wv)) | |||
| 652 | + | } | |||
| 653 | + | ||||
| 569 | // ============================================================================ | 654 | // ============================================================================ | ||
| 570 | // Race-window tests (write/close interleave) | 655 | // Race-window tests (write/close interleave) | ||
| 571 | // ============================================================================ | 656 | // ============================================================================ | ||
| @@ -583,9 +668,9 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { | |||||
| 583 | } | 668 | } | ||
| 584 | 669 | ||||
| 585 | wv := &WebView{ | 670 | wv := &WebView{ | ||
| 586 | - | ctx: ctx, | 671 | + | ctx: ctx, |
| 587 | - | activeAudioStream: stream, | |||
| 588 | } | 672 | } | ||
| 673 | + | setAudioStream(wv, stream) | |||
| 589 | handlers := &handlerSet{wv: wv} | 674 | handlers := &handlerSet{wv: wv} | ||
| 590 | 675 | ||||
| 591 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | 676 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | ||
| @@ -613,7 +698,7 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) { | |||||
| 613 | close(stream.doneCh) | 698 | close(stream.doneCh) | ||
| 614 | 699 | ||||
| 615 | assert.True(t, stream.closeCalled) | 700 | assert.True(t, stream.closeCalled) | ||
| 616 | - | assert.Nil(t, wv.activeAudioStream) | 701 | + | assert.Nil(t, activeAudioStream(wv)) |
| 617 | } | 702 | } | ||
| 618 | 703 | ||||
| 619 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | 704 | // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a | ||
| @@ -624,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) { | |||||
| 624 | stream := &mockAudioStream{} | 709 | stream := &mockAudioStream{} | ||
| 625 | 710 | ||||
| 626 | wv := &WebView{ | 711 | wv := &WebView{ | ||
| 627 | - | ctx: ctx, | 712 | + | ctx: ctx, |
| 628 | - | activeAudioStream: stream, | |||
| 629 | } | 713 | } | ||
| 714 | + | setAudioStream(wv, stream) | |||
| 630 | handlers := &handlerSet{wv: wv} | 715 | handlers := &handlerSet{wv: wv} | ||
| 631 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | 716 | data := [][]float32{{0.1, 0.2}, {0.3, 0.4}} | ||
| 632 | 717 | ||||
M internal/infrastructure/cef/webview_handlers.go +27 -53
| @@ -1134,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud | |||
| 1134 | 1134 | Msg("cef: OnAudioStreamStarted") | |
| 1135 | 1135 | } | |
| 1136 | 1136 | ||
| 1137 | - | // Reset packet counters for the new stream | |
| 1137 | + | // Starting a stream invalidates both a previous publication and any older | |
| 1138 | + | // creation already in flight. Close detached streams outside the mutex. | |
| 1139 | + | generation, previous, accepted := h.wv.beginAudioStreamStart() | |
| 1140 | + | if !accepted { | |
| 1141 | + | return | |
| 1142 | + | } | |
| 1143 | + | h.wv.closeDetachedAudioStream(previous) | |
| 1138 | 1144 | h.wv.audioPacketCount.Store(0) | |
| 1139 | 1145 | h.wv.audioWriteCount.Store(0) | |
| 1140 | 1146 | ||
| 1141 | - | // Close any existing stream first | |
| 1142 | - | h.wv.closeAudioStream() | |
| 1143 | - | ||
| 1144 | - | // Create new stream | |
| 1145 | 1147 | ctx := h.wv.ctx | |
| 1146 | 1148 | if ctx == nil { | |
| 1147 | 1149 | ctx = context.Background() | |
| 1148 | 1150 | } | |
| 1149 | - | ||
| 1150 | 1151 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | |
| 1151 | 1152 | if err != nil { | |
| 1152 | - | // Log error but don't panic - audio is non-critical. | |
| 1153 | - | // Do NOT set audioPlaying — the stream was never created. | |
| 1154 | 1153 | if h.wv.ctx != nil { | |
| 1155 | - | logging.FromContext(h.wv.ctx).Warn(). | |
| 1156 | - | Err(err). | |
| 1157 | - | Int("sample_rate", format.SampleRate). | |
| 1158 | - | Int("channels", format.ChannelCount). | |
| 1154 | + | logging.FromContext(h.wv.ctx).Warn().Err(err). | |
| 1155 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). | |
| 1159 | 1156 | Msg("cef: failed to create audio output stream") | |
| 1160 | 1157 | } | |
| 1161 | 1158 | return | |
| 1162 | 1159 | } | |
| 1163 | 1160 | ||
| 1164 | - | // Set playing state only after stream creation succeeds | |
| 1165 | - | h.wv.setAudioPlaying(true) | |
| 1166 | - | ||
| 1161 | + | replaced, published := h.wv.publishAudioStream(generation, stream) | |
| 1162 | + | if !published { | |
| 1163 | + | // Stop, Destroy, or a newer Start won the race. This callback owns the | |
| 1164 | + | // unpublishable stream and closes it exactly once. | |
| 1165 | + | h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation}) | |
| 1166 | + | return | |
| 1167 | + | } | |
| 1168 | + | h.wv.closeDetachedAudioStream(replaced) | |
| 1167 | 1169 | if h.wv.ctx != nil { | |
| 1168 | 1170 | logging.FromContext(h.wv.ctx).Info(). | |
| 1169 | - | Int("sample_rate", format.SampleRate). | |
| 1170 | - | Int("channels", format.ChannelCount). | |
| 1171 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). | |
| 1171 | 1172 | Int("frames_per_buffer", format.FramesPerBuffer). | |
| 1172 | 1173 | Msg("cef: audio output stream created successfully") | |
| 1173 | 1174 | } | |
| 1174 | - | ||
| 1175 | - | // Store the new stream | |
| 1176 | - | h.wv.audioStreamMu.Lock() | |
| 1177 | - | h.wv.activeAudioStream = stream | |
| 1178 | - | h.wv.audioStreamMu.Unlock() | |
| 1179 | 1175 | } | |
| 1180 | 1176 | ||
| 1181 | 1177 | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | |
| @@ -1189,43 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha | |||
| 1189 | 1185 | } | |
| 1190 | 1186 | } | |
| 1191 | 1187 | ||
| 1192 | - | // OnAudioStreamPacket hands CEF's callback-owned packet to the active output. | |
| 1193 | - | // AudioOutputStream takes its one owned copy synchronously. The stream lock is | |
| 1194 | - | // only held for the snapshot, so neither copying nor the callback is serialized | |
| 1195 | - | // behind lifecycle work; Write and Close are a port-level concurrent contract. | |
| 1196 | - | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) { | |
| 1188 | + | // OnAudioStreamPacket is a real-time callback: it atomically snapshots an | |
| 1189 | + | // immutable handle and performs only allocation-free atomic diagnostics. It | |
| 1190 | + | // never takes a lifecycle mutex or emits synchronous logs. | |
| 1191 | + | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) { | |
| 1197 | 1192 | if len(data) == 0 || frames <= 0 { | |
| 1198 | 1193 | return | |
| 1199 | 1194 | } | |
| 1200 | - | ||
| 1201 | - | h.wv.audioStreamMu.Lock() | |
| 1202 | - | stream := h.wv.activeAudioStream | |
| 1203 | - | h.wv.audioStreamMu.Unlock() | |
| 1204 | - | if stream == nil { | |
| 1195 | + | handle := h.wv.audioStream.Load() | |
| 1196 | + | if handle == nil || handle.stream == nil { | |
| 1205 | 1197 | return | |
| 1206 | 1198 | } | |
| 1207 | - | ||
| 1208 | - | pktNum := h.wv.audioPacketCount.Add(1) | |
| 1209 | - | if pktNum == 1 && h.wv.ctx != nil { | |
| 1210 | - | logging.FromContext(h.wv.ctx).Info(). | |
| 1211 | - | Int("channels", len(data)). | |
| 1212 | - | Int32("frames", frames). | |
| 1213 | - | Int64("pts", pts). | |
| 1214 | - | Msg("cef: first audio packet received") | |
| 1215 | - | } | |
| 1216 | - | ||
| 1217 | - | err := stream.Write(data) | |
| 1218 | - | ||
| 1219 | - | if err != nil { | |
| 1220 | - | if h.wv.ctx != nil { | |
| 1221 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 1222 | - | Err(err). | |
| 1223 | - | Uint64("packets_received", pktNum). | |
| 1224 | - | Msg("cef: audio stream write failed") | |
| 1225 | - | } | |
| 1226 | - | return | |
| 1199 | + | h.wv.audioPacketCount.Add(1) | |
| 1200 | + | if handle.stream.Write(data) == nil { | |
| 1201 | + | h.wv.audioWriteCount.Add(1) | |
| 1227 | 1202 | } | |
| 1228 | - | h.wv.audioWriteCount.Add(1) | |
| 1229 | 1203 | } | |
| 1230 | 1204 | ||
| 1231 | 1205 | // OnAudioStreamStopped handles stream stop. | |
| @@ -1134,48 +1134,44 @@ func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.Aud | |||||
| 1134 | Msg("cef: OnAudioStreamStarted") | 1134 | Msg("cef: OnAudioStreamStarted") | ||
| 1135 | } | 1135 | } | ||
| 1136 | 1136 | ||||
| 1137 | - | // Reset packet counters for the new stream | 1137 | + | // Starting a stream invalidates both a previous publication and any older |
| 1138 | + | // creation already in flight. Close detached streams outside the mutex. | |||
| 1139 | + | generation, previous, accepted := h.wv.beginAudioStreamStart() | |||
| 1140 | + | if !accepted { | |||
| 1141 | + | return | |||
| 1142 | + | } | |||
| 1143 | + | h.wv.closeDetachedAudioStream(previous) | |||
| 1138 | h.wv.audioPacketCount.Store(0) | 1144 | h.wv.audioPacketCount.Store(0) | ||
| 1139 | h.wv.audioWriteCount.Store(0) | 1145 | h.wv.audioWriteCount.Store(0) | ||
| 1140 | 1146 | ||||
| 1141 | - | // Close any existing stream first | |||
| 1142 | - | h.wv.closeAudioStream() | |||
| 1143 | - | ||||
| 1144 | - | // Create new stream | |||
| 1145 | ctx := h.wv.ctx | 1147 | ctx := h.wv.ctx | ||
| 1146 | if ctx == nil { | 1148 | if ctx == nil { | ||
| 1147 | ctx = context.Background() | 1149 | ctx = context.Background() | ||
| 1148 | } | 1150 | } | ||
| 1149 | - | ||||
| 1150 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | 1151 | stream, err := h.wv.audioOutputFactory.NewStream(ctx, format) | ||
| 1151 | if err != nil { | 1152 | if err != nil { | ||
| 1152 | - | // Log error but don't panic - audio is non-critical. | |||
| 1153 | - | // Do NOT set audioPlaying — the stream was never created. | |||
| 1154 | if h.wv.ctx != nil { | 1153 | if h.wv.ctx != nil { | ||
| 1155 | - | logging.FromContext(h.wv.ctx).Warn(). | 1154 | + | logging.FromContext(h.wv.ctx).Warn().Err(err). |
| 1156 | - | Err(err). | 1155 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). |
| 1157 | - | Int("sample_rate", format.SampleRate). | |||
| 1158 | - | Int("channels", format.ChannelCount). | |||
| 1159 | Msg("cef: failed to create audio output stream") | 1156 | Msg("cef: failed to create audio output stream") | ||
| 1160 | } | 1157 | } | ||
| 1161 | return | 1158 | return | ||
| 1162 | } | 1159 | } | ||
| 1163 | 1160 | ||||
| 1164 | - | // Set playing state only after stream creation succeeds | 1161 | + | replaced, published := h.wv.publishAudioStream(generation, stream) |
| 1165 | - | h.wv.setAudioPlaying(true) | 1162 | + | if !published { |
| 1166 | - | 1163 | + | // Stop, Destroy, or a newer Start won the race. This callback owns the | |
| 1164 | + | // unpublishable stream and closes it exactly once. | |||
| 1165 | + | h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation}) | |||
| 1166 | + | return | |||
| 1167 | + | } | |||
| 1168 | + | h.wv.closeDetachedAudioStream(replaced) | |||
| 1167 | if h.wv.ctx != nil { | 1169 | if h.wv.ctx != nil { | ||
| 1168 | logging.FromContext(h.wv.ctx).Info(). | 1170 | logging.FromContext(h.wv.ctx).Info(). | ||
| 1169 | - | Int("sample_rate", format.SampleRate). | 1171 | + | Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount). |
| 1170 | - | Int("channels", format.ChannelCount). | |||
| 1171 | Int("frames_per_buffer", format.FramesPerBuffer). | 1172 | Int("frames_per_buffer", format.FramesPerBuffer). | ||
| 1172 | Msg("cef: audio output stream created successfully") | 1173 | Msg("cef: audio output stream created successfully") | ||
| 1173 | } | 1174 | } | ||
| 1174 | - | ||||
| 1175 | - | // Store the new stream | |||
| 1176 | - | h.wv.audioStreamMu.Lock() | |||
| 1177 | - | h.wv.activeAudioStream = stream | |||
| 1178 | - | h.wv.audioStreamMu.Unlock() | |||
| 1179 | } | 1175 | } | ||
| 1180 | 1176 | ||||
| 1181 | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | 1177 | // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat. | ||
| @@ -1189,43 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha | |||||
| 1189 | } | 1185 | } | ||
| 1190 | } | 1186 | } | ||
| 1191 | 1187 | ||||
| 1192 | - | // OnAudioStreamPacket hands CEF's callback-owned packet to the active output. | 1188 | + | // OnAudioStreamPacket is a real-time callback: it atomically snapshots an |
| 1193 | - | // AudioOutputStream takes its one owned copy synchronously. The stream lock is | 1189 | + | // immutable handle and performs only allocation-free atomic diagnostics. It |
| 1194 | - | // only held for the snapshot, so neither copying nor the callback is serialized | 1190 | + | // never takes a lifecycle mutex or emits synchronous logs. |
| 1195 | - | // behind lifecycle work; Write and Close are a port-level concurrent contract. | 1191 | + | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) { |
| 1196 | - | func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) { | |||
| 1197 | if len(data) == 0 || frames <= 0 { | 1192 | if len(data) == 0 || frames <= 0 { | ||
| 1198 | return | 1193 | return | ||
| 1199 | } | 1194 | } | ||
| 1200 | - | 1195 | + | handle := h.wv.audioStream.Load() | |
| 1201 | - | h.wv.audioStreamMu.Lock() | 1196 | + | if handle == nil || handle.stream == nil { |
| 1202 | - | stream := h.wv.activeAudioStream | |||
| 1203 | - | h.wv.audioStreamMu.Unlock() | |||
| 1204 | - | if stream == nil { | |||
| 1205 | return | 1197 | return | ||
| 1206 | } | 1198 | } | ||
| 1207 | - | 1199 | + | h.wv.audioPacketCount.Add(1) | |
| 1208 | - | pktNum := h.wv.audioPacketCount.Add(1) | 1200 | + | if handle.stream.Write(data) == nil { |
| 1209 | - | if pktNum == 1 && h.wv.ctx != nil { | 1201 | + | h.wv.audioWriteCount.Add(1) |
| 1210 | - | logging.FromContext(h.wv.ctx).Info(). | |||
| 1211 | - | Int("channels", len(data)). | |||
| 1212 | - | Int32("frames", frames). | |||
| 1213 | - | Int64("pts", pts). | |||
| 1214 | - | Msg("cef: first audio packet received") | |||
| 1215 | - | } | |||
| 1216 | - | ||||
| 1217 | - | err := stream.Write(data) | |||
| 1218 | - | ||||
| 1219 | - | if err != nil { | |||
| 1220 | - | if h.wv.ctx != nil { | |||
| 1221 | - | logging.FromContext(h.wv.ctx).Debug(). | |||
| 1222 | - | Err(err). | |||
| 1223 | - | Uint64("packets_received", pktNum). | |||
| 1224 | - | Msg("cef: audio stream write failed") | |||
| 1225 | - | } | |||
| 1226 | - | return | |||
| 1227 | } | 1202 | } | ||
| 1228 | - | h.wv.audioWriteCount.Add(1) | |||
| 1229 | } | 1203 | } | ||
| 1230 | 1204 | ||||
| 1231 | // OnAudioStreamStopped handles stream stop. | 1205 | // OnAudioStreamStopped handles stream stop. | ||