213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
/** Bounded output tails and private spill files shared by process providers. */
|
|
import { randomBytes } from 'node:crypto'
|
|
import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import type { CollectedOutput } from '@deepseek-ai/dsh-subprocess'
|
|
|
|
let spillCounter = 0
|
|
let defaultSpillDir: string | undefined
|
|
|
|
/**
|
|
* The default spill location: a private (0700) per-process directory under
|
|
* the OS tmpdir, created lazily. Predictable world-readable paths would let
|
|
* other local users read command output or pre-create symlinks. At a
|
|
* JavaScript-observable process exit the directory is removed only when it
|
|
* holds no completed spill file (spill files are retained as full-output
|
|
* recovery artifacts until an external cleanup).
|
|
*/
|
|
function privateSpillDir(): string {
|
|
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
|
|
return defaultSpillDir
|
|
}
|
|
|
|
// The per-process spill directory is removed at process exit when it holds no
|
|
// completed spill file: a directory that never spilled is empty and is safe to
|
|
// remove, while a directory holding completed spill files keeps them (their
|
|
// content is retained until an external cleanup). A SIGKILLed process cannot
|
|
// run this at all; its residue is left to OS temp hygiene.
|
|
/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
|
|
process.once('exit', () => {
|
|
if (defaultSpillDir === undefined) return
|
|
try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
|
|
})
|
|
|
|
/**
|
|
* Prepare fallible output storage before starting a managed native process.
|
|
* @param internals - optional caller-owned spill directory.
|
|
* @returns binding inputs whose spill directory is ready for use.
|
|
*/
|
|
export function prepareManagedProcessBinding(
|
|
internals: { spillDir?: string } = {},
|
|
): { spillDir: string } {
|
|
return { spillDir: internals.spillDir ?? privateSpillDir() }
|
|
}
|
|
|
|
|
|
/**
|
|
* Collects one stream with a bounded in-memory tail. With a spill cap, on
|
|
* first overflow a spill file is created and every chunk (including those
|
|
* already collected) is appended there while the full stream remains within
|
|
* the cap; without one, only the in-memory tail is ever retained (the
|
|
* diagnostic-tail shape — a language server's stderr).
|
|
*
|
|
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
|
* end of command output; the spill file covers the head.
|
|
*/
|
|
export class OutputCollector {
|
|
private chunks: Buffer[] = []
|
|
private bytes = 0
|
|
private dropped = false
|
|
private spillFd: number | undefined
|
|
private spillFile: string | undefined
|
|
private spillDisabled: boolean
|
|
/** Total bytes ever pushed (not just retained). */
|
|
private total = 0
|
|
|
|
constructor(
|
|
private readonly maxBytes: number,
|
|
private readonly maxSpillBytes: number | undefined,
|
|
private readonly label: string,
|
|
private readonly spillDir: string,
|
|
) {
|
|
this.spillDisabled = maxSpillBytes === undefined
|
|
}
|
|
|
|
/**
|
|
* Ingest one stream chunk, counting it toward the whole-stream total. On
|
|
* first overflow of the in-memory cap a spill file is opened (when spilling
|
|
* is enabled) and every chunk (already-collected ones included) is appended
|
|
* there from then on; the in-memory tail then drops whole chunks from its
|
|
* head (or the head of a single over-cap chunk) until it fits the cap again.
|
|
* @param chunk - the raw bytes from one stream 'data' event.
|
|
*/
|
|
push(chunk: Buffer): void {
|
|
this.total += chunk.length
|
|
const overflows = this.bytes + chunk.length > this.maxBytes
|
|
if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
|
|
this.chunks.push(chunk)
|
|
this.bytes += chunk.length
|
|
while (this.bytes > this.maxBytes) {
|
|
const head = this.chunks[0] as Buffer
|
|
const excess = this.bytes - this.maxBytes
|
|
if (head.length <= excess) {
|
|
// Drop the whole head chunk (length ≥ 1 is guaranteed while over cap).
|
|
this.chunks.shift()
|
|
this.bytes -= head.length
|
|
} else {
|
|
// Trim the head so the retained window is byte-exact at the cap — a
|
|
// diagnostic tail (an LSP server's stderr) must hold the LAST
|
|
// maxBytes regardless of how the stream was chunked.
|
|
this.chunks[0] = head.subarray(excess)
|
|
this.bytes -= excess
|
|
}
|
|
this.dropped = true
|
|
}
|
|
}
|
|
|
|
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
|
private spillAll(chunk: Buffer): void {
|
|
if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
|
|
this.discardSpill()
|
|
return
|
|
}
|
|
if (this.spillFd === undefined) {
|
|
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
|
|
// existing path, symlink or not) + owner-only mode: defeats spill-path
|
|
// prediction and symlink planting in shared tmp dirs.
|
|
this.spillFile = join(
|
|
this.spillDir,
|
|
`dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
|
)
|
|
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
|
|
for (const prior of this.chunks) writeSync(this.spillFd, prior)
|
|
}
|
|
writeSync(this.spillFd, chunk)
|
|
}
|
|
|
|
/** Stop spilling and remove the file once it can no longer hold the complete stream. */
|
|
private discardSpill(): void {
|
|
const fd = this.spillFd
|
|
const file = this.spillFile
|
|
this.spillFd = undefined
|
|
this.spillFile = undefined
|
|
this.spillDisabled = true
|
|
if (fd !== undefined) {
|
|
try {
|
|
closeSync(fd)
|
|
} catch {
|
|
// Retain the descriptor so finalize can retry the failed close.
|
|
this.spillFd = fd
|
|
}
|
|
}
|
|
if (file !== undefined) {
|
|
try {
|
|
unlinkSync(file)
|
|
} catch {
|
|
// A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Incremental read in whole-stream byte coordinates: returns everything
|
|
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
|
* in-memory tail window, the read is `lossy` — it returns the whole
|
|
* retained tail and the gap is only recoverable from the spill file.
|
|
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
|
|
* @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
|
|
*/
|
|
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
|
|
const windowStart = this.total - this.bytes
|
|
const buffer = Buffer.concat(this.chunks)
|
|
const lossy = fromByte < windowStart
|
|
const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
|
|
return {
|
|
text: slice.toString('utf8'),
|
|
nextOffset: this.total,
|
|
lossy,
|
|
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy the retained raw tail with its position in the complete observed stream.
|
|
* @returns independent tail bytes and the total byte count before truncation.
|
|
*/
|
|
snapshot(): { bytes: Buffer; totalBytes: number } {
|
|
return { bytes: Buffer.concat(this.chunks), totalBytes: this.total }
|
|
}
|
|
|
|
/**
|
|
* Close the spill file once the stream has ended. A failed close (delayed
|
|
* writeback fault) stops advertising the spill path — the file may be
|
|
* missing its tail — while every in-memory read keeps working. Idempotent;
|
|
* the spawn path seals both collectors at settlement so reads after exit
|
|
* never point at a still-open file.
|
|
*/
|
|
seal(): void {
|
|
if (this.spillFd === undefined) return
|
|
try {
|
|
closeSync(this.spillFd)
|
|
} catch {
|
|
// A delayed writeback failure makes the spill unreliable; keep the
|
|
// in-memory result but stop advertising that file.
|
|
this.spillFile = undefined
|
|
}
|
|
this.spillFd = undefined
|
|
}
|
|
|
|
/**
|
|
* Seal the spill file and return the final output.
|
|
* @returns the final collected output: tail text, truncation flag, and the spill path when intact.
|
|
*/
|
|
finalize(): CollectedOutput {
|
|
this.seal()
|
|
return {
|
|
text: Buffer.concat(this.chunks).toString('utf8'),
|
|
truncated: this.dropped,
|
|
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
|
}
|
|
}
|
|
}
|