Skip to content

feat: one open at a time — a second waits, then joins the link - #26

Merged
lfnothias merged 1 commit into
mainfrom
feat/serialised-open
Oct 9, 2026
Merged

lfnothias merged 1 commit into
mainfrom
feat/serialised-open

Conversation

@lfnothias

Copy link
Copy Markdown
Contributor

One commit, 59b0d0a, on top of main (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 open about thirty seconds apart from a VPN-down state. Each
found no master and no tunnel, and each ran HS_VPN_UP_CMD. One Cisco Secure
Client serves the machine, so the second connect met the first in flight:

  • the first vpn -s connect stalled at >> contacting host (open.unice.fr) for login information... until its caller's 240 s timeout;
  • the second was told the client was held by another application — the
    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's
start and its master coming up there is no holder to see. The VPN bring-up
itself was unserialised.

The change

open takes a lock, $HS_CONTROL_DIR/open.lock, before it looks for a master
and 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.

  • A second opener waits, printing 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 and
    joins it as a holder: one tunnel, one authentication, two sessions.
  • Bounded: after HS_OPEN_LOCK_WAIT seconds (default 300) the open fails,
    naming the pid and holder that still hold the lock, and leaves their lock
    alone. 0 means do not wait.
  • Stale locks are taken over and the takeover is reported: a lock whose
    owner process is gone, or one older than HS_OPEN_LOCK_STALE seconds
    (default 600) — the backstop against a connect that hangs while its process
    lives on.
  • 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. Without that last one, an hs_die inside the
    subshell left the lock under the parent's pid — alive — and the parent's own
    next open waited the full HS_OPEN_LOCK_WAIT for itself. (The existing
    suite found this: two older tests drive hs_open_session inside $( ).)
  • The lock is a symlink, not a directory: ln -s creates it or fails
    because 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 reader
    never meets a lock whose owner is not recorded yet. readlink returns it
    without opening anything. Portable to macOS bash 3.2; no flock.
  • close keeps the link while another session's open is in progress: the
    opener has no holder yet, and taking the tunnel down under it is the same
    collision from the other side. HS_CLOSE_FORCE=1 still closes. status
    shows 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_DIR values are not serialised against each other (noted under
Limitations). 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 ssh and VPN hooks that log and sleep:

  • the VPN came up once and the master opened once; the second open names the
    pid it waited for and joins the link the first raised; both hold it; the
    lock is released;
  • a lock left by a dead process, and one a live process has held past
    HS_OPEN_LOCK_STALE, are taken over and say so;
  • the wait is bounded; the failure names the other open and leaves its lock;
  • close during an open keeps the link and tears nothing down; status
    shows the open; HS_CLOSE_FORCE=1 closes over it;
  • a failed VPN connect fails the open and leaves no lock;
  • an open that dies inside a $( ) 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 ## Unreleased section becomes ## 0.2.0 — 2026-10-09, with
the 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

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>
@lfnothias
lfnothias merged commit 59b0d0a into main Oct 9, 2026
3 checks passed
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