Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Starting GemDB from two places at once starts it once.** A cell run while the database was
starting on its own, cells in two notebooks, or a cell and **GemDB: Start** each ran the whole
start: two stone starts, two installs of Python support, two MCP servers. Now whatever asks while
a start is under way in the same window waits for that start and gets its result.
- **GemDB asks about shared memory once, even when two things need it.** A cell run while the
first-run question about shared memory was still open queued a second copy of it, and answering
both could open two `sudo` terminals. Now the second waits for the first answer. Running
**GemDB: Configure Shared Memory** while that setup is already running waits for it instead of
opening another terminal.
- **The MCP server is installed and started once when GemDB starts.** Opening a window on a running
database while a cell started it could install the MCP server's classes twice and start two
servers on one port. Now the second waits for the first.

## [1.6.0] - 2026-10-07

The engine is unchanged at GemStone 4.0.0.a4, but start with a fresh database: delete
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ measured about when to ask for shared memory.
whether the user pressed Start or just ran a notebook cell. It finishes any
outstanding preparation, prompts for shared memory, starts the processes, files
Grail in, and brings the MCP server up. New entry points that need a database
should call it rather than checking and asking.
should call it rather than checking and asking. Within a window it is
single-flight (`singleFlight.ts`): a second caller waits for the call already
in progress and gets its result, and only the first caller reports telemetry.

## CI

Expand Down
10 changes: 8 additions & 2 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ the Database Running After Logout** while RemoveIPC was unset. The dialog comes
back every time the database is needed until the user agrees, but a failure is
sent once for each trigger, outcome and `missing` until setup works or the
database next starts. Running either command is not deduped: every run is
sent.
sent. A request that arrives while the dialog, or the shared-memory setup it
started, is already under way in the same window waits for it and sends
nothing, so one dialog is one event, carrying the trigger of whatever opened
it. The same holds for **GemDB: Configure Shared Memory** run during that
setup, and for a dialog that would open while the command's setup is running.

| Property | Values |
| --------- | ----------------------------------------------------------------------------- |
Expand All @@ -167,7 +171,9 @@ sent.
The database was brought up, or failed to come up. Sent only when something
actually happened. Running a cell when the database is already up sends
nothing, and a failure is sent once for each trigger until the database next
starts.
starts. A request that arrives while a start is already under way in the same
window waits for it and sends nothing, so one start is one event, carrying the
trigger of whatever began it.

| Property / measure | Values |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
18 changes: 17 additions & 1 deletion src/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,16 @@ export const window = {
dispose(): void;
} {
__terminals.push(nameOrOptions as FakeTerminalOptions);
return { show: () => {}, sendText: () => {}, dispose: () => {} };
const terminal = { show: () => {}, sendText: () => {}, dispose: () => {} };
__openTerminals.push(terminal);
return terminal;
},
/** Fires when a test calls `__closeTerminal`, as the editor does when the user closes one. */
onDidCloseTerminal(listener: (terminal: unknown) => void): Disposable {
__closeTerminalListeners.push(listener);
return new Disposable(() => {
__closeTerminalListeners.splice(__closeTerminalListeners.indexOf(listener), 1);
});
},
/**
* Progress is a pass-through here: run the callback and return its result.
Expand Down Expand Up @@ -489,6 +498,13 @@ export interface FakeTerminalOptions {
};
}
export const __terminals: FakeTerminalOptions[] = [];
/** The terminal objects `createTerminal` handed back, oldest first. */
export const __openTerminals: object[] = [];
const __closeTerminalListeners: Array<(terminal: unknown) => void> = [];
/** Close a terminal the way the editor reports it to `onDidCloseTerminal`. */
export function __closeTerminal(terminal: object): void {
for (const listener of [...__closeTerminalListeners]) listener(terminal);
}

export const workspace = {
notebookDocuments: [] as unknown[],
Expand Down
221 changes: 221 additions & 0 deletions src/__tests__/oneBringUp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetSettings, __setSetting } from '../__mocks__/vscode';
import { eventsNamed, fakeExtensionContext } from './telemetryTestSupport';

// `ensureRunning` is single-flight within a window: two notebooks and Start
// pressed together would otherwise each run the whole bring-up, with two stone
// starts, two Grail file-ins and two events. These tests hold the stone start
// open on a promise so the calls genuinely overlap, then check that one run
// served them all and that only the caller that started it reported.
vi.mock('../cli', () => ({ writeCliScripts: () => {}, ensureCliCurrent: () => true }));
// Creating the database account logs in over GCI; that is integration's.
vi.mock('../account', () => ({ ensureDatabaseAccount: () => {} }));

const bundledGrailStamp = vi.fn(() => 'grail=0.1-1-gabc\n');
const grailNeedsUpdate = vi.fn(() => false);
const fileInGrail = vi.fn(async () => {});
vi.mock('../grail', () => ({
grailLabel: () => 'grail 0.1',
grailNeedsUpdate: () => grailNeedsUpdate(),
stageGrail: () => {},
fileInGrail: () => fileInGrail(),
bundledGrailStamp: () => bundledGrailStamp(),
}));

const grailInstalled = vi.fn(() => true);
const isInstalled = vi.fn(() => true);
vi.mock('../paths', () => ({
databaseExists: () => isInstalled(),
databasePath: () => '/db',
enginePath: () => (isInstalled() ? '/engine' : undefined),
grailInstalled: () => grailInstalled(),
grailPath: () => '/grail',
grailStagedOnDisk: () => isInstalled(),
mcpPath: () => '/mcp',
}));

const ensureOsConfigured = vi.fn(
async (_extensionPath: string, _trigger: string): Promise<string> => 'alreadyConfigured',
);
const isSharedMemoryConfigured = vi.fn(async () => true);
vi.mock('../osConfig', async (importOriginal) => {
const actual = await importOriginal<typeof import('../osConfig')>();
return {
OS_CONFIG_RESULT: actual.OS_CONFIG_RESULT,
osConfigAllowsStart: actual.osConfigAllowsStart,
ensureOsConfigured: (extensionPath: string, trigger: string) =>
ensureOsConfigured(extensionPath, trigger),
isSharedMemoryConfigured: () => isSharedMemoryConfigured(),
};
});

const findStone = vi.fn(() => true);
const findNetldi = vi.fn(() => true);
const startStone = vi.fn(async () => {});
const startNetldi = vi.fn(async () => {});
vi.mock('../processes', () => ({
listProcesses: () => [],
findStone: () => findStone(),
findNetldi: () => findNetldi(),
startStone: () => startStone(),
startNetldi: () => startNetldi(),
isListening: () => true,
isRunning: () => true,
stopNetldi: async () => {},
stopStone: async () => {},
}));

// The MCP server is off unless a test turns `gemdb.mcp.enabled` on.
const mcpNeedsUpdate = vi.fn(() => false);
const installMcp = vi.fn(async () => {});
const isMcpRunning = vi.fn(async () => false);
const startMcpServer = vi.fn(async () => true);
vi.mock('../mcp', async (importOriginal) => ({
...(await importOriginal<typeof import('../mcp')>()),
bundledMcpStamp: () => 'mcp=abc\n',
mcpNeedsUpdate: () => mcpNeedsUpdate(),
stageMcp: () => {},
installMcp: () => installMcp(),
recordMcpInstalled: () => {},
isMcpRunning: () => isMcpRunning(),
startMcpServer: () => startMcpServer(),
}));

vi.mock('../platform', () => ({ isSupportedPlatform: () => true, setContext: () => {} }));
vi.mock('../autoStart', () => ({
allowAutoStart: () => {},
autoStartSuppressed: () => false,
initAutoStart: () => {},
suppressAutoStart: () => {},
}));

const { ensureRunning, resumeMcpServing } = await import('../lifecycle');
const { TRIGGER, initTelemetry } = await import('../telemetry');
const { initUnattendedSetupMarker } = await import('../unattendedSetupMarker');

/** A promise the test settles by hand, so a bring-up stays in progress until it chooses. */
function deferred() {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

describe('ensureRunning', () => {
beforeEach(() => {
__resetSettings();
bundledGrailStamp.mockReturnValue('grail=0.1-1-gabc\n');
grailNeedsUpdate.mockReturnValue(true);
grailInstalled.mockReturnValue(false);
isInstalled.mockReturnValue(true);
ensureOsConfigured.mockReset().mockResolvedValue('alreadyConfigured');
isSharedMemoryConfigured.mockReset().mockResolvedValue(true);
fileInGrail.mockClear();
findStone.mockReturnValue(false);
findNetldi.mockReturnValue(true);
startStone.mockReset();

const context = fakeExtensionContext();
initTelemetry(context, false);
initUnattendedSetupMarker(context.globalStorageUri.fsPath);
});

describe('when called while a bring-up is in progress', () => {
it('serves every caller with one run, reported once under the first caller', async () => {
const stoneStart = deferred();
startStone.mockImplementation(async () => {
await stoneStart.promise;
findStone.mockReturnValue(true);
});

const calls = [
ensureRunning('/ext', TRIGGER.autoStart),
ensureRunning('/ext', TRIGGER.notebook),
ensureRunning('/ext', TRIGGER.startCommand),
];
stoneStart.resolve();

expect(await Promise.all(calls)).toEqual([true, true, true]);
expect(startStone).toHaveBeenCalledTimes(1);
expect(fileInGrail).toHaveBeenCalledTimes(1);
const events = eventsNamed('databaseStarted');
expect(events).toHaveLength(1);
expect(events[0].properties).toMatchObject({
trigger: 'autoStart',
outcome: 'started',
filedGrail: 'firstTime',
});
});

it('fails every caller together when the run fails, reporting the failure once', async () => {
const stoneStart = deferred();
startStone.mockImplementation(() => stoneStart.promise);

const calls = [
ensureRunning('/ext', TRIGGER.notebook),
ensureRunning('/ext', TRIGGER.notebook),
ensureRunning('/ext', TRIGGER.startCommand),
];
stoneStart.reject(new Error('stone would not start'));

expect(await Promise.all(calls)).toEqual([false, false, false]);
expect(startStone).toHaveBeenCalledTimes(1);
const events = eventsNamed('databaseStarted');
expect(events).toHaveLength(1);
expect(events[0].properties).toMatchObject({ trigger: 'notebook', outcome: 'startFailed' });
});
});

it('starts a new run for a call made after the previous one has settled', async () => {
startStone.mockImplementation(async () => {});
ensureOsConfigured.mockResolvedValue('configured');

await ensureRunning('/ext', TRIGGER.notebook);
await ensureRunning('/ext', TRIGGER.startCommand);

expect(ensureOsConfigured).toHaveBeenCalledTimes(2);
expect(startStone).toHaveBeenCalledTimes(2);
});
});

// Activation's `resumeMcpServing` and a cell's `ensureRunning` both reach
// `ensureMcpServing`; on a running stone they would otherwise install the
// classes twice and fork two routers at one port.
describe('ensureMcpServing', () => {
beforeEach(() => {
__resetSettings();
__setSetting('gemdb.mcp.enabled', true);
grailNeedsUpdate.mockReturnValue(false);
grailInstalled.mockReturnValue(true);
isInstalled.mockReturnValue(true);
ensureOsConfigured.mockReset().mockResolvedValue('alreadyConfigured');
findStone.mockReturnValue(true);
findNetldi.mockReturnValue(true);
mcpNeedsUpdate.mockReturnValue(true);
installMcp.mockReset();
isMcpRunning.mockReset().mockResolvedValue(false);
startMcpServer.mockReset().mockResolvedValue(true);

const context = fakeExtensionContext();
initTelemetry(context, false);
initUnattendedSetupMarker(context.globalStorageUri.fsPath);
});

it('installs and starts the MCP server once when resume and a start overlap', async () => {
const install = deferred();
installMcp.mockImplementation(() => install.promise);

const resumed = resumeMcpServing('/ext');
const started = ensureRunning('/ext', TRIGGER.notebook);
// Let both callers reach the MCP step before the install finishes.
await new Promise((resolve) => setTimeout(resolve, 20));
install.resolve();

expect(await Promise.all([resumed, started])).toEqual([true, true]);
expect(installMcp).toHaveBeenCalledTimes(1);
expect(startMcpServer).toHaveBeenCalledTimes(1);
});
});
Loading
Loading