chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
// Package procio routes a spawned subprocess's stderr into structured
// zap logging instead of letting it write raw text wherever the
// parent's own os.Stderr happens to point. For a detached daemon that
// IS its own log file (daemon.log), an inherited raw stderr means a
// crashing child (a language server panic backtrace, hundreds of
// llama.cpp load-time trace lines) interleaves unstructured text with
// structured JSON log lines forever. StderrWatcher scans, bounds, and
// rate-limits each child's stderr into normal Warn-level log entries.
package procio
import (
"bytes"
"io"
"time"
"go.uber.org/zap"
)
const (
// DefaultMaxLineBytes bounds a single logged stderr line. Longer
// lines (or lines with no newline in sight, e.g. a JSON blob) are
// truncated rather than growing the scan buffer without bound or
// aborting the scan.
DefaultMaxLineBytes = 8 * 1024
// DefaultBurstLimit is how many stderr lines are logged verbatim
// within one rate-limit window before the rest of the window's
// lines are counted and suppressed. A crash-looping subprocess
// (rust-analyzer panicking repeatedly) must not flood the log.
DefaultBurstLimit = 100
// DefaultBurstWindow is the rate-limit window duration. Once a
// window elapses, the burst counter resets and any suppressed count
// from the previous window is flushed as one summary line.
DefaultBurstWindow = 10 * time.Second
)
// StderrWatcher scans a subprocess's stderr stream and routes it into
// a zap.Logger at Warn, bounded and rate-limited. Zero value uses the
// package defaults; construct with the fields you want to override.
type StderrWatcher struct {
// Logger receives one Warn per (unsuppressed) line, plus a
// suppression summary at each window boundary. A nil Logger drains
// the stream silently (so the child's write doesn't block) without
// logging anything.
Logger *zap.Logger
// Tag identifies the subprocess in every log entry (e.g. the LSP
// server name or command).
Tag string
// MaxLineBytes bounds a single line; <= 0 uses DefaultMaxLineBytes.
MaxLineBytes int
// BurstLimit is the per-window verbatim line cap; <= 0 uses
// DefaultBurstLimit.
BurstLimit int
// BurstWindow is the rate-limit window; <= 0 uses
// DefaultBurstWindow.
BurstWindow time.Duration
}
// Watch starts a goroutine that reads r until EOF (i.e. until the
// owning subprocess closes/dies) or a read error, logging lines as
// they arrive. It returns immediately; the goroutine is panic-safe and
// requires no further cleanup from the caller — closing/killing the
// subprocess is enough to end it.
func (w StderrWatcher) Watch(r io.Reader) {
go w.run(r)
}
func (w StderrWatcher) run(r io.Reader) {
defer func() { _ = recover() }()
if w.Logger == nil {
// Still drain so the child never blocks on a full pipe buffer,
// we just don't log any of it.
_, _ = io.Copy(io.Discard, r)
return
}
maxLine := w.MaxLineBytes
if maxLine <= 0 {
maxLine = DefaultMaxLineBytes
}
burst := w.BurstLimit
if burst <= 0 {
burst = DefaultBurstLimit
}
window := w.BurstWindow
if window <= 0 {
window = DefaultBurstWindow
}
lr := &lineReader{r: r, maxLine: maxLine}
var (
windowStart = time.Now()
count int
suppressed int
)
flush := func() {
if suppressed > 0 {
w.Logger.Warn("subprocess stderr suppressed",
zap.String("tag", w.Tag),
zap.Int("suppressed_lines", suppressed),
zap.Duration("window", window),
)
suppressed = 0
}
}
for {
line, ok := lr.next()
if !ok {
break
}
now := time.Now()
if now.Sub(windowStart) > window {
flush()
windowStart = now
count = 0
}
count++
if count > burst {
suppressed++
continue
}
w.Logger.Warn("subprocess stderr", zap.String("tag", w.Tag), zap.String("line", line))
}
flush()
}
// lineReader splits r into newline-delimited (or forced-length)
// strings, capped at maxLine bytes each. Unlike bufio.Scanner with a
// fixed Buffer, it never errors out on an over-long line (bufio's
// ErrTooLong would silently end the whole scan mid-stream) — an
// oversized line is simply truncated and the read continues.
type lineReader struct {
r io.Reader
maxLine int
buf []byte // pending, not-yet-newline-terminated bytes
chunk [4096]byte
err error
}
// next returns the next line (without its trailing newline), or
// ok=false once the underlying reader is exhausted/erroring and no
// buffered data remains.
func (lr *lineReader) next() (string, bool) {
for {
if i := bytes.IndexByte(lr.buf, '\n'); i >= 0 {
line := lr.buf[:i]
lr.buf = lr.buf[i+1:]
return lr.emit(line), true
}
if len(lr.buf) >= lr.maxLine {
// No newline yet but already over the cap — force a token
// now instead of buffering an unbounded fragment.
line := lr.buf[:lr.maxLine]
lr.buf = lr.buf[lr.maxLine:]
return lr.emit(line), true
}
if lr.err != nil {
if len(lr.buf) == 0 {
return "", false
}
line := lr.buf
lr.buf = nil
return lr.emit(line), true
}
n, err := lr.r.Read(lr.chunk[:])
if n > 0 {
lr.buf = append(lr.buf, lr.chunk[:n]...)
}
if err != nil {
lr.err = err
}
}
}
// emit trims a trailing '\r' (CRLF streams) and truncates to maxLine,
// copying out of the shared buf so the caller can keep it past the
// next Read.
func (lr *lineReader) emit(line []byte) string {
line = bytes.TrimSuffix(line, []byte{'\r'})
if len(line) > lr.maxLine {
line = line[:lr.maxLine]
}
out := make([]byte, len(line))
copy(out, line)
return string(out)
}
+167
View File
@@ -0,0 +1,167 @@
package procio
import (
"io"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
// waitForEntries polls obs until at least n entries have been recorded
// or the deadline elapses.
func waitForEntries(t *testing.T, obs *observer.ObservedLogs, n int) []observer.LoggedEntry {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if obs.Len() >= n {
return obs.All()
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %d log entries, got %d", n, obs.Len())
return nil
}
func TestStderrWatcher_LogsLines(t *testing.T) {
core, obs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
r, w := io.Pipe()
StderrWatcher{Logger: logger, Tag: "testproc"}.Watch(r)
go func() {
_, _ = w.Write([]byte("line one\nline two\n"))
_ = w.Close()
}()
entries := waitForEntries(t, obs, 2)
require.Equal(t, "subprocess stderr", entries[0].Message)
require.Equal(t, "testproc", entries[0].ContextMap()["tag"])
require.Equal(t, "line one", entries[0].ContextMap()["line"])
require.Equal(t, "line two", entries[1].ContextMap()["line"])
}
func TestStderrWatcher_NilLoggerDrainsSilently(t *testing.T) {
r, w := io.Pipe()
StderrWatcher{Logger: nil, Tag: "testproc"}.Watch(r)
done := make(chan struct{})
go func() {
_, _ = w.Write([]byte(strings.Repeat("x", 1<<20)))
_ = w.Close()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("write to a nil-logger watcher blocked — pipe was not drained")
}
}
func TestStderrWatcher_TruncatesOverlongLines(t *testing.T) {
core, obs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
r, w := io.Pipe()
StderrWatcher{Logger: logger, Tag: "t", MaxLineBytes: 16}.Watch(r)
go func() {
_, _ = w.Write([]byte(strings.Repeat("a", 100) + "\nshort\n"))
_ = w.Close()
}()
entries := waitForEntries(t, obs, 2)
line0, _ := entries[0].ContextMap()["line"].(string)
require.LessOrEqual(t, len(line0), 16)
require.Equal(t, "short", entries[1].ContextMap()["line"])
}
func TestStderrWatcher_BurstSuppressionSummary(t *testing.T) {
core, obs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
r, w := io.Pipe()
StderrWatcher{
Logger: logger,
Tag: "spam",
BurstLimit: 5,
BurstWindow: 24 * time.Hour, // never rolls over during the test
}.Watch(r)
go func() {
var sb strings.Builder
for i := 0; i < 20; i++ {
sb.WriteString("boom\n")
}
_, _ = w.Write([]byte(sb.String()))
_ = w.Close()
}()
// 5 verbatim lines + 1 suppression summary flushed at EOF.
entries := waitForEntries(t, obs, 6)
var logged, summaries int
var suppressedCount int64
for _, e := range entries {
switch e.Message {
case "subprocess stderr":
logged++
case "subprocess stderr suppressed":
summaries++
if v, ok := e.ContextMap()["suppressed_lines"]; ok {
suppressedCount, _ = toInt64(v)
}
}
}
require.Equal(t, 5, logged)
require.Equal(t, 1, summaries)
require.EqualValues(t, 15, suppressedCount)
}
func toInt64(v any) (int64, bool) {
switch n := v.(type) {
case int64:
return n, true
case int:
return int64(n), true
default:
return 0, false
}
}
func TestStderrWatcher_ExitsOnEOF(t *testing.T) {
core, _ := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
r, w := io.Pipe()
var wg sync.WaitGroup
wg.Add(1)
orig := StderrWatcher{Logger: logger, Tag: "t"}
// Wrap run in a goroutine we can join on directly (Watch itself
// fires an untracked goroutine, so re-implement the same call here
// to observe completion).
go func() {
defer wg.Done()
orig.run(r)
}()
_, _ = w.Write([]byte("bye\n"))
_ = w.Close()
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("watcher goroutine did not exit after EOF")
}
}