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
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:
@@ -0,0 +1,78 @@
|
||||
// Package crashpool runs tree-sitter extraction inside isolated worker
|
||||
// subprocesses so a grammar SIGSEGV / OOM / hang on one pathological
|
||||
// file cannot abort the whole index pass.
|
||||
//
|
||||
// A worker that dies mid-parse is detected by the parent as a broken
|
||||
// pipe; the in-flight file is quarantined and a fresh worker is spawned
|
||||
// to drain the rest of the queue. A worker that merely hangs is killed
|
||||
// on a per-request deadline and treated the same way. A recovered Go
|
||||
// panic in an extractor comes back as an ordinary error response and
|
||||
// the worker stays alive.
|
||||
//
|
||||
// Quarantined files are persisted (Quarantine) so a file that crashes
|
||||
// the parser survives daemon restarts and is skipped until its content
|
||||
// changes — at which point it gets exactly one retry.
|
||||
package crashpool
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Node.Meta / Edge.Meta carry these concrete types inside their
|
||||
// interface values; gob needs every concrete type registered to
|
||||
// encode an interface. Mirrors the snapshot codec registrations in
|
||||
// internal/persistence.
|
||||
gob.Register(map[string]any{})
|
||||
gob.Register([]any{})
|
||||
gob.Register([]string{})
|
||||
gob.Register([]int{})
|
||||
gob.Register([]map[string]string{})
|
||||
gob.Register([]map[string]any{})
|
||||
}
|
||||
|
||||
// extractRequest is one unit of parse work sent parent → worker.
|
||||
type extractRequest struct {
|
||||
Seq uint64
|
||||
RelPath string
|
||||
Language string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// extractResponse is the worker → parent reply for one request.
|
||||
type extractResponse struct {
|
||||
Seq uint64
|
||||
Nodes []*graph.Node
|
||||
Edges []*graph.Edge
|
||||
ParseErrors int
|
||||
HasParseErr bool
|
||||
// Err is non-empty when the extractor returned an error or
|
||||
// panicked. Panicked distinguishes a recovered Go panic (the
|
||||
// worker survives) from a plain error return.
|
||||
Err string
|
||||
Panicked bool
|
||||
}
|
||||
|
||||
// Result is the parent-side outcome of one Submit call.
|
||||
type Result struct {
|
||||
Nodes []*graph.Node
|
||||
Edges []*graph.Edge
|
||||
ParseErrors int
|
||||
HasParseErr bool
|
||||
// Crashed is true when the worker subprocess died or hung
|
||||
// (SIGSEGV / OOM / kill / deadline). The file should be
|
||||
// quarantined and skipped.
|
||||
Crashed bool
|
||||
// Panicked is true when an extractor panicked but was recovered:
|
||||
// the file is bad but the worker survived.
|
||||
Panicked bool
|
||||
// Err carries the failure detail for Crashed / Panicked / error
|
||||
// responses.
|
||||
Err string
|
||||
}
|
||||
|
||||
// Bad reports whether the file failed to parse (crash, hang, panic, or
|
||||
// extractor error) and should be quarantined.
|
||||
func (r Result) Bad() bool { return r.Crashed || r.Panicked || r.Err != "" }
|
||||
@@ -0,0 +1,276 @@
|
||||
package crashpool
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/zzet/gortex/internal/procio"
|
||||
)
|
||||
|
||||
// defaultRequestTimeout bounds one parse round-trip. A worker that
|
||||
// exceeds it is presumed hung (a non-crashing pathological file) and is
|
||||
// killed and respawned, the same as a crash. Generous: the in-process
|
||||
// parse budget is 5s, and a cold worker also pays registry build time.
|
||||
const defaultRequestTimeout = 45 * time.Second
|
||||
|
||||
// Config configures a Pool.
|
||||
type Config struct {
|
||||
// Argv is the command used to spawn one worker subprocess. In
|
||||
// production this is {gortexBinary, "__parse-worker"}.
|
||||
Argv []string
|
||||
// Env is appended to the inherited environment of every worker.
|
||||
Env []string
|
||||
// Workers is the number of worker subprocesses. Clamped to >= 1.
|
||||
Workers int
|
||||
// RequestTimeout bounds one parse round-trip; 0 uses
|
||||
// defaultRequestTimeout.
|
||||
RequestTimeout time.Duration
|
||||
// Logger receives crash / respawn diagnostics. May be nil.
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// Pool manages a fixed set of parser worker subprocesses and dispatches
|
||||
// extraction work to them. It is safe for concurrent use: Submit may be
|
||||
// called from many goroutines at once.
|
||||
type Pool struct {
|
||||
cfg Config
|
||||
free chan *procWorker
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
seq atomic.Uint64
|
||||
|
||||
spawns atomic.Int64 // worker spawns incl. respawns — telemetry
|
||||
crashes atomic.Int64 // worker deaths detected by Submit
|
||||
}
|
||||
|
||||
// procWorker wraps one worker subprocess and its gob pipes.
|
||||
type procWorker struct {
|
||||
cmd *exec.Cmd
|
||||
enc *gob.Encoder
|
||||
dec *gob.Decoder
|
||||
stdin io.Closer
|
||||
}
|
||||
|
||||
// NewPool spawns cfg.Workers worker subprocesses and returns a ready
|
||||
// Pool. If no worker can be spawned it returns an error and leaks
|
||||
// nothing.
|
||||
func NewPool(cfg Config) (*Pool, error) {
|
||||
if cfg.Workers < 1 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
if len(cfg.Argv) == 0 {
|
||||
return nil, errors.New("crashpool: empty worker argv")
|
||||
}
|
||||
p := &Pool{cfg: cfg, free: make(chan *procWorker, cfg.Workers)}
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
w, err := p.spawn()
|
||||
if err != nil {
|
||||
p.Close()
|
||||
return nil, fmt.Errorf("crashpool: spawn worker: %w", err)
|
||||
}
|
||||
p.free <- w
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Workers returns the configured worker count.
|
||||
func (p *Pool) Workers() int { return p.cfg.Workers }
|
||||
|
||||
// reqTimeout is the effective per-request deadline.
|
||||
func (p *Pool) reqTimeout() time.Duration {
|
||||
if p.cfg.RequestTimeout > 0 {
|
||||
return p.cfg.RequestTimeout
|
||||
}
|
||||
return defaultRequestTimeout
|
||||
}
|
||||
|
||||
// Stats returns cumulative telemetry: total worker spawns (initial +
|
||||
// respawns) and total worker deaths detected.
|
||||
func (p *Pool) Stats() (spawns, crashes int64) {
|
||||
return p.spawns.Load(), p.crashes.Load()
|
||||
}
|
||||
|
||||
// spawn starts one worker subprocess. Its stderr is not inherited: a
|
||||
// scanner goroutine routes it through the pool's Logger as structured,
|
||||
// rate-limited Warn entries instead of raw text landing wherever the
|
||||
// daemon's own stderr is wired to.
|
||||
func (p *Pool) spawn() (*procWorker, error) {
|
||||
p.spawns.Add(1)
|
||||
cmd := exec.Command(p.cfg.Argv[0], p.cfg.Argv[1:]...) //nolint:gosec // argv is internal, not user-derived
|
||||
if len(p.cfg.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), p.cfg.Env...)
|
||||
}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = stdin.Close()
|
||||
return nil, err
|
||||
}
|
||||
procio.StderrWatcher{Logger: p.cfg.Logger, Tag: "crashpool worker"}.Watch(stderr)
|
||||
return &procWorker{
|
||||
cmd: cmd,
|
||||
enc: gob.NewEncoder(stdin),
|
||||
dec: gob.NewDecoder(stdout),
|
||||
stdin: stdin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// kill terminates the worker and reaps it so no zombie is left.
|
||||
func (w *procWorker) kill() {
|
||||
if w == nil || w.cmd == nil {
|
||||
return
|
||||
}
|
||||
_ = w.stdin.Close()
|
||||
if w.cmd.Process != nil {
|
||||
_ = w.cmd.Process.Kill()
|
||||
}
|
||||
_ = w.cmd.Wait()
|
||||
}
|
||||
|
||||
// roundTrip sends one request and decodes the matching response. Any
|
||||
// pipe error means the worker died.
|
||||
func (w *procWorker) roundTrip(req *extractRequest, resp *extractResponse) error {
|
||||
if err := w.enc.Encode(req); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.dec.Decode(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Seq != req.Seq {
|
||||
return fmt.Errorf("crashpool: response seq %d != request seq %d", resp.Seq, req.Seq)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit extracts one file in a worker subprocess. It blocks until a
|
||||
// worker is free, then runs the round-trip under requestTimeout. A
|
||||
// crashed or hung worker is killed, replaced, and reported via
|
||||
// Result.Crashed; the pool stays at full strength so the caller can
|
||||
// keep submitting.
|
||||
func (p *Pool) Submit(relPath, language string, content []byte) Result {
|
||||
p.mu.Lock()
|
||||
closed := p.closed
|
||||
p.mu.Unlock()
|
||||
if closed {
|
||||
return Result{Err: "crashpool: pool is closed"}
|
||||
}
|
||||
|
||||
w, ok := <-p.free
|
||||
if !ok {
|
||||
return Result{Err: "crashpool: pool is closed"}
|
||||
}
|
||||
|
||||
req := extractRequest{
|
||||
Seq: p.seq.Add(1),
|
||||
RelPath: relPath,
|
||||
Language: language,
|
||||
Content: content,
|
||||
}
|
||||
var resp extractResponse
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- w.roundTrip(&req, &resp) }()
|
||||
|
||||
timeout := p.reqTimeout()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return p.replace(w, "parser worker crashed: "+err.Error())
|
||||
}
|
||||
p.free <- w
|
||||
if resp.Panicked {
|
||||
return Result{Panicked: true, Err: resp.Err}
|
||||
}
|
||||
return Result{
|
||||
Nodes: resp.Nodes,
|
||||
Edges: resp.Edges,
|
||||
ParseErrors: resp.ParseErrors,
|
||||
HasParseErr: resp.HasParseErr,
|
||||
Err: resp.Err,
|
||||
}
|
||||
case <-timer.C:
|
||||
// Worker hung. Killing it unblocks the roundTrip goroutine
|
||||
// (its Decode errors out and drains into done).
|
||||
return p.replace(w, fmt.Sprintf("parser worker timed out after %s on %s", timeout, relPath))
|
||||
}
|
||||
}
|
||||
|
||||
// replace kills a dead/hung worker, spawns a replacement, returns it to
|
||||
// the free pool, and reports the crash. The free channel always keeps
|
||||
// exactly Workers entries so Submit never deadlocks: on respawn failure
|
||||
// the dead worker is returned and the spawn is retried on its next use.
|
||||
func (p *Pool) replace(dead *procWorker, reason string) Result {
|
||||
p.crashes.Add(1)
|
||||
dead.kill()
|
||||
if p.cfg.Logger != nil {
|
||||
p.cfg.Logger.Warn("crashpool: worker died, respawning", zap.String("reason", reason))
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
closed := p.closed
|
||||
p.mu.Unlock()
|
||||
if closed {
|
||||
// Don't respawn into a closed pool; the free slot is gone but
|
||||
// no further Submit will read it.
|
||||
return Result{Crashed: true, Err: reason}
|
||||
}
|
||||
|
||||
fresh, err := p.spawn()
|
||||
if err != nil {
|
||||
if p.cfg.Logger != nil {
|
||||
p.cfg.Logger.Error("crashpool: respawn failed; reusing dead slot", zap.Error(err))
|
||||
}
|
||||
// Return the dead worker so the channel stays balanced; its
|
||||
// next roundTrip fails fast and triggers another respawn.
|
||||
p.free <- dead
|
||||
return Result{Crashed: true, Err: reason + " (respawn failed: " + err.Error() + ")"}
|
||||
}
|
||||
p.free <- fresh
|
||||
return Result{Crashed: true, Err: reason}
|
||||
}
|
||||
|
||||
// Close terminates every worker subprocess. It is idempotent.
|
||||
func (p *Pool) Close() {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
p.closed = true
|
||||
p.mu.Unlock()
|
||||
|
||||
// Drain every worker currently in the free channel. Workers
|
||||
// checked out by an in-flight Submit are returned to free after
|
||||
// the round-trip and reaped by the OS at process exit; in normal
|
||||
// use Close runs after all Submit callers have finished.
|
||||
for i := 0; i < p.cfg.Workers; i++ {
|
||||
select {
|
||||
case w := <-p.free:
|
||||
w.kill()
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package crashpool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QuarantineEntry records one file that crashed, hung, or panicked the
|
||||
// parser.
|
||||
type QuarantineEntry struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Reason string `json:"reason"`
|
||||
// MtimeNano pins the file revision that failed. When the file's
|
||||
// current mtime differs the file is treated as changed and gets
|
||||
// one retry instead of being skipped.
|
||||
MtimeNano int64 `json:"mtime_nano"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
Attempts int `json:"attempts"`
|
||||
}
|
||||
|
||||
// Quarantine is a persistent set of files that have crashed parsing. It
|
||||
// is JSON-backed so a file that SIGSEGVs the parser survives daemon
|
||||
// restarts and is skipped until its content changes.
|
||||
//
|
||||
// The zero value is not usable; construct with LoadQuarantine. A nil
|
||||
// *Quarantine is safe for every method (no-op / not-quarantined), so
|
||||
// callers can keep crash isolation optional without nil checks.
|
||||
type Quarantine struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
entries map[string]*QuarantineEntry
|
||||
}
|
||||
|
||||
// LoadQuarantine reads the quarantine file at path, tolerating a
|
||||
// missing or corrupt file (treated as empty). path is where Save will
|
||||
// persist; an empty path makes Save a no-op (in-memory only).
|
||||
func LoadQuarantine(path string) *Quarantine {
|
||||
q := &Quarantine{path: path, entries: map[string]*QuarantineEntry{}}
|
||||
if path == "" {
|
||||
return q
|
||||
}
|
||||
data, err := os.ReadFile(path) //nolint:gosec // path is daemon-internal
|
||||
if err != nil {
|
||||
return q
|
||||
}
|
||||
var list []*QuarantineEntry
|
||||
if json.Unmarshal(data, &list) != nil {
|
||||
return q
|
||||
}
|
||||
for _, e := range list {
|
||||
if e != nil && e.RelPath != "" {
|
||||
q.entries[e.RelPath] = e
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// IsQuarantined reports whether relPath is a known-bad file at the
|
||||
// given mtime. It returns true only when the file is unchanged since
|
||||
// it failed — a changed file (different mtime) is not quarantined so
|
||||
// it gets retried.
|
||||
func (q *Quarantine) IsQuarantined(relPath string, mtimeNano int64) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
e, ok := q.entries[relPath]
|
||||
return ok && e.MtimeNano == mtimeNano
|
||||
}
|
||||
|
||||
// Add records relPath as quarantined. Re-adding bumps the attempt
|
||||
// count and refreshes the mtime + reason.
|
||||
func (q *Quarantine) Add(relPath, reason string, mtimeNano int64) {
|
||||
if q == nil || relPath == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if e, ok := q.entries[relPath]; ok {
|
||||
e.Reason = reason
|
||||
e.MtimeNano = mtimeNano
|
||||
e.LastSeen = now
|
||||
e.Attempts++
|
||||
return
|
||||
}
|
||||
q.entries[relPath] = &QuarantineEntry{
|
||||
RelPath: relPath,
|
||||
Reason: reason,
|
||||
MtimeNano: mtimeNano,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
Attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Forget drops relPath from the quarantine — used when a previously
|
||||
// bad file parses cleanly on retry.
|
||||
func (q *Quarantine) Forget(relPath string) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
delete(q.entries, relPath)
|
||||
}
|
||||
|
||||
// Entries returns a stable, sorted snapshot of the quarantine.
|
||||
func (q *Quarantine) Entries() []QuarantineEntry {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
out := make([]QuarantineEntry, 0, len(q.entries))
|
||||
for _, e := range q.entries {
|
||||
out = append(out, *e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].RelPath < out[j].RelPath })
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of quarantined files.
|
||||
func (q *Quarantine) Len() int {
|
||||
if q == nil {
|
||||
return 0
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.entries)
|
||||
}
|
||||
|
||||
// Save persists the quarantine to its backing file. A no-op when the
|
||||
// quarantine was loaded with an empty path.
|
||||
func (q *Quarantine) Save() error {
|
||||
if q == nil || q.path == "" {
|
||||
return nil
|
||||
}
|
||||
entries := q.Entries()
|
||||
data, err := json.MarshalIndent(entries, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(q.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Unique temp name so concurrent Saves — the indexer re-indexes
|
||||
// files in parallel through one shared quarantine — never clobber
|
||||
// each other's in-flight write before the rename.
|
||||
f, err := os.CreateTemp(filepath.Dir(q.path), ".quarantine-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, q.path)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package crashpool
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQuarantine_LoadMissingFile(t *testing.T) {
|
||||
q := LoadQuarantine(filepath.Join(t.TempDir(), "nope.json"))
|
||||
require.Equal(t, 0, q.Len())
|
||||
require.False(t, q.IsQuarantined("a.go", 1))
|
||||
}
|
||||
|
||||
func TestQuarantine_AddAndCheck(t *testing.T) {
|
||||
q := LoadQuarantine("")
|
||||
q.Add("bad.go", "SIGSEGV", 100)
|
||||
|
||||
// Same revision → quarantined (skip on retry).
|
||||
require.True(t, q.IsQuarantined("bad.go", 100))
|
||||
// Changed revision → not quarantined (gets one retry).
|
||||
require.False(t, q.IsQuarantined("bad.go", 200))
|
||||
// Unknown file → never quarantined.
|
||||
require.False(t, q.IsQuarantined("good.go", 100))
|
||||
}
|
||||
|
||||
func TestQuarantine_ReAddBumpsAttempts(t *testing.T) {
|
||||
q := LoadQuarantine("")
|
||||
q.Add("bad.go", "crash", 100)
|
||||
q.Add("bad.go", "crash again", 200)
|
||||
|
||||
entries := q.Entries()
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, 2, entries[0].Attempts)
|
||||
require.Equal(t, int64(200), entries[0].MtimeNano)
|
||||
require.Equal(t, "crash again", entries[0].Reason)
|
||||
}
|
||||
|
||||
func TestQuarantine_Forget(t *testing.T) {
|
||||
q := LoadQuarantine("")
|
||||
q.Add("bad.go", "crash", 100)
|
||||
require.True(t, q.IsQuarantined("bad.go", 100))
|
||||
q.Forget("bad.go")
|
||||
require.False(t, q.IsQuarantined("bad.go", 100))
|
||||
require.Equal(t, 0, q.Len())
|
||||
}
|
||||
|
||||
func TestQuarantine_SaveReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "quarantine.json")
|
||||
q := LoadQuarantine(path)
|
||||
q.Add("a.go", "boom", 1)
|
||||
q.Add("b.go", "hang", 2)
|
||||
require.NoError(t, q.Save())
|
||||
|
||||
reloaded := LoadQuarantine(path)
|
||||
require.Equal(t, 2, reloaded.Len())
|
||||
require.True(t, reloaded.IsQuarantined("a.go", 1))
|
||||
require.True(t, reloaded.IsQuarantined("b.go", 2))
|
||||
|
||||
entries := reloaded.Entries()
|
||||
require.Equal(t, "a.go", entries[0].RelPath) // sorted
|
||||
require.Equal(t, "b.go", entries[1].RelPath)
|
||||
}
|
||||
|
||||
func TestQuarantine_NilSafe(t *testing.T) {
|
||||
var q *Quarantine
|
||||
require.False(t, q.IsQuarantined("x", 1))
|
||||
require.Equal(t, 0, q.Len())
|
||||
require.Nil(t, q.Entries())
|
||||
q.Add("x", "y", 1) // no panic
|
||||
q.Forget("x") // no panic
|
||||
require.NoError(t, q.Save())
|
||||
}
|
||||
|
||||
func TestQuarantine_EmptyPathSaveNoop(t *testing.T) {
|
||||
q := LoadQuarantine("")
|
||||
q.Add("a.go", "x", 1)
|
||||
require.NoError(t, q.Save()) // no file, no error
|
||||
}
|
||||
|
||||
func TestQuarantine_CorruptFileTolerated(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "corrupt.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte("{not json at all"), 0o644))
|
||||
q := LoadQuarantine(path)
|
||||
require.Equal(t, 0, q.Len()) // corrupt → treated as empty
|
||||
}
|
||||
|
||||
// TestQuarantine_ConcurrentSave runs many Saves at once: a shared
|
||||
// quarantine is saved per indexed file across parallel re-indexes, so
|
||||
// the unique temp-file name must keep concurrent writes from
|
||||
// clobbering each other and leave a final file that decodes cleanly.
|
||||
func TestQuarantine_ConcurrentSave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "q.json")
|
||||
q := LoadQuarantine(path)
|
||||
q.Add("bad.go", "SIGSEGV", 123)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 16 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
require.NoError(t, q.Save())
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
reloaded := LoadQuarantine(path)
|
||||
require.Equal(t, 1, reloaded.Len())
|
||||
require.True(t, reloaded.IsQuarantined("bad.go", 123))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package crashpool
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/parser"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
)
|
||||
|
||||
// RunWorker is the subprocess entry point. It serves extract requests
|
||||
// decoded from r and writes gob-encoded responses to w until r reaches
|
||||
// EOF, then returns nil.
|
||||
//
|
||||
// It is invoked by the hidden `gortex __parse-worker` subcommand. The
|
||||
// parent (Pool) owns the lifecycle: a worker is expected to run until
|
||||
// its stdin is closed or it is killed.
|
||||
func RunWorker(r io.Reader, w io.Writer) error {
|
||||
reg := parser.NewRegistry()
|
||||
languages.RegisterAll(reg)
|
||||
registerWorkerGrammars(reg)
|
||||
registerWorkerExtractorPlugins(reg)
|
||||
registerWorkerFallbackChunkers(reg)
|
||||
return serveWorker(reg, r, w)
|
||||
}
|
||||
|
||||
// registerWorkerGrammars loads the custom tree-sitter grammars the
|
||||
// parent passed through the GORTEX_CUSTOM_GRAMMARS environment
|
||||
// variable — a JSON array of config.GrammarSpec — so a crash-isolated
|
||||
// worker resolves the same languages as the parent process. A malformed
|
||||
// or absent value is silently ignored: the worker simply has no custom
|
||||
// grammars, exactly as before this channel existed.
|
||||
func registerWorkerGrammars(reg *parser.Registry) {
|
||||
raw := os.Getenv("GORTEX_CUSTOM_GRAMMARS")
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
var specs []config.GrammarSpec
|
||||
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
|
||||
return
|
||||
}
|
||||
languages.RegisterCustomGrammars(reg, specs, nil)
|
||||
}
|
||||
|
||||
// registerWorkerExtractorPlugins loads the subprocess extractor plugins
|
||||
// the parent passed through the GORTEX_EXTRACTOR_PLUGINS environment
|
||||
// variable — a JSON array of config.ExtractorPluginSpec — so a
|
||||
// crash-isolated worker resolves the same plugin languages as the
|
||||
// parent. A malformed or absent value is silently ignored.
|
||||
func registerWorkerExtractorPlugins(reg *parser.Registry) {
|
||||
raw := os.Getenv("GORTEX_EXTRACTOR_PLUGINS")
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
var specs []config.ExtractorPluginSpec
|
||||
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
|
||||
return
|
||||
}
|
||||
languages.RegisterExtractorPlugins(reg, specs, nil)
|
||||
}
|
||||
|
||||
// registerWorkerFallbackChunkers loads the regex fallback chunkers the
|
||||
// parent passed through the GORTEX_FALLBACK_CHUNKERS environment
|
||||
// variable — a JSON array of config.FallbackChunkerSpec — so a
|
||||
// crash-isolated worker resolves the same grammar-less languages as the
|
||||
// parent. A malformed or absent value is silently ignored.
|
||||
func registerWorkerFallbackChunkers(reg *parser.Registry) {
|
||||
raw := os.Getenv("GORTEX_FALLBACK_CHUNKERS")
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
var specs []config.FallbackChunkerSpec
|
||||
if err := json.Unmarshal([]byte(raw), &specs); err != nil {
|
||||
return
|
||||
}
|
||||
languages.RegisterFallbackChunkers(reg, specs, nil)
|
||||
}
|
||||
|
||||
// serveWorker is the decode/extract/encode loop, factored out of
|
||||
// RunWorker so tests can drive it with an in-memory registry.
|
||||
func serveWorker(reg *parser.Registry, r io.Reader, w io.Writer) error {
|
||||
dec := gob.NewDecoder(r)
|
||||
enc := gob.NewEncoder(w)
|
||||
for {
|
||||
var req extractRequest
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resp := serveOne(reg, req)
|
||||
if err := enc.Encode(&resp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveOne runs one extraction under panic recovery. A Go panic in an
|
||||
// extractor (a bug, deep recursion, an out-of-range index) is converted
|
||||
// to an error response so the parent can quarantine the file without
|
||||
// losing the worker. A hard fault (SIGSEGV in the C grammar, OOM kill)
|
||||
// is not recoverable here — it kills the process, and the parent
|
||||
// detects it as a pipe EOF.
|
||||
func serveOne(reg *parser.Registry, req extractRequest) (resp extractResponse) {
|
||||
resp.Seq = req.Seq
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
resp.Nodes = nil
|
||||
resp.Edges = nil
|
||||
resp.Err = fmt.Sprintf("extractor panic: %v\n%s", rec, debug.Stack())
|
||||
resp.Panicked = true
|
||||
}
|
||||
}()
|
||||
|
||||
ext, ok := reg.GetByLanguage(req.Language)
|
||||
if !ok || ext == nil {
|
||||
resp.Err = "crashpool: no extractor for language " + req.Language
|
||||
return resp
|
||||
}
|
||||
result, err := ext.Extract(req.RelPath, req.Content)
|
||||
if err != nil {
|
||||
resp.Err = err.Error()
|
||||
return resp
|
||||
}
|
||||
if result != nil {
|
||||
resp.Nodes = result.Nodes
|
||||
resp.Edges = result.Edges
|
||||
if result.Tree != nil {
|
||||
if result.Tree.HasParseErrors() {
|
||||
resp.HasParseErr = true
|
||||
resp.ParseErrors = result.Tree.CountParseErrors()
|
||||
}
|
||||
result.Tree.Release()
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
Reference in New Issue
Block a user