Browse Source

fix(ghost-print): run SD card cleanup before archive lookup and add retry logic (#374)

The SD card file cleanup (which prevents phantom prints on power cycle)
was placed after the archive_id early-return in on_print_complete. Users
with auto-archiving disabled never reached the cleanup code, so ghost
prints persisted on every reboot.

- Move cleanup before the archive_id check so it always runs
- Add retry loop (3 attempts, 2s delay) for printers that briefly lock
  the filesystem after a print ends
- Try both .3mf and .gcode extensions

Reported by @DIYofThings in #374.
maziggy 3 months ago
parent
commit
27e4fe49bd
2 changed files with 45 additions and 26 deletions
  1. 1 1
      CHANGELOG.md
  2. 44 25
      backend/app/main.py

+ 1 - 1
CHANGELOG.md

@@ -40,7 +40,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
 - **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
-- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking).
+- **Phantom Prints on Power Cycle** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The print queue uploaded `.3mf` files to the printer's SD card root (`/`) but never deleted them after the print finished. Some printers (e.g. P1S) auto-start files found in the root directory on power cycle, causing ghost prints on every reboot. Now deletes the uploaded file from the SD card after print completion (best-effort, non-blocking). The cleanup also tries `.gcode` files and retries up to 3 times with a 2-second delay to handle printers that briefly lock the filesystem after a print ends. Runs before the archive lookup so it works even when auto-archiving is disabled.
 - **Spool Form Scrollbar Flicker in Edge** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — The Add/Edit Spool modal's scrollable area used `overflow-y: auto`, which on Windows Edge (where scrollbars take layout space) caused the scrollbar to appear and disappear on hover — making the color picker unusable at certain zoom levels. Added `scrollbar-gutter: stable` to reserve scrollbar space and prevent layout thrashing.
 - **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
 - **Schedule Print Allows No Plate Selected for Multi-Plate Files** ([#394](https://github.com/maziggy/bambuddy/issues/394)) — When scheduling a multi-plate file from the file manager, the modal showed a "Selection required" warning but still allowed submission without selecting a plate. The job defaulted to plate 1, but the queue item didn't indicate which plate, and editing showed no plate selected. Now auto-selects the first plate by default when plates load, and the submit button validation applies to both archive and library files.

+ 44 - 25
backend/app/main.py

@@ -2088,6 +2088,50 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive:
                     archive_id = archive.id
 
+    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
+    # The print scheduler uploads files to the SD card root (/). Some printers (e.g. P1S)
+    # auto-start files found in root on power cycle, causing ghost prints.
+    # Must run before the archive_id early-return so it executes even when archiving is disabled.
+    try:
+        printer_info = printer_manager.get_printer(printer_id)
+        if printer_info and subtask_name:
+            from backend.app.services.bambu_ftp import delete_file_async
+
+            # Try both .3mf and .gcode extensions — the printer may have either
+            for ext in (".3mf", ".gcode"):
+                remote_path = f"/{subtask_name}{ext}"
+                # Retry up to 3 times — the printer may still lock the filesystem briefly after a print ends
+                for attempt in range(1, 4):
+                    try:
+                        delete_result = await delete_file_async(
+                            printer_info.ip_address,
+                            printer_info.access_code,
+                            remote_path,
+                            printer_model=printer_info.model,
+                        )
+                        if delete_result:
+                            logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
+                        break  # Success or file doesn't exist — no need to retry
+                    except Exception as e:
+                        if attempt < 3:
+                            logger.debug(
+                                "SD card cleanup attempt %d/3 failed for %s: %s, retrying in 2s",
+                                attempt,
+                                remote_path,
+                                e,
+                            )
+                            await asyncio.sleep(2)
+                        else:
+                            logger.debug(
+                                "SD card cleanup failed after 3 attempts for %s: %s (non-critical)",
+                                remote_path,
+                                e,
+                            )
+    except Exception as e:
+        logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
+
+    log_timing("SD card cleanup")
+
     if not archive_id:
         logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name)
         return
@@ -2778,31 +2822,6 @@ async def on_print_complete(printer_id: int, data: dict):
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
     log_timing("Queue item update")
-
-    # Cleanup: delete uploaded file from printer SD card to prevent phantom prints (Issue #374)
-    # The print scheduler uploads .3mf files to the SD card root (/). Some printers (e.g. P1S)
-    # auto-start files found in root on power cycle, causing ghost prints.
-    try:
-        printer_info = printer_manager.get_printer(printer_id)
-        if printer_info and subtask_name:
-            from backend.app.services.bambu_ftp import delete_file_async
-
-            remote_path = f"/{subtask_name}.3mf"
-            logger.info("Cleaning up uploaded file from printer SD card: %s", remote_path)
-            delete_result = await delete_file_async(
-                printer_info.ip_address,
-                printer_info.access_code,
-                remote_path,
-                printer_model=printer_info.model,
-            )
-            if delete_result:
-                logger.info("Deleted %s from printer %s SD card", remote_path, printer_info.name)
-            else:
-                logger.debug("File delete returned False for %s (may not exist)", remote_path)
-    except Exception as e:
-        logger.debug("SD card file cleanup failed for printer %s: %s (non-critical)", printer_id, e)
-
-    log_timing("SD card cleanup")
     logger.info("[CALLBACK] on_print_complete finished for printer %s, archive %s", printer_id, archive_id)