Skip to content

feat(desktop): dynamic free-port selection (v0.5.0) - #259

Merged
robdmac merged 7 commits into
mainfrom
feat/desktop-dynamic-ports
Jul 12, 2026
Merged

robdmac merged 7 commits into
mainfrom
feat/desktop-dynamic-ports

Conversation

@robdmac

@robdmac robdmac commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Adds dynamic port allocation

robdmac and others added 7 commits July 12, 2026 14:51
…'t block boot

The desktop app bound fixed ports (control plane 8787, frontend 8788, d1-shim
9001) and failed to start if any was occupied — e.g. a stray `wrangler dev` or
another app on 8787. Now the app picks the first free port at/after each default
before binding (explicit CONTROLPLANE_PORT / FRONTEND_PORT / D1_SHIM_ADDR
overrides are still honored verbatim).

Wiring for a dynamically-chosen control-plane port:
- workerd control plane: `--external-addr d1-shim=<addr>` so it reaches a
  dynamic shim port (the capnp hardcodes 127.0.0.1:9001).
- Sandbox VM reverse bridge: the guest side stays baked at 8787, but the HOST
  target of `--reverse-port-forward` now follows the real control-plane port
  (new VMConfig.controlplane_host_port) so guest→host callbacks still land.
- Frontend bakes NEXT_PUBLIC_API_URL=:8787 at build time and can't learn a
  runtime port, so the trusted loading screen hands it over via ?cp=<port> on
  the redirect (mirrors the surface-token handoff). config/env.ts reads ?cp=,
  strictly gated to a loopback baked base, caches it, and strips it from the URL.
- New get_ports Tauri command reports the bound ports; the loading screen fetches
  it to probe the right URLs and build the redirect.

Scope: covers the workerd services (control plane + web interface + d1-shim),
which is what "failing to start" referred to. The sandbox VM's own host port
(8080) stays fixed for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…hed cp port

Two review findings on the dynamic-ports change:

P1 — CLI ignored dynamic ports. orcabot.rs hardcoded 8787/8080/8788 for health
checks, API calls, WebSockets and status, so after a dynamic-port GUI boot
"Switch to CLI" hit the wrong process (or nothing). The backend now records the
bound ports in <data>/ports; the CLI reads them fresh on each call (not cached,
so `up`'s spawn-then-poll converges once the file appears) and falls back to the
defaults when the file is absent (older backend / not started). Also fixes the
terminal-WS Origin header, which was a hardcoded :8788 that ALLOWED_ORIGINS would
reject under a dynamic frontend port.

P2 — a cached non-default control-plane port could survive into a default boot.
The loading screen omitted cp= when the port was the default 8787, so after a
prior CONTROLPLANE_PORT=8790 run a default relaunch (same :8788 origin) left the
frontend reading the stale cached 8790 and never reaching the real CP on 8787.
The loading screen now ALWAYS passes the current cp= so env.ts overwrites the
cache every boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
Completes the free-port story: if 8080 is busy on the host, the app now picks a
free host port for the sandbox's host→guest forward. The GUEST side stays fixed
at 8080 (new vm::SANDBOX_GUEST_PORT) because config.env isn't delivered to the
guest — it boots from image defaults — so only the host TCP listen can move, same
constraint as the control-plane reverse bridge.

- Host→guest forward is now `{host_port}:8080` (native VZ --port-forward, macOS
  QEMU fallback hostfwd, and Linux QEMU hostfwd).
- main.rs picks a free SANDBOX_PORT (avoiding cp/fe/d1) and points SANDBOX_URL at
  it so the control plane reaches the sandbox; the port is recorded in the ports
  file, so the CLI (/debug/exec, status) and the loading screen follow it.
- PORT env handed to the VM config is the guest bind (8080), not the host listen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…_DATA_HOME

Two more review findings on dynamic ports:

P1 — a busy default control-plane port still blocked CLI startup. `up` and bare
`orcabot` treated any healthy :8787 /health as "stack already running", so a
foreign listener (another Orcabot's `wrangler dev`, an unrelated server) made the
CLI attach to it and never launch our own backend — the exact conflict dynamic
ports is meant to survive. Detection now requires a live `orcabot-desktop` process
(pid file / pgrep) AND a healthy control plane (new `stack_running()`). Also
hardened the post-spawn readiness poll: it clears any stale ports file before
launch and only trusts health once OUR backend has rewritten it, so a foreign
listener on the default port isn't mistaken for our control plane mid-boot.

P2 — Linux port discovery ignored XDG_DATA_HOME. The CLI hardcoded
~/.local/share/com.orcabot.desktop while the backend writes via Tauri's
app_data_dir(), which follows an absolute $XDG_DATA_HOME. On a custom data dir the
CLI never found <data>/ports and fell back to defaults. data_dir() now mirrors
Tauri's resolution (honors an absolute XDG_DATA_HOME on Linux; macOS unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
stack_running() required both a live backend PID and a healthy control plane, so
a backend that existed but was still staging binaries / starting workerd counted
as "absent" — and `orcabot up` (or bare `orcabot`) would launch a SECOND
orcabot-desktop. The two would pick overlapping ports, clobber the shared ports
file, and race their service children.

Separate the two states: cmd_up now guards on app_pid() (backend existence) first.
If a backend process is present it attaches and waits for readiness (extracted
wait_for_ready) instead of spawning; only a genuine absence of any backend leads
to a launch. run_tui claims ownership (tear-down-on-quit) only when it actually
started the backend, not when it merely waited for one already coming up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…hen-spawn race

The app_pid() check and the spawn in cmd_up weren't atomic, so two simultaneous
first-launches could both pass the check and spawn rival backends. Wrap the
critical section (check + spawn + pid-file write) in a best-effort exclusive
flock on <data>/up.lock, released before the long readiness poll. A concurrent
`up` now blocks briefly, then finds the backend the winner started and attaches
to it. Best-effort: if locking is unavailable we proceed as before (no regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ore wait

Two follow-ups on the CLI launch path:

P2 — concurrent bare `orcabot`s both claimed ownership. run_tui computed
`started_it` from app_pid() OUTSIDE cmd_up's launch lock, so two simultaneous
callers both saw no PID and both set we_own=true; closing either TUI then tore the
backend down under the other. cmd_up's core is now ensure_stack_up() -> (exit,
spawned), which decides `spawned` INSIDE the lock. run_tui claims ownership from
that authoritative flag (the pre-check now only drives the cosmetic message).

P3 — the attach path held the launch lock across the up-to-150s readiness wait
(`return wait_for_ready(...)` evaluated before the guard dropped), blocking other
CLI starts. It now explicitly drops the lock before waiting, matching the spawn
path and the "short critical section" contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
@robdmac
robdmac merged commit b7abea0 into main Jul 12, 2026
1 check passed
@robdmac
robdmac deleted the feat/desktop-dynamic-ports branch July 12, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant