Public: anyone can read this; the crew can change it.
An admin's "All fine" in tests reads only its own forge: the asks, board and capacity suites stop failing on signals their shard neighbours raise #1635
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1614-an-admin-s-all-fine-in-tests-reads-only → main 2 commits fork 3 threads · 3 unresolved
src/actions/health.ts +6 −1
| @@ -25,7 +25,12 @@ export function readHealth( | ||
| 25 | 25 | const envs = runtime |
| 26 | 26 | ? listApps(app.db).flatMap((record) => runtime.status(record)) |
| 27 | 27 | : []; |
| 28 | const v = vitals({ db: app.db, dataDir: app.config.dataDir, envs }); | |
| 28 | const v = vitals({ | |
| 29 | db: app.db, | |
| 30 | dataDir: app.config.dataDir, | |
| 31 | envs, | |
| 32 | signals: app.signals, | |
| 33 | }); | |
| 29 | 34 | return { ...v, ...forge, ok: forge.ok && v.ok }; |
| 30 | 35 | } |
| 31 | 36 |
src/app.ts +15 −2
| @@ -17,7 +17,14 @@ import { follow } from "./core/following.ts"; | ||
| 17 | 17 | import { enqueueForEvent } from "./core/webhooks.ts"; |
| 18 | 18 | import { openDb } from "./kernel/db.ts"; |
| 19 | 19 | import { id } from "./kernel/id.ts"; |
| 20 | import { createLog, type Log } from "./kernel/log.ts"; | |
| 20 | import { | |
| 21 | createLog, | |
| 22 | forgeErrorLedger, | |
| 23 | releaseErrorLedger, | |
| 24 | type ErrorLedger, | |
| 25 | type Log, | |
| 26 | } from "./kernel/log.ts"; | |
| 27 | import { RefusedLedger } from "./kernel/internal_refused.ts"; | |
| 21 | 28 | import { closeReadersUnder } from "./git/read.ts"; |
| 22 | 29 | |
| 23 | 30 | export interface ForgeConfig { |
| @@ -138,6 +145,8 @@ export interface App { | ||
| 138 | 145 | readonly hookAddress: { current: string }; |
| 139 | 146 | /** Per-boot secret every internal request must carry (`FORGE_HOOK_SECRET` in a hook's env). */ |
| 140 | 147 | readonly hookSecret: string; |
| 148 | /** What `vitals` turns into needs: this forge's own, not the process's. */ | |
| 149 | readonly signals: { errors: ErrorLedger; refused: RefusedLedger }; | |
| 141 | 150 | close(): void; |
| 142 | 151 | } |
| 143 | 152 | |
| @@ -166,11 +175,14 @@ export function createApp(config: ForgeConfig): App { | ||
| 166 | 175 | // and the forks whose default branch is still a copy of this one's. |
| 167 | 176 | follow({ db, reposRoot, bus }); |
| 168 | 177 | |
| 178 | const owner = id(); | |
| 179 | const errors = forgeErrorLedger(owner); | |
| 169 | 180 | return { |
| 170 | 181 | config: { ...config, dataDir }, |
| 171 | 182 | db, |
| 172 | 183 | bus, |
| 173 | log: createLog("forge"), | |
| 184 | log: createLog("forge", owner), | |
| 185 | signals: { errors, refused: new RefusedLedger() }, | |
| 174 | 186 | reposRoot, |
| 175 | 187 | // This process's own: two forges share a data directory for the length |
| 176 | 188 | // of a release, and each spawns git with its own socket in the env. |
| @@ -182,6 +194,7 @@ export function createApp(config: ForgeConfig): App { | ||
| 182 | 194 | // and processes outlive a closed database unless somebody says |
| 183 | 195 | // otherwise. One data dir is one forge, so closing the forge is where |
| 184 | 196 | // that is said. |
| 197 | releaseErrorLedger(errors); | |
| 185 | 198 | closeReadersUnder(reposRoot); |
| 186 | 199 | db.close(); |
| 187 | 200 | }, |
src/http/server.ts +1 −1
| @@ -1761,7 +1761,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1761 | 1761 | // port has none, so every internal request proves the boot secret. |
| 1762 | 1762 | if (!hookCallerAllowed(app, request)) { |
| 1763 | 1763 | const path = new URL(request.url).pathname; |
| 1764 | if (noteRefusedInternal(request.method, path)) | |
| 1764 | if (noteRefusedInternal(request.method, path, Date.now(), app.signals.refused)) | |
| 1765 | 1765 | app.log.warn("internal call refused: no boot secret", { |
| 1766 | 1766 | method: request.method, |
| 1767 | 1767 | path: path.slice(0, 120), |
src/kernel/internal_refused.ts +33 −22
| @@ -17,33 +17,44 @@ export interface RefusedCall { | ||
| 17 | 17 | readonly path: string; |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | let seen: RefusedCall[] = []; | |
| 20 | /** One forge's count (`App.signals.refused`). */ | |
| 21 | export class RefusedLedger { | |
| 22 | private seen: RefusedCall[] = []; | |
| 23 | ||
| 24 | /** Note one refused internal call; the probe is expected and not counted. */ | |
| 25 | note(method: string, path: string, now: number = Date.now()): boolean { | |
| 26 | if (path === PROBE) return false; | |
| 27 | this.seen.push({ | |
| 28 | at: now, | |
| 29 | method: method.slice(0, 16), | |
| 30 | path: path.replace(/[^\x21-\x7e]/g, "?").slice(0, 120), | |
| 31 | }); | |
| 32 | if (this.seen.length > KEEP) this.seen = this.seen.slice(-KEEP); | |
| 33 | return true; | |
| 34 | } | |
| 35 | ||
| 36 | recent(now: number = Date.now()): { | |
| 37 | lastHour: number; | |
| 38 | latest: RefusedCall | undefined; | |
| 39 | } { | |
| 40 | const recent = this.seen.filter((c) => now - c.at < WINDOW_MS); | |
| 41 | return { lastHour: recent.length, latest: recent.at(-1) }; | |
| 42 | } | |
| 43 | ||
| 44 | clear(): void { | |
| 45 | this.seen = []; | |
| 46 | } | |
| 47 | } | |
| 21 | 48 | |
| 22 | /** Note one refused internal call; the probe is expected and not counted. */ | |
| 23 | 49 | export function noteRefusedInternal( |
Non-blocking: |
||
| 24 | 50 | method: string, |
| 25 | 51 | path: string, |
| 26 | now: number = Date.now(), | |
| 52 | now: number, | |
| 53 | ledger: RefusedLedger, | |
| 27 | 54 | ): boolean { |
| 28 | if (path === PROBE) return false; | |
| 29 | seen.push({ | |
| 30 | at: now, | |
| 31 | method: method.slice(0, 16), | |
| 32 | path: path.replace(/[^\x21-\x7e]/g, "?").slice(0, 120), | |
| 33 | }); | |
| 34 | if (seen.length > KEEP) seen = seen.slice(-KEEP); | |
| 35 | return true; | |
| 55 | return ledger.note(method, path, now); | |
| 36 | 56 | } |
| 37 | 57 | |
| 38 | export function refusedInternal(now: number = Date.now()): { | |
| 39 | lastHour: number; | |
| 40 | latest: RefusedCall | undefined; | |
| 41 | } { | |
| 42 | const recent = seen.filter((c) => now - c.at < WINDOW_MS); | |
| 43 | return { lastHour: recent.length, latest: recent.at(-1) }; | |
| 44 | } | |
| 45 | ||
| 46 | /** For tests: forget everything. */ | |
| 47 | export function resetRefusedInternal(): void { | |
| 48 | seen = []; | |
| 58 | export function refusedInternal(now: number, ledger: RefusedLedger) { | |
| 59 | return ledger.recent(now); | |
| 49 | 60 | } |
src/kernel/log.ts +91 −32
| @@ -113,52 +113,111 @@ export interface ErrorLine { | ||
| 113 | 113 | readonly message: string; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | const ring: ErrorLine[] = []; | |
| 117 | /** scope → minute bucket → count: an hour of counts in at most 61 buckets per scope. */ | |
| 118 | const buckets = new Map<string, Map<number, number>>(); | |
| 116 | /** | |
| 117 | * Where error lines are kept and counted. The default ledger takes every | |
| 118 | * line the process logs. A forge's own ledger (`createApp`) takes the lines | |
| 119 | * from loggers that belong to no forge, and those from its own `app.log`, | |
Unowned loggers reach every forge. That's 46 of the ~51 error sites in |
||
| 120 | * and starts empty: a second forge in one process does not inherit, or | |
| 121 | * answer for, the first one's errors. With one forge a process, the two | |
| 122 | * ledgers read the same. | |
| 123 | */ | |
| 124 | export class ErrorLedger { | |
| 125 | private readonly ring: ErrorLine[] = []; | |
| 126 | /** scope → minute bucket → count: an hour of counts in at most 61 buckets per scope. */ | |
| 127 | private readonly buckets = new Map<string, Map<number, number>>(); | |
| 119 | 128 | |
| 120 | function prune(now: number): void { | |
| 121 | const floor = Math.floor((now - HOUR_MS) / MINUTE_MS); | |
| 122 | for (const [scope, minutes] of buckets) { | |
| 123 | for (const minute of minutes.keys()) if (minute <= floor) minutes.delete(minute); | |
| 124 | if (minutes.size === 0) buckets.delete(scope); | |
| 129 | /** `owner` set: only lines from unowned loggers and from that owner. */ | |
| 130 | constructor(readonly owner?: string) {} | |
| 131 | ||
| 132 | accepts(owner: string | undefined): boolean { | |
| 133 | return this.owner === undefined || owner === undefined || owner === this.owner; | |
| 134 | } | |
| 135 | ||
| 136 | private prune(now: number): void { | |
| 137 | const floor = Math.floor((now - HOUR_MS) / MINUTE_MS); | |
| 138 | for (const [scope, minutes] of this.buckets) { | |
| 139 | for (const minute of minutes.keys()) if (minute <= floor) minutes.delete(minute); | |
| 140 | if (minutes.size === 0) this.buckets.delete(scope); | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | record(scope: string, message: string, now: number): void { | |
| 145 | this.ring.push({ at: new Date(now).toISOString(), scope, message }); | |
| 146 | if (this.ring.length > ERROR_RING) | |
| 147 | this.ring.splice(0, this.ring.length - ERROR_RING); | |
| 148 | this.prune(now); | |
| 149 | const minutes = this.buckets.get(scope) ?? new Map<number, number>(); | |
| 150 | const minute = Math.floor(now / MINUTE_MS); | |
| 151 | minutes.set(minute, (minutes.get(minute) ?? 0) + 1); | |
| 152 | this.buckets.set(scope, minutes); | |
| 153 | } | |
| 154 | ||
| 155 | /** Errors in the last hour, in total and per scope, and the newest lines first. */ | |
| 156 | recent( | |
| 157 | limit = 5, | |
| 158 | now = Date.now(), | |
| 159 | ): { lastHour: number; byScope: Record<string, number>; latest: ErrorLine[] } { | |
| 160 | this.prune(now); | |
| 161 | const byScope: Record<string, number> = {}; | |
| 162 | let lastHour = 0; | |
| 163 | for (const [scope, minutes] of this.buckets) { | |
| 164 | let n = 0; | |
| 165 | for (const c of minutes.values()) n += c; | |
| 166 | byScope[scope] = n; | |
| 167 | lastHour += n; | |
| 168 | } | |
| 169 | return { lastHour, byScope, latest: this.ring.slice(-limit).reverse() }; | |
| 170 | } | |
| 171 | ||
| 172 | clear(): void { | |
| 173 | this.ring.length = 0; | |
| 174 | this.buckets.clear(); | |
| 125 | 175 | } |
| 126 | 176 | } |
| 127 | 177 | |
| 128 | function record(scope: string, message: string, now: number): void { | |
| 129 | ring.push({ at: new Date(now).toISOString(), scope, message }); | |
| 130 | if (ring.length > ERROR_RING) ring.splice(0, ring.length - ERROR_RING); | |
| 131 | prune(now); | |
| 132 | const minutes = buckets.get(scope) ?? new Map<number, number>(); | |
| 133 | const minute = Math.floor(now / MINUTE_MS); | |
| 134 | minutes.set(minute, (minutes.get(minute) ?? 0) + 1); | |
| 135 | buckets.set(scope, minutes); | |
| 178 | const processLedger = new ErrorLedger(); | |
| 179 | /** Every live forge's ledger, besides the process's own. */ | |
| 180 | const forgeLedgers = new Set<ErrorLedger>(); | |
| 181 | ||
| 182 | /** A ledger for one forge; `release` it when the forge closes. */ | |
| 183 | export function forgeErrorLedger(owner: string): ErrorLedger { | |
| 184 | const ledger = new ErrorLedger(owner); | |
| 185 | forgeLedgers.add(ledger); | |
| 186 | return ledger; | |
| 136 | 187 | } |
| 137 | 188 | |
| 138 | /** Errors in the last hour, in total and per scope, and the newest lines first. */ | |
| 189 | export function releaseErrorLedger(ledger: ErrorLedger): void { | |
| 190 | forgeLedgers.delete(ledger); | |
| 191 | } | |
| 192 | ||
| 193 | function record( | |
| 194 | scope: string, | |
| 195 | message: string, | |
| 196 | now: number, | |
| 197 | owner: string | undefined, | |
| 198 | ): void { | |
| 199 | processLedger.record(scope, message, now); | |
| 200 | for (const ledger of forgeLedgers) | |
| 201 | if (ledger.accepts(owner)) ledger.record(scope, message, now); | |
| 202 | } | |
| 203 | ||
| 204 | /** Errors in the last hour (the whole process's, or `ledger`'s) and the newest lines first. */ | |
| 139 | 205 | export function recentErrors( |
| 140 | 206 | limit = 5, |
| 141 | 207 | now = Date.now(), |
| 208 | ledger: ErrorLedger = processLedger, | |
| 142 | 209 | ): { lastHour: number; byScope: Record<string, number>; latest: ErrorLine[] } { |
| 143 | prune(now); | |
| 144 | const byScope: Record<string, number> = {}; | |
| 145 | let lastHour = 0; | |
| 146 | for (const [scope, minutes] of buckets) { | |
| 147 | let n = 0; | |
| 148 | for (const c of minutes.values()) n += c; | |
| 149 | byScope[scope] = n; | |
| 150 | lastHour += n; | |
| 151 | } | |
| 152 | return { lastHour, byScope, latest: ring.slice(-limit).reverse() }; | |
| 210 | return ledger.recent(limit, now); | |
| 153 | 211 | } |
| 154 | 212 | |
| 155 | /** Forget everything kept. For tests. */ | |
| 213 | /** Forget everything kept, in the process's ledger and every forge's. For tests. */ | |
| 156 | 214 | export function clearErrors(): void { |
| 157 | ring.length = 0; | |
| 158 | buckets.clear(); | |
| 215 | processLedger.clear(); | |
| 216 | for (const ledger of forgeLedgers) ledger.clear(); | |
| 159 | 217 | } |
| 160 | 218 | |
| 161 | export function createLog(scope: string): Log { | |
| 219 | /** `owner`: the forge whose own logger this is; module loggers have none. */ | |
| 220 | export function createLog(scope: string, owner?: string): Log { | |
| 162 | 221 | const emit = (level: LogLevel, message: string, fields?: LogFields) => { |
| 163 | 222 | if (order[level] < order[threshold]) return; |
| 164 | 223 | let line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} [${scope}] ${message}`; |
| @@ -172,7 +231,7 @@ export function createLog(scope: string): Log { | ||
| 172 | 231 | line = redactHeld(line); |
| 173 | 232 | if (level === "error") { |
| 174 | 233 | const body = line.slice(line.indexOf(`[${scope}] `) + scope.length + 3); |
| 175 | record(scope, body, Date.now()); | |
| 234 | record(scope, body, Date.now(), owner); | |
| 176 | 235 | } |
| 177 | 236 | console[level === "debug" ? "log" : level](line); |
| 178 | 237 | }; |
src/kernel/vitals.ts +6 −4
| @@ -11,8 +11,8 @@ | ||
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import { statfsSync } from "node:fs"; |
| 13 | 13 | import { freemem, totalmem } from "node:os"; |
| 14 | import { refusedInternal } from "./internal_refused.ts"; | |
| 15 | import { recentErrors, type ErrorLine } from "./log.ts"; | |
| 14 | import { refusedInternal, type RefusedLedger } from "./internal_refused.ts"; | |
| 15 | import { recentErrors, type ErrorLedger, type ErrorLine } from "./log.ts"; | |
| 16 | 16 | import { |
| 17 | 17 | budgetFailures, |
| 18 | 18 | leaseLapsed, |
| @@ -75,6 +75,8 @@ export interface VitalsInput { | ||
| 75 | 75 | /** Every environment the runtime holds, when one is up. */ |
| 76 | 76 | readonly envs?: readonly EnvCount[]; |
| 77 | 77 | readonly now?: number; |
| 78 | /** One forge's own signals (`App.signals`). */ | |
| 79 | readonly signals: { errors: ErrorLedger; refused: RefusedLedger }; | |
outdated
Mis-indented closing brace. |
||
| 78 | 80 | } |
| 79 | 81 | |
| 80 | 82 | /** The lane as one line (no label; callers add their own), or null when nothing is queued. */ |
| @@ -208,13 +210,13 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 208 | 210 | else if (e.state === "running") apps.serving++; |
| 209 | 211 | } |
| 210 | 212 | |
| 211 | const seen = recentErrors(5, now); | |
| 213 | const seen = recentErrors(5, now, input.signals.errors); | |
| 212 | 214 | if (seen.lastHour >= ERROR_BURST) |
| 213 | 215 | needs.push( |
| 214 | 216 | `${seen.lastHour} errors in the last hour (${ERROR_BURST} or more is a burst).`, |
| 215 | 217 | ); |
| 216 | 218 | |
| 217 | const refused = refusedInternal(now); | |
| 219 | const refused = refusedInternal(now, input.signals.refused); | |
| 218 | 220 | if (refused.lastHour > 0) |
| 219 | 221 | needs.push( |
| 220 | 222 | `${refused.lastHour} internal call${refused.lastHour === 1 ? "" : "s"} without the boot secret refused in the last hour${refused.latest ? ` (latest ${refused.latest.method} ${refused.latest.path})` : ""}.`, |
src/web/pages/board.ts +1
| @@ -332,6 +332,7 @@ function forgeStrip(ctx: Ctx): Html | null { | ||
| 332 | 332 | const v = vitals({ |
| 333 | 333 | db: app.db, |
| 334 | 334 | dataDir: app.config.dataDir, |
| 335 | signals: app.signals, | |
| 335 | 336 | envs: runtime |
| 336 | 337 | ? listApps(app.db).flatMap((record) => runtime.status(record)) |
| 337 | 338 | : [], |
src/web/studio/asks.ts +1
| @@ -954,6 +954,7 @@ export function forgeNeedsFor(ctx: Ctx, user: User | undefined): string[] { | ||
| 954 | 954 | return vitals({ |
| 955 | 955 | db: ctx.app.db, |
| 956 | 956 | dataDir: ctx.app.config.dataDir, |
| 957 | signals: ctx.app.signals, | |
| 957 | 958 | envs, |
| 958 | 959 | }).needs; |
| 959 | 960 | } |
test/asks.test.ts +7 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * in the header. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| @@ -60,6 +60,12 @@ describe("the asks page", () => { | ||
| 60 | 60 | const sentence = (page: string): string => |
| 61 | 61 | /<h2 id="asks-sentence"[^>]*>([^<]*)<\/h2>/.exec(page)?.[1] ?? ""; |
| 62 | 62 | |
| 63 | // Module loggers reach every forge in the process: each test starts from a quiet ledger. | |
| 64 | beforeEach(() => { | |
| 65 | app.signals.errors.clear(); | |
| 66 | app.signals.refused.clear(); | |
| 67 | }); | |
| 68 | ||
| 63 | 69 | beforeAll(async () => { |
| 64 | 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-")); |
| 65 | 71 | app = createApp({ dataDir, port: 0 }); |
test/board.test.ts +13 −1
| @@ -5,7 +5,7 @@ import { stylesheetAsset } from "../src/web/asset.ts"; | ||
| 5 | 5 | * `?wall` is its wall mode, where every old `/big` address lands; |
| 6 | 6 | * anonymous is sent to sign in. |
| 7 | 7 | */ |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | 9 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 10 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 11 | import { tmpdir } from "node:os"; |
| @@ -69,6 +69,12 @@ describe("the board", () => { | ||
| 69 | 69 | return made.value; |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | // Module loggers reach every forge in the process: each test starts from a quiet ledger. | |
| 73 | beforeEach(() => { | |
| 74 | app.signals.errors.clear(); | |
| 75 | app.signals.refused.clear(); | |
| 76 | }); | |
| 77 | ||
| 72 | 78 | beforeAll(async () => { |
| 73 | 79 | dataDir = await mkdtemp(join(tmpdir(), "forge-board-")); |
| 74 | 80 | app = createApp({ dataDir, port: 0 }); |
| @@ -406,6 +412,12 @@ describe("the board's forge strip", () => { | ||
| 406 | 412 | }) |
| 407 | 413 | ).text(); |
| 408 | 414 | |
| 415 | // Module loggers reach every forge in the process: each test starts from a quiet ledger. | |
| 416 | beforeEach(() => { | |
| 417 | app.signals.errors.clear(); | |
| 418 | app.signals.refused.clear(); | |
| 419 | }); | |
| 420 | ||
| 409 | 421 | beforeAll(async () => { |
| 410 | 422 | dataDir = await mkdtemp(join(tmpdir(), "forge-board-strip-")); |
| 411 | 423 | app = createApp({ dataDir, port: 0 }); |
test/capacity.test.ts +13 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * read-only token — and every change is kept: who, when, from what to what. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 11 | 11 | import { chmodSync, writeFileSync } from "node:fs"; |
| 12 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 13 | import { tmpdir } from "node:os"; |
| @@ -95,6 +95,12 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 95 | 95 | }; |
| 96 | 96 | }; |
| 97 | 97 | |
| 98 | // Module loggers reach every forge in the process: each test starts from a quiet ledger. | |
| 99 | beforeEach(() => { | |
| 100 | app.signals.errors.clear(); | |
| 101 | app.signals.refused.clear(); | |
| 102 | }); | |
| 103 | ||
| 98 | 104 | beforeAll(async () => { |
| 99 | 105 | dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-")); |
| 100 | 106 | app = createApp({ dataDir, port: 0 }); |
| @@ -301,6 +307,12 @@ describe("report mode is loud to admins, and to nobody else", () => { | ||
| 301 | 307 | let alice: User; |
| 302 | 308 | let bob: User; |
| 303 | 309 | |
| 310 | // Module loggers reach every forge in the process: each test starts from a quiet ledger. | |
| 311 | beforeEach(() => { | |
| 312 | app.signals.errors.clear(); | |
| 313 | app.signals.refused.clear(); | |
| 314 | }); | |
| 315 | ||
| 304 | 316 | beforeAll(async () => { |
| 305 | 317 | dataDir = await mkdtemp(join(tmpdir(), "forge-capacity-loud-")); |
| 306 | 318 | app = createApp({ dataDir, port: 0 }); |
test/check-budget.test.ts +1 −1
| @@ -88,7 +88,7 @@ test("a check past its budget is re-queued once; the second overrun fails it and | ||
| 88 | 88 | expect(second.state).toBe("failed"); |
| 89 | 89 | expect(second.summary).toMatch(/^budget: a full gate took \d+ min, limit \d+$/); |
| 90 | 90 | |
| 91 | const seen = vitals({ db: app.db, dataDir }); | |
| 91 | const seen = vitals({ db: app.db, signals: app.signals, dataDir }); | |
| 92 | 92 | expect(seen.ok).toBe(false); |
| 93 | 93 | expect(seen.needs.some((n) => n.includes("budget: a full gate"))).toBe(true); |
| 94 | 94 | }, 60_000); |
test/vitals.test.ts +48 −16
| @@ -102,15 +102,15 @@ test("a site admin with a read-only token gets the shape; others get the short o | ||
| 102 | 102 | |
| 103 | 103 | test("a full queue shows in queue, and an old one in needs", async () => { |
| 104 | 104 | // Errors and refused calls are process-wide: another file in the shard may have left some. |
| 105 | (await import("../src/kernel/log.ts")).clearErrors(); | |
| 106 | (await import("../src/kernel/internal_refused.ts")).resetRefusedInternal(); | |
| 105 | app.signals.errors.clear(); | |
| 106 | app.signals.refused.clear(); | |
| 107 | 107 | const old = Date.now() - 3_600_000; |
| 108 | 108 | for (let i = 0; i < 3; i++) createRun(app.db, { repoId, kind: "agent" }); |
| 109 | const v = vitals({ db: app.db, dataDir }); | |
| 109 | const v = vitals({ db: app.db, signals: app.signals, dataDir }); | |
| 110 | 110 | expect(v.queue).toEqual({ agent: 3 }); |
| 111 | 111 | // Process-wide signals (errors, refused calls) from other suites may speak too. |
| 112 | 112 | expect(v.needs.join(" ")).not.toContain("queue"); |
| 113 | const later = vitals({ db: app.db, dataDir, now: old + 2 * 3_600_000 + 3_600_000 }); | |
| 113 | const later = vitals({ db: app.db, signals: app.signals, dataDir, now: old + 2 * 3_600_000 + 3_600_000 }); | |
| 114 | 114 | expect(later.ok).toBe(false); |
| 115 | 115 | expect(later.needs.join(" ")).toContain("agent queue"); |
| 116 | 116 | }); |
| @@ -131,7 +131,7 @@ test("a running run with a lapsed lease is stuck, and needs says so", async () = | ||
| 131 | 131 | app.db |
| 132 | 132 | .query("UPDATE run SET heartbeat_at=?2 WHERE id=?1") |
| 133 | 133 | .run(run.id, new Date().toISOString()); |
| 134 | expect(vitals({ db: app.db, dataDir }).stuck).toEqual([]); | |
| 134 | expect(vitals({ db: app.db, signals: app.signals, dataDir }).stuck).toEqual([]); | |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | 137 | test("the number of queries does not grow with runs", () => { |
| @@ -144,7 +144,7 @@ test("the number of queries does not grow with runs", () => { | ||
| 144 | 144 | return typeof v === "function" ? v.bind(t) : v; |
| 145 | 145 | }, |
| 146 | 146 | }); |
| 147 | vitals({ db: counting, dataDir }); | |
| 147 | vitals({ db: counting, signals: app.signals, dataDir }); | |
| 148 | 148 | return n; |
| 149 | 149 | }; |
| 150 | 150 | const before = count(); |
| @@ -181,8 +181,8 @@ test("errors are counted, bounded, redacted, and a burst is said", async () => { | ||
| 181 | 181 | expect(seen.latest.length).toBe(ERROR_RING); |
| 182 | 182 | expect(seen.lastHour).toBe(ERROR_RING + 51); |
| 183 | 183 | expect(seen.lastHour).toBeGreaterThanOrEqual(ERROR_BURST); |
| 184 | expect(vitals({ db: app.db, dataDir }).needs.some((n) => n.includes("errors in the last hour"))).toBe(true); | |
| 185 | expect(vitals({ db: app.db, dataDir, now: Date.now() + 2 * 3_600_000 }).errors.lastHour).toBe(0); | |
| 184 | expect(vitals({ db: app.db, signals: app.signals, dataDir }).needs.some((n) => n.includes("errors in the last hour"))).toBe(true); | |
| 185 | expect(vitals({ db: app.db, signals: app.signals, dataDir, now: Date.now() + 2 * 3_600_000 }).errors.lastHour).toBe(0); | |
| 186 | 186 | } finally { |
| 187 | 187 | console.error = orig; |
| 188 | 188 | clearErrors(); |
| @@ -195,10 +195,10 @@ test("the lane: depth, the running check and its age, the median of the last fin | ||
| 195 | 195 | const now = Date.now(); |
| 196 | 196 | const iso = (ms: number) => new Date(ms).toISOString(); |
| 197 | 197 | // Empty lane: no line. |
| 198 | expect(vitals({ db: app.db, dataDir, now }).lane).toEqual({ | |
| 198 | expect(vitals({ db: app.db, signals: app.signals, dataDir, now }).lane).toEqual({ | |
| 199 | 199 | queued: 0, running: null, medianMinutes: null, clearsInMinutes: null, |
| 200 | 200 | }); |
| 201 | expect(laneLine(vitals({ db: app.db, dataDir, now }).lane, now)).toBeNull(); | |
| 201 | expect(laneLine(vitals({ db: app.db, signals: app.signals, dataDir, now }).lane, now)).toBeNull(); | |
| 202 | 202 | // Three finished checks of 10, 20 and 30 minutes: the median is 20. |
| 203 | 203 | for (const min of [10, 20, 30]) { |
| 204 | 204 | const r = createRun(app.db, { repoId, kind: "check" }); |
| @@ -211,7 +211,7 @@ test("the lane: depth, the running check and its age, the median of the last fin | ||
| 211 | 211 | .query("UPDATE run SET state='running', started_at=?2, heartbeat_at=?3 WHERE id=?1") |
| 212 | 212 | .run(live.id, iso(now - 6 * 60_000), iso(now)); |
| 213 | 213 | for (let i = 0; i < 6; i++) createRun(app.db, { repoId, kind: "check" }); |
| 214 | const lane = vitals({ db: app.db, dataDir, now }).lane; | |
| 214 | const lane = vitals({ db: app.db, signals: app.signals, dataDir, now }).lane; | |
| 215 | 215 | expect(lane.queued).toBe(6); |
| 216 | 216 | expect(lane.running?.since).toBe(iso(now - 6 * 60_000)); |
| 217 | 217 | expect(lane.medianMinutes).toBe(20); |
| @@ -224,17 +224,16 @@ test("the lane: depth, the running check and its age, the median of the last fin | ||
| 224 | 224 | expect(body.lane.queued).toBe(6); |
| 225 | 225 | expect(body.lane.medianMinutes).toBe(20); |
| 226 | 226 | // Waiting past thirty minutes names the cause, not only the symptom. |
| 227 | const later = vitals({ db: app.db, dataDir, now: now + 3_600_000 }); | |
| 227 | const later = vitals({ db: app.db, signals: app.signals, dataDir, now: now + 3_600_000 }); | |
| 228 | 228 | const cause = later.needs.find((n) => n.includes("one lane")); |
| 229 | 229 | expect(cause).toMatch(/^The check queue has waited over 30 minutes; 6 checks queued .*; one lane, checks run one at a time\.$/); |
| 230 | 230 | expect(cause).not.toContain("Lane:"); |
| 231 | 231 | }); |
| 232 | 232 | |
| 233 | 233 | test("an unauthenticated internal call is refused, logged and flagged; the probe is not", async () => { |
| 234 | const { resetRefusedInternal } = await import("../src/kernel/internal_refused.ts"); | |
| 235 | resetRefusedInternal(); | |
| 234 | app.signals.refused.clear(); | |
| 236 | 235 | const flagged = () => |
| 237 | vitals({ db: app.db, dataDir: app.config.dataDir }).needs.filter((n) => | |
| 236 | vitals({ db: app.db, signals: app.signals, dataDir: app.config.dataDir }).needs.filter((n) => | |
| 238 | 237 | n.includes("internal call"), |
| 239 | 238 | ); |
| 240 | 239 | const call = (path: string) => |
| @@ -246,5 +245,38 @@ test("an unauthenticated internal call is refused, logged and flagged; the probe | ||
| 246 | 245 | const [line] = flagged(); |
| 247 | 246 | expect(line).toContain("1 internal call"); |
| 248 | 247 | expect(line).toContain("/internal/hook"); |
| 249 | resetRefusedInternal(); | |
| 248 | app.signals.refused.clear(); | |
| 249 | }); | |
| 250 | ||
| 251 | test("two forges in one process keep their own error and refusal signals", async () => { | |
| 252 | const { ERROR_BURST } = await import("../src/kernel/vitals.ts"); | |
| 253 | const { createLog } = await import("../src/kernel/log.ts"); | |
| 254 | const { noteRefusedInternal } = await import("../src/kernel/internal_refused.ts"); | |
| 255 | const dirB = await mkdtemp(join(tmpdir(), "forge-vitals-b-")); | |
| 256 | const other = createApp({ dataDir: dirB, port: 0 }); | |
| 257 | try { | |
| 258 | const needs = (a: App) => | |
| 259 | vitals({ db: a.db, dataDir: a.config.dataDir, signals: a.signals }).needs; | |
| 260 | expect(needs(app)).toEqual([]); | |
| 261 | expect(needs(other)).toEqual([]); | |
| 262 | for (let i = 0; i < ERROR_BURST; i++) other.log.error("boom"); | |
| 263 | noteRefusedInternal("POST", "/internal/hook", Date.now(), other.signals.refused); | |
| 264 | expect(needs(other).join(" ")).toContain("errors in the last hour"); | |
| 265 | expect(needs(other).join(" ")).toContain("internal call"); | |
| 266 | expect(needs(app)).toEqual([]); | |
| 267 | // A logger that belongs to no forge speaks for the whole process: production's path. | |
| 268 | const shared = createLog("shared"); | |
| 269 | for (let i = 0; i < ERROR_BURST; i++) shared.error("boom"); | |
| 270 | expect(needs(app).join(" ")).toContain("errors in the last hour"); | |
| 271 | // A forge born later starts clean. | |
| 272 | const dirC = await mkdtemp(join(tmpdir(), "forge-vitals-c-")); | |
| 273 | const later = createApp({ dataDir: dirC, port: 0 }); | |
| 274 | expect(needs(later)).toEqual([]); | |
| 275 | later.close(); | |
| 276 | await rm(dirC, { recursive: true, force: true }); | |
| 277 | } finally { | |
| 278 | other.close(); | |
| 279 | await rm(dirB, { recursive: true, force: true }); | |
| 280 | (await import("../src/kernel/log.ts")).clearErrors(); | |
| 281 | } | |
| 250 | 282 | }); |
Nothing in
srcwrites to or reads from this ledger any more:server.tsand all threevitalscallers passapp.signals.refused. Dead default, so remove it along with the defaulted parameters.