Skip to content

Commit 8751b9a

Browse files
authored
fix(selfhost,observability): close three durable-wrong-state gaps, label the latency histogram, probe the browser, and add memory to host pressure (#9544, #9545) (#9547)
1 parent 47f9ae4 commit 8751b9a

20 files changed

Lines changed: 731 additions & 14 deletions

apps/loopover-ui/src/lib/selfhost-env-reference.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
119119
},
120120
{
121121
name: "BROWSER_WS_ENDPOINT",
122-
firstReference: "src/selfhost/stubs/puppeteer.ts",
122+
firstReference: "src/selfhost/health.ts",
123123
},
124124
{
125125
name: "CLAUDE_AI_EFFORT",
@@ -333,6 +333,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
333333
name: "MAINTENANCE_ADMISSION_MAX_HOST_LOAD",
334334
firstReference: "src/selfhost/maintenance-admission.ts",
335335
},
336+
{
337+
name: "MAINTENANCE_ADMISSION_MAX_HOST_MEMORY",
338+
firstReference: "src/selfhost/maintenance-admission.ts",
339+
},
336340
{
337341
name: "MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS",
338342
firstReference: "src/selfhost/maintenance-admission.ts",
@@ -698,7 +702,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
698702
"| `ANTHROPIC_AI_MODEL` | `src/selfhost/ai.ts` |",
699703
"| `ANTHROPIC_API_KEY` | `src/selfhost/ai-config.ts` |",
700704
"| `BACKUP_ACKNOWLEDGED` | `src/server.ts` |",
701-
"| `BROWSER_WS_ENDPOINT` | `src/selfhost/stubs/puppeteer.ts` |",
705+
"| `BROWSER_WS_ENDPOINT` | `src/selfhost/health.ts` |",
702706
"| `CLAUDE_AI_EFFORT` | `src/selfhost/ai.ts` |",
703707
"| `CLAUDE_AI_FIRST_OUTPUT_TIMEOUT_MS` | `src/selfhost/ai.ts` |",
704708
"| `CLAUDE_AI_MODEL` | `src/selfhost/ai.ts` |",
@@ -752,6 +756,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
752756
"| `MAINTENANCE_ADMISSION_MAX_BACKLOG_CONVERGENCE_PENDING` | `src/selfhost/maintenance-admission.ts` |",
753757
"| `MAINTENANCE_ADMISSION_MAX_DEFER_AGE_MS` | `src/selfhost/maintenance-admission.ts` |",
754758
"| `MAINTENANCE_ADMISSION_MAX_HOST_LOAD` | `src/selfhost/maintenance-admission.ts` |",
759+
"| `MAINTENANCE_ADMISSION_MAX_HOST_MEMORY` | `src/selfhost/maintenance-admission.ts` |",
755760
"| `MAINTENANCE_ADMISSION_MAX_LIVE_AGE_MS` | `src/selfhost/maintenance-admission.ts` |",
756761
"| `MAINTENANCE_ADMISSION_MAX_LIVE_PENDING` | `src/selfhost/maintenance-admission.ts` |",
757762
"| `MAINTENANCE_ADMISSION_MAX_PENDING` | `src/selfhost/maintenance-admission.ts` |",

src/selfhost/blob-store.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
// surface those two paths use; every other R2Bucket method is unused on self-host. Node-only (fs import never
66
// reaches the Worker bundle — wired in server.ts behind REVIEW_AUDIT_DIR). MODULAR + off by default: unset
77
// REVIEW_AUDIT_DIR ⇒ no REVIEW_AUDIT binding ⇒ captures degrade to on-demand exactly as before.
8-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
8+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
9+
import { randomUUID } from "node:crypto";
910
import { dirname, resolve, sep } from "node:path";
1011

1112
/** Build a filesystem-backed REVIEW_AUDIT store rooted at `baseDir`. Keys are app-generated
@@ -48,7 +49,27 @@ export function createFsBlobStore(baseDir: string): R2Bucket {
4849
async put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null): Promise<R2Object> {
4950
const target = pathFor(key);
5051
await mkdir(dirname(target), { recursive: true });
51-
await writeFile(target, Buffer.from(await new Response(value ?? "").arrayBuffer()));
52+
// #9487: write to a unique temp path, then rename. Keys here are INPUT-HASH-ADDRESSED
53+
// (`loopover/shots/<hash>.png`), so a half-written file from a mid-write kill is never retried or
54+
// overwritten -- the next lookup for that same input hits it, finds a file, and serves a truncated PNG
55+
// as a permanently "valid" cache entry. rename(2) is atomic within a filesystem, so a reader sees
56+
// either no file or the complete one, never a partial. Same tmp+rename the config writer already does
57+
// (private-config.ts's atomicWriteWithBackup) -- and the temp name carries a UUID so two concurrent
58+
// puts of the same key cannot clobber each other's in-progress file.
59+
const tmpTarget = `${target}.tmp-${randomUUID()}`;
60+
try {
61+
await writeFile(tmpTarget, Buffer.from(await new Response(value ?? "").arrayBuffer()));
62+
await rename(tmpTarget, target);
63+
} catch (error) {
64+
// Never leave the temp file behind on a failed write -- otherwise a full disk or a mid-write crash
65+
// accretes orphans in the same directory the real objects live in. Best-effort: the original error is
66+
// what the caller needs, not a cleanup failure.
67+
/* v8 ignore next -- the cleanup's own failure arm: `force: true` already swallows ENOENT, so this only
68+
fires for something like an unwritable directory, in which case the ORIGINAL write error is what the
69+
caller needs. Unreachable without mocking fs, and mocking it here would test the mock. */
70+
await rm(tmpTarget, { force: true }).catch(() => undefined);
71+
throw error;
72+
}
5273
return { key } as unknown as R2Object;
5374
},
5475
/** Remove a stored object. A missing file is not an error (matches R2's own delete-is-idempotent

src/selfhost/health.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,75 @@ export function codexAuthReadinessProbe(
143143
};
144144
}
145145

146+
/**
147+
* #9487/#9464: readiness probe for the browserless endpoint backing visual capture.
148+
*
149+
* Every other dependency the review path needs has a probe (Redis, Qdrant, the GitHub App, codex);
150+
* `BROWSER_WS_ENDPOINT` had none, so a browserless outage was invisible in `/ready` AND in Prometheus while it
151+
* silently changed gate OUTCOMES — the screenshot-table gate reads absent evidence as a close signal. #9464
152+
* stopped that from closing PRs; this makes the outage itself visible instead of inferred after the fact.
153+
*
154+
* Only registered when the endpoint is configured — an instance with visual review switched off is not
155+
* degraded for lacking a browser, so an unconditional probe would make `/ready` red for every deployment that
156+
* never wanted the feature.
157+
*
158+
* Probes the endpoint's HTTP sibling rather than opening a websocket + launching Chromium: a readiness check
159+
* must be cheap enough to run on every poll, and a real launch costs a browser process. browserless answers
160+
* `/json/version` over plain HTTP on the same host/port as its `ws://` endpoint, which is exactly the
161+
* "is the service accepting connections" signal wanted here.
162+
*
163+
* Result is cached like the codex probe (same `cacheMs` shape, same single-flight guard) so a poll loop can't
164+
* turn readiness into its own load source against an already-struggling backend.
165+
*/
166+
export function browserEndpointReadinessProbe(
167+
env: Record<string, string | undefined>,
168+
fetchImpl: (url: string) => Promise<{ ok: boolean }>,
169+
cacheMs = 30_000,
170+
): ReadinessProbe | null {
171+
const endpoint = (env.BROWSER_WS_ENDPOINT ?? "").trim();
172+
if (!endpoint) return null;
173+
const versionUrl = browserVersionUrl(endpoint);
174+
// A configured-but-unparseable endpoint is a real misconfiguration, and one the ordinary capture path would
175+
// only surface as a per-shot render failure. Fail readiness closed rather than skipping the probe.
176+
if (versionUrl === null) return { name: "browser_endpoint", check: () => Promise.resolve(false) };
177+
let cached: boolean | undefined;
178+
let cachedUntil = 0;
179+
let inFlight: Promise<boolean> | undefined;
180+
return {
181+
name: "browser_endpoint",
182+
check: () => {
183+
const now = Date.now();
184+
if (cached !== undefined && now < cachedUntil) return Promise.resolve(cached);
185+
if (inFlight) return inFlight;
186+
inFlight = fetchImpl(versionUrl)
187+
.then((response) => response.ok)
188+
.catch(() => false)
189+
.then((ok) => {
190+
cached = ok;
191+
cachedUntil = Date.now() + cacheMs;
192+
return ok;
193+
})
194+
.finally(() => {
195+
inFlight = undefined;
196+
});
197+
return inFlight;
198+
},
199+
};
200+
}
201+
202+
/** The `http(s)://<host>/json/version` sibling of a `ws(s)://` browserless endpoint, or null when the
203+
* configured value is not a parseable ws/wss URL. Query strings (browserless carries `?token=`) are dropped:
204+
* the version endpoint needs no auth and the token must never end up in a probe URL that could be logged. */
205+
function browserVersionUrl(endpoint: string): string | null {
206+
try {
207+
const url = new URL(endpoint);
208+
if (url.protocol !== "ws:" && url.protocol !== "wss:") return null;
209+
return `${url.protocol === "wss:" ? "https:" : "http:"}//${url.host}/json/version`;
210+
} catch {
211+
return null;
212+
}
213+
}
214+
146215
/** Boot-time DATA-SAFETY advisory. A single SQLite file with no acknowledged backup is a data-loss SPOF — yet
147216
* `/ready` would still answer 200, so an operator can run with zero durability believing they're healthy. Returns
148217
* the warning to log at boot (or null on Postgres, or once the operator sets `BACKUP_ACKNOWLEDGED=true` after

src/selfhost/host-pressure.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// `node:os`'s loadavg() has no meaningful signal on Cloudflare Workers -- this module is imported ONLY by the
33
// self-host Node queue backends (sqlite-queue.ts / pg-queue.ts), never by src/index.ts's Worker bundle, so a
44
// static `node:os` import here is safe (mirrors the existing `hostname` import in selfhost/posthog.ts).
5-
import { cpus, loadavg } from "node:os";
5+
import { cpus, freemem, loadavg, totalmem } from "node:os";
66

77
/** The 1-minute load average normalized per logical core, so the SAME threshold means the same thing on a
88
* 4-vCPU box as a 32-vCPU box. Best-effort and fail-open: any error, or a reading that can't possibly be a
@@ -20,3 +20,37 @@ export function hostLoadAvg1PerCore(): number | null {
2020
return null;
2121
}
2222
}
23+
24+
/**
25+
* #9487: the fraction of host memory currently IN USE (0..1), or `null` when unavailable.
26+
*
27+
* Host pressure watched CPU only, but on the deployment this was found on (a GPU box running Ollama at
28+
* ~9.9 GiB alongside browserless at ~1.5 GiB) memory is the realistic killer: nothing observed it, nothing
29+
* shed load for it, and the OOM killer made the decision instead — which takes the whole container, losing
30+
* every in-flight job, rather than deferring one maintenance job.
31+
*
32+
* Same fail-open contract as {@link hostLoadAvg1PerCore}: any error, or a reading that cannot be a real
33+
* ratio, yields `null` ("signal unavailable"), never a misleading 0. A caller must treat `null` as "skip this
34+
* check".
35+
*
36+
* HONEST LIMIT, shared with the load signal above: `node:os` reports the HOST's memory, so under a container
37+
* memory limit (cgroup) this understates pressure — the container can be at its own ceiling while the host
38+
* looks idle. Reading `/sys/fs/cgroup/memory.current` would fix that and is deliberately not done here: it is
39+
* Linux- and cgroup-v2-specific, and this module is a best-effort *hint* for admission, not an accounting
40+
* boundary. The same caveat already applies to loadavg-over-container-cores.
41+
*/
42+
export function hostMemoryUsedFraction(): number | null {
43+
try {
44+
const total = totalmem();
45+
const free = freemem();
46+
if (!Number.isFinite(total) || total <= 0) return null;
47+
if (!Number.isFinite(free) || free < 0) return null;
48+
const used = (total - free) / total;
49+
// A free reading above total would put this outside 0..1 -- treat any impossible ratio as unavailable
50+
// rather than clamping, so a broken platform reading can never masquerade as "no pressure".
51+
if (used < 0 || used > 1) return null;
52+
return used;
53+
} catch {
54+
return null;
55+
}
56+
}

src/selfhost/load-file-secrets.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ export function loadFileSecrets(
2727
const target = key.slice(0, -"_FILE".length);
2828
if (env[target]) continue; // an explicit value wins
2929
const path = env[key] as string;
30+
let value: string;
3031
try {
31-
env[target] = readFile(path).trim();
32+
value = readFile(path).trim();
3233
} catch (error) {
3334
console.error(
3435
JSON.stringify({
@@ -43,5 +44,26 @@ export function loadFileSecrets(
4344
}`,
4445
);
4546
}
47+
// #9487: an EMPTY (zero-byte or whitespace-only) secret file is as fatal as a missing one. Setting
48+
// `env[target] = ""` looks like a successful load, but every downstream `nonBlank()` reads "" as
49+
// UNCONFIGURED and preflight.ts deliberately skips absent values -- so a truncated
50+
// GITHUB_WEBHOOK_SECRET file booted an instance that silently rejected every webhook. Directly adjacent
51+
// to the known secret-rotation footgun on edge-nl-01, where a file is rewritten in place: the window in
52+
// which it is momentarily empty is exactly when a container restart reads it.
53+
//
54+
// Checked OUTSIDE the try above on purpose: throwing inside it would be caught by that catch and
55+
// re-reported as "unreadable", collapsing two genuinely different operator problems (a bad path/permission
56+
// vs a truncated write) into one misleading message and the wrong log event.
57+
if (value === "") {
58+
console.error(
59+
JSON.stringify({
60+
level: "error",
61+
event: "selfhost_secret_file_empty",
62+
var: key,
63+
}),
64+
);
65+
throw new Error(`Secret file for ${key} (${path}) is empty; an empty secret silently reads as unconfigured downstream. Write the value, or unset ${key}.`);
66+
}
67+
env[target] = value;
4668
}
4769
}

src/selfhost/maintenance-admission.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ export interface MaintenancePressureSignals {
108108
oldestMaintenancePendingAgeMs: number | null;
109109
/** Null when unavailable (see host-pressure.ts) -- a caller must treat null as "skip this check". */
110110
hostLoadAvg1PerCore: number | null;
111+
/** #9487: fraction of host memory in use (0..1), or null when unavailable -- same skip-on-null contract as
112+
* hostLoadAvg1PerCore above. Pressure watched CPU only, but on a box also running Ollama (~9.9 GiB) and
113+
* browserless (~1.5 GiB) memory is the realistic killer, and nothing observed it: the OOM killer decided
114+
* instead, taking the whole container and every in-flight job with it rather than deferring one
115+
* maintenance job. */
116+
hostMemoryUsedFraction: number | null;
111117
/** #selfhost-backlog-convergence: pending+processing count of `agent-regate-pr` jobs tagged
112118
* `foreground_lane='backlog'` (queue-fairness.ts) -- the backlog-convergence sweeper's own output, DISTINCT
113119
* from `livePendingCount` (which is priority-gated, not lane-gated, and includes fresh webhook/foreground
@@ -128,6 +134,8 @@ export interface MaintenanceAdmissionConfig {
128134
maxLiveJobAgeMs: number;
129135
maxMaintenancePendingCount: number;
130136
maxHostLoadAvg1PerCore: number;
137+
/** #9487: defer maintenance above this fraction of host memory in use. */
138+
maxHostMemoryUsedFraction: number;
131139
maxBacklogConvergencePendingCount: number;
132140
deferMs: number;
133141
maxDeferAgeMs: number;
@@ -146,6 +154,10 @@ const DEFAULT_MAX_LIVE_PENDING_COUNT = 5;
146154
const DEFAULT_MAX_LIVE_JOB_AGE_MS = 2 * 60_000;
147155
const DEFAULT_MAX_MAINTENANCE_PENDING_COUNT = 15;
148156
const DEFAULT_MAX_HOST_LOAD_AVG1_PER_CORE = 1.5;
157+
// #9487: 0.92 leaves genuine headroom before the OOM killer without deferring on ordinary steady-state usage
158+
// -- a box running a resident model server sits legitimately high (page cache plus a multi-GiB model), so a
159+
// tighter default would defer maintenance permanently on exactly the deployment this was found on.
160+
const DEFAULT_MAX_HOST_MEMORY_USED_FRACTION = 0.92;
149161
// Deliberately more permissive than maxLivePendingCount (5): a real incident's backlog-convergence sweep can
150162
// legitimately queue several PRs across several repos at once (BACKLOG_CONVERGENCE_SWEEP_MAX_PRS=5 per repo per
151163
// sweep, selfhost/backlog-convergence.ts) without that alone meaning maintenance must fully yield -- only a
@@ -200,6 +212,10 @@ export function resolveMaintenanceAdmissionConfig(): MaintenanceAdmissionConfig
200212
"MAINTENANCE_ADMISSION_MAX_HOST_LOAD",
201213
DEFAULT_MAX_HOST_LOAD_AVG1_PER_CORE,
202214
),
215+
maxHostMemoryUsedFraction: parsePositiveFloatEnv(
216+
"MAINTENANCE_ADMISSION_MAX_HOST_MEMORY",
217+
DEFAULT_MAX_HOST_MEMORY_USED_FRACTION,
218+
),
203219
maxBacklogConvergencePendingCount: parsePositiveIntEnv("MAINTENANCE_ADMISSION_MAX_BACKLOG_CONVERGENCE_PENDING", {
204220
min: 0,
205221
fallback: DEFAULT_MAX_BACKLOG_CONVERGENCE_PENDING_COUNT,
@@ -225,6 +241,7 @@ export type MaintenanceAdmissionReason =
225241
| "maintenance_pending_high"
226242
| "maintenance_pending_high_drain"
227243
| "host_load_high"
244+
| "host_memory_high"
228245
| "pressure_clear";
229246

230247
export interface MaintenanceAdmissionDecision {
@@ -268,18 +285,25 @@ export function evaluateMaintenanceAdmission(
268285
}
269286
const hostLoadHigh =
270287
signals.hostLoadAvg1PerCore !== null && signals.hostLoadAvg1PerCore > config.maxHostLoadAvg1PerCore;
288+
// #9487: memory sits beside CPU as a peer pressure dimension, with the identical null-means-skip contract.
289+
const hostMemoryHigh =
290+
signals.hostMemoryUsedFraction !== null && signals.hostMemoryUsedFraction > config.maxHostMemoryUsedFraction;
271291
if (signals.maintenancePendingCount > config.maxMaintenancePendingCount) {
272292
if (nowMs - pendingSinceMs >= config.maintenanceDrainAgeMs) {
273293
// Host load is re-checked HERE, gating the drain escape specifically: draining more maintenance work onto
274294
// an already CPU-overloaded box is exactly what host_load_high exists to prevent. A job that hasn't hit
275295
// drain age yet is denied `maintenance_pending_high` regardless of host load (unchanged from before this
276296
// escape existed) -- this check only ever changes the outcome for a job the drain would otherwise admit.
277297
if (hostLoadHigh) return { admit: false, reason: "host_load_high" };
298+
// Memory gates the drain escape for the same reason load does: draining more work onto a box that is
299+
// already near its memory ceiling is what invites the OOM kill this signal exists to avoid.
300+
if (hostMemoryHigh) return { admit: false, reason: "host_memory_high" };
278301
return { admit: true, reason: "maintenance_pending_high_drain" };
279302
}
280303
return { admit: false, reason: "maintenance_pending_high" };
281304
}
282305
if (hostLoadHigh) return { admit: false, reason: "host_load_high" };
306+
if (hostMemoryHigh) return { admit: false, reason: "host_memory_high" };
283307
return { admit: true, reason: "pressure_clear" };
284308
}
285309

0 commit comments

Comments
 (0)