Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
67e37a8
Tolerate a session listener start that lost a race (#89)
MatiasFernandez Oct 9, 2026
7e10629
Start the stone and session listener once, under the stone lock
MatiasFernandez Oct 9, 2026
b946017
Count the stone as up at OK, not as soon as gslist lists it
MatiasFernandez Oct 9, 2026
11417ad
Have the gemdb wrapper wait for the stone to reach OK
MatiasFernandez Oct 9, 2026
2375814
Note in CLAUDE.md how the processes start and what ready means
MatiasFernandez Oct 9, 2026
622d15b
Race two real processes through ensureProcesses in integration
MatiasFernandez Oct 9, 2026
45c129f
Read every gslist status, not just OK, before waiting on the stone
MatiasFernandez Oct 14, 2026
a5246b8
Time the wrapper's waits by the clock instead of counting polls
MatiasFernandez Oct 14, 2026
c696a7a
Generate no lock and no start for an external database in the wrapper
MatiasFernandez Oct 14, 2026
429c594
Put back only the listener after a declined force-stop
MatiasFernandez Oct 14, 2026
71536b4
Skip the stone lock when both processes are already up
MatiasFernandez Oct 14, 2026
f944261
Keep waiting for the stone lock while its holder visibly makes progress
MatiasFernandez Oct 14, 2026
71bb051
Drop startStone, a second way to start the stone that nothing ships
MatiasFernandez Oct 14, 2026
a50913d
Count listeners in the race test by process, and fix the guard note
MatiasFernandez Oct 14, 2026
6ab1ce5
Give an overlapping listener time to answer, and list once fewer unde…
MatiasFernandez Oct 15, 2026
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
shown only when the program could not run to the end, such as a refused login, and a
`.topazini` file is no longer read.

### Fixed

- **Starting GemDB while something else is starting it no longer reports a failure.** Pressing
Start, running a notebook cell or opening the GemDB Shell while another window or terminal was
bringing the database up could end in "Start session listener failed (exit code 1)", although the
database and its session listener were both running. GemDB now checks for and starts the session
listener under the same lock as the database, so one start waits for the other, and a listener
started by an older GemDB in another editor counts as started.
- **GemDB waits for a database that is still starting before logging in to it.** After a crash
the database takes a while to recover before it accepts logins, and GemDB treated it as running
as soon as it appeared, so the first notebook cell, the GemDB Shell or a `gemdb` command could
fail to log in. GemDB and the `gemdb` command now wait for the database to finish starting, for up
to a minute, and then name the database's log if it has not.
- **The GemDB Shell checks the database before starting it, as the window does.** Opening the
Shell with the database stopped started it without the checks the window makes first, so a
database the installed engine cannot read started and then refused every login, and one on a
network drive or a disk without room for it failed with the engine's own error. The Shell now
refuses with the same explanation the window gives, and applies the space limits a database
from an earlier GemDB is missing.

## [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
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ in progress and gets its result, and only the first caller reports telemetry —
except after a Stop: a caller other than auto-start arriving once a Stop began
during that bring-up waits for the Stop and starts again.

Below it, the processes themselves start only through `ensureProcesses` in
`processes.ts`, which checks and starts under the stone lock (NetLDI cannot be
started any other way; `ensureListener` is the listener-only door a declined
force-stop uses), and "ready" means gslist status `OK`, not "listed": a
stone at `Startup` is listed and refuses logins, so wait on it, never start it.
`stoneState` reads the rest, case-insensitively since the engine prints mixed
case: `Shutdown` fails at once, and any other listed status (`exe deleted`,
`frozen`, ...) goes on to the login as it always did. The wrapper in `cli.ts`
makes the same calls in shell; change both together.

## CI

`.github/workflows/ci.yml`, two jobs split on what they need. `checks` runs
Expand Down Expand Up @@ -240,8 +250,9 @@ something GemDB could do on a user's behalf. The guard reads the repository's
version with
`copydbf -i` and refuses, naming both versions and the directory to delete.
Checked in two places, and both are needed: `prepareFiles`, which is the
first-install path, and `startProcesses` before the stone starts, because an
extension update reaches that line without preparing anything — engine
first-install path, and `assertSafeToStart` before the stone starts (the
`beforeStoneStart` that `ensureProcesses` runs inside the stone lock), because
an extension update reaches that line without preparing anything — engine
downloaded, database present, Grail staged, so `isInstalled()` is true.

**Below its free-space threshold the stone stops collecting garbage.** GemDB
Expand Down
2 changes: 1 addition & 1 deletion docs/repository-space.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ DBF_PRE_GROW = TRUE;
numbers and a way out rather than the stone's log line: `assertRoomForSetup`
before setup downloads anything (the cap plus the engine), and
`assertRoomForExtent` before every start (what the extent still needs), and
`startStone` explains a startup that failed with "No space left on device"
`runStartstone` explains a startup that failed with "No space left on device"
anyway. With the extent pregrown, `SystemRepository freeSpace` alone is the
room left, for every tool that reads it.
- **The test suite reserves 1 GB, not 10.** Each integration file makes a
Expand Down
5 changes: 2 additions & 3 deletions src/__integration__/breakpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
setVariableQuery,
unquote,
} from '../pauseVariables';
import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes';
import { ensureProcesses, isRunning, stopNetldi, stopStone } from '../processes';
import { fileOwner } from '../fileOwner';
import { runPython, runPythonFile } from '../pythonQueries';
import {
Expand Down Expand Up @@ -94,8 +94,7 @@ beforeAll(async () => {
if (!fixture) return;
createDatabaseWithPython(fixture);
stageGrail(ext);
await startStone();
await startNetldi();
await ensureProcesses();
});

afterEach(() => setHaltHandler(undefined));
Expand Down
12 changes: 9 additions & 3 deletions src/__integration__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ import * as path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { cliPath } from '../cli';
import { bundledGrailStamp, stageGrail } from '../grail';
import { isRunning, startStone, stopStone } from '../processes';
import { createDatabaseWithPython, Fixture, haveTestExtent, makeFixture } from './fixture';
import { isRunning, stopStone } from '../processes';
import {
createDatabaseWithPython,
Fixture,
haveTestExtent,
makeFixture,
startStoneAlone,
} from './fixture';

/**
* The `gemdb` shell command, run the way a user runs it: bash, a file
Expand Down Expand Up @@ -69,7 +75,7 @@ beforeAll(async () => {

createDatabaseWithPython(fixture); // Python already filed in — see testExtentPath
stageGrail(ext); // stages Grail and generates the CLI
await startStone();
await startStoneAlone();

fs.writeFileSync(path.join(workDir, 'hello.py'), 'print("hello from", 6 * 7)\n');
fs.writeFileSync(path.join(workDir, 'both.py'), 'print("partial")\n1 / 0\n');
Expand Down
10 changes: 4 additions & 6 deletions src/__integration__/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import { createDatabase } from '../database';
import { isSharedMemoryConfigured } from '../osConfig';
import { databaseExists, databaseStatPath, extentPath } from '../paths';
import {
ensureProcesses,
findNetldi,
findStone,
isListening,
isRunning,
listProcesses,
startNetldi,
startStone,
stopNetldi,
stopStone,
} from '../processes';
import { execute, isConnected, logout } from '../session';
import { ensureStatmonitor, recordedFiles } from '../statmonitor';
import { Fixture, TEST_CAP_MB, limitTestDatabase, makeFixture } from './fixture';
import { Fixture, TEST_CAP_MB, limitTestDatabase, makeFixture, startStoneAlone } from './fixture';

/**
* The parts of GemDB that are only interesting against a real database.
Expand Down Expand Up @@ -81,8 +80,7 @@ describe.skipIf(!makeFixtureIsPossible())('a real database', () => {
it('starts, and reports itself through gslist', async () => {
// As startProcesses does before every start.
ensureStatmonitor();
await startStone();
await startNetldi();
await ensureProcesses();
ensureDatabaseAccount();

const processes = listProcesses();
Expand Down Expand Up @@ -163,7 +161,7 @@ describe.skipIf(!makeFixtureIsPossible())('a real database', () => {

it('stops cleanly once nothing is logged in', async () => {
logout();
await startStone();
await startStoneAlone();
expect(isRunning()).toBe(true);

await stopStone(false);
Expand Down
5 changes: 2 additions & 3 deletions src/__integration__/debugConsole.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
parsePausedStack,
pausedStackQuery,
} from '../pauseVariables';
import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes';
import { ensureProcesses, isRunning, stopNetldi, stopStone } from '../processes';
import { PyResult, runPython, runPythonFile } from '../pythonQueries';
import {
SessionOwner,
Expand Down Expand Up @@ -92,8 +92,7 @@ beforeAll(async () => {
fs.writeFileSync(path.join(fixture.root, 'dcmod.py'), DCMOD);
createDatabaseWithPython(fixture);
stageGrail(ext);
await startStone();
await startNetldi();
await ensureProcesses();
});

afterAll(async () => {
Expand Down
12 changes: 12 additions & 0 deletions src/__integration__/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { __setSetting } from '../__mocks__/vscode';
import { STONE_NAME, databasePasswordPath } from '../config';
import { createDatabase } from '../database';
import { databaseConfPath, ensureRootPath, expectedEnginePath, extentPath } from '../paths';
import { ensureProcesses, stopNetldi } from '../processes';

/**
* A database of GemDB's own making, in a directory of its own, on the real
Expand Down Expand Up @@ -149,3 +150,14 @@ export function createDatabaseWithPython(fixture: Fixture): void {
fs.copyFileSync(testExtentPasswordPath(), databasePasswordPath());
fs.chmodSync(databasePasswordPath(), 0o600);
}

/**
* Bring the stone up without leaving a session listener this call started,
* for tests of what needs no listener (linked sessions) or of starting one.
* Through `ensureProcesses`, the one way GemDB starts processes, rather than a
* stone-only start of the tests' own.
*/
export async function startStoneAlone(): Promise<void> {
const { startedNetldi } = await ensureProcesses();
if (startedNetldi) await stopNetldi();
}
87 changes: 87 additions & 0 deletions src/__integration__/fixtures/processContender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawn } from 'child_process';
import { NETLDI_NAME } from '../../config';
import { databaseLogPath } from '../../paths';
import { engineEnvironment, ensureProcesses } from '../../processes';

/**
* One process starting the database, run as a process of its own.
*
* processes.test.ts bundles this with the same `vscode` alias the shell bundle
* uses and starts two at once, because the race it covers is between
* processes: two windows, or a window and a GemDB Shell, that both find the
* database down. The root path arrives as GEMDB_ROOT_PATH, as it does for the
* real wrapper.
*
* argv: <ensure|raw-netldi> <ready file> <go file> <report file> [rival log]
*
* `ensure` is what every start does today. `raw-netldi` is what GemDB 1.6.0
* did: `startnetldi` with no lock and no look first (#89).
*
* It says it is ready, waits for the go file so both start at the same
* moment, then appends one line saying what happened to the report.
*
* Released together, the raw `startnetldi` finishes before the other process
* has taken the lock and looked, so that one finds the listener up and the
* two never collide. Given a rival's log, the raw one waits after the go file
* until the rival logs its own `startnetldi`, so both commands run at once.
*/
const [mode, readyFile, goFile, reportFile, rivalLog] = process.argv.slice(2);
const me = process.pid;

const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
const report = (line: string): void => fs.appendFileSync(reportFile, `${line}\n`);

/** `startnetldi` exactly as 1.6.0 ran it; resolves with its exit code and output. */
function rawStartNetldi(): Promise<{ code: number | null; output: string }> {
const env = engineEnvironment();
return new Promise((resolve, reject) => {
const child = spawn(
path.join(env.GEMSTONE, 'bin', 'startnetldi'),
[
'-a',
os.userInfo().username,
'-g',
'-l',
path.join(databaseLogPath(), `${NETLDI_NAME}.log`),
NETLDI_NAME,
],
{ env: { ...process.env, ...env } },
);
let output = '';
child.stdout.on('data', (data: Buffer) => (output += data.toString()));
child.stderr.on('data', (data: Buffer) => (output += data.toString()));
child.on('close', (code) => resolve({ code, output }));
child.on('error', reject);
});
}

/** Wait until the rival logs that it is running `startnetldi`, or give up after a while. */
async function rivalStartsNetldi(log: string): Promise<void> {
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
if (fs.existsSync(log) && fs.readFileSync(log, 'utf8').includes('$ startnetldi')) return;
await sleep(2);
}
}

async function main(): Promise<void> {
fs.appendFileSync(readyFile, `${me}\n`);
while (!fs.existsSync(goFile)) await sleep(2);
if (rivalLog) await rivalStartsNetldi(rivalLog);

if (mode === 'ensure') {
const started = await ensureProcesses();
report(JSON.stringify({ mode, pid: me, ...started }));
} else {
const { code, output } = await rawStartNetldi();
report(JSON.stringify({ mode, pid: me, code, output: output.trim() }));
}
}

main().catch((e: unknown) => {
report(JSON.stringify({ mode, pid: me, error: String(e) }));
process.exitCode = 1;
});
5 changes: 2 additions & 3 deletions src/__integration__/grail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createDatabase } from '../database';
import { bundledGrailStamp, installGrail, recordGrailInstalled, stageGrail } from '../grail';
import { isSharedMemoryConfigured } from '../osConfig';
import { grailInstalled, grailStagedOnDisk } from '../paths';
import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes';
import { ensureProcesses, isRunning, stopNetldi, stopStone } from '../processes';
import {
isErrorResult,
isGrailInstalled,
Expand Down Expand Up @@ -61,8 +61,7 @@ beforeAll(async () => {

createDatabase(fixture.engine);
limitTestDatabase();
await startStone();
await startNetldi();
await ensureProcesses();
// Grail installs into the gemdb account, as `ensureRunning` creates it.
ensureDatabaseAccount();
});
Expand Down
5 changes: 2 additions & 3 deletions src/__integration__/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
stopMcpServer,
} from '../mcp';
import { mcpInstalled, mcpRouterStatePath, mcpStagedOnDisk } from '../paths';
import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes';
import { ensureProcesses, isRunning, stopNetldi, stopStone } from '../processes';
import { GciSession, execute, logoutAll } from '../session';
import { createDatabaseWithPython, Fixture, haveTestExtent, makeFixture } from './fixture';

Expand Down Expand Up @@ -73,8 +73,7 @@ beforeAll(async () => {
// Grail's files on disk, because the router's worker gems inherit the
// NetLDI's environment and resolve Python modules through GRAIL_DIR.
stageGrail(ext);
await startStone();
await startNetldi();
await ensureProcesses();
});

afterAll(async () => {
Expand Down
Loading
Loading