From 92a1e3fa66210417aba55b91ba2296a978de58d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 15 Aug 2026 07:22:01 +0000 Subject: [PATCH] fix(workspaces): release runtime resources on shutdown --- packages/server/src/server/bootstrap.ts | 1 + .../git-observation/internal/integration.ts | 6 ++++ .../src/server/workspace-runtime/index.ts | 1 + .../workspace-runtime/internal/service.ts | 30 +++++++++++++++++-- .../workspace-runtime.command.test.ts | 2 +- .../workspace-runtime.posix.test.ts | 26 ++++++++++++++++ packages/workspace-helper/src/binding.ts | 2 +- packages/workspace-helper/src/client.ts | 10 +++++-- 8 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 9038c7bdd3..6283f25b39 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1990,6 +1990,7 @@ export async function createPaseoDaemon( if (wsServer) { await wsServer.close(); } + await workspaceRuntime.close(); await serviceProxy.stopStandalone(); // Force-drop remaining sockets so httpServer.close() resolves promptly. // We've already closed wsServer (which sent ws-layer close frames) and diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts index 621a2d9d16..fdb7d9a57f 100644 --- a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts @@ -18,6 +18,7 @@ export interface ObservationRebindTransaction { export interface GitCommonObservationCoordinator { bind(runtime: BoundWorkspaceRuntime, workspaceId: string, driver: WorkspaceRuntimeDriver): void; + close(): Promise; pause(workspaceId: string): Promise; stageResume(workspaceId: string): Promise; destroy(workspaceId: string): Promise; @@ -54,6 +55,11 @@ export function createGitCommonObservationCoordinator(): GitCommonObservationCoo }, }); }, + async close() { + const selected = [...observations]; + observations.clear(); + await Promise.allSettled(selected.map((observation) => observation.physical?.unsubscribe())); + }, async pause(workspaceId) { for (const observation of owned(workspaceId)) { const physical = observation.physical; diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index bc19f09682..1ba2b89098 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -136,6 +136,7 @@ export interface WorkspaceRuntimeProviderCapability { export interface WorkspaceRuntimeService { listRuntimes(): readonly WorkspaceRuntimeCatalogEntry[]; reconcile(): Promise; + close(): Promise; create(input: CreateWorkspaceInput): Promise; run(input: WorkspaceProcessInput): Promise; openTerminal(input: WorkspaceTerminalInput): Promise; diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 064e0efcad..83adfb842d 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -183,6 +183,28 @@ export function createService( if (failures.length > 0) throw new AggregateError(failures, "Workspace reconciliation failed"); }, + async close() { + const workspaceIds = new Set([ + ...processesByWorkspaceId.keys(), + ...fileClients.keys(), + ...fileSubscriptions.keys(), + ...boundRuntimes.keys(), + ]); + boundRuntimes.clear(); + await Promise.all( + [...fileSubscriptions.values()].flatMap((subscriptions) => + [...subscriptions].map(async (subscription) => { + await subscription.bound?.unsubscribe(); + subscription.bound = null; + }), + ), + ); + await gitCommonObservations.close(); + await Promise.all( + [...workspaceIds].map((workspaceId) => closeFiles(workspaceId, true, null)), + ); + await Promise.all([...workspaceIds].map((workspaceId) => stopProcesses(workspaceId))); + }, async create(input) { return sequence(input.workspaceId, async () => { const selectedRuntimeId = await records.resolveRuntimeId(input.workspaceId); @@ -520,7 +542,11 @@ export function createService( return client; } - async function closeFiles(workspaceId: string, forgetSubscriptions = false): Promise { + async function closeFiles( + workspaceId: string, + forgetSubscriptions = false, + reason: Error | null = new Error("Workspace files client is closed"), + ): Promise { const cached = fileClients.get(workspaceId); fileClients.delete(workspaceId); const subscriptions = fileSubscriptions.get(workspaceId); @@ -528,7 +554,7 @@ export function createService( for (const subscription of subscriptions) subscription.bound = null; if (forgetSubscriptions) fileSubscriptions.delete(workspaceId); } - if (cached) await cached.client.close(); + if (cached) await cached.client.close(reason ?? undefined); } async function stageSubscriptionRebind( diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index 673e4e1f9e..3eb24fa1cd 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -399,7 +399,7 @@ test("a failed second subscription rebind rolls back the staged observer set bef ), ).toBe(false); await fixture.service.destroy(fixture.workspaceId); -}); +}, 10_000); test("a file operation racing pause cannot reconstruct the closing helper client", async () => { const fixture = await createFixture("files-pause-race", true); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index d453cc1e01..58371e7ffa 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -22,6 +22,7 @@ import { type WorkspaceRuntimeOptions, type WorkspaceRuntimeService, } from "./index.js"; +import { observeWorkspaceGit } from "../workspace-git-observation.js"; const cleanupRoots: string[] = []; const posixDescribe = describe.runIf(process.platform !== "win32"); @@ -150,6 +151,31 @@ posixDescribe("setup lifecycle environment", () => { }); }); +posixDescribe("service lifecycle", () => { + test("close releases runtime processes and observations without destroying backing", async () => { + const fixture = await createFixture("worktree"); + await fixture.service.create(fixture.createInput); + const runtime = await fixture.service.bind(fixture.workspaceId); + const observation = await observeWorkspaceGit(runtime, () => undefined); + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [process.execPath, "-e", "setInterval(() => {}, 1000)"], + env: {}, + purpose: { kind: "workspace-script", script: "daemon-shutdown" }, + }); + child.stdin.end(); + + await fixture.service.close(); + + await expect(child.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + expect(existsSync(fixture.runtimeRoot())).toBe(true); + expect(fixture.runtimeIds.get(fixture.workspaceId)).toBe("worktree"); + await observation.unsubscribe(); + + await createWorkspaceRuntimeService(fixture.options).destroy(fixture.workspaceId); + }); +}); + posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => { test("returns after materialization without waiting for configured setup", async () => { const fixture = await createFixture(runtimeId); diff --git a/packages/workspace-helper/src/binding.ts b/packages/workspace-helper/src/binding.ts index 864ed57ead..ecf608771e 100644 --- a/packages/workspace-helper/src/binding.ts +++ b/packages/workspace-helper/src/binding.ts @@ -15,7 +15,7 @@ export interface WorkspaceFilesOwner { files: WorkspaceFiles; resolveCommand(command: string): Promise; verify(): Promise; - close(): Promise; + close(reason?: Error): Promise; } export function bindWorkspaceHelper(options: { diff --git a/packages/workspace-helper/src/client.ts b/packages/workspace-helper/src/client.ts index 8efffe050b..3ca354e72a 100644 --- a/packages/workspace-helper/src/client.ts +++ b/packages/workspace-helper/src/client.ts @@ -164,12 +164,12 @@ export function createClient(options: { ); } }, - async close() { + async close(reason) { closePromise ??= (async () => { closing = true; watchSubscriptions.clear(); if (watcher) { - await stopWatcher(watcher, new Error("Workspace files client is closed")); + await stopWatcher(watcher, reason); } await watcherRecovery?.catch(() => undefined); await Promise.all([...processes].map((child) => terminate(child))); @@ -390,7 +390,11 @@ function handleWatchEvent(state: WatchState, line: string): void { } function acknowledgement(state: WatchState, key: string): Promise { - return new Promise((resolve, reject) => state.acknowledgements.set(key, { resolve, reject })); + const pending = new Promise((resolve, reject) => + state.acknowledgements.set(key, { resolve, reject }), + ); + void pending.catch(() => undefined); + return pending; } async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise {