From f52545c4ba1b92baa46b95377dcba0d920a098d2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 18:23:55 +0100 Subject: [PATCH] Stop asking the forge again for a mesh/check it refuses for good, and end the ask when its key is deleted (issue 774) gitea asked an owed status again every 30 s forever, even on 403 and 404 (jschoubben/hal and jschoubben/novadock, 119 times each in the sweep), and a key the checker deleted did not end a loop already running. Quick fix: return from askAgain on 403/404. Thorough fix: the same, said once with its cause; a delete of the key marks its revision seen, so any running ask again ends before its next write; a newer revision still asks anew. The checker no longer owes statuses outside the mesh (mesh-delivery), which removes the trigger. Not done: raising the module's health, since gitea's TypeScript SDK has no seat publish for a condition (mesh-sdk). --- modules/gitea/check.ts | 18 ++++++++++++++++ modules/gitea/test/check-owed.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/modules/gitea/check.ts b/modules/gitea/check.ts index 63800bf5..3af49049 100644 --- a/modules/gitea/check.ts +++ b/modules/gitea/check.ts @@ -83,6 +83,12 @@ export function checkStatusOf(change: OwedChange): { owner: string; repo: string return { owner, repo, sha, revision, status }; } +/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository + * or commit (404), as the client words it ("Gitea API : ..."). */ +export function refusedForGood(err: unknown): boolean { + return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err)); +} + /** How many mesh/check writes this module has at the forge at once. */ export const WRITES_AT_ONCE = 4; @@ -115,6 +121,12 @@ export class CheckStatuses { try { asked = checkStatusOf(change); } catch (err) { + // A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain). + if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) { + const seen = this.seen.get(change.key); + if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision); + return "dropped"; + } this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`); return "dropped"; } @@ -172,6 +184,12 @@ export class CheckStatuses { private async askAgain(change: OwedChange, first: unknown): Promise { let err = first; for (let attempt = 1; ; attempt++) { + if (refusedForGood(err)) { + // A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue + // 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew. + this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`); + return; + } const wait = bindWait(attempt); this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`); await this.sleep(wait); diff --git a/modules/gitea/test/check-owed.test.ts b/modules/gitea/test/check-owed.test.ts index 5ec9e22d..afd43584 100644 --- a/modules/gitea/test/check-owed.test.ts +++ b/modules/gitea/test/check-owed.test.ts @@ -175,3 +175,33 @@ test("the writes a watch's start hands over go to the forge a few at a time, nev assert.equal(done, 50); assert.equal(most, WRITES_AT_ONCE); }); + +// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal +// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and +// never asked again, a 5xx is still asked again, and a delete ends a loop that is running. +test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => { + const { CheckStatuses, refusedForGood } = await import("../check.ts"); + for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true], + ["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false], + ["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) { + assert.equal(refusedForGood(new Error(err)), forGood, err); + } + const said: string[] = []; + const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } }; + const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); }); + await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3)); + await checks.settled(); + assert.equal(said.length, 1); + assert.match(said[0], /refused for good.*403.*not asked again/); + // A 502 asks again; the key's delete meanwhile ends the loop before its next ask. + const g = forge(); + let release: () => void = () => {}; + const gate = new Promise((r) => { release = r; }); + const later = new CheckStatuses(g, () => {}, () => gate); + g.fail(5); + await later.take(put(key(), owed(), 8)); + await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any); + release(); + await later.settled(); + assert.deepEqual(g.sent, [], "a deleted key is not written"); +});