From 4bc3def0794831a5c30877321193ee3cec585675 Mon Sep 17 00:00:00 2001 From: lequesilva <34479622+lequesilva@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:07:01 -0300 Subject: [PATCH] fix(windows): make dev:desktop and the CLI bridge work on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent issues, both only surfacing on win32: 1. scripts/dev-desktop.mjs spawned "npm" and node_modules/.bin binaries directly via execFileSync/spawn without shell:true. On Windows these are .cmd shims, so Node's ENOENT lookup fails before anything runs. Also fixed the teardown logic, which killed process GROUPS via a negative PID (POSIX-only) — on win32 it now just kills the PID directly. Vite's port is now passed explicitly (--port/--strictPort) rather than relying on the config default, and the "is this port held by our own stale vite" detection (previously lsof/ps) now has a PowerShell equivalent (Get-NetTCPConnection / Get-CimInstance Win32_Process) for win32. 2. The CLI <-> app handshake over the named pipe never completed on Windows. The client wrote the handshake and called sock.end() immediately (half-close), which is how the Unix-socket version of this protocol expects to signal "done sending" while leaving the socket open for the server's reply. Windows named pipes don't support that: end() on one side tears down the whole duplex pipe, so by the time the app's cli-server tried to write its reply, the pipe was already gone (write EPIPE) and the CLI's JSON.parse("") failed with "Unexpected end of JSON input" on every single command (open, context, etc). Fixed by switching the handshake to newline-delimited framing instead of relying on a half-close to mark the end of the message: the client keeps its socket open, writes `\n`, and only closes after reading the server's `\n` reply. The server mirrors this (parses up to the first newline instead of waiting for the 'end' event). Verified end-to-end on Windows 11: `dapi open ` and `dapi context` both round-trip correctly now. Tested via `npm run dev:desktop` from a clean clone on Windows 11 (Node 24). --- apps/cli/src/cli-client.ts | 18 ++++++++++++---- apps/desktop/src/cli-server.ts | 26 +++++++++++++++++------ scripts/dev-desktop.mjs | 38 +++++++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/cli-client.ts b/apps/cli/src/cli-client.ts index 6e2d3f6d..497ad776 100644 --- a/apps/cli/src/cli-client.ts +++ b/apps/cli/src/cli-client.ts @@ -35,14 +35,19 @@ function requestConnection(handshake: CliHandshake, timeoutMs: number): Promise< sock.setTimeout(timeoutMs, () => settle(() => reject(new Error("Timed out waiting for the app to accept the connection"))), ); - sock.on("connect", () => sock.end(JSON.stringify(handshake))); + // Windows named pipes tear down the whole duplex connection when the + // client half-closes with sock.end() — the server never gets to write + // its reply (EPIPE). Write only, keep reading, and close ourselves once + // we've read the newline-delimited reply. + sock.on("connect", () => sock.write(JSON.stringify(handshake) + "\n")); sock.on("data", (chunk) => { buf += chunk; - }); - sock.on("end", () => { + const newlineAt = buf.indexOf("\n"); + if (newlineAt === -1) return; + const line = buf.slice(0, newlineAt); let reply: CliHandshakeReply; try { - reply = JSON.parse(buf) as CliHandshakeReply; + reply = JSON.parse(line) as CliHandshakeReply; } catch (e) { settle(() => reject(e instanceof Error ? e : new Error(String(e)))); return; @@ -50,6 +55,11 @@ function requestConnection(handshake: CliHandshake, timeoutMs: number): Promise< if (reply.ok) settle(resolve); else settle(() => reject(new Error(reply.error))); }); + sock.on("end", () => { + // Server closed without ever sending a complete line — surface something + // useful instead of leaving the promise hanging until the timeout. + settle(() => reject(new Error("App closed the connection before replying"))); + }); sock.on("error", (err) => settle(() => reject(err))); }); } diff --git a/apps/desktop/src/cli-server.ts b/apps/desktop/src/cli-server.ts index c78cf301..b4ad833d 100644 --- a/apps/desktop/src/cli-server.ts +++ b/apps/desktop/src/cli-server.ts @@ -97,7 +97,16 @@ async function deliverHandshake(handshake: CliHandshake, sock: Socket): Promise< } catch (err) { reply = { ok: false, error: (err as Error).message }; } - if (!sock.destroyed) sock.end(JSON.stringify(reply)); + // Windows named pipes don't support allowHalfOpen the way Unix sockets do — + // if the CLIENT closes its write side first (sock.end()) the whole duplex + // pipe tears down on Windows before the server gets a chance to write back + // (EPIPE). Fix: the client now keeps its socket fully open until it has + // read our reply (newline-delimited), so it's safe for us to write then end. + if (!sock.destroyed) { + sock.write(JSON.stringify(reply) + "\n", () => { + if (!sock.destroyed) sock.end(); + }); + } } export function startCliServer() { @@ -107,21 +116,26 @@ export function startCliServer() { cliServer = createServer({ allowHalfOpen: true }, (sock: Socket) => { enableHeadless(); let buf = ""; + let handled = false; sock.setEncoding("utf8"); sock.setTimeout(60000, () => sock.destroy()); - sock.on("data", (chunk) => { + // Newline-delimited framing instead of relying on the client's `end()` to + // mark the message boundary — see the note on Windows named pipes above. + sock.on("data", async (chunk) => { buf += chunk; - }); - sock.on("end", async () => { + const newlineAt = buf.indexOf("\n"); + if (handled || newlineAt === -1) return; + handled = true; sock.setTimeout(0); + const line = buf.slice(0, newlineAt); let handshake: CliHandshake; try { - handshake = JSON.parse(buf) as CliHandshake; + handshake = JSON.parse(line) as CliHandshake; if (typeof handshake.port !== "number" || typeof handshake.token !== "string") { throw new Error("Malformed handshake"); } } catch { - sock.end(JSON.stringify({ ok: false, error: "Invalid handshake" })); + sock.write(JSON.stringify({ ok: false, error: "Invalid handshake" }) + "\n", () => sock.end()); return; } await deliverHandshake(handshake, sock); diff --git a/scripts/dev-desktop.mjs b/scripts/dev-desktop.mjs index a5cf8262..5262f6ae 100644 --- a/scripts/dev-desktop.mjs +++ b/scripts/dev-desktop.mjs @@ -27,7 +27,7 @@ function run(name, bin, args, cwd) { // SIGTERM isn't dressed up as a "Lifecycle script failed" error by an npm // wrapper. Own process group (detached) so we can signal the tool *and* its // children (esbuild, electron) in one shot on teardown. - const child = spawn(join(BIN, bin), args, { cwd, stdio: "inherit", detached: true }); + const child = spawn(join(BIN, bin), args, { cwd, stdio: "inherit", detached: process.platform !== "win32", shell: process.platform === "win32" }); child.on("exit", (code) => { if (shuttingDown) return; // A child dying on its own (e.g. Vite crashed) should bring the rest down. @@ -43,7 +43,8 @@ function shutdown(code) { shuttingDown = true; for (const child of children) { try { - process.kill(-child.pid, "SIGTERM"); + if (process.platform === "win32") process.kill(child.pid, "SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); } catch { // Already gone. } @@ -79,19 +80,42 @@ for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, () => shutdown(0)); } -/** PIDs listening on a TCP port (macOS/Linux via lsof); [] when none or unknown. */ +/** PIDs listening on a TCP port (macOS/Linux via lsof, Windows via PowerShell); [] when none or unknown. */ function listeners(port) { try { + if (process.platform === "win32") { + const out = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + `Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`, + ], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + return out.toString().split(/\r?\n/).map((line) => Number(line.trim())).filter(Boolean); + } const out = execFileSync("lsof", ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"], { stdio: ["ignore", "pipe", "ignore"] }); return out.toString().split("\n").map((line) => Number(line.trim())).filter(Boolean); } catch { - return []; // lsof exits 1 when nothing listens. + return []; // lsof/PowerShell exit non-zero when nothing listens. } } /** The command line of a process, or "" when it is gone. */ function commandOf(pid) { try { + if (process.platform === "win32") { + return execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue).CommandLine`, + ], + { stdio: ["ignore", "pipe", "ignore"] }, + ).toString().trim(); + } return execFileSync("ps", ["-o", "command=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; @@ -135,12 +159,12 @@ async function reclaimPort(port) { // 1. Build the CLI (blocking) so `dapi` and the app agree on the latest code. console.log("[dev:desktop] building CLI…"); -execFileSync("npm", ["run", "build", "--workspace=@diffusionstudio/cli"], { stdio: "inherit" }); +execFileSync("npm", ["run", "build", "--workspace=@diffusionstudio/cli"], { stdio: "inherit", shell: true }); // 2. Start the web dev server, on a port that is free. await reclaimPort(DEV_PORT); console.log("[dev:desktop] starting web dev server…"); -run("web", "vite", [], join(ROOT, "apps", "web")); +run("web", "vite", ["--port", String(DEV_PORT), "--strictPort"], join(ROOT, "apps", "web")); // 3. Once it is up, build the desktop app (blocking, mirrors its `dev` // script) and launch Electron, which loads :5173. @@ -151,6 +175,6 @@ try { shutdown(1); } console.log("[dev:desktop] building desktop app…"); -execFileSync("npm", ["run", "build", "--workspace=@diffusionstudio/desktop"], { stdio: "inherit" }); +execFileSync("npm", ["run", "build", "--workspace=@diffusionstudio/desktop"], { stdio: "inherit", shell: true }); console.log("[dev:desktop] starting desktop app…"); run("desktop", "electron-forge", ["start"], join(ROOT, "apps", "desktop"));