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
33 changes: 31 additions & 2 deletions src/core/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -201,7 +205,7 @@ export class JobManager {
command: string,
forcedDialect: ShellDialect | null,
): Promise<Job> {
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
Expand Down Expand Up @@ -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<PaneState> {
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
Expand Down
10 changes: 6 additions & 4 deletions src/tmux/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,27 @@ export const PANE_STATE_FORMAT = [
"#{history_size}",
"#{history_limit}",
"#{cursor_y}",
"#{cursor_x}",
"#{pane_height}",
"#{pane_current_command}",
"#{pane_current_path}",
].join(SEP);

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] ?? "";
return {
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),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface PaneState {
historySize: number;
historyLimit: number;
cursorY: number;
cursorX: number;
paneHeight: number;
currentCommand: string;
currentPath: string;
Expand Down
5 changes: 3 additions & 2 deletions test/unit/formats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,20 @@ 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",
});
});

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");
});

Expand Down
26 changes: 26 additions & 0 deletions test/unit/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function stubClient(currentCommand = "sh"): TmuxClient {
historySize: 0,
historyLimit: 2000,
cursorY: 0,
cursorX: 8,
paneHeight: 30,
currentCommand,
currentPath: "/proj",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -274,6 +299,7 @@ describe("JobManager per-job log files", () => {
historySize: 0,
historyLimit: 2000,
cursorY: 0,
cursorX: 8,
paneHeight: 30,
currentCommand: "sh",
currentPath: "/proj",
Expand Down