Ver Fonte

fix(printers): a printer deleted while its print was starting gets no archive or tracking

on_print_start downloads the 3MF for seconds to minutes; a printer deleted
meanwhile still got a "printing" archive no completion would end (SQLite),
_active_prints, a layer timelapse and its 3MF cache entry back (PostgreSQL
refused the insert and logged an error). It now checks the printer still
exists after the downloads, again after the archive's commit (removing a
row the delete's sweep missed, leaving one the user chose to keep), in the
queue/reprint branch before adoption and after its commit (undoing the
adoption to the archive's previous printer), and forgets the printer once
more at the end of each branch in its own session. The scheduler no longer
records the owner of a queue print whose printer was deleted during upload.
maziggy há 1 semana atrás
pai
commit
0988349519

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A printer deleted while its print was starting no longer gets an archive or tracking** — When a print starts, Bambuddy downloads its 3MF from the printer, which can take minutes. A printer deleted during that download still got an archive afterwards: on SQLite one that showed "printing" forever, since the deleted printer would never report the end, along with a layer timelapse and tracking for it; on PostgreSQL the archive was refused and an error logged. Bambuddy now checks that the printer still exists once the download is done, and again once the archive is written; a queue or reprint print starting just as its printer is deleted is no longer attached to that printer either. Archives the delete was told to keep stay as they are. A queue print dispatched to a printer deleted during its upload no longer records its owner for that printer either.
 - **Closing one camera view no longer cuts off the others** — With an external RTSP or USB camera, closing one view of a printer (a Cam Wall tile turning to snapshot, the Camera page, the camera window's refresh) stopped that printer's camera stream for every other open view too, which then froze until it reconnected. Each view now names itself on its stream and its stop: the stop ends that view's own stream (at once, as before, so a USB camera is released when its view closes) and any stream that has stopped sending pictures, and leaves the other views' streams running.
 - **Closing or minimizing a camera view now ends its stream** — The camera window on the Printers page left its stream open when minimized (one more open connection per minimize and expand), and both it and the Camera page closed only the first picture they had shown: after a reconnect or a restart, closing them left the current stream running. An open stream keeps the printer's camera connection busy, keeps the chamber light on when "Turn on chamber light for camera" is on for that printer, and made the "stop camera" request on closing do nothing. Every picture a camera view drops now ends its stream. A reconnect also no longer counts one extra failed attempt toward the reconnect limit.
 - **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it.

+ 148 - 21
backend/app/main.py

@@ -16,7 +16,7 @@ from urllib.parse import urlparse
 from fastapi import FastAPI
 from fastapi.responses import FileResponse
 from fastapi.staticfiles import StaticFiles
-from sqlalchemy import delete, or_, select, text
+from sqlalchemy import delete, inspect as sa_inspect, or_, select, text, update
 
 from backend.app.api.routes import (
     ams_history,
@@ -1044,10 +1044,13 @@ def forget_print_creator(user_id: int) -> None:
 async def forget_printer(printer_id: int) -> None:
     """Drop what is kept for a deleted printer's id, here and in the services.
 
-    SQLite gives the id to the next printer created, which inherited it all: the
-    running print's owner (its prints passed the kill switch and were billed to
-    that user), the plate-clear gate, pending and suppressed notifications, the
-    camera feed, the plate references. Called after the delete is committed.
+    SQLite used to give the id to the next printer created, which inherited it
+    all: the running print's owner (its prints passed the kill switch and were
+    billed to that user), the plate-clear gate, pending and suppressed
+    notifications, the camera feed, the plate references. Ids are no longer
+    reused (AUTOINCREMENT), but nothing kept for a printer that is gone should
+    stay. Called after the delete is committed, and again by work that was in
+    flight and wrote state back.
     Every printer-keyed map of this module is listed here or, with the reason,
     in tests/unit/test_forget_printer.py.
     """
@@ -4178,6 +4181,61 @@ async def dispatch_outcome_confirmation(
     return True
 
 
+async def _printer_gone(db, printer_id: int) -> bool:
+    """Whether the printer was deleted. A column read: the session's loaded
+    Printer would otherwise answer for the database."""
+    from backend.app.models.printer import Printer
+
+    return await db.scalar(select(Printer.id).where(Printer.id == printer_id)) is None
+
+
+async def _drop_archive_of_deleted_printer(db, printer_id: int, archive_id: int) -> bool:
+    """Remove the archive just written if its printer was deleted meanwhile.
+
+    The delete route sweeps the printer's archives before it commits, so a row
+    committed after that sweep (the check before writing passed while the
+    delete was still uncommitted) is left for a printer that will never report
+    the print's end. A row the sweep reached is the route's: deleted with the
+    printer's archives, or detached (printer_id NULL) when the user kept them,
+    and then left as it is. Either way nothing is registered for it. Run after
+    the archive's commit, before anything is registered for it.
+    """
+    from backend.app.models.archive import PrintArchive
+
+    if not await _printer_gone(db, printer_id):
+        return False
+    owner = await db.scalar(select(PrintArchive.printer_id).where(PrintArchive.id == archive_id))
+    if owner == printer_id:
+        logging.getLogger(__name__).info(
+            "Printer %s was deleted while archive %s was written; removing it", printer_id, archive_id
+        )
+        await ArchiveService(db).delete_archive(archive_id)
+    await forget_printer(printer_id)
+    return True
+
+
+async def _forget_if_printer_gone(printer_id: int) -> None:
+    """At the end of a print start: the slow steps after the last check (smart
+    plug, notifications, the timelapse baseline's FTP listing) can outlast the
+    printer's delete and its forget_printer, and then wrote state back.
+    (An ActivePrintSpoolman row written then is the init_db orphan sweep's on
+    SQLite; PostgreSQL refuses it.)
+
+    Its own session: those steps can leave the handler's needing a rollback (a
+    write to the archive the delete removed). Never raises: it runs inside the
+    fallback branch's try, whose except reports the archive as failed.
+    """
+    try:
+        async with async_session() as db:
+            gone = await _printer_gone(db, printer_id)
+        if gone:
+            await forget_printer(printer_id)
+    except Exception:
+        logging.getLogger(__name__).warning(
+            "Could not check whether printer %s was deleted during its print start", printer_id, exc_info=True
+        )
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     logger = logging.getLogger(__name__)
@@ -4531,7 +4589,25 @@ async def on_print_start(printer_id: int, data: dict):
             result = await db.execute(select(PrintArchive).where(PrintArchive.id == expected_archive_id))
             archive = result.scalar_one_or_none()
 
+            # Deleted after this handler loaded it (the plate check waits
+            # seconds) but before the route forgot the expected print: the
+            # archive the route kept or deleted is left as the route left it.
+            if archive and await _printer_gone(db, printer_id):
+                logger.info(
+                    "Printer %s was deleted before its print started; not adopting archive %s", printer_id, archive.id
+                )
+                await forget_printer(printer_id)
+                return
+
             if archive:
+                # As it was before this adoption: put back if the printer turns
+                # out to have been deleted while it was adopted (below).
+                before_adoption = {
+                    attr.columns[0].name: getattr(archive, attr.key)
+                    for attr in sa_inspect(PrintArchive).column_attrs
+                    if attr.key != "id"
+                }
+
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.started_at = datetime.now(timezone.utc)
@@ -4585,22 +4661,8 @@ async def on_print_start(printer_id: int, data: dict):
                         expected_archive_id,
                         starting_plate_id,
                     )
-                elif stale_timelapse_relpath:
-                    try:
-                        stale_path = app_settings.base_dir / stale_timelapse_relpath
-                        if stale_path.is_file():
-                            stale_path.unlink()
-                            logger.info(
-                                "Deleted stale timelapse %s on reprint of archive %s",
-                                stale_timelapse_relpath,
-                                expected_archive_id,
-                            )
-                    except OSError as e:
-                        logger.warning(
-                            "Failed to delete stale timelapse %s on reprint: %s",
-                            stale_timelapse_relpath,
-                            e,
-                        )
+                # The video is deleted once the adoption stands (below).
+                delete_stale_video = bool(stale_timelapse_relpath) and stale_timelapse_plate_id == starting_plate_id
                 # Persist a restart-stable id so a later restart resumes this
                 # archive by subtask_id instead of name-matching + duplicating
                 # it (#1485). The printer often hasn't echoed subtask_id back
@@ -4638,6 +4700,52 @@ async def on_print_start(printer_id: int, data: dict):
                     archive.printer_id = printer_id
                 await db.commit()
 
+                # The printer can be deleted between the check above and this
+                # commit (the route's transaction lasts seconds), and the commit
+                # then adopted the archive anyway: status, verdict and timelapse
+                # changed, and the deleted printer's id written on a row the
+                # route had detached or never touched. Undo the adoption.
+                if await _printer_gone(db, printer_id):
+                    logger.info(
+                        "Printer %s was deleted while archive %s was adopted; undoing the adoption",
+                        printer_id,
+                        archive.id,
+                    )
+                    # Everything as before this adoption. The printer it had
+                    # then stays, unless it was the deleted one: the route
+                    # detached that row (kept) or deleted it (no row matches).
+                    previous_printer = before_adoption["printer_id"]
+                    await db.execute(
+                        update(PrintArchive)
+                        .where(PrintArchive.id == archive.id)
+                        .values(
+                            {
+                                **before_adoption,
+                                "printer_id": None if previous_printer == printer_id else previous_printer,
+                            }
+                        )
+                    )
+                    await db.commit()
+                    await forget_printer(printer_id)
+                    return
+
+                if delete_stale_video and stale_timelapse_relpath:
+                    try:
+                        stale_path = app_settings.base_dir / stale_timelapse_relpath
+                        if stale_path.is_file():
+                            stale_path.unlink()
+                            logger.info(
+                                "Deleted stale timelapse %s on reprint of archive %s",
+                                stale_timelapse_relpath,
+                                expected_archive_id,
+                            )
+                    except OSError as e:
+                        logger.warning(
+                            "Failed to delete stale timelapse %s on reprint: %s",
+                            stale_timelapse_relpath,
+                            e,
+                        )
+
                 # Track as active print
                 _active_prints[(printer_id, archive.filename)] = archive.id
                 if subtask_name:
@@ -4732,6 +4840,7 @@ async def on_print_start(printer_id: int, data: dict):
                 # AFTER the new MP4 already exists and never matches a diff
                 # (#1403 follow-up — see pwostran's 2026-05-18 support bundle).
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
+                await _forget_if_printer_gone(printer_id)
 
             return  # Skip creating a new archive
 
@@ -5333,6 +5442,16 @@ async def on_print_start(printer_id: int, data: dict):
                     display_name_after_plate_reject = corrected_subtask or subtask_name or None
                     subtask_name = corrected_subtask or ""
 
+        # Deleted while the 3MF was being fetched (that can take minutes): no
+        # archive, timelapse or tracking for a printer that will never report
+        # the print's end. forget_printer again: the download registered its
+        # 3MF cache entry after the delete cleared the id. (A delete committing
+        # while the archive is written is caught after its commit.)
+        if await _printer_gone(db, printer_id):
+            logger.info("Printer %s was deleted during the print-start download; not archiving", printer_id)
+            await forget_printer(printer_id)
+            return
+
         if not downloaded_filename or not temp_path:
             logger.warning("Could not find 3MF file for print: %s", filename or subtask_name)
             # Create a fallback archive without 3MF data so the print is still tracked
@@ -5416,6 +5535,9 @@ async def on_print_start(printer_id: int, data: dict):
 
                 logger.info("Created fallback archive %s for %s (no 3MF available)", fallback_archive.id, print_name)
 
+                if await _drop_archive_of_deleted_printer(db, printer_id, fallback_archive.id):
+                    return
+
                 _maybe_start_layer_timelapse(printer, printer_id, fallback_archive.id)
 
                 # Track as active print
@@ -5510,6 +5632,7 @@ async def on_print_start(printer_id: int, data: dict):
                     await _capture_timelapse_baseline_at_start(
                         printer, printer_id, logger, archive_id=fallback_archive.id
                     )
+                await _forget_if_printer_gone(printer_id)
                 return
             except Exception as e:
                 logger.error("Failed to create fallback archive: %s", e)
@@ -5558,6 +5681,9 @@ async def on_print_start(printer_id: int, data: dict):
                             "Could not reload archive %s after the failed flag write: %s", archive_id, reload_error
                         )
 
+                if await _drop_archive_of_deleted_printer(db, printer_id, archive_id):
+                    return
+
                 # Track this active print (use both original filename and downloaded filename)
                 _active_prints[(printer_id, downloaded_filename)] = archive.id
                 if filename and filename != downloaded_filename:
@@ -5654,6 +5780,7 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Capture timelapse file baseline for snapshot-diff on completion
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
+                await _forget_if_printer_gone(printer_id)
         finally:
             # Keep temp_path around until print completes so the cover endpoint
             # can reuse it (#972). Cache eviction in on_print_complete deletes

+ 3 - 0
backend/app/services/print_scheduler.py

@@ -8107,10 +8107,13 @@ class PrintScheduler:
         # Read from the row, not item.created_by_id or db.get: both can still
         # hold a user deleted during the upload, after delete_user forgot them
         # (on SQLite the id may be someone new's by the time the print ends).
+        # Joined with the printer too: one deleted during the upload was
+        # forgotten as well, and this would write the owner back under its id.
         owner = (
             await db.execute(
                 select(User.id, User.username)
                 .join(PrintQueueItem, PrintQueueItem.created_by_id == User.id)
+                .join(Printer, Printer.id == PrintQueueItem.printer_id)
                 .where(PrintQueueItem.id == item.id)
             )
         ).first()

+ 435 - 0
backend/tests/integration/test_print_start_printer_deleted_midway.py

@@ -0,0 +1,435 @@
+"""A printer deleted while its print-start handler downloads the 3MF.
+
+Deleting a printer cuts its MQTT link, but an ``on_print_start`` already in its
+FTP phase (seconds to minutes) carried on: it wrote a "printing" archive that no
+completion would ever end, registered it in ``_active_prints``, started a layer
+timelapse and re-added the 3MF cache entry ``forget_printer`` had cleared. It
+now checks the printer still exists once the downloads are done, and forgets it
+again if not.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import delete, select, update
+
+from backend.app.main import _active_prints
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+from backend.tests.integration.test_external_print_confirmation_1898 import (
+    _clear_print_state,  # noqa: F401 - autouse fixture
+    _drive_print_start,
+    _StubArchiveService,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _download_deleting(db_session, printer_id: int, *, ok: bool):
+    """The FTP download, during which the printer is deleted."""
+
+    async def download(*_args, **_kwargs):
+        await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+        await db_session.commit()
+        return ok
+
+    return patch("backend.app.main.download_file_async", new=AsyncMock(side_effect=download))
+
+
+class _ArchiveService(_StubArchiveService):
+    """The #1898 stub, recording its writes, with the real delete (the check
+    after the archive's commit deletes through it)."""
+
+    written: list[int] = []
+
+    async def archive_print(self, **kwargs):
+        _ArchiveService.written.append(kwargs.get("printer_id"))
+        return await super().archive_print(**kwargs)
+
+    async def delete_archive(self, archive_id: int) -> bool:
+        from backend.app.services.archive import ArchiveService
+
+        return await ArchiveService(self.db).delete_archive(archive_id)
+
+
+async def _archives(db_session, printer_id: int) -> list[PrintArchive]:
+    db_session.expire_all()
+    return list((await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all())
+
+
+@pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
+async def test_a_printer_deleted_during_the_download_gets_nothing(
+    test_engine, db_session, printer_factory, download_ok
+):
+    printer = await printer_factory()
+    printer_id = printer.id
+    _ArchiveService.written = []
+    ask_outcome = AsyncMock(return_value=False)  # builds the fallback row
+
+    with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
+        mocks = await _drive_print_start(
+            test_engine,
+            printer,
+            download_ok=download_ok,
+            extra_patches=(
+                _download_deleting(db_session, printer_id, ok=download_ok),
+                patch("backend.app.main.ArchiveService", _ArchiveService),
+                patch("backend.app.main._ask_outcome_for_external_print", new=ask_outcome),
+            ),
+        )
+
+    # Checked before writing: no archive was even started (the check after the
+    # commit would remove one, but only after it was written).
+    assert _ArchiveService.written == []
+    ask_outcome.assert_not_awaited()
+    assert await _archives(db_session, printer_id) == []
+    assert not [key for key in _active_prints if key[0] == printer_id]
+    mocks["_maybe_start_layer_timelapse"].assert_not_called()
+    # What the download wrote under the id (the 3MF cache entry) goes again.
+    forget.assert_awaited_once_with(printer_id)
+
+
+@pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
+async def test_a_printer_deleted_while_its_archive_is_written_gets_nothing(
+    test_engine, db_session, printer_factory, download_ok
+):
+    """The delete commits between the check and the archive's commit (the route
+    sweeps the printer's archives before it commits): the row written after
+    that sweep is removed again."""
+    printer = await printer_factory()
+    printer_id = printer.id
+
+    async def delete_printer():
+        await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+        await db_session.commit()
+
+    class _DeletingArchiveService(_ArchiveService):
+        async def archive_print(self, **kwargs):
+            await delete_printer()
+            return await super().archive_print(**kwargs)
+
+    async def ask_outcome(*_args, **_kwargs):  # runs while the fallback row is built
+        await delete_printer()
+        return False
+
+    with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
+        mocks = await _drive_print_start(
+            test_engine,
+            printer,
+            download_ok=download_ok,
+            extra_patches=(
+                patch("backend.app.main.ArchiveService", _DeletingArchiveService),
+                patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(side_effect=ask_outcome)),
+            ),
+        )
+
+    assert await _archives(db_session, printer_id) == []
+    assert not [key for key in _active_prints if key[0] == printer_id]
+    mocks["_maybe_start_layer_timelapse"].assert_not_called()
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_an_archive_kept_by_the_delete_is_left_alone(test_engine, db_session, printer_factory):
+    """Deleted with "keep archives" right after the archive's commit: the route
+    detached it (printer_id NULL) as the user asked, so it stays. Nothing is
+    registered for it."""
+    printer = await printer_factory()
+    printer_id = printer.id
+
+    async def delete_keeping_archives(*_args, **_kwargs):  # runs after archive_print's commit
+        await db_session.execute(
+            update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None)
+        )
+        await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+        await db_session.commit()
+        return False
+
+    with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
+        mocks = await _drive_print_start(
+            test_engine,
+            printer,
+            download_ok=True,
+            extra_patches=(
+                patch("backend.app.main.ArchiveService", _ArchiveService),
+                patch(
+                    "backend.app.main._ask_outcome_for_external_print",
+                    new=AsyncMock(side_effect=delete_keeping_archives),
+                ),
+            ),
+        )
+
+    db_session.expire_all()
+    kept = (await db_session.scalars(select(PrintArchive))).all()
+    assert len(kept) == 1 and kept[0].printer_id is None
+    assert not [key for key in _active_prints if key[0] == printer_id]
+    mocks["_maybe_start_layer_timelapse"].assert_not_called()
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_a_printer_still_there_gets_its_archive(test_engine, db_session, printer_factory):
+    printer = await printer_factory()
+
+    with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
+        await _drive_print_start(test_engine, printer, download_ok=False)
+
+    assert len(await _archives(db_session, printer.id)) == 1
+    forget.assert_not_awaited()
+
+
+async def test_a_queue_print_whose_printer_was_deleted_during_the_upload_credits_nobody(
+    db_session, test_engine, monkeypatch
+):
+    """The dispatch would write the owner under the deleted printer's id after
+    forget_printer cleared it."""
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.user import User
+    from backend.app.services import print_scheduler as scheduler_module
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    owner = User(username="olga", password_hash="x", role="user")
+    printer = Printer(name="P", serial_number="DELMID1", ip_address="192.0.2.11", access_code="x")
+    db_session.add_all([owner, printer])
+    await db_session.commit()
+    item = PrintQueueItem(printer_id=printer.id, position=1, created_by_id=owner.id)
+    db_session.add(item)
+    await db_session.commit()
+
+    other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with other() as admin_db:  # meanwhile, through the API (SQLite keeps the item)
+        await admin_db.execute(delete(Printer).where(Printer.id == printer.id))
+        await admin_db.commit()
+
+    credited: list = []
+    monkeypatch.setattr(scheduler_module.printer_manager, "set_current_print_user", lambda *args: credited.append(args))
+    await PrintScheduler()._propagate_owner_to_printer_manager(db_session, item)
+
+    assert credited == []
+
+
+async def _queued_archive(db_session, printer_id: int) -> int:
+    """An archive Bambuddy dispatched to the printer, waiting for its start."""
+    from datetime import datetime, timezone
+
+    from backend.app.main import register_expected_print
+
+    archive = PrintArchive(
+        printer_id=printer_id,
+        filename="Bracket_plate_1.3mf",
+        file_path="archives/test/Bracket_plate_1.3mf",
+        file_size=1,
+        print_name="Bracket_plate_1",
+        status="completed",
+        started_at=datetime.now(timezone.utc),
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    register_expected_print(printer_id, "Bracket_plate_1.3mf", archive.id)
+    return archive.id
+
+
+async def _delete_keeping_archives(db_session, printer_id: int) -> None:
+    await db_session.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
+    await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+    await db_session.commit()
+
+
+async def _archive(db_session, archive_id: int) -> PrintArchive:
+    db_session.expire_all()
+    return await db_session.get(PrintArchive, archive_id)
+
+
+async def test_a_queued_print_of_a_deleted_printer_is_not_adopted(test_engine, db_session, printer_factory):
+    """Deleted with "keep archives" before the start was handled (the plate
+    check waits seconds), but before the route forgot the expected print."""
+    from backend.app import main
+
+    printer = await printer_factory()
+    printer_id = printer.id
+    archive_id = await _queued_archive(db_session, printer_id)
+    real = main._printer_gone
+    deleted = []
+
+    async def deleted_before_the_check(db, pid):
+        if not deleted:  # after the handler loaded the printer, before the branch
+            deleted.append(pid)
+            await _delete_keeping_archives(db_session, printer_id)
+        return await real(db, pid)
+
+    with (
+        patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
+        patch("backend.app.main._printer_gone", new=deleted_before_the_check),
+    ):
+        mocks = await _drive_print_start(test_engine, printer, download_ok=False)
+
+    archive = await _archive(db_session, archive_id)
+    assert archive.printer_id is None and archive.status == "completed"
+    assert not [key for key in _active_prints if key[0] == printer_id]
+    mocks["_maybe_start_layer_timelapse"].assert_not_called()
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_a_queued_print_whose_printer_goes_while_it_is_adopted_is_left_as_kept(
+    test_engine, db_session, printer_factory, tmp_path, monkeypatch
+):
+    """The route's "keep archives" update lands between the check and the
+    commit (the route's transaction lasts seconds), which then adopted the
+    archive anyway: pointed back at the deleted printer, "printing" forever,
+    its verdict cleared and its timelapse video deleted from disk."""
+    from backend.app import main
+
+    monkeypatch.setattr(main.app_settings, "base_dir", tmp_path)
+    video = tmp_path / "archives" / "run.mp4"
+    video.parent.mkdir(parents=True)
+    video.write_bytes(b"mp4")
+
+    printer = await printer_factory()
+    printer_id = printer.id
+    archive_id = await _queued_archive(db_session, printer_id)
+    kept = await db_session.get(PrintArchive, archive_id)
+    kept.timelapse_path = "archives/run.mp4"  # same (unrecorded) plate: a reprint would delete it
+    kept.confirm_requested = True
+    kept.user_verdict = "good"
+    await db_session.commit()
+    real = main._printer_gone
+    calls = []
+
+    async def gone_after_the_check(db, pid):
+        calls.append(pid)
+        if len(calls) == 1:
+            await _delete_keeping_archives(db_session, printer_id)
+            return False  # read before the delete committed
+        return await real(db, pid)
+
+    with (
+        patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
+        patch("backend.app.main._printer_gone", new=gone_after_the_check),
+    ):
+        mocks = await _drive_print_start(test_engine, printer, download_ok=False)
+
+    archive = await _archive(db_session, archive_id)
+    assert archive.printer_id is None
+    assert archive.status == "completed"
+    assert archive.user_verdict == "good"
+    assert archive.timelapse_path == "archives/run.mp4"
+    assert video.exists()
+    assert not [key for key in _active_prints if key[0] == printer_id]
+    mocks["_maybe_start_layer_timelapse"].assert_not_called()
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_another_printers_archive_adopted_for_a_deleted_printer_stays_that_printers(
+    test_engine, db_session, printer_factory
+):
+    """A reprint or queue item can reuse printer A's archive on printer B. B
+    deleted while it is adopted: the route never touched the row (it was A's),
+    so it goes back to A, not to no printer."""
+    from backend.app import main
+
+    owner = await printer_factory()
+    target = await printer_factory()
+    owner_id, target_id = owner.id, target.id
+    archive_id = await _queued_archive(db_session, owner_id)
+    main._expected_prints.clear()
+    main.register_expected_print(target_id, "Bracket_plate_1.3mf", archive_id)
+    real = main._printer_gone
+    calls = []
+
+    async def gone_after_the_check(db, pid):
+        calls.append(pid)
+        if len(calls) == 1:
+            await db_session.execute(delete(Printer).where(Printer.id == target_id))
+            await db_session.commit()
+            return False  # read before the delete committed
+        return await real(db, pid)
+
+    with (
+        patch("backend.app.main.forget_printer", new_callable=AsyncMock),
+        patch("backend.app.main._printer_gone", new=gone_after_the_check),
+    ):
+        await _drive_print_start(test_engine, target, download_ok=False)
+
+    archive = await _archive(db_session, archive_id)
+    assert archive.printer_id == owner_id
+    assert archive.status == "completed"
+
+
+@pytest.mark.parametrize("branch", ["fallback", "downloaded", "queued"])
+async def test_a_printer_deleted_during_the_last_steps_is_forgotten_again(
+    test_engine, db_session, printer_factory, branch
+):
+    """After the last check the handler still waits on the smart plug, the
+    notifications and the timelapse baseline's FTP listing; the route may
+    delete the printer and forget it meanwhile, and the handler then writes
+    again (the baseline). It forgets the printer once more at the end."""
+    printer = await printer_factory()
+    printer_id = printer.id
+    if branch == "queued":
+        await _queued_archive(db_session, printer_id)
+
+    async def deleted_during_the_listing(*_args, **_kwargs):
+        await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+        await db_session.commit()
+
+    with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
+        await _drive_print_start(
+            test_engine,
+            printer,
+            download_ok=branch == "downloaded",
+            extra_patches=(
+                patch("backend.app.main.ArchiveService", _ArchiveService),
+                patch(
+                    "backend.app.main._capture_timelapse_baseline_at_start",
+                    new=AsyncMock(side_effect=deleted_during_the_listing),
+                ),
+            ),
+        )
+
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_the_last_check_works_on_a_session_the_late_steps_broke(test_engine, db_session, printer_factory):
+    """The smart plug's energy write hits the archive the delete removed and
+    leaves the handler's session needing a rollback: the check must not use it."""
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    from backend.app import main
+
+    printer = await printer_factory()
+    printer_id = printer.id
+    archive_id = await _queued_archive(db_session, printer_id)
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as handler_db:
+        archive = await handler_db.get(PrintArchive, archive_id)
+        await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
+        await db_session.execute(delete(Printer).where(Printer.id == printer_id))
+        await db_session.commit()
+        archive.energy_start_kwh = 1.0
+        with pytest.raises(Exception):  # noqa: B017 - StaleDataError, as _record_energy_start swallows it
+            await handler_db.commit()
+
+        with (
+            patch("backend.app.main.async_session", maker),
+            patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
+        ):
+            await main._forget_if_printer_gone(printer_id)
+
+    forget.assert_awaited_once_with(printer_id)
+
+
+async def test_the_last_check_never_raises(printer_factory):
+    """It runs inside the fallback branch's try: an error there would report the
+    archive as failed and send a second start notification."""
+    from backend.app import main
+
+    def broken_session():
+        raise RuntimeError("database unavailable")
+
+    with (
+        patch("backend.app.main.async_session", broken_session),
+        patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
+    ):
+        await main._forget_if_printer_gone(1)
+
+    forget.assert_not_awaited()

+ 5 - 4
backend/tests/unit/test_forget_printer.py

@@ -1,9 +1,10 @@
 """What Bambuddy keeps per printer id goes when the printer is deleted.
 
-SQLite gives a deleted printer's id to the next printer created, which inherited
-it all: the old print's owner (its prints passed the kill switch and were billed
-to that user), the plate-clear gate, pending notifications, the old camera feed,
-the old printer's plate-detection references.
+SQLite used to give a deleted printer's id to the next printer created, which
+inherited it all: the old print's owner (its prints passed the kill switch and
+were billed to that user), the plate-clear gate, pending notifications, the old
+camera feed, the old printer's plate-detection references. Ids are no longer
+reused, but nothing kept for a printer that is gone should stay.
 """
 
 import ast