Skip to content

fix(usage): keep a running sign-in's credential home taken across a server restart (#2313) - #2325

Merged
devswha merged 8 commits into
mainfrom
fix/usage-login-restart-lock
Oct 29, 2026
Merged

devswha merged 8 commits into
mainfrom
fix/usage-login-restart-lock

Conversation

@devswha

@devswha devswha commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Closes #2313

What

Sign in again and Add account allow one login per credential home (#2309), but the lock lived only in the server's memory, while the login itself runs in a herdr tab that outlives the server. After an Ovis restart (update, crash, manual restart) with a sign-in tab still open, the same account could start a second CLI login into the same home, and the two raced each other's tokens.

Now UsageLoginService keeps each holder in <stateDir>/usage-logins.json (via writePrivateState: 0600, fsync, atomic rename), and createServer points it at its state dir. A holder is the home key, the tab's pane ID and the tab's herdr terminal ID (tabCreate's root_pane.terminal_id), saved as soon as the tab opens, so a restart mid-launch is covered too.

A home stays taken while that terminal is in herdr's session snapshot, in any tab:

  • herdr keeps a terminal's ID when its tab moves to another workspace (the pane ID changes; checked in an isolated herdr), and never hands one out again; a herdr restart gives restored panes new terminals (scripts/herdr-restart-regression.ts), while their pane IDs repeat. Pane IDs and pids are therefore not used as proof: a restarted herdr reuses pane IDs, and a pid can name anything after a reboot.
  • The terminal is there exactly as long as the login runs: the CLI replaces the tab's shell (exec), or the shell exits after it (Windows).
  • Any herdr error keeps the home taken (fail closed: the same 409 login_in_progress as feat(usage): sign in again to an expired Claude or Codex account #2309, whose message says to finish or close the tab).

Servers on one state dir share the file as one record (a source bun run server beside the installed app, say). Each change is one read-modify-write under the file's kernel guard (withOwnerGuard, server/owner-guard.ts), and a server adds or removes only its own entries, matched by launch ID or terminal, and takes over another's only in the step that finds it unchanged and ended; it never writes back a stale view of others. Taking a home fails closed: when the guard cannot be taken or released, or the file cannot be written, the request fails without opening a tab (a sign-in other servers cannot see could run beside another). The login command is sent only after the file names the opened tab as the home's holder; if that write fails (or another server took the entry over), the tab is closed and the request fails, so no login runs under a launch entry that expires. A state file that exists but cannot be read or parsed refuses sign-ins (409 login_state_unreadable, saying to close any open sign-in tab and repair or remove the file); only a missing file counts as empty. Freeing a home is best effort and logged: an entry left behind holds only until a server finds it ended. The guard file is opened without following a symlink (O_NOFOLLOW, server/owner-guard.ts), so a planted link cannot make it create a file elsewhere. A launch is recorded in the file before anything is awaited (its server's pid, its start time and a random ID), so another server refuses that home while the tab is still opening; a launch whose server is gone, or older than 60 s (a launch opens its tab within the request's 30 s), no longer holds. This replaces #2309's in-memory pane + pid check with one snapshot check (no per-pane pane.process_info fan-out). No HTTP contract change.

Tests

server/usage-login.contract.test.ts, seven new cases, and one in server/owner-guard.test.ts:

  • keeps a home taken across a server restart while its sign-in still runs in its tab, and frees it once that ended: a server started beside the first (before its sign-in) answers 409; after the first stops, a restarted server answers 409 with tabCreate called once; once herdr says the login's pane exited, 200. On main the restarted server answers 200 and opens a second tab.
  • starts one sign-in when two servers on one state dir are asked for one home at once: the first server's launch is held before its tab opens; the second server answers 409, the first opens its tab, tabCreate called once. Fails on a version that keeps launches in memory only (200 twice).
  • refuses once, without looking again and again, when a crashed launch's entry cannot be replaced, and signs in once it can: an expired launch in the file and a disk refusing writes (ENOSPC): one write attempt, 500, no tab; once writes work, 200. A version that released and looked again re-read the same launch for every refused write (a microtask loop that starved the server); one that swallowed the write started a sign-in no other server could see.
  • refuses a sign-in while the state file of running sign-ins is corrupt / unreadable, rather than reading it as empty ({broken; mode 000): 409 login_state_unreadable, no tab, no credential home left. Both answered 200 before.
  • closes the tab and sends no login when that tab cannot be recorded as the home's holder (ENOSPC on the second write): 500, the tab closed, no command sent to it, the file empty again. Answered 200 and ran the CLI before.
  • owner guard: refuses a guard planted as a link, creating nothing where it points: a dangling *.guard symlink; nothing runs and the target is not created.
  • frees a home after a restart when its sign-in ended while no server ran, even when its pane ID now names another tab: the saved pane ID is pointed at another live tab (a restarted herdr reuses IDs); the home is free. Fails when the check trusts the pane ID (mutation run).

The restart test hears the pane's exit on its own herdr subscription: the shared test server hears no exit before the file's first (beforeAll), so a filtered run would miss it. The whole file passed in repeated runs, also filtered to the new cases; bun run check fast passed.

Manual QA

The real entrypoint (bun run server/index.ts) twice on one OVIS_STATE_DIR, against a throwaway headless herdr session, with a stub claude CLI: server A opened the sign-in tab (200, w1:p1, holder term_65ef2e58586051 written); A stopped (port free); server B (new pid, bound the port itself) answered 409 login_in_progress for the same account while the CLI still ran; after the login finished, B answered 200 and wrote the new tab's terminal. Everything torn down afterwards (no process, port or temp dir left).

Known limits (LOW, not fixed here)

  • The pre-0.4.16 sibling state-dir merge (server/state-merge.ts) does not carry usage-logins.json: a sign-in tab open during an install's one-time switch between herdr-web-ui and ovis state dirs is not seen by the next server.
  • The guard's no-follow open is POSIX only; on Windows (where a symlink needs admin or Developer Mode) the guard still follows one. Follow-up.
  • Pre-existing: if paneSendText fails after herdr already delivered the command, the sign-in runs while the home is freed. Follow-up.
  • By design: one damaged entry blocks sign-ins for every account until the file is repaired or removed; the 409 says so. The file is new in this PR, so no older release writes one.
  • Pre-existing: ended(paneId) frees this server's holder by pane ID, so if a moved login's old pane ID is handed to a new pane and that pane exits, the home is freed while the CLI runs.
  • A launch that hangs for more than 60 s before its tab opens no longer holds the home for another server;
  • Where the file's guard cannot be loaded at all (no usable libc for bun:ffi), every sign-in now fails with 500 instead of starting; managed installs already need that guard at startup, so this affects only a hand-run server on such a host.

Reviews

omo-native-code-reviewer and Codex Sol 6.1 fast xhigh, five rounds: both APPROVE on fd0a106; the merge pass's strict review of b187b88 then found two fail-open MEDIUMs (a damaged state file read as empty; the tab's record failing after the login was sent) and a LOW (guard symlink), fixed in the next head. Every HIGH and MEDIUM was fixed; the record of findings and resolutions is in a PR comment. The LOWs left are the known limits above.

Remote bundle

Changes server/ only; no REMOTE_BUNDLE_VERSION change (the release PR raises it).

…erver restart (#2313)

The one-login-per-home lock lived only in the server's memory, while the
login runs in a herdr tab that outlives the server. The service now keeps
each holder (home, pane, CLI pid) in <stateDir>/usage-logins.json and, after
a restart, holds a home only while herdr still shows that login: its pane
running that pid, or that pid as the shell of a moved pane. A pid alone is
no proof; herdr errors keep the home taken.

Closes #2313
…rvers on one state dir (#2313)

A pane ID and a pid could not tell a login apart after a restart: herdr
hands pane IDs out again, a move changes the pane ID (racing the pid's
capture), and a pid can name any process after a reboot. herdr's terminal
ID stays with the tab when it moves and is never reused, so a holder is
now that terminal, and one session snapshot says whether its login still
runs. Servers on one state dir read each other's holders, and a server
writes back the others' entries. The file is written with
writePrivateState.
…er the file's guard (#2313)

Each server now changes usage-logins.json only through one guarded
read-modify-write (withOwnerGuard), adding or removing only entries that
are its own (by launch ID or terminal), so a stale view of another
server's holder is never written back and concurrent saves cannot drop
one. A launch is recorded in the file before anything is awaited, with
its server's pid and start time, so another server refuses that home
while the tab is still opening; a launch whose server is gone or that is
older than a minute no longer holds.

The restart test hears the pane's exit on its own herdr subscription, and
the send-failure test waits for its retry's CLI, which raced the next
test's look at the fixture home.
…keep sign-ins in memory without the file (#2313)

reserve() released a holder it found ended and looked again; when the
file could not be written, it found the same holder every time, a
microtask loop that starved the server. It now replaces that holder with
its own launch in the guarded step that finds it unchanged. When the
file's guard cannot be taken or the file cannot be written, the server
logs it and keeps its sign-ins in memory as before, rather than refusing
every sign-in. The two-servers test settles its held request on failure.
…rvers (#2313)

Keeping sign-ins in memory when the state file could not be written or
its guard not taken or released let a server start a sign-in no other
server could see, beside theirs. Taking a home fails the request again,
without a tab; freeing a home and recording the opened tab stay best
effort. A refused write now ends that request once instead of looking
again forever.
@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Review record (omo-native-code-reviewer + Codex Sol 6.1 fast xhigh)

This PR touches a sign-in lock (access control) and is reviewed by both agent reviewers. Findings and how each was resolved, by round:

Round 1 (aa39905, pane ID + pid holders)

  • omo APPROVE, MEDIUM: a holder saved without a pid stays held while any pane has its pane ID, and a restarted herdr hands IDs out again. Fixed in 95a1f61: a holder is the tab's herdr terminal_id (kept when the tab moves, never reused); one session.snapshot decides.
  • Codex CHANGES: HIGH a tab moved while its pid was captured was freed after a restart; HIGH the moved-pane scan raced a move; HIGH two servers on one state dir overwrote each other's file; MEDIUM the same reused-pane-ID block; MEDIUM the pre-0.4.16 sibling-dir merge skips the file. Fixed in 95a1f61 (terminal ID) and b8d5535 (shared file), except the sibling merge, a documented known limit in the PR body.

Round 2 (95a1f61)

  • omo APPROVE, MEDIUM: the new tests also passed on aa39905. Fixed: the beside server is built before the sign-in, and a new two-servers test holds the first launch before its tab opens; both fail on the earlier code.
  • Codex CHANGES: HIGH a stale holder adopted from the file could be written back over a newer one; HIGH a launch was invisible to other servers until its tab opened (200/200); MEDIUM concurrent read-merge-write saves lost holders. Fixed in b8d5535: every file change is one read-modify-write under the file's kernel guard (withOwnerGuard), a server changes only its own entries, and a launch is recorded before anything is awaited.

Round 3 (b8d5535)

  • omo APPROVE (A, B, C closed), LOWs only.
  • Codex CHANGES, MEDIUM: an expired holder whose removal could not be written (ENOSPC) was read again forever, a microtask loop. Fixed in df463b3: an ended holder is taken over in the guarded step that finds it unchanged.

Round 4 (df463b3)

  • Codex CHANGES, two MEDIUMs, both from a memory fallback added for omo's LOW (guard unavailable answers 500): a swallowed reservation write, and a guard-release failure replayed on an empty map, each let a second server start a sign-in. Fixed in fd0a106 by failing closed again; the LOW is declined and listed as a known limit.

Round 5 (fd0a106)

  • Codex APPROVE: both MEDIUMs closed, nothing new (seven fault-injection scenarios).
  • omo APPROVE (36/36, the two new tests 15 runs each; the full-disk test fails on b8d5535 and df463b3). LOW: a failed write of the opened tab leaves the launch entry, which other servers honour for the rest of its minute; listed as a known limit and said in the code comment.

LOWs not fixed are listed under Known limits in the PR body. Local verification on fd0a106: bun run check fast passed (8426 unit tests), the contract file passed 36/36 in repeated runs, and the real-server restart QA passed (409 after a restart while the sign-in ran, 200 once it ended).

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between f545c87 and b187b88.

Files selected for processing: 4
  • changelog.d/usage-login-restart-lock.md
  • server/index.ts
  • server/usage-login.contract.test.ts
  • server/usage-login.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.

📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Active sign-in and account-add sessions remain recognized after the server restarts, preventing duplicate login attempts while the original session is still open.
    • Login attempts can be retried after the previous session has ended or is no longer active.
    • If the server cannot save login state, it reports an error without starting the login command.

Usage-login reservations now persist in the server state directory. The service checks persisted and in-memory holders when reserving a credential home, and updates the holder when a login tab opens or ends.

Changes: Usage login reservation lifecycle

Layer / File(s) Summary
Holder state and persistence
server/usage-login.ts, server/index.ts
The service tracks launch and login holders in usage-logins.json under the server state directory.
Holder liveness and reservation
server/usage-login.ts
The service checks local and persisted holders before reserving a credential home. Active holders block a reservation; ended holders can be released or replaced if unchanged.
Login transitions and contract tests
server/usage-login.ts, server/usage-login.contract.test.ts
Login requests create launch holders and replace them with terminal holders when tabs open. Tests cover server restarts, concurrent servers, stale pane IDs, persistence failures, and retries.
Changelog
changelog.d/usage-login-restart-lock.md
The changelog describes the open-login behavior across a server restart.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~40 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related PRs

  • devswha/ovis#2309: Its account-keyed Sign in again path uses the reservation behavior changed here.
  • devswha/ovis#1931: It changes state-directory resolution, which determines where this change stores reservation state.
Merge Risk: Minimal · previous review b187b

This change keeps a running sign-in's credential home reserved across a server restart, using a shared state file. No actionable merge-blocking risk remains. The limits listed in the PR description are accepted tradeoffs.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: preserving a running sign-in's credential-home reservation across a server restart.
Linked Issues check Passed For #2313, UsageLoginService persists reservations in usage-logins.json and createServer assigns the state directory. The reservation records the herdr terminal ID and remains active while that …
Out of Scope Changes check Passed The changed implementation, server wiring, contract tests, and changelog support #2313. The tests validate persistence, concurrency, failure handling, and terminal identity. No unrelated product behav…
Description check Passed The description follows the template's two sections, "Change" and "Validation", and goes well beyond them. The Change section states the problem (in-memory lock lost on Ovis restart with a sign-in tab…
Full details: Linked Issues check

Explanation

For #2313, UsageLoginService persists reservations in usage-logins.json and createServer assigns the state directory. The reservation records the herdr terminal ID and remains active while that terminal appears in sessionSnapshot(). Guarded read-modify-write operations protect shared state across server instances. The added contract tests cover restart persistence, concurrent servers, ended logins, failed writes, and reused pane IDs. These changes keep the interactive login running and reject a second login with login_in_progress.

Full details: Out of Scope Changes check

Explanation

The changed implementation, server wiring, contract tests, and changelog support #2313. The tests validate persistence, concurrency, failure handling, and terminal identity. No unrelated product behavior or unrelated files are included in the reported changes.

Full details: Description check

Explanation

The description follows the template's two sections, "Change" and "Validation", and goes well beyond them. The Change section states the problem (in-memory lock lost on Ovis restart with a sign-in tab still open, allowing a second CLI login into the same credential home), the fix (persistent holders in usage-logins.json with terminal IDs, guarded read-modify-write, fail-closed behavior), and the resulting behavior (409 while the login runs, 200 after it ends). It links the issue (Closes #2313). The Validation section lists the new contract tests, the owner-guard test, bun run check fast, and manual QA with two real server processes. The template asks for contract-test coverage for HTTP/WS changes, and the PR covers it. The description is long and dense, but it is complete against the template.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 1 functions across 3 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ose tab was not recorded (#2313)

The merge pass's strict review found two fail-open paths and a LOW:
- a usage-logins.json that would not read or parse counted as empty, so
  after a restart a second login started beside the first; only a missing
  file is empty now, any other refuses with 409 login_state_unreadable;
- when recording the opened tab as the home's holder failed, the login
  was still sent under a launch entry that expires; the tab is now closed
  and the request fails unless that record is written and still ours;
- the owner guard followed a planted symlink and created its target; it
  is opened with O_NOFOLLOW.
An Add account home made for a login that was never sent is removed on
every early return, not only on a thrown error.
@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Merge pass strict review of b187b88: resolved in 4600ca6

Each finding has a test that fails on b187b88 and passes on 4600ca6.

  • MEDIUM 1, a damaged state file read as empty (fail open). read() now treats only a missing file (ENOENT) as empty. A file that will not read (e.g. mode 000) or parse, or a malformed list or entry, raises LoginStateUnreadable, and the request answers 409 login_state_unreadable before any home or tab is made; the message says to close any open sign-in tab and repair or remove the file. Tests: refuses a sign-in while the state file of running sign-ins is corrupt / unreadable, rather than reading it as empty (both 200 on b187b88).
  • MEDIUM 2, the login sent although the tab's ownership was not recorded (fail open). The opened tab is written to the file as the home's holder before the login command is sent. If that write fails, the tab is closed and the request fails; if the entry is no longer this launch's (another server took it over), the tab is closed and the request answers 409. Test: closes the tab and sends no login when that tab cannot be recorded as the home's holder (ENOSPC on the second rename: 500, the tab closed, no command sent, the file empty again; 200 and a running CLI on b187b88).
  • LOW, the guard followed a planted symlink. server/owner-guard.ts opens the guard with O_NOFOLLOW (POSIX). Test: owner guard: refuses a guard planted as a link, creating nothing where it points. The Windows guard still follows links (creating one there needs admin or Developer Mode); listed as a follow-up in the PR body.
  • Also: an Add account home made for a login that was never sent is now removed on every early return (the refused, cancelled and unrecorded ones), not only on a thrown error.

Verification on 4600ca6: bun run check fast passed (8427 unit tests); the usage-login contract file passed twice (all new cases included); the owner-guard tests passed (16, 4 Windows-only skips); the real-server restart QA passed again (200, server stopped, restarted server 409 while the CLI ran, 200 after it ended). omo-native-code-reviewer re-reviewed the delta: APPROVE, no fail-open path left; its LOWs (Windows guard, a pre-existing send-failure race, one damaged entry blocking every account by design) are listed in the PR body. Codex was not re-run on this delta, to keep the release path short.

… written (#2313)

writePrivateState answers directorySynced: false when the directory fsync
after the rename fails (EIO): the record may be gone after a crash. The
merge pass found the tab's holder record accepted that way, after which
the login was sent. Outside Windows, which syncs no directory, such a
write now fails like a refused one: the claim or tab record is taken back
where it landed, the tab is closed and the request fails.
@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Merge pass: directorySynced:false after the holder rename, resolved in 0600228

edit() now treats writePrivateState's directorySynced: false as a failed write outside Windows (where syncDirectory never attempts a directory fsync and always answers false, so failing there would refuse every sign-in). Like the other publication failures: the claim or tab record is taken back where it landed (best effort), the tab is closed, no login is sent, and the request fails.

Test: closes the tab and sends no login when the record of that tab as the home's holder is not synced (EIO injected on the second directory fsync, the one after the holder rename). On 4600ca6 it answered 200 (expected 500); now 500, the tab closed, no command sent to it, the file empty again. The ENOSPC variant shares the test.

Verification on 0600228: bun run check fast passed; the usage-login contract file passed twice; the real-server restart QA passed (200, restarted server 409 while the CLI ran, 200 after it ended).

@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Merging 0600228 (closes #2313). A sign-in started from Ovis keeps its credential home taken across a server restart: the holder is recorded in a private state file (usage-logins.json), so a restarted server answers 409 instead of opening a second sign-in into the same home, and frees it once the first sign-in's terminal is gone. Authentication path, so the merge pass reviewed it strictly itself over three rounds (the last a one-line final round). Fixed on the way: an unreadable or corrupt state file no longer counts as empty (409 login_state_unreadable, no tab opened, with recovery text); the login command is typed only after the tab is durably recorded as the holder, and any failure to record it (write, rename, file sync, and outside Windows the directory sync) takes the claim back, closes the tab and fails the request; the guard file is opened with O_NOFOLLOW (a symlink is refused, nothing outside touched). Verified on a real server: corrupt JSON, a directory and an EACCES file in place of the state file; injected write/rename/fsync/directory-fsync failures (the last 200 on 4600ca6, 500 now); restart before typing; two concurrent requests (200 + 409); restart QA 200 -> 409 -> 200; watch devices and the PC proxy unchanged. 98 tests + typecheck. Note (LOW): the lane's claim that all four earlier tests fail on the old head was not independently reproduced (the baseline harness failed to set up); the injected directory-sync test was checked against 4600ca6.

@devswha
devswha merged commit 0b0fab8 into main Oct 29, 2026
19 checks passed
@devswha
devswha deleted the fix/usage-login-restart-lock branch October 29, 2026 07:50
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.

Usage sign-in: a server restart forgets a running login, so a second CLI can sign in to the same home

1 participant