From 9ec541b78f9f7d7f82d07ef4ce1c6ef7217496c7 Mon Sep 17 00:00:00 2001 From: Aanas Sayed Date: Sat, 9 May 2026 22:52:32 +0100 Subject: [PATCH 1/4] fix: prime Wine against :99 and skip Xvfb when caller provides DISPLAY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the X11-forwarding path: - The entrypoint took the caller's $DISPLAY (e.g. host.docker.internal:0 for XQuartz) and tried to start Xvfb on it. Xvfb only binds local display numbers, so it failed immediately and the loop printed "ERROR: Xvfb exited unexpectedly!" on every X11-forwarded run. - The Wine priming step ran with that same caller-supplied DISPLAY, meaning Wine's first-run service init talked to the real host X server in a half-initialised state — exactly the case the comment in the file warned against ("services try to create windows and block forever"). The visible symptom: the first `ltspice` invocation in the resulting shell failed and only the second succeeded. Pin the prime step to DISPLAY=:99 (Xvfb hasn't started yet, so the connection fails fast and Wine init completes cleanly). Then branch: if the caller supplied DISPLAY, use it as-is and skip Xvfb entirely; otherwise start the private Xvfb on :99 as before. Drops the now-stale "first-run Xvfb failure" note from README; the underlying race is gone. --- README.md | 4 --- entrypoint.sh | 71 +++++++++++++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index bfad05d..26f2986 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,10 @@ docker run --rm -it \ aanas0sayed/docker-ltspice:macos-latest ``` -> [!NOTE] -> **First-run Xvfb failure:** On the first `docker run`, Xvfb may fail to start (`ERROR: Xvfb exited unexpectedly`). This is a known issue on macOS. Simply run `ltspice` from the shell prompt and it will work — the container is still usable after the entrypoint error. This only occurs with X11 forwarding. - --- ## Troubleshooting -- **Xvfb fails on first run (macOS):** The entrypoint prints `ERROR: Xvfb exited unexpectedly` on the first container start on macOS. This is a known issue — the container is still usable. Run `ltspice` from the shell and it will work normally. - **File not found / path errors:** LTspice runs inside Wine, so paths must use the Wine `Z:` drive (which maps to `/` on the container). For example, a netlist mounted at `/sim/circuit.net` should be passed as `Z:\\sim\\circuit.net`. - **Permission denied writing the log/raw file:** the container is non-root (uid 1000 by default). If your bind-mount directory isn't writable by that uid, either pass `--user=$(id -u):$(id -g)` so the in-container uid matches the directory owner, or `chmod` the directory so uid 1000 can write to it. - **Slower first call inside a container:** on every fresh `docker run`, the entrypoint copies the Wine prefix template into `/tmp/wine-prefix` (~150 MB). This is sub-second on tmpfs but adds a small fixed cost per container start. diff --git a/entrypoint.sh b/entrypoint.sh index c3c9fe7..ec62a8f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,7 +6,8 @@ # owned by the current uid (required: Wine refuses to use a prefix # not owned by the running uid). # 2. Prime Wine so first-run service init completes without an X server. -# 3. Start Xvfb on DISPLAY :99 so subsequent Wine calls have a display. +# 3. Either start a local Xvfb on :99 (headless / batch mode), or +# respect the caller's DISPLAY if X11 forwarding was set up. # 4. exec the user's command (bash by default). # # Runs as the unprivileged wineuser (uid 1000) by default. Also tolerates @@ -15,7 +16,11 @@ set -e -XVFB_DISPLAY="${DISPLAY:-:99}" +# Caller-supplied DISPLAY (e.g. `-e DISPLAY=host.docker.internal:0` for X11 +# forwarding from XQuartz). Empty if the user didn't pass one — in which +# case we run a private Xvfb on :99. +USER_DISPLAY="${DISPLAY:-}" +XVFB_DISPLAY=":99" DISPLAY_NUM="${XVFB_DISPLAY#:}" XVFB_RESOLUTION="${XVFB_RESOLUTION:-1024x768x16}" @@ -44,38 +49,44 @@ fi LTSPICE_EXE="${WINEPREFIX}/drive_c/Program Files/ADI/LTspice/LTspice.exe" -# ── 2. Prime Wine (no X server yet) ─────────────────────────────────────── +# ── 2. Prime Wine against a guaranteed-non-existent display ─────────────── # The first wine invocation after a fresh container start triggers internal # service/init work (winebth, shell32, etc.). If an X server IS available, -# those services try to create windows and block forever. Running LTspice -# once with DISPLAY pointing to a non-existent server makes them fail fast -# and complete their init. The second run then works cleanly. -echo "[entrypoint] Priming Wine (display ${XVFB_DISPLAY}, no X yet)..." -export DISPLAY="${XVFB_DISPLAY}" -wine "$LTSPICE_EXE" -b 2>/dev/null || true -wineserver --wait 2>/dev/null || true +# those services try to create windows and either block forever or finish +# in a half-initialised state that breaks the next GUI launch. Pin +# DISPLAY to :99 here — Xvfb hasn't started yet, so the connection fails +# fast and the services complete their non-GUI init cleanly. We override +# any caller-supplied DISPLAY for this single step only. +echo "[entrypoint] Priming Wine (no X yet)..." +DISPLAY=":99" wine "$LTSPICE_EXE" -b 2>/dev/null || true +DISPLAY=":99" wineserver --wait 2>/dev/null || true echo "[entrypoint] Wine primed." -# ── 3. Start Xvfb ───────────────────────────────────────────────────────── -rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true -echo "[entrypoint] Starting Xvfb on ${XVFB_DISPLAY} (${XVFB_RESOLUTION})" -Xvfb "${XVFB_DISPLAY}" -screen 0 "${XVFB_RESOLUTION}" -nolisten tcp 2>/dev/null & -XVFB_PID=$! - -# Wait for the X server socket to appear (max 10 s) -for i in $(seq 1 20); do - if [ -e "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; then - echo "[entrypoint] Xvfb ready (PID ${XVFB_PID})." - break - fi - if ! kill -0 "$XVFB_PID" 2>/dev/null; then - echo "[entrypoint] ERROR: Xvfb exited unexpectedly!" >&2 - break - fi - sleep 0.5 -done - -export DISPLAY="${XVFB_DISPLAY}" +# ── 3. Pick the runtime DISPLAY ─────────────────────────────────────────── +# If the caller supplied DISPLAY (X11 forwarding, e.g. host.docker.internal:0 +# from XQuartz on macOS), use it as-is and skip Xvfb. Otherwise start a +# private Xvfb on :99 for headless / batch operation. +if [ -n "$USER_DISPLAY" ]; then + echo "[entrypoint] Using caller-provided DISPLAY=${USER_DISPLAY} (skipping Xvfb)" + export DISPLAY="$USER_DISPLAY" +else + rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true + echo "[entrypoint] Starting Xvfb on ${XVFB_DISPLAY} (${XVFB_RESOLUTION})" + Xvfb "${XVFB_DISPLAY}" -screen 0 "${XVFB_RESOLUTION}" -nolisten tcp 2>/dev/null & + XVFB_PID=$! + for i in $(seq 1 20); do + if [ -e "/tmp/.X11-unix/X${DISPLAY_NUM}" ]; then + echo "[entrypoint] Xvfb ready (PID ${XVFB_PID})." + break + fi + if ! kill -0 "$XVFB_PID" 2>/dev/null; then + echo "[entrypoint] ERROR: Xvfb exited unexpectedly!" >&2 + break + fi + sleep 0.5 + done + export DISPLAY="${XVFB_DISPLAY}" +fi # ── 4. Hand off to the user's command ────────────────────────────────────── exec "$@" From 77aa50c14fbb5929faefa1294a7f97cccc118bfb Mon Sep 17 00:00:00 2001 From: Aanas Sayed Date: Sat, 9 May 2026 23:00:13 +0100 Subject: [PATCH 2/4] ci: allow empty rolling_tag input to publish only the dated devel tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default stays "macos-latest" so existing behavior is unchanged. Setting rolling_tag to an empty string in the workflow_dispatch form now skips the rolling pointer push and only publishes the immutable devel-YYYYMMDD-wine-X.Y tag — useful for trial builds that should not move macos-latest. --- .github/workflows/macos-latest.yml | 39 +++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/macos-latest.yml b/.github/workflows/macos-latest.yml index 7898b87..0a627e4 100644 --- a/.github/workflows/macos-latest.yml +++ b/.github/workflows/macos-latest.yml @@ -9,11 +9,14 @@ name: Publish macos-latest (Wine 9) # `macos-latest` tag stays pinned to Wine 9 until upstream #58084 ships. # # Tags published on every run: -# - devel-YYYYMMDD-wine- (immutable, audit trail) -# - macos-latest (rolling pointer, can be overridden) +# - devel-YYYYMMDD-wine- (immutable, audit trail — always pushed) +# - (rolling pointer; defaults to +# macos-latest, blank to skip) # # Run via the Actions tab → "Run workflow", optionally overriding the -# Wine version pin (e.g. to bump to a newer 9.x point release). +# Wine version pin (e.g. to bump to a newer 9.x point release). Leave +# rolling_tag empty to publish only the dated devel tag — useful for +# trial builds that should not move the macos-latest pointer. on: workflow_dispatch: @@ -23,8 +26,8 @@ on: required: true default: "9.0.0.0~bookworm-1" rolling_tag: - description: "Rolling tag to publish alongside the dated devel tag" - required: true + description: "Rolling tag to publish alongside the dated devel tag (leave blank to skip)" + required: false default: "macos-latest" jobs: @@ -42,9 +45,23 @@ jobs: WINE_SHORT=$(echo "${{ inputs.wine_version }}" | cut -d. -f1-2) DEVEL_TAG="devel-${DATE}-wine-${WINE_SHORT}" IMAGE=aanas0sayed/docker-ltspice - echo "devel=${IMAGE}:${DEVEL_TAG}" >> "$GITHUB_OUTPUT" - echo "rolling=${IMAGE}:${{ inputs.rolling_tag }}" >> "$GITHUB_OUTPUT" - echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}" + # The dated devel tag is always pushed; the rolling pointer is + # only included when the input is non-empty. Multi-line tag + # output uses GITHUB_OUTPUT's heredoc form. + { + echo "list<> "$GITHUB_OUTPUT" + + if [ -n "${{ inputs.rolling_tag }}" ]; then + echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}" + else + echo "Will publish: ${IMAGE}:${DEVEL_TAG} (no rolling tag)" + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -72,15 +89,13 @@ jobs: chmod +x test.sh ./test.sh docker-ltspice:macos-ci - - name: Push image with both tags + - name: Push image uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64 push: true - tags: | - ${{ steps.tags.outputs.devel }} - ${{ steps.tags.outputs.rolling }} + tags: ${{ steps.tags.outputs.list }} build-args: | WINE_BRANCH=stable WINE_VERSION=${{ inputs.wine_version }} From 6058dbcd0ea600c6055c3377963d9c9a3c91a0e6 Mon Sep 17 00:00:00 2001 From: Aanas Sayed Date: Sun, 10 May 2026 00:25:05 +0100 Subject: [PATCH 3/4] fix: drop DISPLAY=:99 from image ENV so caller-DISPLAY detection works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's entrypoint logic checked USER_DISPLAY="${DISPLAY:-}" if [ -n "$USER_DISPLAY" ]; then ... skip Xvfb ... to decide whether the caller had set DISPLAY (X11 forwarding) or not. But the Dockerfile baked DISPLAY=:99 into ENV, so $DISPLAY was *always* non-empty inside the container even when the caller passed nothing. The entrypoint mistook the image default for a caller value and skipped Xvfb on every run — Wine then had no X server to talk to and the simulation produced a perm-denied / no-log failure (visible in the macos-latest workflow run as "Using caller-provided DISPLAY=:99 (skipping Xvfb)"). Drop DISPLAY from ENV; set it inline only on the build-time wine commands that actually need it. Now an unset DISPLAY at runtime correctly means "headless, start Xvfb." --- Dockerfile | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 24aa499..406cc5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,21 +68,26 @@ RUN groupadd -g 1000 wineuser \ # WINEPREFIX deliberately points into /tmp (tmpfs in callers) — the # entrypoint materialises it from the on-image template on every fresh # container start. The build-time prefix lives at /opt/wineprefix-template. +# DISPLAY is intentionally NOT set here — the entrypoint uses an unset +# DISPLAY as the signal "no caller-supplied X server, start a private +# Xvfb on :99." Setting it as an image-level ENV would defeat that +# signal: the entrypoint can't distinguish image-default from +# caller-provided. ENV HOME=/home/wineuser \ WINEPREFIX=/tmp/wine-prefix \ - WINEDEBUG=-all \ - DISPLAY=:99 + WINEDEBUG=-all # ── 7. Install LTspice into a build-time prefix (as wineuser) ───────────── USER wineuser WORKDIR /home/wineuser RUN Xvfb :99 -screen 0 1024x768x24 & \ sleep 2 \ - && WINEPREFIX=/home/wineuser/.wine WINEDLLOVERRIDES="mscoree,mshtml=" \ - wineboot --init \ + && DISPLAY=:99 WINEPREFIX=/home/wineuser/.wine \ + WINEDLLOVERRIDES="mscoree,mshtml=" wineboot --init \ && WINEPREFIX=/home/wineuser/.wine wineserver --wait \ && wget -q -O /tmp/LTspice64.msi https://ltspice.analog.com/software/LTspice64.msi \ - && WINEPREFIX=/home/wineuser/.wine wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \ + && DISPLAY=:99 WINEPREFIX=/home/wineuser/.wine \ + wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \ && WINEPREFIX=/home/wineuser/.wine wineserver --wait \ && rm /tmp/LTspice64.msi \ && rm -rf /tmp/.wine-* /tmp/wine-* \ From b472280250f2fcdaad4afc1205dcb38d01e2790d Mon Sep 17 00:00:00 2001 From: Aanas Sayed Date: Sun, 10 May 2026 00:38:39 +0100 Subject: [PATCH 4/4] ci: gate rolling-tag publish on a boolean toggle, not empty string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's workflow_dispatch UI substitutes the configured default back in when a string field is left blank, so the previous "leave rolling_tag empty to skip" mechanism never actually skipped — every run still pushed macos-latest. Replace with a publish_rolling boolean input (defaults to true so the common case is unchanged); rolling_tag is only consulted when the toggle is true. --- .github/workflows/macos-latest.yml | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/macos-latest.yml b/.github/workflows/macos-latest.yml index 0a627e4..bcd389d 100644 --- a/.github/workflows/macos-latest.yml +++ b/.github/workflows/macos-latest.yml @@ -10,13 +10,12 @@ name: Publish macos-latest (Wine 9) # # Tags published on every run: # - devel-YYYYMMDD-wine- (immutable, audit trail — always pushed) -# - (rolling pointer; defaults to -# macos-latest, blank to skip) +# - (rolling pointer; only pushed when +# publish_rolling is true) # -# Run via the Actions tab → "Run workflow", optionally overriding the -# Wine version pin (e.g. to bump to a newer 9.x point release). Leave -# rolling_tag empty to publish only the dated devel tag — useful for -# trial builds that should not move the macos-latest pointer. +# Run via the Actions tab → "Run workflow". Toggle "publish_rolling" off +# to publish only the dated devel tag — useful for trial builds that +# should not move the macos-latest pointer. on: workflow_dispatch: @@ -25,8 +24,13 @@ on: description: "Debian package version pin for wine-stable (e.g. 9.0.0.0~bookworm-1)" required: true default: "9.0.0.0~bookworm-1" + publish_rolling: + description: "Also publish the rolling tag (uncheck for trial builds)" + type: boolean + required: false + default: true rolling_tag: - description: "Rolling tag to publish alongside the dated devel tag (leave blank to skip)" + description: "Rolling tag name (only used when publish_rolling is checked)" required: false default: "macos-latest" @@ -46,18 +50,19 @@ jobs: DEVEL_TAG="devel-${DATE}-wine-${WINE_SHORT}" IMAGE=aanas0sayed/docker-ltspice # The dated devel tag is always pushed; the rolling pointer is - # only included when the input is non-empty. Multi-line tag - # output uses GITHUB_OUTPUT's heredoc form. + # only included when the publish_rolling toggle is true. The + # tag list is emitted via GITHUB_OUTPUT's heredoc form so the + # next step can pass it straight to docker/build-push-action. { echo "list<> "$GITHUB_OUTPUT" - if [ -n "${{ inputs.rolling_tag }}" ]; then + if [ "${{ inputs.publish_rolling }}" = "true" ]; then echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}" else echo "Will publish: ${IMAGE}:${DEVEL_TAG} (no rolling tag)"