Repository navigation
feat: one open at a time — a second waits, then joins the link - #26
Merged
Merged
Conversation
The holders coordinate sessions once the link is UP. Bringing it up was not coordinated at all: on 9 October two sessions ran `open` half a minute apart from a VPN-down state, each found no master and no tunnel, and each ran HS_VPN_UP_CMD. One Cisco client serves the machine, so the second connect met the first in flight — one stalled at "contacting host" until its caller's 240 s timeout, the other was told the client was held by another application — and neither session got a link. `open` now takes a lock, $HS_CONTROL_DIR/open.lock, before it looks for a master and keeps it until its holder is recorded. A second opener waits, printing "waiting for another session's open (pid N)", for up to HS_OPEN_LOCK_WAIT seconds (default 300), then finds the master up and joins it as a holder. A lock whose process is gone, or older than HS_OPEN_LOCK_STALE seconds (default 600), is taken over. The lock is a symlink whose target is the record — pid, time, holder — so it is created and described in one atomic step, and it is released on every exit path: explicitly on the way out of hs_open_session, from the EXIT/INT/TERM/HUP traps on hs_die or a signal, and from a trap the subshell arms for itself, because the tool's own implicit opens run inside $( ) in watch, submit and fetch, where the parent's traps do not apply and the parent's pid would have looked alive. `close` keeps the link while another session's open is in progress — the opener has no holder yet, and tearing the tunnel down under it is the same collision from the other side. `status` shows an open in progress. Twenty-two offline assertions, run as two real processes of the tool against a shadowed ssh and VPN hooks that log and sleep: the VPN comes up once and the master opens once; the second open names the pid it waited for and joins; both hold the link and the lock is gone; a dead owner's lock and one past the stale age are taken over and say so; the wait is bounded and the failure names the other open; close during an open keeps the link, status shows it, HS_CLOSE_FORCE=1 closes over it; a failed VPN connect leaves no lock; an open that dies inside a subshell releases it. Version 0.2.0; CHANGELOG, README, SKILL.md, config.example and docs/vpn-hooks.md updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One commit,
59b0d0a, on top ofmain(f9a2b82, the holders change from #25).The collision
On 9 October 2026, 06:39–06:43Z, two Claude sessions on the same Mac ran
hpc-session openabout thirty seconds apart from a VPN-down state. Eachfound no master and no tunnel, and each ran
HS_VPN_UP_CMD. One Cisco SecureClient serves the machine, so the second connect met the first in flight:
vpn -s connectstalled at>> contacting host (open.unice.fr) for login information...until its caller's 240 s timeout;wrapper's "the Cisco Secure Client app holds the VPN connect capability"
message, which also ran "disconnecting current tunnel first" under the
first connect.
Neither session got a link. The holders mechanism from #25 could not see it:
holders coordinate sessions once the master is UP, and between an
open'sstart and its master coming up there is no holder to see. The VPN bring-up
itself was unserialised.
The change
opentakes a lock,$HS_CONTROL_DIR/open.lock, before it looks for a masterand holds it until its holder is recorded — the whole of "bring the VPN up +
start the master", and the master check itself, which is inside the lock on
purpose so a waiter cannot raise a second master over the one it waited for.
waiting for another session's open (pid N) — holder H, Ns in; up to 300s (HS_OPEN_LOCK_WAIT), then joining the link it raises. When the first finishes, the second finds the master up andjoins it as a holder: one tunnel, one authentication, two sessions.
HS_OPEN_LOCK_WAITseconds (default 300) the open fails,naming the pid and holder that still hold the lock, and leaves their lock
alone.
0means do not wait.owner process is gone, or one older than
HS_OPEN_LOCK_STALEseconds(default 600) — the backstop against a connect that hangs while its process
lives on.
hs_open_session; from the EXIT/INT/TERM/HUP traps onhs_dieor a signal;and from a trap the subshell arms for itself, because the tool's own
implicit opens run inside
$( )inwatch,submitandfetch, where theparent's traps do not apply. Without that last one, an
hs_dieinside thesubshell left the lock under the parent's pid — alive — and the parent's own
next open waited the full
HS_OPEN_LOCK_WAITfor itself. (The existingsuite found this: two older tests drive
hs_open_sessioninside$( ).)ln -screates it or failsbecause it exists, as atomically as
mkdir, but the record —pid=N since=S holder=H— is the link's target, written in the same step, so a readernever meets a lock whose owner is not recorded yet.
readlinkreturns itwithout opening anything. Portable to macOS bash 3.2; no
flock.closekeeps the link while another session's open is in progress: theopener has no holder yet, and taking the tunnel down under it is the same
collision from the other side.
HS_CLOSE_FORCE=1still closes.statusshows an open in progress. These two are small, but they close the mirror
image of the race rather than leaving it documented.
The lock is per control directory, which is the default for every profile and
the right scope for one VPN client per machine; two profiles with different
HS_CONTROL_DIRvalues are not serialised against each other (noted underLimitations). TOTP and keychain handling are untouched.
Tests
Twenty-two new assertions, 242 in total, offline, 6.9 s on this machine. The
race is run as two real processes of the tool — the lock records a pid and
is released by an EXIT trap, neither of which a function call inside the
harness exercises — against a shadowed
sshand VPN hooks that log and sleep:pid it waited for and joins the link the first raised; both hold it; the
lock is released;
HS_OPEN_LOCK_STALE, are taken over and say so;closeduring an open keeps the link and tears nothing down;statusshows the open;
HS_CLOSE_FORCE=1closes over it;$( )subshell releases the lock.shellcheck, run as in CI, is clean. Nothing site-specific is in the diff.
Docs and version
0.2.0— the## Unreleasedsection becomes## 0.2.0 — 2026-10-09, withthe lock beside the holders entry; the version test holds the tool and the
changelog together. README (Use, Limitations), SKILL.md (an agent that meets
another open lets it wait), config.example (the two knobs) and
docs/vpn-hooks.md (a "Two sessions, one VPN client" section, and what the
Cisco "held by another application" message means) updated.
🤖 Generated with Claude Code