From 14720f3c908a789efe1bb3c0514511a94038788d Mon Sep 17 00:00:00 2001 From: Hector Marquez Date: Thu, 29 Oct 2026 22:33:07 -0400 Subject: [PATCH] fix(azure-devops): pull request Code tab loads when the pull request changes many files A diff page read up to 300 files, two az processes each, before answering, so pull requests with many files outran the client's 60s diff timeout and the Code tab never rendered. Close a page once 15s have passed after a batch of reads; the existing cursor loads the rest as the user scrolls. Claude Opus 5.5 in T3 Code (Claude Code harness) --- .../AzureDevOpsPullRequestProvider.test.ts | 22 +++++++++++++++++++ .../server/AzureDevOpsPullRequestProvider.ts | 14 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts index 345d74de84ba1..8038cb4b2be43 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import { + DIFF_SLICE_READ_BUDGET_MILLIS, LOCATION_CACHE_CAPACITY, make, MAX_DIFF_SPAWNS, @@ -58,6 +60,7 @@ const readSlice = (input: { readonly refused?: ReadonlyArray; readonly created?: ReadonlyArray; readonly cursor?: string; + readonly millisPerRead?: number; }) => Effect.gen(function* () { const reads: string[] = []; @@ -87,6 +90,7 @@ const readSlice = (input: { const answersAfter = input.paths.length - input.paths.indexOf(item.path); for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; inFlight -= 1; + if (input.millisPerRead !== undefined) yield* TestClock.adjust(input.millisPerRead); if (refused.has(item.path)) { return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({ command: "az", @@ -299,6 +303,24 @@ describe("what one diff slice spends", () => { }), ); + it.effect( + "stops starting reads once the slice has run long enough to risk the client giving up", + () => + Effect.gen(function* () { + const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`); + // Two reads per file and four files a batch: the second batch crosses the budget. + const read = yield* readSlice({ + paths, + lines: 2, + width: 4, + millisPerRead: DIFF_SLICE_READ_BUDGET_MILLIS / 15, + }); + + expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 8)); + expect(read.slice.nextCursor).toBe(`${ITERATION.id}:8`); + }), + ); + it.effect("carries on from where the last slice stopped", () => Effect.gen(function* () { const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"]; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 17aaed9a930d2..8baacb0007854 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -1,4 +1,5 @@ import { resolvePullRequestAuthorFilter, SourceControlProviderKind } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -43,6 +44,12 @@ export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; export const LOCATION_CACHE_CAPACITY = 128; +/** + * Stop starting file reads once a slice has run this long. The client abandons a diff page after + * 60 seconds, and each `az` read can take several seconds on a busy machine. + */ +export const DIFF_SLICE_READ_BUDGET_MILLIS = 15_000; + const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: false, @@ -389,6 +396,7 @@ export const make = Effect.gen(function* () { // the Code tab has nothing to scope itself to and always asks for the whole change. getDiff: Effect.fn("AzureDevOpsPullRequestProvider.getDiff")( function* (input) { + const startedAt = yield* Clock.currentTimeMillis; const scope = yield* diffScope(input); if (scope === null) return EMPTY_DIFF_SLICE; const cursor = parseAzureDevOpsDiffCursor(input.cursor); @@ -465,6 +473,12 @@ export const make = Effect.gen(function* () { break; } } + if ( + !full && + (yield* Clock.currentTimeMillis) - startedAt >= DIFF_SLICE_READ_BUDGET_MILLIS + ) { + full = true; + } } const slice: ProviderDiffSlice = {