fix(windows): bundle vcruntime140_1.dll so greenlet loads on fresh Win10 (#2474)

A clean Windows 10 install crashed on startup: init_db() -> SQLAlchemy async
engine -> greenlet failed with "DLL load failed while importing _greenlet:
The specified module could not be found", so uvicorn never bound :8000 and the
dashboard refused all connections while the NSSM service still showed running.

greenlet's _greenlet.pyd is C++ and needs vcruntime140_1.dll, which the
python.org embeddable distribution does not ship (it includes only
vcruntime140.dll, enough for the pure-C python313.dll). Machines with the VC++
2015-2022 redistributable already installed have the DLL in System32, which
masked the bug in testing.

Stage vcruntime140_1.dll and msvcp140.dll next to python.exe at build time,
from a vendored copy or the runner's System32, failing loudly if absent. The
Inno Setup [Files] step already copies staging\python\* recursively.
This commit is contained in:
maziggy
2026-07-07 07:50:08 +02:00
parent af867c0392
commit 90a7d68d4a
2 changed files with 43 additions and 0 deletions
+1
View File
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.5b2] - Unreleased
### Fixed
- **Windows installer: fresh install fails to start — "connection refused", nothing listening on port 8000 (#2474, reporter @fangme)** — On a clean Windows 10 machine, installing the `.exe` left the Bambuddy service showing "running" in services.msc but the dashboard refused every connection (localhost, IP, and hostname), and `netstat -ano` showed nothing on :8000. **Root cause — bottom of a 100k-line `service-stderr.log` traceback:** `init_db()` → SQLAlchemy async engine → `greenlet_spawn` → `ValueError: the greenlet library is required to use this function. DLL load failed while importing _greenlet: The specified module could not be found.` The 100k repeated `merged_lifespan` frames above it are just FastAPI's nested-lifespan stack unwinding — noise. The real failure: greenlet's `_greenlet.pyd` couldn't load, SQLAlchemy's async engine couldn't start, the FastAPI lifespan raised, and uvicorn **never bound the port** — so the supervised process stays up (NSSM sees it running) while the app itself has crashed on startup. **Why greenlet specifically:** the Win32 error 126 ("The specified module could not be found") on a `.pyd` that pip installed successfully means the module is present but a *dependency DLL* is missing. greenlet's extension is **C++** and needs `vcruntime140_1.dll` (table-based exception handling); the python.org **embeddable** distribution the installer bundles ships `vcruntime140.dll` but **not** `vcruntime140_1.dll`. `python313.dll` is pure C and only needs the former, which is exactly why python.exe starts and runs all the way to `init_db` before greenlet is the first thing to need the missing C++ runtime. On machines that already have the VC++ 2015-2022 redistributable installed (the maintainer's box, CI runners) `vcruntime140_1.dll` is in System32 and everything works — masking the bug until a truly fresh Win10 box hit it. **Fix:** the installer build now ships the C++ runtime app-locally — `vcruntime140_1.dll` and `msvcp140.dll` are staged next to `python.exe` (where `vcruntime140.dll` already lives), sourced from a vendored copy if present or the build runner's System32 otherwise, failing the build loudly if neither has them. `installers/windows/build.py` only; the Inno Setup `[Files]` step already copies `staging\python\*` recursively so the new DLLs are packaged automatically. **Workaround for the current build:** install the "Microsoft Visual C++ 2015-2022 Redistributable (x64)" from Microsoft, then restart the Bambuddy service — that puts `vcruntime140_1.dll` in System32 where the embedded Python finds it.
- **Virtual Printer "bind interface" dropdown is empty on macOS** — Adding a Virtual Printer on macOS showed no interfaces to bind to. `get_network_interfaces()` only routed Windows to the cross-platform psutil path; macOS fell into the Linux branch, which uses the Linux-only `SIOCGIFADDR`/`SIOCGIFNETMASK` ioctls (`0x8915`/`0x891B`). macOS/BSD have `fcntl` but different ioctl numbers and sockaddr layout, so every per-interface ioctl raised `OSError` and the function silently returned an empty list (and `get_all_interface_ips()`, which has no `ip` binary to fall back to on macOS, inherited the empty result). Interface enumeration now routes **all** non-Linux platforms (macOS, BSD, Windows) through psutil, which returns each interface's name + IPv4 + netmask and filters loopback/link-local/down adapters while keeping real LAN and VPN (utun/Tailscale) interfaces bindable. Linux keeps its existing ioctl path unchanged.
- **Native install script fails on macOS with Homebrew/venv permission errors** — On macOS the installer mixed root-only steps (defaulting to `/opt/bambuddy`, which nudged users into `sudo ./install.sh`) with steps that must **not** run as root: `brew install` hard-refuses to run as root (aborting the script mid-way), and any venv / `node_modules` created by root can't be managed by the launchd agent (which runs as the user), producing permission errors on `pip`/`npm`. The macOS path is now fully rootless: the script refuses to run under `sudo` on macOS with an actionable message, defaults the install directory to `~/bambuddy` (user-owned, no `/opt` write), and the download/venv/frontend/env/directory steps no longer shell out to `sudo` on macOS. A custom `--path` under a root-owned parent still works — the script elevates only to create+chown that one directory to the user, then continues rootless. Linux behaviour (service user + systemd) is unchanged.
- **External camera "connection lost" when the snapshot URL serves a non-JPEG image (#1902)** — An external camera configured in HTTP-snapshot mode failed to load in Bambuddy with a repeating "connection lost", even though the camera URL rendered fine when opened directly in a browser. The log showed `Snapshot does not appear to be JPEG` on every polled frame followed by the stream ending. Root cause: `_capture_snapshot` returned the fetched bytes even when they weren't JPEG, and the MJPEG stream wraps every part with a hard-coded `Content-Type: image/jpeg` boundary — so a camera serving PNG/WebP/BMP stills (common on IP cameras and reverse-proxied snapshot endpoints) sent the browser a non-JPEG payload labelled as JPEG, which the browser rejected, tearing down the whole `multipart/x-mixed-replace` stream. `_capture_snapshot` now transcodes non-JPEG stills to JPEG (via OpenCV, already a dependency) before streaming; genuine JPEG snapshots keep their byte-for-byte fast path, and truly undecodable responses (HTML error pages, auth redirects) fall back to the previous raw-return behaviour with a clearer one-off warning instead of a per-frame log flood. Fixes browser playback and keeps the JPEG-only downstream (plate detection, Obico, finish photo) working for these cameras.
+42
View File
@@ -61,6 +61,19 @@ FFMPEG_URL = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffm
# get-pip.py for bootstrapping pip into the embedded distribution
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
# C++ runtime DLLs the embeddable distribution does NOT ship. The python.org
# embeddable zip includes vcruntime140.dll but not vcruntime140_1.dll or
# msvcp140.dll. python313.dll is pure C and only needs vcruntime140.dll, so
# python.exe starts fine — but greenlet's _greenlet.pyd is C++ and needs
# vcruntime140_1.dll (table-based exception handling). On a fresh Windows box
# that never had the VC++ 2015-2022 redistributable installed, loading greenlet
# fails with "DLL load failed ... The specified module could not be found",
# SQLAlchemy's async engine can't start, init_db() raises, and the app never
# binds its port — the service shows "running" but the dashboard refuses the
# connection (issue #2474). These runtime DLLs are redistributable, so we ship
# them app-locally next to python.exe (where vcruntime140.dll already lives).
VCRUNTIME_DLLS = ("vcruntime140_1.dll", "msvcp140.dll")
def log(msg: str) -> None:
print(f"[build] {msg}", flush=True)
@@ -143,6 +156,34 @@ def stage_embedded_python() -> Path:
return target
def stage_vcruntime(python_dir: Path) -> None:
"""Ship the C++ runtime DLLs the embeddable distribution omits.
Placed next to python.exe so the extension-module loader (which searches the
interpreter's own directory) finds them without a redistributable install on
the target machine. Prefers vendored copies under installers/windows/vendor/
for reproducibility; falls back to the build runner's System32, where the
redistributable runtime lives. Fails loudly if neither source has them, so a
misconfigured build machine is caught here instead of by end users.
"""
vendor = INSTALLER_DIR / "vendor"
system32 = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32"
for dll in VCRUNTIME_DLLS:
dst = python_dir / dll
if dst.exists():
log(f"{dll} already present in embedded Python")
continue
src = vendor / dll if (vendor / dll).exists() else system32 / dll
if not src.exists():
raise RuntimeError(
f"required C++ runtime DLL not found: looked in {vendor} and {system32} "
f"for {dll}. Install the Microsoft Visual C++ 2015-2022 Redistributable "
f"(x64) on the build machine, or vendor {dll} under installers/windows/vendor/."
)
log(f"staging {dll} from {src}")
shutil.copy(src, dst)
def install_requirements(python_dir: Path) -> None:
"""Install Bambuddy's requirements.txt into the embedded Python."""
py = python_dir / "python.exe"
@@ -370,6 +411,7 @@ def main() -> int:
STAGING.mkdir(parents=True, exist_ok=True)
python_dir = stage_embedded_python()
stage_vcruntime(python_dir)
if not args.skip_pip:
install_requirements(python_dir)