From c13e5f03102a526104d3702b199a5cdda5d6a17f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:53:54 +0000 Subject: [PATCH] fix(server): keep missing workspaces suspended during refresh Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer. Co-authored-by: Dominic Anaeto --- ...it-service.observation.integration.test.ts | 101 ++++++++++++++++++ .../src/server/workspace-git-service.ts | 7 +- 2 files changed, 106 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 371502748d..1ae04bfc2f 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 @@ -1,3 +1,4 @@ +import { flushGitCommandTrace } from "../utils/git-command-trace.js"; import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; @@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin rmSync(root, { recursive: true, force: true }); } }); + +test.skipIf(process.platform !== "linux")( + "missing folders stop degraded Git polling and explicit refreshes, then recover", + async () => { + const artifacts = path.resolve(".artifacts"); + mkdirSync(artifacts, { recursive: true }); + const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-")); + const cwd = path.join(root, "repo"); + const initializeRepo = (branch: string) => { + mkdirSync(cwd); + execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" }); + writeFileSync(path.join(cwd, "README.md"), "fixture\n"); + execFileSync("git", ["add", "."], { cwd, stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ], + { cwd, stdio: "pipe" }, + ); + }; + initializeRepo("main"); + // Exceed the real Linux observer's directory limit to enter degraded polling. + // No watcher errors or Git responses are injected. + for (let index = 0; index < 5100; index++) { + mkdirSync(path.join(cwd, `generated-${index}`)); + } + const trace = path.join(root, "git.jsonl"); + writeFileSync(trace, ""); + const previousTrace = process.env.PASEO_GIT_TRACE_FILE; + process.env.PASEO_GIT_TRACE_FILE = trace; + let degraded = false; + let suspended = false; + const logger = pino( + { level: "warn" }, + { + write(line) { + const message = JSON.parse(line).msg; + if (message === "Failed to start working tree watcher; using degraded polling") + degraded = true; + if ( + message === + "Workspace folder no longer exists; suspended git observation until it is back" + ) + suspended = true; + }, + }, + ); + const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") }); + const subscription = service.registerWorkspace({ cwd }, () => {}); + const commandCount = async () => { + await flushGitCommandTrace(); + return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + }; + try { + await expect + .poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, { + timeout: 15_000, + }) + .toBe(true); + rmSync(cwd, { recursive: true, force: true }); + await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true); + await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + const commandsWhileMissing = await commandCount(); + await service.getSnapshot(cwd, { + force: true, + includeForge: false, + reason: "refresh-button", + }); + await service.refresh(cwd); + expect(await commandCount()).toBe(commandsWhileMissing); + await new Promise((resolve) => setTimeout(resolve, 16_000)); + expect(await commandCount()).toBe(commandsWhileMissing); + + initializeRepo("restored"); + const snapshot = await service.getSnapshot(cwd, { + force: true, + includeForge: false, + reason: "refresh-button", + }); + expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" }); + await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1); + } finally { + subscription.unsubscribe(); + await service.dispose(); + if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE; + else process.env.PASEO_GIT_TRACE_FILE = previousTrace; + rmSync(root, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index d7483948d3..47a36a2b04 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1722,6 +1722,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.latestFacts = null; + target.folderMissing = false; await this.refreshWorkspaceTarget(target, { force: false, refreshStructure: true, @@ -1731,10 +1732,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { queueIfBusy: true, movedRemoteRefs: new Set(), }); - if (!this.isSuspendedWorkspaceTarget(target)) { + if (target.closed || this.workspaceTargets.get(target.cwd) !== target) { return; } - target.folderMissing = false; target.observationSetupComplete = false; if (target.observationReensureTimer) { clearTimeout(target.observationReensureTimer); @@ -3183,6 +3183,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target: WorkspaceGitTarget, request: WorkspaceGitRefreshRequest, ): Promise { + if (target.folderMissing) { + return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd)); + } if (target.refreshState.status === "in-flight") { const active = target.refreshState.request; const addsWork =