From 1ec22c82dc92da6360ee313219eaf63ae6e00cc9 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 20 Jun 2026 07:43:08 -0600 Subject: [PATCH] Add gated LTspice engine via Wine 11.10 Wire mcltspice into the backend image: WineHQ 11.10 (matching the dev host), i386 multiarch, Mesa software GL, a build-time Wine prefix seeded with the LTspice.ini first-run config, and an entrypoint that starts Xvfb. The LTspice install (exe/lib/examples) mounts from the host; the engine reads LTSPICE_DIR. Gated for now: LTspice v26 stalls at graphics init under headless Wine in the slim image (runs fine on a full desktop). The mount + LTSPICE_DIR are commented in docker-compose.prod.yml so the engine fails fast as 'unavailable' rather than hanging. ngspice is unaffected. --- .gitignore | 3 ++ backend/Dockerfile | 84 +++++++++++++++++++++++++---- backend/docker-entrypoint.sh | 34 ++++++++++++ backend/ltspice-config/LTspice.ini | Bin 0 -> 8962 bytes docker-compose.prod.yml | 10 ++++ 5 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 backend/docker-entrypoint.sh create mode 100644 backend/ltspice-config/LTspice.ini diff --git a/.gitignore b/.gitignore index 14fc18f..7939616 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ docker-compose.override.yml # Notebook user data (tracked separately) notebooks/user/ +# LTspice install -- transferred to prod host via rsync, not tracked (~182MB) +/ltspice/ + # Coverage htmlcov/ .coverage diff --git a/backend/Dockerfile b/backend/Dockerfile index e5ecf8e..33fd7e4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,11 +1,43 @@ FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base -# System dependencies -- ngspice is required for simulation -# NOTE: LTspice requires Wine + a Windows LTspice install, which is only -# available on the dev host (not in this container). The backend gracefully -# degrades -- LTspice engine returns UnsupportedEngineError in Docker. +# System dependencies: +# ngspice -- native SPICE engine +# wine -- runs LTspice (a Windows x86-64 binary) in batch mode +# xvfb -- headless X display; LTspice opens an X connection even with -b +# The LTspice binaries themselves are NOT in the image -- they are mounted from +# the host at /opt/ltspice-src (see docker-compose.prod.yml). Without that mount +# the LTspice engine degrades gracefully and ngspice still works. +# Debian's Wine (8.0) is too old for LTspice v26 -- it opens a GUI event loop +# instead of running -b headless. Use WineHQ 11.10 to match the dev host, where +# LTspice runs fine. i386 multiarch is required (Wine pulls 32-bit components +# even for the x86-64 LTspice.exe). All four packages are pinned to the same +# version because the WineHQ repo sometimes carries mismatched amd64/i386 +# versions, and the wine-devel metapackage demands an exact-version match. +RUN dpkg --add-architecture i386 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ngspice xvfb xauth wget gnupg ca-certificates && \ + mkdir -pm755 /etc/apt/keyrings && \ + wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key && \ + wget -qNP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources && \ + apt-get update && \ + apt-get install -y --install-recommends \ + winehq-devel=11.10~bookworm-1 wine-devel=11.10~bookworm-1 \ + wine-devel-amd64=11.10~bookworm-1 wine-devel-i386=11.10~bookworm-1 && \ + rm -rf /var/lib/apt/lists/* + +# WineHQ installs to /opt/wine-devel/bin; put it on PATH so `wine` resolves +# (mcltspice's runner invokes the bare `wine` command). +ENV PATH="/opt/wine-devel/bin:${PATH}" + +# LTspice v26 blocks on graphics init unless libGL/libEGL are loadable. The +# slim image ships neither, so add Mesa software rendering (llvmpipe) -- Xvfb +# has no GPU, so this is the fallback the dev host reaches via DRI3 anyway. +# Separate layer to keep the heavy WineHQ layer cached. RUN apt-get update && \ - apt-get install -y --no-install-recommends ngspice && \ + apt-get install -y --no-install-recommends \ + libgl1 libglx-mesa0 libgl1-mesa-dri libegl1 libegl-mesa0 \ + libvulkan1 mesa-vulkan-drivers && \ rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -21,7 +53,7 @@ FROM base AS dev # Install in editable mode (source mounted via docker-compose volume) RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv pip install --system -e ".[dev]" + uv pip install --system -e ".[dev,ltspice]" # Copy source for initial build (overridden by volume mount in dev) COPY src/ ./src/ @@ -40,20 +72,50 @@ ENV UV_COMPILE_BYTECODE=1 # Install dependencies first (no source yet -- better caching) RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv pip install --system . + uv pip install --system ".[ltspice]" COPY src/ ./src/ # Re-install with source to get the package registered RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system . + uv pip install --system ".[ltspice]" -# Run as non-root +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Run as non-root. /opt/ltspice is the writable LTSPICE_DIR (holds the Wine +# prefix); /opt/ltspice-src is the read-only mountpoint for the host binaries. RUN useradd --create-home --shell /bin/bash spicebook && \ - mkdir -p /app/notebooks/user /app/notebooks/examples && \ - chown -R spicebook:spicebook /app/notebooks + mkdir -p /app/notebooks/user /app/notebooks/examples /opt/ltspice /opt/ltspice-src && \ + chown -R spicebook:spicebook /app/notebooks /opt/ltspice USER spicebook +# Build a fresh Wine prefix with this image's own Wine version (avoids the +# version-mismatch hazard of shipping a prefix built by a different Wine). +# LTspice batch mode needs no Mono/Gecko, so disable them to skip the download. +ENV WINEARCH=win64 \ + WINEPREFIX=/opt/ltspice/.wine \ + WINEDEBUG=-all \ + WINEDLLOVERRIDES="mscoree=;mshtml=" +# Keep one Xvfb up for the whole init -- xvfb-run tears the display down the +# moment wineboot returns, leaving wineserver's lingering processes to spin on +# a dead X server (fatal XIO errors, hung build). Explicit display + timeouts +# make the step deterministic and unable to hang. +RUN Xvfb :99 -screen 0 1024x768x16 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \ + sleep 2 && \ + DISPLAY=:99 timeout 90 wineboot --init && \ + DISPLAY=:99 timeout 30 wineserver -w; \ + wineserver -k 2>/dev/null; pkill Xvfb 2>/dev/null; \ + rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; true + +# Seed LTspice's settings file so batch (-b) runs skip the one-time first-run +# consent dialog (which otherwise blocks even in batch mode and the sim hangs). +# Captured from a broken-in install; LastRunVersion/CaptureAnalytics are the +# lines that matter. Without this the LTspice engine times out on every run. +COPY --chown=spicebook:spicebook ltspice-config/LTspice.ini \ + /opt/ltspice/.wine/drive_c/users/spicebook/AppData/Roaming/LTspice.ini + EXPOSE 8000 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["python", "-m", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..fe0f7f5 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Wire the host-mounted LTspice binaries into the writable LTSPICE_DIR (which +# holds the Wine prefix baked at image build), start a virtual display for +# Wine, then hand off to the CMD. +set -e + +src=/opt/ltspice-src +dst=${LTSPICE_DIR:-/opt/ltspice} + +# The LTspice install is mounted read-only from the host; symlink the three +# things mcltspice's config references (exe, lib, examples) next to the prefix. +if [ -d "$src" ]; then + for item in LTspice.exe lib examples; do + if [ -e "$src/$item" ]; then + ln -sfn "$src/$item" "$dst/$item" + fi + done +fi + +# LTspice opens an X connection even in batch (-b) mode, so give Wine a +# headless display. Clear any stale lock first -- the build-time Xvfb on the +# same display can leave /tmp/.X99-lock baked into the image, which would make +# a naive "already running?" check skip startup (one container = one Xvfb). +if [ -n "$DISPLAY" ]; then + rm -f "/tmp/.X${DISPLAY#:}-lock" + Xvfb "$DISPLAY" -screen 0 1024x768x16 -nolisten tcp >/tmp/xvfb.log 2>&1 & + # Give the server a moment to come up before the app can ask for it. + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -e "/tmp/.X11-unix/X${DISPLAY#:}" ] && break + sleep 0.3 + done +fi + +exec "$@" diff --git a/backend/ltspice-config/LTspice.ini b/backend/ltspice-config/LTspice.ini new file mode 100644 index 0000000000000000000000000000000000000000..991f683c317ad0368c50f2dc93b55f63d44b0c39 GIT binary patch literal 8962 zcmcJUTT>g!703HIRrwD4nAFxf7|g{FlPV0x%dS}y1UOkLr22y!eNMMsK3XrDH{eF7>^Y4@JA$$t6a2amHI84Jo!ym&R!jXPRgnd0f)zgvmxeYhcsFb@WjN5RpbEFxBE5pC(fO`=BnaR)-lqDhIVp-I5*3!#~Oz*q6L$=gX!%NnDbzeP!U4tke%b ziPKCN-BNM-L)ej(4h1o?c;1wiew9vB$yM5CQ%?ty^HS2D$|KS5B^>%4h~Bm7*|!?j_1(xDwg0}tBN7>dh^ekaFB@Nl^6{l%+YAyC1 z2qOAxU(d$MI;g{?ZM8jvAp3`+4K9$|;!aQLT4i3R*M@7yzFDho_h7k&{wQ9Z>Z%(%$GO?| zMir1ax!3PRT`OjR^L+|zZl%YuV%8;2d0cxH_`9Hy5J8Cy)wpM%=D}5I&{J<{z`hWgx_k*M_ z^5L!0Ym0C^ZoUi$xzTW0zf0og`^h3M^Wtd{4v~d*)}+60^y_mvVzL0y=KfnmnMiva ziYR*=iWno~hz=|f<7W|nkGU_ixkn#)yDDy;8H;#$96m08ibC#Emi4n*N9%aeM#`%w@NHuutdn3v<+Pye0O zeZ_g4i{eGy5&P;N@g{3p_mNG@5x=+U*RvBh3Q49)Sllpts|f(vzt?(3F=ji}NIz5iN#-W%r6dX8t2ni=3!Fh3V3(e{D#`*lwau?8mD`-zRe?Xf~@qdJa2BVXbm)5F7%FD z@IEoYmv|`QcU$lWf^^@Z6%&SgC701}FVT@5$)UDdlzdW2yI@t@J*ULX;~P(<`KQ#C z2xBrLmRpS}qV*?Hb>ICtvkOpAW$I+AzL1o@wa$m%GRq8} z07)+U4SGqf-9J3r{4OO~#B&ad3{3j54<~{QhREl4ZkaSY!fM@P= zD$1tfj-JT76Z$%}?5(mruj;X9x-?xF`Hf6oKfJf4E*m3ZaWAE7wEOU)4jXUmQkMG7 zmf!Z2Rp3OmhkpFDoJi6yk|05S-6U}=%X6#hwfb6B?m?c2cTda@R7rYUsyY9#eDvy^ zPZ(au?*+S4PvwWILl<`sfkY~L`#eVV$M^VnDvPG~NM3XCG0?mX zdMaj$ljbyfTiw2XU;Z!FB