Repository navigation
Start the stone and session listener once, across processes - #118
Merged
MatiasFernandez merged 15 commits intoOct 15, 2026
Merged
Conversation
MatiasFernandez
force-pushed
the
mfernandez/handle-start-stone-concurrently
branch
from
October 13, 2026 16:43
d7ee4b2 to
43e50d3
Compare
This was referenced Oct 14, 2026
MatiasFernandez
force-pushed
the
mfernandez/handle-start-stone-concurrently
branch
2 times, most recently
from
October 15, 2026 19:05
99d54b7 to
15433f4
Compare
MatiasFernandez
marked this pull request as ready for review
October 15, 2026 20:04
jgfoster
approved these changes
Oct 15, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Oct 15, 2026
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
force-pushed
the
mfernandez/handle-start-stone-concurrently
branch
from
October 15, 2026 21:20
ea64653 to
6ab1ce5
Compare
MatiasFernandez
enabled auto-merge
October 15, 2026 21:20
MatiasFernandez
deleted the
mfernandez/handle-start-stone-concurrently
branch
October 15, 2026 21:42
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.
Closes #112.
Problem
Three callers started NetLDI without a lock: the window (
startProcesses), the GemDB Shell (cliMain.ts), andrunStop's restart after a declined force-stop. When two of them raced, the loser'sstartnetldifailed. 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:Startupcounted as running.databaseStarted.What changed
ensureProcesses()(processes.ts) lists and starts the stone and NetLDI insidewithStoneLock. It returns the flags for what this call started, so a window that only waited sends nodatabaseStarted. The window and the Shell use it.startNetldiis no longer exported, andstartStone()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,
ensureProcessesreturns without taking the lock. Inside the lock it always lists again. The already-running path makes onegslistcall. 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.
runStopcallsensureListener(), which under the lock starts NetLDI only if the stone is listed and not shutting down, and never starts the stone.runStopalso re-checks the stone after the user declines, and logs "GemDB stopped." if it went away meanwhile.NetLDI race tolerance. If
startnetldifails butgemdbldiis listed with statusOKwithin 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 reportsstartnetldi'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…".exe deleted,frozen,full, …): go ahead and let the login judge it, as before.findStone/isRunningare 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 andstartnetldi. 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
gemdbwrapper gets the same model: onegslist -cvlper check (stone_status), branching on the lowercased status. Its waits are timed withdate +%sinstead of counted polls; a frozen stone makes everygslist -cvltake about 5 s, which stretched the old "minute" to most of an hour. The wrapper's fingerprint changes, so existingbin/gemdbfiles 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.startstoneis 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
ensureProcessesrefuses external databases, so the window and the Shell both check one before calling it, sharingexternalDatabaseDownError. Without that, every Shell start against an external database would throw. Centralising the check is noted ongemdbrefuses to start a database without Python support #115, which edits the same function.Startupwould get a secondstartstonebeside it: the two-stones-on-one-extent case the lock exists for. Agslistrow of unexpected shape also counts as listed, so it can never cause a start.OK→Shutdown→Startup(20–40 ms) → gone.Shutdownseen inside the lock fails at once; a first look that lands in theStartuptail 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
startProcesses.test.ts(new), plus updates todatabaseStarted,externalLifecycle,externalDatabase,gslist,cliandstop. They cover:ensureProcessesand in the generated wrapper (exe deleted/frozen/fullgo ahead with onegslistcall,Shutdownfails at once,Startup/recoverywait);startstone20 s, then 50 s atStartup): the second waiter no longer fails at 60 s;startstoneand no lock.src/__integration__/processes.test.ts(new) races real node processes against a real engine:ensureProcessescalls: one started the stone, one started the listener, andpgrepcounts exactly onenetldid(two simultaneousstartnetldican both succeed and leave an orphan thatgslistdoesn't show, Two simultaneousstartnetldileave an orphaned NetLDI #127);startnetldi(as 1.6.0 does) againstensureProcesses.processes,databaseandclipass again on a throwaway root.bin/gemdbboth waited on it, and on release they raced for it. Result: onegemdbstone and onegemdbldi, bothOK, the Shell reached its prompt, and the window's Start finished without "Start session listener failed".gemdbldiunlocked. This window waited and found bothOK, started nothing and reported no error. The NetLDI tolerance branch was not reached by hand; the unit and integration tests cover it.kill -9the stone, thengemdb -c .... The wrapper started the stone, waited through crash recovery atStartup, then ran the code and exited 0. Threegemdb -crun in parallel after akill -9: one started the stone, the others waited, all three ran, andpgrepshowed a singlestoned 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:gemdb file.pystarts the stone without the pre-start guards. After an engine pin move it gets a raw 4045 instead of the "delete this directory" message. This was already the case onmain; this PR guards the Shell only. It touches the same lines asgemdbrefuses to start a database without Python support #115.ensureProcessesthrough the staged Node bundle, which also closes Follow-ups from the review of #118 (concurrent database starts) #134.1 and Follow-ups from the review of #118 (concurrent database starts) #134.3.Startup. The wrapper waits, the window and Shell don't (unchanged frommain). The external-is-down check is also duplicated betweenlifecycle.tsandcliMain.ts.main; the message now at least names the lock.main, a different message. See also Stop takes no lock, so a start in another window fails #125.killedrow in the wrapper, the log line after a declined force-stop atShutdown,$STATUSreuse in the wrapper, the duplicate### Fixedheadings, and the overlap betweenensureListenerandensureProcesses.Also planned:
startnetldican leave an orphaned NetLDI. The lock prevents it between these processes, not against 1.5.x/1.6.0: Two simultaneousstartnetldileave an orphaned NetLDI #127.system.confkeeps whatever the window's last start wrote.waitCancelledwork.startnetldicalls up, but which one wins varies. The tolerance branch ran in about 1 of 8 local runs; the unit tests cover it every time.🤖 Generated with Claude Code