Public: anyone can read this; the crew can change it.
Sentinel: release was degraded 180 minutes today: Release 0.7.495 is unproven: https://forge.open-platform.sh/search?q=a as desktop /search?q=a did not answer within 200… #10420
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10395-sentinel-release-was-degraded-180-minute → main 1 commit fork
3 files
changed +127
−7
src/core/search.ts +7 −3
| @@ -417,7 +417,8 @@ export function searchLocalIssues( | ||
| 417 | 417 | * a query starting with `-` a pattern rather than a flag; `--` ends the |
| 418 | 418 | * options so a query containing `--` cannot re-open them; `-F` makes the |
| 419 | 419 | * query a fixed string, so a search box cannot hand git a pathological |
| 420 | * regex; `-I` skips binaries; `--max-count` and the timeout bound the work. | |
| 420 | * regex; `-I` skips binaries; `--max-count` (per file), the read that stops | |
| 421 | * after `maxCount` lines and the timeout bound the work. | |
| 421 | 422 | * The ref goes through `isSafeRef` like every other rev in the forge. |
| 422 | 423 | */ |
| 423 | 424 | export async function grepRepo( |
| @@ -463,12 +464,15 @@ async function grepRun( | ||
| 463 | 464 | "--", |
| 464 | 465 | ".", |
| 465 | 466 | ], |
| 466 | { cwd: dir, timeoutMs }, | |
| 467 | // `--max-count` is per file: `a` in a repository of thousands of files | |
| 468 | // is megabytes of matches. Only the first `maxCount` are kept, so git is | |
| 469 | // stopped once they are read rather than left to write the rest (#10395). | |
| 470 | { cwd: dir, timeoutMs, stopAfterLines: maxCount }, | |
| 467 | 471 | ); |
| 468 | 472 | // Exit 1 is "no match" — data, not failure. Anything else (a repo with no |
| 469 | 473 | // commits, a killed process) is simply no results from this repo. |
| 470 | 474 | if (run.timedOut === true) return "timeout"; |
| 471 | if (run.code !== 0 && run.code !== 1) return null; | |
| 475 | if (run.stopped !== true && run.code !== 0 && run.code !== 1) return null; | |
| 472 | 476 | |
| 473 | 477 | // `-z` makes each record `ref:path NUL line NUL text`, so a path with a |
| 474 | 478 | // colon in it (`a:1:b.txt`) cannot be split at the wrong colon. |
src/git/env.ts +61 −4
| @@ -302,6 +302,57 @@ export interface GitCapture { | ||
| 302 | 302 | readonly stderr: string; |
| 303 | 303 | /** The timeout killed the process. */ |
| 304 | 304 | readonly timedOut?: boolean; |
| 305 | /** `stopAfterLines` was reached: stdout holds that many lines and the process was killed for it. */ | |
| 306 | readonly stopped?: boolean; | |
| 307 | } | |
| 308 | ||
| 309 | export interface CaptureOptions extends RunOptions { | |
| 310 | /** | |
| 311 | * Keep only the first this-many newline-ended lines of stdout and kill the | |
| 312 | * process once they are read. For a caller that wants the first few | |
| 313 | * answers of a command whose whole output can be megabytes (`git grep a`). | |
| 314 | */ | |
| 315 | stopAfterLines?: number; | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * The first `max` lines of `stream`, read as they come; `stop` is called | |
| 320 | * once they are in, and nothing past them is kept. Without `max`, all of it. | |
| 321 | */ | |
| 322 | async function readLines( | |
| 323 | stream: ReadableStream<Uint8Array>, | |
| 324 | max: number | undefined, | |
| 325 | stop: () => void, | |
| 326 | ): Promise<{ text: string; stopped: boolean }> { | |
| 327 | if (max === undefined) return { text: await new Response(stream).text(), stopped: false }; | |
| 328 | const decoder = new TextDecoder(); | |
| 329 | const parts: string[] = []; | |
| 330 | let lines = 0; | |
| 331 | const reader = stream.getReader(); | |
| 332 | try { | |
| 333 | for (;;) { | |
| 334 | const { done, value } = await reader.read(); | |
| 335 | if (done) break; | |
| 336 | let cut = -1; | |
| 337 | for (let i = 0; i < value.length; i++) { | |
| 338 | if (value[i] === 10 && ++lines >= max) { | |
| 339 | cut = i + 1; | |
| 340 | break; | |
| 341 | } | |
| 342 | } | |
| 343 | if (cut < 0) { | |
| 344 | parts.push(decoder.decode(value, { stream: true })); | |
| 345 | continue; | |
| 346 | } | |
| 347 | parts.push(decoder.decode(value.subarray(0, cut))); | |
| 348 | stop(); | |
| 349 | return { text: parts.join(""), stopped: true }; | |
| 350 | } | |
| 351 | parts.push(decoder.decode()); | |
| 352 | return { text: parts.join(""), stopped: false }; | |
| 353 | } finally { | |
| 354 | reader.cancel().catch(() => {}); | |
| 355 | } | |
| 305 | 356 | } |
| 306 | 357 | |
| 307 | 358 | /** |
| @@ -311,7 +362,7 @@ export interface GitCapture { | ||
| 311 | 362 | */ |
| 312 | 363 | export async function runGitCapture( |
| 313 | 364 | args: readonly string[], |
| 314 | options: RunOptions = {}, | |
| 365 | options: CaptureOptions = {}, | |
| 315 | 366 | ): Promise<GitCapture> { |
| 316 | 367 | const start = performance.now(); |
| 317 | 368 | return gitGate.run(async () => { |
| @@ -331,12 +382,18 @@ export async function runGitCapture( | ||
| 331 | 382 | options.timeoutMs ?? 30_000, |
| 332 | 383 | ); |
| 333 | 384 | try { |
| 334 | const [stdout, stderr, code] = await Promise.all([ | |
| 335 | new Response(proc.stdout).text(), | |
| 385 | const [out, stderr, code] = await Promise.all([ | |
| 386 | readLines(proc.stdout, options.stopAfterLines, () => proc.kill("SIGKILL")), | |
| 336 | 387 | new Response(proc.stderr).text(), |
| 337 | 388 | proc.exited, |
| 338 | 389 | ]); |
| 339 | return { code, stdout, stderr, ...(timedOut ? { timedOut } : {}) }; | |
| 390 | return { | |
| 391 | code, | |
| 392 | stdout: out.text, | |
| 393 | stderr, | |
| 394 | ...(timedOut && !out.stopped ? { timedOut } : {}), | |
| 395 | ...(out.stopped ? { stopped: true } : {}), | |
| 396 | }; | |
| 340 | 397 | } finally { |
| 341 | 398 | clearTimeout(timer); |
| 342 | 399 | noteGit(gitRecord, args, start); |
test/search-grep-stops.test.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | // #10395: `git grep --max-count` is per file, so a one-letter query in a | |
| 2 | // repository of many files wrote megabytes that the forge read whole on its | |
| 3 | // event loop. The read now stops git once the hits it keeps are in. | |
| 4 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 5 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { runGitCapture } from "../src/git/env.ts"; | |
| 9 | import { grepRepo } from "../src/core/search.ts"; | |
| 10 | ||
| 11 | const FILES = 400; | |
| 12 | const LINES = 30; | |
| 13 | let dir = ""; | |
| 14 | ||
| 15 | beforeAll(() => { | |
| 16 | dir = mkdtempSync(join(tmpdir(), "forge-grep-stops-")); | |
| 17 | const line = "a line that matches a\n"; | |
| 18 | for (let i = 0; i < FILES; i++) writeFileSync(join(dir, `f${i}.txt`), line.repeat(LINES)); | |
| 19 | const git = (...args: string[]) => { | |
| 20 | const r = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "[email protected]", ...args], { cwd: dir }); | |
| 21 | if (r.exitCode !== 0) throw new Error(`git ${args[0]}: ${r.stderr.toString()}`); | |
| 22 | }; | |
| 23 | git("init", "--quiet", "--initial-branch=main"); | |
| 24 | git("add", "."); | |
| 25 | git("commit", "--quiet", "-m", "many"); | |
| 26 | }); | |
| 27 | ||
| 28 | afterAll(() => rmSync(dir, { recursive: true, force: true })); | |
| 29 | ||
| 30 | const grepArgs = ["grep", "-z", "-n", "-I", "-i", "-F", "--max-count=20", "-e", "a", "main", "--", "."]; | |
| 31 | ||
| 32 | test("without a stop, the per-file cap still lets git write every file's matches", async () => { | |
| 33 | const run = await runGitCapture(grepArgs, { cwd: dir }); | |
| 34 | expect(run.stdout.split("\n").filter(Boolean).length).toBe(FILES * 20); | |
| 35 | expect(run.stopped).toBeUndefined(); | |
| 36 | }); | |
| 37 | ||
| 38 | test("stopAfterLines keeps exactly that many lines and stops git", async () => { | |
| 39 | const run = await runGitCapture(grepArgs, { cwd: dir, stopAfterLines: 20 }); | |
| 40 | expect(run.stopped).toBe(true); | |
| 41 | expect(run.timedOut).toBeUndefined(); | |
| 42 | const lines = run.stdout.split("\n"); | |
| 43 | expect(lines.pop()).toBe(""); | |
| 44 | expect(lines.length).toBe(20); | |
| 45 | expect(lines.every((l) => l.startsWith("main:"))).toBe(true); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a stop that is never reached reads the whole output", async () => { | |
| 49 | const run = await runGitCapture(["grep", "-n", "-F", "-e", "a", "main", "--", "f0.txt"], { cwd: dir, stopAfterLines: 1000 }); | |
| 50 | expect(run.stopped).toBeUndefined(); | |
| 51 | expect(run.code).toBe(0); | |
| 52 | expect(run.stdout.split("\n").filter(Boolean).length).toBe(LINES); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a code search for one letter answers its cap of hits from a stopped grep", async () => { | |
| 56 | const hits = await grepRepo(dir, "main", "a", 20); | |
| 57 | expect(hits.length).toBe(20); | |
| 58 | expect(hits[0]).toEqual({ path: expect.stringMatching(/^f\d+\.txt$/), line: expect.any(Number), text: "a line that matches a" }); | |
| 59 | }); |