From bdef9e9fbb4330eace0962cf3f08349525b37035 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 30 Oct 2026 10:59:40 +0100 Subject: [PATCH] fix(app): stop isolated daemon writers before removing test homes Waiting only for the supervisor lets a surviving daemon worker recreate its deleted home during teardown. Reuse daemon lifecycle shutdown and terminal reaping before removing owned state, including forced cleanup. --- .../app/e2e/browser/daemon-lifecycle.spec.ts | 57 ++++++++++++++++++- .../e2e/support/helpers/daemon-lifecycle.ts | 41 ++++++++++++- .../support/helpers/isolated-host-daemon.ts | 55 ++++++++++++++---- 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/browser/daemon-lifecycle.spec.ts b/packages/app/e2e/browser/daemon-lifecycle.spec.ts index 2e65778868..23f6f5b2ff 100644 --- a/packages/app/e2e/browser/daemon-lifecycle.spec.ts +++ b/packages/app/e2e/browser/daemon-lifecycle.spec.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "../support/fixtures"; +import { expect, test, daemonTest as cleanupTest } from "../support/fixtures"; import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; import { openDaemonOverview, @@ -13,6 +14,10 @@ import { readWorkerPid, openLongLivedTerminal, listTerminalIds, + suspendDaemonProcess, + resumeDaemonProcess, + daemonProcessCanWrite, + readTerminalProcessIds, } from "../support/helpers/daemon-lifecycle"; test("settings restart waits for a replacement worker while retaining the supervisor", async ({ @@ -119,3 +124,51 @@ async function createHomeWithSlowProvider(versionDelayMs: number): Promise { + cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection"); + const daemon = await startIsolatedHostDaemon(randomUUID()); + const workerPid = await readWorkerPid(daemon); + try { + suspendDaemonProcess(workerPid); + await daemon.close(); + resumeDaemonProcess(workerPid); + await expect.poll(() => daemonProcessCanWrite(workerPid)).toBe(false); + expect(existsSync(daemon.paseoHome)).toBe(false); + } finally { + resumeDaemonProcess(workerPid); + await daemon.close(); + await rm(daemon.paseoHome, { recursive: true, force: true }); + } + }, +); + +cleanupTest( + "isolated cleanup force-stops a frozen supervisor and its detached terminal writers", + async () => { + cleanupTest.skip(process.platform === "win32", "POSIX stopped-process failure injection"); + const daemon = await startIsolatedHostDaemon(randomUUID()); + const supervisorPid = daemon.getPid()!; + const workerPid = await readWorkerPid(daemon); + try { + await openLongLivedTerminal(daemon); + const terminalProcesses = await readTerminalProcessIds(daemon); + expect(terminalProcesses.length).toBeGreaterThanOrEqual(2); + suspendDaemonProcess(supervisorPid); + await daemon.close(); + const writerStates = await Promise.all( + [supervisorPid, workerPid, ...terminalProcesses].map(daemonProcessCanWrite), + ); + expect(writerStates).toEqual(Array(terminalProcesses.length + 2).fill(false)); + expect(existsSync(daemon.paseoHome)).toBe(false); + } finally { + resumeDaemonProcess(supervisorPid); + await daemon.close(); + } + }, +); diff --git a/packages/app/e2e/support/helpers/daemon-lifecycle.ts b/packages/app/e2e/support/helpers/daemon-lifecycle.ts index d72cf063b1..c38c0f5f78 100644 --- a/packages/app/e2e/support/helpers/daemon-lifecycle.ts +++ b/packages/app/e2e/support/helpers/daemon-lifecycle.ts @@ -1,5 +1,5 @@ import { execFile, spawn } from "node:child_process"; -import { writeFile } from "node:fs/promises"; +import { writeFile, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { expect, type Page } from "@playwright/test"; @@ -154,3 +154,42 @@ export async function plantUnclosableTerminalProgram( throw error; } } + +/** Fault injection and liveness checks for the isolated daemon cleanup journey. */ +export function suspendDaemonProcess(pid: number): void { + process.kill(pid, "SIGSTOP"); +} + +export function resumeDaemonProcess(pid: number): void { + try { + process.kill(pid, "SIGCONT"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +export async function daemonProcessCanWrite(pid: number): Promise { + try { + const state = await psField(pid, "stat"); + return state.length > 0 && !state.startsWith("Z"); + } catch (error) { + if ((error as { code?: unknown }).code === 1) return false; + throw error; + } +} + +export async function readTerminalProcessIds(daemon: IsolatedHostDaemon): Promise { + const directory = path.join(daemon.paseoHome, "terminal-worker"); + const records = await Promise.all( + (await readdir(directory)) + .filter((name) => name.endsWith(".json")) + .map(async (name) => { + const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as { + pid: number; + terminals: Array<{ pid: number }>; + }; + return [record.pid].concat(record.terminals.map((terminal) => terminal.pid)); + }), + ); + return records.flat(); +} diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index 01fe3a70c1..85f88bc164 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -1,3 +1,6 @@ +import { pathToFileURL } from "node:url"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "./daemon-client-loader"; import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import net from "node:net"; @@ -152,6 +155,34 @@ export async function startIsolatedHostDaemon( const serverDir = publishedPackageRoot ? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server") : path.resolve(__dirname, "../../../../server"); + let child: ChildProcess | null = null; + const stopDaemon = async (): Promise => { + // The daemon lifecycle owns its worker and detached PTYs. Waiting only for + // the supervisor lets a surviving worker recreate the home during removal. + // Load the ESM lifecycle entry natively, as daemon-client-loader does for + // the SDK; Playwright's CommonJS transform cannot load its createRequire. + const lifecycleUrl = pathToFileURL( + path.resolve(__dirname, "../../../../server/dist/server/server/daemon-instance.js"), + ).href; + const { stopDaemonInstance } = (await import( + lifecycleUrl + )) as typeof import("@getpaseo/server/daemon-control"); + await stopDaemonInstance(paseoHome, { + force: true, + requestShutdown: async () => { + const client = await connectDaemonClient({ + port, + clientIdPrefix: "isolated-host-cleanup", + }); + try { + await client.shutdownServer(); + } finally { + await client.close(); + } + }, + }); + await killProcessTree(child); + }; const spawnDaemon = async (): Promise => { const spawnOptions: SpawnOptions = { cwd: serverDir, @@ -169,21 +200,21 @@ export async function startIsolatedHostDaemon( stdio: ["ignore", "ignore", "pipe"], detached: false, }; - const child = publishedPackageRoot + const spawned = publishedPackageRoot ? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions) : spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions); + child = spawned; let stderr = ""; - child.stderr?.on("data", (chunk: Buffer) => { + spawned.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); stderr = stderr.split("\n").slice(-40).join("\n"); }); try { - await waitForServer(port, child); - return child; + await waitForServer(port, spawned); + return spawned; } catch (error) { - await killProcessTree(child); throw new Error( `${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`, { cause: error }, @@ -191,10 +222,10 @@ export async function startIsolatedHostDaemon( } }; - let child: ChildProcess; try { - child = await spawnDaemon(); + await spawnDaemon(); } catch (error) { + await stopDaemon(); if (!options.preserveHome) { await rm(paseoHome, { recursive: true, force: true }); } @@ -209,22 +240,22 @@ export async function startIsolatedHostDaemon( serverId, port, paseoHome, - getPid: () => child.pid, + getPid: () => child?.pid, restart: async () => { if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); - await killProcessTree(child); - child = await spawnDaemon(); + await stopDaemon(); + await spawnDaemon(); }, close: async () => { if (closed) return; - closed = true; - await killProcessTree(child); + await stopDaemon(); if (!options.preserveHome) { await rm(paseoHome, { recursive: true, force: true }); } if (publishedPackageRoot) { await rm(publishedPackageRoot, { recursive: true, force: true }); } + closed = true; }, }; }