Procházet zdrojové kódy

fix(camera): the orphan cleanup only kills ffmpeg this Bambuddy started

The once-a-minute /proc scan killed every ffmpeg whose command named a
Bambu camera (rtsp(s)://bblp:) and was not in this process's own list, so
another Bambuddy on the same host (a second install, a test instance, a test
run) killed this one's live camera every minute. A camera ffmpeg now counts
as ours only when its parent is this process, or init for an orphan an
earlier run left behind, as USB camera commands already did.
maziggy před 5 dny
rodič
revize
217ab5f53a

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A second Bambuddy on the same machine no longer cuts the camera every minute** — Bambuddy's once-a-minute cleanup kills camera ffmpeg processes left behind by a crash or restart, and recognised them by their command alone. Another Bambuddy on the same host (a second install, a test instance) runs the very same command, so each one's cleanup killed the other's live camera once a minute and the view reconnected. Only an ffmpeg started by this Bambuddy, or left orphaned by an earlier run, is cleaned up now.
 - **A restart with a camera view open no longer logs errors** — A camera stream never ends by itself, so a restart closes it after 5 seconds. Each stream closed that way was logged as an ERROR with a full traceback ("Exception in ASGI application ... timeout graceful shutdown exceeded"), which looked like a crash in the log and in support bundles. It is now one INFO line per stream, and the "Cancel N running task(s)" line before them is INFO too; real errors are logged as before.
 - **Archive export includes the whole end day, and an invalid print speed is refused** — `GET /api/v1/archives/export?date_to=2026-10-15` read the date as midnight and left out everything printed that day; a date on its own now ends with that day, as in every other date filter, and a date and time with a UTC offset is converted to UTC. The Archives page was not affected, because it never sends dates. `POST /api/v1/printers/{id}/print-speed` with a mode other than 1-4 answered 500 "Failed to set print speed"; it now answers 422.
 - **AMS drying now applies to "Any <model>" queue jobs** — With **Wait for drying to complete** on, a job for "Any X1C" could still start on an X1C whose AMS was drying, and its drying was never stopped for the print; queue auto-drying also never dried anything for such a job. Now a model job passes over a drying printer for another of the model, and waits only while every one of them is drying. The printer it lands on stops drying like it would for a job pinned to it, and a scheduled model job has queue auto-drying dry every printer it could run on.

+ 13 - 10
backend/app/api/routes/camera.py

@@ -2268,18 +2268,22 @@ def _is_our_usb_command(argv: list[bytes]) -> bool:
 
 
 def _is_bambuddy_ffmpeg(argv: list[bytes], ppid: int | None, own_pid: int) -> bool:
-    """Whether an ffmpeg process is one of ours.
-
-    - Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
-    - External USB (V4L2): our command shape, run by this process or orphaned
-      to init by an earlier run. Other software reads USB cameras with ffmpeg
-      too (motion, webcam servers on a bare-metal install), and a bare
-      ``v4l2`` match killed those.
+    """Whether an ffmpeg process is one of ours: our command, run by this
+    process or orphaned to init by an earlier run.
+
+    - Bambu RTSP: ``rtsp(s)://bblp:``. Another Bambuddy on the same host (a
+      second install, a test server) runs the very same command, and matching
+      the command alone made each one's cleanup kill the other's live camera
+      every minute.
+    - External USB (V4L2): our command shape. Other software reads USB cameras
+      with ffmpeg too (motion, webcam servers on a bare-metal install).
     """
+    if ppid is None or ppid not in (own_pid, 1):
+        return False
     joined = b"\x00".join(argv)
     if b"rtsp://bblp:" in joined or b"rtsps://bblp:" in joined:
         return True
-    return _is_our_usb_command(argv) and ppid is not None and ppid in (own_pid, 1)
+    return _is_our_usb_command(argv)
 
 
 def _scan_bambu_ffmpeg_pids() -> list[int]:
@@ -2303,8 +2307,7 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
                 if b"ffmpeg" not in cmdline:
                     continue
                 argv = cmdline.rstrip(b"\x00").split(b"\x00")
-                ppid = _read_ppid(int(entry)) if b"v4l2" in cmdline else None
-                if _is_bambuddy_ffmpeg(argv, ppid, own_pid):
+                if _is_bambuddy_ffmpeg(argv, _read_ppid(int(entry)), own_pid):
                     pids.append(int(entry))
             except (OSError, PermissionError, ValueError):
                 continue

+ 56 - 3
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -12,6 +12,8 @@ ffmpeg into the same registries the RTSP path uses.
 from __future__ import annotations
 
 import asyncio
+import os
+import sys
 import time
 from contextlib import suppress
 from unittest.mock import AsyncMock, mock_open, patch
@@ -196,6 +198,47 @@ def test_scan_matches_v4l2_ffmpeg(monkeypatch):
         assert 52020 in camera._scan_bambu_ffmpeg_pids()
 
 
+def test_scan_matches_an_orphaned_rtsp_ffmpeg(monkeypatch):
+    cmdline = b"ffmpeg\x00-i\x00rtsp://bblp:code@127.0.0.1:4000/streaming/live/1\x00-\x00"
+    monkeypatch.setattr("os.listdir", lambda _p: ["52022"])
+    monkeypatch.setattr(camera, "_read_ppid", lambda _pid: 1)
+    with patch("builtins.open", mock_open(read_data=cmdline)):
+        assert camera._scan_bambu_ffmpeg_pids() == [52022]
+
+
+@pytest.mark.skipif(sys.platform != "linux", reason="/proc")
+def test_scan_leaves_another_processes_camera_ffmpeg_alone(tmp_path):
+    """For real, through /proc: the same command run by another program (here
+    a shell standing in for another Bambuddy) is not found; ours is."""
+    import subprocess
+
+    fake = tmp_path / "ffmpeg"
+    fake.write_text("#!/bin/sh\nsleep 30\n")
+    fake.chmod(0o755)
+    url = "rtsp://bblp:code@127.0.0.1:4000/streaming/live/1"
+    theirs = subprocess.Popen(["sh", "-c", f"'{fake}' -i '{url}' -; true"])
+    ours = subprocess.Popen([str(fake), "-i", url, "-"])
+    try:
+        deadline = time.monotonic() + 5
+        other = None
+        while other is None and time.monotonic() < deadline:
+            for pid in os.listdir("/proc"):
+                if pid.isdigit() and camera._read_ppid(int(pid)) == theirs.pid:
+                    other = int(pid)
+            time.sleep(0.05)
+        assert other is not None, "the other program's ffmpeg did not start"
+
+        found = camera._scan_bambu_ffmpeg_pids()
+
+        assert ours.pid in found
+        assert other not in found
+    finally:
+        for proc in (theirs, ours):
+            proc.kill()
+            proc.wait()
+        subprocess.run(["pkill", "-f", str(fake)], check=False)
+
+
 def test_scan_ignores_unrelated_ffmpeg(monkeypatch):
     # A transcode of a local file is not ours — must not be reaped.
     cmdline = b"ffmpeg\x00-i\x00/home/user/movie.mp4\x00out.mkv\x00"
@@ -233,9 +276,19 @@ def test_a_different_v4l2_command_shape_is_not_ours():
     assert not camera._is_bambuddy_ffmpeg(recording, OWN_PID, OWN_PID)
 
 
-def test_bambu_rtsp_is_ours_whatever_the_parent():
-    rtsp = [b"ffmpeg", b"-rtsp_transport", b"tcp", b"-i", b"rtsps://bblp:code@10.0.0.5:322/streaming/live/1", b"-"]
-    assert camera._is_bambuddy_ffmpeg(rtsp, 777, OWN_PID)
+RTSP = [b"ffmpeg", b"-rtsp_transport", b"tcp", b"-i", b"rtsps://bblp:code@10.0.0.5:322/streaming/live/1", b"-"]
+
+
+def test_our_bambu_rtsp_and_its_orphans_are_ours():
+    assert camera._is_bambuddy_ffmpeg(RTSP, OWN_PID, OWN_PID)
+    assert camera._is_bambuddy_ffmpeg(RTSP, 1, OWN_PID)
+
+
+def test_another_bambuddys_rtsp_is_not_ours():
+    """A second install or a test server on the same host runs the same
+    command: its cleanup killed this one's live camera every minute."""
+    assert not camera._is_bambuddy_ffmpeg(RTSP, 777, OWN_PID)
+    assert not camera._is_bambuddy_ffmpeg(RTSP, None, OWN_PID)
 
 
 def test_unknown_parent_is_not_ours_for_usb():