Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ jobs:
bun run --cwd apps/server test src/serverPrivateDirectories.test.ts
bun run --cwd apps/desktop test src/desktopScientDataDirectories.test.ts

- name: Test Windows backend lifecycle
run: |
bun run --cwd apps/desktop test src/desktopBackendSupervisor.test.ts src/backendProcessTree.test.ts
bun run --cwd apps/server test src/desktopParentShutdown.test.ts

- name: Exercise Windows release staging
run: bun run release:smoke

Expand Down
89 changes: 89 additions & 0 deletions apps/desktop/src/backendProcessTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { EventEmitter } from "node:events";

import { describe, expect, it, vi } from "vitest";

import {
backendProcessContainmentOptions,
forceTerminateBackendProcessTree,
} from "./backendProcessTree";

describe("forceTerminateBackendProcessTree", () => {
it("always reserves an IPC channel and isolates POSIX process groups", () => {
expect(backendProcessContainmentOptions(true, "linux")).toEqual({
detached: true,
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
expect(backendProcessContainmentOptions(false, "darwin")).toEqual({
detached: true,
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
expect(backendProcessContainmentOptions(false, "win32")).toEqual({
detached: false,
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
});

it("kills the detached POSIX process group", async () => {
const killProcessGroup = vi.fn();

await forceTerminateBackendProcessTree({ pid: 4321 }, { platform: "linux", killProcessGroup });

expect(killProcessGroup).toHaveBeenCalledWith(-4321, "SIGKILL");
});

it("ignores a POSIX process group that already exited", async () => {
await expect(
forceTerminateBackendProcessTree(
{ pid: 4321 },
{
platform: "darwin",
killProcessGroup: () => {
const error = new Error("missing") as NodeJS.ErrnoException;
error.code = "ESRCH";
throw error;
},
},
),
).resolves.toBeUndefined();
});

it("uses the Windows taskkill executable without a shell", async () => {
const process = new EventEmitter();
const spawnProcess = vi.fn(() => process);

const terminating = forceTerminateBackendProcessTree(
{ pid: 4321 },
{
platform: "win32",
env: { SystemRoot: "D:\\Windows" },
spawnProcess: spawnProcess as never,
},
);
process.emit("exit", 0, null);
await terminating;

expect(spawnProcess).toHaveBeenCalledWith(
"D:\\Windows\\System32\\taskkill.exe",
["/PID", "4321", "/T", "/F"],
{
env: { SystemRoot: "D:\\Windows" },
shell: false,
stdio: "ignore",
windowsHide: true,
},
);
});

it("does not treat a missing Windows root as successful descendant cleanup", async () => {
const process = new EventEmitter();
const spawnProcess = vi.fn(() => process);
const terminating = forceTerminateBackendProcessTree(
{ pid: 4321 },
{ platform: "win32", spawnProcess: spawnProcess as never },
);

process.emit("exit", 128, null);

await expect(terminating).rejects.toThrow("taskkill exited with status 128");
});
});
73 changes: 73 additions & 0 deletions apps/desktop/src/backendProcessTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { spawn } from "node:child_process";
import type { ChildProcess, SpawnOptions } from "node:child_process";
import path from "node:path";

import { resolveWindowsSystemRoot } from "@synara/shared/windowsProcess";

export interface ForceTerminateBackendProcessTreeOptions {
readonly platform?: NodeJS.Platform;
readonly env?: NodeJS.ProcessEnv;
readonly killProcessGroup?: (pid: number, signal: NodeJS.Signals) => void;
readonly spawnProcess?: typeof spawn;
}

export function backendProcessContainmentOptions(
captureLogs: boolean,
platform: NodeJS.Platform = process.platform,
): Pick<SpawnOptions, "detached" | "stdio"> {
return {
detached: platform !== "win32",
stdio: captureLogs
? ["ignore", "pipe", "pipe", "ipc"]
: ["ignore", "inherit", "inherit", "ipc"],
};
}

function ignoreMissingProcess(error: unknown): void {
if ((error as NodeJS.ErrnoException)?.code !== "ESRCH") throw error;
}

async function forceTerminateWindowsTree(
pid: number,
options: ForceTerminateBackendProcessTreeOptions,
): Promise<void> {
const env = options.env ?? process.env;
const taskkill = path.win32.join(resolveWindowsSystemRoot(env), "System32", "taskkill.exe");
const child = (options.spawnProcess ?? spawn)(taskkill, ["/PID", String(pid), "/T", "/F"], {
env,
shell: false,
stdio: "ignore",
windowsHide: true,
});

await new Promise<void>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`taskkill exited with status ${code ?? "null"}`));
});
});
}

/** Force-kills the backend and all descendants after graceful IPC shutdown timed out. */
export async function forceTerminateBackendProcessTree(
child: Pick<ChildProcess, "pid">,
options: ForceTerminateBackendProcessTreeOptions = {},
): Promise<void> {
const pid = child.pid;
if (!pid || pid <= 0) return;

if ((options.platform ?? process.platform) === "win32") {
await forceTerminateWindowsTree(pid, options);
return;
}

try {
(options.killProcessGroup ?? process.kill)(-pid, "SIGKILL");
} catch (error) {
ignoreMissingProcess(error);
}
}
50 changes: 41 additions & 9 deletions apps/desktop/src/backendStartupReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,70 @@ import { waitForBackendStartupReady } from "./backendStartupReadiness";
describe("waitForBackendStartupReady", () => {
it("resolves from http when no listening promise is provided", async () => {
const waitForHttpReady = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const cancelHttpWait = vi.fn();
const onHttpReady = vi.fn();

await expect(
waitForBackendStartupReady({
waitForHttpReady,
cancelHttpWait,
onHttpReady,
}),
).resolves.toBe("http");

expect(waitForHttpReady).toHaveBeenCalledTimes(1);
expect(cancelHttpWait).not.toHaveBeenCalled();
expect(onHttpReady).toHaveBeenCalledTimes(1);
});

it("prefers the listening signal and cancels the http wait", async () => {
it("opens from the listening signal without declaring semantic readiness", async () => {
let resolveListening!: () => void;
let resolveHttp!: () => void;
const listeningPromise = new Promise<void>((resolve) => {
resolveListening = resolve;
});
const waitForHttpReady = vi.fn(() => new Promise<void>(() => {}));
const cancelHttpWait = vi.fn();
const waitForHttpReady = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveHttp = resolve;
}),
);
const onHttpReady = vi.fn();

const resultPromise = waitForBackendStartupReady({
listeningPromise,
waitForHttpReady,
cancelHttpWait,
onHttpReady,
});

resolveListening();

await expect(resultPromise).resolves.toBe("listening");
expect(cancelHttpWait).toHaveBeenCalledTimes(1);
expect(onHttpReady).not.toHaveBeenCalled();

resolveHttp();
await vi.waitFor(() => expect(onHttpReady).toHaveBeenCalledTimes(1));
});

it("reports semantic readiness failure after the listening signal already opened the window", async () => {
let resolveListening!: () => void;
let rejectHttp!: (error: Error) => void;
const listeningPromise = new Promise<void>((resolve) => {
resolveListening = resolve;
});
const onHttpFailure = vi.fn();
const resultPromise = waitForBackendStartupReady({
listeningPromise,
waitForHttpReady: () =>
new Promise<void>((_resolve, reject) => {
rejectHttp = reject;
}),
onHttpFailure,
});

resolveListening();
await expect(resultPromise).resolves.toBe("listening");

const error = new Error("startup readiness timed out");
rejectHttp(error);
await vi.waitFor(() => expect(onHttpFailure).toHaveBeenCalledWith(error));
});

it("rejects when the listening promise fails before http is ready", async () => {
Expand All @@ -45,7 +78,6 @@ describe("waitForBackendStartupReady", () => {
waitForBackendStartupReady({
listeningPromise: Promise.reject(error),
waitForHttpReady: () => new Promise<void>(() => {}),
cancelHttpWait: vi.fn(),
}),
).rejects.toThrow("backend exited");
});
Expand Down
25 changes: 15 additions & 10 deletions apps/desktop/src/backendStartupReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { isBackendReadinessAborted } from "./backendReadiness";
export interface WaitForBackendStartupReadyOptions {
readonly listeningPromise?: Promise<void> | null;
readonly waitForHttpReady: () => Promise<void>;
readonly cancelHttpWait: () => void;
readonly onHttpReady?: () => void;
readonly onHttpFailure?: (error: unknown) => void;
}

export async function waitForBackendStartupReady(
Expand All @@ -13,8 +14,14 @@ export async function waitForBackendStartupReady(
const listeningPromise = options.listeningPromise;

if (!listeningPromise) {
await httpReadyPromise;
return "http";
try {
await httpReadyPromise;
options.onHttpReady?.();
return "http";
} catch (error) {
if (!isBackendReadinessAborted(error)) options.onHttpFailure?.(error);
throw error;
}
}

return await new Promise<"listening" | "http">((resolve, reject) => {
Expand All @@ -25,9 +32,6 @@ export async function waitForBackendStartupReady(
return;
}
settled = true;
if (source === "listening") {
options.cancelHttpWait();
}
resolve(source);
};

Expand All @@ -44,11 +48,12 @@ export async function waitForBackendStartupReady(
(error) => settleReject(error),
);
httpReadyPromise.then(
() => settleResolve("http"),
() => {
options.onHttpReady?.();
settleResolve("http");
},
(error) => {
if (settled && isBackendReadinessAborted(error)) {
return;
}
if (!isBackendReadinessAborted(error)) options.onHttpFailure?.(error);
settleReject(error);
},
);
Expand Down
Loading
Loading