From ad094a528b758ff547e9c44efa5291e76b2ad72f Mon Sep 17 00:00:00 2001 From: CDVolvik Date: Mon, 10 Aug 2026 01:20:00 -0700 Subject: [PATCH 1/3] fix(server): let slow provider CLIs raise their discovery probe budget Azure DevOps was reported as "Not available on this server" on Windows even with `az` and the azure-devops extension installed. `az` boots a fresh Python interpreter on every invocation, so `az --version` takes roughly six seconds and overruns the fixed five-second discovery probe. probeCli treats any failure, including a timeout, as "missing", so the install hint was rendered for a CLI that was present and working. Add an optional probeTimeoutMs to the CLI discovery spec, defaulting to the existing five seconds, and honour it at all three sites that spawn the spec's executable: the version probe, the auth probe, and unknown-remote refinement. Slowness is a property of the CLI rather than of one call site, and the issue notes the auth probe was at risk of the same failure. Azure DevOps opts in at twenty seconds; gh and glab answer in about 0.3s and keep the default. Discovery probes run with concurrency "unbounded", and a genuinely absent binary fails fast with a spawn error rather than a timeout, so the longer budget only costs time on machines where az is installed and slow. Fixes #5355 --- .../AzureDevOpsSourceControlProvider.ts | 4 + .../SourceControlDiscovery.test.ts | 74 ++++++++++++++++++- .../SourceControlProviderDiscovery.ts | 15 +++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac9829275..2f147452f9ec 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -45,6 +45,10 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04cd..83d3c8d60e0b 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -281,3 +281,75 @@ Logged in to gitlab.com as gitlab-user ); }).pipe(Effect.provide(testLayer)); }); + +it.effect( + "keeps a slow provider CLI available when its probe budget covers the startup cost", + () => { + // `az` boots a fresh Python interpreter on every invocation, so `az --version` takes + // roughly six seconds on Windows. This mock answers only when the caller waits that long. + const azStartupMs = 6_000; + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "az") { + if ((input.timeoutMs ?? 0) < azStartupMs) { + return Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? 0, + }), + ); + } + if (input.args[0] === "--version") { + return Effect.succeed(processOutput("azure-cli 2.77.0\n")); + } + if (input.args.join(" ") === "account show --query user.name -o tsv") { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-slow-cli-discovery-", + }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + const azure = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); + assert.ok(azure); + assert.strictEqual(azure.status, "available"); + assert.strictEqual(azure.auth.status, "authenticated"); + assert.deepStrictEqual(azure.auth.account, Option.some("azure-user@example.com")); + }).pipe(Effect.provide(testLayer)); + }, +); diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb205..b2b9e4513378 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -52,6 +53,14 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; @@ -167,7 +176,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(input.spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -244,7 +253,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +296,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) From 5bb967c49ff6c15f1c56bd2dce791dc463502466 Mon Sep 17 00:00:00 2001 From: CDVolvik Date: Tue, 11 Aug 2026 14:16:20 -0700 Subject: [PATCH 2/3] test(server): pin the worst case when a slow CLI wedges after --version --- .../SourceControlDiscovery.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 83d3c8d60e0b..aec9da1729b7 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -353,3 +353,73 @@ it.effect( }).pipe(Effect.provide(testLayer)); }, ); + +it.effect( + "spends the raised budget twice when a slow CLI answers --version but wedges on auth", + () => { + // The version and auth probes are sequential and share one per-spec budget, so raising + // it also raises the worst case: an `az` that is installed but wedged on `account show` + // costs the budget twice before discovery reports an unknown auth state. This test pins + // that cost so it stays visible if the number is ever changed. + const budgets: Array = []; + const processMock = { + run: (input: VcsProcess.VcsProcessInput) => { + if (input.command === "az") { + budgets.push(input.timeoutMs ?? 0); + if (input.args[0] === "--version") { + return Effect.succeed(processOutput("azure-cli 2.77.0\n")); + } + return Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? 0, + }), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + } satisfies Partial; + const testLayer = SourceControlDiscovery.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-source-control-wedged-cli-discovery-", + }), + ), + Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), + Layer.provide( + sourceControlProviderRegistryTestLayer({ + process: processMock, + bitbucket: { + probeAuth: Effect.succeed({ + status: "unauthenticated", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.none(), + }), + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; + const result = yield* discovery.discover; + + const azure = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); + assert.ok(azure); + assert.strictEqual(azure.status, "available"); + assert.strictEqual(azure.auth.status, "unknown"); + assert.deepStrictEqual(budgets, [20_000, 20_000]); + }).pipe(Effect.provide(testLayer)); + }, +); From 9b2c73bd31eb64e729a42844e11b46b7c76205f8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 12:40:38 +0200 Subject: [PATCH 3/3] Discard changes to apps/server/src/sourceControl/SourceControlDiscovery.test.ts --- .../SourceControlDiscovery.test.ts | 144 +----------------- 1 file changed, 1 insertion(+), 143 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index aec9da1729b7..9e4702af04cd 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -281,145 +281,3 @@ Logged in to gitlab.com as gitlab-user ); }).pipe(Effect.provide(testLayer)); }); - -it.effect( - "keeps a slow provider CLI available when its probe budget covers the startup cost", - () => { - // `az` boots a fresh Python interpreter on every invocation, so `az --version` takes - // roughly six seconds on Windows. This mock answers only when the caller waits that long. - const azStartupMs = 6_000; - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.command === "az") { - if ((input.timeoutMs ?? 0) < azStartupMs) { - return Effect.fail( - new VcsProcessTimeoutError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 0, - }), - ); - } - if (input.args[0] === "--version") { - return Effect.succeed(processOutput("azure-cli 2.77.0\n")); - } - if (input.args.join(" ") === "account show --query user.name -o tsv") { - return Effect.succeed(processOutput("azure-user@example.com\n")); - } - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const testLayer = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-slow-cli-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - sourceControlProviderRegistryTestLayer({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }), - }, - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; - const result = yield* discovery.discover; - - const azure = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); - assert.ok(azure); - assert.strictEqual(azure.status, "available"); - assert.strictEqual(azure.auth.status, "authenticated"); - assert.deepStrictEqual(azure.auth.account, Option.some("azure-user@example.com")); - }).pipe(Effect.provide(testLayer)); - }, -); - -it.effect( - "spends the raised budget twice when a slow CLI answers --version but wedges on auth", - () => { - // The version and auth probes are sequential and share one per-spec budget, so raising - // it also raises the worst case: an `az` that is installed but wedged on `account show` - // costs the budget twice before discovery reports an unknown auth state. This test pins - // that cost so it stays visible if the number is ever changed. - const budgets: Array = []; - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.command === "az") { - budgets.push(input.timeoutMs ?? 0); - if (input.args[0] === "--version") { - return Effect.succeed(processOutput("azure-cli 2.77.0\n")); - } - return Effect.fail( - new VcsProcessTimeoutError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 0, - }), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const testLayer = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-wedged-cli-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - sourceControlProviderRegistryTestLayer({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "unauthenticated", - account: Option.none(), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }), - }, - }), - ), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; - const result = yield* discovery.discover; - - const azure = result.sourceControlProviders.find((item) => item.kind === "azure-devops"); - assert.ok(azure); - assert.strictEqual(azure.status, "available"); - assert.strictEqual(azure.auth.status, "unknown"); - assert.deepStrictEqual(budgets, [20_000, 20_000]); - }).pipe(Effect.provide(testLayer)); - }, -);