Skip to content

bridges: give each concurrent process its own Machine slot - #43

Open
guygrigsby wants to merge 8 commits into
mainfrom
guygrigsby/APT-330/bridge-concurrency
Open

guygrigsby wants to merge 8 commits into
mainfrom
guygrigsby/APT-330/bridge-concurrency

Conversation

@guygrigsby

@guygrigsby guygrigsby commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Parallel aperture sessions in bridge mode evicted each other (APT-330). Every process opened the same state directory for a bridge, so every process registered the same node key, and the control plane handed the session to the newest registrant. The older processes showed Running locally while their dials reached nothing, which surfaced as hangs and "api error". The shared directory was also being written concurrently.

This PR solves the bridge concurrency problem with the naive approach of one tailnet machine per agent.

This PR numbers a bridge's Machine identity by slot. Slot 1 keeps the existing state directory and hostname, so devices users already authorized carry over. Slots 2 and up get suffixed siblings: bridges/<hex>-N on disk and aperture-cli-<bridgeID>-N in the admin console. A process claims the lowest free slot when it builds the node and holds it until the Machine closes or is destroyed. The claim is an exclusive non-blocking lock on bridges/locks/<hex>-<slot>.lock, flock on unix and LockFileEx on Windows. Locks live outside the state directory so removal never deletes an open lock.

Machines.Destroy now logs out every slot the bridge has on disk. It refuses, before any logout, when another live process holds a slot and names the conflict. The removal screens list every device the bridge registered, the way the admin console shows them.

TS_AUTHKEY, when set, authorizes a fresh slot without the browser login. Once a slot has registered, its state directory carries the credentials and the key is not consulted again for it. The README gains a section on concurrent sessions and a table of every environment variable the launcher reads. ADR 0006 records the decision and why a shared daemon and ephemeral per-process nodes were rejected.

Risks:

  • Each concurrent process is its own device in the admin console. Ten parallel sessions are ten devices. A process that dies without closing leaves its device listed offline until the bridge is removed.
  • Every new slot needs one browser login unless TS_AUTHKEY is set. Agent fleets need a reusable key or each slot asks once. The key decides which tailnet the fresh device joins, so a key from the wrong tailnet registers the device there.
  • Removing a bridge fails while another aperture process has it open. That case used to log the live session out silently, so this is the intended trade, but it is a new error users will see.
  • A Destroy that returns at its deadline while the node's Close hangs keeps slot 1 locked until the close finishes. A reopen in that window takes slot 2, a fresh device and a fresh login, where it used to wait on the stuck close.
  • Slots are capped at 100 per bridge. Past that the process errors rather than probing further.

A make install or go build binary reported a commit height (B29), so a
running binary could not be matched to the release it came from. The
goreleaser build already stamped the tag, so the height scheme only
ever showed on source builds, which is every build a dev runs.

git describe gives the tag on a release commit and the tag plus
distance past it otherwise, for both the Makefile ldflags and the
init() fallback plain go build exercises.

Keeping B-numbers would have cost nothing to write and stayed
unmatchable against the release list forever.
Every existing test exercises internals in-process; nothing executed the
shipped binary, so the flag-before-TUI ordering, the PTY handoff to a
launched agent, and the return to the picker after the child exits were
all untested at the level users hit them.

Four tests against the real build: -version, a bad endpoint failing
before the TUI takes the terminal, the Pi happy path against a fake
Aperture (httptest /v1/models) with a stub pi on PATH recording argv and
capturing the generated provider extension while it exists, and the
unreachable-endpoint banner. Runs hermetically: temp HOME/XDG, PATH of
the stub dir plus system dirs only so host agent binaries cannot leak
into the picker.

creack/pty is promoted from the module graph; no new third-party code.
TERM=dumb because startup asks the terminal its color profile and a PTY
never answers — with xterm each run paid the five-second query timeout.
Skipped: bridge flows (need a control plane), install flows (network),
Windows PTYs.
APT-330: parallel aperture sessions in bridge mode shared one state directory per bridge, so every process registered the same node key and the control plane handed the session to the newest registrant. The older processes looked Running locally while their dials reached nothing, surfacing as hangs and "api error", and the shared directory was being written concurrently on top.

A Machine's identity is now numbered by slot. Slot 1 keeps the existing state directory and hostname so authorized devices carry over; slots 2 and up get suffixed siblings. A process claims the lowest free slot with an exclusive non-blocking lock on a lock file under bridges/locks/ when it builds the node and holds it until the Machine closes or is destroyed. Lock files live outside the state directory so removal never deletes an open lock and a claimant's inode cannot be deleted under it.

Machines.Destroy logs out every slot the bridge has on disk and fails before any logout when a live process holds one, naming the conflict. Evicting a running session is the silent kill this change removes, not a removal feature.

Rejected the shared daemon (a wire protocol, trust boundary and daemon lifecycle for a first iteration) and ephemeral per-process nodes (key expiry forces a browser login per launch). ADR 0006 records the call and the revisit conditions.
ADR 0006 makes every concurrent process its own device, so a fresh slot needed one browser login each before it could join. With TS_AUTHKEY set, tsnet registers the slot non-interactively; a reusable key covers every slot an agent fleet claims. Once a slot has registered, its state directory carries the credentials and the key is not consulted again for it.
APT-330 taught users the hard way that concurrent sessions evict each
other; the fix (ADR 0006) and the TS_AUTHKEY escape hatch for unattended
sessions were only discoverable by reading the code.
The flags table only names the two vars that mirror flags; TS_AUTHKEY,
the Codex install vars and the incidental reads were undocumented.
The suite skipped bridge flows on the assumption they need a control plane.
The slot contract turned out to be local: state dirs, held flocks, and menu
screens, all observable without a login ever completing. Four tests, one per
ADR 0006 decision: concurrent processes claim distinct numbered slots, a
dead process's slot is reclaimed before a new one is minted, removal fails
naming the conflict while a slot is live and destroys every slot once it is
gone, and the 100-slot cap errors instead of probing. The cap test holds the
100 locks itself because an exclusive flock is exactly what a live process
presents, which keeps 101 real processes out of the run.

Two harness additions this needed: spawn now reaps parked processes via
cleanup (a bridge waiting on login never exits on its own), and hermeticEnv
drops TS_AUTHKEY and forces BROWSER=true (bridge attempts reach the
login-link phase, so a dev box would otherwise authorize fresh slots against
a real tailnet or pop browser tabs during make check).

Not covered: two sessions actually reaching Aperture through their slots.
That is the data plane and does need credentials the test environment does
not have.
@guygrigsby
guygrigsby force-pushed the guygrigsby/APT-330/bridge-concurrency branch from abf4b65 to b97d3d6 Compare September 22, 2026 20:44
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