fix(#1150): skip MQTT reconnect on watchdog timeout when project_file landed

Background: P1P firmware can take ~135 s after a project_file MQTT publish
  to actually start parsing the uploaded .3mf — gcode_state stays IDLE and
  subtask_id doesn't advance until parse completes. The dispatch watchdogs
  treated the missed transition as a #887/#936 half-broken session and called
  force_reconnect_stale_session, which interrupts the printer's in-progress
  parse and triggers 0500_4003 ("can't parse print file") on the printer side.

  Both #1150 (slow parse) and #887/#936 (zombie session) look identical from
  state and subtask_id alone — both have stale state and stale subtask_id with
  fresh telemetry. The distinguishing signal is the printer's gcode_file
  field: it updates in push_status when the project_file command actually
  lands on the printer, but stays unchanged when the publish was silently
  swallowed.

  Both watchdogs (_verify_print_response in background_dispatch and
  _watchdog_print_start in print_scheduler) now capture pre_gcode_file from
  printer_manager.get_status() before sending the publish, then on timeout
  compare it against the last good status seen during the poll loop. If the
  file changed, the command landed → log a #1150 warning, skip the forced
  reconnect to avoid 0500_4003 mid-parse. If unchanged, fall through to the
  original force_reconnect_stale_session call so the half-broken-session
  recovery is preserved exactly.

  Caveat documented in code: in a retry-same-file slow-parse scenario the
  gcode_file looks identical pre/post-publish, so the watchdog falls through
  to the reconnect path and the user still hits 0500_4003 on that retry.
  Accepted to avoid breaking the half-broken-session recovery, which is the
  more impactful regression of the two.

  The new pre_gcode_file kwarg has a default of None on both watchdog
  functions, so any caller that doesn't pass it keeps the original
  reconnect-on-timeout behavior verbatim.

  4 new unit tests cover both watchdogs: skip on gcode_file change (#1150
  fix), reconnect when unchanged (#936 protection preserved), skip when
  pre=None and current is non-None (printer just connected), reconnect when
  pre_gcode_file arg is omitted (backward-compat). All 439 existing
  dispatch / scheduler / mqtt tests pass unchanged.
This commit is contained in:
maziggy
2026-04-28 09:26:25 +02:00
parent 05f9c418ae
commit 69b6b5a334
5 changed files with 266 additions and 15 deletions
+2
View File
@@ -41,6 +41,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Per-request trace ID column on every log line, plumbed through HTTP access log + application logs + response headers** — Builds on the new uvicorn-access-log-into-bambuddy.log change below: the access line tells you *who* called an endpoint, but until now there was no way to tie that line to the application records emitted on the server side while handling that request. A new FastAPI middleware (`trace_id_middleware` in `main.py`, sourced from `backend.app.core.trace`) stamps each request with a fresh 8-char hex ID (or honours a sane inbound `X-Trace-Id` header for cross-system correlation), stores it in a `ContextVar` so any code in the request's call stack can read it, echoes it on the response as `X-Trace-Id`, and a new `TraceIDFilter` injects it into every `LogRecord` so the format string `[%(trace_id)s]` resolves to the right ID for the right request. ContextVars (rather than `request.state`) are the right plumbing here because asyncio copies the current context into every `asyncio.create_task`, so background work spawned from inside a request inherits the trace ID without explicit threading; the logging filter has no access to the FastAPI request object regardless. Records emitted outside any request scope (startup, MQTT callbacks, scheduler) get a stable `-` placeholder so the column stays visually aligned and missing values are obvious in `grep`. Inbound `X-Trace-Id` is hard-validated against a strict whitelist (`[A-Za-z0-9_-]+`, max 64 chars) before being honoured — a hostile or buggy caller cannot smuggle log-injection payloads (newlines, control chars, megabyte blobs) into `bambuddy.log` via the trace-ID column; values that fail the gate silently trigger a freshly minted server-side ID rather than failing the request. Middleware is decorated AFTER `auth_middleware` on purpose: Starlette stacks `@app.middleware` decorators LIFO so the last-decorated runs first inbound, making trace stamp the OUTERMOST layer — auth log lines and every record emitted on the way down to and back from the route handler all carry the same ID. Output now looks like `2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] 192.168.1.42:54812 - "POST /api/v1/printers/1/print/stop HTTP/1.1" 200` paired with the route handler's `2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent stop print command` — one `grep a4f3b1e7` away from the full causality chain. 30 new tests across `tests/unit/test_trace.py` (placeholder when no request scope, filter copies ContextVar value onto records, ID propagates into spawned tasks via asyncio context copy, concurrent requests don't leak IDs into each other, generator produces unique hex IDs, hostile payloads rejected by validator, max-length boundary, dash/underscore variants accepted) plus `tests/integration/test_trace_middleware.py` (X-Trace-Id header echoed on response, body and header IDs match, each request gets a unique ID, generator format stays short hex, safe inbound IDs honoured, hostile inbound IDs replaced, overlong inbound IDs replaced, ContextVar reset cleanly after request).
### Fixed
- **P1P print dispatch failed with `0500_4003 "can't parse print file"` when the printer was slow to acknowledge** ([#1150](https://github.com/maziggy/bambuddy/issues/1150), reported by @d3ni3) — On a P1P at firmware 01.10.00.00 the printer can take up to ~135 seconds to actually start parsing a freshly uploaded `.3mf` after the MQTT `project_file` command lands; FTP STOR returns 226 cleanly and the upload is intact, but `gcode_state` stays at `IDLE` and `subtask_id` doesn't advance until the printer's slow internal parse completes. Both dispatch watchdogs (`_verify_print_response` in `background_dispatch.py` and `_watchdog_print_start` in `print_scheduler.py`) interpreted the missed transition as a half-broken MQTT session — the original #887/#936 condition where telemetry kept arriving but our publishes were silently swallowed — and called `force_reconnect_stale_session` to wipe paho's QoS-1 queue and reconnect with a fresh client_id. That reconnect mid-parse is precisely what makes the P1P emit `0500_4003`: the new MQTT session interrupts the in-progress parse on the printer side and the printer reports the file as unparseable. The repro: send a print job, wait 15 seconds while the printer is still parsing, watch the watchdog force-reconnect, watch the printer fail with the parse error, retry — same loop. Sending the same file from BambuStudio worked because BambuStudio doesn't reconnect MQTT mid-parse. The fix uses the printer's `gcode_file` field as a definitive discriminator between #1150 (slow parse) and #887/#936 (half-broken session), since both look identical from telemetry alone: in both cases push_status keeps flowing, `state` stays unchanged, and `subtask_id` stays at the pre-dispatch value. The distinguishing signal: when the project_file command actually lands on the printer side, the printer's `gcode_file` field updates in push_status to reflect the newly-uploaded file; if the publish was silently swallowed (#887/#936), the field stays at whatever the printer was previously showing. Both watchdogs now capture `pre_gcode_file` alongside `pre_state` and `pre_subtask_id` from `printer_manager.get_status()` before sending the publish, then compare against the printer's current `gcode_file` after the watchdog times out. If the value changed → command landed → log a `#1150` warning explaining the skip and leave the MQTT session alone. If the value is unchanged → publish was silently swallowed → fall through to the original `force_reconnect_stale_session` call so the #887/#936/#1136 zombie-session recovery is preserved exactly. The user-facing dispatch still fails on timeout (correctly — the print didn't start within the timeout window so the job is marked failed), the queue item still reverts to `pending` so the scheduler can retry, and the next dispatch attempt proceeds against the same intact MQTT session that was about to start the print. Pairs with the 15s → 90s timeout bump that already shipped in commit 9d041868 (the original 15s timeout was a separate v0.2.3.2 limit). Caveat acknowledged in code comments: in a retry-same-file slow-parse scenario the printer's `gcode_file` looks identical before and after the publish lands, so the watchdog falls through to the original reconnect path and the user still sees `0500_4003` on that specific retry — accepted to avoid breaking the half-broken-session recovery, which is the more impactful regression of the two. 4 new unit tests covering both watchdogs: skip reconnect when `gcode_file` changed (the #1150 fix), reconnect when `gcode_file` is unchanged (the #936 protection preserved), skip reconnect when `pre_gcode_file=None` and current is non-None (printer just connected), reconnect when `pre_gcode_file` arg is omitted (backward-compat for callers we haven't updated). All 439 existing dispatch / scheduler / mqtt tests still pass unchanged.
- **3MF profile-driven slicing always fell back to embedded settings, plus sidecar errors were generic** — `_strip_3mf_embedded_settings` only removed `Metadata/project_settings.config` before forwarding the model to the slicer sidecar; real-world Bambu Studio / OrcaSlicer 3MFs also carry `Metadata/model_settings.config`, `Metadata/slice_info.config`, and `Metadata/cut_information.xml`, all of which reference the original slice's printer / filament IDs. Any single leftover triggered the CLI's input validation and the slice fell back to embedded settings — making the new SliceModal's profile picker theatrical for 3MF inputs. Strip widened to remove all four configs (centralised in `_STRIPPABLE_3MF_CONFIGS` with per-file rationale); geometry (`3D/3dmodel.model`), thumbnails, and multi-part data preserved verbatim. Existing strip integration test extended to assert all four files go and `3D/3dmodel.model` stays. Separately, `slicer_api.py` was reading only `message` from sidecar 5xx responses and dropping `details`, so every CLI failure surfaced as the unhelpful generic `Failed to slice the model`. New `_format_sidecar_error` helper combines both fields (preferring `message: details`, falling back to whichever is set, falling back to `response.text` for non-JSON 5xx like nginx 502s) and replaces the four duplicated `response.json().get("message", "")` blocks. After both changes, a CLI rejection log line goes from `Slicer CLI failed (500): Failed to slice the model` to `Slicer CLI failed (500): Failed to slice the model: <actual reason from sidecar>`. 3 new unit tests in `test_slicer_api.py`: `details` field surfaced alongside `message`, `details`-only response still produces a useful error, plain-text 5xx (gateway timeouts) doesn't crash the JSON decode and falls back to the body. Pairs with the orca-slicer-api fork's `bambuddy/profile-resolver` branch which now emits `details` on its `AppError` responses (`d9c6121`) and captures CLI stdout / stderr in the failure path (`fb928c8`).
- **Moving a file to an external folder updated the DB row but never wrote the bytes to the mount** ([#1112](https://github.com/maziggy/bambuddy/issues/1112) follow-up — confirmed by @Carter3DP after testing 0.2.4b1) — Carter's report read "the file appears in Bambuddy but not physically on the external folder", which traced to `move_files` only updating `file.folder_id` in the DB while leaving the bytes in the internal `library_files_dir`. Direct upload to a writable external folder was already fixed in 0.2.4b1; the move path was not. Cross-boundary moves now physically relocate the bytes through a new `_move_file_bytes` helper. Same-boundary moves (managed → managed) keep the existing DB-only fast path because the file's on-disk location doesn't depend on which managed folder owns it. The helper handles four flows: managed → external (copy bytes to `<external_path>/<filename>`, flip `is_external=True`, store the absolute path, unlink the managed source), external → managed (copy bytes into internal storage with a fresh UUID name, flip `is_external=False`, store the relative path, unlink the external source, recompute `file_hash` since scan-tracked rows historically carry `file_hash=None`), external → external (same as managed → external), and managed → managed (DB-only). Copy-then-unlink ordering means a partial copy followed by a failed unlink leaves both copies on disk rather than losing the source if the target write fails halfway through on a flaky NAS mount. Failed `shutil.copy2` cleans up partial dest before raising. Defence-in-depth checks block: source on a read-only external mount (move = delete-on-source which a RO mount can't fulfil — would copy-then-fail-to-unlink and silently duplicate the file), filename collisions on the target mount (won't silently overwrite a file the user already has on the NAS), traversal-style filenames after `Path.resolve()`, missing source on disk, and `os.access(W_OK)` on the target mount. Each skip carries a structured `{file_id, code, reason}` entry in a new `skipped_reasons` field on the response so the UI can surface "5 of 10 files skipped: 3 had filename collisions on the NAS, 2 are no longer on disk" instead of a blank "skipped: 5". The original `{moved, skipped}` numeric counters are preserved so existing frontend code that only reads those keeps working unchanged. Six new integration tests in `test_external_folders_api.py::TestCrossBoundaryMove` covering: managed → external relocates bytes (the actual #1112 fix — bytes land on mount, internal source removed, DB row matches reality), external → managed relocates bytes (symmetric path including hash recompute), name collision on target external mount skips with `code: "name_collision"` and leaves the pre-existing target file intact, source on read-only external mount skips with `code: "source_readonly"`, managed → managed stays DB-only (file_path doesn't change, no shutil.copy), and `skipped_reasons` is always present (empty list when nothing skipped) so frontend code can treat it as the source of truth without optional-chaining.
+33 -5
View File
@@ -698,6 +698,7 @@ class BackgroundDispatchService:
pre_status = printer_manager.get_status(job.printer_id)
pre_state = getattr(pre_status, "state", None) if pre_status else None
pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
if pre_state:
await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
transitioned = await self._verify_print_response(
@@ -705,6 +706,7 @@ class BackgroundDispatchService:
printer_name,
pre_state,
pre_subtask_id=pre_subtask_id,
pre_gcode_file=pre_gcode_file,
)
if not transitioned:
raise RuntimeError(
@@ -889,6 +891,7 @@ class BackgroundDispatchService:
pre_status = printer_manager.get_status(job.printer_id)
pre_state = getattr(pre_status, "state", None) if pre_status else None
pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
if pre_state:
await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
transitioned = await self._verify_print_response(
@@ -896,6 +899,7 @@ class BackgroundDispatchService:
printer_name,
pre_state,
pre_subtask_id=pre_subtask_id,
pre_gcode_file=pre_gcode_file,
)
if not transitioned:
await db.rollback()
@@ -944,6 +948,7 @@ class BackgroundDispatchService:
printer_name: str,
pre_state: str,
pre_subtask_id: str | None = None,
pre_gcode_file: str | None = None,
timeout: float = 90.0,
poll_interval: float = 3.0,
) -> bool:
@@ -963,6 +968,7 @@ class BackgroundDispatchService:
landed" signal even while state is still FINISH (#1078).
"""
deadline = time.monotonic() + timeout
last_status = None
while time.monotonic() < deadline:
await asyncio.sleep(poll_interval)
state = printer_manager.get_status(printer_id)
@@ -972,6 +978,7 @@ class BackgroundDispatchService:
# failure on the first missed tick; the printer may reconnect
# within the remaining timeout and still surface a transition.
continue
last_status = state
if state.state != pre_state:
return True
if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
@@ -985,13 +992,34 @@ class BackgroundDispatchService:
pre_state,
pre_subtask_id,
)
# Strong signal the MQTT session is half-broken (#887, #936): telemetry
# still arrives but our publishes don't reach the printer. Force a fresh
# session so the next dispatch can land without a power cycle.
# Distinguish #1150 (slow parse) from #887/#936 (half-broken session)
# via gcode_file: if the printer is now showing a different file than
# before dispatch, the project_file command landed and the printer is
# parsing — a forced reconnect mid-parse causes 0500_4003. If
# gcode_file is unchanged, the publish was silently swallowed and the
# original #936 recovery (force_reconnect → fresh client_id) is what
# we want. Caveat: in the rare retry-same-file-after-timeout case the
# printer's gcode_file looks identical before and after the publish
# lands, so a slow parse on retry-same-file still falls through to the
# reconnect (and the original 0500_4003) — accepted to avoid breaking
# the half-broken-session recovery path.
client = printer_manager.get_client(printer_id)
if client and hasattr(client, "force_reconnect_stale_session"):
current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
if publish_landed:
logger.warning(
"Printer %s (%d) gcode_file changed to %r (was %r) — printer "
"received the command and is parsing slowly. Skipping forced "
"MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
printer_name,
printer_id,
current_gcode_file,
pre_gcode_file,
)
elif client and hasattr(client, "force_reconnect_stale_session"):
client.force_reconnect_stale_session(
f"print command unacknowledged after {timeout:.0f}s (state still {pre_state})"
f"print command unacknowledged after {timeout:.0f}s "
f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
)
return False
+25 -4
View File
@@ -1851,6 +1851,7 @@ class PrintScheduler:
pre_status = printer_manager.get_status(item.printer_id)
pre_state = getattr(pre_status, "state", None) if pre_status else None
pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
# Start the print with AMS mapping, plate_id and print options
started = printer_manager.start_print(
@@ -1884,6 +1885,7 @@ class PrintScheduler:
item.printer_id,
pre_state,
pre_subtask_id,
pre_gcode_file,
)
)
@@ -1957,6 +1959,7 @@ class PrintScheduler:
printer_id: int,
pre_state: str,
pre_subtask_id: str | None = None,
pre_gcode_file: str | None = None,
timeout: float = 90.0,
poll_interval: float = 3.0,
) -> None:
@@ -1982,11 +1985,13 @@ class PrintScheduler:
that also don't emit an early subtask_id tick.
"""
deadline = time.monotonic() + timeout
last_status = None
while time.monotonic() < deadline:
await asyncio.sleep(poll_interval)
status = printer_manager.get_status(printer_id)
if not status:
return # Printer disconnected — don't mess with the DB
last_status = status
if status.state != pre_state:
return # Printer picked up the job (state transition)
if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
@@ -2011,12 +2016,28 @@ class PrintScheduler:
pre_subtask_id,
)
# Same half-broken-session recovery as background_dispatch: force the
# MQTT client to reconnect so the next dispatch lands without a power cycle.
# Same #1150 / #887/#936 discriminator as background_dispatch: if the
# printer's gcode_file changed since pre-dispatch, the project_file
# command landed and the printer is parsing — a forced reconnect
# mid-parse triggers 0500_4003. If gcode_file is unchanged, the
# publish was silently swallowed (#887/#936) and the original
# force_reconnect recovery is what we want.
client = printer_manager.get_client(printer_id)
if client and hasattr(client, "force_reconnect_stale_session"):
current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
if publish_landed:
logger.warning(
"Queue item %s: gcode_file changed to %r (was %r) — printer "
"received the command and is parsing slowly. Skipping forced "
"MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
queue_item_id,
current_gcode_file,
pre_gcode_file,
)
elif client and hasattr(client, "force_reconnect_stale_session"):
client.force_reconnect_stale_session(
f"queue print command unacknowledged after {timeout:.0f}s (state still {pre_state})"
f"queue print command unacknowledged after {timeout:.0f}s "
f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
)
@@ -22,9 +22,9 @@ import pytest
from backend.app.services.background_dispatch import BackgroundDispatchService
def _status(state: str, subtask_id: str | None = None):
"""Minimal stand-in for PrinterState — only the two fields the watchdog reads."""
return SimpleNamespace(state=state, subtask_id=subtask_id)
def _status(state: str, subtask_id: str | None = None, gcode_file: str | None = None):
"""Minimal stand-in for PrinterState — only the fields the watchdog reads."""
return SimpleNamespace(state=state, subtask_id=subtask_id, gcode_file=gcode_file)
class TestReturnsTrueOnPickup:
@@ -224,6 +224,144 @@ class TestDefaults:
assert sig.parameters["timeout"].default == 90.0
class TestGcodeFileDiscriminator:
"""#1150 vs #887/#936 discriminator: skip the forced reconnect when the
printer's gcode_file changed since pre-dispatch (project_file landed,
printer is parsing slowly — reconnecting mid-parse causes 0500_4003).
Reconnect when gcode_file is unchanged (publish was silently swallowed —
half-broken session needs the original recovery)."""
@pytest.mark.asyncio
async def test_skips_reconnect_when_gcode_file_changed(self):
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
get_status,
),
patch(
"backend.app.services.background_dispatch.printer_manager.get_client",
get_client,
),
):
result = await BackgroundDispatchService._verify_print_response(
printer_id=42,
printer_name="P1P",
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file="/old.3mf",
timeout=0.2,
poll_interval=0.05,
)
assert result is False
client.force_reconnect_stale_session.assert_not_called()
@pytest.mark.asyncio
async def test_reconnects_when_gcode_file_unchanged(self):
# The half-broken-session case (#887/#936): publish was dropped, so
# the printer is still showing the previous file. Reconnect to clear
# the broken paho QoS-1 queue.
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
get_status,
),
patch(
"backend.app.services.background_dispatch.printer_manager.get_client",
get_client,
),
):
await BackgroundDispatchService._verify_print_response(
printer_id=42,
printer_name="P1P",
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file="/old.3mf",
timeout=0.2,
poll_interval=0.05,
)
client.force_reconnect_stale_session.assert_called_once()
@pytest.mark.asyncio
async def test_skips_reconnect_when_pre_gcode_file_was_none(self):
# Printer just connected (pre_gcode_file=None) and now reports a
# file — that's a clear "command landed" signal too.
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
get_status,
),
patch(
"backend.app.services.background_dispatch.printer_manager.get_client",
get_client,
),
):
await BackgroundDispatchService._verify_print_response(
printer_id=42,
printer_name="P1P",
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file=None,
timeout=0.2,
poll_interval=0.05,
)
client.force_reconnect_stale_session.assert_not_called()
@pytest.mark.asyncio
async def test_reconnects_when_no_pre_gcode_file_arg_supplied(self):
# Backward-compat: callers that don't pass pre_gcode_file at all
# (everything but our updated dispatch sites) must still get the
# original reconnect-on-timeout behaviour. Here pre_gcode_file
# defaults to None and the printer's current gcode_file is also
# None → publish_landed=False → reconnect.
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file=None),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch(
"backend.app.services.background_dispatch.printer_manager.get_status",
get_status,
),
patch(
"backend.app.services.background_dispatch.printer_manager.get_client",
get_client,
),
):
await BackgroundDispatchService._verify_print_response(
printer_id=42,
printer_name="P1P",
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
timeout=0.2,
poll_interval=0.05,
)
client.force_reconnect_stale_session.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests: the call sites in _run_reprint_archive and
# _run_print_library_file must (a) await the watchdog instead of fire-and-
+65 -3
View File
@@ -45,9 +45,9 @@ async def db_session():
await engine.dispose()
def _status(state: str, subtask_id: str | None = None):
"""Minimal stand-in for PrinterState — only the two fields the watchdog reads."""
return SimpleNamespace(state=state, subtask_id=subtask_id)
def _status(state: str, subtask_id: str | None = None, gcode_file: str | None = None):
"""Minimal stand-in for PrinterState — only the fields the watchdog reads."""
return SimpleNamespace(state=state, subtask_id=subtask_id, gcode_file=gcode_file)
class TestWatchdogExitsEarlyOnPickup:
@@ -258,3 +258,65 @@ class TestWatchdogFallbackBehaviour:
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "completed" # untouched
class TestGcodeFileDiscriminator:
"""#1150 vs #887/#936: skip the forced reconnect when gcode_file changed
(project_file landed, slow parse — reconnecting causes 0500_4003).
Reconnect when gcode_file is unchanged (publish dropped — half-broken
session needs the original recovery)."""
@pytest.mark.asyncio
async def test_skips_reconnect_when_gcode_file_changed(self, db_session):
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/new.3mf"),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
patch("backend.app.services.print_scheduler.async_session", db_session),
):
await PrintScheduler._watchdog_print_start(
queue_item_id=1,
printer_id=42,
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file="/old.3mf",
timeout=0.2,
poll_interval=0.05,
)
# Item still reverts (the user-facing failure stays correct), but the
# MQTT session is left intact so the slow printer can finish parsing.
async with db_session() as db:
item = await db.get(PrintQueueItem, 1)
assert item.status == "pending"
client.force_reconnect_stale_session.assert_not_called()
@pytest.mark.asyncio
async def test_reconnects_when_gcode_file_unchanged(self, db_session):
get_status = MagicMock(
return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"),
)
client = MagicMock()
get_client = MagicMock(return_value=client)
with (
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
patch("backend.app.services.print_scheduler.printer_manager.get_client", get_client),
patch("backend.app.services.print_scheduler.async_session", db_session),
):
await PrintScheduler._watchdog_print_start(
queue_item_id=1,
printer_id=42,
pre_state="FINISH",
pre_subtask_id="OLD_SUBTASK",
pre_gcode_file="/old.3mf",
timeout=0.2,
poll_interval=0.05,
)
client.force_reconnect_stale_session.assert_called_once()