diff options
| author | user <user@clank> | 2026-08-12 23:49:19 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 23:49:19 +0200 |
| commit | acf61609ee7b99138251cea879d593d5f1689392 (patch) | |
| tree | 06f880e8c2e312e1a600c4ee0a63943c29eb902f /cli/src/core | |
| parent | release: publish sl0ppi.sl0p.foo, and a script to keep it current (diff) | |
| download | sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.gz sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.tar.xz sl0ppi-acf61609ee7b99138251cea879d593d5f1689392.zip | |
cli: survive a dead session, and a flaky terminal
Three bugs found on a fresh Debian box, all of which made sl0ppi look broken
when the terminal underneath it was the problem.
1. JSON crash on startup. `action list-panes --json` against a session that has
died exits 0 with EMPTY stdout, and JSON.parse("") threw "JSON Parse error:
Unexpected EOF" at the user. Empty output is now "nothing", and genuinely
malformed output is reported with the command name and the offending text.
2. `up` silently resurrected. A dead-but-resurrectable session is not an empty
slot: `attach --create-background NAME` restores it from the serialised copy
and IGNORES --layout. The result was a root full of projects coming back as
plain shells with no agents and no purposes -- and the resurrected background
session then exited again within seconds. sl0ppi now refuses to guess and
explains the two real options (`up --fresh` to rebuild, `sl0ppij attach` to
restore it properly in a terminal). Verified on a clean slate that a fresh
`up` produces exactly the right panes: purpose='agent:main' held=true cmd='pi'.
3. The terminal's CLI actions are racy. Measured against a LIVE session on a
2-vCPU box: list-tabs and list-panes each fail ~60% of the time while the
session is detached, ~25% with a client attached, either with empty output or
"There is no active session!" (the action client consults
get_active_session() before it looks at -s). That is a defect in sl0ppij --
filed as a known issue -- but these are idempotent reads, so sl0ppi retries
them with backoff and distinguishes "the session is gone" from "the terminal
would not answer". `sl0ppi ls` went from failing most runs to 8/8.
Also:
- session creation returns before the server is registered, so `up` now waits
for readiness instead of racing its own next command
- UserError: actionable answers ("no project foo", "that session is a saved
dead copy") print and exit 1 instead of falling through to sl0ppij, which
buried the explanation and started a session nobody asked for
- `sl0ppi print <project>` implemented; it was referenced by the init template
and the design doc but did not exist
Diffstat (limited to 'cli/src/core')
| -rw-r--r-- | cli/src/core/errors.ts | 14 | ||||
| -rw-r--r-- | cli/src/core/sl0ppij.ts | 131 |
2 files changed, 133 insertions, 12 deletions
diff --git a/cli/src/core/errors.ts b/cli/src/core/errors.ts new file mode 100644 index 0000000..0bab4bd --- /dev/null +++ b/cli/src/core/errors.ts @@ -0,0 +1,14 @@ +/** + * An error the user can act on, as opposed to a bug. + * + * These are printed and the process exits non-zero. They deliberately do NOT + * trigger the fail-open handover to sl0ppij: "no project called foo" or "that + * session is a saved dead copy" are answers, and replacing them with a raw + * multiplexer would hide the answer and start a session nobody asked for. + */ +export class UserError extends Error { + constructor(message: string) { + super(message); + this.name = "UserError"; + } +} diff --git a/cli/src/core/sl0ppij.ts b/cli/src/core/sl0ppij.ts index b8442b2..e5db9e8 100644 --- a/cli/src/core/sl0ppij.ts +++ b/cli/src/core/sl0ppij.ts @@ -133,13 +133,51 @@ export class Sl0ppij { } listTabs(): TabInfo[] { - const { stdout } = this.runOk(["action", "list-tabs", "--json"], "list-tabs"); - return JSON.parse(stdout) as TabInfo[]; + return this.readList<TabInfo>(["action", "list-tabs", "--json"], "list-tabs"); } listPanes(): PaneInfo[] { - const { stdout } = this.runOk(["action", "list-panes", "--json"], "list-panes"); - return JSON.parse(stdout) as PaneInfo[]; + return this.readList<PaneInfo>(["action", "list-panes", "--json"], "list-panes"); + } + + /** + * Read a `--json` listing, tolerating a flaky underlying CLI. + * + * Measured on a 2-vCPU Debian box against a live session: `list-tabs` and + * `list-panes` each fail about 60% of the time when the session is detached, + * and about 25% when a client is attached. The failure is either an empty + * stdout with exit 0, or "There is no active session!" from the action client + * -- which checks `get_active_session()` before it even looks at `-s`. + * + * That is a defect in the terminal, not something a workspace tool should + * inherit: these are idempotent reads, so retry them. If the session really + * is gone, say so instead of retrying into a wall. + */ + private readList<T>(args: string[], what: string, attempts = 5): T[] { + let lastProblem = ""; + for (let attempt = 0; attempt < attempts; attempt++) { + const result = this.run(args); + if (result.code === 0) { + const text = result.stdout.trim(); + if (text.length > 0) return parseJsonList<T>(text, what); + lastProblem = "empty output"; + } else { + lastProblem = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? ""; + } + if (process.env.SL0PPI_DEBUG) { + process.stderr.write(`sl0ppi: ${what} attempt ${attempt + 1}: ${lastProblem}\n`); + } + pause(120 * (attempt + 1)); + } + + // Out of retries. Distinguish "the session is gone" (an answer) from "the + // terminal would not talk to us" (a problem worth reporting). + if (this.session !== undefined && !this.sessionExists(this.session)) return []; + throw new Sl0ppijError(`${what} failed ${attempts}x (last: ${lastProblem})`, { + code: -1, + stdout: "", + stderr: lastProblem, + }); } /** Adds a tab from a KDL layout string. Returns its new position. */ @@ -162,26 +200,68 @@ export class Sl0ppij { } /** - * Live sessions, excluding dead-but-resurrectable ones. + * Every session sl0ppij knows about, live or dead-but-resurrectable. * * `--short` lists EXITED sessions too with no way to tell them apart, so we - * parse the long form and drop them. (The same trap is documented in the - * pane-finder installer.) + * parse the long form. (The same trap is documented in the pane-finder + * installer.) The dead ones matter enormously: see `deadSessions`. */ - liveSessions(): string[] { + sessions(): { name: string; dead: boolean }[] { const result = this.run(["list-sessions", "--no-formatting"], { session: false }); - if (result.code !== 0) return []; // "no sessions" is an error exit, not a failure + if (result.code !== 0) return []; // "no sessions" exits non-zero, not a failure return result.stdout .split("\n") - .filter((line) => line.trim() !== "" && !line.includes("EXITED")) - .map((line) => line.trim().split(/\s+/)[0] as string) - .filter((name) => name.length > 0); + .filter((line) => line.trim().length > 0) + .map((line) => ({ + name: line.trim().split(/\s+/)[0] as string, + dead: line.includes("EXITED"), + })) + .filter((session) => session.name.length > 0); + } + + liveSessions(): string[] { + return this.sessions().filter((s) => !s.dead).map((s) => s.name); + } + + /** + * Sessions that are gone but still resurrectable. + * + * These are not cosmetic. `attach --create-background NAME` on one of these + * RESURRECTS it from the serialised copy and silently ignores `--layout`, so + * "create a session from this layout" quietly becomes "restore whatever was + * there before" -- which is how a root full of projects came back as plain + * shells with no agents. + */ + deadSessions(): string[] { + return this.sessions().filter((s) => s.dead).map((s) => s.name); } sessionExists(name: string): boolean { return this.liveSessions().includes(name); } + /** Discards a session AND its serialised copy, so the name is reusable. */ + deleteSession(name: string): void { + this.run(["delete-session", name], { session: false, detached: true }); + } + + /** + * Block until a session is actually answering, or give up. + * + * Creating (or resurrecting) a session returns before the server is + * registered, so querying it straight away fails with "There is no active + * session!". Poll for readiness rather than sleeping a guessed interval: + * usually it is ready on the first check, and a slow box still works. + */ + waitForSession(name: string, timeoutMs = 10_000): boolean { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (this.sessionExists(name)) return true; + if (Date.now() >= deadline) return false; + pause(150); + } + } + /** * Creates a detached session from a layout file, without attaching. * @@ -199,6 +279,33 @@ export class Sl0ppij { } } +/** + * Parse a `--json` listing. + * + * Empty stdout with a zero exit is a real state, not corruption: it is what + * `action list-panes` returns once the session has died underneath us. Treat it + * as "nothing", and keep the raw text in the error for anything genuinely + * malformed -- a bare "JSON Parse error: Unexpected EOF" tells a user nothing. + */ +export function parseJsonList<T>(stdout: string, what: string): T[] { + const text = stdout.trim(); + if (text.length === 0) return []; + try { + const parsed = JSON.parse(text); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch (error) { + throw new Sl0ppijError( + `${what} returned something that is not JSON: ${text.slice(0, 200)}`, + { code: 0, stdout, stderr: "" }, + ); + } +} + +/** Synchronous pause. These commands are one-shot and strictly sequential. */ +function pause(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + /** True when we are running inside a sl0ppij/zellij pane already. */ export function insideSession(env: NodeJS.ProcessEnv = process.env): string | null { return env.SL0PPIJ_SESSION_NAME ?? env.ZELLIJ_SESSION_NAME ?? null; |
