Skip to content

Start the stone and session listener once, across processes - #118

Merged
MatiasFernandez merged 15 commits into
mainfrom
mfernandez/handle-start-stone-concurrently
Oct 15, 2026
Merged

MatiasFernandez merged 15 commits into
mainfrom
mfernandez/handle-start-stone-concurrently

Conversation

@MatiasFernandez

@MatiasFernandez MatiasFernandez commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #112.

Problem

Three callers started NetLDI without a lock: the window (startProcesses), the GemDB Shell (cliMain.ts), and runStop's restart after a declined force-stop. When two of them raced, the loser's startnetldi failed. The window then reported "Start session listener failed" and stopped bring-up, although both processes were up (closed PR #89). Smaller problems fed into it:

  • A stone at Startup counted as running.
  • A caller that had only waited still sent databaseStarted.
  • The Shell started the stone without the window's pre-start guards.

What changed

  • ensureProcesses() (processes.ts) lists and starts the stone and NetLDI inside withStoneLock. It returns the flags for what this call started, so a window that only waited sends no databaseStarted. The window and the Shell use it. startNetldi is no longer exported, and startStone() is gone: the integration tests use a fixture helper instead.

  • Already up, no lock. One listing before the lock decides: when the stone is ready (or in a status we let through, below) and NetLDI is listed, ensureProcesses returns without taking the lock. Inside the lock it always lists again. The already-running path makes one gslist call. A start that only needs the listener makes two, because the in-lock listing doubles as the ready-wait's first reading. A cold start makes three.

  • Declined force-stop puts back only the listener. runStop calls ensureListener(), which under the lock starts NetLDI only if the stone is listed and not shutting down, and never starts the stone. runStop also re-checks the stone after the user declines, and logs "GemDB stopped." if it went away meanwhile.

  • NetLDI race tolerance. If startnetldi fails but gemdbldi is listed with status OK within 5 s, that counts as "another process started it". The 5 s matter for an overlap (exit 3, "could not start server" over the winner's lock file), where the winner's listener may not be answering yet when ours gives up. A start that really fails reports startnetldi's own error, up to 5 s later. This is still needed because 1.5.x and 1.6.0 start NetLDI with no lock.

  • "Listed" and "ready" are separate questions, with a status model. "Listed" decides whether to start a stone. The status decides what to do next, compared without case (the engine prints Startup, Shutdown, OK, frozen, exe deleted, …):

    • OK: ready.
    • startup, recovery: wait, up to a minute, then an error naming the stone log. Never started again.
    • shutdown: fail at once with "The database is shutting down…".
    • anything else listed (exe deleted, frozen, full, …): go ahead and let the login judge it, as before.

    findStone/isRunning are unchanged, so the status bar and maintenance still ask "is there a process".

  • Waiting on the stone lock. The holder now keeps the lock through the guards, startstone, the ready-wait and startnetldi. A waiter still gives up after a minute when the holder shows nothing; past that, a stone listed and not shutting down counts as progress and keeps it waiting for 30 s after it was last seen, up to 2.5 minutes. When the holder is this same window, the message says so instead of "another process", and still names the lock file to remove. A crashed process whose pid we happened to get again looks the same.

  • The gemdb wrapper gets the same model: one gslist -cvl per check (stone_status), branching on the lowercased status. Its waits are timed with date +%s instead of counted polls; a frozen stone makes every gslist -cvl take about 5 s, which stretched the old "minute" to most of an hour. The wrapper's fingerprint changes, so existing bin/gemdb files are rewritten once.

  • External databases in the wrapper get no lock and no start block: not listed → the administrator error, shutdown → error, starting → bounded wait, anything else → go ahead. startstone is unreachable for them.

  • The pre-start guards move to assertSafeToStart() (NFS, engine version, space limits, room for the extent). They run inside the lock, and the Shell now gets them too.

Commit 1 (the tolerance) stands alone and can be cherry-picked.

Worth a reviewer's attention

  • External database. ensureProcesses refuses external databases, so the window and the Shell both check one before calling it, sharing externalDatabaseDownError. Without that, every Shell start against an external database would throw. Centralising the check is noted on gemdb refuses to start a database without Python support #115, which edits the same function.
  • Wrapper re-check inside the lock. It decides on "listed", not "OK". With "OK" there, a stone still at Startup would get a second startstone beside it: the two-stones-on-one-extent case the lock exists for. A gslist row of unexpected shape also counts as listed, so it can never cause a start.
  • The stopping tail. A stopping stone reports OK → Shutdown → Startup (20–40 ms) → gone. Shutdown seen inside the lock fails at once; a first look that lands in the Startup tail still reports "stopped while it was starting". Coordinating Stop with start is Stop takes no lock, so a start in another window fails #125.

Testing

  • Unit: startProcesses.test.ts (new), plus updates to databaseStarted, externalLifecycle, externalDatabase, gslist, cli and stop. They cover:
    • every status class, in ensureProcesses and in the generated wrapper (exe deleted/frozen/full go ahead with one gslist call, Shutdown fails at once, Startup/recovery wait);
    • the already-up path taking no lock;
    • a lock holder that takes 70 s (startstone 20 s, then 50 s at Startup): the second waiter no longer fails at 60 s;
    • a declined force-stop after the stone went away starts nothing;
    • the external wrapper containing no startstone and no lock.
  • Integration: src/__integration__/processes.test.ts (new) races real node processes against a real engine:
    • two ensureProcesses calls: one started the stone, one started the listener, and pgrep counts exactly one netldid (two simultaneous startnetldi can both succeed and leave an orphan that gslist doesn't show, Two simultaneous startnetldi leave an orphaned NetLDI #127);
    • a raw, unlocked startnetldi (as 1.6.0 does) against ensureProcesses.
  • Full integration suite passed locally on macOS arm64 (114/114) before the review fixes; after them, processes, database and cli pass again on a throwaway root.
  • After the second review round (ea64653: the 5 s listener retry, the reused listing, the lock message): unit tests only, 945/945. The overlap test was checked against the code before the fix and fails there. Integration not re-run yet.
  • Manual, macOS arm64, before the review fixes: GemDB Shell opened while a window is starting the database. A hand-held stone lock (live pid) made the race deterministic: Start in the window and bin/gemdb both waited on it, and on release they raced for it. Result: one gemdb stone and one gemdbldi, both OK, the Shell reached its prompt, and the window's Start finished without "Start session listener failed".
  • Manual, macOS arm64, before the review fixes: Start while GemDB 1.6.0 in another editor is starting the database. Both editors queued on a hand-held stone lock. 1.6.0 won, started the stone, then started gemdbldi unlocked. This window waited and found both OK, started nothing and reported no error. The NetLDI tolerance branch was not reached by hand; the unit and integration tests cover it.
  • Manual, macOS arm64, before the review fixes: kill -9 the stone, then gemdb -c .... The wrapper started the stone, waited through crash recovery at Startup, then ran the code and exited 0. Three gemdb -c run in parallel after a kill -9: one started the stone, the others waited, all three ran, and pgrep showed a single stoned gemdb.

Known gaps, planned for later

A second review of this PR checked each finding against main. Those that are not regressions are tracked in #134, so they are deliberately not fixed here:

Also planned:

🤖 Generated with Claude Code

@MatiasFernandez
MatiasFernandez added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 15, 2026
MatiasFernandez and others added 15 commits October 15, 2026 18:17
Two callers that both find NetLDI missing both run startnetldi, and the
loser exits 1 with "already running" although everything it wanted is up.
startNetldi now re-lists after a failure and, when gemdbldi is there and
answering, logs that another process started it and reports false (not
started by this call) instead of throwing. Needed whatever else lands:
1.5.x and 1.6.0 keep starting NetLDI without any lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensureProcesses() looks for both processes and starts what is missing
inside withStoneLock, so the window, the GemDB Shell and the listener a
declined force-stop puts back no longer race each other. The flags it
returns say what this call started, so a window that queued behind
another start sends no databaseStarted of its own. Waiting for the lock
ends early only when both are up and answering.

The pre-start guards move to assertSafeToStart() in database.ts and run
through beforeStoneStart, so the Shell now gets the NFS, engine-version,
space-limit and disk-room checks (not statmonitor, which needs editor
settings). An external database is still only checked, by both callers.

startNetldi is no longer exported; integration tests that started both
processes use ensureProcesses() instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gslist lists a stone from the moment it exists, including the Startup
it sits in while recovering from a crash, so the lock's early exit and
the start path took a stone that would refuse logins as ready.
stoneReady/listenerReady ask for status OK; findStone and isRunning keep
meaning "a process exists" for the status bar and maintenance.

ensureProcesses and startStone wait for a listed stone to reach OK
before going on (polled on the stone lock's cadence, for up to a minute),
and end the lock wait only on OK. A stone that vanishes or is still
starting after the timeout is an error naming the stone log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stone_is_up split in two. stone_is_listed (plain gslist, as before)
decides whether to run startstone, so a stone still at Startup is never
started a second time beside itself. stone_is_ready (gslist -cvl, status
OK) is what lets a command go on to log in: the top-level check, the
lock loop's early exit, and a new wait of up to a minute for a listed
stone to finish starting, which ends in an error naming the stone log if
the stone disappears or never gets there.

The wrapper's fingerprint changes, so existing bin/gemdb files are
rewritten once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two node processes run ensureProcesses() against one temp root at the
same moment and must end with one stone and one NetLDI, both OK, with
exactly one of them reporting that it started the stone. A second test
reproduces 1.6.0 (#89): a raw, unlocked startnetldi timed to overlap
ensureProcesses' own; the ensureProcesses side must not fail.

The overlap showed the loser of a true race exits 3 ("could not start
server", the winner's lock file exists), not 1 "already running", so
the startNetldi comment now names both. Neither is matched on: the
tolerance checks the listener, not the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Waiting for exactly "OK" turned every other listed status into a minute's
wait ending in "starting for over a minute": "exe deleted" (a stone left
running when the engine tree was replaced, which takes logins), "frozen",
"full", and "Shutdown", which is a stop in progress. Main let all of those
go on to the login.

Only Startup and recovery are waited on now, Shutdown fails at once with a
message that says so, and anything else goes on to the login as before.
Statuses are compared without case because the engine prints them mixed.
stoneReady/listenerReady use the parser's `responding` instead of a second
spelling of the same test.

The wrapper asks gslist -cvl once per check (a frozen stone makes each
call take about five seconds) and branches on the status it printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ready wait was 600 polls of 0.1 s and the lock wait 100, each poll a
gslist call. Against a frozen stone every -cvl call takes about five
seconds, so "a minute" became most of an hour. Both waits now end at a
date +%s deadline, one gslist per iteration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For an external database the wrapper checked that the stone was listed and
then fell into the same block as GemDB's own: a stone at Startup took the
lock in GemDB's root path and, had it disappeared meanwhile, ran startstone
as this user on the administrator's stone, logging to a GemDB path that
does not describe it. GemDB never starts someone else's database.

The external branch is now only: not listed fails as before, Shutdown fails,
starting is waited for (by the clock), anything else proceeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declining "Stop Anyway" restarted the listener through ensureProcesses,
which starts whatever is missing. If the stone finished stopping while the
modal was up, that started it again: a database the user had just stopped,
brought back without the pre-start guards or statmonitor.

ensureListener starts the listener under the stone lock and nothing else,
and does nothing when the stone is gone or shutting down. runStop also
re-checks the stone after the answer and reports a stone that went away
as stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensureRunning runs before every notebook cell, and with the lock in place
each call made three gslist calls and a lock round trip to learn that
nothing needed starting; main made one. A listing before the lock now
returns at once when the stone is up (or in a state left to the login)
and the listener is listed. A stone starting or stopping always takes the
lock, and inside it the processes are listed again, since the first look
may be stale by then.

The listener is judged from waitForStoneReady's last listing, so a cold
start makes no more gslist calls than it did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A waiter gave up at the lock's one-minute deadline, but the holder now
keeps the lock through the guards (copydbf -i alone up to 30 s),
startstone, up to a minute's wait for the stone, and startnetldi. A start
that took 70 s (startstone 20 s, then 50 s at Startup) made a second
window fail at 60 s with "another process has been starting the database",
over a database that came up ten seconds later.

The minute still applies to a holder that shows nothing. Past it, a stone
listed and not shutting down counts as progress and keeps the wait going
for 30 s after it was last seen, up to 2.5 minutes. When the holder is this
very process the message says so instead of sending the user looking for
another one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
startStone had no production caller, its comment said the wrapper used it
(the wrapper starts the stone in shell), and it contradicted the rule that
processes start only through ensureProcesses. The integration tests that
wanted a stone without a listener get startStoneAlone in the fixture,
which goes through ensureProcesses and stops a listener it started.
References to it in comments and docs now name what they meant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The race test asserted one startedStone but nothing about the listener,
and gslist cannot catch a duplicate: two startnetldi run together can both
exit 0 and leave two netldid processes behind one row. It now asserts that
exactly one contender started the listener and counts netldid processes
for the fixture's listener with pgrep, keyed on the fixture's root path so
a developer's own gemdbldi is not counted.

CLAUDE.md still said the engine-version guard runs in startProcesses; it
runs in assertSafeToStart, which ensureProcesses calls as beforeStoneStart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r the lock

A startnetldi that loses an overlap (exit 3 over the winner's lock file) can
give up before the winner's listener answers, so a single re-list still failed
the start #89 is about. The loser now gives that listener a few seconds.

Inside the lock, the listing that decides whether to start the stone is reused
as the wait's first reading, rather than asking gslist again at once.

Also: the timeout for a stone lock holding this process's pid names the lock
again (it can be a stale one whose pid was reused), the lock comment no longer
claims copydbf -i takes 30 s (it reads a header), and a stop test's comment
gives the reason that still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MatiasFernandez
MatiasFernandez force-pushed the mfernandez/handle-start-stone-concurrently branch from ea64653 to 6ab1ce5 Compare October 15, 2026 21:20
@MatiasFernandez
MatiasFernandez added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 6601ef3 Oct 15, 2026
5 checks passed
@MatiasFernandez
MatiasFernandez deleted the mfernandez/handle-start-stone-concurrently branch October 15, 2026 21:42
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.

Follow-ups from the review of #118 (concurrent database starts) Start the stone and session listener once, across processes

2 participants