Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,31 @@ jobs:
# this step for. It shares `H56_STAMP_TOLERANCE_MIN` and the position
# reader with `check-half-states.mjs`, so the write side and the read-side
# patrol cannot come to disagree about what a stamp is.
# Write-pace throttle self-test (#19572) — the shared throttle the four
# steps below and `sweep-stale-finding` all issue their writes through,
# under the maintainer's 「截流应该要加」. Two seat accounts were suspended
# during bulk issue operations; this file is what makes pacing a property
# of the write path rather than of who remembered it.
#
# It has no production run at all: the live path is every OTHER tool's
# write, so the self-test is not "the half CI can hold", it is the only
# instrument there is. What it exercises is exactly what a clean tree
# cannot — that the 41st write in an hour is REFUSED with a distinct exit
# code and a prescription rather than slept on (a silent hour-long wait
# inside a write path is a hang with a good excuse); that a 429 writes a
# stop marker every later write is refused against; that an unexpired
# marker survives the trailing-hour prune by its own expiry; and that the
# token itself never reaches the cache file, only a SHA-256 prefix of it.
# Weakening any of them leaves every ordinary invocation just as green —
# the throttle would simply stop throttling — which is the shape
# `check:self-test-wired` requires this step for.
#
# Its last battery is the wiring: the five write transports each call both
# halves, and the roster IS the set of files under scripts/pm that issue a
# write verb, so a SIXTH write path cannot land unpaced.
- name: Write-pace throttle self-test
run: pnpm check:pm-write-pace

- name: Stamped-post helper self-test
run: pnpm check:pm-post-stamped

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"check:pm-prior-rulings": "node scripts/pm/check-prior-rulings.mjs --self-test",
"check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test",
"check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test",
"check:pm-write-pace": "node scripts/pm/write-pace.mjs --self-test",
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
"check:pm-label-write": "node scripts/pm/label-write.mjs --self-test",
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",
Expand Down
16 changes: 16 additions & 0 deletions scripts/pm/close-cards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import {
resolveSweepRepo,
} from './check-half-states.mjs';
import { classifyHttp, parseOptions as parseLabelWriteOptions, runLabelWrite } from './label-write.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
import {
EXIT_NOT_STORED as POST_STAMPED_EXIT_NOT_STORED,
STAMP_TOKEN,
Expand Down Expand Up @@ -437,6 +438,11 @@ export function skipReason(card, { expectState, openPrs = [] } = {}) {
// ---------------------------------------------------------------------------

async function rest(path, { method = 'GET', body = null } = {}) {
// ⏱ The throttle (#19572), on the write verbs only — the `PATCH` that
// closes a card. The comment this tool posts goes through `post-stamped.mjs`
// as a child process, which is paced by its own transport.
const paced = isWriteMethod(method);
if (paced) await paceWrite({ token: TOKEN, kind: `close-cards ${method}` });
let res;
try {
res = await fetch(`${API}${path}`, {
Expand All @@ -460,6 +466,16 @@ async function rest(path, { method = 'GET', body = null } = {}) {
json = null;
}
}
// ⏱ …and the other half, with the verdict `classifyHttp` already reached.
if (paced) {
noteResponse({
token: TOKEN,
status: res.status,
headers: res.headers,
body: json,
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemaining === null ? null : Number(rateRemaining) }),
});
}
return {
status: res.status,
rateRemaining: rateRemaining === null ? null : Number(rateRemaining),
Expand Down
18 changes: 18 additions & 0 deletions scripts/pm/label-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

import { isEntrypoint } from '../invoked-as.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';
import {
EXIT_PREREQUISITE_NOT_MET,
PM_EXCLUSIVE_STATE_LABELS,
Expand Down Expand Up @@ -327,6 +328,11 @@ const render = (values) => (values.length ? values.map((v) => `\`${v}\``).join('
// ---------------------------------------------------------------------------

async function rest(path, { method = 'GET', body = null } = {}) {
// ⏱ The throttle (#19572), on the write verbs only. `paceWrite` reserves
// this write's slot and sleeps the minimum gap; a spent budget or a live stop
// marker refuses here and the request is never made.
const paced = isWriteMethod(method);
if (paced) await paceWrite({ token: TOKEN, kind: `label-write ${method}` });
let res;
try {
res = await fetch(`${API}${path}`, {
Expand All @@ -350,6 +356,18 @@ async function rest(path, { method = 'GET', body = null } = {}) {
json = null;
}
}
// ⏱ …and the other half: a back-off signal in this answer writes the stop
// marker every later write by this token is refused against. `classifyHttp`'s
// own verdict is handed over rather than re-derived there.
if (paced) {
noteResponse({
token: TOKEN,
status: res.status,
headers: res.headers,
body: json,
verdict: classifyHttp({ status: res.status, rateRemaining: rateRemaining === null ? null : Number(rateRemaining) }),
});
}
return {
status: res.status,
rateRemaining: rateRemaining === null ? null : Number(rateRemaining),
Expand Down
12 changes: 12 additions & 0 deletions scripts/pm/post-stamped.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,7 @@ import {
threadReadField,
} from './check-half-states.mjs';
import { readClause2Line } from './check-clause2-carriers.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';

const SELF_PATH = fileURLToPath(import.meta.url);
const API = 'https://api.github.com';
Expand Down Expand Up @@ -2539,6 +2540,11 @@ export function parseOptions(argv) {
// ---------------------------------------------------------------------------

async function rest(path, { method = 'GET', body = null } = {}) {
// ⏱ The throttle (#19572), on the write verbs only — the comment `POST` and
// the body `PATCH`. A spent budget or a live stop marker refuses here, before
// the request is made and therefore before anything can be half-written.
const paced = isWriteMethod(method);
if (paced) await paceWrite({ token: TOKEN, kind: `post-stamped ${method}` });
const res = await fetch(`${API}${path}`, {
method,
headers: {
Expand All @@ -2554,11 +2560,17 @@ async function rest(path, { method = 'GET', body = null } = {}) {
// the refusal reports carry it either way: a 3 that prints only a status
// code hides the one line that says what went wrong.
const said = platformRefusalText(await res.text().catch(() => ''));
// ⏱ …and the sentence is what names a SECONDARY rate limit, so the marker
// is written from the failing answer BEFORE this throws past every caller.
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
err.status = res.status;
err.refusalText = said;
throw err;
}
// ⏱ A 2xx carries a signal too: `x-ratelimit-remaining: 0` on the answer that
// spent the last unit is the one reading that precedes the first refusal.
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
if (res.status === 204) return null;
return res.json();
}
Expand Down
11 changes: 11 additions & 0 deletions scripts/pm/sweep-closed-cards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ import {
proxyRearmPlan,
resolveSweepRepo,
} from './check-half-states.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';

const SELF_PATH = fileURLToPath(import.meta.url);
const API = 'https://api.github.com';
Expand Down Expand Up @@ -495,6 +496,10 @@ export function summariseRun(rows) {
// ---------------------------------------------------------------------------

async function rest(path, { method = 'GET', body = null } = {}) {
// ⏱ The throttle (#19572), on the write verbs only — one `DELETE` per residue
// label and one comment `POST`. The listing walk is a read and is never paced.
const paced = isWriteMethod(method);
if (paced) await paceWrite({ token: TOKEN, kind: `sweep-closed-cards ${method}` });
const res = await fetch(`${API}${path}`, {
method,
headers: {
Expand All @@ -505,10 +510,16 @@ async function rest(path, { method = 'GET', body = null } = {}) {
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!res.ok) {
// ⏱ The body is read only to be CLASSIFIED: a secondary-rate-limit 403
// names itself in prose and nowhere else. The error carries the status, as
// it always has.
const said = paced ? await res.text().catch(() => '') : '';
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
err.status = res.status;
throw err;
}
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
if (res.status === 204) return null;
return res.json();
}
Expand Down
10 changes: 10 additions & 0 deletions scripts/pm/sweep-stale-finding.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import {
resolveSweepRepo,
staleFindingScreen,
} from './check-half-states.mjs';
import { isWriteMethod, noteResponse, paceWrite } from './write-pace.mjs';

// dispatch-gates: no-path-population -- this tool reads no file in the tree at all; its whole input is the GitHub API (one label-scoped issue listing per board, plus the per-card label read-back each write verifies itself against), so no card's file surface can predict it and the honest derivation is a repo-wide undetermined one (#16904)

Expand Down Expand Up @@ -353,6 +354,10 @@ export function namesOf(payload) {
// ---------------------------------------------------------------------------

async function restLive(path, { method = 'GET', body = null } = {}) {
// ⏱ The throttle (#19572), on the write verbs only — the one `DELETE` that
// strips the stale grade. Steps 1 and 4 are reads and are never paced.
const paced = isWriteMethod(method);
if (paced) await paceWrite({ token: TOKEN, kind: `sweep-stale-finding ${method}` });
const res = await fetch(`${API}${path}`, {
method,
headers: {
Expand All @@ -363,12 +368,17 @@ async function restLive(path, { method = 'GET', body = null } = {}) {
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!res.ok) {
// ⏱ The body is read only to be CLASSIFIED — a secondary-rate-limit 403
// names itself in prose. The error keeps carrying exactly what it did.
const said = paced ? await res.text().catch(() => '') : '';
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers, body: said });
const err = new Error(`${method} ${path} -> HTTP ${res.status}`);
err.status = res.status;
err.retryAfter = res.headers?.get?.('retry-after') ?? null;
err.rateRemaining = res.headers?.get?.('x-ratelimit-remaining') ?? null;
throw err;
}
if (paced) noteResponse({ token: TOKEN, status: res.status, headers: res.headers });
if (res.status === 204) return null;
return res.json();
}
Expand Down
Loading
Loading