Skip to content

Commit 85569b1

Browse files
committed
Serialize WSL toggle requests and gate node-pty rebuild for packaged installs
- Queue concurrent wsl-set-config IPC calls behind a single in-flight promise (wslConfigUpdateInFlight). Two rapid toggles previously raced through stopBackend / startBackend in interleaved order, leaving the backend pinned to whichever spawn won the race and the on-disk config ahead of reality. - Always roll back the persisted wsl-config.json when the swap fails, not just when startBackend resolves to false. Wrap stopBackend / startBackend / waitForBackendWindowReady in a single try block so synchronous spawn failures, missing distros, or HTTP-readiness timeouts all unwind the on-disk config to the previous value before the IPC call rejects. Without this, an unhandled spawn throw left the renderer with a generic IPC error and the next launch silently honored a never-confirmed config. - Surface a Windows-side rollback message too, so disabling WSL with a failing Windows backend produces the same toast/log behavior as the WSL-enable failure case. - Split ensureWslNodePty's behavior with an allowBuild flag: dev builds still rebuild on demand, packaged builds only reuse a verified staged binary. Add prepareWslNodePty.ts entry point so packagers can stage the prebuild in CI via 'bun run prepare:wsl' instead of relying on a cold-path build inside the desktop process.
1 parent f8f2299 commit 85569b1

5 files changed

Lines changed: 281 additions & 63 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ type LinuxDesktopNamedApp = Electron.App & {
218218
let mainWindow: BrowserWindow | null = null;
219219
let backendProcess: ChildProcess.ChildProcess | null = null;
220220
let backendStartInFlight: Promise<void> | null = null;
221+
let wslConfigUpdateInFlight: Promise<boolean> | null = null;
221222
let backendPort = 0;
222223
let backendBindHost = DESKTOP_LOOPBACK_HOST;
223224
let backendBootstrapToken = "";
@@ -1427,13 +1428,13 @@ async function startBackend(): Promise<boolean> {
14271428
try {
14281429
linuxEntry = await windowsToWslPathAsync(wslConfig.distro, windowsEntry);
14291430
// 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-
}
1431+
// Windows-side install has no Linux pty.node. Dev builds can compile it
1432+
// on demand; packaged builds only reuse a verified staged binary.
1433+
const result = await ensureWslNodePty(wslConfig.distro, ROOT_DIR, {
1434+
allowBuild: !app.isPackaged,
1435+
});
1436+
if (!result.ok) {
1437+
nodePtyError = result.reason;
14371438
}
14381439
} finally {
14391440
backendStartInFlight = null;
@@ -1998,20 +1999,42 @@ function registerIpcHandlers(): void {
19981999
if (distro !== null && !DISTRO_NAME_PATTERN.test(distro)) {
19992000
return false;
20002001
}
2001-
const previousConfig = loadWslConfig(STATE_DIR);
2002-
saveWslConfig(STATE_DIR, { enabled: typed.enabled, distro });
2003-
await stopBackendAndWaitForExit();
2004-
const started = await startBackend();
2005-
if (!started && typed.enabled) {
2006-
// Roll back so the on-disk config matches the renderer's view; the
2007-
// exponential-backoff restart will relaunch under the previous
2008-
// configuration. Without this, the renderer's error toast leaves the
2009-
// UI showing the old toggle while the next app launch silently honors
2010-
// the never-confirmed configuration.
2011-
saveWslConfig(STATE_DIR, previousConfig);
2012-
throw new Error("Could not start the backend inside WSL.");
2013-
}
2014-
return true;
2002+
2003+
const update = async (): Promise<boolean> => {
2004+
const previousConfig = loadWslConfig(STATE_DIR);
2005+
saveWslConfig(STATE_DIR, { enabled: typed.enabled, distro });
2006+
try {
2007+
await stopBackendAndWaitForExit();
2008+
const started = await startBackend();
2009+
if (!started) {
2010+
throw new Error(
2011+
typed.enabled
2012+
? "Could not start the backend inside WSL."
2013+
: "Could not restart the backend on Windows.",
2014+
);
2015+
}
2016+
await waitForBackendWindowReady(backendHttpUrl);
2017+
return true;
2018+
} catch (error) {
2019+
// Roll back so the on-disk config matches the renderer's view; the
2020+
// exponential-backoff restart will relaunch under the previous
2021+
// configuration. Without this, the renderer's error toast leaves the
2022+
// UI showing the old toggle while the next app launch silently honors
2023+
// the never-confirmed configuration.
2024+
saveWslConfig(STATE_DIR, previousConfig);
2025+
throw error;
2026+
}
2027+
};
2028+
2029+
const previousUpdate = wslConfigUpdateInFlight;
2030+
const queuedUpdate = (previousUpdate ?? Promise.resolve()).catch(() => false).then(update);
2031+
const trackedUpdate = queuedUpdate.finally(() => {
2032+
if (wslConfigUpdateInFlight === trackedUpdate) {
2033+
wslConfigUpdateInFlight = null;
2034+
}
2035+
});
2036+
wslConfigUpdateInFlight = trackedUpdate;
2037+
return await trackedUpdate;
20152038
});
20162039

20172040
ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
@@ -2241,25 +2264,43 @@ async function bootstrap(): Promise<void> {
22412264

22422265
registerIpcHandlers();
22432266
writeDesktopLogHeader("bootstrap ipc handlers registered");
2267+
const shouldGateDevWindowOnBackend =
2268+
isDevelopment && loadWslConfig(STATE_DIR).enabled && isWslAvailable();
22442269
void startBackend();
22452270
writeDesktopLogHeader("bootstrap backend start requested");
22462271

22472272
if (isDevelopment) {
2248-
mainWindow = createWindow();
2249-
writeDesktopLogHeader("bootstrap main window created");
2250-
void waitForBackendWindowReady(backendHttpUrl)
2251-
.then((source) => {
2273+
if (shouldGateDevWindowOnBackend) {
2274+
try {
2275+
const source = await waitForBackendWindowReady(backendHttpUrl);
22522276
writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
2253-
})
2254-
.catch((error) => {
2277+
} catch (error) {
22552278
if (isBackendReadinessAborted(error)) {
22562279
return;
22572280
}
22582281
writeDesktopLogHeader(
22592282
`bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
22602283
);
22612284
console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2262-
});
2285+
}
2286+
}
2287+
mainWindow = createWindow();
2288+
writeDesktopLogHeader("bootstrap main window created");
2289+
if (!shouldGateDevWindowOnBackend) {
2290+
void waitForBackendWindowReady(backendHttpUrl)
2291+
.then((source) => {
2292+
writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
2293+
})
2294+
.catch((error) => {
2295+
if (isBackendReadinessAborted(error)) {
2296+
return;
2297+
}
2298+
writeDesktopLogHeader(
2299+
`bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
2300+
);
2301+
console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2302+
});
2303+
}
22632304
return;
22642305
}
22652306

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
3+
import * as Path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { ensureWslNodePty } from "./wsl.ts";
7+
8+
const repoRoot = Path.resolve(Path.dirname(fileURLToPath(import.meta.url)), "../../..");
9+
10+
if (process.platform !== "win32") {
11+
console.error("prepare:wsl must be run from Windows so it can invoke wsl.exe.");
12+
process.exit(1);
13+
}
14+
15+
const result = await ensureWslNodePty(null, repoRoot, { allowBuild: true });
16+
if (!result.ok) {
17+
console.error(result.reason);
18+
process.exit(1);
19+
}
20+
21+
console.log("WSL node-pty is prepared for the default distro.");

apps/desktop/src/wsl.test.ts

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import * as ChildProcess from "node:child_process";
1+
import { spawn } from "node:child_process";
22
import { EventEmitter } from "node:events";
33
import * as FS from "node:fs";
44
import * as Path from "node:path";
55
import * as OS from "node:os";
66
import { Readable } from "node:stream";
77
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
88
import {
9+
ensureWslNodePty,
910
extractDistroFromUncPath,
1011
parseWslDistroList,
1112
loadWslConfig,
@@ -15,10 +16,57 @@ import {
1516
windowsToWslPathAsync,
1617
} from "./wsl.js";
1718

19+
vi.mock("node:child_process", () => ({
20+
spawn: vi.fn(),
21+
}));
22+
1823
function makeUtf16LeBuffer(text: string): Buffer {
1924
return Buffer.from("\uFEFF" + text, "utf16le");
2025
}
2126

27+
const spawnMock = spawn as unknown as ReturnType<typeof vi.fn>;
28+
29+
function makeChildProcessMock(options?: {
30+
stdout?: string;
31+
stderr?: string;
32+
closeCode?: number;
33+
emitStdinError?: boolean;
34+
throwOnWrite?: boolean;
35+
}) {
36+
const stdout = new Readable({
37+
read() {
38+
if (options?.stdout) this.push(options.stdout);
39+
this.push(null);
40+
},
41+
});
42+
const stderr = new Readable({
43+
read() {
44+
if (options?.stderr) this.push(options.stderr);
45+
this.push(null);
46+
},
47+
});
48+
const stdin = Object.assign(new EventEmitter(), {
49+
write: vi.fn(() => {
50+
if (options?.throwOnWrite) {
51+
throw new Error("stdin closed");
52+
}
53+
if (options?.emitStdinError) {
54+
setTimeout(() => stdin.emit("error", new Error("stdin closed")), 0);
55+
}
56+
return true;
57+
}),
58+
end: vi.fn(),
59+
});
60+
const child = Object.assign(new EventEmitter(), {
61+
stdout,
62+
stderr,
63+
stdin,
64+
kill: vi.fn(),
65+
});
66+
setTimeout(() => child.emit("close", options?.closeCode ?? 0), options?.emitStdinError ? 10 : 0);
67+
return child as unknown as ReturnType<typeof spawn>;
68+
}
69+
2270
describe("parseWslDistroList", () => {
2371
it("parses standard output with default distro marked", () => {
2472
const output = makeUtf16LeBuffer(
@@ -85,34 +133,90 @@ describe("parseWslDistroList", () => {
85133

86134
describe("windowsToWslPathAsync", () => {
87135
afterEach(() => {
88-
vi.restoreAllMocks();
136+
vi.clearAllMocks();
89137
});
90138

91139
it("normalizes backslashes before passing the path to wslpath", async () => {
92-
const stdout = new Readable({
93-
read() {
94-
this.push("/mnt/c/Users/Josh/project\n");
95-
this.push(null);
96-
},
97-
});
98-
const child = Object.assign(new EventEmitter(), {
99-
stdout,
100-
kill: vi.fn(),
101-
});
102-
const spawn = vi
103-
.spyOn(ChildProcess, "spawn")
104-
.mockReturnValue(child as unknown as ChildProcess.ChildProcess);
105-
setTimeout(() => child.emit("close", 0), 0);
140+
spawnMock.mockReturnValueOnce(makeChildProcessMock({ stdout: "/mnt/c/Users/Josh/project\n" }));
106141

107142
await expect(windowsToWslPathAsync("Ubuntu", "C:\\Users\\Josh\\project")).resolves.toBe(
108143
"/mnt/c/Users/Josh/project",
109144
);
110-
expect(spawn).toHaveBeenCalledWith(
145+
expect(spawnMock).toHaveBeenCalledWith(
111146
"wsl.exe",
112147
["-d", "Ubuntu", "--", "wslpath", "-u", "C:/Users/Josh/project"],
113148
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
114149
);
115150
});
151+
152+
it("normalizes WSL UNC paths before passing them to wslpath", async () => {
153+
spawnMock.mockReturnValueOnce(makeChildProcessMock({ stdout: "/home/josh/project\n" }));
154+
155+
await expect(
156+
windowsToWslPathAsync("Ubuntu", "\\\\wsl.localhost\\Ubuntu\\home\\josh\\project"),
157+
).resolves.toBe("/home/josh/project");
158+
expect(spawnMock).toHaveBeenCalledWith(
159+
"wsl.exe",
160+
["-d", "Ubuntu", "--", "wslpath", "-u", "//wsl.localhost/Ubuntu/home/josh/project"],
161+
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
162+
);
163+
});
164+
});
165+
166+
describe("ensureWslNodePty", () => {
167+
afterEach(() => {
168+
vi.clearAllMocks();
169+
});
170+
171+
it("reuses an already prepared node-pty build", async () => {
172+
spawnMock
173+
.mockReturnValueOnce(makeChildProcessMock({ stdout: "/mnt/c/repo\n" }))
174+
.mockReturnValueOnce(makeChildProcessMock());
175+
176+
await expect(ensureWslNodePty("Ubuntu", "C:\\repo")).resolves.toEqual({ ok: true });
177+
expect(spawnMock).toHaveBeenCalledTimes(2);
178+
});
179+
180+
it("reports a setup command instead of building when builds are disabled", async () => {
181+
spawnMock
182+
.mockReturnValueOnce(makeChildProcessMock({ stdout: "/mnt/c/repo\n" }))
183+
.mockReturnValueOnce(makeChildProcessMock({ closeCode: 2 }));
184+
185+
await expect(ensureWslNodePty("Ubuntu", "C:\\repo", { allowBuild: false })).resolves.toEqual({
186+
ok: false,
187+
reason:
188+
"node-pty is not prepared for this WSL Node runtime. Run `bun run prepare:wsl` from this checkout, then restart the desktop app.",
189+
});
190+
expect(spawnMock).toHaveBeenCalledTimes(2);
191+
});
192+
193+
it("stages the rebuilt binary under the Linux Node architecture", async () => {
194+
spawnMock
195+
.mockReturnValueOnce(makeChildProcessMock({ stdout: "/mnt/c/repo\n" }))
196+
.mockReturnValueOnce(makeChildProcessMock({ closeCode: 2 }))
197+
.mockReturnValueOnce(makeChildProcessMock());
198+
199+
await expect(ensureWslNodePty("Ubuntu", "C:\\repo", { allowBuild: true })).resolves.toEqual({
200+
ok: true,
201+
});
202+
expect(spawnMock.mock.calls[2]?.[1]).toEqual(["-d", "Ubuntu", "--", "bash", "-l", "-s"]);
203+
const stdin = spawnMock.mock.results[2]?.value.stdin as { write: ReturnType<typeof vi.fn> };
204+
const script = stdin.write.mock.calls[0]?.[0] as string;
205+
expect(script).toContain('prebuild_dir="prebuilds/linux-$arch"');
206+
expect(script).toContain("t3code-wsl-node-pty.json");
207+
});
208+
209+
it("handles stdin pipe errors without throwing", async () => {
210+
spawnMock
211+
.mockReturnValueOnce(makeChildProcessMock({ stdout: "/mnt/c/repo\n" }))
212+
.mockReturnValueOnce(makeChildProcessMock({ closeCode: 2 }))
213+
.mockReturnValueOnce(makeChildProcessMock({ throwOnWrite: true }));
214+
215+
await expect(ensureWslNodePty("Ubuntu", "C:\\repo", { allowBuild: true })).resolves.toEqual({
216+
ok: false,
217+
reason: "node-pty Linux build failed (exit 127): stdin closed",
218+
});
219+
});
116220
});
117221

118222
describe("extractDistroFromUncPath", () => {
@@ -169,8 +273,9 @@ describe("resolveWslPickFolderDefaultPath", () => {
169273
});
170274

171275
it("maps Linux initial paths to WSL UNC paths", () => {
172-
expect(resolveWslPickFolderDefaultPath({ initialPath: "/home/josh/project" }, config, distros))
173-
.toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project");
276+
expect(
277+
resolveWslPickFolderDefaultPath({ initialPath: "/home/josh/project" }, config, distros),
278+
).toBe("\\\\wsl.localhost\\Debian\\home\\josh\\project");
174279
});
175280

176281
it("maps tilde initial paths under the WSL home UNC path", () => {

0 commit comments

Comments
 (0)