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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Sessions now say who they belong to.** Every session GemDB opens names
itself in the database's shared cache — `gemdb nb analysis` for a notebook,
`gemdb sh 41234` for a GemDB Shell, `gemdb run backfill` for a script started
with `gemdb backfill.py`. The names are visible to anything attached to the
same database, so a second VS Code window, topaz, or an administrator's tool
can tell which of the ten sessions belongs to what, instead of seeing a row
of anonymous gems. Nothing is committed to do this and the entry disappears
with the process, so a window that crashes leaves nothing behind.

### Changed

- **Each notebook now runs in its own database session.** Two notebooks no
Expand Down
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,28 @@ naming what this window holds and which session has been idle longest.
first, carrying GemStone's own session serial so a row here can be matched to
`gemdb.sessions.all()` over there. The status view shows it.

That registry is private to one extension host, which is why every session also
publishes itself with **`System cacheName:`** — `cacheNameFor` in `session.ts`.
It writes the shared page cache, so the name is readable by every session on
the host (`System cacheStatisticsForAllSlots`, whose rows are
`(name, pid, sessionId)`), which is what lets another window, topaz, or a
dashboard attribute a session GemDB did not open for it. Preferred over a
committed registry deliberately: it costs no commit, and the entry dies with
the process rather than outliving a window that crashed. **The limit is 31
characters** — 32 raises `OutOfRange` (2061), measured — so the name is a
label and the sessionId remains the identifier; `cacheNameFor` truncates and
strips non-ASCII rather than letting a long notebook title fail a login. File
mode needs its own copy of this in `gemdb-run.tpz`, because linked topaz never
reaches `session.ts` and would otherwise show as the stock `TopazL`.

Two things the GCI headers say, so nobody goes looking again: there is no
`GciInit` equivalent in the thread-safe library at all, `GciInitAppName` "has
no effect in remote GCI applications", and `GciSetCacheName_` does nothing when
`GciIsRemote()`. The Smalltalk send is the only route that works for GemDB.
For reporting on sessions, `System descriptionOfSession:` carries what matters
in slots 5 (last begin/commit/abort), 16 (commits behind this session's view),
8 (holding the oldest commit record) and 21 (the client's pid, RPC only).

**A release ships a database, not just the code to build one.**
`scripts/bundle-extent.sh` creates a scratch database, files Grail into it, and
stages the result as `extent/gemdb.dbf`; `createDatabase` copies that instead of
Expand Down
42 changes: 41 additions & 1 deletion src/__integration__/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { stageGrail } from '../grail';
import { bundledExtentPath } from '../paths';
import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes';
import { isErrorResult, runPython } from '../pythonQueries';
import { SessionOwner, logoutAll, sessionRegistry } from '../session';
import { SessionOwner, cacheNameFor, execute, logoutAll, sessionRegistry } from '../session';
import { Fixture, makeFixture } from './fixture';

/**
Expand Down Expand Up @@ -143,4 +143,44 @@ describe.skipIf(!havePreloaded || !canMakeFixture())('a session per notebook', (

await runPython('gemdb.abort()', A);
});

it('publishes each session’s owner where any other session can read it', async () => {
// The point of naming: what this window knows (which notebook owns which
// session) becomes readable by anything attached to the same cache —
// another window, topaz, a dashboard. So the assertion deliberately reads
// it back the long way round, through the shared cache, rather than from
// the registry that wrote it.
await runPython('1', A);
await runPython('1', B);

const listing = execute(
`| ws |
ws := WriteStream on: Unicode7 new.
System cacheStatisticsForAllSlots do: [:each |
each ifNotNil: [
ws nextPutAll: (each at: 1); nextPut: $|; print: (each at: 3); lf]].
ws contents encodeAsUTF8`,
);
const slots = new Map(
listing
.split('\n')
.filter((line) => line.includes('|'))
.map((line) => {
const [name, sessionId] = line.split('|');
return [name, Number.parseInt(sessionId, 10)] as const;
}),
);

expect(cacheNameFor(A)).toBe('gemdb nb one');
expect([...slots.keys()]).toEqual(expect.arrayContaining(['gemdb nb one', 'gemdb nb two']));
// `execute` runs in the extension's own session, so it named itself too.
expect(slots.has('gemdb ext')).toBe(true);

// The join a dashboard needs: the cache slot's session id is the number
// this side recorded at login, so a name over there resolves to an owner
// over here.
const held = new Map(sessionRegistry().map((s) => [s.owner.key, s]));
expect(slots.get('gemdb nb one')).toBe(held.get(A.key)?.serial);
expect(slots.get('gemdb nb two')).toBe(held.get(B.key)?.serial);
});
});
6 changes: 6 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ describe('writeCliScripts', () => {
// enables canonical modules — without this line every CLI run
// cold-imports and gemdb's clean-session contract is void.
expect(run).toContain('___canonicalClassesEnabled___: true');
// 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:');
});

it('makes no arguments the GemDB Shell, run by the recorded Node runtime', () => {
Expand Down
50 changes: 49 additions & 1 deletion src/__tests__/sessions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { SessionInfo, SessionOwner, sessionLimitMessage } from '../session';
import { SessionInfo, SessionOwner, cacheNameFor, sessionLimitMessage } from '../session';

/**
* Running out of sessions is a normal consequence of opening notebooks, not a
Expand Down Expand Up @@ -61,3 +61,51 @@ describe('the message when the database has no sessions left', () => {
expect(message).toContain('c (idle 2s)');
});
});

/**
* The name a session takes in the shared cache.
*
* The 31-character limit is the whole difficulty: 32 raises OutOfRange, and it
* would raise it at login, on the notebook the user just opened. So the rule
* that keeps names short is worth testing without a database — the same reason
* `sessionLimitMessage` takes its sessions as an argument.
*/
describe('the name a session publishes to the shared cache', () => {
const nb = (label: string): SessionOwner => ({
key: `file:///${label}`,
kind: 'notebook',
label,
});

it('names a notebook, without repeating what the tag already said', () => {
expect(cacheNameFor(nb('analysis.ipynb'))).toBe('gemdb nb analysis');
});

it('names a shell by its process, which is what leads back to the terminal', () => {
expect(cacheNameFor({ key: 'shell', kind: 'shell', label: 'shell' }, 41234)).toBe(
'gemdb sh 41234',
);
});

it('names the extension’s own session', () => {
expect(cacheNameFor({ key: 'gemdb.extension', kind: 'extension', label: 'GemDB' })).toBe(
'gemdb ext',
);
});

it('never exceeds what the cache will take', () => {
const long = nb('a-notebook-with-a-really-quite-long-name.ipynb');
expect(cacheNameFor(long).length).toBeLessThanOrEqual(31);
expect(cacheNameFor(long)).toBe('gemdb nb a-notebook-with-a-real');
});

it('drops what the cache cannot store', () => {
// The cache holds 8-bit code points, so an emoji or a CJK title would be
// meaningless there at best; strip rather than let login fail over it.
expect(cacheNameFor(nb('sales–📈.ipynb'))).toBe('gemdb nb sales');
});

it('falls back to the tag when nothing usable is left', () => {
expect(cacheNameFor(nb('📈.ipynb'))).toBe('gemdb nb');
});
});
20 changes: 19 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ set user ${DB_USER} pass ${DB_PASSWORD}
set gemstone ${STONE_NAME}
login
run
| args ofs target status statusFile |
| args ofs target status statusFile label |
"Canonical modules, session-local and default off: with it on, the modules
the extent ships deployed (gemdb above all) warm-bind instead of
cold-importing, so running a script leaves no uncommitted plumbing behind
Expand All @@ -156,6 +156,24 @@ SessionTemps current at: #'GrailConsole' put: (Array with: GsFile stdout).
[
[
target := args at: ofs + 1.
"Name this session in the shared cache, so a long-running script is
identifiable from outside -- another window, topaz, a dashboard --
rather than showing as the stock 'TopazL'. The extension does the
same for its own sessions after login (session.ts cacheNameFor); this
mode never goes through that code, because it is linked topaz rather
than a GCI login. Limit is 31 characters (32 raises OutOfRange), and
the whole thing is best-effort: a script must run even if it cannot
be labelled."
label := target = '-m'
ifTrue: [args at: ofs + 2]
ifFalse: [(target subStrings: '/') isEmpty
ifTrue: [target]
ifFalse: [(target subStrings: '/') last]].
(label size > 3 and: [(label copyFrom: label size - 2 to: label size) = '.py'])
ifTrue: [label := label copyFrom: 1 to: label size - 3].
label := 'gemdb run ', label.
label size > 31 ifTrue: [label := label copyFrom: 1 to: 31].
[System cacheName: label] on: Error do: [:ignored | ignored return: nil].
target = '-m'
ifTrue: [importlib runModule: (args at: ofs + 2)]
ifFalse: [importlib runPath: target].
Expand Down
63 changes: 63 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,56 @@ export interface SessionOwner {
label: string;
}

/**
* The longest name the shared cache will take: 32 raises OutOfRange (error
* 2061, measured on 3.7.5). It is a small budget on purpose — the name is a
* label for a person reading a list of sessions, not an identifier. What joins
* a session here to a row over there is the serial.
*/
const CACHE_NAME_LIMIT = 31;

/** How each kind of owner introduces itself in that list. */
const CACHE_NAME_TAGS: Record<SessionKind, string> = {
notebook: 'gemdb nb',
shell: 'gemdb sh',
extension: 'gemdb ext',
};

/**
* What to call this session where the whole machine can see it.
*
* `System cacheName:` writes into the shared page cache, so the name shows up
* for every session on the host — other VS Code windows, topaz, Jasper, a
* dashboard — via `System cacheStatisticsForAllSlots`, alongside the stock
* `GcReclaim`, `SymbolGem` and `TopazL`. That is the point: without it a
* GemDB session is anonymous, and "which of these ten is worth closing" has no
* answer from outside the window that opened it.
*
* The `gemdb` prefix earns its six characters on a database this extension did
* not install alone — it is the only thing saying which client a session
* belongs to. Shells report their pid because a shell is its own process and
* that is what leads back to the terminal; in the extension host `process.pid`
* would be the same number for every notebook, which is why only 'shell' uses
* it (shells never log in from the extension host — "Open GemDB Shell" runs
* the wrapper in a terminal).
*
* Truncation is plain and lossy, and that is accepted: two notebooks whose
* names agree for 22 characters get the same label, and the serial tells them
* apart. Non-ASCII goes too, since the cache stores 8-bit code points.
*/
export function cacheNameFor(owner: SessionOwner, pid: number = process.pid): string {
const tag = CACHE_NAME_TAGS[owner.kind];
if (owner.kind === 'extension') return tag;
if (owner.kind === 'shell') return `${tag} ${pid}`;
// `.ipynb` is what the `nb` tag already said, so spend the room on the name.
const name = owner.label
.replace(/\.ipynb$/i, '')
.replace(/[^\x20-\x7e]/g, '')
.trim();
if (!name) return tag;
return `${tag} ${name}`.slice(0, CACHE_NAME_LIMIT);
}

/** A session, described for a human or for joining to `gemdb.sessions`. */
export interface SessionInfo {
owner: SessionOwner;
Expand Down Expand Up @@ -343,6 +393,19 @@ export class GciSession {
} catch (e) {
log(`Could not read the session serial: ${e instanceof Error ? e.message : e}`);
}
// Publish who this session belongs to, where anything on the host can read
// it. Everything above this point is knowledge trapped in one extension
// host; `System cacheName:` is what makes it visible to another window, to
// topaz, and to whatever ends up reporting on sessions. It writes shared
// memory rather than the repository, so it costs no commit and — the
// reason it beats a committed registry — the entry dies with the process
// instead of outliving a window that crashed. Best-effort, like the serial:
// an unnamed session still works.
try {
session.execute(`System cacheName: '${cacheNameFor(resolved).replace(/'/g, "''")}'. true`);
} catch (e) {
log(`Could not name the session (${resolved.label}): ${e instanceof Error ? e.message : e}`);
}
log(
`Connected to GemDB as ${DB_USER} (${resolved.label}` +
`${session.sessionSerial === undefined ? '' : `, session ${session.sessionSerial}`})`,
Expand Down