Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 109 additions & 53 deletions app/audio_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
Design:
- Synchron (kein Thread): der aufrufende Thread blockiert nicht,
parec laeuft als Hintergrundprozess.
- Stale-PID-Schutz: abgestuerzte parec-Prozesse werden erkannt
und der PID-File wird bereinigt, bevor der naechste Start versucht wird.
- Only the child started by this instance can be controlled, using a pidfd.
PID files are untrusted status markers, never signal targets.
- XDG_RUNTIME_DIR ist Pflicht (kein /tmp-Fallback, sicherheitsrelevant).
"""
from __future__ import annotations
Expand All @@ -16,6 +16,7 @@
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
Expand All @@ -26,6 +27,14 @@
_RATE = 16000
_CHANNELS = 1
_LATENCY_MS = 100
_STOP_TIMEOUT = 2.0
# The child cannot capture audio until the parent has pinned its identity. EOF
# aborts startup without a signal; exec preserves the PID and its pidfd binding.
_START_GATE = (
"import os, sys\n"
"if sys.stdin.buffer.read(1) == b'1':\n"
" os.execvp(sys.argv[1], sys.argv[1:])\n"
)


class AudioRecorderError(Exception):
Expand Down Expand Up @@ -63,13 +72,15 @@ def __init__(self, runtime_dir: Optional[Path] = None) -> None:
self._pid_file = self._runtime_dir / f"blitztext-linux-{user}.pid"
self._wav_file = self._runtime_dir / f"blitztext-linux-{user}.wav"
self._parec_err = self._runtime_dir / f"blitztext-linux-{user}.parec.err"
self._process: Optional[subprocess.Popen] = None
self._pidfd: Optional[int] = None

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def is_recording(self) -> bool:
"""True, wenn ein parec-Prozess aktiv laeuft (PID-File existiert und Prozess lebt)."""
"""True only while this instance's recording child is alive."""
return self._live_pid() is not None

def start_recording(self, device: str = "@DEFAULT_SOURCE@") -> Path:
Expand Down Expand Up @@ -104,9 +115,9 @@ def start(self, device: str = "@DEFAULT_SOURCE@") -> Path:
# Stale PID guard for interrupted recording processes.
self._cleanup_stale_pid()

if self._pid_file.is_file():
if self._process is not None:
raise AudioRecorderError(
"Aufnahme laeuft bereits (PID-File vorhanden). Zuerst stop() aufrufen."
"Aufnahme laeuft bereits. Zuerst stop() aufrufen."
)

if not self._pipewire_available():
Expand All @@ -115,11 +126,21 @@ def start(self, device: str = "@DEFAULT_SOURCE@") -> Path:
if not self._device_exists(device):
raise AudioRecorderError(f"Mikrofon nicht gefunden: {device!r}")

# Fail before starting capture on kernels without safe process handles.
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
raise AudioRecorderError("Sichere Aufnahme-Prozessverwaltung benoetigt Linux pidfd-Unterstuetzung.")
try:
probe_fd = os.pidfd_open(os.getpid())
os.close(probe_fd)
except OSError as exc:
raise AudioRecorderError("Linux pidfd-Unterstuetzung ist nicht verfuegbar.") from exc

self._parec_err.unlink(missing_ok=True)
err_fh = open(self._parec_err, "wb")
try:
proc = subprocess.Popen(
[
sys.executable, "-I", "-S", "-c", _START_GATE,
"parec",
f"--device={device}",
"--file-format=wav",
Expand All @@ -130,16 +151,49 @@ def start(self, device: str = "@DEFAULT_SOURCE@") -> Path:
],
stdout=subprocess.DEVNULL,
stderr=err_fh,
stdin=subprocess.PIPE,
)
except OSError as exc:
raise AudioRecorderError(f"Aufnahme-Prozess konnte nicht gestartet werden: {exc}") from exc
finally:
err_fh.close()

self._pid_file.write_text(f"{proc.pid}\n")
# This local Popen child has not been polled/reaped or exposed to another
# caller yet. Its PID cannot be recycled before pidfd_open pins it.
try:
pidfd = os.pidfd_open(proc.pid)
except OSError as exc:
# EOF ends the gate without starting parec or trusting a numeric PID.
self._process = proc
proc.stdin.close()
try:
proc.wait(timeout=_STOP_TIMEOUT)
except subprocess.TimeoutExpired:
logger.warning("Aufnahme-Startbarriere ist noch nicht beendet (PID=%d).", proc.pid)
else:
self._release_process()
raise AudioRecorderError(
"Aufnahme-Prozess konnte nicht sicher vorbereitet werden; Aufnahme nicht gestartet."
) from exc
self._process = proc
self._pidfd = pidfd

try:
self._pid_file.write_text(f"{proc.pid}\n")
# No shell interpolation; all parec arguments cross exec as argv.
with proc.stdin:
proc.stdin.write(b"1")
except OSError as exc:
if not proc.stdin.closed:
proc.stdin.close()
if self._stop_owned_process():
self._wav_file.unlink(missing_ok=True)
raise AudioRecorderError("Aufnahme-Prozess konnte nicht freigegeben werden.") from exc

# Kurz warten und sicherstellen, dass parec nicht sofort abgestuerzt ist
time.sleep(0.2)
if proc.poll() is not None:
self._pid_file.unlink(missing_ok=True)
self._release_process()
self._wav_file.unlink(missing_ok=True)
detail = self._read_parec_err()
msg = f"parec sofort abgestuerzt: {detail}" if detail else "parec sofort abgestuerzt."
Expand All @@ -157,20 +211,11 @@ def stop(self) -> Optional[Path]:
Raises:
AudioRecorderError: Wenn keine Aufnahme lief.
"""
self._cleanup_stale_pid()

if not self._pid_file.is_file():
if self._live_pid() is None:
raise AudioRecorderError("stop() aufgerufen, obwohl keine Aufnahme laeuft.")

pid = self._read_pid()
self._pid_file.unlink(missing_ok=True)

if pid is not None:
try:
os.kill(pid, signal.SIGTERM)
time.sleep(0.4) # parec Zeit geben, WAV-Header abzuschliessen
except (ProcessLookupError, PermissionError, OSError) as exc:
logger.debug("SIGTERM fehlgeschlagen (PID=%d): %s", pid, exc)
if not self._stop_owned_process():
raise AudioRecorderError("Aufnahme-Prozess konnte nicht sicher gestoppt werden.")

if not self._wav_file.is_file() or self._wav_file.stat().st_size == 0:
detail = self._read_parec_err()
Expand All @@ -188,13 +233,10 @@ def discard(self) -> None:
"""Bricht die laufende Aufnahme ab, ohne zu transkribieren.
WAV-Datei wird geloescht. Kein Fehler wenn keine Aufnahme lief.
"""
pid = self._live_pid()
self._pid_file.unlink(missing_ok=True)
if pid is not None:
try:
os.kill(pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
if self._live_pid() is not None and not self._stop_owned_process():
# Preserve the handle and files rather than pretending capture ended.
logger.warning("Aufnahme konnte nicht sicher verworfen werden.")
return
self._wav_file.unlink(missing_ok=True)
logger.debug("Aufnahme verworfen.")

Expand All @@ -203,39 +245,53 @@ def discard(self) -> None:
# ------------------------------------------------------------------

def _live_pid(self) -> Optional[int]:
"""Gibt PID zurueck wenn Prozess lebt, sonst None. Bereinigt Stale-PID."""
if not self._pid_file.is_file():
return None
pid = self._read_pid()
if pid is None:
self._pid_file.unlink(missing_ok=True)
return None
try:
os.kill(pid, 0)
return pid
except OSError:
self._pid_file.unlink(missing_ok=True)
return None
"""Return only the owned child PID; never trust or probe a PID file."""
if self._process is not None and self._process.poll() is None:
return self._process.pid
self._release_process()
return None

def _cleanup_stale_pid(self) -> None:
"""Entfernt PID-File wenn Prozess nicht mehr laeuft (Stale-PID-Schutz)."""
if not self._pid_file.is_file():
return
pid = self._read_pid()
if pid is None:
self._pid_file.unlink(missing_ok=True)
return
"""Clean up markers without adopting or signalling unowned processes."""
self._live_pid()
Comment on lines 254 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep orphaned recorder processes from being forgotten

If the app is killed or crashes during a manual ./run.sh session, the exec'd parec child has no parent-death signal or persistent liveness channel and can continue recording. On the next launch, this cleanup treats the live child's PID file as an unowned marker and deletes it, allowing another recorder to start against the same WAV path while the orphan continues capturing. Preserve safe cross-process exclusion or arrange for the child to terminate with its parent rather than silently forgetting it.

Useful? React with 👍 / 👎.


def _release_process(self) -> None:
"""Release the identity handle only after the child has stopped."""
if self._pidfd is not None:
os.close(self._pidfd)
self._pidfd = None
self._process = None
try:
os.kill(pid, 0) # Signal 0 = nur Existenz-Check, kein Kill
except OSError:
logger.debug("Stale PID %d bereinigt.", pid)
self._pid_file.unlink(missing_ok=True)
except OSError as exc:
logger.warning("Aufnahme-PID-Marker konnte nicht entfernt werden: %s", exc)

def _read_pid(self) -> Optional[int]:
def _stop_owned_process(self) -> bool:
"""Signal the pinned child, even if its numeric PID is later recycled."""
proc = self._process
if proc is None:
return True
if self._pidfd is None:
logger.warning("Kein verifizierter Prozess-Handle fuer Aufnahme-PID %d.", proc.pid)
return False
try:
return int(self._pid_file.read_text().strip())
except (OSError, ValueError):
return None
try:
signal.pidfd_send_signal(self._pidfd, signal.SIGTERM)
except ProcessLookupError:
pass # The pinned child exited before delivery; no PID fallback.
try:
proc.wait(timeout=_STOP_TIMEOUT)
except subprocess.TimeoutExpired:
try:
signal.pidfd_send_signal(self._pidfd, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait(timeout=_STOP_TIMEOUT)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("Aufnahme-Prozess konnte nicht gestoppt werden: %s", exc)
return False
self._release_process()
return True

def _read_parec_err(self) -> str:
try:
Expand Down
14 changes: 13 additions & 1 deletion app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,11 @@ def gui_discard(self) -> None:
"""Laufende Aufnahme verwerfen, ohne zu transkribieren."""
if self.state == "RECORDING":
self.audio_recorder.discard()
if self.audio_recorder.is_recording():
self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(
error="Aufnahme-Prozess konnte nicht sicher gestoppt werden."
))
return
self._recording_routes_to_compose = False
self.current_workflow = None
self._set_state("IDLE", "discarded via gui")
Expand All @@ -1195,8 +1200,8 @@ def _stop_recording_and_process(self) -> None:
route_to_compose = (
self._recording_routes_to_compose and self._compose_voice_routing_enabled()
)
self._recording_routes_to_compose = False
wav_path = self.audio_recorder.stop()
self._recording_routes_to_compose = False
if not wav_path:
logger.warning("No audio was recorded")
self.show_tray_warning("Blitztext", "Keine Audioaufnahme erfasst.")
Expand Down Expand Up @@ -1240,6 +1245,8 @@ def _stop_recording_and_process(self) -> None:
except AudioRecorderError as e:
logger.error("Failed to stop recording: %s", e)
self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(error=e))
if self.audio_recorder.is_recording():
return
Comment on lines +1248 to +1249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the recovered stop error before updating state

After a transient stop failure, show_tray_error() stores the failure in _tray_error_message, and this return intentionally leaves the recording available for a retry. If a later retry succeeds, none of the success paths clears that message, so update_tray_state() continues to prioritize the ERROR icon and tooltip throughout transcription and after returning to idle. Clear the prior stop error once the owned process has subsequently stopped successfully.

Useful? React with 👍 / 👎.

self._recording_routes_to_compose = False
self.current_workflow = None
self._set_state("IDLE", "recording stop failed")
Expand Down Expand Up @@ -1504,6 +1511,11 @@ def show_tray_warning(self, title: str, message: str) -> None:
def quit_app(self) -> None:
logger.info("Quitting application...")
self.audio_recorder.discard()
if self.audio_recorder.is_recording():
self.show_tray_error(t("error.recording.title"), t("error.recording.stop_failed").format(
error="Aufnahme-Prozess konnte nicht sicher gestoppt werden."
))
return
self.stop_hotkey_worker()
if self._tts_window is not None:
self._tts_window.close()
Expand Down
Loading
Loading