From 936b74812713a8c40ebaa735832a98a2eeac96e5 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 19 Apr 2026 13:40:43 +0200 Subject: [PATCH] fix(archive): truncation of large 3MF uploads on sendfile short-return (#1032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF files larger than a few megabytes arrived complete via the virtual-printer FTP server but the copy into data/archives/ was silently truncated. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive — the subsequent zipfile.ZipFile() in GET /archives/{id}/plates raised BadZipFile and the UI came up blank with no thumbnail, plate list, or filament data. Two things conspired: 1. archive_print() used shutil.copy2, which takes Python's sendfile() fast path on Linux. On the reporter's kernel/fs combination sendfile returned a short count on the first call for the upload sizes hit in practice and the destination ended up truncated. Small files completed in one syscall and were fine. 2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare `except Exception: pass`, so the archive pipeline kept going with empty metadata and left the bad file on disk — nothing in the logs hinted anything had gone wrong until a support bundle came in and the "Failed to parse plates" warning fired much later. The archive copy is now an explicit chunked read/write with fsync — sendfile is not in the path. After the copy, if the source was a valid ZIP but the destination isn't, we refuse to create the archive row, remove only the truncated file (and the archive directory if empty — archive_dir is created with exist_ok=True so rmtree would be unsafe if a same-second same-filename collision happened), and log both sizes at ERROR so the condition is obvious in future support bundles. The parser's silent catch now logs at WARNING for the same reason. All nine archive_print() call sites already check `if archive:` or `if not archive:`, so returning None for corrupted ZIPs propagates cleanly without behaviour changes elsewhere. Regression tests cover single-chunk and multi-chunk copies, mtime preservation via copystat, overwrite of an existing destination, a ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING, and a truncation sentinel verifying that zipfile.is_zipfile() flips to False on a half-written ZIP — the exact post-condition archive_print now trusts. --- CHANGELOG.md | 1 + backend/app/services/archive.py | 75 +++++++++- .../tests/unit/services/test_archive_copy.py | 133 ++++++++++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 backend/tests/unit/services/test_archive_copy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c755a1424..38a48e84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4b1] - Unreleased ### Fixed +- **Large 3MF Uploads Archived as Corrupted ZIPs** ([#1032](https://github.com/maziggy/bambuddy/issues/1032)) — On bare-metal Raspberry Pi installs (armv7l / Python 3.11 / Bookworm), 3MF files larger than a few MB arrived complete via the virtual-printer FTP server but the copy into `data/archives/` ended up not being a valid ZIP. The archive row was still written, the printer card looked fine, and the problem only surfaced later when opening the archive in the UI, where `GET /archives/{id}/plates` logged `Failed to parse plates from archive N: File is not a zip file` and the thumbnail / plate / filament panels came up blank. Two things conspired: `shutil.copy2` takes the Linux `sendfile()` fast path on Python ≥ 3.8, and a partial-return from that syscall silently truncated the destination for the upload sizes users hit; and `ThreeMFParser.parse()` had a bare `except: pass` around its `zipfile.ZipFile` open, so the archive pipeline kept going with empty metadata and left the bad file on disk. The copy is now an explicit chunked read/write with `fsync()` — no sendfile involved — with a post-condition `zipfile.is_zipfile()` check that refuses to create the archive row (and cleans up the archive directory) when the source was a valid ZIP and the destination isn't, logging both sizes at `ERROR`. The parser's silent catch now logs at `WARNING` so corrupted 3MFs are visible in support bundles instead of disappearing into empty metadata. Regression tests cover small / multi-chunk copies, ZIP roundtrips, the post-copy `is_zipfile` sentinel on a truncated file, and the new parser WARNING. Thanks to @saint-hh for the detailed diagnosis. - **Thumbnails Blank Until Reload After Sign-In** — On auth-enabled instances, signing out and back in left the File Manager (and occasionally the Archives page) full of broken thumbnails until the page was manually reloaded. Thumbnail URLs are gated by a short-lived camera-stream token that `` tags can't send via `Authorization` headers, so the token is appended as `?token=…` at render time. Two race conditions conspired to break this: (1) the token query was keyed only on `['camera-stream-token']` and fired while the user was still on the login page, 401'd, and stayed cached — after sign-in nothing invalidated it; (2) when the token did eventually arrive, the global variable holding it was not reactive, so any File Manager / Archives page that had already rendered kept serving image URLs with no token. The token query now includes the user id in its key and is gated on `!!user`, so a new login always triggers a fresh fetch; and when the token transitions from null to a value, `useStreamTokenSync` walks the DOM once and updates `src` on every already-rendered ``/`