The worker runs inside WSL2 Linux, but Tailscale is installed on the Windows host. WSL2 sits behind Windows' internal NAT with its own IP, so by default the client can't reach the worker over your tailnet. Pick one of the two networking fixes below.
Open your WSL2 distro (Ubuntu) and run:
# uv: the same Python manager as the client, so client/worker versions match
curl -LsSf https://astral.sh/uv/install.sh | sh
exec $SHELL # reload PATH
# clone/copy the project onto the PC, then:
cd ~/simfarm # wherever you put it
uv sync # installs simfarm + deps into .venv (Python 3.13)
# your simulation dependencies must live in the SAME env, e.g.:
uv pip install numpy pandas scipy numba
# for GPU/ML: install the CUDA build of torch that matches your driver
# uv pip install torch --index-url https://download.pytorch.org/whl/cu124Verify CUDA is visible inside WSL2 (only if you have an NVIDIA GPU):
nvidia-smi # should list your GPU; simfarm auto-detects itOn the Windows host, edit C:\Users\<you>\.wslconfig (create if absent):
[wsl2]
networkingMode=mirroredThen from an admin PowerShell: wsl --shutdown and reopen WSL2.
Now WSL2 shares the Windows network stack and the host's Tailscale IP — the
worker on 0.0.0.0:8765 is reachable at the PC's tailscale hostname directly.
Find the WSL2 internal IP (run inside WSL2): ip addr show eth0 | grep inet.
Then from an admin PowerShell on Windows, forward the port:
netsh interface portproxy add v4tov4 `
listenaddress=0.0.0.0 listenport=8765 `
connectaddress=<WSL2_IP> connectport=8765
# allow it through the firewall
New-NetFirewallRule -DisplayName "simfarm" -Direction Inbound `
-LocalPort 8765 -Protocol TCP -Action AllowNote: the WSL2 IP changes on reboot, so re-run the add line (or script it) if
you use Option B. Option A avoids this entirely.
# set a shared secret (any random string); the client must use the same one
export SIMFARM_TOKEN="$(openssl rand -hex 16)"
echo "token: $SIMFARM_TOKEN" # copy this to the client
uv run simfarm worker # binds 0.0.0.0:8765To keep it running after you close the terminal, use tmux, nohup, or a
systemd user service inside WSL2.
The worker must survive logons, crashes, and terminal closes. Two Windows
Scheduled Tasks run scripts/start_worker.sh (idempotent: exits 0 if the
worker already answers on :8765, otherwise kills the port and supervises a
restart loop). Both launch through a hidden-window VBS wrapper so no console
flashes up:
%USERPROFILE%\simfarm-hidden.vbs:
CreateObject("Wscript.Shell").Run "wsl.exe -u $USER -- bash $HOME/simfarm/scripts/start_worker.sh", 0Created from any user shell on the PC (no admin needed):
schtasks /Create /TN simfarm-worker /SC ONLOGON /TR "wscript.exe %USERPROFILE%\simfarm-hidden.vbs" /F
schtasks /Create /TN simfarm-watchdog /SC MINUTE /MO 15 /TR "wscript.exe %USERPROFILE%\simfarm-hidden.vbs" /Fsimfarm-worker(ONLOGON): starts the worker at logon.simfarm-watchdog(every 15 min): revives a crashed worker mid-session; a healthy worker makes it a 1-second no-op.
The VBS Run ..., 0 call does not wait, so every task run completes
immediately with result 0 while the wsl.exe supervisor loop persists as a
detached process. Duplicate protection is in start_worker.sh itself: it
exits early when :8765 answers, and kills the port holder before starting
fresh when it doesn't.
Known limits (accepted):
- Both tasks are "Interactive only" — after a cold boot someone must log on before the worker starts. A wake-on-LAN from full power-off boots to the login screen and the worker stays down. Fix = sleep the PC instead of shutting down (session + worker survive S3 sleep/resume, WoL works, draw is a few watts), or store task credentials / enable auto-logon (owner call).
- Worth monitoring from the client side:
GET http://<worker>:8765/infodistinguishes the three states cleanly — any HTTP answer (including a 401) means alive, connection refused means the worker is down but the machine is up, and a timeout means the machine itself is off.
export SIMFARM_TOKEN="<the token you copied>"
uv run simfarm ping <worker-tailscale-hostname>You should see the worker's CPU count, GPU(s), and an "environment matches" line. If versions differ, align them — cloudpickle tolerates minor skew but big gaps (especially numpy/torch) can break deserialization.