Skip to content

Commit f8f2299

Browse files
committed
Fix two crashes that broke the WSL backend on a fresh install
- Bootstrap reader: attach an error listener to the readline Interface so the EAGAIN that the Linux pty bridge surfaces after stdin closes does not bubble up as an unhandled error and exit the backend with code 1 right after parsing the envelope. Guard the resume path so a late error fired before cleanup runs is a no-op. - node-pty Linux prebuild: node-pty's npm tarball ships prebuilds for darwin/win32 only, so a Windows-side install has no pty.node the inner Linux process can load. On WSL backend startup, probe whether node-pty loads inside the distro and, if not, run \`node-gyp rebuild\` once via wsl.exe and stage the result in prebuilds/linux-x64/. Pipe the script through stdin to avoid wsl.exe's quote re-escaping.
1 parent ae0d45c commit f8f2299

3 files changed

Lines changed: 144 additions & 6 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog.ts";
5858
import { resolveDesktopServerExposure } from "./serverExposure.ts";
5959
import {
6060
DISTRO_NAME_PATTERN,
61+
ensureWslNodePty,
6162
extractDistroFromUncPath,
6263
isWslAvailable,
6364
listWslDistrosAsync,
@@ -1421,9 +1422,19 @@ async function startBackend(): Promise<boolean> {
14211422
backendStartInFlight = new Promise<void>((resolve) => {
14221423
resolveStartInFlight = resolve;
14231424
});
1424-
let linuxEntry: string | null;
1425+
let linuxEntry: string | null = null;
1426+
let nodePtyError: string | null = null;
14251427
try {
14261428
linuxEntry = await windowsToWslPathAsync(wslConfig.distro, windowsEntry);
1429+
// node-pty's npm tarball ships prebuilds for darwin/win32 only, so a
1430+
// Windows-side install has no Linux pty.node. Build it once via
1431+
// node-gyp inside WSL before launching the backend.
1432+
if (!app.isPackaged) {
1433+
const result = await ensureWslNodePty(wslConfig.distro, ROOT_DIR);
1434+
if (!result.ok) {
1435+
nodePtyError = result.reason;
1436+
}
1437+
}
14271438
} finally {
14281439
backendStartInFlight = null;
14291440
resolveStartInFlight();
@@ -1433,6 +1444,10 @@ async function startBackend(): Promise<boolean> {
14331444
scheduleBackendRestart("wslpath conversion failed for backend entry");
14341445
return false;
14351446
}
1447+
if (nodePtyError !== null) {
1448+
scheduleBackendRestart(`WSL node-pty unavailable: ${nodePtyError}`);
1449+
return false;
1450+
}
14361451

14371452
const wslForwardedEnv: Record<string, string> = {};
14381453
const wslForwardedEnvNames: string[] = [];

apps/desktop/src/wsl.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,117 @@ export function resolveWslPickFolderDefaultPath(
172172
return homePath;
173173
}
174174

175+
interface RunWslShellResult {
176+
readonly exitCode: number;
177+
readonly stderr: string;
178+
}
179+
180+
// wsl.exe re-escapes args before forwarding them to the Linux side, which
181+
// mangles quotes inside `bash -lc "<script>"`. Pipe the script via stdin to
182+
// avoid passing it on the command line at all.
183+
function runWslShell(
184+
distro: string | null,
185+
bashScript: string,
186+
timeoutMs: number,
187+
): Promise<RunWslShellResult> {
188+
const distroArgs = distro ? ["-d", distro] : [];
189+
return new Promise((resolve) => {
190+
let child: ChildProcess.ChildProcess;
191+
try {
192+
child = ChildProcess.spawn(
193+
"wsl.exe",
194+
// -l so nvm/profile-managed tools (node, node-gyp) are on PATH;
195+
// -s so bash reads the script from stdin instead of the command line.
196+
[...distroArgs, "--", "bash", "-l", "-s"],
197+
{ stdio: ["pipe", "ignore", "pipe"], windowsHide: true },
198+
);
199+
} catch (error) {
200+
resolve({ exitCode: 127, stderr: error instanceof Error ? error.message : String(error) });
201+
return;
202+
}
203+
204+
let stderr = "";
205+
child.stderr?.setEncoding("utf8");
206+
child.stderr?.on("data", (chunk: string) => {
207+
stderr += chunk;
208+
});
209+
210+
let settled = false;
211+
const settle = (exitCode: number, extraStderr?: string) => {
212+
if (settled) return;
213+
settled = true;
214+
clearTimeout(timeout);
215+
resolve({ exitCode, stderr: extraStderr ? `${stderr}${extraStderr}` : stderr });
216+
};
217+
218+
const timeout = setTimeout(() => {
219+
child.kill();
220+
settle(124, "\n[timeout]");
221+
}, timeoutMs);
222+
timeout.unref();
223+
224+
child.on("error", (error) => settle(127, `\n${error.message}`));
225+
child.on("close", (code) => settle(code ?? 1));
226+
227+
if (child.stdin) {
228+
child.stdin.write(bashScript);
229+
child.stdin.end();
230+
} else {
231+
settle(127, "\n[stdin pipe unavailable]");
232+
}
233+
});
234+
}
235+
236+
function shellQuote(value: string): string {
237+
return `'${value.replaceAll("'", "'\\''")}'`;
238+
}
239+
240+
export type EnsureWslNodePtyResult = { ok: true } | { ok: false; reason: string };
241+
242+
// node-pty ships prebuilds for darwin/win32 only — Linux installs build from
243+
// source via node-gyp. When the desktop runs on Windows but launches the
244+
// backend through WSL, the inner Linux node loads modules from the Windows
245+
// node_modules tree, which has no Linux pty.node. This helper probes the
246+
// inner Linux side and, if missing, runs `node-gyp rebuild` once and stages
247+
// the result alongside the existing Windows/macOS prebuilds.
248+
export async function ensureWslNodePty(
249+
distro: string | null,
250+
windowsRepoRoot: string,
251+
): Promise<EnsureWslNodePtyResult> {
252+
const linuxRepoRoot = await windowsToWslPathAsync(distro, windowsRepoRoot);
253+
if (linuxRepoRoot === null) {
254+
return { ok: false, reason: `wslpath conversion failed for ${windowsRepoRoot}` };
255+
}
256+
257+
// node-pty lives in the apps/server workspace's node_modules; resolve from
258+
// there rather than the monorepo root, where Bun's hoist layout omits it.
259+
const linuxServerDir = `${linuxRepoRoot}/apps/server`;
260+
const probe = await runWslShell(
261+
distro,
262+
`cd ${shellQuote(linuxServerDir)} && node -e 'require("node-pty")' >/dev/null 2>&1`,
263+
10_000,
264+
);
265+
if (probe.exitCode === 0) return { ok: true };
266+
267+
const buildScript = [
268+
"set -e",
269+
`cd ${shellQuote(linuxServerDir)}`,
270+
`pkg_dir=$(node -p "require('node:path').dirname(require.resolve('node-pty/package.json'))")`,
271+
`cd "$pkg_dir"`,
272+
"npx --yes node-gyp rebuild",
273+
"mkdir -p prebuilds/linux-x64",
274+
"cp build/Release/pty.node prebuilds/linux-x64/pty.node",
275+
].join("\n");
276+
277+
const build = await runWslShell(distro, buildScript, 5 * 60_000);
278+
if (build.exitCode === 0) return { ok: true };
279+
const trimmed = build.stderr.trim().slice(-500);
280+
return {
281+
ok: false,
282+
reason: `node-pty Linux build failed (exit ${build.exitCode}): ${trimmed || "no stderr captured"}`,
283+
};
284+
}
285+
175286
export async function listWslDistrosAsync(): Promise<WslDistro[]> {
176287
if (process.platform !== "win32") return [];
177288
return new Promise((resolve) => {

apps/server/src/bootstrap.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
3131
crlfDelay: Infinity,
3232
});
3333

34+
let settled = false;
35+
const safeResume = (effect: Effect.Effect<Option.Option<A>, BootstrapError>) => {
36+
if (settled) return;
37+
settled = true;
38+
resume(effect);
39+
};
40+
3441
const cleanup = () => {
3542
stream.removeListener("error", handleError);
43+
input.removeListener("error", handleError);
3644
input.removeListener("line", handleLine);
3745
input.removeListener("close", handleClose);
3846
input.close();
@@ -41,10 +49,10 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
4149

4250
const handleError = (error: Error) => {
4351
if (isUnavailableBootstrapFdError(error)) {
44-
resume(Effect.succeedNone);
52+
safeResume(Effect.succeedNone);
4553
return;
4654
}
47-
resume(
55+
safeResume(
4856
Effect.fail(
4957
new BootstrapError({
5058
message: "Failed to read bootstrap envelope.",
@@ -57,9 +65,9 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
5765
const handleLine = (line: string) => {
5866
const parsed = decodeJsonResult(schema)(line);
5967
if (Result.isSuccess(parsed)) {
60-
resume(Effect.succeedSome(parsed.success));
68+
safeResume(Effect.succeedSome(parsed.success));
6169
} else {
62-
resume(
70+
safeResume(
6371
Effect.fail(
6472
new BootstrapError({
6573
message: "Failed to decode bootstrap envelope.",
@@ -71,10 +79,14 @@ export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function
7179
};
7280

7381
const handleClose = () => {
74-
resume(Effect.succeedNone);
82+
safeResume(Effect.succeedNone);
7583
};
7684

7785
stream.once("error", handleError);
86+
// The readline Interface re-emits stream errors; without a listener the
87+
// process crashes. This shows up under the wsl.exe PTY bridge where the
88+
// inner pipe surfaces EAGAIN after the parent closes stdin.
89+
input.once("error", handleError);
7890
input.once("line", handleLine);
7991
input.once("close", handleClose);
8092

0 commit comments

Comments
 (0)