Skip to content

Commit 4c5f771

Browse files
committed
fix(review): avoid cancel-restarting an in-flight visual-capture fallback dispatch
The fallback workflow runs with concurrency.cancel-in-progress: true per head SHA, so a recapture-poll retry firing before a slower build finishes would dispatch a second run, cancel the first, and repeat forever. Check for an already-queued/in-progress run for the same (PR, head SHA) before dispatching.
1 parent 1b4b7d1 commit 4c5f771

4 files changed

Lines changed: 192 additions & 10 deletions

File tree

src/review/visual/actions-fallback.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,45 @@ export function parseFallbackRunCorrelation(displayTitle: string | undefined | n
144144
return { prNumber, headSha: (match[2] as string).toLowerCase() };
145145
}
146146

147+
/** True when a fallback run for this EXACT (prNumber, headSha) is already queued or in progress -- checked
148+
* by buildCapture before dispatching, so the existing recapture-poll retry (every 90s, up to 5 attempts,
149+
* see PREVIEW_POLL_SECONDS/MAX_PREVIEW_POLLS in processors.ts) doesn't repeatedly re-dispatch while waiting
150+
* for the SAME run's workflow_run completion. That matters because the workflow's own `concurrency: group:
151+
* visual-capture-fallback-${{ inputs.head_sha }}` + `cancel-in-progress: true` means a second dispatch for
152+
* the same head SHA CANCELS the first -- without this check, a poll firing before a slow build finishes
153+
* would cancel-and-restart it every 90s and the fallback could never complete. Queries GitHub's own run
154+
* list rather than persisting new dispatch-tracking state, mirroring this pipeline's existing
155+
* live-query-don't-persist pattern (getLatestDeploymentStatus, findPreviewUrlFromChecks). Fails OPEN (false,
156+
* "nothing in flight") on any error -- a transient list-runs failure should still let the existing
157+
* concurrency group be the backstop dedup, not silently stop the fallback from ever being tried. */
158+
export async function hasInFlightFallbackDispatch(params: {
159+
token: string;
160+
repo: GitHubRepo;
161+
prNumber: number;
162+
headSha: string;
163+
rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined;
164+
}): Promise<boolean> {
165+
const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`;
166+
try {
167+
const response = await timeoutFetch(`${base}/actions/workflows/${FALLBACK_WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=20`, {
168+
headers: githubApiHeaders(params.token),
169+
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
170+
githubRateLimitAdmission: params.rateLimitAdmissionKey !== undefined,
171+
...(params.rateLimitAdmissionKey ? { githubRateLimitAdmissionKey: params.rateLimitAdmissionKey } : {}),
172+
});
173+
if (!response.ok) return false;
174+
const payload = (await response.json().catch(() => null)) as { workflow_runs?: Array<{ status?: string; display_title?: string }> } | null;
175+
const headSha = params.headSha.toLowerCase();
176+
return (payload?.workflow_runs ?? []).some((run) => {
177+
if (run.status !== "queued" && run.status !== "in_progress") return false;
178+
const correlation = parseFallbackRunCorrelation(run.display_title);
179+
return correlation !== null && correlation.prNumber === params.prNumber && correlation.headSha === headSha;
180+
});
181+
} catch {
182+
return false;
183+
}
184+
}
185+
147186
// ---------------------------------------------------------------------------------------------------------
148187
// Minimal ZIP reader -- just enough to read a GitHub Actions artifact (STORED / DEFLATE entries only).
149188
// ---------------------------------------------------------------------------------------------------------

src/review/visual/capture.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// default TanStack route convention; those hooks can return if a per-repo visual config is added.
1313
import { sha256Hex } from "../../utils/crypto";
1414
import type { GitHubRateLimitAdmissionKey } from "../../github/client";
15-
import { dispatchVisualCaptureFallback, fallbackShotR2Key } from "./actions-fallback";
15+
import { dispatchVisualCaptureFallback, fallbackShotR2Key, hasInFlightFallbackDispatch } from "./actions-fallback";
1616
import {
1717
findPreviewUrlFromChecks,
1818
findPreviewUrlFromPrComments,
@@ -405,15 +405,23 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge
405405
const actionsFallbackEnabled = visualConfig?.actionsFallback === true;
406406
const routes = resolveVisualRoutes(visualFiles, visualConfig?.routes);
407407
if (!previewBase && !previewFailed && !previewPending && actionsFallbackEnabled && target.headSha && target.defaultBranchRef) {
408-
const dispatched = await dispatchVisualCaptureFallback({
409-
token,
410-
repo,
411-
ref: target.defaultBranchRef,
412-
prNumber: target.prNumber,
413-
headSha: target.headSha,
414-
routes,
415-
rateLimitAdmissionKey,
416-
});
408+
// Never re-dispatch onto an already in-flight run (#4112 review fix): the workflow's own `concurrency:
409+
// cancel-in-progress: true` group would CANCEL that run the instant a second dispatch for the same head
410+
// SHA lands, so a recapture-poll retry (every 90s -- see PREVIEW_POLL_SECONDS in processors.ts) firing
411+
// before a slower build finishes could cancel-and-restart it forever and never complete. See
412+
// hasInFlightFallbackDispatch's own doc comment for the full rationale.
413+
const alreadyInFlight = await hasInFlightFallbackDispatch({ token, repo, prNumber: target.prNumber, headSha: target.headSha, rateLimitAdmissionKey });
414+
const dispatched =
415+
alreadyInFlight ||
416+
(await dispatchVisualCaptureFallback({
417+
token,
418+
repo,
419+
ref: target.defaultBranchRef,
420+
prNumber: target.prNumber,
421+
headSha: target.headSha,
422+
routes,
423+
rateLimitAdmissionKey,
424+
}));
417425
if (dispatched) previewPending = true;
418426
}
419427

test/unit/actions-fallback.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
fallbackShotR2Key,
88
fetchFallbackArtifactShots,
99
FALLBACK_ARTIFACT_NAME,
10+
hasInFlightFallbackDispatch,
1011
isGithubArtifactStorageUrl,
1112
parseFallbackRunCorrelation,
1213
parseZipEntries,
@@ -349,6 +350,82 @@ describe("dispatchVisualCaptureFallback", () => {
349350
});
350351
});
351352

353+
describe("hasInFlightFallbackDispatch (#4112 review fix -- avoid cancel-in-progress re-dispatch)", () => {
354+
const HEAD_SHA = "cafebabecafebabecafebabecafebabecafebabe";
355+
356+
function runsResponse(runs: Array<{ status?: string; display_title?: string }>): Response {
357+
return Response.json({ workflow_runs: runs });
358+
}
359+
360+
it("true when a QUEUED run matches this exact pr+headSha", async () => {
361+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "queued", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }]));
362+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
363+
expect(inFlight).toBe(true);
364+
});
365+
366+
it("true when an IN_PROGRESS run matches (case-insensitive headSha)", async () => {
367+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }]));
368+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA.toUpperCase() });
369+
expect(inFlight).toBe(true);
370+
});
371+
372+
it("false for an empty run list", async () => {
373+
vi.stubGlobal("fetch", async () => runsResponse([]));
374+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
375+
expect(inFlight).toBe(false);
376+
});
377+
378+
it("false when the matching run has already COMPLETED (not queued/in_progress)", async () => {
379+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "completed", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }]));
380+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
381+
expect(inFlight).toBe(false);
382+
});
383+
384+
it("false when an in-progress run exists for a DIFFERENT PR", async () => {
385+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=99 sha=${HEAD_SHA}` }]));
386+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
387+
expect(inFlight).toBe(false);
388+
});
389+
390+
it("false when an in-progress run exists for the same PR but a DIFFERENT headSha (new push)", async () => {
391+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=7 sha=${"f".repeat(40)}` }]));
392+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
393+
expect(inFlight).toBe(false);
394+
});
395+
396+
it("false when the run's display_title doesn't match the expected correlation shape at all", async () => {
397+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: "Manually triggered run" }]));
398+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
399+
expect(inFlight).toBe(false);
400+
});
401+
402+
it("false on a non-ok response", async () => {
403+
vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 }));
404+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
405+
expect(inFlight).toBe(false);
406+
});
407+
408+
it("false (never throws) on a network failure", async () => {
409+
vi.stubGlobal("fetch", async () => {
410+
throw new Error("network down");
411+
});
412+
const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA });
413+
expect(inFlight).toBe(false);
414+
});
415+
416+
it("true when a rateLimitAdmissionKey is supplied and a match is found", async () => {
417+
vi.stubGlobal("fetch", async () => runsResponse([{ status: "queued", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }]));
418+
const inFlight = await hasInFlightFallbackDispatch({
419+
token: "tok",
420+
repo: { owner: "acme", repo: "widgets" },
421+
prNumber: 7,
422+
headSha: HEAD_SHA,
423+
rateLimitAdmissionKey: "installation:1",
424+
});
425+
expect(inFlight).toBe(true);
426+
});
427+
});
428+
352429
describe("fetchFallbackArtifactShots", () => {
353430
function stubSequence(handlers: Array<(input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>>): void {
354431
let call = 0;

test/unit/visual-capture.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,64 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f
12891289
expect(result.previewPending).toBe(true);
12901290
});
12911291

1292+
it("skips dispatching a NEW run when one is already queued/in-progress for this exact pr+headSha, but still marks the capture pending (#4112 review fix)", async () => {
1293+
let dispatchCalled = false;
1294+
vi.stubGlobal(
1295+
"fetch",
1296+
stubNoPreviewFound((url) => {
1297+
if (url.includes("/actions/workflows/visual-capture-fallback.yml/runs")) {
1298+
return Response.json({ workflow_runs: [{ status: "in_progress", display_title: "gittensory-visual-fallback pr=20 sha=cafebabecafebabecafebabecafebabecafebabe" }] });
1299+
}
1300+
if (url.includes("/dispatches")) {
1301+
dispatchCalled = true;
1302+
return new Response(null, { status: 204 });
1303+
}
1304+
return null;
1305+
}),
1306+
);
1307+
1308+
const result = await buildCapture(
1309+
createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }),
1310+
"installation-token",
1311+
{ repoFullName: "owner/repo", prNumber: 20, headSha: "cafebabecafebabecafebabecafebabecafebabe", previewFromChecks: true, defaultBranchRef: "main" },
1312+
["apps/gittensory-ui/src/routes/app.index.tsx"],
1313+
undefined,
1314+
{ actionsFallback: true },
1315+
);
1316+
1317+
expect(dispatchCalled).toBe(false);
1318+
expect(result.previewPending).toBe(true);
1319+
});
1320+
1321+
it("dispatches a NEW run when the only in-flight run found is for a DIFFERENT headSha (a later push)", async () => {
1322+
let dispatchCalled = false;
1323+
vi.stubGlobal(
1324+
"fetch",
1325+
stubNoPreviewFound((url) => {
1326+
if (url.includes("/actions/workflows/visual-capture-fallback.yml/runs")) {
1327+
return Response.json({ workflow_runs: [{ status: "in_progress", display_title: "gittensory-visual-fallback pr=20 sha=ffffffffffffffffffffffffffffffffffffffff" }] });
1328+
}
1329+
if (url.includes("/dispatches")) {
1330+
dispatchCalled = true;
1331+
return new Response(null, { status: 204 });
1332+
}
1333+
return null;
1334+
}),
1335+
);
1336+
1337+
const result = await buildCapture(
1338+
createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }),
1339+
"installation-token",
1340+
{ repoFullName: "owner/repo", prNumber: 20, headSha: "cafebabecafebabecafebabecafebabecafebabe", previewFromChecks: true, defaultBranchRef: "main" },
1341+
["apps/gittensory-ui/src/routes/app.index.tsx"],
1342+
undefined,
1343+
{ actionsFallback: true },
1344+
);
1345+
1346+
expect(dispatchCalled).toBe(true);
1347+
expect(result.previewPending).toBe(true);
1348+
});
1349+
12921350
it("leaves the capture non-pending when the dispatch call itself fails", async () => {
12931351
vi.stubGlobal(
12941352
"fetch",

0 commit comments

Comments
 (0)