Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Ctrl+C ends a `gemdb` run without an error.** Stopping `gemdb app.py` printed
`[Sending soft break]`, a line number in GemDB's own script, `ERROR 3004 , a ExitClientError
occurred` and `Logging out session 1.` around the `KeyboardInterrupt`, which read as a failure. It
now prints only `KeyboardInterrupt`, as Python does. GemStone's own messages from a `gemdb` run are
shown only when the program could not run to the end, such as a refused login, and a
`.topazini` file is no longer read.

## [1.6.0] - 2026-10-07

Expand Down
30 changes: 22 additions & 8 deletions docs/grail.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,29 @@ longer stamps at all and the ordering question has gone with it.

**The CLI's exit codes go through a status file, not topaz.** topaz cannot
carry an exit status out of a `run` block — `ExitClientError status:` is not
translated, and an `iferr … exit 1` action exits 0 (all measured). The driver
therefore ends with no `exit` command at all, and must not grow one: `topaz -h`
says of `-S` that topaz "exits when the script completes" and that "exit and
quit commands are ignored". Ignored silently when stdin is a pipe — which is
every CI run, every test, and every `gemdb x.py | cat` — and out loud when
stdin is a tty, where it printed four lines of explanation and a
`Logging out session 1.` in front of the user. So
translated, and an `iferr … exit 1` action exits 0 (all measured). So
`gemdb-run.tpz` writes the status to the file named in `GEMDB_STATUS_FILE` and
the bash wrapper becomes the exit code. Errors there are caught as
the bash wrapper becomes the exit code.

**Topaz says nothing on the terminal during a file run.** The driver runs
under `-P` with an `-I` init file whose one line is
`output push $GEMDB_TOPAZ_LOG only`, so everything topaz prints goes to a
per-run log, while the program's output, written to `GsFile stdout` and
`stderr`, still reaches the terminal directly. Without it Ctrl-C ended a run
with a soft-break notice, `Near line 155 of file …/gemdb-run.tpz`,
`ERROR 3004` for the `ExitClientError` that ends topaz, and
`Logging out session 1.` (#131). All three pieces are needed, as measured on
4.0.0.a4. Under `-S`, `OUTPUT PUSH … ONLY` has no effect on stdout. Under `-P`
without `-I`, the lines before the push are echoed. And a log, not
`/dev/null`, because a refused login or a session the stone ends mid-run is
reported by topaz alone: the wrapper prints the log, less topaz's echo of the
driver, whenever no status was written. `-P` does not exit at the end of the
script, so the driver ends with `exit`. Without it, topaz reads its next
commands from stdin, and a refused login with input piped in ran that input as
topaz commands (measured, and covered by `cli.test.ts`). Under `-S` the same
`exit` was ignored, and on a tty it printed four lines saying so.

Errors in the driver are caught as
`AbstractException`, not `Error`: Grail's Python exceptions live outside the
`Error` branch, which is why grail.tpz's own file mode exits 0 on a Python
error. `sys.exit(n)` is decoded by that same handler: Grail raises its own
Expand Down
92 changes: 87 additions & 5 deletions src/__integration__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile } from 'child_process';
import { execFile, spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand Down Expand Up @@ -222,7 +222,7 @@ describe.skipIf(!ready || !canMakeFixture())('the gemdb command', () => {
expect(ran.code).toBe(0);
});

it('stops on Ctrl-C, after finally blocks, rather than at a topaz prompt', async () => {
it('stops on Ctrl-C as python3 does, after finally blocks, with nothing from topaz', async () => {
// A real Ctrl-C needs a real terminal: the line discipline turns ^C into
// SIGINT for topaz, which sends the gem a soft break. expect(1) owns the
// pty, as in repl.test.ts. The script blocks in a primitive, where a web
Expand Down Expand Up @@ -268,12 +268,81 @@ exit [lindex $result 3]
},
);
});
expect(ran.transcript).toContain('cleaned up');
expect(ran.transcript).toContain('KeyboardInterrupt');
expect(ran.transcript).not.toContain('topaz 1>');
// Everything the terminal showed after expect's own spawn line, less the
// ^C the line discipline echoes. Topaz used to add a soft-break notice,
// the driver line it stopped at, ERROR 3004 and a logout (#131).
const shown = ran.transcript
.replaceAll('^C', '')
.split(/\r?\n/)
.slice(1)
.map((line) => line.trim())
.filter((line) => line !== '');

expect(shown).toEqual(['serving', 'cleaned up', 'KeyboardInterrupt']);
expect(ran.code).toBe(130);
});

it('says why when the database refuses the login, and runs nothing piped to it', async () => {
const driver = path.join(path.dirname(cliPath()), 'gemdb-run.tpz');
const good = fs.readFileSync(driver, 'utf8');
fs.writeFileSync(driver, good.replace(/^(set user \S+ pass )\S*$/m, '$1not-the-password'));
let ran: Ran;
try {
ran = await run(['hello.py'], 'shell echo LEAKED\n');
} finally {
fs.writeFileSync(driver, good);
}

// Exactly GemStone's report of the refusal: none of the driver, which
// topaz echoes into the log and the wrapper strips, nothing about the run
// block topaz goes on through with no session, and not the piped line.
// stdout is not pinned: the gem sometimes writes a line of its own there
// about the failed login, which is not topaz's to keep quiet.
expect(ran.stderr.trimEnd().split('\n')).toEqual([
expect.stringMatching(/^-+$/),
'GemStone: Error Fatal',
'Login failed: the userId/password combination is invalid or expired.',
expect.stringContaining('Number: 4051'),
'Arg 1: nil',
]);
expect(ran.stdout).not.toContain('LEAKED');
expect(ran.stdout).not.toContain('hello from');
// topaz's own status, which the wrapper passes on when there is no other.
expect(ran.code).toBe(1);
});

it('leaves no temporary files behind, even when its terminal closes mid-run', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gemdb-cli-tmp-'));
const env = { ...process.env, TMPDIR: tmp };
fs.writeFileSync(
path.join(workDir, 'forever.py'),
'import time\nprint("serving", flush=True)\nwhile True:\n time.sleep(0.2)\n',
);
await new Promise((resolve) =>
execFile('bash', [cliPath(), '-c', 'print(1)'], { cwd: workDir, env }, resolve),
);
// Its own process group, so the hangup reaches the wrapper and topaz
// together, as closing a terminal tab does.
const child = spawn('bash', [cliPath(), 'forever.py'], { cwd: workDir, env, detached: true });
const exited = new Promise((resolve) => child.on('exit', resolve));
await new Promise<void>((resolve) => {
child.stdout.on('data', (chunk: Buffer) => {
if (chunk.toString().includes('serving')) resolve();
});
});

process.kill(-(child.pid ?? 0), 'SIGHUP');
await exited;
const left = fs.readdirSync(tmp);
// topaz outlives a hangup, as it did before the wrapper had a trap, so it
// is ended here: left running it holds a session the next test needs gone.
process.kill(-(child.pid ?? 0), 'SIGTERM');
await waitForGroupToEnd(child.pid ?? 0);
fs.rmSync(tmp, { recursive: true, force: true });

expect(left).toEqual([]);
});

it('raises EOFError when stdin runs dry', async () => {
const ran = await run(['-c', 'input()'], '');
expect(ran.stderr).toContain('EOF');
Expand Down Expand Up @@ -316,3 +385,16 @@ function canMakeFixture(): boolean {
probe?.remove();
return probe !== undefined;
}

/** Resolves once no process in the group is left, polling as `kill -0` does. */
async function waitForGroupToEnd(pgid: number): Promise<void> {
for (let i = 0; i < 100; i++) {
try {
process.kill(-pgid, 0);
} catch {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`process group ${pgid} still running after 10 s`);
}
53 changes: 44 additions & 9 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,13 @@ describe('writeCliScripts', () => {
// only path. Sending it is now a doesNotUnderstand that kills the run at
// that line, so the driver must not carry it.
expect(run).not.toContain('___canonicalClassesEnabled___');
// A file run is linked topaz, so it never reaches session.ts and would
// otherwise sit in the shared cache as the stock 'TopazL'. The truncation
// is not optional: 32 characters raises OutOfRange, at login.
// No `exit` command: with -S, topaz exits when the script completes and
// ignores exit outright — quietly on a pipe, but on a tty it prints four
// lines about ignoring it and a spurious "Logging out session 1.". A test
// here because a pipe is what both suites use, so nothing else would
// notice it coming back.
expect(run.split('\n').some((line) => /^\s*(exit|quit)\b/.test(line))).toBe(false);
// The console sink is a GsFile, which takes bytes; the second slot of
// the #GrailConsole box is what tells Grail to encode. Without it,
// non-ASCII print() output is UTF-16 code units on the terminal.
expect(run).toContain("put: (Array with: GsFile stdout with: #'utf8')");
// A file run is linked topaz, so it never reaches session.ts and would
// otherwise sit in the shared cache as the stock 'TopazL'. The truncation
// is not optional: 32 characters raises OutOfRange, at login.
expect(run).toContain('System cacheName: label');
expect(run).toContain("label := 'GemDB run ', label");
expect(run).toContain('label size > 31 ifTrue:');
Expand All @@ -113,6 +107,47 @@ describe('writeCliScripts', () => {
);
});

it("keeps topaz's own output off the terminal, and shows it when a run never finished", () => {
writeCliScripts(ext);

const script = fs.readFileSync(cliPath(), 'utf8');
const init = fs.readFileSync(path.join(root, 'bin', 'gemdb-run-init.tpz'), 'utf8');

// Only -P honours OUTPUT PUSH ... ONLY for stdout, and only an -I file is
// not echoed before the push takes effect.
expect(init).toBe('output push $GEMDB_TOPAZ_LOG only\n');
expect(script).toContain(
'topaz -L -q -I "$ROOT/bin/gemdb-run-init.tpz" -P "$ROOT/bin/gemdb-run.tpz"',
);
expect(script).toContain('GEMDB_TOPAZ_LOG="$(mktemp');
expect(script).toMatch(/if \[ -z "\$STATUS" \]; then\n\s+sed .* "\$GEMDB_TOPAZ_LOG" >&2\n/);
});

it('removes its temporary files however the run ends', () => {
writeCliScripts(ext);

const script = fs.readFileSync(cliPath(), 'utf8');

// Armed before the first file is made, so no exit path — a hangup
// included — can skip it, and nothing after the run removes them itself.
expect(script.indexOf('trap remove_temp_files EXIT')).toBeGreaterThan(-1);
expect(script.indexOf('trap remove_temp_files EXIT')).toBeLessThan(script.indexOf('mktemp'));
expect(script).not.toMatch(/^rm -f "\$GEMDB_STATUS_FILE"/m);
});

it('never leaves topaz reading commands from stdin', () => {
writeCliScripts(ext);

const lines = fs
.readFileSync(path.join(root, 'bin', 'gemdb-run.tpz'), 'utf8')
.trimEnd()
.split('\n');

// Under -P, topaz reads stdin once the script ends — a terminal, or
// whatever was piped to the user's program.
expect(lines[lines.length - 1]).toBe('exit');
});

it('reports an uncaught exception only after the stack has unwound', () => {
writeCliScripts(ext);

Expand Down
Loading
Loading