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
269 lines
7.4 KiB
Go
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")
|
|
}
|