mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* Fix coding-agent terminal shortcuts not working on Windows On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did nothing, and afterwards even a plain New Terminal stopped working until restart. Three Windows-specific problems combined: - A failed node-pty conpty spawn completes asynchronously on its conout worker thread; the uncaught exception escaped the per-request try/catch and crashed the whole terminal worker, severing every terminal. Add uncaughtException / unhandledRejection guards so a single bad spawn keeps the worker alive. - Profile commands were passed to conpty unresolved. conpty's CreateProcess ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the real path and route .cmd/.bat through cmd.exe /c on Windows. - winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir and aren't on PATH. findExecutable() now falls back to those known install locations, so all providers and terminal profiles benefit. Also surface a failed terminal create to the user via a toast instead of silently dropping the error. * Address review: simplify winget scan, narrow worker exception guard - Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and map package dirs directly to the root <name>.exe candidate. - Remove the unhandledRejection handler in the terminal worker (only the uncaughtException path is exercised by conpty's async spawn failure) and expand the comment to explain the keep-alive trade-off and the absence of a worker restart path. * Refactor executable resolution out of utils * Serialize terminal create requests in worker --------- Co-authored-by: danniel <liminfhu@gmail.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import { afterEach, describe, expect, test } from "vitest";
|
|
|
|
import { isPlatform } from "../test-utils/platform.js";
|
|
import { probeExecutable } from "./executable-resolution.js";
|
|
|
|
const timeoutMs = 1000;
|
|
const timeoutSlackMs = 500;
|
|
const tempDirs: string[] = [];
|
|
|
|
interface ProbeFixture {
|
|
name: string;
|
|
expected: boolean;
|
|
create: (dir: string) => { executablePath: string; pidFile?: string };
|
|
}
|
|
|
|
function makeTempDir(): string {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), "paseo-probe-test-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function writeExecutable(filePath: string, content: string | Buffer): string {
|
|
writeFileSync(filePath, content);
|
|
if (process.platform !== "win32") {
|
|
chmodSync(filePath, 0o755);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function scriptPath(dir: string, name: string): string {
|
|
return process.platform === "win32" ? path.join(dir, `${name}.cmd`) : path.join(dir, name);
|
|
}
|
|
|
|
function createHangingFixture(dir: string): string {
|
|
if (process.platform === "win32") {
|
|
return writeExecutable(
|
|
scriptPath(dir, "hangs"),
|
|
"@echo off\r\n:loop\r\ntimeout /T 5 /NOBREAK > NUL\r\ngoto loop\r\n",
|
|
);
|
|
}
|
|
return writeExecutable(
|
|
scriptPath(dir, "hangs"),
|
|
`#!/bin/sh\ntrap '' TERM\necho $$ > "${path.join(dir, "hangs.pid")}"\nwhile :; do :; done\n`,
|
|
);
|
|
}
|
|
|
|
function createNoVersionFixture(dir: string): string {
|
|
if (process.platform === "win32") {
|
|
return writeExecutable(scriptPath(dir, "no-version"), "@echo off\r\nexit /b 0\r\n");
|
|
}
|
|
return writeExecutable(scriptPath(dir, "no-version"), "#!/bin/sh\nexit 0\n");
|
|
}
|
|
|
|
function createNonZeroFixture(dir: string): string {
|
|
if (process.platform === "win32") {
|
|
return writeExecutable(
|
|
scriptPath(dir, "non-zero"),
|
|
"@echo off\r\necho oops 1>&2\r\nexit /b 1\r\n",
|
|
);
|
|
}
|
|
return writeExecutable(scriptPath(dir, "non-zero"), "#!/bin/sh\necho oops 1>&2\nexit 1\n");
|
|
}
|
|
|
|
function createSlowSuccessFixture(dir: string): string {
|
|
if (process.platform === "win32") {
|
|
return writeExecutable(
|
|
scriptPath(dir, "slow-success"),
|
|
"@echo off\r\nping -n 1 127.0.0.1 > NUL\r\nexit /b 0\r\n",
|
|
);
|
|
}
|
|
return writeExecutable(scriptPath(dir, "slow-success"), "#!/bin/sh\nsleep 0.05\nexit 0\n");
|
|
}
|
|
|
|
function createDirectoryFixture(dir: string): string {
|
|
const directoryPath = path.join(dir, "candidate-directory");
|
|
mkdirSync(directoryPath);
|
|
return directoryPath;
|
|
}
|
|
|
|
function missingAbsolutePath(): string {
|
|
return process.platform === "win32" ? "C:\\no\\such\\path.exe" : "/no/such/path";
|
|
}
|
|
|
|
async function waitForFile(filePath: string): Promise<void> {
|
|
const deadline = performance.now() + timeoutSlackMs;
|
|
while (!existsSync(filePath) && performance.now() < deadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|
|
|
|
const fixtures: ProbeFixture[] = [
|
|
{
|
|
name: "hangs forever after starting",
|
|
expected: true,
|
|
create: (dir) => ({
|
|
executablePath: createHangingFixture(dir),
|
|
pidFile: process.platform === "win32" ? undefined : path.join(dir, "hangs.pid"),
|
|
}),
|
|
},
|
|
{
|
|
name: "does not know --version and exits zero",
|
|
expected: true,
|
|
create: (dir) => ({ executablePath: createNoVersionFixture(dir) }),
|
|
},
|
|
{
|
|
name: "exits non-zero immediately",
|
|
expected: true,
|
|
create: (dir) => ({ executablePath: createNonZeroFixture(dir) }),
|
|
},
|
|
{
|
|
name: "starts slowly and exits zero",
|
|
expected: true,
|
|
create: (dir) => ({ executablePath: createSlowSuccessFixture(dir) }),
|
|
},
|
|
{
|
|
name: "points at a directory",
|
|
expected: false,
|
|
create: (dir) => ({ executablePath: createDirectoryFixture(dir) }),
|
|
},
|
|
{
|
|
name: "does not exist at an absolute path",
|
|
expected: false,
|
|
create: () => ({ executablePath: missingAbsolutePath() }),
|
|
},
|
|
];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("probeExecutable", () => {
|
|
// POSIX-only: positive fixtures rely on direct script probing; Windows command-script probing has separate coverage.
|
|
test.skipIf(isPlatform("win32")).each(fixtures.filter((fixture) => fixture.expected))(
|
|
"$name",
|
|
async ({ create, expected }) => {
|
|
const { executablePath, pidFile } = create(makeTempDir());
|
|
const startedAt = performance.now();
|
|
|
|
const result = await probeExecutable(executablePath, timeoutMs);
|
|
|
|
expect(result).toBe(expected);
|
|
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
|
if (pidFile) {
|
|
await waitForFile(pidFile);
|
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
|
}
|
|
},
|
|
);
|
|
|
|
test.each(fixtures.filter((fixture) => !fixture.expected))(
|
|
"$name",
|
|
async ({ create, expected }) => {
|
|
const { executablePath, pidFile } = create(makeTempDir());
|
|
const startedAt = performance.now();
|
|
|
|
const result = await probeExecutable(executablePath, timeoutMs);
|
|
|
|
expect(result).toBe(expected);
|
|
expect(performance.now() - startedAt).toBeLessThanOrEqual(timeoutMs + timeoutSlackMs);
|
|
if (pidFile) {
|
|
await waitForFile(pidFile);
|
|
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
|
}
|
|
},
|
|
);
|
|
});
|