aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/core
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 23:49:19 +0200
committeruser <user@clank>2026-08-12 23:49:19 +0200
commitacf61609ee7b99138251cea879d593d5f1689392 (patch)
tree06f880e8c2e312e1a600c4ee0a63943c29eb902f /cli/src/core
parentrelease: publish sl0ppi.sl0p.foo, and a script to keep it current (diff)
downloadsl0ppi-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.ts14
-rw-r--r--cli/src/core/sl0ppij.ts131
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;