From e6193f6def313151b63c883f9c491bf8a138e220 Mon Sep 17 00:00:00 2001 From: Tom Ford Date: Wed, 29 Jul 2026 18:07:52 -0400 Subject: [PATCH] fix(jobs): wait for a fresh pane's prompt before typing a command Launching into a pane created moments earlier raced the shell's first prompt. The pty buffers the typed command, so the kernel's canonical echo lands in the output stream BEFORE the prompt; when the shell finally prints it, the prompt glues itself to the job's first output line ("sh-5.3$ 1"). That polluted reads and per-job log files, and the prompt landed inside the job's output slice because baselineLines was captured before it printed. This was the root cause of the flaky job-log integration test (sidemux-pbg): under CPU load the fixture's fresh pane lost the race 2 of 3 full-suite runs, and readJobLog's first output element became 'sh-5.3$ 1' instead of '1'. Reproduced deterministically with a 30-iteration stress run under load; zero failures after this fix. launch() now polls pane state until the pane stops being completely blank (cursor at origin, no history) before typing, capped at 2s for shells that print no prompt at all. cursor_x is added to PANE_STATE_FORMAT so 'prompt printed' is detectable on row 0. Closes sidemux-pbg --- src/core/jobs.ts | 33 +++++++++++++++++++++++++++++++-- src/tmux/formats.ts | 10 ++++++---- src/types.ts | 1 + test/unit/formats.test.ts | 5 +++-- test/unit/jobs.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/core/jobs.ts b/src/core/jobs.ts index 1c469bd..0bd6aef 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { mkdir } from "node:fs/promises"; import { incompatibleShellReason, type ShellDialect } from "../config.js"; import type { TmuxClient } from "../tmux/client.js"; -import type { Job } from "../types.js"; +import type { Job, PaneState } from "../types.js"; import { jobLogPath, readLogTail, @@ -138,6 +138,10 @@ export function scrubOutput(lines: string[]): string[] { /** Finished jobs retained for late read/wait lookups before pruning. */ const MAX_FINISHED_JOBS = 100; +/** How long launch waits for a fresh pane's shell to print its prompt. */ +const SHELL_READY_TIMEOUT_MS = 2000; +const SHELL_READY_POLL_MS = 25; + /** * How much of a job's log tail to scan for the exit sentinel. The sentinel * prints at exit, so it sits within the last few lines of the stream — but a @@ -201,7 +205,7 @@ export class JobManager { command: string, forcedDialect: ShellDialect | null, ): Promise { - const state = await this.client.paneState(paneId); + const state = await this.awaitShellReady(paneId); // An unrecognized foreground command is not fatal — it is usually a // wrapper or a program the pane is running — and posix stays the safe // default. A shell that is known to be unable to evaluate the sentinel @@ -245,6 +249,31 @@ export class JobManager { return job; } + /** + * A brand-new pane reports a completely blank screen — cursor at origin, + * no history — until its shell prints the first prompt. Typing the command + * before that still works (the pty buffers input), but the pty's canonical + * echo then precedes the prompt, which arrives later and glues itself to + * the job's first output line (`sh-5.3$ 1`). That pollutes reads and log + * parsing, and the prompt lands inside the job's output slice because the + * baseline was taken before it printed. Wait for the prompt; a pane that + * stays blank past the cap proceeds anyway (some shells print nothing). + */ + private async awaitShellReady(paneId: string): Promise { + let state = await this.client.paneState(paneId); + const deadline = Date.now() + SHELL_READY_TIMEOUT_MS; + while ( + state.historySize === 0 && + state.cursorY === 0 && + state.cursorX === 0 && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, SHELL_READY_POLL_MS)); + state = await this.client.paneState(paneId); + } + return state; + } + /** * Open the pane's output pipe onto this job's log file. Best-effort: a * failure (unwritable state dir, tmux hiccup) returns null and the run diff --git a/src/tmux/formats.ts b/src/tmux/formats.ts index 6069139..254bec5 100644 --- a/src/tmux/formats.ts +++ b/src/tmux/formats.ts @@ -73,6 +73,7 @@ export const PANE_STATE_FORMAT = [ "#{history_size}", "#{history_limit}", "#{cursor_y}", + "#{cursor_x}", "#{pane_height}", "#{pane_current_command}", "#{pane_current_path}", @@ -80,7 +81,7 @@ export const PANE_STATE_FORMAT = [ export function parsePaneState(line: string): PaneState { const parts = line.replace(/\n$/, "").split(SEP); - if (parts.length < 6) { + if (parts.length < 7) { throw new Error(`unexpected pane state line: ${JSON.stringify(line)}`); } const field = (index: number): string => parts[index] ?? ""; @@ -88,10 +89,11 @@ export function parsePaneState(line: string): PaneState { historySize: Number.parseInt(field(0), 10), historyLimit: Number.parseInt(field(1), 10), cursorY: Number.parseInt(field(2), 10), - paneHeight: Number.parseInt(field(3), 10), - currentCommand: field(4), + cursorX: Number.parseInt(field(3), 10), + paneHeight: Number.parseInt(field(4), 10), + currentCommand: field(5), // pane_current_path may itself contain tabs in pathological cases; rejoin. - currentPath: parts.slice(5).join(SEP), + currentPath: parts.slice(6).join(SEP), }; } diff --git a/src/types.ts b/src/types.ts index 1790b94..818fecc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface PaneState { historySize: number; historyLimit: number; cursorY: number; + cursorX: number; paneHeight: number; currentCommand: string; currentPath: string; diff --git a/test/unit/formats.test.ts b/test/unit/formats.test.ts index 1d1815d..5e939df 100644 --- a/test/unit/formats.test.ts +++ b/test/unit/formats.test.ts @@ -10,11 +10,12 @@ import { describe("parsePaneState", () => { test("parses a tab-separated state line", () => { - const state = parsePaneState("1042\t2000\t12\t50\tzsh\t/home/tom/code\n"); + const state = parsePaneState("1042\t2000\t12\t7\t50\tzsh\t/home/tom/code\n"); expect(state).toEqual({ historySize: 1042, historyLimit: 2000, cursorY: 12, + cursorX: 7, paneHeight: 50, currentCommand: "zsh", currentPath: "/home/tom/code", @@ -22,7 +23,7 @@ describe("parsePaneState", () => { }); test("rejoins paths containing tabs", () => { - const state = parsePaneState("0\t2000\t0\t50\tbash\t/tmp/a\tb"); + const state = parsePaneState("0\t2000\t0\t7\t50\tbash\t/tmp/a\tb"); expect(state.currentPath).toBe("/tmp/a\tb"); }); diff --git a/test/unit/jobs.test.ts b/test/unit/jobs.test.ts index b23cfbc..b14d931 100644 --- a/test/unit/jobs.test.ts +++ b/test/unit/jobs.test.ts @@ -138,6 +138,7 @@ function stubClient(currentCommand = "sh"): TmuxClient { historySize: 0, historyLimit: 2000, cursorY: 0, + cursorX: 8, paneHeight: 30, currentCommand, currentPath: "/proj", @@ -166,6 +167,30 @@ describe("JobManager", () => { expect(sent).toMatch(/^cmd \| tee log; /); }); + test("launch waits for a fresh pane's first prompt before typing", async () => { + // A pane created moments ago reports a fully blank screen until the + // shell prints its prompt; typing early makes the late prompt glue to + // the job's first output line (github flake sidemux-pbg). + const blank = { + historySize: 0, + historyLimit: 2000, + cursorY: 0, + cursorX: 0, + paneHeight: 30, + currentCommand: "sh", + currentPath: "/proj", + }; + const ready = { ...blank, cursorX: 8 }; + const states = [blank, blank, ready]; + const client = stubClient(); + vi.mocked(client.paneState).mockImplementation( + async () => states.shift() ?? ready, + ); + await new JobManager(client).launch("%1", "echo hi", "posix"); + expect(client.paneState).toHaveBeenCalledTimes(3); + expect(client.sendLiteral).toHaveBeenCalledOnce(); + }); + test("launch refuses a pane running a shell that cannot report exit codes", async () => { // tcsh parses `$?` as a variable-existence test, so the sentinel never // prints and the job would hang until its timeout. Failing here is the @@ -274,6 +299,7 @@ describe("JobManager per-job log files", () => { historySize: 0, historyLimit: 2000, cursorY: 0, + cursorX: 8, paneHeight: 30, currentCommand: "sh", currentPath: "/proj",