Repository navigation
Setup and bring-up under the root lock #113
Description
Closes #70. Blocked by #111 (single-flight is what keeps "telemetry once" and "one sudo prompt" true under the new lock).
Problem. Across windows or editors on one root, bring-up isn't serialised. Window A's autoStart holds the setup lock, but window B's notebook never asks for it. Both can file Grail in, create the gemdb account, stage MCP (stageMcp does rm) and fork two routers for one port. Separately, the first-run marker is read before the lock and re-checked inside it only as isInstalled(). A cancelled first run can therefore start again in another window (the gap is narrow).
What a double Grail file-in does (measured: 4.0.0.a4, 24 trials, throwaway stone): no damage.
- One run fails and the other succeeds. The failure is a commit conflict (2738), a Python error, or a DNU because the other run rebuilt the runtime classes. The database is left matching a single install.
- If both restage
grail/within about 100 ms, both fail and Python is missing until the next bring-up, which reinstalls cleanly. - So this is a fix for spurious failures and misleading telemetry. It is not data safety.
Not obvious:
-
The old lock and
withRootLockmust not be mixed within one release.- A
withRootLock({contention:'wait'})nested insidewithSetupLockwaits on its own pid. - While
withRootLockholds the lock,withSetupLockWhenFree'sif (heldHere) return work()lets a concurrent setup in the same window walk in.
Hence one PR: every caller moves, and
heldHere,withSetupLockandwithSetupLockWhenFreeare deleted. The callers that move arerunSetupOnce(withSetupLockWhenFree), the first run (prepareOnFirstRun) andautoStart(bothwithSetupLock). - A
-
Locks are leaf-only. Callers don't wrap the lock around their work; they pass
contentionandprecondition. The first run passescontention:'skip'with the preconditionmarker === 'none' && !isInstalled(), and it alone writes the marker. The sudo prompt stays outside the lock: a prompt waiting on an unanswered password must not block another window's Resume. -
Fix
takeMutexfirst. IfstopWaiting()throws, the queued grant is never removed and the in-process mutex is lost for good. A throwaway test confirmed it. It's latent today only becausewithRootLockhas no callers yet. -
Re-check inside the lock:
isRunning,grailFileInDue(not baregrailNeedsUpdate; it also honoursreinstallPythonOnUpdate) andmcpNeedsUpdate. -
Coordinate with Grail and MCP versioning: gaps the new install mechanism must not inherit #110. The Grail and MCP installer inside this lock is being replaced. The lock stays whatever the installer, and Grail and MCP versioning: gaps the new install mechanism must not inherit #110's "installed" record should live outside the directory it restages.
Scope:
- Fix
takeMutex. Test with a throwingstopWaiting. - Move the first run to
withRootLock, as described above. Port the test in Keep a setup's outcome accurate when another caller joins or waits for it #83's3e35a6b(setup.test.ts: a cell joining a cancelled first run reportssetupCancelled, with one download and the marker set tocancelled). - Run the bring-up body (processes, account, Grail, MCP, statistics) under
withRootLock.- Explicit callers wait, behind a cancellable "Waiting for another window…" notice. A cancel reports a new
DATABASE_OUTCOME.waitCancelled; updatedocs/telemetry.md. autoStartandresumeRunningpassskip.- The MCP resolve hook and
registerMcpClientalso wait. VS Code gives that hook no timeout.
- Explicit callers wait, behind a cancellable "Waiting for another window…" notice. A cancel reports a new
- Delete
heldHere,withSetupLockandwithSetupLockWhenFree. - Clean up the lock's log messages:
- "Clearing a setup lock left behind…" is logged when the file merely vanished.
- "recovered as stale once this window closes" is wrong for this window.
- "Another process is starting the database" is also logged when the holder is this host.
- Add a "lock rules" paragraph to CLAUDE.md. CHANGELOG entry.
Must stay safe beside 1.6.0 and 1.5.x in another editor: same lock file, same format.
Manual check:
- Fresh root, two windows activating at once: one download and one first-run prompt.
- Two windows pressing Start at once: one Grail file-in and one MCP fork.
When this merges, comment on #70 listing which part each PR covered. #94 already serialised the files step.
Activity
MatiasFernandez commentedon Oct 14, 2026
Scope item 5, third bullet ("Another process is starting the database" is also logged when the holder is this host): that message is the stone lock waiter in
withStoneLock(src/lock.ts), not the setup lock.PR #118 covers it for the stone lock in 0fbcb01 ("Keep waiting for the stone lock while its holder visibly makes progress"): when the holder is this same process, the message now starts "This window has been starting the database…". Once #118 merges, drop that bullet or narrow it to whatever the setup lock still logs.
MatiasFernandez commentedon Oct 16, 2026
Update after #140 (1.7.0):
ensureRunningcan now open a modal mid-bring-up (grailFileInAgreed→confirmGrailInstall, asking whether to install newer Python support). If step 3 wraps the bring-up body inwithRootLockas written, every other window waits behind an unanswered modal, which is the problem #70 was about. The question has to be asked outside the lock, with a re-check inside it (grailFirstInstallDueplus the recorded answer in.gemdb-grail-declined). Decide this before coding.Also:
grailFileInDueis gone (nowgrailFirstInstallDue/grailFileInAgreed) andgemdb.reinstallPythonOnUpdateis deprecated and unread; read references to them above in that light. #111 is closed (shipped in #117), so this is no longer blocked by it.