diff --git a/AGENTS.md b/AGENTS.md index 6ed9130..bc578fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ src/ test/ unit/ Unit tests (node:test, dependency-injected, no VS Code API) batchApply.test.ts Batch template and operation count parsing (18 tests) - binary.test.ts Binary discovery, managed install, compatibility, workspace env (66 tests) + binary.test.ts Binary discovery, managed install, compatibility, workspace env (74 tests) binaryDiscovery.test.ts Real executable discovery on PATH (13 tests) initializeProject.test.ts Status display, agents file classification, formatError (61 tests) managedLifecycle.test.ts Managed install with real file I/O (22 tests) diff --git a/src/binary/patchloom.ts b/src/binary/patchloom.ts index 7a30c3a..0fc6e64 100644 --- a/src/binary/patchloom.ts +++ b/src/binary/patchloom.ts @@ -53,16 +53,46 @@ export interface PatchloomStatusInputs { readonly isTrusted?: boolean; } +let inflightStatus: Promise | undefined; + +/** + * Share one in-flight status probe. Activate runs status bar, auto-update, + * and MCP register together; each would otherwise exec `--version`. + * The first `resolve` wins for joiners. No TTL cache: after the probe + * finishes (or after `clearPatchloomStatusInflight`) the next call + * starts a new probe so settings, trust, and install stay fresh. + */ +export async function resolvePatchloomStatusWithSharedInflight( + resolve: () => Promise +): Promise { + if (inflightStatus !== undefined) { + return inflightStatus; + } + const pending = resolve().finally(() => { + if (inflightStatus === pending) { + inflightStatus = undefined; + } + }); + inflightStatus = pending; + return pending; +} + +export function clearPatchloomStatusInflight(): void { + inflightStatus = undefined; +} + export async function resolvePatchloomStatus(): Promise { - const vscode = await import("vscode"); - const managedInstallRoot = getManagedInstallRoot(); - return resolvePatchloomStatusWithInputs({ - configuredPath: vscode.workspace.getConfiguration("patchloom").get("path", ""), - pathValue: process.env.PATH, - platform: process.platform, - arch: process.arch, - managedInstallRoot, - isTrusted: vscode.workspace.isTrusted + return resolvePatchloomStatusWithSharedInflight(async () => { + const vscode = await import("vscode"); + const managedInstallRoot = getManagedInstallRoot(); + return resolvePatchloomStatusWithInputs({ + configuredPath: vscode.workspace.getConfiguration("patchloom").get("path", ""), + pathValue: process.env.PATH, + platform: process.platform, + arch: process.arch, + managedInstallRoot, + isTrusted: vscode.workspace.isTrusted + }); }); } diff --git a/src/commands/managedInstall.ts b/src/commands/managedInstall.ts index 8ef7fc8..719de99 100644 --- a/src/commands/managedInstall.ts +++ b/src/commands/managedInstall.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { resolvePatchloomStatus, comparePatchloomVersions, PATCHLOOM_RELEASES_URL } from "../binary/patchloom.js"; +import { resolvePatchloomStatus, comparePatchloomVersions, PATCHLOOM_RELEASES_URL, clearPatchloomStatusInflight } from "../binary/patchloom.js"; import { detectManagedInstallTarget, fetchLatestReleaseVersion, @@ -67,6 +67,7 @@ export async function installPatchloom(): Promise { }); log?.log(`Managed install complete: Patchloom ${result.version} at ${result.binaryPath}`); + clearPatchloomStatusInflight(); await refreshStatusBar(); await refreshMcpServerBinary(); await vscode.window.showInformationMessage( @@ -143,6 +144,7 @@ export async function updatePatchloom(): Promise { }); log?.log(`Managed update complete: Patchloom ${result.version} at ${result.binaryPath}`); + clearPatchloomStatusInflight(); await refreshStatusBar(); await refreshMcpServerBinary(); await vscode.window.showInformationMessage( @@ -196,6 +198,7 @@ export async function reinstallPatchloom(): Promise { }); log?.log(`Managed reinstall complete: Patchloom ${result.version} at ${result.binaryPath}`); + clearPatchloomStatusInflight(); await refreshStatusBar(); await refreshMcpServerBinary(); await vscode.window.showInformationMessage( diff --git a/src/extension.ts b/src/extension.ts index ac938b6..fc50891 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { setupWorkspace, openPatchloomReleases, openPatchloomSettings, openDocum import { showStatus } from "./commands/showStatus.js"; import { verifyMcp } from "./commands/verifyMcp.js"; import { checkForUpdates } from "./commands/autoUpdate.js"; +import { clearPatchloomStatusInflight } from "./binary/patchloom.js"; import { setManagedInstallRoot } from "./install/managed.js"; import { createPatchloomLog, getPatchloomLog, setPatchloomLog } from "./logging/outputChannel.js"; import { registerMcpServerProviderWithBinary } from "./mcp/register.js"; @@ -37,13 +38,16 @@ export function activate(context: vscode.ExtensionContext): void { new vscode.Disposable(disposeStatusBar), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration("patchloom")) { + clearPatchloomStatusInflight(); void refreshStatusBar(); } }), vscode.workspace.onDidChangeWorkspaceFolders(() => { + clearPatchloomStatusInflight(); void refreshStatusBar(); }), vscode.workspace.onDidGrantWorkspaceTrust(() => { + clearPatchloomStatusInflight(); void refreshStatusBar(); }) ); @@ -54,6 +58,7 @@ export function activate(context: vscode.ExtensionContext): void { } export function deactivate(): void { + clearPatchloomStatusInflight(); setManagedInstallRoot(undefined); const log = getPatchloomLog(); log?.dispose(); diff --git a/test/unit/binary.test.ts b/test/unit/binary.test.ts index 1b84ea8..1f5957d 100644 --- a/test/unit/binary.test.ts +++ b/test/unit/binary.test.ts @@ -15,7 +15,10 @@ import { parsePatchloomVersion, ensurePatchloomReadyOrNotify, preferredBinaryRemediationAction, - resolvePatchloomStatusWithInputs + resolvePatchloomStatusWithInputs, + resolvePatchloomStatusWithSharedInflight, + clearPatchloomStatusInflight, + type PatchloomStatus } from "../../src/binary/patchloom.js"; import { assertTrustedManagedInstallDownloadUrl, @@ -1084,6 +1087,111 @@ test("resolvePatchloomStatusWithInputs reports restricted message when untrusted assert.ok(status.message.includes("untrusted")); }); +test("resolvePatchloomStatusWithSharedInflight coalesces concurrent probes", async () => { + clearPatchloomStatusInflight(); + let calls = 0; + const status: PatchloomStatus = { + ready: true, + source: "path", + message: "ok", + binaryPath: "/bin/patchloom" + }; + const resolve = async () => { + calls += 1; + await new Promise((r) => setTimeout(r, 20)); + return status; + }; + + const [a, b, c] = await Promise.all([ + resolvePatchloomStatusWithSharedInflight(resolve), + resolvePatchloomStatusWithSharedInflight(resolve), + resolvePatchloomStatusWithSharedInflight(resolve) + ]); + + assert.equal(calls, 1); + assert.equal(a, status); + assert.equal(b, status); + assert.equal(c, status); + clearPatchloomStatusInflight(); +}); + +test("resolvePatchloomStatusWithSharedInflight starts a new probe after the first finishes", async () => { + clearPatchloomStatusInflight(); + let calls = 0; + const resolve = async () => { + calls += 1; + return { + ready: true, + source: "path" as const, + message: `call-${calls}`, + binaryPath: "/bin/patchloom" + }; + }; + + const first = await resolvePatchloomStatusWithSharedInflight(resolve); + const second = await resolvePatchloomStatusWithSharedInflight(resolve); + + assert.equal(calls, 2); + assert.equal(first.message, "call-1"); + assert.equal(second.message, "call-2"); + clearPatchloomStatusInflight(); +}); + +test("clearPatchloomStatusInflight starts a new probe while one is pending", async () => { + clearPatchloomStatusInflight(); + let calls = 0; + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const resolve = async () => { + calls += 1; + const n = calls; + if (n === 1) { + await firstGate; + } + return { + ready: true, + source: "path" as const, + message: `call-${n}`, + binaryPath: "/bin/patchloom" + }; + }; + + const first = resolvePatchloomStatusWithSharedInflight(resolve); + clearPatchloomStatusInflight(); + const second = resolvePatchloomStatusWithSharedInflight(resolve); + releaseFirst?.(); + const [a, b] = await Promise.all([first, second]); + assert.equal(calls, 2); + assert.equal(a.message, "call-1"); + assert.equal(b.message, "call-2"); + clearPatchloomStatusInflight(); +}); + +test("resolvePatchloomStatusWithSharedInflight retries after a failed probe", async () => { + clearPatchloomStatusInflight(); + let calls = 0; + const resolve = async () => { + calls += 1; + if (calls === 1) { + throw new Error("version failed"); + } + return { + ready: true, + source: "path" as const, + message: "ok", + binaryPath: "/bin/patchloom" + }; + }; + + await assert.rejects(() => resolvePatchloomStatusWithSharedInflight(resolve), /version failed/); + const recovered = await resolvePatchloomStatusWithSharedInflight(resolve); + assert.equal(calls, 2); + assert.equal(recovered.ready, true); + clearPatchloomStatusInflight(); +}); + test("resolvePatchloomStatusWithInputs allows setting and PATH in trusted workspaces", async () => { const status = await resolvePatchloomStatusWithInputs({ configuredPath: "/custom/patchloom",