ソースを参照

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 5 日 前
親
コミット
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():