detached 5f3e0e8

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
198198 }
199199
200200 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)
205204 }
206205
207206 // 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-handoff206 // 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 {
250250 inputDiagDeltaXSum int64
251251 inputDiagDeltaYSum int64
252252
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]
257259
258260 // Audio instrumentation counters (diagnostic only).
259261 audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
260262 audioWriteCount atomic.Uint64 // successful Write calls to stream
261263 }
262264
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+
263272 // pendingBrowserCreate holds the parameters needed to create a CEF browser,
264273 // deferred until the GL area has a non-zero size.
265274 type pendingBrowserCreate struct {
@@ -2331,17 +2340,50 @@ func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
23312340 return pc
23322341 }
23332342
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.
23372375 func (wv *WebView) closeAudioStream() {
23382376 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)
23412380 wv.audioStreamMu.Unlock()
2381+ wv.closeDetachedAudioStream(stream)
2382+}
23422383
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 {
23452387 logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream")
23462388 }
23472389 }
@@ -250,16 +250,25 @@ type WebView struct {
250 inputDiagDeltaXSum int64250 inputDiagDeltaXSum int64
251 inputDiagDeltaYSum int64251 inputDiagDeltaYSum int64
252 252
253- // Audio output factory and active stream.253+ // Audio output lifecycle. Packet callbacks load audioStream directly: its
254- audioOutputFactory port.AudioOutputFactory254+ // immutable handle is atomically published and never requires a mutex.
255- audioStreamMu sync.Mutex255+ audioOutputFactory port.AudioOutputFactory
256- activeAudioStream port.AudioOutputStream256+ 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 calls261 audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
260 audioWriteCount atomic.Uint64 // successful Write calls to stream262 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 pc2340 return pc
2332 }2341 }
2333 2342
2334-// closeAudioStream detaches then closes the active output stream. Detaching2343+// beginAudioStreamStart invalidates any prior stream before creating a new
2335-// under the lock keeps packet snapshots consistent without holding the lock2344+// 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.activeAudioStream2377+ wv.audioStreamGeneration.Add(1)
2340- wv.activeAudioStream = nil2378+ 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 (
44 "bytes"
55 "context"
66 "errors"
7+ "sync/atomic"
78 "testing"
89 "time"
910
@@ -83,10 +84,10 @@ func TestWebView_AudioStreamLifecycleFieldsExist(t *testing.T) {
8384 }
8485
8586 // Verify the fields exist and can be set
86- wv.activeAudioStream = &mockAudioStream{}
87+ setAudioStream(wv, &mockAudioStream{})
8788
8889 assert.NotNil(t, wv.audioOutputFactory)
89- assert.NotNil(t, wv.activeAudioStream)
90+ assert.NotNil(t, activeAudioStream(wv))
9091 }
9192
9293 // mockAudioStream is a test double for port.AudioOutputStream
@@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error {
110111 // Verify mock implements the interface
111112 var _ port.AudioOutputStream = (*mockAudioStream)(nil)
112113
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+
113126 // racyMockAudioStream blocks inside Write until doneCh is closed, allowing
114127 // tests to verify that the caller holds a lock across the Write call.
115128 type racyMockAudioStream struct {
@@ -131,6 +144,26 @@ func (m *racyMockAudioStream) Close() error {
131144
132145 var _ port.AudioOutputStream = (*racyMockAudioStream)(nil)
133146
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+
134167 // ============================================================================
135168 // Task 6: Audio Stream Lifecycle Handling Tests
136169 // ============================================================================
@@ -159,8 +192,8 @@ func TestHandlerSet_OnAudioStreamStarted_CreatesStream(t *testing.T) {
159192 handlers.OnAudioStreamStarted(nil, params, 2)
160193
161194 // 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))
164197 assert.True(t, wv.audioPlaying.Load())
165198
166199 // Verify factory was called with correct format
@@ -205,8 +238,8 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
205238 wv := &WebView{
206239 ctx: ctx,
207240 audioOutputFactory: mockFactory,
208- activeAudioStream: oldStream,
209241 }
242+ setAudioStream(wv, oldStream)
210243
211244 handlers := &handlerSet{wv: wv}
212245
@@ -221,7 +254,7 @@ func TestHandlerSet_OnAudioStreamStarted_ClosesExistingStream(t *testing.T) {
221254
222255 // Assert
223256 assert.True(t, oldStream.closeCalled)
224- assert.Equal(t, newStream, wv.activeAudioStream)
257+ assert.Equal(t, newStream, activeAudioStream(wv))
225258 }
226259
227260 // TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully verifies
@@ -251,7 +284,7 @@ func TestHandlerSet_OnAudioStreamStarted_FactoryError_HandlesGracefully(t *testi
251284 // audioPlaying must be false when NewStream fails — if it stays true,
252285 // the UI/state layer incorrectly believes audio is active.
253286 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")
255288 }
256289
257290 // ============================================================================
@@ -265,9 +298,9 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) {
265298 mockStream := &mockAudioStream{}
266299
267300 wv := &WebView{
268- ctx: ctx,
269- activeAudioStream: mockStream,
301+ ctx: ctx,
270302 }
303+ setAudioStream(wv, mockStream)
271304
272305 handlers := &handlerSet{wv: wv}
273306
@@ -296,8 +329,7 @@ func TestHandlerSet_OnAudioStreamPacket_ForwardsCallbackData(t *testing.T) {
296329 // that packets are silently discarded when no stream is active.
297330 func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T) {
298331 wv := &WebView{
299- ctx: context.Background(),
300- activeAudioStream: nil,
332+ ctx: context.Background(),
301333 }
302334
303335 handlers := &handlerSet{wv: wv}
@@ -316,10 +348,8 @@ func TestHandlerSet_OnAudioStreamPacket_NoActiveStream_DoesNothing(t *testing.T)
316348 // TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic verifies
317349 // that nil data is handled gracefully.
318350 func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
319- mockStream := &mockAudioStream{}
320351 wv := &WebView{
321- ctx: context.Background(),
322- activeAudioStream: mockStream,
352+ ctx: context.Background(),
323353 }
324354
325355 handlers := &handlerSet{wv: wv}
@@ -332,7 +362,9 @@ func TestHandlerSet_OnAudioStreamPacket_NilData_DoesNotPanic(t *testing.T) {
332362
333363 func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) {
334364 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}
336368 data := [][]float32{make([]float32, 512), make([]float32, 512)}
337369 b.ReportAllocs()
338370 b.SetBytes(2 * 512 * 4)
@@ -350,9 +382,9 @@ func BenchmarkHandlerSet_OnAudioStreamPacketStereo(b *testing.B) {
350382 func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
351383 mockStream := &mockAudioStream{}
352384 wv := &WebView{
353- ctx: context.Background(),
354- activeAudioStream: mockStream,
385+ ctx: context.Background(),
355386 }
387+ setAudioStream(wv, mockStream)
356388 wv.audioPlaying.Store(true)
357389
358390 handlers := &handlerSet{wv: wv}
@@ -362,7 +394,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
362394
363395 // Assert
364396 assert.True(t, mockStream.closeCalled)
365- assert.Nil(t, wv.activeAudioStream)
397+ assert.Nil(t, activeAudioStream(wv))
366398 assert.False(t, wv.audioPlaying.Load())
367399 }
368400
@@ -370,8 +402,7 @@ func TestHandlerSet_OnAudioStreamStopped_ClosesStream(t *testing.T) {
370402 // that stopping when no stream exists doesn't panic.
371403 func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.T) {
372404 wv := &WebView{
373- ctx: context.Background(),
374- activeAudioStream: nil,
405+ ctx: context.Background(),
375406 }
376407 wv.audioPlaying.Store(true)
377408
@@ -390,9 +421,9 @@ func TestHandlerSet_OnAudioStreamStopped_NoActiveStream_DoesNotPanic(t *testing.
390421 func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
391422 mockStream := &mockAudioStream{}
392423 wv := &WebView{
393- ctx: context.Background(),
394- activeAudioStream: mockStream,
424+ ctx: context.Background(),
395425 }
426+ setAudioStream(wv, mockStream)
396427 wv.audioPlaying.Store(true)
397428
398429 handlers := &handlerSet{wv: wv}
@@ -402,7 +433,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
402433
403434 // Assert
404435 assert.True(t, mockStream.closeCalled)
405- assert.Nil(t, wv.activeAudioStream)
436+ assert.Nil(t, activeAudioStream(wv))
406437 assert.False(t, wv.audioPlaying.Load())
407438 }
408439
@@ -410,8 +441,7 @@ func TestHandlerSet_OnAudioStreamError_ClosesStream(t *testing.T) {
410441 // that error handling when no stream exists doesn't panic.
411442 func TestHandlerSet_OnAudioStreamError_NoActiveStream_DoesNotPanic(t *testing.T) {
412443 wv := &WebView{
413- ctx: context.Background(),
414- activeAudioStream: nil,
444+ ctx: context.Background(),
415445 }
416446 wv.audioPlaying.Store(true)
417447
@@ -464,9 +494,9 @@ func TestHandlerSet_OnAudioStreamError_ShutdownSocketCloseLogsDebug(t *testing.T
464494 func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
465495 mockStream := &mockAudioStream{}
466496 wv := &WebView{
467- ctx: context.Background(),
468- activeAudioStream: mockStream,
497+ ctx: context.Background(),
469498 }
499+ setAudioStream(wv, mockStream)
470500 wv.audioPlaying.Store(true)
471501
472502 // Act — Destroy must close the audio stream as part of teardown.
@@ -474,7 +504,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
474504
475505 // Assert
476506 assert.True(t, mockStream.closeCalled, "Destroy must close the active audio stream")
477- assert.Nil(t, wv.activeAudioStream)
507+ assert.Nil(t, activeAudioStream(wv))
478508 }
479509
480510 // TestWebView_Destroy_NoAudioStream_DoesNotPanic verifies that Destroy
@@ -566,6 +596,61 @@ func TestOnAudioStreamStarted_CorrectParameterMapping(t *testing.T) {
566596 assert.Equal(t, 1024, mockFactory.lastFormat.FramesPerBuffer, "frames_per_buffer should be 1024 (from params.FramesPerBuffer)")
567597 }
568598
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+
569654 // ============================================================================
570655 // Race-window tests (write/close interleave)
571656 // ============================================================================
@@ -583,9 +668,9 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) {
583668 }
584669
585670 wv := &WebView{
586- ctx: ctx,
587- activeAudioStream: stream,
671+ ctx: ctx,
588672 }
673+ setAudioStream(wv, stream)
589674 handlers := &handlerSet{wv: wv}
590675
591676 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
@@ -613,7 +698,7 @@ func TestOnAudioStreamPacket_CloseDoesNotWaitForWrite(t *testing.T) {
613698 close(stream.doneCh)
614699
615700 assert.True(t, stream.closeCalled)
616- assert.Nil(t, wv.activeAudioStream)
701+ assert.Nil(t, activeAudioStream(wv))
617702 }
618703
619704 // TestOnAudioStreamPacket_StreamClosedBetweenPackets verifies that when a
@@ -624,9 +709,9 @@ func TestOnAudioStreamPacket_StreamClosedBetweenPackets(t *testing.T) {
624709 stream := &mockAudioStream{}
625710
626711 wv := &WebView{
627- ctx: ctx,
628- activeAudioStream: stream,
712+ ctx: ctx,
629713 }
714+ setAudioStream(wv, stream)
630715 handlers := &handlerSet{wv: wv}
631716 data := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
632717
@@ -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 set86 // 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.AudioOutputStream93 // mockAudioStream is a test double for port.AudioOutputStream
@@ -110,6 +111,18 @@ func (m *mockAudioStream) Close() error {
110 // Verify mock implements the interface111 // 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, allowing126 // 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 Tests168 // 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 // Assert194 // 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 format199 // 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 // Assert255 // 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 verifies260 // 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 verifies348 // 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 // Assert395 // 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 // Assert434 // 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 // Assert505 // 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 Destroy510 // 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 a704 // 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
11341134 Msg("cef: OnAudioStreamStarted")
11351135 }
11361136
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)
11381144 h.wv.audioPacketCount.Store(0)
11391145 h.wv.audioWriteCount.Store(0)
11401146
1141- // Close any existing stream first
1142- h.wv.closeAudioStream()
1143-
1144- // Create new stream
11451147 ctx := h.wv.ctx
11461148 if ctx == nil {
11471149 ctx = context.Background()
11481150 }
1149-
11501151 stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
11511152 if err != nil {
1152- // Log error but don't panic - audio is non-critical.
1153- // Do NOT set audioPlaying — the stream was never created.
11541153 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).
11591156 Msg("cef: failed to create audio output stream")
11601157 }
11611158 return
11621159 }
11631160
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)
11671169 if h.wv.ctx != nil {
11681170 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).
11711172 Int("frames_per_buffer", format.FramesPerBuffer).
11721173 Msg("cef: audio output stream created successfully")
11731174 }
1174-
1175- // Store the new stream
1176- h.wv.audioStreamMu.Lock()
1177- h.wv.activeAudioStream = stream
1178- h.wv.audioStreamMu.Unlock()
11791175 }
11801176
11811177 // buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
@@ -1189,43 +1185,21 @@ func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, cha
11891185 }
11901186 }
11911187
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) {
11971192 if len(data) == 0 || frames <= 0 {
11981193 return
11991194 }
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 {
12051197 return
12061198 }
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)
12271202 }
1228- h.wv.audioWriteCount.Add(1)
12291203 }
12301204
12311205 // 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 stream1137+ // 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.ctx1147 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 return1158 return
1162 }1159 }
1163 1160
1164- // Set playing state only after stream creation succeeds1161+ 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 is1189+// immutable handle and performs only allocation-free atomic diagnostics. It
1194-// only held for the snapshot, so neither copying nor the callback is serialized1190+// 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 return1193 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 return1197 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close