Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions apps/cli/src/cli-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,31 @@ 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;
}
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)));
});
}
Expand Down
26 changes: 20 additions & 6 deletions apps/desktop/src/cli-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down
38 changes: 31 additions & 7 deletions scripts/dev-desktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
}
Expand Down Expand Up @@ -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 "";
Expand Down Expand Up @@ -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.
Expand All @@ -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"));