mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
fix(scheduler): watchdog falsely reverts slow H2D dispatches, causing reprints (#1078)
_watchdog_print_start reverted queue items to "pending" at 45 s if gcode_state hadn't changed, assuming the MQTT project_file was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keeps state=FINISH for 48-55 s after actually accepting the command before transitioning to PREPARE. The watchdog reverted items the printer had already started physically printing; the archive updated normally via _active_prints, but the queue item was now "pending" again, and the next scheduler tick after plate-clear re-dispatched the same item as if it had never run. With one item left in the queue that looked like a reprint of the just-finished job; with multiple items the symptom was masked by item N+1 getting dispatched during the race. Add a second "command landed" signal: subtask_id advancing past the pre-dispatch value. Bambuddy already mints a unique submission_id per project_file publish (#1042) and the printer echoes it back on the next push_status as soon as it starts processing the command - well before gcode_state transitions on slow-transition models. _start_print now captures pre_subtask_id alongside pre_state and passes both to the watchdog, which exits early on either a state change or a subtask_id advance. Raise default timeout 45 s → 90 s as belt-and-braces for printers that neither flip state nor echo subtask_id inside the polling window. Genuinely half-broken sessions (both signals unchanged across the full 90 s) still revert + force-reconnect exactly as before. Transient subtask_id=None during reconnect is not mis-detected as a change. pre_subtask_id=None falls back to state-only checking so the fix is safe for printers that haven't reported a subtask_id yet. New test_scheduler_watchdog.py pins the eight behaviours that matter: pickup via state change; pickup via subtask_id change with state still FINISH (the exact #1078 case); revert when neither signal changes; default timeout is 90 s; pre_subtask_id=None state-only fallback; current subtask_id=None not treated as change; printer disconnect mid-watchdog leaves DB untouched; item that already moved on is not clobbered.
This commit is contained in:
@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Printer Card Shows Plate Name on Multi-Plate Prints** ([#881](https://github.com/maziggy/bambuddy/issues/881)) — When two printers were running different plates of the same multi-plate 3MF, the Printers page cards displayed the same file name on both and gave no visual way to tell them apart. The Queue view already showed the plate name by querying the archive's plate list; the Printers page didn't have that linkage. The `GET /printers/{id}/status` endpoint now returns `current_archive_id` (resolved by matching the MQTT `subtask_id` against `PrintArchive.subtask_id`, the same bridge introduced in #972 for restart-resume) and `current_plate_id` (parsed from the MQTT `gcode_file` path by a new shared `parse_plate_id` helper that's also used by the WebSocket push path, so plate transitions within a running print reflect immediately instead of waiting 30 s for the next REST poll). The card fetches plate metadata via the same `api.getArchivePlates()` call the Queue page uses — shared React Query cache keeps it cheap across polls — and renders the actual plate name (or a "Plate N" fallback) only when the source 3MF is multi-plate, so single-plate prints stay noise-free. Falls back to the previous `plate_(\d+).gcode` regex when there's no archive linkage (e.g. prints started directly from the printer LCD). Regression tests cover the plate-id extraction across Bambu Studio path shapes and the label-override precedence in `formatPrintName`. Thanks to @stringham for the follow-up and screenshot.
|
||||
|
||||
### Fixed
|
||||
- **Print Scheduler Reprints the Just-Finished Job When Queue Has One Item Left (H2D)** ([#1078](https://github.com/maziggy/bambuddy/issues/1078)) — On H2D, clearing the plate and starting the next (and only) queued item caused the printer to re-run the job it had just finished while the UI reported the queued one as started. With multiple items left the symptom was hidden by forward progress. Root cause: `_watchdog_print_start` in `print_scheduler.py` gives up at 45 s and reverts the queue item to `pending` if `gcode_state` hasn't flipped away from `pre_state`, on the assumption that a non-transitioning printer means the MQTT `project_file` publish was swallowed by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00) routinely keeps `gcode_state=FINISH` for 48–55 s after actually accepting the command before transitioning to `PREPARE` — logs from the reporter show the revert firing at +45 s and a legitimate `PRINT START detected` arriving just ~3 s later — so the watchdog reverted an item that the printer *had* already started physically printing. The physical print ran to completion and updated the linked archive (via `register_expected_print`), but the queue item was now `pending` again; on the next scheduler tick after the user cleared the plate, the same item was re-dispatched as if it had never run. With multiple items queued, item N+1 getting dispatched during the 45 s race window looked like forward progress to the user and masked the duplicate revert/re-dispatch of item N. Fixed in `_watchdog_print_start` by adding a second "command landed" signal: `subtask_id` changing past the pre-dispatch value. Bambuddy already mints a unique `submission_id` per `project_file` publish (capped at int32 post-#1042) and assigns it to `subtask_id` / `task_id` in the command payload; the printer echoes this back on the next `push_status` as soon as it starts processing — well before `gcode_state` transitions on slow-transition models. `_start_print` now captures `pre_subtask_id` alongside `pre_state` and passes both to the watchdog, which treats *either* a state change *or* a `subtask_id` advance as proof the command landed. Timeout raised 45 s → 90 s as belt-and-braces for printers that neither transition state nor echo `subtask_id` inside the polling window. None of the earlier exit paths are weakened — genuine half-broken sessions (state *and* `subtask_id` both unchanged across the full window) still revert, still force the MQTT reconnect, and are still recoverable without a power cycle. Added eight regression tests in `test_scheduler_watchdog.py` covering: pickup via state change, pickup via `subtask_id` change while state stays at `FINISH` (the exact #1078 case), revert when neither signal changes, default timeout of 90 s, `pre_subtask_id=None` fallback to state-only, `status.subtask_id=None` not mis-detected as a change, printer disconnect mid-watchdog (no DB write), and the `#967` race where the item already moved on (`completed`). No frontend or MQTT changes — purely tightens the "did the printer accept?" decision. Thanks to @VREmma for the clear reproduction and the full support bundle that made pinpointing the H2D state-lag behaviour possible.
|
||||
- **Printers-Page "Clear Plate" Button Takes 30–300+ s to Appear After Print Completes** ([#939](https://github.com/maziggy/bambuddy/pull/939) follow-up) — A trusted user reported that on every printer (A1, H2D, X1C), the "Clear Plate & Start Next" button didn't show for 60+ seconds after a print finished; refreshing didn't help; one H2D sat in the "Finished" state for 5 minutes without the button ever appearing. Root cause: PR #939 added the `awaiting_plate_clear` gate but stored it on `PrinterManager._awaiting_plate_clear` (a per-process set, persisted to `printers.awaiting_plate_clear` via #961), not on `PrinterState` — and `printer_state_to_dict()` in `printer_manager.py`, which builds every WebSocket `printer_status` payload, was never updated to emit it. Only the HTTP endpoint `GET /printers/{id}/status` (line 634) surfaced the flag. That left the frontend in a deadlock: when `print_complete` arrived over the WebSocket, `useWebSocket.ts` intentionally *didn't* invalidate `['printerStatus']` (avoiding the render-cascade freeze the comment at line 235 warns about), expecting the subsequent `printer_status` WS messages to "naturally update the status" — but those messages carried no `awaiting_plate_clear` field, so the merge at line 146 preserved the stale `false`. The only path that ever surfaced `true` was the 30 s HTTP fallback poll at `PrintersPage.tsx:1430`, and on a chatty printer each incoming WS tick's `setQueryData` bumped React Query's `dataUpdatedAt`, pushing the next fetch further out — which is why the delay varied from ~30 s to several minutes. The plate-status pill at `PrintersPage.tsx:1672-1675` rendered "Plate Clear" (the fallback label for falsy `awaiting_plate_clear`) during the entire stale window, compounding the confusion. Fixed by emitting `awaiting_plate_clear` from `printer_state_to_dict`: the function already has `printer_id`, so it reads `printer_manager.is_awaiting_plate_clear(printer_id)` directly and returns `False` when no id is passed (for the few callsites that don't have one). No frontend change needed — the existing WS merge path now carries the flag end-to-end, the "Clear Plate" button appears instantly on completion, and the queue-dispatch side of the gate (which already reads the in-memory set directly via `print_scheduler.py:1125`) is unaffected. Regression tests in `test_printer_manager.py` assert the WS dict always contains the key and that it surfaces `True` when the manager has the flag set for that printer_id. Affects every printer equally because the path is transport-agnostic — not an H2D- or A1-specific problem, just more visible on H2D because its longer finish sequence gave the poll slip more opportunities to miss.
|
||||
- **Printers-Page Search Turns Into a Password Field After Opening Change-Password Modal** — On the Printers page, clicking the key icon in the sidebar to open the Change Password modal caused the "Search printers" input to render as a password field (masked dots); closing the modal didn't restore it, requiring a full reload. Root cause: the Change Password modal has three `<input type="password">` fields but no accompanying username input, so password-manager browser extensions (1Password, Bitwarden, Chrome/Safari built-in) scanned the current DOM for a matching username anchor and latched onto the nearest `type="text"` input with no `name`/`autoComplete` — which happened to be the Printers-page search bar — and overrode its rendering. Fixed on two levels: (1) added a hidden `<input type="text" name="username" autoComplete="username" value={user.username} readOnly hidden>` at the top of the Change Password modal so password managers have a proper anchor and stop hunting elsewhere — as a bonus, saved new passwords are now correctly keyed to the logged-in user; (2) hardened the Printers-page search input with `type="search"`, `name="printer-search"`, `autoComplete="off"`, and `data-1p-ignore` / `data-lpignore="true"` so any future heuristic-based autofill also skips it.
|
||||
- **AMS Slot Configure: Custom Cloud Preset Resolves to "Generic" in Slicer & Printer LCD** ([#1053](https://github.com/maziggy/bambuddy/issues/1053) follow-up) — After configuring any AMS slot (HT or regular) with a user custom Bambu Cloud preset built on top of a Bambu base profile (e.g. "Sting3D ABS" inheriting from "Generic ABS @BBL H2D"), OrcaSlicer's *Sync Filaments* continued to resolve the slot to "Generic ABS" and the custom preset never appeared on the printer's own LCD — independent of the earlier UI fix (commit `87a5aa36`) which only corrected Bambuddy's own modal. Root cause: when Bambu Cloud's `GET /cloud/settings/{setting_id}` returns a user preset with `filament_id: null` and `base_id: "GFSB99_07"` (cloud doesn't mint a distinct filament_id for presets that only override fields of a generic base), `ConfigureAmsSlotModal.tsx:382-384` fell back to `convertToTrayInfoIdx(base_id)` which strips the version suffix and the `S` prefix → `"GFB99"` — Generic ABS's filament_id. The printer accepted and reported back `GFB99`, so both the LCD and OrcaSlicer correctly resolved the slot to Generic ABS. The fallback was never right: the preceding default already set `tray_info_idx = convertToTrayInfoIdx(selectedPresetId)` which for any `PFUS*`/`PFSP*` setting_id returns the base setting_id itself (via the helper's `startsWith('PFUS')` branch added earlier), and the printer + both slicers round-trip that format unchanged — confirmed by existing backend integration tests (`test_configure_pfus_sent_directly`, `test_pfus_slicer_filament_used_directly`), by the print scheduler's slot-matching which already expects `P*` short-form IDs in the printer's reported `tray_info_idx` (`print_scheduler.py:910`), and by the inventory Assign Spool flow which has been sending `PFUS*` preset IDs to the printer for months. The buggy fallback *overwrote* the correct default with a generic mapping. Fixed by removing the base_id branch: when cloud detail carries a distinct `filament_id` we still prefer it, otherwise we keep the setting_id-derived default. BambuStudio Sync now resolves the custom preset cleanly; OrcaSlicer (whose user presets don't carry a `filament_id` field at all, only `inherits`) will continue to fall back to the inherited generic — that's an OrcaSlicer preset-format limitation, not something Bambuddy can fix on its side, and the behaviour is strictly not worse than before. Regression tests in `ConfigureAmsSlotModal.test.tsx` pin four paths: (1) cloud detail with `filament_id: null` → `tray_info_idx` is the `PFUS*` setting_id, (2) cloud detail with a concrete `filament_id` → that filament_id wins over the default, (3) GFS* Bambu presets skip the cloud-detail fetch entirely and still map to the short `GF*` filament_id, and (4) a 5xx / network error on the cloud-detail fetch degrades gracefully to the `PFUS*` default instead of aborting the configure flow. An end-to-end backend test (`test_configure_pfus_preserves_setting_id_pair`) locks in that both `tray_info_idx=PFUS…` and `setting_id=PFUS…` survive the HT-slot `POST /slots/{ams}/{tray}/configure` path untouched. Thanks to @mrnoisytiger for the detailed browser-console / network / backend-log diagnostic data that isolated the fallback path, and for sharing the OrcaSlicer preset JSON that showed the missing `filament_id` field.
|
||||
|
||||
@@ -1833,9 +1833,12 @@ class PrintScheduler:
|
||||
logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
|
||||
|
||||
# Capture state before dispatch so the watchdog can detect whether the
|
||||
# printer actually transitioned (#967).
|
||||
# printer actually transitioned (#967). Also capture subtask_id so the
|
||||
# watchdog can recognise "command landed but state hasn't flipped yet"
|
||||
# on slow H2D transitions (#1078).
|
||||
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
|
||||
|
||||
# Start the print with AMS mapping, plate_id and print options
|
||||
started = printer_manager.start_print(
|
||||
@@ -1854,12 +1857,23 @@ class PrintScheduler:
|
||||
if started:
|
||||
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
|
||||
|
||||
# Watchdog: if the printer never transitions out of pre_state, the MQTT
|
||||
# publish was accepted locally but didn't reach the printer (half-broken
|
||||
# session — same shape as #887/#936). Revert the queue item so the next
|
||||
# dispatch can pick it up instead of leaving it stuck in "printing" (#967).
|
||||
# Watchdog: if the printer never transitions out of pre_state AND
|
||||
# never advances subtask_id, the MQTT publish was accepted locally but
|
||||
# didn't reach the printer (half-broken session — same shape as
|
||||
# #887/#936). Revert the queue item so the next dispatch can pick it
|
||||
# up instead of leaving it stuck in "printing" (#967). subtask_id
|
||||
# check avoids false reverts on slow H2D FINISH→PREPARE transitions
|
||||
# that would otherwise cause the item to re-dispatch as a reprint
|
||||
# of the just-finished job (#1078).
|
||||
if pre_state:
|
||||
asyncio.create_task(self._watchdog_print_start(item.id, item.printer_id, pre_state))
|
||||
asyncio.create_task(
|
||||
self._watchdog_print_start(
|
||||
item.id,
|
||||
item.printer_id,
|
||||
pre_state,
|
||||
pre_subtask_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Get estimated time for notification
|
||||
estimated_time = None
|
||||
@@ -1930,7 +1944,8 @@ class PrintScheduler:
|
||||
queue_item_id: int,
|
||||
printer_id: int,
|
||||
pre_state: str,
|
||||
timeout: float = 45.0,
|
||||
pre_subtask_id: str | None = None,
|
||||
timeout: float = 90.0,
|
||||
poll_interval: float = 3.0,
|
||||
) -> None:
|
||||
"""Revert a queue item if the printer never acknowledges the start command.
|
||||
@@ -1939,6 +1954,20 @@ class PrintScheduler:
|
||||
MQTT project_file publish succeeds locally. If the printer drops/ignores the
|
||||
command (half-broken MQTT session — #887/#936), the state never transitions
|
||||
and the item would otherwise stay stuck in "printing" forever (#967).
|
||||
|
||||
Exit paths (printer picked up the job — no revert):
|
||||
- gcode_state changed from pre_state, OR
|
||||
- subtask_id advanced past pre_subtask_id — the printer echoes our
|
||||
per-dispatch identity back on push_status, so a subtask_id change is
|
||||
a definitive "command landed" signal even while state is still FINISH.
|
||||
H2D can sit at FINISH for ~50 s after accepting project_file before
|
||||
transitioning to PREPARE, which used to trip the state-only watchdog
|
||||
and caused the scheduler to revert + re-dispatch the item; the next
|
||||
successful dispatch then looked like a reprint of the just-finished
|
||||
job (#1078).
|
||||
|
||||
Timeout raised from 45 s → 90 s as belt-and-braces for slow transitions
|
||||
that also don't emit an early subtask_id tick.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -1947,7 +1976,9 @@ class PrintScheduler:
|
||||
if not status:
|
||||
return # Printer disconnected — don't mess with the DB
|
||||
if status.state != pre_state:
|
||||
return # Printer picked up the job
|
||||
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:
|
||||
return # Printer picked up the job (subtask_id advanced)
|
||||
|
||||
# No transition. Revert the item so the scheduler can retry.
|
||||
async with async_session() as db:
|
||||
@@ -1959,11 +1990,13 @@ class PrintScheduler:
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"Queue item %s: printer %d did not respond to print command within "
|
||||
"%.0fs (state still %s) — reverted to 'pending' for retry (#967)",
|
||||
"%.0fs (state still %s, subtask_id still %s) — reverted to 'pending' "
|
||||
"for retry (#967)",
|
||||
queue_item_id,
|
||||
printer_id,
|
||||
timeout,
|
||||
pre_state,
|
||||
pre_subtask_id,
|
||||
)
|
||||
|
||||
# Same half-broken-session recovery as background_dispatch: force the
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Regression tests for ``_watchdog_print_start``.
|
||||
|
||||
The watchdog reverts queue items to ``pending`` when a dispatched print never
|
||||
lands on the printer (half-broken MQTT session — #887/#936/#967). H2D firmware
|
||||
can sit at ``FINISH`` for 50+ seconds after accepting a ``project_file``
|
||||
command before flipping ``gcode_state`` to ``PREPARE``, which used to trip the
|
||||
state-only watchdog and cause the scheduler to revert the item; the subsequent
|
||||
successful dispatch then looked like a reprint of the just-finished job (#1078).
|
||||
|
||||
The fix: treat ``subtask_id`` advancing past the pre-dispatch value as an
|
||||
equivalent "command landed" signal, and raise the timeout from 45 s to 90 s as
|
||||
belt-and-braces for slow transitions that also don't emit an early subtask_id
|
||||
tick.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
"""In-memory SQLite with one ``printing`` queue item at id=1."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401 — populate Base.metadata
|
||||
from backend.app.core.database import Base
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add(PrintQueueItem(id=1, printer_id=42, archive_id=99, status="printing"))
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
yield session_maker
|
||||
finally:
|
||||
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)
|
||||
|
||||
|
||||
class TestWatchdogExitsEarlyOnPickup:
|
||||
"""The watchdog must NOT revert when the printer has clearly picked up the job."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exits_on_state_change(self, db_session):
|
||||
"""State transitioning away from pre_state is the primary "accepted" signal."""
|
||||
get_status = MagicMock(return_value=_status("RUNNING", "OLD_SUBTASK"))
|
||||
with (
|
||||
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
|
||||
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",
|
||||
timeout=0.3,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
# Item should remain "printing" — watchdog recognised the pickup.
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "printing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exits_on_subtask_id_change_even_if_state_still_finish(self, db_session):
|
||||
"""Regression for #1078: H2D keeps state=FINISH for ~50 s after accepting
|
||||
project_file, but subtask_id flips to our new submission_id almost
|
||||
immediately. That must short-circuit the revert."""
|
||||
get_status = MagicMock(return_value=_status("FINISH", "NEW_SUBTASK_12345"))
|
||||
with (
|
||||
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
|
||||
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_99999",
|
||||
timeout=0.3,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "printing", (
|
||||
"subtask_id advanced past pre_subtask_id — the printer accepted our "
|
||||
"project_file and the watchdog must not revert the queue item even "
|
||||
"though state is still FINISH (#1078)"
|
||||
)
|
||||
|
||||
|
||||
class TestWatchdogRevertsWhenStuck:
|
||||
"""Genuine half-broken sessions still need the revert + reconnect recovery."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reverts_when_neither_state_nor_subtask_id_changes(self, db_session):
|
||||
"""Both signals unchanged across the full timeout → revert to pending
|
||||
and force MQTT reconnect (the #967 recovery path)."""
|
||||
get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
|
||||
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",
|
||||
timeout=0.2,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "pending"
|
||||
assert item.started_at is None
|
||||
|
||||
client.force_reconnect_stale_session.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_timeout_is_90_seconds(self):
|
||||
"""The default timeout must cover slow H2D FINISH→PREPARE transitions
|
||||
(~50 s observed). A 45 s default would trip on the exact scenario the
|
||||
subtask_id check is guarding against, leaving no fallback for printers
|
||||
that don't echo subtask_id."""
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(PrintScheduler._watchdog_print_start)
|
||||
assert sig.parameters["timeout"].default == 90.0
|
||||
|
||||
|
||||
class TestWatchdogFallbackBehaviour:
|
||||
"""Backwards-compat and defensive behaviour around missing data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_subtask_id_none_falls_back_to_state_only(self, db_session):
|
||||
"""When we never captured a pre-dispatch subtask_id (e.g. printer just
|
||||
connected), the watchdog must still work on the state signal alone —
|
||||
and still revert when state stays unchanged, so half-broken sessions
|
||||
are still recovered."""
|
||||
get_status = MagicMock(return_value=_status("FINISH", "SOMETHING"))
|
||||
get_client = MagicMock(return_value=None)
|
||||
|
||||
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=None,
|
||||
timeout=0.2,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_subtask_id_none_does_not_trigger_early_exit(self, db_session):
|
||||
"""If the printer transiently reports subtask_id=None (e.g. during
|
||||
reconnect), that must not be treated as "changed" — otherwise the
|
||||
watchdog would exit early without a real pickup signal and leave the
|
||||
item stuck in "printing" after a genuinely broken session."""
|
||||
get_status = MagicMock(return_value=_status("FINISH", None))
|
||||
get_client = MagicMock(return_value=None)
|
||||
|
||||
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",
|
||||
timeout=0.2,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_disconnected_returns_without_reverting(self, db_session):
|
||||
"""If the printer drops during the watchdog window, don't touch the DB —
|
||||
the reconnect path will sort the queue state out."""
|
||||
get_status = MagicMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
|
||||
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",
|
||||
timeout=0.2,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "printing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_revert_if_item_already_completed(self, db_session):
|
||||
"""If the print completed between watchdog arm-time and timeout (item is
|
||||
no longer "printing"), the watchdog must not clobber whatever status it
|
||||
ended up in — #967 race guard."""
|
||||
# Move item on to "completed" before the watchdog fires.
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
item.status = "completed"
|
||||
await db.commit()
|
||||
|
||||
get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK"))
|
||||
get_client = MagicMock(return_value=None)
|
||||
|
||||
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",
|
||||
timeout=0.2,
|
||||
poll_interval=0.05,
|
||||
)
|
||||
|
||||
async with db_session() as db:
|
||||
item = await db.get(PrintQueueItem, 1)
|
||||
assert item.status == "completed" # untouched
|
||||
Reference in New Issue
Block a user