From 7d4dfd5a7d2bdd3b0d00c2e7564c586080711fb6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 5 Jul 2026 10:32:13 +0200 Subject: [PATCH] fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896) Native (non-Docker) installs launched uvicorn without --loop asyncio, so uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops already-received but still-buffered data when the client closes the data connection without a TLS close_notify while the reader is flow-control paused on slow storage. cmd_STOR writes each chunk to disk inside the read loop, so a slow consumer falls behind, the tail is lost, read() returns a clean EOF, and the loop exits with no exception -- the server acked 226 for a file it truncated itself, then archived, queued, and forwarded the corrupt 3MF to the real printer. Fix in two independent layers: 1. Remove the trigger: add --loop asyncio to every native launch path, matching the Dockerfile -- deploy/bambuddy.service, install/install.sh (systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM service, README, CONTRIBUTING dev command. 2. Defense in depth (loop-independent): cmd_STOR now validates that a received .3mf opens as a ZIP (reads the central directory, no decompression) before replying 226. A truncated/corrupt file is dropped and answered with 426, and on_file_received never runs -- so a broken upload surfaces as an immediate slicer-side send error instead of being archived and pushed to the printer. Scoped to .3mf; other filetypes pass through unchanged. --- CHANGELOG.md | 1 + CONTRIBUTING.md | 5 +- README.md | 4 +- .../services/virtual_printer/ftp_server.py | 38 ++++++++++ backend/tests/unit/test_vp_ftp_stor.py | 69 ++++++++++++++++++- deploy/bambuddy.service | 4 +- install/install.sh | 6 +- installers/windows/README.md | 2 +- .../windows/service/install-service.bat | 3 +- spoolbuddy/install/install.sh | 3 +- 10 files changed, 125 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a53146b71..36f708f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Virtual Printer FTP uploads silently truncated under uvloop — a corrupt `.gcode.3mf` was archived, queued, and forwarded to the real printer with a `226 Transfer complete` (#1896, reporter @dj-oyu)** — On a native venv install (not Docker), slicing in Bambu Studio and sending to a queue-mode VP produced a truncated upload: Bambuddy logged `226 Transfer complete`, archived the file, added it to the queue, and later pushed the corrupt file to the physical printer (A1 mini), which then failed to parse/start the job. Every truncated file ended at an exact multiple of 4096 bytes with a valid `PK\x03\x04` local header but no ZIP End-Of-Central-Directory record. **Root cause — isolated deterministically by the reporter.** uvloop's SSL layer discards already-received but still-buffered data when the client closes the data connection **without a TLS `close_notify`** (a "ragged EOF") while the reader is **flow-control-paused** (slow consumer). `cmd_STOR` writes each 64 KiB chunk to disk synchronously inside the read loop; on slow storage (the reporter's data dir was on a microSD on an ARM64 SBC) the reader falls behind, the transport pauses, and the tail of the upload is lost — `read()` then returns a clean empty EOF, so the loop exits normally with **no exception and no write error**, and the server acks 226 for a file it truncated itself. The reporter's isolation matrix reproduces it on a minimal uvloop 0.22.1 TLS server (slow reader → 2,248,704 of 2,500,001 bytes, 3/3 runs) but never on CPython's default asyncio loop or on uvloop with a fast reader — which is why Docker/x86-with-SSD deployments almost never hit it (the reader keeps up, flow control never pauses, the loss window never opens). Bambuddy's Dockerfile already runs `--loop asyncio`; **every native launch path did not** and so auto-selected uvloop via `uvicorn[standard]`. **Fix — two independent layers.** (1) *Remove the trigger.* Added `--loop asyncio` to every native launch path so uploads actually arrive intact, matching the Dockerfile: `deploy/bambuddy.service`, `install/install.sh` (systemd unit + macOS launchd plist), `spoolbuddy/install/install.sh` (the bundled Bambuddy backend service), `installers/windows/service/install-service.bat` (NSSM), `README.md`, and the wiki install docs (run command, systemd, launchd) — each with an inline "do not remove, see #1896" note. The reporter verified `--loop asyncio` fully resolves it (before: 8/8 real Bambu Studio uploads truncated; after: 2/2 intact, valid ZIPs, `testzip` clean). (2) *Defense in depth, loop-independent.* `cmd_STOR` now validates that a received `.3mf` opens as a ZIP (reads the central directory — O(dir), no decompression) **before** replying 226. A truncated/corrupt 3MF is treated exactly like a failed transfer: the file is dropped and the slicer gets `426 Transfer failed: uploaded 3MF is incomplete or corrupt`, and the `on_file_received` callback that archives/queues/forwards the job never runs — so a broken upload surfaces as an immediate, actionable slicer-side send error instead of a confusing printer-side parse failure later. Validation is scoped to `.3mf` uploads; other filetypes keep the prior pass-through behaviour. This layer protects anyone who still runs uvloop for any reason (custom launch command, future `uvicorn[standard]` default). **Tests.** `test_vp_ftp_stor.py`: the happy-path test now feeds a real multi-chunk ZIP and asserts 226 (not 426); new `test_stor_rejects_truncated_3mf` drops the EOCD-bearing tail and asserts 426 + file removed + `on_file_received` never called; new `test_stor_skips_zip_validation_for_non_3mf` asserts a plain `.gcode` still gets 226 (no false-positive). 6/6 in the file green, ruff clean. **Scope.** Backend (one validation block in `cmd_STOR` + `zipfile` import) plus launch-config across repo installers, the Windows/SpoolBuddy installers, README, and the install wiki. No DB migration, no new permission, no i18n key, no frontend change. Users on a native install: after upgrade, re-run the installer (or add `--loop asyncio` to your existing service command) to stop the truncation at the source; the ZIP-validation guard takes effect on the next Bambuddy restart regardless. **Workaround for older versions:** launch uvicorn with `--loop asyncio`. - **API keys could not manage Projects — every project mutation returned `403 "API keys cannot be used for administrative operations"` regardless of the key's granted permissions (#1893, reporter @abbasegbeyemi)** — `POST /projects/{id}/add-archives`, project create/update/delete, and every other project mutation route was unreachable for any API key. **Root cause.** `PROJECTS_CREATE` / `PROJECTS_UPDATE` / `PROJECTS_DELETE` were in `_APIKEY_DENIED_PERMISSIONS` in `core/auth.py` with no corresponding entry in `_APIKEY_SCOPE_BY_PERMISSION` and no `can_manage_projects` flag on `api_keys` at all — so under the GHSA-r2qv allowlist model they resolved to scope `None` and raised the generic administrative-operations 403. This is the exact regression class already fixed for archives (#1888) and library (#1832): the projects block sat directly between the comment blocks documenting those two carve-outs but was never itself carved out. **Fix.** New per-key scope `can_manage_projects` (column on `api_keys`, DEFAULT TRUE for keys created via the UI going forward; existing rows backfill to FALSE so the upgrade path never silently widens scope — these permissions were explicitly denied for every key before, so nothing relies on them). Unlike archives/library, the project routes gate on plain `RequirePermissionIfAuthEnabled(Permission.PROJECTS_*)` — there is no OWN/ALL ownership split for projects — so all three CRUD permissions map directly to the one scope. Project **membership** edits (`add_archives_to_project` etc.) gate on `PROJECTS_UPDATE`, so they're covered by the same toggle; `PROJECTS_READ` is unchanged (already under `can_read_status`, so API keys could always read projects). Users opt a key in from Settings → API Keys ("Manage Projects" toggle, with a "Projects" badge on the key list). The bundled SpoolBuddy kiosk key (created via the CLI) is set to `can_manage_projects=False` to stay minimally scoped. **Migration** is dialect-agnostic (`BOOLEAN` is valid on both SQLite and Postgres); verified end-to-end on a throwaway fresh SQLite and Postgres 17 that the column adds, legacy rows backfill to FALSE, and a new row defaults to TRUE. **Tests.** `test_auth_apikey_rbac.py` extended: the `_check_apikey_permissions` scope matrix now covers all three project permissions (true→allow, false→403, no cross-scope leakage), and `PROJECTS_CREATE` / `_UPDATE` / `_DELETE` added to the operational-allowed drift guard + threaded through the structural allowlist/flag-parity checks — 63 cases green. **Scope.** Backend (model + migration + allowlist + schema + route + CLI) plus the Settings API-key UI (toggle + badge + type) and 11-locale i18n for the new label/description/badge. No change to the project routes themselves — they already gated on the right permissions; only the API-key classification of those permissions was wrong. - **Auto-drying stopped a manually started AMS drying cycle after exactly 30 minutes, cutting long PETG/PA dries short (#1892, reporter @Spionkiller01)** — With ambient/queue auto-drying enabled, starting a drying cycle *manually on the printer* (or a cycle that survived a Bambuddy restart) got a stop-drying MQTT command ~30 minutes in, every time — killing an intended 8-12 h cycle. The reporter had Bambu Lab support analyse the printer logs, which confirmed an external tool issued the stop; that tool was Bambuddy. **Root cause — two defects compounding in `_check_auto_drying()` (`backend/app/services/print_scheduler.py`).** (1) The already-drying branch carried the comment *"Drying we didn't start (manual or from before restart) — track but don't stop"* but the very next lines applied the humidity-based auto-stop to it anyway; a manually started dry was treated identically to a Bambuddy-initiated one. (2) The humidity re-check is fundamentally unreliable: relative humidity drops steeply in heated air, so the AMS sensor reads ~15-20% within minutes of the dryer starting even while the filament is still saturated (the reporter's log shows 18%). So `humidity <= threshold` is effectively *always true* once drying runs, and the only thing delaying the stop was the `_min_drying_seconds = 1800` floor — which is why the kill landed at exactly the 30-minute mark. This second defect also silently truncated Bambuddy's **own** preset-duration dries (e.g. a PETG 8 h cycle) to ~30 min, not just manual ones. **Fix.** Removed the humidity-based early-stop entirely — a running drying cycle is now left to run to its configured duration, which the firmware stops when the duration elapses. This is simpler and more correct than exempting only manual dries (the reporter's suggested `_manual_drying` set), because the humidity re-check can't distinguish "filament is dry" from "air is hot" for *any* cycle, so it never did its intended job — it just always fired at the floor. Scheduling-driven stops are unaffected and still work through `_stop_drying()`: a print taking priority, or queue-mode no longer needing the dry, still stops it. The now-unused `_min_drying_seconds` attribute was removed. Bambuddy still *starts* auto-drying on the same humidity-over-threshold trigger; only the mid-cycle humidity re-stop is gone. **Tests.** `test_scheduler_auto_drying.py` updated: `TestMinimumDryingTime` now pins the #1892 contract (a running dry is never stopped by a humidity re-check — before or long after the old floor, including when humidity reads low), and `TestBlockForDryingBugFix` asserts an already-running dry in block mode is left alone (block mode still gates *new* starts on printers with pending items). 51/51 in the file green, ruff clean. **Scope.** Backend-only, one branch simplified in `_check_auto_drying` plus the attribute removal. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier: the fix takes effect on the next Bambuddy restart. **Workaround for older versions:** disable ambient/queue auto-drying in Settings before starting a manual drying cycle. - **When auth was enabled, a browser that could not mint a WebSocket token retried forever, hammering `POST /api/v1/auth/ws-token` every 3 seconds (reported by corporate sponsor)** — After the GHSA-r2qv hardening (`b7d7c825`), `/api/v1/ws` requires a token from `POST /api/v1/auth/ws-token` (gated by `Permission.WEBSOCKET_CONNECT`). If that mint failed, `useWebSocket` swallowed the error and **fell through to open a tokenless socket anyway**; the server closed it with code `4401`, and `ws.onclose` unconditionally scheduled a reconnect 3 s later — an endless loop that spammed the auth endpoint with `401`/`403`s. The dominant trigger was a *validly logged-in* user whose group lacks `WEBSOCKET_CONNECT` (e.g. a custom lower-privilege "ops" group): the mint returns `403` and the loop never ends. A secondary leak: on logout the provider unmounts, but the `close()`-triggered `onclose` could still schedule one more post-unmount reconnect. **Fix.** The token-mint failure is now classified: a `401` (JWT expired — `request()` already clears it and dispatches `auth:expired`, redirecting to `/login`) or `403` (valid session, missing permission — stays logged in, live updates degrade to the existing REST polling) **stops** the hook — no tokenless socket, no reconnect. A `4401` close is now treated as terminal (reconnecting can't fix an auth rejection without a fresh login, which remounts the provider). Network/`5xx` errors still reconnect as before. A `disposedRef` set in the effect cleanup before `close()` prevents the unmount-race reconnect. The same 401/403 no-open guard was applied to `StreamOverlayPage` (which has no reconnect loop, so it was one doomed socket per mount rather than a spin). **Tests.** New `useWebSocket` cases: a `4401` close does not reconnect, a `403` mint opens no socket and does not loop, and a close firing during unmount schedules no reconnect. **Note.** Auto-granting `WEBSOCKET_CONNECT` to lesser groups was rejected on purpose — the WebSocket streams all printer status, so that would partly undo the GHSA-r2qv gate; graceful degradation is the correct behavior. The group editor now shows a one-line hint under the WebSocket permission explaining that live updates require it (falls back to polling otherwise), translated across all 11 locales. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02083ad31..1590a9893 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,8 +117,9 @@ pip install -r requirements-dev.txt # Dev/test dependencies (pytest, ruff, band pip install pre-commit pre-commit install -# Run backend -DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 +# Run backend (--loop asyncio matches production; avoids a uvloop TLS bug +# that can truncate Virtual Printer FTP uploads on slow storage — see #1896) +DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 --loop asyncio ``` ### Frontend Setup diff --git a/README.md b/README.md index fe26bbf4c..316759718 100644 --- a/README.md +++ b/README.md @@ -665,8 +665,8 @@ python3 -m venv venv source venv/bin/activate pip install -r requirements.txt -# Run -uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 +# Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads) +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio ``` Open **http://localhost:8000** and add your printer! diff --git a/backend/app/services/virtual_printer/ftp_server.py b/backend/app/services/virtual_printer/ftp_server.py index d31b4dc06..0a242ccf6 100644 --- a/backend/app/services/virtual_printer/ftp_server.py +++ b/backend/app/services/virtual_printer/ftp_server.py @@ -13,6 +13,7 @@ import logging import os import random import ssl +import zipfile from collections.abc import Callable from pathlib import Path @@ -476,6 +477,43 @@ class FTPSession: await self.send(426, f"Transfer failed: {write_failed}") return + # Defense in depth (#1896): a clean read-loop EOF does NOT prove the + # upload arrived intact. Under uvloop, the SSL layer can silently drop + # already-received but still-buffered data when the client closes the + # data connection without a TLS close_notify (a "ragged EOF") while the + # transport is flow-control-paused on slow storage — read() then returns + # b"" and we would otherwise reply 226 for a tail-truncated file, archive + # it, queue it, and forward the corrupt job to the real printer. + # + # Bambu 3MF uploads are ZIP containers whose End-Of-Central-Directory + # record sits at the very end of the file, so any lost tail makes the + # archive impossible to open. Verify that before acknowledging success: + # a truncated file is treated exactly like a failed transfer (426 + + # drop) so the slicer surfaces an actionable send error instead of the + # printer choking on a half-written job later. Only ZIP-based (.3mf) + # uploads are validated — other filetypes keep the prior pass-through + # behaviour. Reading the central directory is O(dir), not O(file): no + # decompression, negligible next to the write loop above. + if filename.lower().endswith(".3mf"): + try: + with zipfile.ZipFile(file_path) as zf: + zf.namelist() + except Exception as e: + logger.error( + "FTP upload of %s is a corrupt/truncated 3MF (%s bytes): %s(%s) — " + "rejecting with 426 instead of archiving a broken file", + filename, + total_received, + type(e).__name__, + e, + ) + try: + file_path.unlink(missing_ok=True) + except OSError: + pass + await self.send(426, "Transfer failed: uploaded 3MF is incomplete or corrupt") + return + # Confirm + notify logger.info("FTP saved file: %s (%s bytes)", file_path, total_received) await self.send(226, "Transfer complete") diff --git a/backend/tests/unit/test_vp_ftp_stor.py b/backend/tests/unit/test_vp_ftp_stor.py index 41ccd8b66..280d4d2f6 100644 --- a/backend/tests/unit/test_vp_ftp_stor.py +++ b/backend/tests/unit/test_vp_ftp_stor.py @@ -10,7 +10,9 @@ up a real TLS/FTP server. """ import asyncio +import io import ssl +import zipfile from unittest.mock import AsyncMock, MagicMock import pytest @@ -18,6 +20,22 @@ import pytest from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession +def _valid_3mf_bytes() -> bytes: + """A minimal but structurally valid ZIP (stands in for a .gcode.3mf). + + Bambu 3MF files are ZIP containers; the streaming STOR path validates the + received file opens as a ZIP before acking 226 (#1896), so happy-path + tests must feed real ZIP bytes rather than arbitrary filler. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("Metadata/slice_info.config", "") + zf.writestr("3D/3dmodel.model", "") + # Pad an entry so the archive spans several 64 KiB read chunks. + zf.writestr("plate_1.gcode", b"G1 X0 Y0\n" * 40000) + return buf.getvalue() + + def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession: """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR can iterate through the chunks without a real TCP connection. @@ -63,8 +81,9 @@ def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession: async def test_stor_writes_payload_to_disk(tmp_path): """Happy path: chunks fed to the data reader land in the upload_dir with the right content + the slicer gets 226.""" - payload = b"X" * (3 * 64 * 1024 + 123) # 3 chunks + a partial one + payload = _valid_3mf_bytes() # spans several 64 KiB chunks, opens as ZIP chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)] + assert len(chunks) > 3 # exercise the multi-chunk read loop session = _make_session(tmp_path, data_chunks=chunks) session.send = AsyncMock() @@ -78,6 +97,54 @@ async def test_stor_writes_payload_to_disk(tmp_path): sent_codes = [args[0][0] for args in session.send.call_args_list] assert 150 in sent_codes # "Opening data connection" assert 226 in sent_codes # "Transfer complete" + assert 426 not in sent_codes + + +@pytest.mark.asyncio +async def test_stor_rejects_truncated_3mf(tmp_path): + """#1896: a .3mf whose tail was lost (uvloop ragged-EOF data loss, or any + other silent truncation) must NOT be acked with 226 — the read loop sees a + clean EOF and no write error, so only a ZIP-integrity check catches it. + Reject with 426, drop the file, and never fire the on_file_received + callback that would archive/queue/forward the corrupt job.""" + payload = _valid_3mf_bytes() + truncated = payload[: len(payload) - 4096] # drop the EOCD-bearing tail + chunks = [truncated[i : i + 65536] for i in range(0, len(truncated), 65536)] + + callback = AsyncMock() + session = _make_session(tmp_path, data_chunks=chunks) + session.on_file_received = callback + session.send = AsyncMock() + + await session.cmd_STOR("truncated.gcode.3mf") + + # Corrupt file dropped, not left in the upload dir. + assert not (session.upload_dir / "truncated.gcode.3mf").exists() + sent_codes = [args[0][0] for args in session.send.call_args_list] + assert 426 in sent_codes + assert 226 not in sent_codes + # The archive/queue/forward callback must never run for a corrupt upload. + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_stor_skips_zip_validation_for_non_3mf(tmp_path): + """The ZIP-integrity gate is scoped to .3mf uploads. A non-3MF file (e.g. + a plain .gcode some slicers still send) is not a ZIP and must keep the + prior pass-through behaviour — 226, not a false-positive 426.""" + payload = b"G1 X0 Y0\n" * 5000 # plain text, deliberately not a ZIP + chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)] + session = _make_session(tmp_path, data_chunks=chunks) + session.send = AsyncMock() + + await session.cmd_STOR("plain.gcode") + + saved = session.upload_dir / "plain.gcode" + assert saved.exists() + assert saved.read_bytes() == payload + sent_codes = [args[0][0] for args in session.send.call_args_list] + assert 226 in sent_codes + assert 426 not in sent_codes @pytest.mark.asyncio diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index f679088d1..3daa6f9e8 100644 --- a/deploy/bambuddy.service +++ b/deploy/bambuddy.service @@ -32,7 +32,9 @@ EnvironmentFile=-INSTALL_PATH/.env Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin" # Server configuration -ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} +# --loop asyncio is required: uvloop's SSL layer can silently truncate VP FTP +# uploads on a ragged client close over slow storage (#1896). Do not remove. +ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio # Restart policy Restart=on-failure diff --git a/install/install.sh b/install/install.sh index 0a2700e10..8d6fb773a 100755 --- a/install/install.sh +++ b/install/install.sh @@ -548,7 +548,8 @@ Environment="DATA_DIR=$DATA_DIR" Environment="LOG_DIR=$LOG_DIR" Environment="TZ=$TIMEZONE" -ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT +# --loop asyncio required: uvloop can truncate VP FTP uploads (#1896) +ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT --loop asyncio Restart=on-failure RestartSec=5 StandardOutput=journal @@ -613,6 +614,9 @@ create_launchd_service() { $BIND_ADDRESS --port $PORT + + --loop + asyncio WorkingDirectory $INSTALL_PATH diff --git a/installers/windows/README.md b/installers/windows/README.md index efaab6949..dcb243da4 100644 --- a/installers/windows/README.md +++ b/installers/windows/README.md @@ -10,7 +10,7 @@ service. No Python or Node installation required on the target machine. - **Data target:** `C:\ProgramData\Bambuddy\data\` (preserved on uninstall by default) - **Logs target:** `C:\ProgramData\Bambuddy\logs\` - **Service:** registered via NSSM, runs as `LocalSystem`, autostart on boot -- **Service command:** `python.exe -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8000` +- **Service command:** `python.exe -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio` (`--loop asyncio` avoids a uvloop TLS bug that can truncate VP FTP uploads, #1896) - **Bundled binaries:** Python 3.13 embeddable, NSSM, ffmpeg static build Browser is the UI. Start Menu shortcut opens `http://localhost:8000`. diff --git a/installers/windows/service/install-service.bat b/installers/windows/service/install-service.bat index eb15f050a..5d8e5b35e 100644 --- a/installers/windows/service/install-service.bat +++ b/installers/windows/service/install-service.bat @@ -29,7 +29,8 @@ REM "service not found" returns non-zero and we want to proceed. REM Register the service. NSSM wraps uvicorn so Windows treats it as a REM proper service (autostart, recovery, supervised restart). -"%NSSM%" install Bambuddy "%PYTHON%" "-m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT%" +REM --loop asyncio required: uvloop can truncate VP FTP uploads (#1896). +"%NSSM%" install Bambuddy "%PYTHON%" "-m uvicorn backend.app.main:app --host 0.0.0.0 --port %PORT% --loop asyncio" if errorlevel 1 ( echo [install-service] nssm install failed exit /b 1 diff --git a/spoolbuddy/install/install.sh b/spoolbuddy/install/install.sh index f8b5aaf46..a69b230ba 100755 --- a/spoolbuddy/install/install.sh +++ b/spoolbuddy/install/install.sh @@ -770,7 +770,8 @@ WorkingDirectory=$INSTALL_PATH EnvironmentFile=$INSTALL_PATH/.env Environment="DATA_DIR=$INSTALL_PATH/data" Environment="LOG_DIR=$INSTALL_PATH/logs" -ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port $BAMBUDDY_PORT +# --loop asyncio required: uvloop can truncate VP FTP uploads (#1896) +ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port $BAMBUDDY_PORT --loop asyncio Restart=on-failure RestartSec=5 StandardOutput=journal