Files
wehub-resource-sync a06f331eb8
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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

269 lines
7.4 KiB
Go

package crashpool
import (
"encoding/gob"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
const testWorkerEnv = "GORTEX_CRASHPOOL_TESTWORKER"
// TestMain lets the test binary re-execute itself as a worker
// subprocess: a Pool spawned with Argv={os.Args[0]} and the env var set
// runs one of the helper workers below instead of the test suite.
func TestMain(m *testing.M) {
switch os.Getenv(testWorkerEnv) {
case "real":
_ = RunWorker(os.Stdin, os.Stdout)
os.Exit(0)
case "crash":
testCrashWorker()
os.Exit(0)
case "slow":
testSlowWorker()
os.Exit(0)
case "stderr":
testStderrWorker()
os.Exit(0)
default:
os.Exit(m.Run())
}
}
// mockExtractor is a minimal parser.Extractor for worker tests.
type mockExtractor struct {
lang string
exts []string
nodes int
panics bool
}
func (m *mockExtractor) Language() string { return m.lang }
func (m *mockExtractor) Extensions() []string { return m.exts }
func (m *mockExtractor) Extract(filePath string, _ []byte) (*parser.ExtractionResult, error) {
if m.panics {
panic("mock extractor boom")
}
nodes := make([]*graph.Node, m.nodes)
for i := range nodes {
nodes[i] = &graph.Node{
ID: fmt.Sprintf("%s::n%d", filePath, i),
Kind: graph.KindFunction,
Name: fmt.Sprintf("n%d", i),
}
}
return &parser.ExtractionResult{Nodes: nodes}, nil
}
func mockRegistry() *parser.Registry {
reg := parser.NewRegistry()
reg.Register(&mockExtractor{lang: "mock", exts: []string{".mock"}, nodes: 2})
reg.Register(&mockExtractor{lang: "mockpanic", exts: []string{".mp"}, panics: true})
return reg
}
// testCrashWorker serves mock extractions but hard-exits on any file
// whose path contains "BOOM" — simulating a SIGSEGV mid-parse.
func testCrashWorker() {
reg := mockRegistry()
dec := gob.NewDecoder(os.Stdin)
enc := gob.NewEncoder(os.Stdout)
for {
var req extractRequest
if dec.Decode(&req) != nil {
return
}
if strings.Contains(req.RelPath, "BOOM") {
os.Exit(99)
}
resp := serveOne(reg, req)
if enc.Encode(&resp) != nil {
return
}
}
}
// testSlowWorker sleeps before every response — used to exercise the
// per-request timeout.
func testSlowWorker() {
dec := gob.NewDecoder(os.Stdin)
enc := gob.NewEncoder(os.Stdout)
for {
var req extractRequest
if dec.Decode(&req) != nil {
return
}
time.Sleep(3 * time.Second)
if enc.Encode(&extractResponse{Seq: req.Seq}) != nil {
return
}
}
}
// testStderrWorker writes a fixed burst of lines to stderr before
// serving normally — used to exercise crashpool's stderr-routing path
// (a real worker prints panic/runtime diagnostics to stderr on the way
// down; this simulates that without actually crashing).
func testStderrWorker() {
for i := 1; i <= 2; i++ {
fmt.Fprintf(os.Stderr, "worker stderr line %d\n", i)
}
reg := mockRegistry()
dec := gob.NewDecoder(os.Stdin)
enc := gob.NewEncoder(os.Stdout)
for {
var req extractRequest
if dec.Decode(&req) != nil {
return
}
resp := serveOne(reg, req)
if enc.Encode(&resp) != nil {
return
}
}
}
func newTestPool(t *testing.T, mode string, cfg Config) *Pool {
t.Helper()
cfg.Argv = []string{os.Args[0]}
cfg.Env = []string{testWorkerEnv + "=" + mode}
if cfg.Workers == 0 {
cfg.Workers = 2
}
p, err := NewPool(cfg)
require.NoError(t, err)
t.Cleanup(p.Close)
return p
}
func TestServeOne_Normal(t *testing.T) {
resp := serveOne(mockRegistry(), extractRequest{Seq: 1, RelPath: "x.mock", Language: "mock"})
require.Equal(t, uint64(1), resp.Seq)
require.False(t, resp.Panicked)
require.Empty(t, resp.Err)
require.Len(t, resp.Nodes, 2)
}
func TestServeOne_PanicRecovered(t *testing.T) {
resp := serveOne(mockRegistry(), extractRequest{Seq: 7, RelPath: "x.mp", Language: "mockpanic"})
require.Equal(t, uint64(7), resp.Seq)
require.True(t, resp.Panicked)
require.Contains(t, resp.Err, "boom")
require.Nil(t, resp.Nodes)
}
func TestServeOne_NoExtractor(t *testing.T) {
resp := serveOne(mockRegistry(), extractRequest{Seq: 1, Language: "nonesuch"})
require.Contains(t, resp.Err, "no extractor")
}
func TestPool_NormalExtraction(t *testing.T) {
p := newTestPool(t, "real", Config{Workers: 2})
res := p.Submit("main.go", "go", []byte("package main\n\nfunc Hello() {}\n"))
require.False(t, res.Bad(), "unexpected failure: %s", res.Err)
require.NotEmpty(t, res.Nodes)
}
// TestPool_CrashIsolation is the headline guarantee: a worker that dies
// mid-parse is detected, the file is reported crashed, and the pool
// respawns so every other file still indexes.
func TestPool_CrashIsolation(t *testing.T) {
p := newTestPool(t, "crash", Config{Workers: 2})
ok := p.Submit("a.mock", "mock", []byte("x"))
require.False(t, ok.Bad())
require.Len(t, ok.Nodes, 2)
bad := p.Submit("dir/BOOM.mock", "mock", []byte("x"))
require.True(t, bad.Crashed)
require.True(t, bad.Bad())
require.Contains(t, bad.Err, "crashed")
// The pool respawned — every later file still indexes.
for i := 0; i < 8; i++ {
r := p.Submit(fmt.Sprintf("after%d.mock", i), "mock", []byte("x"))
require.False(t, r.Bad(), "submit %d after crash failed: %s", i, r.Err)
require.Len(t, r.Nodes, 2)
}
spawns, crashes := p.Stats()
require.GreaterOrEqual(t, crashes, int64(1))
require.GreaterOrEqual(t, spawns, int64(3)) // 2 initial + >=1 respawn
}
func TestPool_PanicSurvivesWorker(t *testing.T) {
p := newTestPool(t, "crash", Config{Workers: 1})
bad := p.Submit("p.mp", "mockpanic", []byte("x"))
require.True(t, bad.Panicked)
require.False(t, bad.Crashed) // recovered panic — worker stays alive
// Same worker keeps serving.
ok := p.Submit("ok.mock", "mock", []byte("x"))
require.False(t, ok.Bad())
require.Len(t, ok.Nodes, 2)
_, crashes := p.Stats()
require.Equal(t, int64(0), crashes)
}
func TestPool_RequestTimeout(t *testing.T) {
p := newTestPool(t, "slow", Config{Workers: 1, RequestTimeout: 250 * time.Millisecond})
res := p.Submit("slow.mock", "mock", []byte("x"))
require.True(t, res.Crashed)
require.Contains(t, res.Err, "timed out")
}
func TestPool_ClosedRejects(t *testing.T) {
p := newTestPool(t, "crash", Config{Workers: 1})
p.Close()
res := p.Submit("x.mock", "mock", []byte("x"))
require.True(t, res.Bad())
require.Contains(t, res.Err, "closed")
}
func TestNewPool_EmptyArgv(t *testing.T) {
_, err := NewPool(Config{Workers: 1})
require.Error(t, err)
}
// TestPool_RoutesWorkerStderrThroughLogger verifies a worker's stderr
// is no longer inherited raw (which, in the daemon, would mean writing
// straight into daemon.log) but routed through cfg.Logger as
// structured, tagged Warn entries.
func TestPool_RoutesWorkerStderrThroughLogger(t *testing.T) {
core, obs := observer.New(zapcore.DebugLevel)
logger := zap.New(core)
p := newTestPool(t, "stderr", Config{Workers: 1, Logger: logger})
res := p.Submit("main.mock", "mock", []byte("x"))
require.False(t, res.Bad())
deadline := time.Now().Add(3 * time.Second)
for obs.Len() < 2 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
var lines []string
for _, e := range obs.All() {
require.Equal(t, "subprocess stderr", e.Message)
require.Equal(t, "crashpool worker", e.ContextMap()["tag"])
line, _ := e.ContextMap()["line"].(string)
lines = append(lines, line)
}
require.Contains(t, lines, "worker stderr line 1")
require.Contains(t, lines, "worker stderr line 2")
}