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 | |
| 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
| -rw-r--r-- | VERSION | 2 | ||||
| -rw-r--r-- | cli/src/commands/workspace.ts | 53 | ||||
| -rw-r--r-- | cli/src/core/errors.ts | 14 | ||||
| -rw-r--r-- | cli/src/core/sl0ppij.ts | 131 | ||||
| -rw-r--r-- | cli/src/index.ts | 45 | ||||
| -rw-r--r-- | cli/test/sessions.test.ts | 39 |
6 files changed, 262 insertions, 22 deletions
@@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/cli/src/commands/workspace.ts b/cli/src/commands/workspace.ts index fe7d9de..bc5d7d2 100644 --- a/cli/src/commands/workspace.ts +++ b/cli/src/commands/workspace.ts @@ -13,6 +13,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { checkFragment } from "./doctor.ts"; import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts"; +import { UserError } from "../core/errors.ts"; import { sessionLayoutKdl, singleTabLayoutKdl, type TabSpec } from "../core/layout.ts"; import { isNoOp, planTabs, type Plan } from "../core/plan.ts"; import { projectPurpose } from "../core/purpose.ts"; @@ -24,6 +25,8 @@ export interface Context { client: Sl0ppij; depth: number; dryRun: boolean; + /** Discard a dead session of the same name instead of resurrecting it. */ + fresh?: boolean; log: (message: string) => void; } @@ -96,12 +99,39 @@ export function up(ctx: Context): { plan: Plan; created: boolean } { const client = ctx.client.forSession(session); if (!rootExists(root)) { - throw new Error(`root does not exist: ${root}`); + throw new UserError(`root does not exist: ${root}`); } const projects = discoverProjects(root, { depth: ctx.depth }); if (!client.sessionExists(session)) { + // A dead session of this name is not an empty slot: `attach + // --create-background NAME` RESURRECTS it from the serialised copy and + // silently ignores --layout. Observed consequences, in order of nastiness: + // the tabs come back without pane purposes or commands (so no agents), and + // the resurrected background session then exits again within seconds. + // + // So sl0ppi refuses to guess. Resurrecting is still available -- from + // sl0ppij itself, in a terminal, which is where it actually works. + const resurrectable = client.deadSessions().includes(session); + if (resurrectable && !ctx.fresh) { + throw new UserError( + [ + `session "${session}" already exists as a saved (dead) copy.`, + ``, + ` sl0ppi up --fresh discard it and rebuild from your project layouts`, + ` sl0ppij attach ${session}${" ".repeat(Math.max(1, 16 - session.length))}restore it as it was, in a terminal`, + ``, + `sl0ppi will not resurrect it for you: restoring in the background brings`, + `the tabs back without their agents, and the session exits again seconds later.`, + ].join("\n"), + ); + } + if (resurrectable && ctx.fresh) { + if (!ctx.dryRun) client.deleteSession(session); + ctx.log(`discarded the saved copy of ${session}`); + } + const { specs, failures } = usableSpecs(root, projects); for (const failure of failures) ctx.log(`! ${failure}`); if (ctx.dryRun) { @@ -120,6 +150,13 @@ export function up(ctx: Context): { plan: Plan; created: boolean } { writeFileSync(layoutPath, layout); client.createBackground(session, layoutPath); rememberRoot(session, root); + + // Creation returns before the server is registered; without this, the very + // next query fails with "There is no active session!". + if (!client.waitForSession(session)) { + throw new Error(`session ${session} did not come up`); + } + ctx.log(`created session ${session} (${specs.length} project tab(s))`); return { plan: emptyPlan(), created: true }; } @@ -227,7 +264,7 @@ export function open(ctx: Context, name: string): TabInfo { const relPath = resolveProjectName(ctx, name); if (!client.sessionExists(session)) { - throw new Error(`no session ${session}; run \`sl0ppi up\` first`); + throw new UserError(`no session ${session}; run \`sl0ppi up\` first`); } const purpose = projectPurpose(relPath); @@ -250,7 +287,7 @@ export function close(ctx: Context, name: string): void { const client = ctx.client.forSession(session); const purpose = projectPurpose(resolveProjectName(ctx, name)); const tab = client.listTabs().find((t) => t.purpose === purpose); - if (!tab) throw new Error(`no open tab for ${name}`); + if (!tab) throw new UserError(`no open tab for ${name}`); client.closeTab(tab.position); ctx.log(`- ${tab.name}`); } @@ -275,11 +312,17 @@ export function resolveProjectName(ctx: Context, name: string): string { const matches = projects.filter((p) => p.startsWith(name) || basename(p) === name); if (matches.length === 1) return matches[0] as string; if (matches.length > 1) { - throw new Error(`ambiguous: ${name} matches ${matches.join(", ")}`); + throw new UserError(`ambiguous: ${name} matches ${matches.join(", ")}`); } - throw new Error(`no project ${name} under ${root}`); + throw new UserError(`no project ${name} under ${root}`); } function emptyPlan(): Plan { return { create: [], close: [], keep: [], foreign: [] }; } + +/** The layout sl0ppi would use for a project — for eyeballing and debugging. */ +export function print(ctx: Context, name: string): string { + const relPath = resolveProjectName(ctx, name); + return singleTabLayoutKdl(specFor(ctx.resolved.root, relPath)); +} 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; diff --git a/cli/src/index.ts b/cli/src/index.ts index ee504cb..7211a08 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,9 +9,10 @@ */ import { spawnSync } from "node:child_process"; +import { UserError } from "./core/errors.ts"; import { formatCheck, gather, report, worstLevel } from "./commands/doctor.ts"; import { init, initMessage } from "./commands/init.ts"; -import { close, ls, open, up, type Context } from "./commands/workspace.ts"; +import { close, ls, open, print, up, type Context } from "./commands/workspace.ts"; import { loadRegistry, resolveRoot } from "./core/root.ts"; import { DEFAULT_BIN, insideSession, Sl0ppij } from "./core/sl0ppij.ts"; @@ -23,6 +24,7 @@ usage: sl0ppi open <project> ensure and focus a project's tab sl0ppi close <project> close a project's tab sl0ppi init [dir] make a directory a project (writes .sl0ppi/layout.kdl) + sl0ppi print <project> show the layout sl0ppi would use for a project sl0ppi doctor check the installation sl0ppi help @@ -30,6 +32,7 @@ options: --root DIR treat DIR as the root (default: cwd, or the parent of a project) --depth N how deep to scan for .sl0ppi markers (default: 1) --force with init, overwrite an existing layout + --fresh with up, discard a dead session instead of resurrecting it -n, --dry-run `; @@ -40,6 +43,7 @@ interface Args { depth: number; dryRun: boolean; force: boolean; + fresh: boolean; } export function parseArgs(argv: string[]): Args { @@ -48,6 +52,7 @@ export function parseArgs(argv: string[]): Args { let depth = 1; let dryRun = false; let force = false; + let fresh = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i] as string; @@ -57,11 +62,20 @@ export function parseArgs(argv: string[]): Args { else if (arg.startsWith("--depth=")) depth = Number.parseInt(arg.slice("--depth=".length), 10); else if (arg === "-n" || arg === "--dry-run") dryRun = true; else if (arg === "--force") force = true; + else if (arg === "--fresh") fresh = true; else rest.push(arg); } const command = rest.length > 0 && KNOWN_VERBS.has(rest[0] as string) ? (rest.shift() as string) : "up"; - return { command, rest, rootFlag, depth: Number.isFinite(depth) ? depth : 1, dryRun, force }; + return { + command, + rest, + rootFlag, + depth: Number.isFinite(depth) ? depth : 1, + dryRun, + force, + fresh, + }; } /** @@ -73,9 +87,18 @@ export function parseArgs(argv: string[]): Args { * anything sl0ppij would itself understand; if nothing does, a bare sl0ppij * gets you a session. */ -const KNOWN_VERBS = new Set(["up", "ls", "open", "close", "init", "doctor", "help"]); +const KNOWN_VERBS = new Set([ + "up", + "ls", + "open", + "close", + "init", + "print", + "doctor", + "help", +]); const OUR_VERBS = KNOWN_VERBS; -const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run", "--force"]); +const OUR_FLAGS = new Set(["--root", "--depth", "-n", "--dry-run", "--force", "--fresh"]); export function passthroughArgs(argv: string[]): string[] { const out: string[] = []; @@ -139,6 +162,7 @@ export function main(argv: string[] = process.argv.slice(2)): void { client, depth: args.depth, dryRun: args.dryRun, + fresh: args.fresh, log: (message) => process.stdout.write(`${message}\n`), }; } catch (error) { @@ -173,6 +197,12 @@ export function main(argv: string[] = process.argv.slice(2)): void { close(ctx, name); return; } + case "print": { + const name = args.rest[0]; + if (!name) throw new Error("print needs a project name"); + process.stdout.write(print(ctx, name)); + return; + } case "init": { const dir = args.rest[0] ?? process.cwd(); process.stdout.write(`${initMessage(init(dir, { force: args.force }), dir)}\n`); @@ -211,6 +241,13 @@ export function main(argv: string[] = process.argv.slice(2)): void { } } } catch (error) { + // A UserError is an answer, not a malfunction: print it and stop. Handing + // over to sl0ppij here would bury the explanation and start a session the + // user never asked for. + if (error instanceof UserError) { + process.stderr.write(`sl0ppi: ${error.message}\n`); + process.exit(1); + } fallThrough(argv, describe(error)); } } diff --git a/cli/test/sessions.test.ts b/cli/test/sessions.test.ts new file mode 100644 index 0000000..00e55a2 --- /dev/null +++ b/cli/test/sessions.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { parseJsonList } from "../src/core/sl0ppij.ts"; + +// The crash a user actually hit: `action list-panes --json` on a session that +// died underneath us exits 0 with EMPTY stdout, and JSON.parse("") threw +// "JSON Parse error: Unexpected EOF" at them on startup. +describe("parseJsonList", () => { + test("empty output is nothing, not a crash", () => { + expect(parseJsonList("", "list-panes")).toEqual([]); + expect(parseJsonList(" \n", "list-panes")).toEqual([]); + }); + + test("parses a normal listing", () => { + expect(parseJsonList('[{"position":0}]', "list-tabs")).toEqual([{ position: 0 }] as never); + }); + + test("genuinely malformed output names the command and shows the text", () => { + expect(() => parseJsonList("There is no active session!", "list-tabs")).toThrow( + /list-tabs returned something that is not JSON: There is no active session!/, + ); + }); + + test("a non-array payload is treated as nothing rather than crashing later", () => { + expect(parseJsonList('{"error":"nope"}', "list-tabs")).toEqual([]); + }); +}); + +import { UserError } from "../src/core/errors.ts"; + +// Actionable answers must not be replaced by a raw multiplexer. Falling through +// on "no project called foo" hides the answer AND starts a session nobody asked +// for. +describe("UserError", () => { + test("is distinguishable from a bug", () => { + expect(new UserError("nope") instanceof UserError).toBe(true); + expect(new UserError("nope") instanceof Error).toBe(true); + expect(new Error("boom") instanceof UserError).toBe(false); + }); +}); |
