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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
no setter. A method's receiver (`self`, or whatever the def calls it), a tuple's items, group
rows and a saved stack's values cannot be changed, and VS Code does not offer Set Value on them.

### 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 @@ -51,6 +51,16 @@ 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.

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 @@ -236,8 +246,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
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