mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
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:
@@ -167,6 +167,31 @@ def reset_auth_enabled_cache():
|
||||
invalidate_auth_enabled_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disconnect_printers_registered_during_a_test():
|
||||
"""Hand the ``printer_manager`` singleton back the way the test found it.
|
||||
|
||||
``POST /api/v1/printers`` really calls ``connect_printer``, so a test that
|
||||
creates a printer through the API parks a live client in the singleton --
|
||||
and the singleton outlives the per-test in-memory database. The next test
|
||||
on the same xdist worker gets a fresh database whose first printer is handed
|
||||
the same primary key, and reads that leftover client as its own live status.
|
||||
``test_scheduled_drying_routes`` saw exactly that: an "online" printer with
|
||||
no firmware version, so scheduling a dry came back 400 instead of 200.
|
||||
|
||||
Only ids this test added are dropped, so a client registered by a wider
|
||||
fixture stays registered. ``disconnect_printer`` is what clears the model
|
||||
and printer-info caches too, and it stops the paho thread the leaked client
|
||||
would otherwise keep retrying on for the rest of the run.
|
||||
"""
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
before = set(printer_manager._clients)
|
||||
yield
|
||||
for printer_id in set(printer_manager._clients) - before:
|
||||
printer_manager.disconnect_printer(printer_id)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for each test session."""
|
||||
|
||||
@@ -4494,3 +4494,186 @@ class TestClearPlateOnAPoweredDownPrinter:
|
||||
body = response.json()
|
||||
assert body["connected"] is False
|
||||
assert body["awaiting_plate_clear"] is True
|
||||
|
||||
|
||||
class TestPrintableObjectsReload:
|
||||
"""Rebuilding the object list must ask the disk before the printer.
|
||||
|
||||
The archive of a running print normally holds the very 3MF the printer is
|
||||
executing. Pulling it back over FTPS costs a full transfer 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.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_archive_on_disk_is_used_before_any_ftp(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
printer = await printer_factory(name="H2C-1")
|
||||
await archive_factory(printer.id, status="printing", subtask_id="330151809")
|
||||
|
||||
client = MagicMock()
|
||||
client.state = MagicMock(
|
||||
printable_objects={},
|
||||
skipped_objects=[],
|
||||
subtask_id="330151809",
|
||||
subtask_name="HULA",
|
||||
state="RUNNING",
|
||||
gcode_file="/data/Metadata/plate_1.gcode",
|
||||
printable_objects_bbox_all=None,
|
||||
)
|
||||
ftp = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
|
||||
patch(
|
||||
"backend.app.services.archive.extract_printable_objects_from_archive",
|
||||
return_value=({101: {"name": "left"}, 102: {"name": "right"}}, None),
|
||||
) as from_disk,
|
||||
patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
|
||||
):
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["total"] == 2
|
||||
from_disk.assert_called_once()
|
||||
ftp.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_reload_that_the_disk_cannot_answer_still_asks_the_printer(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""The fan-out is the fallback, not dead code: a print whose archive has
|
||||
no 3MF (or none at all) still has the printer to ask."""
|
||||
printer = await printer_factory(name="H2C-1")
|
||||
await archive_factory(printer.id, status="printing", subtask_id="330151809")
|
||||
|
||||
client = MagicMock()
|
||||
client.state = MagicMock(
|
||||
printable_objects={},
|
||||
skipped_objects=[],
|
||||
subtask_id="330151809",
|
||||
subtask_name="HULA",
|
||||
state="RUNNING",
|
||||
gcode_file="/data/Metadata/plate_1.gcode",
|
||||
printable_objects_bbox_all=None,
|
||||
)
|
||||
ftp = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client),
|
||||
patch(
|
||||
"backend.app.services.archive.extract_printable_objects_from_archive",
|
||||
return_value=({}, None),
|
||||
),
|
||||
patch("backend.app.services.bambu_ftp.download_file_try_paths_async", new=ftp),
|
||||
):
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/print/objects")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
ftp.assert_awaited()
|
||||
|
||||
|
||||
class TestCoverUsesTheRunningPrintsArchive:
|
||||
"""The cover must not re-fetch a 3MF Bambuddy already has on disk.
|
||||
|
||||
The in-memory download cache dies with the process, so after a restart
|
||||
mid-print every cover, top view and skip-objects plate mask went back to the
|
||||
printer for the whole file — measured on the maintainer's H2C as three
|
||||
concurrent fan-outs, thirteen seconds and a 0-byte read, on a machine that
|
||||
was busy printing. The archive of the running print holds that exact file.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_archived_3mf_is_preferred_over_ftp(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
|
||||
):
|
||||
import zipfile
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
|
||||
printer = await printer_factory(name="H2C-1")
|
||||
# A 3MF that is a real zip but carries no cover, so the endpoint reaches
|
||||
# its "no cover in this file" answer without any FTP work.
|
||||
threemf = tmp_path / "job.3mf"
|
||||
with zipfile.ZipFile(threemf, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", "<config/>")
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="printing",
|
||||
subtask_id="330151809",
|
||||
file_path=str(threemf.relative_to(tmp_path)),
|
||||
)
|
||||
|
||||
state = MagicMock(
|
||||
subtask_name="job",
|
||||
subtask_id="330151809",
|
||||
state="RUNNING",
|
||||
gcode_file="/data/Metadata/plate_1.gcode",
|
||||
current_project_url="ftp://job.3mf",
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
)
|
||||
ftp = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch.object(app_settings, "base_dir", tmp_path),
|
||||
patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
|
||||
patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
|
||||
patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
|
||||
patch("backend.app.api.routes.printers.download_file_try_paths_async", new=ftp),
|
||||
):
|
||||
response = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
|
||||
|
||||
# No cover inside this 3MF, so a 404 — but reached from the file on disk.
|
||||
assert response.status_code == 404, response.text
|
||||
ftp.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_archived_file_is_never_deleted_by_the_cover_flow(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session, tmp_path
|
||||
):
|
||||
"""The cover flow unlinks the file it downloaded. Handed the archive's
|
||||
own copy, that would delete the print's 3MF."""
|
||||
import zipfile
|
||||
|
||||
from backend.app.core.config import settings as app_settings
|
||||
|
||||
printer = await printer_factory(name="H2C-1")
|
||||
threemf = tmp_path / "job.3mf"
|
||||
with zipfile.ZipFile(threemf, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", "<config/>")
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="printing",
|
||||
subtask_id="330151809",
|
||||
file_path=str(threemf.relative_to(tmp_path)),
|
||||
)
|
||||
|
||||
state = MagicMock(
|
||||
subtask_name="job",
|
||||
subtask_id="330151809",
|
||||
state="RUNNING",
|
||||
gcode_file="/data/Metadata/plate_1.gcode",
|
||||
current_project_url="ftp://job.3mf",
|
||||
sdcard=True,
|
||||
sdcard_reported=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(app_settings, "base_dir", tmp_path),
|
||||
patch("backend.app.api.routes.printers.printer_manager.get_status", return_value=state),
|
||||
patch("backend.app.api.routes.printers.resolve_plate_id", return_value=1),
|
||||
patch("backend.app.api.routes.printers.get_cached_3mf", return_value=None),
|
||||
patch(
|
||||
"backend.app.api.routes.printers.download_file_try_paths_async",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
await async_client.get(f"/api/v1/printers/{printer.id}/cover")
|
||||
|
||||
assert threemf.is_file(), "the cover flow deleted the running print's archived 3MF"
|
||||
|
||||
@@ -50,7 +50,9 @@ async def test_concurrent_cover_requests_download_once():
|
||||
|
||||
produce_calls = {"n": 0}
|
||||
|
||||
async def slow_produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key):
|
||||
async def slow_produce(
|
||||
printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None
|
||||
):
|
||||
produce_calls["n"] += 1
|
||||
await asyncio.sleep(0.1) # hold leadership long enough for followers to attach
|
||||
printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA"
|
||||
@@ -76,7 +78,7 @@ async def test_second_request_serves_from_positive_cache():
|
||||
|
||||
produce_calls = {"n": 0}
|
||||
|
||||
async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key):
|
||||
async def produce(printer_row, printer_id, subtask_name, view, view_key, plate_num, cache_key, archive_path=None):
|
||||
produce_calls["n"] += 1
|
||||
printers_mod._cover_cache.setdefault(printer_id, {})[cache_key] = b"PNGDATA"
|
||||
return b"PNGDATA"
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""The skip-objects list has to survive a restart mid-print.
|
||||
|
||||
``PrinterState.printable_objects`` is in-memory and is filled by the print-start
|
||||
path, which the #1304 guard suppresses on the first RUNNING push after Bambuddy
|
||||
comes back up. So a restart during a print left the list empty for the rest of
|
||||
that print: the printer card gates its Skip button on the object count, and the
|
||||
one endpoint that can rebuild the list is reachable only from the modal that
|
||||
button opens. Measured on the maintainer's H2C — 8 objects loaded at 09:02,
|
||||
restart at 09:17, Skip dead for the remaining hour.
|
||||
|
||||
The recovery hook now reloads the objects from the archive of the print that is
|
||||
still running, anchored on the subtask_id the firmware mints per print.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _client(objects=None):
|
||||
client = MagicMock()
|
||||
client.state = MagicMock(printable_objects=objects if objects is not None else {})
|
||||
return client
|
||||
|
||||
|
||||
def _db(archive):
|
||||
db = AsyncMock()
|
||||
db.scalar = AsyncMock(return_value=archive)
|
||||
return db
|
||||
|
||||
|
||||
class TestRestartRecovery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_objects_are_reloaded_from_the_running_print_s_archive(self):
|
||||
from backend.app.main import _restore_printable_objects
|
||||
|
||||
archive = MagicMock(id=360, file_path="archive/7/job/job.3mf")
|
||||
state = MagicMock(subtask_id="330151809")
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main._load_objects_from_archive") as load,
|
||||
):
|
||||
pm.get_client.return_value = _client()
|
||||
await _restore_printable_objects(7, state, _db(archive), logging.getLogger(__name__))
|
||||
|
||||
load.assert_called_once()
|
||||
assert load.call_args.args[0] is archive
|
||||
assert load.call_args.args[1] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_list_that_is_already_loaded_is_left_alone(self):
|
||||
"""The hook also fires on a reconnect during a print Bambuddy saw start.
|
||||
Reloading there would discard what the user has already skipped, which
|
||||
only lives alongside the object list."""
|
||||
from backend.app.main import _restore_printable_objects
|
||||
|
||||
state = MagicMock(subtask_id="330151809")
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main._load_objects_from_archive") as load,
|
||||
):
|
||||
pm.get_client.return_value = _client({1: "cube"})
|
||||
await _restore_printable_objects(7, state, _db(MagicMock()), logging.getLogger(__name__))
|
||||
|
||||
load.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("subtask_id", ["", "0", None])
|
||||
async def test_nothing_is_loaded_without_a_subtask_to_anchor_on(self, subtask_id):
|
||||
"""A leftover status="printing" row from a completion we never saw must
|
||||
not hand this print someone else's objects. The endpoint's reload covers
|
||||
the case on demand instead."""
|
||||
from backend.app.main import _restore_printable_objects
|
||||
|
||||
state = MagicMock(subtask_id=subtask_id)
|
||||
db = _db(MagicMock())
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main._load_objects_from_archive") as load,
|
||||
):
|
||||
pm.get_client.return_value = _client()
|
||||
await _restore_printable_objects(7, state, db, logging.getLogger(__name__))
|
||||
|
||||
load.assert_not_called()
|
||||
db.scalar.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_matching_archive_is_not_an_error(self):
|
||||
from backend.app.main import _restore_printable_objects
|
||||
|
||||
state = MagicMock(subtask_id="330151809")
|
||||
|
||||
with (
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main._load_objects_from_archive") as load,
|
||||
):
|
||||
pm.get_client.return_value = _client()
|
||||
await _restore_printable_objects(7, state, _db(None), logging.getLogger(__name__))
|
||||
|
||||
load.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_recovery_hook_calls_it(self):
|
||||
"""Wiring guard: the restore is only useful if on_print_running_observed
|
||||
runs it alongside the archive and usage-tracking restores."""
|
||||
import backend.app.main as main
|
||||
|
||||
state = MagicMock(subtask_id="330151809", subtask_name="job")
|
||||
session = AsyncMock()
|
||||
session.__aenter__ = AsyncMock(return_value=session)
|
||||
session.__aexit__ = AsyncMock()
|
||||
session.execute = AsyncMock(
|
||||
return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("backend.app.main.async_session", return_value=session),
|
||||
patch("backend.app.main.printer_manager") as pm,
|
||||
patch("backend.app.main._is_bambuddy_authorized_print", new=AsyncMock(return_value=True)),
|
||||
patch("backend.app.main._restore_usage_tracking_session", new=AsyncMock()),
|
||||
patch("backend.app.main._restore_printable_objects", new=AsyncMock()) as restore,
|
||||
):
|
||||
pm.get_status.return_value = state
|
||||
await main.on_print_running_observed(7, {})
|
||||
|
||||
restore.assert_awaited_once()
|
||||
assert restore.await_args.args[0] == 7
|
||||
|
||||
|
||||
class TestExtractFromArchiveFile:
|
||||
def test_a_missing_file_is_empty_rather_than_an_error(self, tmp_path):
|
||||
from backend.app.services.archive import extract_printable_objects_from_archive
|
||||
|
||||
assert extract_printable_objects_from_archive(tmp_path / "gone.3mf") == ({}, None)
|
||||
|
||||
def test_a_fallback_archive_with_no_3mf_is_empty(self, tmp_path):
|
||||
"""An archive created without its 3MF has an empty file_path, which
|
||||
resolves to the data directory itself."""
|
||||
from backend.app.services.archive import extract_printable_objects_from_archive
|
||||
|
||||
assert extract_printable_objects_from_archive(tmp_path) == ({}, None)
|
||||
|
||||
def test_objects_come_back_with_their_positions(self, tmp_path):
|
||||
import zipfile
|
||||
|
||||
from backend.app.services.archive import extract_printable_objects_from_archive
|
||||
|
||||
slice_info = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<object identify_id="101" name="left" skipped="false"/>
|
||||
<object identify_id="102" name="right" skipped="false"/>
|
||||
</plate>
|
||||
</config>"""
|
||||
path = tmp_path / "job.3mf"
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", slice_info)
|
||||
|
||||
objects, _bbox = extract_printable_objects_from_archive(path, plate_number=1)
|
||||
|
||||
assert sorted(objects) == [101, 102]
|
||||
Reference in New Issue
Block a user