Преглед изворни кода

Merge branch 'feat/plugin-mgmt-3-settings' into feat/plugin-mgmt-4-web

Yichen Jiang пре 2 недеља
родитељ
комит
654ec0d8c1
2 измењених фајлова са 65 додато и 13 уклоњено
  1. 45 12
      packages/boot/app-boot/src/probe.ts
  2. 20 1
      packages/boot/app-boot/tests/probe.spec.ts

+ 45 - 12
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'
 

+ 20 - 1
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')
+  })
+})