Browse Source

fix(reconcile): don't synthesize aborted PRINT COMPLETE on the bare-connect edge before push_status arrives (#1679)

  on_printer_status_change fires from MQTT _on_connect BEFORE the first
  push_status round-trips, when PrinterState is still on construction
  defaults (state="unknown", subtask_name=""). The connected-edge
  reconcile was treating that degenerate state as evidence and
  synthesising aborted PRINT COMPLETE for every in-flight archive on
  every Bambuddy restart. The reactive PRINT COMPLETE then created a
  duplicate archive (lookup misses on cleared _active_prints), so
  filament got deducted twice.

  Two-layer guard: gate the reconcile spawn on a real state.state, and
  make _is_active_archive_stale return not-stale on unknown/empty input
  as defensive fallback. #1542 behaviour preserved — real stale archives
  still get caught the moment a real push_status arrives.
maziggy 3 tháng trước cách đây
mục cha
commit
d0d6a659e9

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
CHANGELOG.md


+ 36 - 4
backend/app/main.py

@@ -822,7 +822,22 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # WebSocket dedup / broadcast logic below, and the connected edge is
     # marked True BEFORE the await so concurrent status updates inside
     # the same connection don't re-trigger reconciliation.
-    if state.connected and not _printer_reconciled_since_connect.get(printer_id, False):
+    #
+    # Wait for a real push_status before reconciling (#1679): MQTT
+    # `_on_connect` broadcasts `state` IMMEDIATELY after the broker accepts
+    # the connection, BEFORE `_request_push_all` round-trips. At that
+    # instant the `PrinterState` is still on construction defaults — most
+    # importantly `state.state == "unknown"` and `state.subtask_name == ""`.
+    # If reconcile spawns here, every in-flight archive falls through to
+    # the empty-subtask_name trigger and gets synthesised `aborted`, which
+    # creates a duplicate archive on the real PRINT COMPLETE and
+    # double-counts filament. Gating on `state.state ∉ ("", "unknown")`
+    # keeps the #1542 mechanism intact: once the first real push_status
+    # updates `state.state` (RUNNING / IDLE / FINISH / …), this handler
+    # fires again with the flag still False — reconcile then runs against
+    # actual evidence.
+    state_known = bool(state.state) and state.state.upper() not in ("", "UNKNOWN")
+    if state.connected and state_known and not _printer_reconciled_since_connect.get(printer_id, False):
         _printer_reconciled_since_connect[printer_id] = True
         spawn_background_task(
             reconcile_stale_active_prints(printer_id),
@@ -3371,11 +3386,28 @@ def _is_active_archive_stale(archive, state) -> tuple[bool, str]:
 
     Conservative on purpose: PAUSE / PREPARE / SLICING and any RUNNING state
     with matching subtask_id+subtask_name is left alone. The cost of a false
-    positive is a single misreported "aborted" status that the next real
-    PRINT COMPLETE would have overwritten with the correct status anyway.
-    The cost of a false negative is the ghost-print loop in #1542.
+    positive is a duplicate archive on the next real PRINT COMPLETE — the
+    reactive handler uses ``_active_prints`` for lookup, which the reconcile
+    clears on synthesis, so the real completion creates a fresh row instead
+    of overwriting the synthesised one (#1679). The cost of a false negative
+    is the ghost-print loop in #1542.
+
+    Pre-push guard (#1679): when ``state.state`` is empty or ``"unknown"``,
+    MQTT has connected but the first ``push_status`` response hasn't been
+    applied yet — ``PrinterState`` is sitting on its construction defaults.
+    The reconcile caller in ``on_printer_status_change`` is already gated
+    on a real ``state.state``, so in normal operation this branch is
+    unreachable; it's kept as belt-and-braces for future callers and for
+    the narrow window where a partial state update could arrive
+    (``state.state`` set but ``subtask_name`` not yet populated). Returning
+    ``not stale`` on degenerate input is strictly conservative: a real
+    stale archive will still be caught by the next push_status arriving
+    with terminal state.
     """
     current_state = (state.state or "").upper()
+    if current_state in ("", "UNKNOWN"):
+        # No real push_status yet — PrinterState defaults are not evidence.
+        return False, ""
     if current_state in ("IDLE", "FINISH", "FAILED"):
         return True, f"printer state {current_state}"
     # Below here the printer is in a running / pre-running state (RUNNING /

+ 18 - 0
backend/tests/unit/test_reconcile_stale_active_prints.py

@@ -129,6 +129,24 @@ class TestIsActiveArchiveStale:
         assert is_stale is True
         assert "IDLE" in reason
 
+    # #1679: defensive pre-push guard. Even if reconcile gets called against
+    # a PrinterState that's still on construction defaults (state="unknown"
+    # / empty / None, subtask_name=""), the function must NOT report stale —
+    # otherwise the reactive PRINT COMPLETE later creates a duplicate
+    # archive and filament gets double-counted. The on_printer_status_change
+    # caller is the primary fix (gates the reconcile spawn on real state),
+    # but this guard is belt-and-braces for any future caller.
+    @pytest.mark.parametrize("degenerate_state", ["unknown", "UNKNOWN", "Unknown", "", None])
+    def test_pre_push_state_returns_not_stale_even_with_empty_subtask(self, degenerate_state):
+        archive = _archive(subtask_id="ABC123")
+        state = _state(degenerate_state, subtask_id="", subtask_name="")
+        is_stale, _ = _is_active_archive_stale(archive, state)
+        assert is_stale is False, (
+            f"state.state={degenerate_state!r} means MQTT hasn't pushed real data yet; "
+            "treating an in-flight archive as stale here causes the #1679 "
+            "duplicate-archive + filament-double-count regression"
+        )
+
 
 class TestReconcileStaleActivePrints:
     """Orchestrator-level tests — mock the printer manager + DB session so

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác