Skip to content

Setup and bring-up under the root lock #113

Open

Description

@MatiasFernandez

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 withRootLock must not be mixed within one release.

    • A withRootLock({contention:'wait'}) nested inside withSetupLock waits on its own pid.
    • While withRootLock holds the lock, withSetupLockWhenFree's if (heldHere) return work() lets a concurrent setup in the same window walk in.

    Hence one PR: every caller moves, and heldHere, withSetupLock and withSetupLockWhenFree are deleted. The callers that move are runSetupOnce (withSetupLockWhenFree), the first run (prepareOnFirstRun) and autoStart (both withSetupLock).

  • Locks are leaf-only. Callers don't wrap the lock around their work; they pass contention and precondition. The first run passes contention:'skip' with the precondition marker === '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 takeMutex first. If stopWaiting() 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 because withRootLock has no callers yet.

  • Re-check inside the lock: isRunning, grailFileInDue (not bare grailNeedsUpdate; it also honours reinstallPythonOnUpdate) and mcpNeedsUpdate.

  • 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:

  1. Fix takeMutex. Test with a throwing stopWaiting.
  2. 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's 3e35a6b (setup.test.ts: a cell joining a cancelled first run reports setupCancelled, with one download and the marker set to cancelled).
  3. 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; update docs/telemetry.md.
    • autoStart and resumeRunning pass skip.
    • The MCP resolve hook and registerMcpClient also wait. VS Code gives that hook no timeout.
  4. Delete heldHere, withSetupLock and withSetupLockWhenFree.
  5. 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.
  6. 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

  1. MatiasFernandez commented on Oct 14, 2026

    @MatiasFernandez
    CollaboratorAuthor

    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.

  2. MatiasFernandez commented on Oct 16, 2026

    @MatiasFernandez
    CollaboratorAuthor

    Update after #140 (1.7.0): ensureRunning can now open a modal mid-bring-up (grailFileInAgreed → confirmGrailInstall, asking whether to install newer Python support). If step 3 wraps the bring-up body in withRootLock as 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 (grailFirstInstallDue plus the recorded answer in .gemdb-grail-declined). Decide this before coding.

    Also: grailFileInDue is gone (now grailFirstInstallDue / grailFileInAgreed) and gemdb.reinstallPythonOnUpdate is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions