Quellcode durchsuchen

Added orphan cleanup

Efren Yevale Varela vor 1 Monat
Ursprung
Commit
07e923f592
2 geänderte Dateien mit 90 neuen und 20 gelöschten Zeilen
  1. 12 2
      README.md
  2. 78 18
      src/stt_daemon/provisioner.py

+ 12 - 2
README.md

@@ -324,7 +324,13 @@ uv run stt-daemon clear
 uv run stt-daemon clear --yes
 ```
 
-This unloads all tracked modules and removes the state file.
+This unloads all tracked modules and removes the state file. If
+`~/.local/state/stt-daemon/audio.json` is already gone (reboot, manual
+delete, crash), `clear --yes` still finds orphaned modules by name
+(`VirtualMicSink`/`VirtualMic` via `pactl list short modules`) and unloads
+them in reverse order (loopbacks → remap-source → null-sink), so the
+command is idempotent — safe to re-run and to recover from
+`Sink 'VirtualMicSink' already exists but is not tracked`.
 
 #### (Legacy) `--list-sources` flag
 
@@ -501,7 +507,11 @@ When you run `uv run stt-daemon setup`, the daemon:
 - Creates all modules and records their numeric IDs
 - Saves the state to `~/.local/state/stt-daemon/audio.json` (or `$STT_DAEMON_STATE`)
 - Allows `run` to re-provision if the sink is missing (e.g., after reboot)
-- Allows `clear` to unload only the modules it created
+- Allows `clear` to unload the modules it created — and falls back to
+  name-based discovery (`sink_name`/`remap_source_name` in
+  `pactl list short modules`) when `audio.json` is missing, so orphaned
+  `VirtualMicSink`/`VirtualMic` sinks are still cleaned up (e.g., after a
+  reboot or manual state-file delete)
 
 The state file is JSON and can be edited manually if needed, but the daemon handles it automatically.
 

+ 78 - 18
src/stt_daemon/provisioner.py

@@ -112,33 +112,93 @@ class Provisioner:
 
         Unloads in reverse creation order (loopbacks, then remap, then null-sink).
         Ignores modules that are already gone. Always clears the state file.
+        Falls back to name-based discovery when no state file exists so
+        ``clear --yes`` can clean orphans (e.g. after reboot or manual delete).
         """
         state = self.store.load()
-        if not state:
-            self.store.clear()
-            return
+        if state:
+            # Unload loopbacks in reverse order
+            for loopback in reversed(state.loopbacks):
+                try:
+                    self.pa.unload_module(loopback.module_id)
+                except RuntimeError:
+                    pass  # Already gone is fine
+
+            # Unload remap source
+            try:
+                self.pa.unload_module(state.remap_module)
+            except RuntimeError:
+                pass
 
-        # Unload loopbacks in reverse order
-        for loopback in reversed(state.loopbacks):
+            # Unload null sink
             try:
-                self.pa.unload_module(loopback.module_id)
+                self.pa.unload_module(state.null_sink_module)
             except RuntimeError:
-                pass  # Already gone is fine
+                pass
 
-        # Unload remap source
-        try:
-            self.pa.unload_module(state.remap_module)
-        except RuntimeError:
-            pass
+            self.store.clear()
+            # Sweep any remaining orphans whose IDs changed since state was saved
+            self._sweep_orphans()
+            return
+
+        # No state file — discover owned modules by name and unload them
+        self._sweep_orphans()
+        self.store.clear()
+
+    def _sweep_orphans(self) -> None:
+        """Unload any live modules that match our sink/remap names.
 
-        # Unload null sink
+        Scans ``pactl list short modules`` and matches on the argument string
+        so cleanup works without ``audio.json`` (orphan case).
+        """
         try:
-            self.pa.unload_module(state.null_sink_module)
-        except RuntimeError:
-            pass
+            output = self.pa.runner(["list", "short", "modules"])
+        except Exception:
+            return
 
-        # Always clear state
-        self.store.clear()
+        loopbacks: list[int] = []
+        remap_id: int | None = None
+        null_sink_id: int | None = None
+        sink_name = self.cfg.sink_name
+        remap_name = self.cfg.remap_source_name
+        monitor_source = f"{sink_name}.monitor"
+
+        for line in output.strip().split("\n"):
+            if not line.strip():
+                continue
+            parts = line.split("\t")
+            if len(parts) < 2:
+                continue
+            try:
+                mid = int(parts[0])
+            except ValueError:
+                continue
+            name = parts[1]
+            args_str = "\t".join(parts[2:]) if len(parts) > 2 else ""
+            # also handle space-joined args in some pactl versions
+            if name == "module-null-sink" and f"sink_name={sink_name}" in args_str:
+                null_sink_id = mid
+            elif name == "module-remap-source" and f"source_name={remap_name}" in args_str:
+                remap_id = mid
+            elif name == "module-loopback":
+                if f"sink={sink_name}" in args_str or f"source={monitor_source}" in args_str:
+                    loopbacks.append(mid)
+
+        for mid in reversed(loopbacks):
+            try:
+                self.pa.unload_module(mid)
+            except RuntimeError:
+                pass
+        if remap_id is not None:
+            try:
+                self.pa.unload_module(remap_id)
+            except RuntimeError:
+                pass
+        if null_sink_id is not None:
+            try:
+                self.pa.unload_module(null_sink_id)
+            except RuntimeError:
+                pass
 
     def ensure(self) -> AudioState | None:
         """Ensure provisioning exists; re-provision if sink is missing.