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
+78
View File
@@ -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 != "" }
+276
View File
@@ -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:
}
}
}
+268
View File
@@ -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")
}
+171
View File
@@ -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))
}
+143
View File
@@ -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
}