Skip to content

Run one bring-up at a time within a window - #117

Merged
MatiasFernandez merged 8 commits into
mainfrom
mfernandez/one-bring-up-at-a-time
Oct 14, 2026
Merged

MatiasFernandez merged 8 commits into
mainfrom
mfernandez/one-bring-up-at-a-time

Conversation

@MatiasFernandez

@MatiasFernandez MatiasFernandez commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Inside one window, ensureRunning didn't share a call that was already running. Auto-start plus a cell, two notebooks, or a cell plus Start each ran the whole bring-up at once: two stone starts, two Grail file-ins, two MCP installs and starts, and a databaseStarted event from each. Separately, the first-run shared-memory modal and a cell's modal could both show, and both could run the sudo script.

Closes part of #111.

What

A small singleFlight helper (src/singleFlight.ts). The first caller starts the work, and anyone who arrives before it finishes joins it and gets the same result, including a rejection. runSetup already did this inline and now uses the helper. One commit for each of the first four:

  1. Helper. No change in behaviour.
  2. ensureRunning. A joiner waits for the whole call, from the shared-memory prompt to the MCP start. Only the caller that started the call reports databaseStarted, under its own trigger, for failures as well as successes.
  3. Shared memory. ensureOsConfigured is single-flight, so osConfigPrompted is sent once, under the first trigger. The sudo script is single-flight across the prompt and GemDB: Configure Shared Memory, so only one terminal opens. Item 6 reworks how the prompt and the command share it.
  4. ensureMcpServing. resumeMcpServing at activation and ensureRunning can no longer install and start the router twice.

After review

  1. Test cleanup. The single-flight test files release any modal, terminal or held bring-up after each test, so one failure no longer cascades into the rest of the file.
  2. The prompt and the command share only the script.
    • The prompt always checks shared memory first, so a cell never waits on a terminal it doesn't need.
    • If memory is short, the prompt shows its modal as usual. "Configure" joins a script that is already running instead of opening a second terminal, and on Linux the RemoveIPC offer still follows.
    • The command checks, then starts or joins the script, then checks again. It never depends on a modal's answer, so declining a modal no longer cancels a command the user ran explicitly.
    • SingleFlight.current is gone, so no flight looks into another's slot.
  3. Stop during a bring-up.
    • A caller that joins a bring-up after Stop was pressed on it now waits for the Stop and then starts the database again. This applies to every caller except auto-start. Before, such a caller took the shared result, which described a database the Stop had since taken down.
    • A Stop pressed after the caller joined still wins.
    • allowAutoStart() moved into ensureRunning so callers that join count too, again except auto-start itself.
  4. Docs. docs/telemetry.md, the CHANGELOG and CLAUDE.md describe these exceptions.

The CHANGELOG has a Fixed entry for each of 2–4.

Worth checking

  • Telemetry semantics: a start that auto-start began and a cell joined is reported as trigger: autoStart. That's deliberate: one bring-up gives one event.
  • The command sends its own event: GemDB: Configure Shared Memory always sends osConfigPrompted when memory was short, even when it joined the prompt's script, as on main. So one script run can give two events: one for the modal and one for the command.
  • A modal can appear while the command's sudo terminal is open: its "Configure" joins that terminal, so there is still only one terminal.
  • Joiners see less progress: a joiner doesn't get its own progress notification for the shared work. For example, if ensureRunning joins an MCP start that resumeMcpServing began, its "Starting GemDB" notification never says "Installing the MCP server…". A cell that is waiting still shows VS Code's pending icon, so the user can see the run was accepted.
  • After a Stop, a caller waits longer: it waits for the old bring-up, then the Stop (including its "Stop Anyway" modal), then a new bring-up.
  • The test mock gained terminals: src/__mocks__/vscode.ts now has onDidCloseTerminal, plus __closeTerminal and __closeAllTerminals, so the tests can finish and clean up sudo terminals.

Known gaps (planned, not in this PR)

All of these are tracked as separate packages of the same concurrency work:

  • Cross-window races are untouched. Two windows can still each ask about shared memory, or both try to start. Everything here is per extension host.
  • A stuck bring-up blocks the window. No step has a timeout or escape: a sudo terminal left open or a hung MCP start makes every entry point in the window wait. Running one bring-up at a time trades this for no more duplicate bring-ups. The cancellable wait in Setup and bring-up under the root lock #113 addresses it.
  • Stop takes no lock (package J):
    • Two Stops in flight at once can let a caller start again before the earlier Stop finishes.
    • Auto-start doesn't check whether Stop was pressed once it has another window's setup lock. That was already the case on main.
  • No second check after "Configure". If the command's script raises memory while a modal is still open (possible with cached sudo credentials), "Configure" opens a second terminal one after the other (plan I/H25).
  • reinstallGrail still runs its own file-in instead of joining a bring-up.
  • startedStone is reported as true when another process had already started the stone.
  • Nothing here touches the locks in lock.ts.
  • One planned test is missing: an activation-level test for "a cell during the first-run modal leads to one modal". activation.test.ts mocks both lifecycle and osConfig, so it can't reach the real wrappers. The same case is covered one layer down in oneOsPrompt.test.ts, with the firstRun and notebook triggers overlapping.

Testing

  • New unit tests:
    • singleFlight.test.ts.
    • oneBringUp.test.ts: overlapping bring-up, shared failure, fresh run after settle, overlapping MCP start, plus the Stop cases: a cell after Stop during the MCP step and during a failed file-in, no Stop in between, a Stop after joining, a Stop cancelled at its modal, and auto-start joining after a Stop.
    • oneOsPrompt.test.ts: overlapping prompts, the command and the prompt overlapping in either order, a declined modal not cancelling the command, memory already enough while the command's terminal is open, and the Linux RemoveIPC offer and its terminal.
    • I checked each one by reverting the code it covers and watching it fail.
  • npm test (798 passed), lint, format check and both typechecks all pass. The single-flight test files passed 20 runs in a row, and 20 more with every CPU core busy.
  • npm run test:integration passed (112 passed, none skipped) with the engine, the payload and the test extent built. ⚠️ That was before the review commits (5–8); it needs re-running.
  • Manual checks, done on macOS arm64 before the review commits:
    • Two notebooks, one bring-up. I stopped the database, deleted <root>/grail/.gemdb-grail-stamp to make Grail file in again, and ran a cell in each notebook during the Grail install. I saw one "Starting GemDB" notification, one stone and NetLDI start, one Grail file-in, and one GemDB is already starting; waiting for it. from the second notebook. With gemdb.mcp.enabled on (it's off by default), I also saw one MCP install and one router fork. Both cells showed VS Code's pending icon while they waited, and both ran once the bring-up finished.
    • Shared memory, prompt then command. With shared memory lowered (scripts/unset-os-config.sh), a cell opened one modal. Pressing Configure opened one sudo terminal. While it waited for the password, a second notebook's cell joined the bring-up, and GemDB: Configure Shared Memory joined the script. Neither opened a modal or a second terminal. After the password, I saw one "Shared memory configured.", and the database came up and both cells ran. After commit 6 the command also sends its own osConfigPrompted event.
    • Shared memory, command then cell. ⚠️ To re-run: commit 6 changed this case. With memory short, the cell now shows a modal while the command's terminal is open. "Configure" should join that terminal (no second one), and the cell should run once the script finishes.
    • Not reachable by hand: a cell during the first-run modal. The modal blocks the rest of the window, so no cell can start while it's open. That overlap stays covered by oneOsPrompt.test.ts.

🤖 Generated with Claude Code

MatiasFernandez and others added 4 commits October 13, 2026 13:39
runSetup already let a second caller join the setup in progress; the
pattern moves into src/singleFlight.ts so ensureRunning, the
shared-memory prompt and MCP serving can use the same one. No change in
behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Auto-start and a cell, two notebooks, or a cell and Start each ran the
whole of ensureRunning at the same time: two startProcesses, two
account checks, two Grail file-ins, two MCP installs and starts, and a
databaseStarted for each. ensureRunning is now single-flight within a
window. A caller arriving mid-flight joins the call in progress and gets
its result; only the caller that started it reports, under its own
trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first-run prompt and a cell's ensureRunning each opened a modal;
VS Code queued the second, and answering both could run the sudo script
twice. ensureOsConfigured is now single-flight, so a second caller takes
the first one's answer and osConfigPrompted is sent once, under the
first caller's trigger.

The shared-memory script is single-flight too, whether the prompt or
"GemDB: Configure Shared Memory" runs it. The command during a prompt
waits for it and shows its usual message without a second terminal; a
prompt during the command's script waits, checks again and returns
without a modal. Only the caller that ran the script reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
resumeMcpServing at activation and ensureRunning both reach
ensureMcpServing, and on a running stone they could install the MCP
classes twice and race to fork two routers onto one port.
ensureMcpServing is now single-flight; a second caller waits for the
first and gets its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MatiasFernandez
MatiasFernandez force-pushed the mfernandez/one-bring-up-at-a-time branch from 973d42c to fb953b8 Compare October 13, 2026 16:42
MatiasFernandez and others added 4 commits October 13, 2026 17:29
The prompt, the shared-memory script, the bring-up and the MCP step are
module-level single flights, and nothing reset them between tests. A test
that failed with a modal unanswered, a terminal open or a deferred
unresolved handed its flight to every later test, which joined it and
timed out, so one failure read as many.

oneOsPrompt.test.ts now cancels any pending modal and closes every
terminal (a new __closeAllTerminals in the vscode mock) after each test,
for a few rounds so a follow-on step gets drained too, before the spies
are restored. oneBringUp.test.ts records every deferred it hands out and
resolves them all after each test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prompt and "GemDB: Configure Shared Memory" coordinated by peeking at
each other's single flight: a prompt that saw the command's script running
waited for it instead of probing and asking, and the command joined the
whole prompt when one was under way. Both shortcuts skipped steps.

Now each runs its normal sequence and they share only the script. The
prompt always probes, shows the modal, starts or joins the script, then
offers RemoveIPC. The command probes, starts or joins the script, probes
again and reports, and never looks at the prompt.

This closes the gaps the shortcuts opened:
- a cell no longer waits on the command's sudo terminal when memory is
  already enough; it answers alreadyConfigured at once
- the command runs its script even when the modal it overlapped is
  cancelled
- a prompt that joins the command's script still offers RemoveIPC on Linux
- the command no longer waits on the RemoveIPC terminal after its script
- a cell that showed no modal no longer answers configured, which counted
  as GemDB having prompted
- the command's first probe is used again rather than wasted

The command now always sends its own osConfigPrompted event when memory
was short, as on main, including when it joined the prompt's script.
A modal can appear while the command's terminal is open, but "Configure"
joins that terminal, so there is still one sudo prompt.

SingleFlight loses its `current` getter, which nothing reads any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stop takes no lock, so it can land on a bring-up already under way. A cell
run or Start pressed after that Stop joined the window's bring-up and took
its result, which described a database Stop had since taken down: the stone
stayed stopped, the cell failed at login or met "GemDB is not running", and
the joiner never ran allowAutoStart, so auto-start stayed suppressed. On
main that caller ran a bring-up of its own and started the stone again.

stop() now counts the Stops begun in this window and keeps the one under
way; the bring-up's owner records the count when it starts. A caller that
joins with a different count, meaning a Stop began after the bring-up did,
waits for the shared result (true or false), waits for that Stop, and runs
ensureRunning again. A Stop pressed after the caller joined still wins.
Each rerun needs another Stop, so this cannot loop. No process check is
involved: isRunning blocks the extension host.

allowAutoStart moves from the bring-up body into ensureRunning, so a joiner
retracts an earlier Stop too. Auto-start is the exception: it is GemDB's
own doing, and calling it there would undo a Stop pressed while auto-start
waits on another window's setup.

A Stop cancelled at its "Stop Anyway" modal is harmless: a caller that
joined after it waits for the modal, then reruns, finds the stone up, and
only brings back the MCP server Stop had already taken down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The telemetry doc said the command waited for the prompt's setup and sent
nothing; it now shares the script's terminal and sends its own event, and a
dialog that opens while the command's setup runs is shown and sent as usual.
databaseStarted, the changelog and CLAUDE.md now say that a caller arriving
after a Stop on the bring-up starts again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MatiasFernandez
MatiasFernandez marked this pull request as ready for review October 14, 2026 16:52
@MatiasFernandez
MatiasFernandez added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit dbfc417 Oct 14, 2026
5 checks passed
@MatiasFernandez
MatiasFernandez deleted the mfernandez/one-bring-up-at-a-time branch October 14, 2026 21:34
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.

2 participants