ソースを参照

fix(virtual-printer): a late slicer update leaves a queue job the scheduler already claimed alone (its archive still keeps the slicer's AMS pick); a backup restore really stops the virtual printers; the legacy PUT /settings/virtual-printer refuses the settings it no longer applies and is no longer blocked by stale legacy values

maziggy 2 日 前
親
コミット
b9b62f739f

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ 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
+- **Virtual printer: a late slicer update could rewrite a queue job already being sent** — The slicer's print options can reach a virtual printer seconds after the job was queued. If the scheduler had already started sending the job, its AMS mapping and options were still rewritten, so the queue entry -- and the spool charged from it -- could differ from what the printer printed. Such a job is now left as it was sent.
+- **Backup restore: the virtual printers kept running during the restore** — The step meant to stop them did nothing, so their FTP, MQTT and discovery services stayed up while files were replaced. They are now stopped, and start again with the restart a restore asks for.
+- **API: the legacy virtual printer settings route answered 200 for settings it no longer applies** — `PUT /settings/virtual-printer` saved enabled, access code, mode, model, target printer and remote interface to settings nothing reads (each virtual printer keeps its own). It now refuses them with a pointer to `PUT /virtual-printers/{id}`; the archive name source and Tailscale switch still work there.
 - **Settings: removed "Save thumbnails" and "Notification Language", which did nothing** — Nothing read either: thumbnails were always stored, and notifications always used their template text as written. The API still accepts both fields.
 - **Restoring an old SQLite backup onto PostgreSQL could put some timestamps hours off** — Required timestamps the backup left empty were filled with the host's local time, while every other timestamp is stored in UTC, so on a host ahead of UTC those rows appeared hours in the future. They are now filled in UTC.
 - **Maintenance: a printer's time offline was counted as print time** — A printer that dropped off mid-print and came back printing hours later had the whole gap added to its runtime, so hours-based maintenance came due early. Time while Bambuddy itself was restarting is still counted, as before.

+ 46 - 139
backend/app/api/routes/settings.py

@@ -1551,11 +1551,16 @@ async def restore_backup(
         try:
             import asyncio
 
-            # 3. Stop virtual printer if running (releases file locks)
+            # 3. Stop the virtual printers if running (releases file locks).
+            # stop_all, not the legacy configure(): that ignores its arguments
+            # and re-syncs from the database not yet replaced, so their FTP,
+            # MQTT and SSDP services ran on through the restore (vp-2). Like
+            # the services below, they come back with the restart a
+            # successful restore asks for.
             try:
                 if virtual_printer_manager.is_enabled:
-                    logger.info("Stopping virtual printer for restore...")
-                    await virtual_printer_manager.configure(enabled=False)
+                    logger.info("Stopping virtual printers for restore...")
+                    await virtual_printer_manager.stop_all()
                     await asyncio.sleep(1)
             except Exception as e:
                 logger.warning("Failed to stop virtual printer: %s", e)
@@ -1879,45 +1884,22 @@ async def update_virtual_printer_settings(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Update virtual printer settings and restart services if needed.
+    """Legacy single-printer settings: the archive name source and the first
+    virtual printer's Tailscale switch.
 
-    For proxy mode with SSDP proxy (dual-homed setup):
-    - remote_interface_ip: IP of interface on slicer's network (LAN B)
-    - Local interface is auto-detected based on target printer IP
+    Every other virtual printer setting lives on its own row, set with
+    ``PUT /virtual-printers/{id}``; the rest of this route's parameters are
+    refused (vp-3).
     """
     from sqlalchemy import select
 
-    from backend.app.models.printer import Printer
-    from backend.app.services.virtual_printer import (
-        DEFAULT_VIRTUAL_PRINTER_MODEL,
-        VIRTUAL_PRINTER_MODELS,
-        virtual_printer_manager,
-    )
+    from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
-    # Get current values
-    current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
-    current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
-    # Default to `archive` (the canonical name) but tolerate legacy `immediate`
-    # in the stored value — normalized later before validation.
-    current_mode = await get_setting(db, "virtual_printer_mode") or "archive"
-    current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
     current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
     current_target_id = int(current_target_id_str) if current_target_id_str else None
-    current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
-    current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
-    # Default True (opt-in) when the setting has never been saved — matches the model default.
-    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else True
-
-    # Apply updates
-    new_enabled = enabled if enabled is not None else current_enabled
-    new_access_code = access_code if access_code is not None else current_access_code
-    new_mode = mode if mode is not None else current_mode
-    new_model = model if model is not None else current_model
-    new_target_id = target_printer_id if target_printer_id is not None else current_target_id
     # One bridging to a printer the caller can't use is not theirs to change,
     # and they can't aim it at such a printer.
-    from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
-
     # The first virtual printer is the one these settings reach.
     first_vp = (await db.execute(select(FirstVP).order_by(FirstVP.position, FirstVP.id).limit(1))).scalar_one_or_none()
     if not printer_scope.allows(current_target_id) or (
@@ -1926,132 +1908,57 @@ async def update_virtual_printer_settings(
         return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
     if target_printer_id is not None and not printer_scope.allows(target_printer_id):
         return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
-    new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
-    new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
-
-    # Validate mode. Canonical wire values are `archive` / `review` / `queue`
-    # / `proxy`; legacy `immediate` and `print_queue` are accepted as aliases
-    # and translated before storage so support bundles stop showing the old
-    # confusing pair (#1429 mode-label discrepancy).
-    from backend.app.models.virtual_printer import VP_MODE_VALUES, normalize_vp_mode
-
-    canonical_mode = normalize_vp_mode(new_mode)
-    if canonical_mode not in VP_MODE_VALUES:
+    # Each virtual printer keeps these itself now; this route wrote them to
+    # settings keys nothing reads and answered 200 as if applied (vp-3). The
+    # stored legacy values are not checked either: an old install's stale
+    # mode or deleted target printer refused the settings this still serves.
+    unread = [
+        name
+        for name, value in (
+            ("enabled", enabled),
+            ("access_code", access_code),
+            ("mode", mode),
+            ("model", model),
+            ("target_printer_id", target_printer_id),
+            ("remote_interface_ip", remote_interface_ip),
+        )
+        if value is not None
+    ]
+    if unread:
         return JSONResponse(
             status_code=400,
             content={
-                "detail": f"Mode must be one of: {', '.join(VP_MODE_VALUES)}",
+                "detail": f"{', '.join(unread)} cannot be set here any more: set them for each virtual printer "
+                "with PUT /api/v1/virtual-printers/{id}"
             },
         )
-    new_mode = canonical_mode
 
-    # Validate archive_name_source
     if archive_name_source is not None and archive_name_source not in ("metadata", "filename"):
         return JSONResponse(
             status_code=400,
             content={"detail": "archive_name_source must be 'metadata' or 'filename'"},
         )
 
-    # Validate model
-    if model is not None and model not in VIRTUAL_PRINTER_MODELS:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
-        )
-
-    # Mode-specific validation and printer lookup
-    target_printer_ip = ""
-    target_printer_serial = ""
-    if new_mode == "proxy":
-        # Proxy mode requires target printer when enabling
-        if new_enabled and not new_target_id:
-            # If just switching to proxy mode (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Target printer is required for proxy mode"},
-                )
-
-        # Look up printer IP and serial if we have a target
-        if new_target_id:
-            result = await db.execute(select(Printer).where(Printer.id == new_target_id))
-            printer = result.scalar_one_or_none()
-            if not printer:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": f"Printer with ID {new_target_id} not found"},
-                )
-            target_printer_ip = printer.ip_address
-            target_printer_serial = printer.serial_number
-        # Access code not required for proxy mode
-    else:
-        # Non-proxy modes require access code when enabling
-        if new_enabled and not new_access_code:
-            # If just switching modes (not explicitly enabling), auto-disable
-            if enabled is None:
-                new_enabled = False
-            else:
-                return JSONResponse(
-                    status_code=400,
-                    content={"detail": "Access code is required when enabling virtual printer"},
-                )
-
-        # Validate access code length (Bambu Studio requires exactly 8 characters)
-        if access_code is not None and access_code and len(access_code) != 8:
-            return JSONResponse(
-                status_code=400,
-                content={"detail": "Access code must be exactly 8 characters"},
-            )
-
-    # Save settings
-    await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
-    if access_code is not None:
-        await set_setting(db, "virtual_printer_access_code", access_code)
-    await set_setting(db, "virtual_printer_mode", new_mode)
-    if model is not None:
-        await set_setting(db, "virtual_printer_model", model)
-    if target_printer_id is not None:
-        await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
-    if remote_interface_ip is not None:
-        await set_setting(db, "virtual_printer_remote_interface_ip", remote_interface_ip)
     if tailscale_disabled is not None:
         await set_setting(db, "virtual_printer_tailscale_disabled", "true" if tailscale_disabled else "false")
+        # The running virtual printers read it from the first row.
+        if first_vp is not None:
+            first_vp.tailscale_disabled = tailscale_disabled
     if archive_name_source is not None:
         await set_setting(db, "virtual_printer_archive_name_source", archive_name_source)
 
-    # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
-    if tailscale_disabled is not None:
-        if first_vp is not None:
-            first_vp.tailscale_disabled = new_ts_disabled
-
     await db.commit()
     db.expire_all()
 
-    # Reconfigure virtual printer
-    try:
-        await virtual_printer_manager.configure(
-            enabled=new_enabled,
-            access_code=new_access_code,
-            mode=new_mode,
-            model=new_model,
-            target_printer_ip=target_printer_ip,
-            target_printer_serial=target_printer_serial,
-            remote_interface_ip=new_remote_iface,
-        )
-    except ValueError as e:
-        logger.warning("Virtual printer configuration validation error: %s", e)
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Invalid virtual printer configuration. Check the provided values."},
-        )
-    except Exception as e:
-        logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
-        return JSONResponse(
-            status_code=500,
-            content={"detail": "Failed to configure virtual printer. Check server logs for details."},
-        )
+    if tailscale_disabled is not None:
+        try:
+            await virtual_printer_manager.sync_from_db()
+        except Exception as e:
+            logger.error("Failed to configure virtual printer: %s", e, exc_info=True)
+            return JSONResponse(
+                status_code=500,
+                content={"detail": "Failed to configure virtual printer. Check server logs for details."},
+            )
 
     return await get_virtual_printer_settings(db, printer_scope=printer_scope)
 

+ 15 - 7
backend/app/services/virtual_printer/manager.py

@@ -629,17 +629,23 @@ class VirtualPrinterInstance:
 
         try:
             async with self._session_factory() as db:
-                # Only stamp items still pending; once the scheduler has
-                # picked the row up we can't safely race the dispatcher.
+                # Only stamp items still pending and unclaimed; once the
+                # scheduler has picked the row up we can't safely race the
+                # dispatcher. A claim leaves the status "pending" while the
+                # upload runs with the row's current mapping, so the claim is
+                # checked too, and again in the writes below.
+                unclaimed = (PrintQueueItem.status == "pending", PrintQueueItem.dispatching_at.is_(None))
                 result = await db.execute(
-                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id, PrintQueueItem.dispatching_at).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.status == "pending",
                     )
                 )
                 rows = result.all()
-                eligible_ids = [row[0] for row in rows]
-                if not eligible_ids:
+                eligible_ids = [row[0] for row in rows if row[2] is None]
+                # A claimed job's archive still takes the slicer's pick below:
+                # that copy serves later reprints, not this run.
+                if not eligible_ids and (ams_mapping_json is None or not rows):
                     self._recent_queue_items.pop(stash_key, None)
                     return
                 # As in `_add_to_print_queue`: a mapping shorter than a plate's
@@ -659,11 +665,13 @@ class VirtualPrinterInstance:
                         )
                 reached_ids = [i for i in eligible_ids if i not in short_ids]
                 if patch and reached_ids:
-                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(reached_ids)).values(**patch))
+                    await db.execute(
+                        update(PrintQueueItem).where(PrintQueueItem.id.in_(reached_ids), *unclaimed).values(**patch)
+                    )
                 short_patch = {k: v for k, v in patch.items() if k != "ams_mapping"}
                 if short_ids and short_patch:
                     await db.execute(
-                        update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids)).values(**short_patch)
+                        update(PrintQueueItem).where(PrintQueueItem.id.in_(short_ids), *unclaimed).values(**short_patch)
                     )
 
                 # The archive was already created (with no slicer_ams_mapping)

+ 41 - 0
backend/tests/integration/test_backup_manifest.py

@@ -131,3 +131,44 @@ async def test_overlay_logo_survives_backup_and_restore(async_client, monkeypatc
     assert response.status_code == 200, response.text
     assert logo.read_bytes() == original
     assert not (logo_dir / "stale.png").exists()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only  # backs up and restores the app's own SQLite database file
+async def test_a_restore_stops_the_virtual_printers(async_client, monkeypatch, tmp_path):
+    """vp-2: the "stop virtual printer" step called the legacy configure(),
+    which ignores its arguments and re-syncs from the database not yet
+    replaced, so their FTP, MQTT and SSDP services ran on through the file
+    swap. A successful restore asks for a restart, as for the other services."""
+    from backend.app.api.routes.settings import create_backup_zip
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+    monkeypatch.setitem(virtual_printer_manager._instances, 4242, object())
+
+    with (
+        patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+        patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+        patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+        patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+        patch("backend.app.services.print_scheduler.scheduler.stop"),
+        patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+        patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+        patch.object(virtual_printer_manager, "stop_all", new_callable=AsyncMock) as stop_all,
+        patch.object(virtual_printer_manager, "configure", new_callable=AsyncMock) as configure,
+    ):
+        response = await async_client.post(
+            "/api/v1/settings/restore",
+            files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
+        )
+
+    assert response.status_code == 200, response.text
+    stop_all.assert_awaited_once()
+    configure.assert_not_awaited()

+ 16 - 12
backend/tests/integration/test_system_support_vp_scope.py

@@ -157,27 +157,31 @@ async def test_a_member_cannot_point_a_virtual_printer_at_another_printer(async_
 
 
 async def test_the_legacy_virtual_printer_settings_keep_to_the_caller_printers(
-    async_client: AsyncClient, printer_factory
+    async_client: AsyncClient, printer_factory, db_session
 ):
+    from backend.app.models.settings import Settings
+
     a_id, b_id, admin, member = await _setup(async_client, printer_factory)
-    set_up = await async_client.put(
-        f"/api/v1/settings/virtual-printer?mode=proxy&target_printer_id={b_id}", headers=admin
-    )
-    assert set_up.status_code == 200, set_up.text
+    # Stored by an older version, which still let this route aim it.
+    db_session.add(Settings(key="virtual_printer_target_printer_id", value=str(b_id)))
+    await db_session.commit()
 
     shown = await async_client.get("/api/v1/settings/virtual-printer", headers=member)
-    changed = await async_client.put("/api/v1/settings/virtual-printer?mode=archive", headers=member)
+    changed = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename", headers=member)
 
     assert shown.json()["target_printer_id"] is None
     assert changed.status_code == 404
-    kept = (await async_client.get("/api/v1/settings/virtual-printer", headers=admin)).json()
-    assert (kept["mode"], kept["target_printer_id"]) == ("proxy", b_id)
 
-    await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={a_id}", headers=admin)
-    aimed = await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={b_id}", headers=member)
-    assert aimed.status_code == 400
+    # The settings also reach the first virtual printer: with the stored target
+    # now one the member may use, the 404 comes from that row alone.
+    from sqlalchemy import update
 
-    # The settings also reach the first virtual printer.
+    await db_session.execute(
+        update(Settings).where(Settings.key == "virtual_printer_target_printer_id").values(value=str(a_id))
+    )
+    await db_session.commit()
+    allowed = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename", headers=member)
+    assert allowed.status_code == 200, allowed.text
     await async_client.post(
         "/api/v1/virtual-printers", headers=admin, json={"name": "first", "mode": "proxy", "target_printer_id": b_id}
     )

+ 40 - 120
backend/tests/integration/test_virtual_printer_api.py

@@ -51,137 +51,57 @@ class TestVirtualPrinterSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_mode(self, async_client: AsyncClient):
-        """Verify mode can be updated."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=review")
+    @pytest.mark.parametrize(
+        "query",
+        [
+            "enabled=true",
+            "enabled=false",
+            "access_code=12345678",
+            "mode=review",
+            "mode=print_queue",
+            "model=BL-P001",
+            "target_printer_id=1",
+            "remote_interface_ip=10.0.0.5",
+        ],
+    )
+    async def test_settings_the_virtual_printers_never_read_are_refused(self, async_client: AsyncClient, query):
+        """vp-3: these were saved to settings keys nothing reads any more -- each
+        virtual printer keeps its own -- and answered 200 as if applied."""
+        response = await async_client.put(f"/api/v1/settings/virtual-printer?{query}")
 
-        assert response.status_code == 200
-        result = response.json()
-        assert result["mode"] == "review"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_mode_to_queue(self, async_client: AsyncClient):
-        """Verify mode can be set to the canonical 'queue' value."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["mode"] == "queue"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_mode_legacy_print_queue_normalises_to_queue(self, async_client: AsyncClient):
-        """Legacy `print_queue` is accepted on input and translated to `queue` on
-        storage so the UI button label and the support-bundle field agree
-        (#1429 mode-label discrepancy)."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["mode"] == "queue"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_mode_legacy_immediate_normalises_to_archive(self, async_client: AsyncClient):
-        """Legacy `immediate` is accepted on input and translated to `archive`
-        on storage (#1429 mode-label discrepancy)."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["mode"] == "archive"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_mode_to_archive(self, async_client: AsyncClient):
-        """Verify mode can be set to the canonical 'archive' value."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?mode=archive")
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["mode"] == "archive"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_access_code(self, async_client: AsyncClient):
-        """Verify access code can be set."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["access_code_set"] is True
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
-        """Verify access code validation for length."""
-        response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
-
-        # Should fail validation
         assert response.status_code == 400
+        assert "/api/v1/virtual-printers/" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_enable_without_access_code(self, async_client: AsyncClient):
-        """Verify enabling fails without access code set."""
-        # First ensure no access code is set by checking current state
-        # Then try to enable
-        response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
-
-        # If access code wasn't set, this should fail
-        # If it was already set, it will succeed
-        # Both are valid test outcomes
-        assert response.status_code in [200, 400]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_enable_with_access_code(self, async_client: AsyncClient):
-        """Verify enabling succeeds when access code is set."""
-        # First set access code
-        await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
-
-        # Then enable (this will start the servers which may fail in test env)
-        # We mock the manager to avoid actually starting servers
-        with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
-            mock_manager.configure = AsyncMock()
-            mock_manager.get_status = MagicMock(
-                return_value={
-                    "enabled": True,
-                    "running": True,
-                    "mode": "archive",
-                    "name": "Bambuddy",
-                    "serial": "00M09A391800001",
-                    "pending_files": 0,
-                }
-            )
+    async def test_stale_legacy_settings_do_not_block_the_archive_name_source(
+        self, async_client: AsyncClient, db_session
+    ):
+        """An old single-VP install left mode "proxy", enabled, aimed at a
+        printer since deleted: the route checked those before saving, and
+        refused the one setting it still serves."""
+        from backend.app.models.settings import Settings
+
+        db_session.add_all(
+            [
+                Settings(key="virtual_printer_mode", value="proxy"),
+                Settings(key="virtual_printer_enabled", value="true"),
+                Settings(key="virtual_printer_target_printer_id", value="999"),
+            ]
+        )
+        await db_session.commit()
 
-            response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
+        response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
 
-            assert response.status_code == 200
+        assert response.status_code == 200, response.text
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_disable_virtual_printer(self, async_client: AsyncClient):
-        """Verify virtual printer can be disabled."""
-        with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
-            mock_manager.configure = AsyncMock()
-            mock_manager.get_status = MagicMock(
-                return_value={
-                    "enabled": False,
-                    "running": False,
-                    "mode": "archive",
-                    "name": "Bambuddy",
-                    "serial": "00M09A391800001",
-                    "pending_files": 0,
-                }
-            )
-
-            response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
+    async def test_the_archive_name_source_is_still_saved(self, async_client: AsyncClient):
+        response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
 
-            assert response.status_code == 200
-            result = response.json()
-            assert result["enabled"] is False
+        assert response.status_code == 200
+        assert response.json()["archive_name_source"] == "filename"
 
 
 class TestPendingUploadsAPI:

+ 89 - 0
backend/tests/integration/test_vp_restamp_claimed_item.py

@@ -0,0 +1,89 @@
+"""The late slicer update leaves a queue job the scheduler has claimed alone.
+
+The slicer's options can arrive up to 30 s after a virtual-printer job was
+queued. By then the scheduler may have claimed the job (``dispatching_at``
+set, status still "pending") and started its upload with the old mapping;
+rewriting the row then made it, and the spool charge read from it, disagree
+with what the printer printed.
+"""
+
+import json
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_only_the_unclaimed_job_is_restamped(test_engine, db_session, printer_factory, tmp_path):
+    printer = await printer_factory()
+    claimed = PrintQueueItem(printer_id=printer.id, status="pending", position=1, dispatching_at=datetime.utcnow())
+    waiting = PrintQueueItem(printer_id=printer.id, status="pending", position=2)
+    db_session.add_all([claimed, waiting])
+    await db_session.commit()
+
+    inst = VirtualPrinterInstance(
+        target_printer_id=printer.id,
+        vp_id=97,
+        name="LateMQTTClaimed",
+        mode="queue",
+        model="O1C2",
+        access_code="12345678",
+        serial_suffix="391800097",
+        base_dir=tmp_path,
+        session_factory=async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
+    )
+    inst._mqtt = MagicMock()
+    inst._recent_queue_items["test.3mf"] = ([claimed.id, waiting.id], 1_000_000.0)
+
+    with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
+        await inst.on_print_command(
+            "test.3mf", {"command": "project_file", "file": "test.3mf", "nozzle_mapping": [16, -1]}
+        )
+
+    await db_session.refresh(claimed)
+    await db_session.refresh(waiting)
+    assert claimed.nozzle_mapping is None
+    assert json.loads(waiting.nozzle_mapping) == [16, -1]
+
+
+async def test_the_slicer_s_pick_is_still_kept_on_a_claimed_job_s_archive(
+    test_engine, db_session, printer_factory, archive_factory, tmp_path
+):
+    """The archive copy only serves later reprints, so it does not race the
+    upload: a claimed job's archive still gets the slicer's AMS pick."""
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    claimed = PrintQueueItem(
+        printer_id=printer.id, archive_id=archive.id, status="pending", position=1, dispatching_at=datetime.utcnow()
+    )
+    db_session.add(claimed)
+    await db_session.commit()
+
+    inst = VirtualPrinterInstance(
+        target_printer_id=printer.id,
+        vp_id=96,
+        name="LateMQTTArchive",
+        mode="queue",
+        model="O1C2",
+        access_code="12345678",
+        serial_suffix="391800096",
+        base_dir=tmp_path,
+        session_factory=async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
+    )
+    inst._mqtt = MagicMock()
+    inst.save_ams_mapping = True
+    inst._recent_queue_items["test.3mf"] = ([claimed.id], 1_000_000.0)
+
+    with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
+        await inst.on_print_command("test.3mf", {"command": "project_file", "file": "test.3mf", "ams_mapping": [2]})
+
+    await db_session.refresh(archive)
+    await db_session.refresh(claimed)
+    assert (archive.extra_data or {}).get("slicer_ams_mapping", {}).get("mapping") == [2]
+    assert claimed.ams_mapping is None

+ 6 - 6
backend/tests/unit/services/test_virtual_printer.py

@@ -2391,7 +2391,7 @@ class TestVirtualPrinterInstance:
         position_max_result = MagicMock()
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
-        select_pending_result.all = MagicMock(return_value=[(101,)])
+        select_pending_result.all = MagicMock(return_value=[(101, None, None)])
         update_result = MagicMock()
         mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
 
@@ -2504,7 +2504,7 @@ class TestVirtualPrinterInstance:
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
         # (queue_item_id, archive_id) — matches the id set by db.add above.
-        select_pending_result.all = MagicMock(return_value=[(101, 55)])
+        select_pending_result.all = MagicMock(return_value=[(101, 55, None)])
         update_result = MagicMock()
         select_archives_result = MagicMock()
         select_archives_result.scalars = MagicMock(
@@ -2603,7 +2603,7 @@ class TestVirtualPrinterInstance:
         position_max_result = MagicMock()
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
-        select_pending_result.all = MagicMock(return_value=[(201, 65)])
+        select_pending_result.all = MagicMock(return_value=[(201, 65, None)])
         update_result = MagicMock()
         # No 4th execute() expected — the archive patch must be skipped
         # entirely, so only 3 calls should ever happen.
@@ -2699,7 +2699,7 @@ class TestVirtualPrinterInstance:
         position_max_result = MagicMock()
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
-        select_pending_result.all = MagicMock(return_value=[(301, 75)])
+        select_pending_result.all = MagicMock(return_value=[(301, 75, None)])
         update_result = MagicMock()
         select_archives_result = MagicMock()
         select_archives_result.scalars = MagicMock(
@@ -2794,7 +2794,7 @@ class TestVirtualPrinterInstance:
         position_max_result = MagicMock()
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
-        select_pending_result.all = MagicMock(return_value=[(401, 85)])
+        select_pending_result.all = MagicMock(return_value=[(401, 85, None)])
         update_result = MagicMock()
         # No 4th execute(): with nothing to save there is no archive SELECT.
         mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
@@ -2881,7 +2881,7 @@ class TestVirtualPrinterInstance:
         position_max_result = MagicMock()
         position_max_result.scalar = MagicMock(return_value=None)
         select_pending_result = MagicMock()
-        select_pending_result.all = MagicMock(return_value=[(301,)])
+        select_pending_result.all = MagicMock(return_value=[(301, None, None)])
         update_result = MagicMock()
         mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
 

+ 4 - 4
backend/tests/unit/services/test_vp_queue_intake_d2.py

@@ -92,7 +92,7 @@ class TestNozzleOffsetCalibration:
     async def test_a_late_slicer_choice_is_patched_on(self, tmp_path):
         added: list = []
         position_max = MagicMock(scalar=MagicMock(return_value=None))
-        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
         factory, db = _session(added, [position_max, pending, MagicMock()])
         inst = _vp(tmp_path, factory, target_printer_id=7)
         inst._mqtt = MagicMock()
@@ -132,7 +132,7 @@ class TestNozzlePositionsNeedOnePrinter:
     async def test_an_any_model_vp_does_not_patch_them_on_late(self, tmp_path):
         added: list = []
         position_max = MagicMock(scalar=MagicMock(return_value=None))
-        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
         factory, db = _session(added, [position_max, pending, MagicMock()])
         inst = _vp(tmp_path, factory)
         inst._mqtt = MagicMock()
@@ -199,7 +199,7 @@ class TestAmsOnOff:
     async def test_a_vp_for_any_printer_does_not_patch_it_on_late(self, tmp_path):
         added: list = []
         position_max = MagicMock(scalar=MagicMock(return_value=None))
-        pending = MagicMock(all=MagicMock(return_value=[(101,)]))
+        pending = MagicMock(all=MagicMock(return_value=[(101, None, None)]))
         factory, db = _session(added, [position_max, pending, MagicMock()])
         inst = _vp(tmp_path, factory)
         inst._mqtt = MagicMock()
@@ -235,7 +235,7 @@ class TestLateAmsMappingPerPlate:
         import time
 
         added: list = []
-        pending = MagicMock(all=MagicMock(return_value=[(101, None), (102, None)]))
+        pending = MagicMock(all=MagicMock(return_value=[(101, None, None), (102, None, None)]))
         factory, db = _session(added, [pending, MagicMock(), MagicMock()])
         inst = VirtualPrinterInstance(
             vp_id=78,