Skip to content

Commit 8ab9ec6

Browse files
committed
fix(queue): stop the reopen-reclose webhook pass on actuation-lock contention
maybeRecloseDisallowedReopen returned a plain false on lock contention, which the caller's boolean contract read as 'not blocked, proceed to normal re-review' — a contended webhook could still evaluate/mutate the same PR the lock holder owns. Replace the boolean with a tri-state ReopenRecloseOutcome (reclosed / allowed / lock_contended) so the caller skips the re-review on contention too.
1 parent dd3d1b6 commit 8ab9ec6

2 files changed

Lines changed: 35 additions & 20 deletions

File tree

src/queue/processors.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3782,19 +3782,21 @@ async function processGitHubWebhook(
37823782
// Reopen-prevention (#one-shot-reopen): a CONTRIBUTOR may not reopen a PR that gittensory or a maintainer
37833783
// closed — closes are one-shot (resubmit, don't reopen). If a non-maintainer reopened a PR whose last close
37843784
// was by the bot / repo owner / admin, re-close it and skip the re-review. Self-closes (the contributor
3785-
// closed their own PR) stay reopenable; the bot's own nightly-re-review reopens are exempt.
3786-
if (
3787-
payload.action === "reopened" &&
3788-
installationId &&
3789-
(await maybeRecloseDisallowedReopen(
3790-
env,
3791-
deliveryId,
3792-
installationId,
3793-
repoFullName,
3794-
pr,
3795-
payload,
3796-
).catch(() => false))
3797-
) {
3785+
// closed their own PR) stay reopenable; the bot's own nightly-re-review reopens are exempt. A contended
3786+
// actuation lock ALSO skips the re-review (#2135, review round 3) — the winning delivery already owns
3787+
// this PR, so this pass must not evaluate/mutate it concurrently under a false "not blocked" reading.
3788+
const reopenOutcome: ReopenRecloseOutcome =
3789+
payload.action === "reopened" && installationId
3790+
? await maybeRecloseDisallowedReopen(
3791+
env,
3792+
deliveryId,
3793+
installationId,
3794+
repoFullName,
3795+
pr,
3796+
payload,
3797+
).catch(() => "allowed" as const)
3798+
: "allowed";
3799+
if (reopenOutcome === "reclosed" || reopenOutcome === "lock_contended") {
37983800
// Stamp the delivery processed like every other owning path — the early return otherwise leaves the
37993801
// webhook_events row stuck at "queued"/its body hash, mis-reporting the delivery as un-acked (#review-audit).
38003802
await recordWebhookEvent(env, {
@@ -7963,30 +7965,37 @@ async function closeDraftDodgeAttemptIfBlocked(
79637965
}
79647966
}
79657967

7968+
/** Outcome of {@link maybeRecloseDisallowedReopen}: "reclosed" and "lock_contended" both mean the caller must
7969+
* skip the normal re-review pass — a plain boolean can't distinguish "evaluated, not blocked" from "never
7970+
* evaluated, another delivery owns this PR", and conflating them let a contended pass fall through to a
7971+
* concurrent re-review (#2135, review round 3). */
7972+
type ReopenRecloseOutcome = "reclosed" | "allowed" | "lock_contended";
7973+
79667974
/** Reopen-prevention (#one-shot-reopen): re-close a contributor's reopen of a PR that gittensory / a maintainer
7967-
* closed (closes are one-shot). Returns true when it re-closed (caller skips the re-review). Exempt: the bot's
7968-
* own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES.
7975+
* closed (closes are one-shot). Returns "reclosed" when it re-closed (caller skips the re-review). Exempt: the
7976+
* bot's own re-review reopens, owner/admin reopens, and a contributor reopening a PR they CLOSED THEMSELVES.
79697977
* Per-PR actuation-locked (#2135): a concurrent delivery for the same PR (e.g. a check_suite completion racing
7970-
* this reopen) must not evaluate + potentially mutate this PR at the same time. Lock-contended fails open
7971-
* (returns false, falls through to normal re-review) — the delivery holding the lock is handling this PR. */
7978+
* this reopen) must not evaluate + potentially mutate this PR at the same time. Lock-contended returns
7979+
* "lock_contended" — the caller skips its own re-review too, since the delivery holding the lock owns this PR. */
79727980
async function maybeRecloseDisallowedReopen(
79737981
env: Env,
79747982
deliveryId: string,
79757983
installationId: number,
79767984
repoFullName: string,
79777985
pr: PullRequestRecord,
79787986
payload: GitHubWebhookPayload,
7979-
): Promise<boolean> {
7980-
if (!(await claimPrActuationLock(env, repoFullName, pr.number))) return false;
7987+
): Promise<ReopenRecloseOutcome> {
7988+
if (!(await claimPrActuationLock(env, repoFullName, pr.number))) return "lock_contended";
79817989
try {
7982-
return await recloseDisallowedReopenIfNeeded(
7990+
const reclosed = await recloseDisallowedReopenIfNeeded(
79837991
env,
79847992
deliveryId,
79857993
installationId,
79867994
repoFullName,
79877995
pr,
79887996
payload,
79897997
);
7998+
return reclosed ? "reclosed" : "allowed";
79907999
} finally {
79918000
await releasePrActuationLock(env, repoFullName, pr.number);
79928001
}

test/unit/queue.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { clearInstallationTokenCacheForTest } from "../../src/github/app";
44
import { PR_PANEL_COMMENT_MARKER } from "../../src/github/comments";
55
import * as backfillModule from "../../src/github/backfill";
66
import * as repositoriesModule from "../../src/db/repositories";
7+
import * as repositorySettingsModule from "../../src/settings/repository-settings";
78
import * as sentryModule from "../../src/selfhost/sentry";
89
import {
910
listCollisionEdges,
@@ -11785,6 +11786,10 @@ describe("one-shot reopen prevention", () => {
1178511786
// Simulates a DIFFERENT concurrent delivery for the same PR already in flight (e.g. the draft-dodge sibling
1178611787
// racing this reopen) — the lock key it would hold is pre-claimed here.
1178711788
await env.SELFHOST_TRANSIENT_CACHE?.set("pr-actuation-lock:jsonbored/gittensory#42", "1", 60);
11789+
// REGRESSION (#2135, review round 3): a contended lock previously returned `false`, which the caller's old
11790+
// boolean contract read as "not blocked, proceed to normal re-review" -- this spy proves that no longer
11791+
// happens; the webhook path must stop BEFORE resolveRepositorySettings, the first call the re-review makes.
11792+
const resolveSettingsSpy = vi.spyOn(repositorySettingsModule, "resolveRepositorySettings");
1178811793

1178911794
await processJob(env, {
1179011795
type: "github-webhook",
@@ -11796,6 +11801,7 @@ describe("one-shot reopen prevention", () => {
1179611801
expect(calls.some((call) => call.method === "PATCH" && call.url.endsWith("/pulls/42"))).toBe(false);
1179711802
const audit = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.reopen_reclosed").first<{ n: number }>();
1179811803
expect(audit?.n).toBe(0); // no decision recorded either way — the in-flight delivery owns this pass
11804+
expect(resolveSettingsSpy).not.toHaveBeenCalled(); // the normal re-review pass never started
1179911805
});
1180011806

1180111807
it("does NOT re-close a disallowed reopen on an OBSERVE-only / un-opted-in repo (autonomy floor, #review-audit)", async () => {

0 commit comments

Comments
 (0)