detached 715769b

fix: pin PipeWire connect params for stream lifetime

commit
715769bb29df437cb8d77120e69f83306913a218
author
brice · · 2026-04-10 13:42 UTC
parent
af7a9d7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +140 -3
M internal/capi/stream_ops.go +24 -3
@@ -49,9 +49,18 @@ type streamCallbackStorage struct {
4949
5050 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API
5151 // bindings registered via purego.
52+//
53+// Ownership/lifetime contract:
54+// - pinned holds streamCallbackStorage for each stream created via CreatePlaybackStream.
55+// It keeps Go callback closures alive so PipeWire can call them safely.
56+// - pinnedParams holds connectParams for each stream connected via ConnectPlaybackStream.
57+// PipeWire reads the SPA POD params asynchronously during format negotiation, so the
58+// backing byte storage must remain live until DestroyStream releases it.
59+// - Both maps are keyed by the stream pointer and must be cleaned up in DestroyStream.
5260 type streamOpsImpl struct {
53- mu sync.Mutex
54- pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
61+ mu sync.Mutex
62+ pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
63+ pinnedParams map[unsafe.Pointer]*connectParams // keyed by stream ptr
5564 }
5665
5766 // Verify interface compliance at compile time.
@@ -120,6 +129,17 @@ func (s *streamOpsImpl) ConnectPlaybackStream(streamPtr unsafe.Pointer, format p
120129 if ret < 0 {
121130 return &PWError{Func: "pw_stream_connect", Code: ret}
122131 }
132+
133+ // Pin the connectParams for the lifetime of the stream. PipeWire reads
134+ // the SPA POD params asynchronously during format negotiation, so the
135+ // backing storage must remain alive until the stream is destroyed.
136+ s.mu.Lock()
137+ if s.pinnedParams == nil {
138+ s.pinnedParams = make(map[unsafe.Pointer]*connectParams)
139+ }
140+ s.pinnedParams[streamPtr] = cp
141+ s.mu.Unlock()
142+
123143 return nil
124144 }
125145
@@ -160,8 +180,9 @@ func (s *streamOpsImpl) DestroyStream(streamPtr unsafe.Pointer) {
160180 s.mu.Unlock()
161181 return
162182 }
163- // Unpin callback storage — safe now that the stream is being destroyed.
183+ // Unpin callback storage and connect params — safe now that the stream is being destroyed.
164184 delete(s.pinned, streamPtr)
185+ delete(s.pinnedParams, streamPtr)
165186 s.mu.Unlock()
166187
167188 // Use the generated pw_stream_destroy binding for proper cleanup.
@@ -49,9 +49,18 @@ type streamCallbackStorage struct {
49 49
50 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API50 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API
51 // bindings registered via purego.51 // bindings registered via purego.
52+//
53+// Ownership/lifetime contract:
54+// - pinned holds streamCallbackStorage for each stream created via CreatePlaybackStream.
55+// It keeps Go callback closures alive so PipeWire can call them safely.
56+// - pinnedParams holds connectParams for each stream connected via ConnectPlaybackStream.
57+// PipeWire reads the SPA POD params asynchronously during format negotiation, so the
58+// backing byte storage must remain live until DestroyStream releases it.
59+// - Both maps are keyed by the stream pointer and must be cleaned up in DestroyStream.
52 type streamOpsImpl struct {60 type streamOpsImpl struct {
53- mu sync.Mutex61+ mu sync.Mutex
54- pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr62+ pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
63+ pinnedParams map[unsafe.Pointer]*connectParams // keyed by stream ptr
55 }64 }
56 65
57 // Verify interface compliance at compile time.66 // Verify interface compliance at compile time.
@@ -120,6 +129,17 @@ func (s *streamOpsImpl) ConnectPlaybackStream(streamPtr unsafe.Pointer, format p
120 if ret < 0 {129 if ret < 0 {
121 return &PWError{Func: "pw_stream_connect", Code: ret}130 return &PWError{Func: "pw_stream_connect", Code: ret}
122 }131 }
132+
133+ // Pin the connectParams for the lifetime of the stream. PipeWire reads
134+ // the SPA POD params asynchronously during format negotiation, so the
135+ // backing storage must remain alive until the stream is destroyed.
136+ s.mu.Lock()
137+ if s.pinnedParams == nil {
138+ s.pinnedParams = make(map[unsafe.Pointer]*connectParams)
139+ }
140+ s.pinnedParams[streamPtr] = cp
141+ s.mu.Unlock()
142+
123 return nil143 return nil
124 }144 }
125 145
@@ -160,8 +180,9 @@ func (s *streamOpsImpl) DestroyStream(streamPtr unsafe.Pointer) {
160 s.mu.Unlock()180 s.mu.Unlock()
161 return181 return
162 }182 }
163- // Unpin callback storage — safe now that the stream is being destroyed.183+ // Unpin callback storage and connect params — safe now that the stream is being destroyed.
164 delete(s.pinned, streamPtr)184 delete(s.pinned, streamPtr)
185+ delete(s.pinnedParams, streamPtr)
165 s.mu.Unlock()186 s.mu.Unlock()
166 187
167 // Use the generated pw_stream_destroy binding for proper cleanup.188 // Use the generated pw_stream_destroy binding for proper cleanup.
M internal/capi/stream_ops_test.go +116 -0
@@ -496,3 +496,119 @@ func TestConnectPlaybackStreamPassesParamsFromHelper(t *testing.T) {
496496 t.Fatal("expected nParams > 0 from buildRawAudioParams, got 0")
497497 }
498498 }
499+
500+// TestConnectPlaybackStreamPinsParamsAfterSuccess verifies that ConnectPlaybackStream
501+// stores the connectParams in pinnedParams so the SPA POD backing storage outlives
502+// the function call.
503+func TestConnectPlaybackStreamPinsParamsAfterSuccess(t *testing.T) {
504+ origConnect := pw_stream_connect
505+ t.Cleanup(func() { pw_stream_connect = origConnect })
506+
507+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
508+ return 0
509+ }
510+
511+ ops := &streamOpsImpl{}
512+ fakePtr := unsafe.Pointer(uintptr(0x5678))
513+
514+ validFmt := portout.PlaybackFormat{
515+ SampleRate: 48000,
516+ Channels: 2,
517+ FramesPerBuffer: 1024,
518+ }
519+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
520+ if err != nil {
521+ t.Fatalf("unexpected error: %v", err)
522+ }
523+
524+ cp, ok := ops.pinnedParams[fakePtr]
525+ if !ok {
526+ t.Fatal("expected connectParams to be pinned for stream after ConnectPlaybackStream")
527+ }
528+ if cp == nil {
529+ t.Fatal("pinned connectParams should not be nil")
530+ }
531+ // Verify the pinned params point to real SPA POD data.
532+ if cp.Count() != 1 {
533+ t.Fatalf("expected pinned params Count()=1, got %d", cp.Count())
534+ }
535+ if cp.Pointer() == nil {
536+ t.Fatal("expected pinned params Pointer() to be non-nil")
537+ }
538+}
539+
540+// TestConnectPlaybackStreamDoesNotPinParamsOnFailure verifies that when pw_stream_connect
541+// returns a negative result, connectParams are NOT pinned.
542+func TestConnectPlaybackStreamDoesNotPinParamsOnFailure(t *testing.T) {
543+ origConnect := pw_stream_connect
544+ t.Cleanup(func() { pw_stream_connect = origConnect })
545+
546+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
547+ return -22 // EINVAL
548+ }
549+
550+ ops := &streamOpsImpl{}
551+ fakePtr := unsafe.Pointer(uintptr(0x5678))
552+
553+ validFmt := portout.PlaybackFormat{
554+ SampleRate: 48000,
555+ Channels: 2,
556+ FramesPerBuffer: 1024,
557+ }
558+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
559+ if err == nil {
560+ t.Fatal("expected error for negative return code")
561+ }
562+
563+ if _, ok := ops.pinnedParams[fakePtr]; ok {
564+ t.Fatal("connectParams should NOT be pinned when pw_stream_connect fails")
565+ }
566+}
567+
568+// TestDestroyStreamReleasesPinnedParams verifies that DestroyStream removes both the
569+// callback storage and the connect params from the pinned maps.
570+func TestDestroyStreamReleasesPinnedParams(t *testing.T) {
571+ origConnect := pw_stream_connect
572+ origDestroy := pw_stream_destroy
573+ t.Cleanup(func() {
574+ pw_stream_connect = origConnect
575+ pw_stream_destroy = origDestroy
576+ })
577+
578+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
579+ return 0
580+ }
581+ pw_stream_destroy = func(_ unsafe.Pointer) {}
582+
583+ ops := &streamOpsImpl{
584+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
585+ }
586+
587+ fakePtr := unsafe.Pointer(uintptr(0xDEAD))
588+ ops.pinned[fakePtr] = &streamCallbackStorage{}
589+
590+ validFmt := portout.PlaybackFormat{
591+ SampleRate: 48000,
592+ Channels: 2,
593+ FramesPerBuffer: 1024,
594+ }
595+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
596+ if err != nil {
597+ t.Fatalf("unexpected error: %v", err)
598+ }
599+
600+ // Verify params are pinned.
601+ if _, ok := ops.pinnedParams[fakePtr]; !ok {
602+ t.Fatal("expected connectParams to be pinned before DestroyStream")
603+ }
604+
605+ ops.DestroyStream(fakePtr)
606+
607+ // Both maps should no longer contain the stream.
608+ if _, ok := ops.pinned[fakePtr]; ok {
609+ t.Error("expected callback storage to be removed after DestroyStream")
610+ }
611+ if _, ok := ops.pinnedParams[fakePtr]; ok {
612+ t.Error("expected connectParams to be removed after DestroyStream")
613+ }
614+}
@@ -496,3 +496,119 @@ func TestConnectPlaybackStreamPassesParamsFromHelper(t *testing.T) {
496 t.Fatal("expected nParams > 0 from buildRawAudioParams, got 0")496 t.Fatal("expected nParams > 0 from buildRawAudioParams, got 0")
497 }497 }
498 }498 }
499+
500+// TestConnectPlaybackStreamPinsParamsAfterSuccess verifies that ConnectPlaybackStream
501+// stores the connectParams in pinnedParams so the SPA POD backing storage outlives
502+// the function call.
503+func TestConnectPlaybackStreamPinsParamsAfterSuccess(t *testing.T) {
504+ origConnect := pw_stream_connect
505+ t.Cleanup(func() { pw_stream_connect = origConnect })
506+
507+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
508+ return 0
509+ }
510+
511+ ops := &streamOpsImpl{}
512+ fakePtr := unsafe.Pointer(uintptr(0x5678))
513+
514+ validFmt := portout.PlaybackFormat{
515+ SampleRate: 48000,
516+ Channels: 2,
517+ FramesPerBuffer: 1024,
518+ }
519+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
520+ if err != nil {
521+ t.Fatalf("unexpected error: %v", err)
522+ }
523+
524+ cp, ok := ops.pinnedParams[fakePtr]
525+ if !ok {
526+ t.Fatal("expected connectParams to be pinned for stream after ConnectPlaybackStream")
527+ }
528+ if cp == nil {
529+ t.Fatal("pinned connectParams should not be nil")
530+ }
531+ // Verify the pinned params point to real SPA POD data.
532+ if cp.Count() != 1 {
533+ t.Fatalf("expected pinned params Count()=1, got %d", cp.Count())
534+ }
535+ if cp.Pointer() == nil {
536+ t.Fatal("expected pinned params Pointer() to be non-nil")
537+ }
538+}
539+
540+// TestConnectPlaybackStreamDoesNotPinParamsOnFailure verifies that when pw_stream_connect
541+// returns a negative result, connectParams are NOT pinned.
542+func TestConnectPlaybackStreamDoesNotPinParamsOnFailure(t *testing.T) {
543+ origConnect := pw_stream_connect
544+ t.Cleanup(func() { pw_stream_connect = origConnect })
545+
546+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
547+ return -22 // EINVAL
548+ }
549+
550+ ops := &streamOpsImpl{}
551+ fakePtr := unsafe.Pointer(uintptr(0x5678))
552+
553+ validFmt := portout.PlaybackFormat{
554+ SampleRate: 48000,
555+ Channels: 2,
556+ FramesPerBuffer: 1024,
557+ }
558+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
559+ if err == nil {
560+ t.Fatal("expected error for negative return code")
561+ }
562+
563+ if _, ok := ops.pinnedParams[fakePtr]; ok {
564+ t.Fatal("connectParams should NOT be pinned when pw_stream_connect fails")
565+ }
566+}
567+
568+// TestDestroyStreamReleasesPinnedParams verifies that DestroyStream removes both the
569+// callback storage and the connect params from the pinned maps.
570+func TestDestroyStreamReleasesPinnedParams(t *testing.T) {
571+ origConnect := pw_stream_connect
572+ origDestroy := pw_stream_destroy
573+ t.Cleanup(func() {
574+ pw_stream_connect = origConnect
575+ pw_stream_destroy = origDestroy
576+ })
577+
578+ pw_stream_connect = func(stream unsafe.Pointer, direction int32, id uint32, flags uint32, ports unsafe.Pointer, n_ports uint32) int32 {
579+ return 0
580+ }
581+ pw_stream_destroy = func(_ unsafe.Pointer) {}
582+
583+ ops := &streamOpsImpl{
584+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
585+ }
586+
587+ fakePtr := unsafe.Pointer(uintptr(0xDEAD))
588+ ops.pinned[fakePtr] = &streamCallbackStorage{}
589+
590+ validFmt := portout.PlaybackFormat{
591+ SampleRate: 48000,
592+ Channels: 2,
593+ FramesPerBuffer: 1024,
594+ }
595+ err := ops.ConnectPlaybackStream(fakePtr, validFmt)
596+ if err != nil {
597+ t.Fatalf("unexpected error: %v", err)
598+ }
599+
600+ // Verify params are pinned.
601+ if _, ok := ops.pinnedParams[fakePtr]; !ok {
602+ t.Fatal("expected connectParams to be pinned before DestroyStream")
603+ }
604+
605+ ops.DestroyStream(fakePtr)
606+
607+ // Both maps should no longer contain the stream.
608+ if _, ok := ops.pinned[fakePtr]; ok {
609+ t.Error("expected callback storage to be removed after DestroyStream")
610+ }
611+ if _, ok := ops.pinnedParams[fakePtr]; ok {
612+ t.Error("expected connectParams to be removed after DestroyStream")
613+ }
614+}

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 1

Tags 8

↑↓ moveenter openesc close