Skip to content
Open
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
57 changes: 4 additions & 53 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema";
import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as TerminalManager from "../terminal/Manager.ts";
import { watchShellReadiness as watchTerminalShellReadiness } from "../terminal/shellReadiness.ts";
import * as ProjectService from "./ProjectService.ts";

export interface ProjectSetupScriptRunnerResultNoScript {
Expand Down Expand Up @@ -154,18 +155,6 @@ function stripTerminalControl(text: string): string {
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
}

// Line editors (zsh, fish, nu, bash 5.1+) enable bracketed paste when they start reading a command.
const BRACKETED_PASTE_ON = "\x1b[?2004h";
const BRACKETED_PASTE_OFF = "\x1b[?2004l";
// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first.
const SHELL_QUIET_FALLBACK = "3 seconds";

function lineEditorReading(text: string, previous: boolean): boolean {
const on = text.lastIndexOf(BRACKETED_PASTE_ON);
const off = text.lastIndexOf(BRACKETED_PASTE_OFF);
return on === off ? previous : on > off;
}

type CompletionShell = "posix" | "fish" | "nushell" | "powershell";

function resolveCompletionShell(shell: string): CompletionShell | null {
Expand Down Expand Up @@ -312,54 +301,16 @@ export const make = Effect.gen(function* () {
},
);

// Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the
// shell, which loses the command. Subscribe before the terminal opens so no activity is missed.
const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")(
function* (input: {
readonly threadId: string;
readonly terminalId: string;
readonly onStartupPrompt: Effect.Effect<void>;
}) {
const outcome = yield* Deferred.make<"ready" | "exited">();
let reading = false;
let carry = "";
let busy = false;
let activity = 0;
const unsubscribe = yield* terminalManager.subscribe((event) => {
if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) {
return Effect.void;
}
activity += 1;
// A new shell process starts idle; the manager resets its subprocess state without an event.
if (event.type === "started" || event.type === "restarted") {
busy = false;
reading = false;
carry = "";
}
if (event.type === "activity") busy = event.hasRunningSubprocess;
if (event.type === "output") {
const text = carry + event.data;
carry = text.slice(-(BRACKETED_PASTE_ON.length - 1));
reading = lineEditorReading(text, reading);
}
if (event.type === "exited" || event.type === "closed") {
return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid);
}
return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void;
});
// A startup prompt runs as a child process, so a busy shell keeps waiting for the user.
const quiet = Effect.gen(function* () {
while (true) {
const seen = activity;
yield* Effect.sleep(SHELL_QUIET_FALLBACK);
if (activity !== seen) continue;
if (!busy) return "ready" as const;
yield* input.onStartupPrompt;
}
return yield* watchTerminalShellReadiness({
...input,
subscribe: terminalManager.subscribe,
});
// Restored history can hold a previous shell's line editor state, so only live output counts.
const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet));
return { wait, unsubscribe };
},
);

Expand Down
141 changes: 141 additions & 0 deletions apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess {
killed = false;
exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined;
onKill: ((signal?: string) => void) | undefined;
onWrite: ((data: string) => void) | undefined;

constructor(pid: number) {
this.pid = pid;
Expand All @@ -83,6 +84,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess {
throw this.writeFailure;
}
this.writes.push(data);
this.onWrite?.(data);
}

resize(cols: number, rows: number): void {
Expand Down Expand Up @@ -137,6 +139,7 @@ class FakePtyAdapter {
private readonly mode: "sync" | "async";
private nextPid = 9000;
exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined;
onWrite: ((data: string) => void) | undefined;

constructor(mode: "sync" | "async" = "sync") {
this.mode = mode;
Expand All @@ -158,6 +161,7 @@ class FakePtyAdapter {
}
const process = new FakePtyProcess(this.nextPid++);
process.exitOnSubscribe = this.exitOnSubscribe;
process.onWrite = this.onWrite;
this.processes.push(process);
if (this.mode === "async") {
return Effect.tryPromise({
Expand Down Expand Up @@ -561,6 +565,143 @@ it.layer(
}),
);

it.effect("writes an initial command once the fresh shell's line editor reads input", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
const written = yield* Deferred.make<string>();
ptyAdapter.onWrite = (data) => Deferred.doneUnsafe(written, Effect.succeed(data));
const { manager } = yield* createManager(5, { ptyAdapter });

const snapshot = yield* manager.open(openInput({ initialCommand: "npm run dev" }));
expect(snapshot.initialCommandHandled).toBe(true);
expect(ptyAdapter.processes[0]?.writes).toEqual([]);

ptyAdapter.processes[0]?.emitData("\x1b[?2004h$ ");
expect(yield* Deferred.await(written)).toBe(" npm run dev\r");
expect(ptyAdapter.processes[0]?.writes).toEqual([" npm run dev\r"]);
}),
);

it.effect("leaves an initial command unhandled for an existing shell", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
yield* manager.open(openInput());

const snapshot = yield* manager.open(openInput({ initialCommand: "npm run dev" }));

expect(ptyAdapter.spawnInputs).toHaveLength(1);
expect(ptyAdapter.processes[0]?.writes).toEqual([]);
expect(snapshot.initialCommandHandled).toBeUndefined();
}),
);

it.effect("runs a second initial command after a pending first one, in order", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
const written = yield* Deferred.make<ReadonlyArray<string>>();
const seen: Array<string> = [];
ptyAdapter.onWrite = (data) => {
seen.push(data);
if (data === " echo SECOND\r") Deferred.doneUnsafe(written, Effect.succeed([...seen]));
};
const { manager } = yield* createManager(5, { ptyAdapter });

const first = yield* manager.open(openInput({ initialCommand: "echo FIRST" }));
const second = yield* manager.open(openInput({ initialCommand: "echo SECOND" }));
expect(first.initialCommandHandled).toBe(true);
expect(second.initialCommandHandled).toBe(true);
expect(ptyAdapter.processes[0]?.writes).toEqual([]);

ptyAdapter.processes[0]?.emitData("\x1b[?2004h$ ");
expect(yield* Deferred.await(written)).toEqual([" echo FIRST\r", " echo SECOND\r"]);
expect(ptyAdapter.processes[0]?.writes).toEqual([" echo FIRST\r", " echo SECOND\r"]);
}),
);

it.effect("handles an initial command when open restarts a running session", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, baseDir } = yield* createManager();
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const originalCwd = path.join(baseDir, "original");
const differentCwd = path.join(baseDir, "different");
yield* fs.makeDirectory(originalCwd, { recursive: true });
yield* fs.makeDirectory(differentCwd, { recursive: true });

yield* manager.open(openInput({ cwd: originalCwd }));
const reopened = yield* manager.open(
openInput({ cwd: differentCwd, initialCommand: "npm run dev" }),
);

expect(ptyAdapter.spawnInputs).toHaveLength(2);
expect(reopened.initialCommandHandled).toBe(true);
expect(reopened.cwd).toBe(differentCwd);
}),
);

it.effect("writes an initial command after the ready limit when a busy shell never signals", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
const written = yield* Deferred.make<string>();
ptyAdapter.onWrite = (data) => Deferred.doneUnsafe(written, Effect.succeed(data));
const { manager } = yield* createManager(5, {
ptyAdapter,
processKillGraceMs: 0,
subprocessPollIntervalMs: 1_000,
subprocessInspector: () =>
Effect.succeed({ hasRunningSubprocess: true, childCommand: null, processIds: [] }),
});

yield* manager.open(openInput({ initialCommand: "npm run dev" }));
ptyAdapter.processes[0]?.emitData("$ ");
yield* TestClock.adjust("6 seconds");

expect(yield* Deferred.await(written)).toBe(" npm run dev\r");
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("drops a pending initial command when open replaces the shell before it is ready", () =>
Effect.gen(function* () {
const { manager, ptyAdapter, baseDir } = yield* createManager(5, { processKillGraceMs: 0 });
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const originalCwd = path.join(baseDir, "original");
const differentCwd = path.join(baseDir, "different");
yield* fs.makeDirectory(originalCwd, { recursive: true });
yield* fs.makeDirectory(differentCwd, { recursive: true });

yield* manager.open(openInput({ cwd: originalCwd, initialCommand: "echo OLD" }));
yield* manager.open(openInput({ cwd: differentCwd }));
ptyAdapter.processes[1]?.emitData("\x1b[?2004h$ ");
yield* TestClock.adjust("6 seconds");

expect(ptyAdapter.processes.map((process) => process.writes)).toEqual([[], []]);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("writes an initial command to PowerShell without waiting for a line editor", () =>
Effect.gen(function* () {
const ptyAdapter = new FakePtyAdapter();
const written = yield* Deferred.make<string>();
ptyAdapter.onWrite = (data) => Deferred.doneUnsafe(written, Effect.succeed(data));
const { manager } = yield* createManager(5, {
ptyAdapter,
env: {
PATH: "C:\\Windows\\System32",
SystemRoot: "C:\\Windows",
},
}).pipe(Effect.provide(layerWithHostPlatform("win32")));

const snapshot = yield* manager.open(
openInput({ initialCommand: "npm run dev", initialCommandShellHistory: "preserve" }),
);

expect(ptyAdapter.spawnInputs[0]?.shell).toBe("pwsh.exe");
expect(snapshot.initialCommandHandled).toBe(true);
expect(yield* Deferred.await(written)).toBe("npm run dev\r");
}),
);

it.effect("keeps attach streams live when a terminal id is closed and reopened", () =>
Effect.gen(function* () {
const { manager, ptyAdapter } = yield* createManager();
Expand Down
Loading
Loading