Two stacked failures on the cad node, three days apart, surfacing as one outage. (1) https://cad.taild4189d.ts.net:8443/mcp returned HTTP 502 because the freecad-mcp proxy container had been Exited (128) for three days and its restart: unless-stopped policy never fired. (2) With the proxy back, execute_code still timed out — and this time the remedy in 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog was not sufficient. All commands and outputs below were verified in-session on 2026-09-05.

The two traps in one line each

restart: unless-stopped does NOT guarantee a container comes back — if the containerd shim dies before the task is created, there is no task to restart and the container stays dead indefinitely with RestartCount: 0. Killing the modal dialog does NOT always unwedge Qt — after ~3 days behind a modal, closing the window left the event loop just as dead, with gui_dispatch still self-reporting healthy.

Host: telep-mainframe, containers freecad-mcp (proxy) and freecad (GUI) — the cad node stack from 2026-08-26-freecad-cad-workstation. Third chapter after 2026-09-02-freecad-mcp-rpc-refused-gui-not-running and 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog.


Symptom chain

  • Claude Code reported the freecad MCP server as failed at startup.
  • https://cad.taild4189d.ts.net:8443/mcp returned HTTP 502 on POST /mcp.
  • https://cad.taild4189d.ts.net:8443/ returned HTTP 502 as well.

A 502 on both paths is the discriminator: it is not a routing/path problem inside the MCP app, it is the whole backend being absent. Note this is a different fingerprint from the two 2026-09-02 failures — those produced Errno 111 and RemoteDisconnected on :9875. A 502 on :8443 means the tailscale serve mount is up and its target is not.


Failure 1 — the proxy container had been dead for three days

Diagnosis — the serve mount was fine, the target was empty

tailscale --socket=/var/run/tailscale-cad/tailscaled.sock serve status
:8443  ->  http://127.0.0.2:9876

The mount was intact and correct. Nothing was listening on 9876. Serve was faithfully proxying to a hole, which is exactly what produces a 502.

Note the loopback: 127.0.0.2, not 127.0.0.1

2026-08-26-freecad-cad-workstation documents this mount as 127.0.0.1:9876; the live config on 2026-09-05 is http://127.0.0.2:9876, i.e. it was migrated to the 127.0.0.2 convention from 2026-08-31-tailnet-plaintext-port-hardening. Same class of .1-vs-.2 split that silently 502’d Homepage’s :8450 mount (see 2026-09-02-lg-tv-network-control-presence). When a tailnet mount 502s on this host, read serve status and check which loopback the backend actually binds — do not trust the note’s port map.

The next command found it:

docker ps -a
freecad-mcp    Exited (128) 3 days ago

docker ps alone would have hidden this — an exited container is invisible without -a. That one missing flag is the whole gap between “the proxy is broken” and “the proxy is not running.”

Root cause — the shim failed before the task existed

docker inspect freecad-mcp
FieldValue
ExitCode128
Errorfailed to create task for container: failed to create shim task: ttrpc: closed
OOMKilledfalse
FinishedAt2026-09-02T03:27:08Z
RestartCount0

Two things to read out of that table:

  1. OOMKilled: false rules out the reflexive memory explanation. This was not a resource kill.
  2. RestartCount: 0 is the proof that the restart policy never even tried. The compose service carries restart: unless-stopped, yet Docker made zero restart attempts across three days.

Why: the failure is failed to create task ... failed to create shim task: ttrpc: closed — the containerd shim died during task creation, so a running task was never established. Docker’s restart policy supervises a task that has started and then exited; with no task ever created there is nothing for the policy to act on. The container simply comes to rest in Exited (128) and stays there.

Note also the timestamp: FinishedAt is 2026-09-02T03:27:08Z — the same night as the FreeCAD crash behind 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog. Both containers were disturbed in the same event.

restart: unless-stopped is not a guarantee of liveness

This is the reusable lesson and it generalises well beyond FreeCAD. A container with a restart policy can stay dead indefinitely if the runtime failed below the task layer. Health-checking by “does it have a restart policy?” is not health-checking.

Whenever a tailnet-served service returns 502, run docker ps -a early and look for Exited containers. It costs one command and it is the single highest-yield probe for this failure class.

Fix

docker start freecad-mcp

It came up clean and 9876 began listening immediately. No rebuild, no recreate, no compose changes — the image and config were never the problem.


Failure 2 — the GUI thread was STILL wedged, and this is a CORRECTION

With the proxy restored, the MCP tools answered again — and landed straight into the second failure mode, the one 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog already documents:

// mcp__freecad__get_rpc_status
{"rpc_server": "running", "gui_dispatch": {"state": "healthy", "task_id": 0}}

…while every execute_code failed with GUI dispatch timed out after 90s.

Isolating it away from the MCP proxy

Because the proxy had just been the culprit, the first job was to prove the proxy was not still implicated. Direct XML-RPC to 127.0.0.1:9875, bypassing freecad-mcp entirely:

Direct XML-RPC callResult
ping()returned in 0.0 s
list_documents()returned in 0.0 s — value []
execute_code("print(42)")90 s timeout

The RPC thread was completely healthy; only GUI-dispatched work was blocked. This cleanly exonerated the proxy and confirmed the fault was inside the FreeCAD process, exactly as the 2026-09-02 note predicted.

The dialog was there — but DISPLAY is :1, not :0

docker exec -u abc -e DISPLAY=:1 freecad xwininfo -root -tree
0x400075 "Document Recovery"

The documented culprit, still standing — the same window, alive since the 2026-09-02 crash.

That note’s commands all use DISPLAY=:0. The correct value is DISPLAY=:1. A :0 socket does exist in the container, which is why the wrong value looks plausible, but xwininfo cannot open it — you get a failure to connect and it reads as “no X server / no windows”, not as “wrong display number”. Use :1.

The correction that matters — closing the window did NOT fix it

The 2026-09-02 remedy is dismiss the dialog, and explicitly do not restart. That remedy was tried here and it was not sufficient.

docker exec -u abc -e DISPLAY=:1 freecad xdotool windowclose 4194421   # 4194421 == 0x400075

The window was removed — verified gone from a re-run of xwininfo -root -tree. And then:

  • execute_code still timed out at 90 s,
  • with no dialog present anywhere in the window tree,
  • and gui_dispatch still self-reporting healthy.

The Qt event loop stayed wedged. After roughly three days parked behind that modal, removing the window did not revive the loop. The dialog was the cause of the wedge, but by this point it was no longer the thing holding it — destroying it freed nothing.

Do not stop at "the dialog is gone"

xwininfo showing a clean window tree is not proof the fix worked. The only proof is execute_code returning. If it still times out with no dialog on screen, the event loop is dead and no amount of xdotool will bring it back — escalate to the stash-and-restart below.

What actually worked — stash the recovery snapshots, THEN restart

Inside the container as user abc:

# 1. Move (never delete) every recovery snapshot out of FreeCAD's sight
mkdir -p /config/.cache/FreeCAD/recovery-stash-2026-09-05
mv /config/.cache/FreeCAD/v1-1/Cache/FreeCAD_Doc_*/ \
   /config/.cache/FreeCAD/recovery-stash-2026-09-05/
# 2. Only then restart
docker restart freecad

Result: RPC was back in ~10 s and execute_code returned in 0.0 s.

There were 10 FreeCAD_Doc_* directories, dated Aug 31 – Sep 2.

Why the stash is the load-bearing step

This resolves the apparent contradiction with the earlier note. The reason 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog says “do NOT restart” is real and still correct as stated: a restart re-raises the Document Recovery dialog while recovery files are present, dropping you straight back into the wedge you were trying to escape. Restarting alone is a loop.

Moving the recovery files out first breaks the loop. With nothing in Cache/FreeCAD_Doc_*/, FreeCAD has no recovery to offer, comes up without the modal, and the fresh process gets a working event loop. The restart is not the fix — the stash is the fix, and the restart is how you collect it.

MOVE the recovery dirs — NEVER delete them

Those snapshots are the user’s only copy of any unsaved work. mv them into a dated stash directory; do not rm, and do not let a cleanup script near them. The stash is cheap; the data is not replaceable.

For Agents — when is the restart safe?

list_documents() returned [] — nothing was open in the GUI. That is what made the restart safe to run. Check list_documents() before restarting: an empty list means no in-memory document state is at risk. A non-empty list means live documents whose unsaved state a restart would discard — stop and ask the human.

Open item — 10 stashed recovery dirs awaiting a decision

Needs the user's call

Path: /config/.cache/FreeCAD/recovery-stash-2026-09-05/ (inside the freecad container) Contents: 10 FreeCAD_Doc_* directories, dated Aug 31 – Sep 2 Status: untouched, nothing deleted.

They are either restored (move a dir back under /config/.cache/FreeCAD/v1-1/Cache/ and let FreeCAD offer recovery — expect the modal, so do it deliberately) or cleaned up. Until the user decides, leave the stash exactly where it is.


cad host access — confirmed, and the practical route

Confirmed: cad is NOT Tailscale-SSH-enabled

Re-verified 2026-09-05. Only telep-mainframe and telep-router carry Tailscale SSH. cad (100.120.203.1) is online in tailscale status but is not in the SSH-enabled machine list — as recorded in 2026-09-02-freecad-mcp-rpc-refused-gui-not-running.

This does not mean the box is unmanageable remotely. ssh 100.120.203.1 works — because cad is not a separate machine. It is a userspace tailscaled sidecar node running on telep-mainframe, and every per-service sidecar node (cad, knowledgebase, drive, chatcut, orcaslicer, bambuddy, …) lives on that same bare-metal host. SSHing to any of their tailnet IPs lands you on telep-mainframe, which is where the docker containers are anyway.

This is consistent with the userspace-networking raw forwarder documented in 2026-08-31-tailnet-plaintext-port-hardening — inbound TCP on a sidecar node’s tailnet IP is forwarded to 127.0.0.1 on the same port, so port 22 arrives at the mainframe’s own sshd.

ssh 100.120.203.1                 # lands on telep-mainframe
ssh levander@100.115.209.87       # telep-mainframe directly — same destination
cd /home/levander/freecad         # the compose stack

Both routes reach the same place. Use whichever is at hand; do not conclude from “cad has no Tailscale SSH” that this needs a person on site. Every command in this note was run remotely.


Small gotcha — command -v with multiple arguments

Chasing whether xdotool was even installed, this looked like a definitive “no”:

command -v xdotool xwininfo wmctrl     # ← misleading

Under the container’s dash, command -v only reports on the FIRST argument. The extra names are silently ignored, so a missing first binary makes every tool in the list look absent. xdotool had been installed all along.

for b in xdotool xwininfo wmctrl; do command -v "$b" || echo "MISSING: $b"; done

Use a loop, or one command -v per binary. This wasted a detour into “we need to install a window manager tool” on a container that already had one.


Revised triage flowchart

Supersedes the flowchart in 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog — same tree, extended at both ends.

FreeCAD MCP unusable
│
├─ HTTP 502 on https://cad...:8443/  (both /mcp and /)
│     → the serve mount is up, the backend is not
│       1. tailscale --socket=/var/run/tailscale-cad/tailscaled.sock serve status
│          (confirm target — it is 127.0.0.2:9876, NOT .1)
│       2. docker ps -a          ← the -a is essential
│       3. Exited? docker inspect <c> → RestartCount:0 + "ttrpc: closed"
│                 = the shim died pre-task, the restart policy never fired
│       4. docker start freecad-mcp
│
├─ get_rpc_status fails (Errno 111 / connection refused)
│     → RPC/GUI is DOWN → docker restart freecad
│       [[2026-09-02-freecad-mcp-rpc-refused-gui-not-running]]
│
└─ get_rpc_status "healthy" but execute_code times out at 90s
      → GUI thread blocked. Isolate first: direct XML-RPC to 127.0.0.1:9875.
        ping()/list_documents() fast + execute_code timing out
        ⇒ the fault is in FreeCAD, not the proxy.
        1. docker exec -u abc -e DISPLAY=:1 freecad xwininfo -root -tree   (:1 !!)
        2. dialog present, wedged for MINUTES → xdotool windowclose <id>, retry
        3. NO dialog, or dialog closed and execute_code STILL times out
           (i.e. wedged for HOURS/DAYS) → the event loop is dead:
             a. list_documents() must be []          ← safety check
             b. mv /config/.cache/FreeCAD/v1-1/Cache/FreeCAD_Doc_*/ \
                   /config/.cache/FreeCAD/recovery-stash-<date>/   (MOVE, never rm)
             c. docker restart freecad
           Restarting WITHOUT (b) re-raises the dialog and re-wedges.