Explorar el Código

Merge branch 'dev' into feature/slicer-api

MartinNYHC hace 1 mes
padre
commit
8829bc2cc6

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
CHANGELOG.md


+ 174 - 13
backend/app/api/routes/library.py

@@ -2,6 +2,7 @@
 
 import base64
 import binascii
+import contextlib
 import hashlib
 import logging
 import os
@@ -186,6 +187,106 @@ def _stored_file_path(abs_path: Path, is_external: bool) -> str:
     return str(abs_path) if is_external else to_relative_path(abs_path)
 
 
+class _MoveSkip(Exception):
+    """Signalled by ``_move_file_bytes`` to skip a file with a user-visible reason.
+
+    Carries an optional `code` for machine-friendly grouping (the
+    front-end can localise it) and a fallback English `reason` for logs.
+    """
+
+    def __init__(self, code: str, reason: str):
+        super().__init__(reason)
+        self.code = code
+        self.reason = reason
+
+
+def _resolve_source_disk_path(file: LibraryFile) -> Path | None:
+    """Return the absolute on-disk path for an existing LibraryFile, or None
+    if it can't be located (legacy DB row, deleted file, etc.)."""
+    if file.is_external:
+        return Path(file.file_path) if file.file_path else None
+    return to_absolute_path(file.file_path)
+
+
+def _move_file_bytes(file: LibraryFile, target_folder: LibraryFolder | None) -> str:
+    """Physically relocate `file`'s bytes to match `target_folder`.
+
+    Used by the move endpoint when source/target straddle the
+    managed↔external boundary (#1112 follow-up — the prior implementation
+    updated the DB row's ``folder_id`` but never moved the bytes, so a
+    file moved to an external SMB folder showed up in Bambuddy's UI but
+    not on the NAS).
+
+    Returns the new ``file_path`` value to persist (relative for managed
+    targets, absolute for external targets — matches the upload + scan
+    paths). Raises ``_MoveSkip`` for any condition that would make the
+    move unsafe (target unwritable, filename collision, source missing).
+
+    The copy-then-unlink ordering means a partial copy followed by a
+    failed unlink leaves both the source and the dest on disk — better
+    than the symmetric "rename or move" which would lose the source if
+    the target write didn't complete on a flaky mount. The DB row stays
+    pointed at the source until the caller commits the new ``file_path``.
+    """
+    src = _resolve_source_disk_path(file)
+    if not src or not src.exists():
+        raise _MoveSkip("source_missing", "source file missing on disk")
+
+    target_is_external = target_folder is not None and target_folder.is_external
+
+    if target_is_external:
+        if target_folder.external_readonly:
+            # Already blocked at top level, but defence-in-depth.
+            raise _MoveSkip("target_readonly", "target external folder is read-only")
+        if not target_folder.external_path:
+            raise _MoveSkip("target_misconfigured", "target external folder has no path")
+        ext_dir = Path(target_folder.external_path)
+        if not ext_dir.exists() or not ext_dir.is_dir():
+            raise _MoveSkip("target_inaccessible", f"target path not accessible: {ext_dir}")
+        if not os.access(ext_dir, os.W_OK):
+            raise _MoveSkip("target_unwritable", f"target path not writable: {ext_dir}")
+        dest = (ext_dir / file.filename).resolve()
+        try:
+            dest.relative_to(ext_dir.resolve())
+        except ValueError:
+            raise _MoveSkip("invalid_filename", f"unsafe filename: {file.filename!r}") from None
+        if dest.exists():
+            raise _MoveSkip("name_collision", f"a file named {file.filename!r} already exists in target")
+        try:
+            shutil.copy2(src, dest)
+        except OSError as e:
+            # Clean up partial dest so a retry can succeed.
+            with contextlib.suppress(OSError):
+                dest.unlink(missing_ok=True)
+            raise _MoveSkip("copy_failed", f"copy failed: {e}") from e
+    else:
+        # → managed (root or non-external folder): generate a fresh UUID
+        # filename in the internal store so we don't collide with another
+        # file that happens to share `filename`.
+        ext = src.suffix.lower()
+        dest = get_library_files_dir() / f"{uuid.uuid4().hex}{ext}"
+        try:
+            shutil.copy2(src, dest)
+        except OSError as e:
+            with contextlib.suppress(OSError):
+                dest.unlink(missing_ok=True)
+            raise _MoveSkip("copy_failed", f"copy failed: {e}") from e
+
+    # Copy succeeded — unlink the original. A failure here leaves an
+    # orphan on disk but the DB row is consistent against the new dest.
+    try:
+        src.unlink(missing_ok=True)
+    except OSError as e:
+        logger.warning(
+            "Move: copied %s → %s but couldn't remove source: %s",
+            src,
+            dest,
+            e,
+        )
+
+    return _stored_file_path(dest, is_external=target_is_external)
+
+
 def _clean_3mf_metadata(obj):
     """Strip bytes and thumbnail-carrier keys so the payload is JSON-storable.
 
@@ -3265,11 +3366,20 @@ async def move_files(
 ):
     """Move multiple files to a folder.
 
-    Files not owned by the user are skipped (unless user has *_all permission).
+    Cross-boundary moves (managed ↔ external, or external ↔ external)
+    physically relocate the bytes — see ``_move_file_bytes``. Same-boundary
+    moves stay DB-only because the file's on-disk location doesn't depend
+    on which managed folder owns it.
+
+    Files not owned by the user are skipped (unless user has ``*_all``
+    permission). Each skip carries a structured reason so the UI can
+    surface "5 of 10 files were skipped: 3 had filename collisions on
+    the NAS, 2 are no longer on disk" rather than a blank "skipped: 5".
     """
     user, can_modify_all = auth_result
 
     # Verify folder exists if specified
+    target_folder: LibraryFolder | None = None
     if data.folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
         target_folder = folder_result.scalar_one_or_none()
@@ -3278,25 +3388,76 @@ async def move_files(
         if target_folder.is_external and target_folder.external_readonly:
             raise HTTPException(status_code=403, detail="Cannot move files to a read-only external folder")
 
-    # Update files
+    target_is_external = target_folder is not None and target_folder.is_external
+
     moved = 0
     skipped = 0
+    skipped_reasons: list[dict] = []
+
     for file_id in data.file_ids:
-        result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+        result = await db.execute(
+            LibraryFile.active().options(selectinload(LibraryFile.folder)).where(LibraryFile.id == file_id)
+        )
         file = result.scalar_one_or_none()
-        if file:
-            # Ownership check
-            if not can_modify_all and file.created_by_id != user.id:
-                skipped += 1
-                continue
-            # Cannot move external files out of their folder
-            if file.is_external:
-                skipped += 1
-                continue
+        if not file:
+            continue
+        # Ownership check
+        if not can_modify_all and file.created_by_id != user.id:
+            skipped += 1
+            skipped_reasons.append({"file_id": file_id, "code": "not_owner", "reason": "not the file owner"})
+            continue
+
+        # No bytes need to move when both ends are managed (same-boundary).
+        if not file.is_external and not target_is_external:
             file.folder_id = data.folder_id
             moved += 1
+            continue
+
+        # Block moves out of a read-only external mount. The user only has
+        # read access to the source, and a move is semantically a delete on
+        # the source — which a read-only mount can't fulfil. Without this
+        # guard we'd succeed at copying to the target, fail to unlink the
+        # source, and the same file would now exist in two places (with
+        # the DB pointing at only one).
+        if file.is_external and file.folder is not None and file.folder.external_readonly:
+            skipped += 1
+            skipped_reasons.append(
+                {"file_id": file_id, "code": "source_readonly", "reason": "source is on a read-only external folder"}
+            )
+            continue
+
+        # Otherwise relocate the bytes, then update the DB row to match.
+        try:
+            new_file_path = _move_file_bytes(file, target_folder)
+        except _MoveSkip as e:
+            skipped += 1
+            skipped_reasons.append({"file_id": file_id, "code": e.code, "reason": e.reason})
+            continue
 
-    return {"status": "success", "moved": moved, "skipped": skipped}
+        file.is_external = target_is_external
+        file.folder_id = data.folder_id
+        file.file_path = new_file_path
+        # External rows historically carry `file_hash=None` (scan skips
+        # hashing). When pulling an external file into managed storage,
+        # compute the hash so dedup detection works for future uploads
+        # of the same content.
+        if not target_is_external and file.file_hash is None:
+            try:
+                abs_path = to_absolute_path(new_file_path)
+                if abs_path:
+                    file.file_hash = calculate_file_hash(abs_path)
+            except OSError:
+                pass  # leave hash null; dedup just won't match this row
+        moved += 1
+
+    await db.commit()
+
+    return {
+        "status": "success",
+        "moved": moved,
+        "skipped": skipped,
+        "skipped_reasons": skipped_reasons,
+    }
 
 
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)

+ 73 - 0
backend/app/core/asyncio_handlers.py

@@ -0,0 +1,73 @@
+"""Asyncio event-loop exception handlers used at app startup.
+
+Currently houses a single Windows-specific filter for the noisy
+``_ProactorBasePipeTransport._call_connection_lost`` ``WinError 10054``
+that fires every time a printer / MQTT broker / camera RSTs a TCP socket
+instead of closing it cleanly. See ``install_proactor_reset_filter`` for
+the why and the failure mode it suppresses.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import sys
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def _is_proactor_connection_reset(context: dict[str, Any]) -> bool:
+    """True if `context` describes the Windows Proactor cleanup-RST noise.
+
+    asyncio's default exception handler is invoked in two distinct cases
+    we care about — generic uncaught task exceptions, and the specific
+    `_call_connection_lost` cleanup path — and we only want to suppress
+    the latter. Match on three signals together so a real
+    `ConnectionResetError` raised inside an application task still
+    surfaces normally:
+
+      1. The exception is `ConnectionResetError` (or a subclass).
+      2. asyncio's own message string mentions `_call_connection_lost`
+         (the Proactor-cleanup callback is the only place Python emits
+         this exact phrase).
+      3. We're actually on Windows, where the Proactor is in use.
+    """
+    if sys.platform != "win32":
+        return False
+    exc = context.get("exception")
+    if not isinstance(exc, ConnectionResetError):
+        return False
+    message = context.get("message", "")
+    return "_call_connection_lost" in message
+
+
+def _proactor_reset_filter(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
+    """Custom event-loop exception handler.
+
+    Handles the Proactor-cleanup `ConnectionResetError` by logging it at
+    DEBUG instead of ERROR, and delegates everything else to asyncio's
+    default handler so unrelated bugs are still visible.
+    """
+    if _is_proactor_connection_reset(context):
+        logger.debug(
+            "asyncio Proactor: peer reset socket during cleanup (WinError 10054); "
+            "ignored — application-layer reconnect handles the disconnect"
+        )
+        return
+    loop.default_exception_handler(context)
+
+
+def install_proactor_reset_filter(loop: asyncio.AbstractEventLoop | None = None) -> bool:
+    """Install the filter on `loop` (or the running loop if omitted).
+
+    Returns True when the filter was installed (Windows only), False on
+    every other platform — so callers can branch on the return value if
+    they want to log the install / skip.
+    """
+    if sys.platform != "win32":
+        return False
+    if loop is None:
+        loop = asyncio.get_running_loop()
+    loop.set_exception_handler(_proactor_reset_filter)
+    return True

+ 17 - 3
backend/app/core/database.py

@@ -141,11 +141,25 @@ async def get_db() -> AsyncSession:
         try:
             yield session
             await session.commit()
-        except Exception:
-            await session.rollback()
+        except BaseException:
+            # Catch BaseException (not just Exception) so CancelledError —
+            # raised when Starlette's BaseHTTPMiddleware cancels the inner
+            # task scope on client disconnect — also triggers rollback.
+            # `asyncio.shield` keeps the rollback running to completion
+            # even when the await itself gets cancelled, so the SQLite
+            # write lock is released promptly instead of being held until
+            # the connection is GC'd ages later (which was producing the
+            # "database is locked" cascade in #1112's support package).
+            try:
+                await asyncio.shield(session.rollback())
+            except BaseException:  # noqa: BLE001 — rollback failure must not mask the original
+                pass
             raise
         finally:
-            await session.close()
+            try:
+                await asyncio.shield(session.close())
+            except BaseException:  # noqa: BLE001 — close failure must not mask the original
+                pass
 
 
 async def init_db():

+ 67 - 4
backend/app/core/logging_filters.py

@@ -1,13 +1,16 @@
 """Logging filters for the Bambuddy log pipeline.
 
-Currently houses a single filter that keeps only state-changing HTTP methods
-in the file-side uvicorn access log. See ``WriteRequestsOnlyFilter`` for the
-why; this lives in its own module so the test suite can import it without
-pulling in ``backend.app.main``'s entire startup graph.
+Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
+uvicorn access log focused on state-changing HTTP methods, and
+``CancelledPoolNoiseFilter`` drops SQLAlchemy connection-pool log noise
+caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
+(see the filter's docstring for details). Both live here so tests can
+import them without pulling in ``backend.app.main``'s startup graph.
 """
 
 from __future__ import annotations
 
+import asyncio
 import logging
 
 
@@ -44,3 +47,63 @@ class WriteRequestsOnlyFilter(logging.Filter):
     def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
         message = record.getMessage()
         return any(token in message for token in self._WRITE_VERB_TOKENS)
+
+
+class CancelledPoolNoiseFilter(logging.Filter):
+    """Drop SQLAlchemy connection-pool log records driven by request cancellation.
+
+    Starlette's ``BaseHTTPMiddleware`` (used under the hood by FastAPI's
+    ``@app.middleware("http")`` decorator) cancels the inner task scope when a
+    client disconnects mid-request. The cancellation propagates into
+    SQLAlchemy's connection-pool cleanup and surfaces as two distinct ERROR
+    records — both expected on disconnect, neither actionable for the user:
+
+    1. ``Exception terminating connection ... CancelledError`` — fires every
+       time ``do_terminate`` is interrupted by the same cancel scope that's
+       unwinding the request. The ``CancelledError`` traceback always
+       attributes the cancel to ``BaseHTTPMiddleware.call_next``.
+
+    2. ``The garbage collector is trying to clean up non-checked-in
+       connection`` — fires later when the GC reclaims the session that
+       couldn't return its connection to the pool because of (1). It's
+       symptomatic of the cancellation, not a separate bug.
+
+    These pile up under heavy upload load (long multipart uploads where the
+    client times out before the server's response). Real connection-pool
+    issues — pool exhaustion, broken connections from network hiccups, etc.
+    — surface through DIFFERENT messages and a non-cancellation
+    ``exc_info`` chain, so they keep flowing through this filter unchanged.
+
+    Attach to ``logging.getLogger("sqlalchemy.pool")`` (and only there).
+    """
+
+    _GC_CLEANUP_PREFIX = "The garbage collector is trying to clean up non-checked-in connection"
+    _TERMINATE_PREFIX = "Exception terminating connection"
+
+    @staticmethod
+    def _has_cancelled_in_chain(exc: BaseException | None) -> bool:
+        """True if `exc` is `CancelledError` or has one in its cause chain."""
+        seen: set[int] = set()
+        cur: BaseException | None = exc
+        while cur is not None and id(cur) not in seen:
+            seen.add(id(cur))
+            if isinstance(cur, asyncio.CancelledError):
+                return True
+            cur = cur.__cause__ or cur.__context__
+        return False
+
+    def filter(self, record: logging.LogRecord) -> bool:  # noqa: A003 — stdlib API name
+        message = record.getMessage()
+        # GC-cleanup records have no exc_info — match by prefix only. Always
+        # symptomatic of the cancellation cascade, never independently useful.
+        if message.startswith(self._GC_CLEANUP_PREFIX):
+            return False
+        # Terminate-connection records carry a traceback; only drop those
+        # that are cancellation-driven. A real terminate failure (broken
+        # connection, network hiccup) keeps a non-CancelledError exc_info
+        # chain and surfaces normally.
+        if message.startswith(self._TERMINATE_PREFIX) and record.exc_info:
+            exc = record.exc_info[1]
+            if self._has_cancelled_in_chain(exc):
+                return False
+        return True

+ 17 - 1
backend/app/main.py

@@ -289,7 +289,10 @@ if app_settings.log_to_file:
     # for exactly this reason. Filtered to write methods only
     # (POST/PUT/PATCH/DELETE) so the high-volume status-poll GETs from the
     # frontend don't churn the rotation window faster than it's useful.
-    from backend.app.core.logging_filters import WriteRequestsOnlyFilter
+    from backend.app.core.logging_filters import (
+        CancelledPoolNoiseFilter,
+        WriteRequestsOnlyFilter,
+    )
 
     uvicorn_access_logger = logging.getLogger("uvicorn.access")
     uvicorn_access_logger.addHandler(file_handler)
@@ -300,6 +303,13 @@ if app_settings.log_to_file:
     # ID column as the application logs they correlate with.
     uvicorn_access_logger.addFilter(TraceIDFilter())
 
+    # Drop SQLAlchemy connection-pool log noise that's caused by Starlette's
+    # BaseHTTPMiddleware cancelling the inner task scope on client
+    # disconnect (#1112). The cancel-safe `get_db` already prevents the
+    # underlying transaction leak; this filter only suppresses the residual
+    # log records that pre-existing pools still emit during their cleanup.
+    logging.getLogger("sqlalchemy.pool").addFilter(CancelledPoolNoiseFilter())
+
 # Reduce noise from third-party libraries in production
 if not app_settings.debug:
     logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
@@ -4161,6 +4171,12 @@ def stop_auth_cleanup() -> None:
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
+    # Install Windows-only asyncio Proactor cleanup-RST filter (#1113) before
+    # anything else can spawn tasks that might trip it.
+    from backend.app.core.asyncio_handlers import install_proactor_reset_filter
+
+    install_proactor_reset_filter()
+
     await init_db()
 
     # Register an app-scoped httpx client for Bambu Cloud services so

+ 99 - 3
backend/app/utils/threemf_tools.py

@@ -6,6 +6,7 @@ accurate partial usage reporting for multi-material prints.
 """
 
 import json
+import logging
 import math
 import re
 import zipfile
@@ -13,6 +14,8 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 
+logger = logging.getLogger(__name__)
+
 # Default filament properties
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
 DEFAULT_FILAMENT_DENSITY = 1.24  # g/cm³ (PLA)
@@ -442,6 +445,90 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
     return filament_usage
 
 
+# Header values exposed as `{placeholder}` substitutions inside snippets.
+# Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
+# Bambu/Orca header keys (`max_z_height`).
+_HEADER_PLACEHOLDER_ALIASES = {
+    "max_layer_z": "max_z_height",
+    "max_print_height": "max_z_height",
+    "total_layers": "total_layer_number",
+}
+
+_HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
+_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
+_START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
+
+
+def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
+    """Parse the `; HEADER_BLOCK_START..END` block into a normalised dict.
+
+    Keys are lowercased, ` [units]` suffixes stripped, and spaces converted
+    to underscores so callers can look up `total_layer_number` regardless of
+    whether the source line is `; total layer number: 80` or
+    `; total filament length [mm] : 12155.34`.
+    """
+    header: dict[str, str] = {}
+    in_header = False
+    for raw_line in content.splitlines():
+        line = raw_line.strip()
+        if line == "; HEADER_BLOCK_START":
+            in_header = True
+            continue
+        if line == "; HEADER_BLOCK_END":
+            break
+        if not in_header:
+            continue
+        m = _HEADER_KEY_RE.match(line)
+        if not m:
+            continue
+        key, value = m.group(1), m.group(2)
+        key = re.sub(r"\s*\[[^\]]*\]\s*$", "", key)
+        key = key.strip().lower().replace(" ", "_")
+        header[key] = value
+    return header
+
+
+def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
+    """Replace `{var}` placeholders with header values, leaving unknowns intact."""
+
+    def repl(m: re.Match) -> str:
+        name = m.group(1)
+        value = header.get(name)
+        if value is None:
+            alias = _HEADER_PLACEHOLDER_ALIASES.get(name)
+            if alias is not None:
+                value = header.get(alias)
+        if value is None:
+            logger.warning(
+                "G-code injection: placeholder {%s} not found in 3MF header; leaving as-is",
+                name,
+            )
+            return m.group(0)
+        return value
+
+    return _PLACEHOLDER_RE.sub(repl, snippet)
+
+
+def _inject_start_at_marker(content: str, snippet: str) -> str:
+    """Insert snippet immediately before `; MACHINE_START_GCODE_END`.
+
+    The marker sits at the bottom of the printer's startup block — bed heat,
+    homing, and nozzle prime are already done, so injected snippets land in
+    the same place a slicer-side custom-start-gcode would. Falls back to
+    prepending if the marker isn't present (older files / non-Bambu slicers).
+    """
+    marker_idx = content.find(_START_GCODE_END_MARKER)
+    if marker_idx == -1:
+        logger.warning(
+            "G-code injection: '%s' not found, prepending start snippet to whole file",
+            _START_GCODE_END_MARKER,
+        )
+        return snippet.rstrip("\n") + "\n" + content
+    line_start = content.rfind("\n", 0, marker_idx)
+    line_start = 0 if line_start == -1 else line_start + 1
+    return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
+
+
 def inject_gcode_into_3mf(
     source_path: Path,
     plate_id: int,
@@ -450,10 +537,16 @@ def inject_gcode_into_3mf(
 ):
     """Create a temp copy of a 3MF with G-code injected at start/end.
 
+    Snippets support `{placeholder}` substitution against values parsed from
+    the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
+    snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
+    run after the printer's own startup (#422). End snippets are appended
+    after the last line of the print.
+
     Args:
         source_path: Path to the original 3MF file.
         plate_id: Plate number (1-indexed) to inject into.
-        start_gcode: G-code to prepend, or None.
+        start_gcode: G-code to insert after printer startup, or None.
         end_gcode: G-code to append, or None.
 
     Returns:
@@ -486,11 +579,14 @@ def inject_gcode_into_3mf(
 
             # Read and modify gcode content
             gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
+            header = _parse_3mf_gcode_header(gcode_content)
 
             if start_gcode:
-                gcode_content = start_gcode + "\n" + gcode_content
+                resolved = _substitute_placeholders(start_gcode, header)
+                gcode_content = _inject_start_at_marker(gcode_content, resolved)
             if end_gcode:
-                gcode_content = gcode_content.rstrip("\n") + "\n" + end_gcode + "\n"
+                resolved = _substitute_placeholders(end_gcode, header)
+                gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n"
 
             # Write modified 3MF to temp file
             with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:

+ 246 - 0
backend/tests/integration/test_external_folders_api.py

@@ -710,3 +710,249 @@ class TestExternalFolderWritableUpload:
         assert row.is_external is False
         # Internal storage: file_path is UUID-scoped, stored as a relative path.
         assert not row.file_path.startswith("/")
+
+
+class TestCrossBoundaryMove:
+    """#1112 follow-up: moving files between managed and external folders
+    must physically relocate the bytes, not just shuffle the DB ``folder_id``.
+
+    Pre-fix symptom (reported by @Carter3DP after testing 0.2.4b1): a file
+    moved from a managed folder to a NAS-backed external folder showed up
+    in Bambuddy's UI under the external folder but was never written to
+    the NAS — so the SMB mount and Bambuddy disagreed about what was
+    actually there.
+    """
+
+    @pytest.fixture
+    def external_dir(self, tmp_path):
+        ext_dir = tmp_path / "writable_share"
+        ext_dir.mkdir()
+        return ext_dir
+
+    @pytest.fixture
+    async def writable_folder(self, async_client, db_session, external_dir):
+        data = {"name": "Writable NAS", "external_path": str(external_dir), "readonly": False}
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+        return response.json()
+
+    @pytest.fixture
+    async def readonly_folder(self, async_client, db_session, tmp_path):
+        ro_dir = tmp_path / "ro_share"
+        ro_dir.mkdir()
+        (ro_dir / "stranded.gcode").write_text("G28")
+        data = {"name": "Read-only NAS", "external_path": str(ro_dir), "readonly": True}
+        response = await async_client.post("/api/v1/library/folders/external", json=data)
+        assert response.status_code == 200
+        # Populate via scan so the file gets a DB row with is_external=True.
+        scan = await async_client.post(f"/api/v1/library/folders/{response.json()['id']}/scan")
+        assert scan.status_code == 200
+        return response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_managed_to_external_relocates_bytes(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """The actual #1112 fix: managed → external must write the bytes
+        to the NAS mount AND drop them from internal storage. Pre-fix the
+        DB row flipped to the new folder but the bytes stayed put."""
+        import io
+
+        from backend.app.api.routes.library import to_absolute_path
+        from backend.app.models.library import LibraryFile
+
+        upload = await async_client.post(
+            "/api/v1/library/files",
+            files={"file": ("ship_me.stl", io.BytesIO(b"original-bytes"), "application/octet-stream")},
+        )
+        assert upload.status_code == 200
+        file_id = upload.json()["id"]
+
+        # Snapshot the pre-move on-disk path so we can verify it's gone after.
+        pre = await db_session.get(LibraryFile, file_id)
+        await db_session.refresh(pre)
+        managed_disk_path = to_absolute_path(pre.file_path)
+        assert managed_disk_path is not None and managed_disk_path.exists()
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
+        )
+        assert response.status_code == 200, response.text
+        body = response.json()
+        assert body["moved"] == 1
+        assert body["skipped"] == 0
+
+        # Bytes are on the NAS mount.
+        on_nas = external_dir / "ship_me.stl"
+        assert on_nas.exists()
+        assert on_nas.read_bytes() == b"original-bytes"
+
+        # Internal copy is gone.
+        assert not managed_disk_path.exists(), "managed source must be removed after the move"
+
+        # DB row matches reality.
+        await db_session.refresh(pre)
+        assert pre.is_external is True
+        assert pre.folder_id == writable_folder["id"]
+        assert pre.file_path == str(on_nas.resolve())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_to_managed_relocates_bytes(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """Symmetric direction: external → managed copies the bytes into
+        internal storage with a UUID name, deletes the source on the
+        mount, and recomputes the file hash (since scan stores
+        ``file_hash=None`` for external rows)."""
+        import io
+
+        from backend.app.models.library import LibraryFile
+
+        # Plant a file on the writable mount and let upload give it a row.
+        upload = await async_client.post(
+            f"/api/v1/library/files?folder_id={writable_folder['id']}",
+            files={"file": ("relocate_me.stl", io.BytesIO(b"nas-bytes"), "application/octet-stream")},
+        )
+        assert upload.status_code == 200
+        file_id = upload.json()["id"]
+        ext_disk = external_dir / "relocate_me.stl"
+        assert ext_disk.exists()
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [file_id], "folder_id": None},
+        )
+        assert response.status_code == 200
+        assert response.json()["moved"] == 1
+
+        db_session.expire_all()
+        row = await db_session.get(LibraryFile, file_id)
+        assert row.is_external is False
+        assert row.folder_id is None
+        assert not row.file_path.startswith("/"), "managed file_path must be relative"
+        assert not ext_disk.exists(), "external source must be removed after the move"
+        # Hash filled in for the now-managed row so future dedup works.
+        assert row.file_hash is not None and len(row.file_hash) == 64
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_managed_to_external_collision_skips_with_reason(
+        self, async_client: AsyncClient, db_session, writable_folder, external_dir
+    ):
+        """A name collision on the target external mount must skip the
+        move with a structured reason — not silently overwrite a file
+        that's already on the NAS."""
+        import io
+
+        # Pre-existing file on the mount with the same name as the upload.
+        (external_dir / "duplicate.stl").write_bytes(b"pre-existing")
+
+        upload = await async_client.post(
+            "/api/v1/library/files",
+            files={"file": ("duplicate.stl", io.BytesIO(b"new-bytes"), "application/octet-stream")},
+        )
+        assert upload.status_code == 200
+        file_id = upload.json()["id"]
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["moved"] == 0
+        assert body["skipped"] == 1
+        reasons = body["skipped_reasons"]
+        assert len(reasons) == 1
+        assert reasons[0]["file_id"] == file_id
+        assert reasons[0]["code"] == "name_collision"
+        # Pre-existing target file is intact.
+        assert (external_dir / "duplicate.stl").read_bytes() == b"pre-existing"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_readonly_source_skips(self, async_client: AsyncClient, db_session, readonly_folder):
+        """A read-only mount allows reading but not deletes, and a move
+        is semantically a delete on the source. Skip with
+        ``source_readonly`` so the file isn't duplicated by half-moving."""
+        listing = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
+        assert listing.status_code == 200
+        ext_file_id = listing.json()[0]["id"]
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [ext_file_id], "folder_id": None},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["moved"] == 0
+        assert body["skipped"] == 1
+        assert body["skipped_reasons"][0]["code"] == "source_readonly"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_managed_to_managed_remains_db_only(self, async_client: AsyncClient, db_session):
+        """Same-boundary moves (managed → managed) keep the existing
+        DB-only fast path — no shutil.copy, no UUID rename. The original
+        file_path stays the same, only ``folder_id`` changes."""
+        import io
+
+        from backend.app.models.library import LibraryFile
+
+        sub = await async_client.post(
+            "/api/v1/library/folders",
+            json={"name": "subfolder", "parent_id": None},
+        )
+        assert sub.status_code == 200
+        target_id = sub.json()["id"]
+
+        upload = await async_client.post(
+            "/api/v1/library/files",
+            files={"file": ("part.stl", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        assert upload.status_code == 200
+        file_id = upload.json()["id"]
+        pre = await db_session.get(LibraryFile, file_id)
+        await db_session.refresh(pre)
+        original_path = pre.file_path
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [file_id], "folder_id": target_id},
+        )
+        assert response.status_code == 200
+        assert response.json()["moved"] == 1
+
+        db_session.expire_all()
+        post = await db_session.get(LibraryFile, file_id)
+        assert post.folder_id == target_id
+        assert post.is_external is False
+        assert post.file_path == original_path  # bytes never moved
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_skipped_reasons_field_present_even_when_empty(self, async_client: AsyncClient, db_session):
+        """Backwards-compatible response shape: ``skipped_reasons`` is
+        always present (empty list when nothing skipped) so frontend
+        code can treat it as the source of truth without optional-chain
+        gymnastics."""
+        import io
+
+        upload = await async_client.post(
+            "/api/v1/library/files",
+            files={"file": ("trivial.stl", io.BytesIO(b"x"), "application/octet-stream")},
+        )
+        assert upload.status_code == 200
+        file_id = upload.json()["id"]
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            json={"file_ids": [file_id], "folder_id": None},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert "skipped_reasons" in body
+        assert body["skipped_reasons"] == []

+ 127 - 0
backend/tests/unit/test_asyncio_handlers.py

@@ -0,0 +1,127 @@
+"""Tests for the Windows asyncio Proactor cleanup-RST filter (#1113)."""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.core.asyncio_handlers import (
+    _is_proactor_connection_reset,
+    _proactor_reset_filter,
+    install_proactor_reset_filter,
+)
+
+
+# `_is_proactor_connection_reset` short-circuits on non-Windows; pretend we're
+# on Windows for the discrimination tests so they exercise the actual logic.
+@pytest.fixture
+def fake_windows():
+    with patch("backend.app.core.asyncio_handlers.sys.platform", "win32"):
+        yield
+
+
+class TestIsProactorConnectionReset:
+    """The discriminator that decides whether a context is the noise we silence."""
+
+    def test_matches_proactor_cleanup_reset(self, fake_windows):
+        ctx = {
+            "exception": ConnectionResetError(10054, "An existing connection was forcibly closed"),
+            "message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
+        }
+        assert _is_proactor_connection_reset(ctx) is True
+
+    def test_rejects_when_not_on_windows(self):
+        # No `fake_windows` fixture — sys.platform reflects the real OS.
+        ctx = {
+            "exception": ConnectionResetError(10054, "irrelevant"),
+            "message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
+        }
+        # The whole point of the filter is to be a Windows-only no-op.
+        with patch("backend.app.core.asyncio_handlers.sys.platform", "linux"):
+            assert _is_proactor_connection_reset(ctx) is False
+
+    def test_rejects_unrelated_connection_reset(self, fake_windows):
+        """A real `ConnectionResetError` raised inside an app coroutine —
+        not from the Proactor cleanup path — must NOT be suppressed.
+        Otherwise we'd hide genuine connectivity bugs."""
+        ctx = {
+            "exception": ConnectionResetError(),
+            "message": "Task exception was never retrieved",
+        }
+        assert _is_proactor_connection_reset(ctx) is False
+
+    def test_rejects_other_exception_types(self, fake_windows):
+        """Other OSErrors (BrokenPipeError, ConnectionAbortedError) might
+        share the cleanup path but they're a different signal worth
+        keeping visible — we only silence the specific 10054 family."""
+        ctx = {
+            "exception": BrokenPipeError(),
+            "message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
+        }
+        assert _is_proactor_connection_reset(ctx) is False
+
+    def test_rejects_when_no_exception(self, fake_windows):
+        """asyncio sometimes invokes the handler with no exception object
+        (e.g. resource warnings) — those shouldn't blanket-match."""
+        ctx = {"message": "_call_connection_lost was slow"}
+        assert _is_proactor_connection_reset(ctx) is False
+
+
+class TestProactorResetFilter:
+    """The handler glue itself — does it suppress the right ones and
+    pass everything else through to the default handler?"""
+
+    @pytest.mark.asyncio
+    async def test_suppresses_proactor_reset(self, fake_windows):
+        loop = asyncio.get_running_loop()
+        with patch.object(loop, "default_exception_handler") as default:
+            _proactor_reset_filter(
+                loop,
+                {
+                    "exception": ConnectionResetError(10054, "forcibly closed"),
+                    "message": "Exception in callback _ProactorBasePipeTransport._call_connection_lost()",
+                },
+            )
+        # Suppression = default handler is never reached.
+        default.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_passes_unrelated_through_to_default(self, fake_windows):
+        """A different uncaught exception must go through asyncio's normal
+        path so it surfaces in logs and tests as an actual problem."""
+        loop = asyncio.get_running_loop()
+        ctx = {
+            "exception": ValueError("real bug"),
+            "message": "Task exception was never retrieved",
+        }
+        with patch.object(loop, "default_exception_handler") as default:
+            _proactor_reset_filter(loop, ctx)
+        default.assert_called_once_with(ctx)
+
+
+class TestInstallation:
+    """Wiring: install_proactor_reset_filter only runs on Windows."""
+
+    @pytest.mark.asyncio
+    async def test_install_is_no_op_on_non_windows(self):
+        """Linux/macOS use the Selector loop, which doesn't hit this code
+        path — the install must be inert so the Linux production path
+        keeps the default exception handler untouched."""
+        loop = asyncio.get_running_loop()
+        with (
+            patch("backend.app.core.asyncio_handlers.sys.platform", "linux"),
+            patch.object(loop, "set_exception_handler") as setter,
+        ):
+            installed = install_proactor_reset_filter(loop)
+        assert installed is False
+        setter.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_install_attaches_handler_on_windows(self, fake_windows):
+        loop = asyncio.get_running_loop()
+        with patch.object(loop, "set_exception_handler") as setter:
+            installed = install_proactor_reset_filter(loop)
+        assert installed is True
+        setter.assert_called_once_with(_proactor_reset_filter)

+ 76 - 0
backend/tests/unit/test_cancelled_pool_filter.py

@@ -0,0 +1,76 @@
+"""Tests for the SQLAlchemy connection-pool cancellation noise filter (#1112)."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from backend.app.core.logging_filters import CancelledPoolNoiseFilter
+
+
+def _make_record(message: str, *, exc: BaseException | None = None) -> logging.LogRecord:
+    """Build a `LogRecord` carrying `message` (no positional args) and
+    optionally an `exc_info` tuple holding `exc`."""
+    record = logging.LogRecord(
+        name="sqlalchemy.pool.impl.AsyncAdaptedQueuePool",
+        level=logging.ERROR,
+        pathname=__file__,
+        lineno=0,
+        msg=message,
+        args=(),
+        exc_info=(type(exc), exc, exc.__traceback__) if exc is not None else None,
+    )
+    return record
+
+
+class TestCancelledPoolNoiseFilter:
+    """Drops the cancellation cascade, keeps real pool errors visible."""
+
+    def test_drops_terminate_with_cancelled_exc(self):
+        cancel = asyncio.CancelledError("Cancelled via cancel scope")
+        record = _make_record("Exception terminating connection <ABC>", exc=cancel)
+        assert CancelledPoolNoiseFilter().filter(record) is False
+
+    def test_drops_gc_cleanup_record(self):
+        # GC cleanup messages have no exc_info attached — match by prefix.
+        record = _make_record("The garbage collector is trying to clean up non-checked-in connection <ABC>")
+        assert CancelledPoolNoiseFilter().filter(record) is False
+
+    def test_keeps_terminate_with_real_oserror(self):
+        """A genuine connection-terminate failure (network hiccup, broken
+        socket) carries a non-cancellation exc_info chain. That's a real
+        problem the user should see — must NOT be dropped."""
+        oserr = OSError("broken pipe")
+        record = _make_record("Exception terminating connection <ABC>", exc=oserr)
+        assert CancelledPoolNoiseFilter().filter(record) is True
+
+    def test_keeps_terminate_without_exc_info(self):
+        """If for any reason `exc_info` is missing on a terminate record,
+        keep it — only filter when we have positive evidence it's the
+        cancellation cascade."""
+        record = _make_record("Exception terminating connection <ABC>")
+        assert CancelledPoolNoiseFilter().filter(record) is True
+
+    def test_keeps_unrelated_pool_message(self):
+        """Other pool messages (pool size warnings, etc.) keep flowing."""
+        record = _make_record("Pool size has been exceeded; will spawn overflow")
+        assert CancelledPoolNoiseFilter().filter(record) is True
+
+    def test_drops_when_cancelled_is_in_cause_chain(self):
+        """Real-world traceback: SQLAlchemy wraps the CancelledError in a
+        chained exception. The filter walks `__cause__`/`__context__` so a
+        chained CancelledError still counts."""
+        cancel = asyncio.CancelledError()
+        wrapper = RuntimeError("terminate failed")
+        wrapper.__cause__ = cancel
+        record = _make_record("Exception terminating connection <ABC>", exc=wrapper)
+        assert CancelledPoolNoiseFilter().filter(record) is False
+
+    def test_handles_self_referential_cause_chain(self):
+        """Defensive: malformed exception chains (rare but possible) must
+        not loop forever — the `seen` set guards against it."""
+        a = RuntimeError("a")
+        a.__cause__ = a  # pathological
+        record = _make_record("Exception terminating connection <ABC>", exc=a)
+        # Doesn't loop, doesn't raise, returns True (no CancelledError found).
+        assert CancelledPoolNoiseFilter().filter(record) is True

+ 221 - 3
backend/tests/unit/test_gcode_injection.py

@@ -4,9 +4,12 @@ import tempfile
 import zipfile
 from pathlib import Path
 
-import pytest
-
-from backend.app.utils.threemf_tools import inject_gcode_into_3mf
+from backend.app.utils.threemf_tools import (
+    _inject_start_at_marker,
+    _parse_3mf_gcode_header,
+    _substitute_placeholders,
+    inject_gcode_into_3mf,
+)
 
 
 def _make_temp_path(suffix=".3mf") -> Path:
@@ -205,3 +208,218 @@ class TestInjectGcodeInto3mf:
             source.unlink(missing_ok=True)
             if result:
                 result.unlink(missing_ok=True)
+
+
+# Realistic Bambu / Orca header + startup block — the start-gcode marker is the
+# anchor point #422 reviewers (DevScarabyte, pleite) reported as the correct
+# injection point. Snippets injected before this should land *after* the bed
+# heat / homing / nozzle prime sequence, not before it.
+_BAMBU_GCODE_TEMPLATE = """\
+; HEADER_BLOCK_START
+; BambuStudio 02.06.00.51
+; total layer number: 80
+; total filament length [mm] : 12155.34
+; total filament weight [g] : 36.55
+; max_z_height: 16.00
+; HEADER_BLOCK_END
+; MACHINE_START_GCODE_BEGIN
+M104 S220 ; preheat
+G28 ; home
+M109 S220 ; wait for nozzle
+G92 E0 ; reset extruder
+; MACHINE_START_GCODE_END
+G1 X10 Y10 Z0.2
+G1 X100 Y100 E5
+M104 S0
+"""
+
+
+class TestStartAnchoredInjection:
+    """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
+
+    def test_start_lands_after_printer_startup(self):
+        """Start snippet sits immediately before MACHINE_START_GCODE_END, not at file head."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; SWAPMOD-START", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            # Original file head is preserved — snippet does NOT prepend.
+            assert gcode.startswith("; HEADER_BLOCK_START\n")
+            # Snippet sits right above the marker.
+            marker_idx = gcode.index("; MACHINE_START_GCODE_END")
+            snippet_idx = gcode.index("; SWAPMOD-START")
+            assert snippet_idx < marker_idx
+            # Nothing else between snippet and marker except the trailing newline.
+            between = gcode[snippet_idx:marker_idx]
+            assert between == "; SWAPMOD-START\n"
+            # Printer's own startup commands still come BEFORE the snippet.
+            startup_idx = gcode.index("M109 S220")
+            assert startup_idx < snippet_idx
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_marker_falls_back_to_prepend(self):
+        """Files without MACHINE_START_GCODE_END (older slicers) keep prepend behaviour."""
+        source = _make_test_3mf("G28\nM400\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; LEGACY-START", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; LEGACY-START\n")
+            assert "G28" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_end_still_appended_at_eof(self):
+        """End g-code keeps the existing append-to-EOF behaviour even with marker present."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.endswith("; SWAPMOD-END\n")
+            # Marker anchor is irrelevant for end snippets.
+            assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+
+class TestPlaceholderSubstitution:
+    """Tests for #422 follow-up: {placeholder} substitution from 3MF header values."""
+
+    def test_max_z_height_substituted_in_end_snippet(self):
+        """`G1 Z{max_layer_z}` resolves to the model's actual top-layer Z (DevScarabyte safety bug)."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            # Prusa-style alias: max_layer_z → max_z_height in the Bambu header
+            result = inject_gcode_into_3mf(source, 1, None, "G1 Z{max_layer_z} F600")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            # max_z_height in the template is 16.00 — the dangerous Z1 fallback is gone.
+            assert "G1 Z16.00 F600" in gcode
+            assert "{max_layer_z}" not in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_direct_header_key_lookup(self):
+        """Snippets can reference normalised header keys directly without going through aliases."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(
+                source, 1, None, "; layers={total_layer_number} weight={total_filament_weight}"
+            )
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert "; layers=80 weight=36.55" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_unknown_placeholder_left_intact(self):
+        """A typo or unsupported placeholder is preserved verbatim instead of becoming empty."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "; nope={does_not_exist}")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert "; nope={does_not_exist}" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_placeholders_no_header_required(self):
+        """Snippets without placeholders inject correctly even when the header is absent."""
+        source = _make_test_3mf("G28\nM400\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; PLAIN", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; PLAIN\n")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+
+class TestHeaderParser:
+    """Direct tests for `_parse_3mf_gcode_header`."""
+
+    def test_parses_bambu_header_block(self):
+        header = _parse_3mf_gcode_header(_BAMBU_GCODE_TEMPLATE)
+        assert header["max_z_height"] == "16.00"
+        assert header["total_layer_number"] == "80"
+        # Units suffix is stripped from the key.
+        assert header["total_filament_length"] == "12155.34"
+        assert header["total_filament_weight"] == "36.55"
+
+    def test_ignores_lines_outside_header_block(self):
+        content = "; HEADER_BLOCK_START\n; key: in\n; HEADER_BLOCK_END\n; key: out\n"
+        header = _parse_3mf_gcode_header(content)
+        assert header == {"key": "in"}
+
+    def test_returns_empty_when_no_header(self):
+        assert _parse_3mf_gcode_header("G28\nG1 X0\n") == {}
+
+
+class TestPlaceholderHelper:
+    """Direct tests for `_substitute_placeholders`."""
+
+    def test_substitutes_known_keys(self):
+        assert _substitute_placeholders("Z={a} F={b}", {"a": "10", "b": "600"}) == "Z=10 F=600"
+
+    def test_alias_resolves_to_underlying_key(self):
+        assert _substitute_placeholders("Z={max_layer_z}", {"max_z_height": "16.00"}) == "Z=16.00"
+
+    def test_unknown_left_verbatim(self):
+        assert _substitute_placeholders("{nope}", {}) == "{nope}"
+
+
+class TestStartMarkerHelper:
+    """Direct tests for `_inject_start_at_marker`."""
+
+    def test_inserts_before_marker_line(self):
+        content = "first\nsecond\n; MACHINE_START_GCODE_END\ntail\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "first\nsecond\nINJECTED\n; MACHINE_START_GCODE_END\ntail\n"
+
+    def test_marker_at_start_of_file(self):
+        content = "; MACHINE_START_GCODE_END\nrest\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "INJECTED\n; MACHINE_START_GCODE_END\nrest\n"
+
+    def test_missing_marker_falls_back_to_prepend(self):
+        content = "G28\nG1 X0\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "INJECTED\nG28\nG1 X0\n"

+ 163 - 0
backend/tests/unit/test_get_db_cancel_safety.py

@@ -0,0 +1,163 @@
+"""Tests for `get_db` cancel-safety (#1112).
+
+Starlette's BaseHTTPMiddleware cancels the inner task scope when a
+client disconnects mid-request. Pre-fix `get_db` only caught `Exception`
+(not `BaseException`), so `CancelledError` skipped the rollback path —
+the SQLite write lock stayed held until the connection was eventually
+GC'd, producing the "database is locked" cascade in @Carter3DP's
+support package on #1112.
+
+The fix:
+  1. Catch `BaseException` so `CancelledError` triggers rollback.
+  2. `asyncio.shield` rollback + close so the cleanup completes even
+     when the await is cancelled by the same cancel scope.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core import database
+
+
+class _FakeSession:
+    """Minimal async-context-manager stand-in for `AsyncSession`.
+
+    Records which lifecycle methods were invoked so tests can assert on
+    the cleanup order without a real engine / DB file.
+    """
+
+    def __init__(self):
+        self.commit = AsyncMock(name="commit")
+        self.rollback = AsyncMock(name="rollback")
+        self.close = AsyncMock(name="close")
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        return False  # don't suppress
+
+
+@pytest.fixture
+def fake_session_factory(monkeypatch):
+    """Patch `database.async_session` to yield a fresh `_FakeSession`."""
+    session = _FakeSession()
+    monkeypatch.setattr(database, "async_session", lambda: session)
+    return session
+
+
+async def _consume_get_db(action):
+    """Drive `get_db` like FastAPI's dependency machinery does:
+    enter the async generator, run `action(session)`, then advance to
+    completion. Returns the entered session."""
+    gen = database.get_db()
+    session = await gen.__anext__()
+    try:
+        await action(session)
+    except StopAsyncIteration:
+        return session
+    # Advance to the end so the generator's finally runs.
+    try:
+        await gen.__anext__()
+    except StopAsyncIteration:
+        pass
+    return session
+
+
+class TestCancelSafety:
+    """Pin the cancel-safety contract end-to-end."""
+
+    @pytest.mark.asyncio
+    async def test_commit_on_clean_exit(self, fake_session_factory):
+        session = fake_session_factory
+
+        async def noop(_s):
+            pass
+
+        await _consume_get_db(noop)
+
+        session.commit.assert_awaited_once()
+        session.rollback.assert_not_awaited()
+        session.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_rollback_on_regular_exception(self, fake_session_factory):
+        session = fake_session_factory
+
+        gen = database.get_db()
+        await gen.__anext__()
+        with pytest.raises(ValueError):
+            await gen.athrow(ValueError("route handler bug"))
+
+        session.commit.assert_not_awaited()
+        session.rollback.assert_awaited_once()
+        session.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_rollback_on_cancelled_error(self, fake_session_factory):
+        """The actual #1112 fix: CancelledError must NOT skip the rollback.
+        Pre-fix `except Exception` caught nothing because CancelledError
+        is a BaseException, not an Exception."""
+        session = fake_session_factory
+
+        gen = database.get_db()
+        await gen.__anext__()
+        with pytest.raises(asyncio.CancelledError):
+            await gen.athrow(asyncio.CancelledError("client disconnected"))
+
+        session.commit.assert_not_awaited()
+        session.rollback.assert_awaited_once()
+        session.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_close_runs_even_if_rollback_raises(self, fake_session_factory):
+        """A failing rollback (broken connection during cancellation) must
+        not prevent `close` from running — otherwise the pool would never
+        reclaim the connection."""
+        session = fake_session_factory
+        session.rollback.side_effect = OSError("broken pipe during rollback")
+
+        gen = database.get_db()
+        await gen.__anext__()
+        with pytest.raises(asyncio.CancelledError):
+            await gen.athrow(asyncio.CancelledError())
+
+        session.rollback.assert_awaited_once()
+        session.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_close_failure_does_not_propagate(self, fake_session_factory):
+        """A failing close on the clean-exit path must not raise out of
+        `get_db` — the request already succeeded."""
+        session = fake_session_factory
+        session.close.side_effect = OSError("close failed")
+
+        async def noop(_s):
+            pass
+
+        # Must not raise.
+        await _consume_get_db(noop)
+
+        session.commit.assert_awaited_once()
+        session.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_rollback_uses_shield(self, fake_session_factory):
+        """Cancellation arriving DURING rollback must not abort the
+        rollback — `asyncio.shield` keeps it running. Verify the call
+        path goes through `shield` so future refactors don't silently
+        drop the protection."""
+        # The fixture wires the fake session into `database.async_session`;
+        # we don't need the local handle here.
+        with patch.object(asyncio, "shield", wraps=asyncio.shield) as shield:
+            gen = database.get_db()
+            await gen.__anext__()
+            with pytest.raises(asyncio.CancelledError):
+                await gen.athrow(asyncio.CancelledError())
+
+        # rollback + close both shielded.
+        assert shield.call_count == 2

+ 3 - 4
frontend/package-lock.json

@@ -6381,9 +6381,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.6",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
-      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "version": "8.5.12",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
+      "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
       "dev": true,
       "funding": [
         {
@@ -6399,7 +6399,6 @@
           "url": "https://github.com/sponsors/ai"
         }
       ],
-      "license": "MIT",
       "dependencies": {
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio