Restore the skip-objects list after a restart mid-print

The object list lives in PrinterState and is filled by the print-start path,
which bambu_mqtt suppresses on the first RUNNING push after startup so a
running print is not archived twice (#1304). Everything else that moment
restores came back - the archive into _active_prints, the filament
attribution session, the timelapse baseline - and the object list did not.
So the card saw zero objects and greyed out its Skip button for the rest of
the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
restart at 09:17, Skip dead for the remaining hour.

Nothing could bring it back either. GET /print/objects rebuilds the list
whenever it is empty, but its only caller is the modal that the greyed-out
button opens.

on_print_running_observed now reloads the objects from the archive of the
print that is still running, anchored on subtask_id - the firmware mints one
per print, so a leftover status="printing" row from a completion that was
never seen cannot lend its objects to another job. Without an id nothing is
loaded rather than guessed; the endpoint's own reload covers that on demand.

That endpoint now reads the archived 3MF from disk before it asks the
printer. The archive of a running print normally holds the very file the
printer is executing, so the fan-out was fetching back 15 MB Bambuddy
already had, over the printer's single FTP socket, while it was printing -
and on a printer that kept the file on internal storage it cannot succeed at
all. FTP stays as the fallback. skipped_objects is left alone: a reload is
not a new print, and what the user has already skipped only lives there.

The plate image had the same fault one layer down. Opening the modal asks
for the cover, the top view and the object-ID mask, and the in-memory 3MF
cache those share dies with the process - so after a restart all three went
back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
read from socket contention, which is the storm #972 was about. The cover
flow takes the running print's archived file too, resolved in the caller's
short-lived session and passed in so _produce_cover_image still does no DB
work, and marked as a shared file so the cleanup cannot delete the archive.

Finally the card: a running print always has at least one object, so a count
of zero means "not loaded", not "nothing to skip". Exactly one object is the
real nothing-to-skip case and still disables the button.

---

Stop a test's printer client leaking into the next test

POST /api/v1/printers really connects, so a test that creates a printer
through the API leaves a live client in the printer_manager singleton. The
singleton outlives the per-test in-memory database, so the next test on that
xdist worker - whose own first printer is handed the same primary key - reads
that leftover client as its own live status.

test_scheduled_drying_routes was the visible victim: an "online" printer with
no firmware version fails the drying preflight, so scheduling came back 400
instead of 200. It only bites when --dist load happens to put victim and
leaker on one worker, which is why it passes on its own and flakes under -n.

Registrations made during a test are now undone after it, ids the test did not
add are left alone, and disconnect_printer is what also drops the model and
printer-info caches and stops the paho thread the leaked client was keeping
alive against an unreachable address for the rest of the run.
This commit is contained in:
maziggy
2026-08-18 13:43:46 +02:00
parent d227d42272
commit cfecfa360e
13 changed files with 686 additions and 30 deletions
+105 -2
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import re
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
@@ -1083,6 +1084,41 @@ def clear_cover_cache(printer_id: int) -> None:
_cover_404_cache.pop(printer_id, None)
async def _running_print_archive_file(printer_id: int, state) -> Path | None:
"""Path to the 3MF of the print this printer is running, if we have it.
Bambuddy archives the sliced file when the print starts, so the copy the
printer is executing is usually already on disk. Anchored on ``subtask_id``,
which the firmware mints per print: a leftover ``status="printing"`` row from
a completion that was never seen must not lend its file to another job.
Opens its own short-lived session, like the caller does, so the pooled
connection is not held across the FTP work that follows.
"""
subtask_id = str(getattr(state, "subtask_id", "") or "").strip()
if subtask_id in ("", "0"):
return None
from backend.app.models.archive import PrintArchive
async with database.async_session() as db:
archive = await db.scalar(
select(PrintArchive)
.where(
PrintArchive.printer_id == printer_id,
PrintArchive.status == "printing",
PrintArchive.subtask_id == subtask_id,
)
.order_by(PrintArchive.created_at.desc())
.limit(1)
)
if archive is None or not archive.file_path:
return None
path = settings.base_dir / archive.file_path
return path if path.is_file() and str(path).endswith(".3mf") else None
@router.get("/{printer_id}/cover")
async def get_printer_cover(
printer_id: int,
@@ -1175,7 +1211,16 @@ async def get_printer_cover(
fut: asyncio.Future = asyncio.get_event_loop().create_future()
_cover_inflight[inflight_key] = fut
try:
image_data = await _produce_cover_image(printer, printer_id, subtask_name, view, view_key, plate_num, cache_key)
image_data = await _produce_cover_image(
printer,
printer_id,
subtask_name,
view,
view_key,
plate_num,
cache_key,
archive_path=await _running_print_archive_file(printer_id, state),
)
return Response(content=image_data, media_type="image/png")
finally:
if not fut.done():
@@ -1191,6 +1236,7 @@ async def _produce_cover_image(
view_key: str,
plate_num: int | None,
cache_key: tuple[str, str],
archive_path: Path | None = None,
) -> bytes:
"""Download the active-print 3MF and extract its cover thumbnail (#2572).
@@ -1198,7 +1244,9 @@ async def _produce_cover_image(
can single-flight through it (see ``_cover_inflight``). Returns the PNG bytes
on success (also filling ``_cover_cache``) and raises ``HTTPException`` on
failure (filling ``_cover_404_cache`` for the definitive 404s). Does no DB
work — the caller already released the pooled connection before this runs.
work — the caller already released the pooled connection before this runs,
which is also why ``archive_path`` arrives resolved rather than looked up
here.
"""
# Build possible 3MF filenames from subtask_name
# Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
@@ -1260,6 +1308,20 @@ async def _produce_cover_image(
using_cached = True
break
if not downloaded:
# Same idea, one step further back: that in-memory cache dies with the
# process, but the archive of the print that is still running holds the
# very 3MF on disk. Without this, reopening a card or the skip-objects
# plate after a restart pulls the whole file back off a printer that is
# mid-print — measured at three concurrent fan-outs, thirteen seconds
# and a 0-byte read on the maintainer's H2C, which is exactly the
# single-socket contention #972 was about.
if archive_path is not None:
logger.info("Cover using the running print's archived 3MF at %s (no FTP)", archive_path)
temp_path = archive_path
downloaded = True
using_cached = True
if not downloaded:
# The cover lives inside the 3MF, so it is only reachable if the 3MF is.
# When the printer kept the print on internal storage there is nothing
@@ -3663,6 +3725,47 @@ async def get_printable_objects(
# Reload objects from 3MF if requested or no objects loaded
if reload or not client.state.printable_objects:
# The archive of a running print normally holds the very file the
# printer is executing, so ask the disk before asking the printer:
# the fan-out below pulls the whole 3MF over FTPS from a machine that
# is mid-print — 15 MB on the print this was written for — and on a
# printer that kept the file on internal storage it cannot succeed at
# all. skipped_objects is deliberately left alone: a reload is
# not a new print, and the list of what the user already skipped only
# lives here.
from backend.app.models.archive import PrintArchive
from backend.app.services.archive import extract_printable_objects_from_archive
subtask_id = str(getattr(client.state, "subtask_id", "") or "").strip()
if subtask_id not in ("", "0"):
archive = await db.scalar(
select(PrintArchive)
.where(
PrintArchive.printer_id == printer_id,
PrintArchive.status == "printing",
PrintArchive.subtask_id == subtask_id,
)
.order_by(PrintArchive.created_at.desc())
.limit(1)
)
if archive is not None:
objects, bbox_all = extract_printable_objects_from_archive(
settings.base_dir / archive.file_path,
plate_number=resolve_plate_id(client.state),
)
if objects:
client.state.printable_objects = objects
client.state.printable_objects_bbox_all = bbox_all
logger.info(
"Reloaded %s objects for printer %s from archive %s",
len(objects),
printer_id,
archive.id,
)
# Only when the disk could not answer: a `reload=true` that the archive
# satisfied has already refreshed from the file the printer is running.
if not client.state.printable_objects:
subtask_name = client.state.subtask_name
if subtask_name:
from backend.app.services.archive import extract_printable_objects_from_3mf