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"));