From a8f2e849469984006bf4ef8a0f299b28fded548e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 17:50:29 +0800 Subject: [PATCH] fix(app-boot): keep the probe's stderr tail across reads of any size The tail dropped a whole kept chunk whenever a later chunk pushed the total over the limit, so a flood that arrived as one read (Linux) left only the bytes written after it. `StreamTail` trims the oldest kept chunk instead and is unit-tested with both read patterns. --- packages/boot/app-boot/src/probe.ts | 57 +++++++++++++++++----- packages/boot/app-boot/tests/probe.spec.ts | 21 +++++++- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/boot/app-boot/src/probe.ts b/packages/boot/app-boot/src/probe.ts index df26dfb39b..c07004f478 100644 --- a/packages/boot/app-boot/src/probe.ts +++ b/packages/boot/app-boot/src/probe.ts @@ -158,17 +158,8 @@ function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: stri ) // The tail of stderr, for the failure text: a package that floods stderr at // import must not grow this process's heap by as much. - const err: Buffer[] = [] - let errBytes = 0 - child.stderr?.on('data', (chunk: Buffer) => { - err.push(chunk) - errBytes += chunk.length - while (errBytes > STDERR_TAIL_BYTES && err.length > 1) errBytes -= (err.shift() as Buffer).length - if (errBytes > STDERR_TAIL_BYTES) { - err[0] = (err[0] as Buffer).subarray(errBytes - STDERR_TAIL_BYTES) - errBytes = STDERR_TAIL_BYTES - } - }) + const err = new StreamTail(STDERR_TAIL_BYTES) + child.stderr?.on('data', (chunk: Buffer) => { err.push(chunk) }) // The outcome lands on `close`. A kill — after the report, or at the // timeout — records its outcome and waits for the close it causes, so the // child's pipes and channel are gone when the caller continues; a spawn @@ -200,7 +191,7 @@ function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: stri outcome() return } - const tail = Buffer.concat(err).toString('utf8').trim() + const tail = err.text().trim() reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`)) }) }) @@ -211,6 +202,48 @@ const DEFAULT_TIMEOUT_MS = 20_000 /** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */ const STDERR_TAIL_BYTES = 16 * 1024 +/** + * The last `limit` bytes a stream delivered, kept as the chunks it arrived in: + * as more arrives, whole chunks leave the front and the oldest kept chunk is + * trimmed, so one chunk larger than the limit keeps its end and a small chunk + * after it never drops what was kept. + */ +export class StreamTail { + private readonly chunks: Buffer[] = [] + private bytes = 0 + + /** @param limit - how many bytes of the end to keep. */ + constructor(private readonly limit: number) {} + + /** + * Take one more chunk from the stream. + * @param chunk - the bytes as delivered. + */ + push(chunk: Buffer): void { + this.chunks.push(chunk) + this.bytes += chunk.length + while (this.bytes > this.limit) { + const excess = this.bytes - this.limit + const first = this.chunks[0] as Buffer + if (first.length <= excess) { + this.chunks.shift() + this.bytes -= first.length + } else { + this.chunks[0] = first.subarray(excess) + this.bytes = this.limit + } + } + } + + /** + * The kept end of the stream. + * @returns the last bytes delivered, at most the limit, as UTF-8 text. + */ + text(): string { + return Buffer.concat(this.chunks).toString('utf8') + } +} + /** The environment name carrying the run's report token; the child removes it before importing anything. */ const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT' diff --git a/packages/boot/app-boot/tests/probe.spec.ts b/packages/boot/app-boot/tests/probe.spec.ts index fca87b6d05..d8533d9d72 100644 --- a/packages/boot/app-boot/tests/probe.spec.ts +++ b/packages/boot/app-boot/tests/probe.spec.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts' import { PLUGIN_PROBE_FORMAT } from '../src/probe.ts' -import { cordisPackageDir, parseProbeRecord } from '../src/probe.ts' +import { cordisPackageDir, parseProbeRecord, StreamTail } from '../src/probe.ts' import { parseChildReport, type ChildReport } from '../src/probe-report.ts' const NAME = 'dsh-test-bin' @@ -340,3 +340,22 @@ describe('parseChildReport', () => { expect(parseChildReport('report')).toBeUndefined() }) }) + +describe('StreamTail', () => { + it('keeps the end of one oversized chunk and what a later small chunk pushes out of it', () => { + // Linux hands the parent a flood as one read; the marker written after it arrives alone. + const tail = new StreamTail(16) + tail.push(Buffer.from('x'.repeat(200))) + tail.push(Buffer.from('tail-marker')) + expect(tail.text()).toBe('xxxxxtail-marker') + }) + + it('drops whole leading chunks before trimming the oldest kept one', () => { + // macOS drains a flood in small reads: the kept chunks are the last few. + const tail = new StreamTail(10) + for (const piece of ['abcd', 'efgh', 'ijkl', 'mn']) tail.push(Buffer.from(piece)) + expect(tail.text()).toBe('efghijklmn') + tail.push(Buffer.from('opq')) + expect(tail.text()).toBe('hijklmnopq') + }) +})