Skip to content

Commit e80563c

Browse files
committed
Recover routed streams after PipeWire relink
1 parent 2fa532d commit e80563c

11 files changed

Lines changed: 598 additions & 95 deletions

src/mini_eq/pipewire_stream_router.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ def _processing_path_node_ids(self) -> set[int]:
143143
def _link_touches_processing_path(self, link: PipeWireLink) -> bool:
144144
return bool(self._processing_path_node_ids() & {link.output_node_id, link.input_node_id})
145145

146+
def _link_touches_routed_stream(self, link: PipeWireLink) -> bool:
147+
return bool(self.routed_stream_ids & {link.output_node_id, link.input_node_id})
148+
146149
def handle_link_state_changed(self, state: str | None) -> None:
147150
if state == LINK_STATE_ACTIVE:
148151
self.schedule_refresh(route_applied=True)
@@ -318,14 +321,19 @@ def handle_object_added(self, _manager, node) -> None:
318321
if self._link_touches_processing_path(link):
319322
self.track_processing_link_state(link)
320323
self.schedule_refresh(route_applied=True)
324+
elif self._link_touches_routed_stream(link):
325+
self.schedule_refresh()
321326

322327
def handle_object_removed(self, _manager, node) -> None:
323328
try:
324329
link = self.backend.link_from_proxy(node)
325330
except Exception:
326331
return
327332

333+
should_refresh = self._link_touches_processing_path(link) or self._link_touches_routed_stream(link)
328334
self.untrack_processing_link_state(link)
335+
if should_refresh:
336+
self.schedule_refresh()
329337

330338
def untrack_processing_link_states(self) -> None:
331339
for handler_id in list(self.link_state_handler_ids.values()):

tests/test_check_flatpak_runtime.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@
55
from tools import check_flatpak_runtime
66

77

8+
def node_item(item_id: int, name: str) -> dict:
9+
return {
10+
"id": item_id,
11+
"type": "PipeWire:Interface:Node",
12+
"info": {"props": {"node.name": name, "object.serial": str(item_id + 1000)}},
13+
}
14+
15+
16+
def link_item(item_id: int, output_node: int, input_node: int, state: str) -> dict:
17+
return {
18+
"id": item_id,
19+
"type": "PipeWire:Interface:Link",
20+
"info": {
21+
"state": state,
22+
"props": {
23+
"link.output.node": str(output_node),
24+
"link.input.node": str(input_node),
25+
},
26+
},
27+
}
28+
29+
830
@pytest.mark.parametrize(
931
"app_ref",
1032
[
@@ -37,3 +59,37 @@ def test_flatpak_runtime_smoke_includes_extra_flatpak_run_args(monkeypatch: pyte
3759
"io.github.bhack.mini-eq//master",
3860
"--check-deps",
3961
]
62+
63+
64+
def test_flatpak_runtime_recognizes_active_processing_path(monkeypatch: pytest.MonkeyPatch) -> None:
65+
monkeypatch.setattr(
66+
check_flatpak_runtime,
67+
"read_pw_dump",
68+
lambda: [
69+
node_item(10, "mini_eq_sink"),
70+
node_item(20, "mini_eq_sink_output"),
71+
node_item(30, "ci_null_sink"),
72+
node_item(40, "browser"),
73+
link_item(90, 40, 10, "active"),
74+
link_item(91, 20, 30, "active"),
75+
],
76+
)
77+
78+
assert check_flatpak_runtime.processing_path_has_active_links() is True
79+
80+
81+
def test_flatpak_runtime_rejects_inactive_processing_path(monkeypatch: pytest.MonkeyPatch) -> None:
82+
monkeypatch.setattr(
83+
check_flatpak_runtime,
84+
"read_pw_dump",
85+
lambda: [
86+
node_item(10, "mini_eq_sink"),
87+
node_item(20, "mini_eq_sink_output"),
88+
node_item(30, "ci_null_sink"),
89+
node_item(40, "browser"),
90+
link_item(90, 40, 10, "active"),
91+
link_item(91, 20, 30, "paused"),
92+
],
93+
)
94+
95+
assert check_flatpak_runtime.processing_path_has_active_links() is False

tests/test_github_workflows.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def test_headless_pipewire_runtime_smoke_is_optional_ci_gate_without_nested_gnom
110110
assert "python3-pyatspi" not in job
111111
assert 'timeout="${MINI_EQ_HEADLESS_PIPEWIRE_TIMEOUT:-90}"' in script
112112
assert 'audio_duration="${MINI_EQ_HEADLESS_PIPEWIRE_AUDIO_DURATION:-180}"' in script
113+
assert 'idle_gap="${MINI_EQ_HEADLESS_PIPEWIRE_IDLE_GAP:-8}"' in script
113114

114115

115116
def test_live_ui_runtime_smoke_uses_host_gir_build_environment() -> None:
@@ -129,3 +130,4 @@ def test_flatpak_runtime_smoke_tolerates_pipewire_startup_race() -> None:
129130
assert "{ pw-dump 2>/dev/null || true; }" in script
130131
assert "first(.[] | select(" in script
131132
assert "| head -n 1" not in script
133+
assert 'idle_gap="${MINI_EQ_FLATPAK_SMOKE_IDLE_GAP:-8}"' in script

tests/test_mini_eq_pipewire_stream_router.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,61 @@ def test_pipewire_router_reapplies_controls_when_processing_link_becomes_active(
536536
assert applied == ["apply", "apply"]
537537

538538

539+
def test_pipewire_router_reroutes_tracked_stream_when_relinked_away(
540+
monkeypatch: pytest.MonkeyPatch,
541+
) -> None:
542+
spotify = make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")
543+
speakers = make_node(22, pw_backend.AUDIO_SINK, "speakers")
544+
backend = FakePipeWireBackend([spotify], sinks=[speakers])
545+
router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend)
546+
scheduled_callbacks: list[object] = []
547+
548+
monkeypatch.setattr(
549+
pw_router.GLib,
550+
"idle_add",
551+
lambda callback: scheduled_callbacks.append(callback) or 321,
552+
)
553+
554+
router.enabled = True
555+
router.accept_stream_events = True
556+
router.routed_stream_ids = {spotify.bound_id}
557+
router.handle_object_added(None, make_link(92, output_node_id=spotify.bound_id, input_node_id=speakers.bound_id))
558+
559+
assert len(scheduled_callbacks) == 1
560+
scheduled_callbacks[0]()
561+
562+
assert backend.moves == [(spotify.bound_id, "mini_eq_sink")]
563+
564+
565+
def test_pipewire_router_reroutes_tracked_stream_when_current_link_disappears(
566+
monkeypatch: pytest.MonkeyPatch,
567+
) -> None:
568+
spotify = make_node(1, pw_backend.STREAM_OUTPUT_AUDIO, "spotify", "Spotify")
569+
virtual_sink = make_node(11, pw_backend.AUDIO_SINK, "mini_eq_sink")
570+
backend = FakePipeWireBackend([spotify], sinks=[virtual_sink])
571+
router = pw_router.PipeWireStreamRouter("mini_eq_sink", "mini_eq_sink_output", lambda _message: None, backend)
572+
scheduled_callbacks: list[object] = []
573+
574+
monkeypatch.setattr(
575+
pw_router.GLib,
576+
"idle_add",
577+
lambda callback: scheduled_callbacks.append(callback) or 321,
578+
)
579+
580+
router.enabled = True
581+
router.accept_stream_events = True
582+
router.routed_stream_ids = {spotify.bound_id}
583+
router.handle_object_removed(
584+
None,
585+
make_link(92, output_node_id=spotify.bound_id, input_node_id=virtual_sink.bound_id),
586+
)
587+
588+
assert len(scheduled_callbacks) == 1
589+
scheduled_callbacks[0]()
590+
591+
assert backend.moves == [(spotify.bound_id, "mini_eq_sink")]
592+
593+
539594
def test_pipewire_router_tracks_internal_output_links(monkeypatch: pytest.MonkeyPatch) -> None:
540595
internal_output = make_node(90, pw_backend.STREAM_OUTPUT_AUDIO, "mini_eq_sink_output")
541596
backend = FakePipeWireBackend([internal_output], sinks=[make_node(22, pw_backend.AUDIO_SINK, "speakers")])

tests/test_release_preflight.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def test_release_preflight_runs_headless_pipewire_runtime_smoke(monkeypatch) ->
8888
monkeypatch.setenv("MINI_EQ_HEADLESS_PIPEWIRE_TIMEOUT", "12")
8989
monkeypatch.setenv("MINI_EQ_HEADLESS_PIPEWIRE_CYCLES", "4")
9090
monkeypatch.setenv("MINI_EQ_HEADLESS_PIPEWIRE_AUDIO_DURATION", "34")
91+
monkeypatch.setenv("MINI_EQ_HEADLESS_PIPEWIRE_IDLE_GAP", "5")
9192
monkeypatch.setattr(release_preflight, "run", lambda command, **_kwargs: commands.append(command))
9293

9394
release_preflight.run_headless_pipewire_runtime_smoke(Path("/python"))
@@ -102,6 +103,8 @@ def test_release_preflight_runs_headless_pipewire_runtime_smoke(monkeypatch) ->
102103
"4",
103104
"--audio-duration",
104105
"34",
106+
"--idle-gap",
107+
"5",
105108
]
106109
]
107110

@@ -132,6 +135,7 @@ def test_release_preflight_uses_hosted_headless_pipewire_defaults(monkeypatch) -
132135
monkeypatch.delenv("MINI_EQ_HEADLESS_PIPEWIRE_TIMEOUT", raising=False)
133136
monkeypatch.delenv("MINI_EQ_HEADLESS_PIPEWIRE_CYCLES", raising=False)
134137
monkeypatch.delenv("MINI_EQ_HEADLESS_PIPEWIRE_AUDIO_DURATION", raising=False)
138+
monkeypatch.delenv("MINI_EQ_HEADLESS_PIPEWIRE_IDLE_GAP", raising=False)
135139
monkeypatch.setattr(release_preflight, "run", lambda command, **_kwargs: commands.append(command))
136140

137141
release_preflight.run_headless_pipewire_runtime_smoke(Path("/python"))
@@ -146,6 +150,8 @@ def test_release_preflight_uses_hosted_headless_pipewire_defaults(monkeypatch) -
146150
"2",
147151
"--audio-duration",
148152
"180",
153+
"--idle-gap",
154+
"8",
149155
]
150156
]
151157

0 commit comments

Comments
 (0)