From 71dc9f44c9c05ddc595189c9d736393718ac79d6 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:29:15 +0000 Subject: [PATCH] fix(server): finish Git refreshes before daemon shutdown (#8579) Retain admitted refresh promises until they settle, including after the last workspace subscriber leaves. Disposal closes admission and waits for those commands before callers can remove their working directories. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- ...it-service.observation.integration.test.ts | 49 ++++++++++++++++++- .../src/server/workspace-git-service.ts | 10 +++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 1ae04bfc2f..c5cefceb72 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -6,7 +6,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { afterEach, expect, test, vi } from "vitest"; -import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; +import { + getCheckoutStatus as readCheckoutStatus, + type CheckoutSnapshotFacts, + type CheckoutStatusGit, +} from "../utils/checkout-git.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -60,6 +64,49 @@ afterEach(async () => { } }); +test.each([false, true])( + "disposal waits for an admitted Git refresh after observation ends (unsubscribed: %s)", + async (unsubscribeBeforeDisposal) => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = realpathSync(mkdtempSync(path.join(artifacts, "git-refresh-shutdown-"))); + execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" }); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(root, "home"), + deps: { + getCheckoutStatus: async (...args) => { + entered.resolve(); + await release.promise; + return readCheckoutStatus(...args); + }, + }, + }); + const subscription = service.registerWorkspace({ cwd: root }, () => {}); + try { + await entered.promise; + if (unsubscribeBeforeDisposal) subscription.unsubscribe(); + let disposed = false; + const closing = service.dispose().then(() => { + disposed = true; + return undefined; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(disposed).toBe(false); + release.resolve(); + await closing; + expect(disposed).toBe(true); + } finally { + release.resolve(); + subscription.unsubscribe(); + await service.dispose(); + rmSync(root, { recursive: true, force: true }); + } + }, +); + test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => { const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-"))); const repo = path.join(root, "repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 26a7f16419..a7225d6b1e 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -614,6 +614,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly disposeController = new AbortController(); private disposed = false; private disposePromise: Promise | null = null; + private readonly pendingRefreshes = new Set>(); private readonly snapshotUpdatedListeners = new Set(); private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); @@ -1092,7 +1093,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.workingTreeWatchResolutions.clear(); this.workingTreeWatchAliases.clear(); this.snapshotUpdatedListeners.clear(); - this.disposePromise = this.fileObserver.close(); + // Unsubscribing can remove a target while its Git command still owns the cwd. + // Stop admission first, then drain those refreshes before releasing the service. + this.disposePromise = Promise.all([ + this.fileObserver.close(), + Promise.allSettled(this.pendingRefreshes), + ]).then(() => undefined); return this.disposePromise; } @@ -3212,11 +3218,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => { + this.pendingRefreshes.delete(promise); const state = target.refreshState; if (state.status === "in-flight" && state.promise === promise) { target.refreshState = { status: "idle" }; } }); + this.pendingRefreshes.add(promise); target.refreshState = { status: "in-flight", promise,