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

324 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package indexer
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/parser/crashpool"
)
// crashIsolationEnabled reports whether tree-sitter extraction should
// run in isolated worker subprocesses. GORTEX_PARSER_ISOLATION
// overrides the index.crash_isolation config key.
func (idx *Indexer) crashIsolationEnabled() bool {
if v := os.Getenv("GORTEX_PARSER_ISOLATION"); v != "" {
return v == "1" || strings.EqualFold(v, "true")
}
return idx.config.CrashIsolation
}
// newParsePool spawns a crash-isolated parser pool — `workers` worker
// subprocesses, each a `gortex __parse-worker` instance. When a
// per-file extraction budget is configured it also bounds the worker
// round-trip so a hung file is killed on the same deadline.
func (idx *Indexer) newParsePool(workers int) (*crashpool.Pool, error) {
exe, err := os.Executable()
if err != nil {
return nil, err
}
cfg := crashpool.Config{
Argv: []string{exe, "__parse-worker"},
Workers: workers,
Logger: idx.logger,
}
if ms := idx.config.MaxExtractMillis; ms > 0 {
cfg.RequestTimeout = time.Duration(ms) * time.Millisecond
}
if env := customGrammarEnv(idx.config.Grammars); env != "" {
cfg.Env = append(cfg.Env, env)
}
if env := extractorPluginEnv(idx.config.ExtractorPlugins); env != "" {
cfg.Env = append(cfg.Env, env)
}
if env := fallbackChunkerEnv(idx.config.FallbackChunkers); env != "" {
cfg.Env = append(cfg.Env, env)
}
return crashpool.NewPool(cfg)
}
// customGrammarEnv encodes the configured custom grammars into the
// GORTEX_CUSTOM_GRAMMARS environment entry a crash-isolation worker
// reads at startup. Library paths are resolved to absolute so the
// worker — which may run with a different working directory — can
// still load them. Returns "" when no custom grammars are configured.
func customGrammarEnv(specs []config.GrammarSpec) string {
if len(specs) == 0 {
return ""
}
resolved := make([]config.GrammarSpec, len(specs))
for i, s := range specs {
resolved[i] = s
if s.Library != "" && !filepath.IsAbs(s.Library) {
if abs, err := filepath.Abs(s.Library); err == nil {
resolved[i].Library = abs
}
}
}
data, err := json.Marshal(resolved)
if err != nil {
return ""
}
return "GORTEX_CUSTOM_GRAMMARS=" + string(data)
}
// extractorPluginEnv encodes the configured subprocess extractor
// plugins into the GORTEX_EXTRACTOR_PLUGINS environment entry a
// crash-isolation worker reads at startup, so the worker resolves the
// same plugin languages as the parent. Returns "" when none are
// configured.
func extractorPluginEnv(specs []config.ExtractorPluginSpec) string {
if len(specs) == 0 {
return ""
}
data, err := json.Marshal(specs)
if err != nil {
return ""
}
return "GORTEX_EXTRACTOR_PLUGINS=" + string(data)
}
// fallbackChunkerEnv encodes the configured regex fallback chunkers into
// the GORTEX_FALLBACK_CHUNKERS environment entry a crash-isolation
// worker reads at startup, so the worker resolves the same grammar-less
// languages as the parent. Returns "" when none are configured.
func fallbackChunkerEnv(specs []config.FallbackChunkerSpec) string {
if len(specs) == 0 {
return ""
}
data, err := json.Marshal(specs)
if err != nil {
return ""
}
return "GORTEX_FALLBACK_CHUNKERS=" + string(data)
}
// sharedParsePool returns the long-lived crash-isolation pool and the
// quarantine, creating them on first use. Reusing one pool across
// single-file re-indexes avoids forking a worker subprocess per file
// on the watcher hot path — the dominant cost when crash isolation is
// on. Returns (nil, nil) if no worker can spawn, so the caller falls
// back to in-process extraction.
func (idx *Indexer) sharedParsePool() (*crashpool.Pool, *crashpool.Quarantine) {
idx.parsePoolMu.Lock()
defer idx.parsePoolMu.Unlock()
if idx.parsePool != nil {
return idx.parsePool, idx.parseQuar
}
// A long-lived pool keeps idle workers resident, so cap the worker
// count well below the bulk-index width.
workers := idx.config.Workers
if workers < 1 {
workers = 1
}
if workers > 4 {
workers = 4
}
p, err := idx.newParsePool(workers)
if err != nil {
idx.logger.Warn("indexer: crash-isolation pool unavailable; using in-process extraction",
zap.Error(err))
return nil, nil
}
idx.parsePool = p
idx.parseQuar = crashpool.LoadQuarantine(
filepath.Join(idx.rootPath, ".gortex", "parser-quarantine.json"))
return idx.parsePool, idx.parseQuar
}
// CloseParsePool tears down the long-lived crash-isolation pool if one
// was created. It is safe to call when none exists, and idempotent.
func (idx *Indexer) CloseParsePool() {
idx.parsePoolMu.Lock()
defer idx.parsePoolMu.Unlock()
if idx.parsePool != nil {
idx.parsePool.Close()
idx.parsePool = nil
}
}
// extractFile produces one file's graph contribution. With pool == nil
// it calls the extractor in-process (the default). With a pool the
const (
// extractBudgetBytesPerMs grants one extra millisecond of extraction
// budget per this many source bytes, on top of the base budget. A flat
// budget skips a legitimately large file (which parses proportionally
// slower) at the same threshold as a pathological small one; scaling by
// size keeps real files in while still tripping on genuine runaways.
extractBudgetBytesPerMs = 1024
// extractBudgetMaxMultiple caps the size-scaled budget at this multiple
// of the base so even a multi-megabyte file cannot stall the pass
// indefinitely.
extractBudgetMaxMultiple = 8
)
// effectiveExtractBudget scales the base per-file extraction budget (ms) by
// file size in bytes. base<=0 keeps the cap disabled (returns base unchanged),
// and an empty file gets exactly the base. Otherwise the budget grows linearly
// with byte count — one millisecond per extractBudgetBytesPerMs bytes — capped
// at extractBudgetMaxMultiple× the base so the worst case stays bounded.
func effectiveExtractBudget(base, fileBytes int) int {
if base <= 0 || fileBytes <= 0 {
return base
}
scaled := base + fileBytes/extractBudgetBytesPerMs
if capped := base * extractBudgetMaxMultiple; scaled > capped {
return capped
}
return scaled
}
// submitWithRetry runs submit once and, if the worker crashed, hung, or
// panicked, retries exactly once. A worker fault can be a worker poisoned by a
// prior parse rather than the file itself; the pool replaces the dead worker
// before returning, so the single retry runs on a clean worker. A file that is
// genuinely the fault fails again and is quarantined as before. A plain
// extraction error (Result.Err without a crash) is deterministic and is not
// retried.
func submitWithRetry(submit func() crashpool.Result) crashpool.Result {
res := submit()
if res.Crashed || res.Panicked {
res = submit()
}
return res
}
// parse runs in a worker subprocess: a crash, hang, or panic quarantines
// the file and yields a synthetic KindFile node carrying
// Meta["parse_error"] instead of aborting the whole index pass.
//
// The returned bool reports whether the file was skipped — quarantined
// after a crash, or skipped after blowing the extraction time budget.
// Callers MUST skip coverage + contract extraction for skipped files,
// since both re-parse the source and would re-trigger the fault.
func (idx *Indexer) extractFile(
pool *crashpool.Pool, q *crashpool.Quarantine,
path, relPath, lang string, ext parser.Extractor, src []byte,
) (result *parser.ExtractionResult, skipped bool, err error) {
// Bundled / minified build artifacts are synthetic source — a
// minified bundle or a sourcemap has no meaningful symbols and
// only pollutes the graph. Detect by content and skip with
// telemetry, unless the user opted into indexing them.
if !idx.config.IndexMinified {
if reason := minifiedArtifactReason(lang, src); reason != "" {
return minifiedSkipResult(relPath, lang, reason), true, nil
}
}
if pool == nil {
r, eerr := idx.extractWithTimeout(ext, relPath, src)
if errors.Is(eerr, errExtractTimeout) {
budget := effectiveExtractBudget(idx.config.MaxExtractMillis, len(src))
idx.logger.Warn("indexer: file extraction exceeded budget; skipped",
zap.String("file", relPath), zap.Int("budget_ms", budget))
return timeoutSkipResult(relPath, lang, budget), true,
fmt.Errorf("extraction exceeded %dms budget: %s", budget, relPath)
}
// An extractor panic was recovered in-process: isolate the file
// like the subprocess pool does — emit a quarantine node so it
// stays visible, record the error, and let indexing continue
// rather than crashing the whole run.
var pe *extractorPanicError
if errors.As(eerr, &pe) {
idx.logger.Warn("indexer: recovered extractor panic; file isolated",
zap.String("file", relPath), zap.String("reason", fmt.Sprint(pe.value)))
return quarantineResult(relPath, lang, "extractor panic: "+fmt.Sprint(pe.value)),
true, eerr
}
if eerr != nil {
return nil, false, eerr
}
stampParseErrors(r)
return r, false, nil
}
var mtime int64
if info, statErr := os.Stat(path); statErr == nil {
mtime = info.ModTime().UnixNano()
}
if q.IsQuarantined(relPath, mtime) {
return quarantineResult(relPath, lang, "skipped — file previously crashed the parser"),
true, fmt.Errorf("skipped quarantined file %s", relPath)
}
res := submitWithRetry(func() crashpool.Result { return pool.Submit(relPath, lang, src) })
switch {
case res.Crashed || res.Panicked:
q.Add(relPath, res.Err, mtime)
idx.logger.Warn("indexer: parser isolated a bad file",
zap.String("file", relPath), zap.String("reason", res.Err))
return quarantineResult(relPath, lang, res.Err), true,
fmt.Errorf("parser crash isolated on %s: %s", relPath, res.Err)
case res.Err != "":
return nil, false, errors.New(res.Err)
}
// Clean parse: if the file was quarantined under an older revision
// and has since been fixed, drop the stale entry.
q.Forget(relPath)
result = &parser.ExtractionResult{Nodes: res.Nodes, Edges: res.Edges}
if res.HasParseErr {
stampParseErrorCount(result.Nodes, res.ParseErrors)
}
return result, false, nil
}
// quarantineResult builds a synthetic single-node result for a file
// that could not be parsed, so the file stays visible in the graph
// with the failure reason attached.
func quarantineResult(relPath, lang, reason string) *parser.ExtractionResult {
return &parser.ExtractionResult{
Nodes: []*graph.Node{{
ID: relPath,
Kind: graph.KindFile,
Name: filepath.Base(relPath),
FilePath: relPath,
Language: lang,
Meta: map[string]any{
"skip_reason": "parse_panic",
"parse_error": reason,
"quarantined": true,
},
}},
}
}
// stampParseErrorCount stamps a known parse-error count onto a file
// node — the subprocess-path equivalent of stampParseErrors, which
// can't run in the parent because the parse tree never crosses the
// process boundary.
func stampParseErrorCount(nodes []*graph.Node, count int) {
if count <= 0 {
return
}
for _, n := range nodes {
if n.Kind != graph.KindFile {
continue
}
if n.Meta == nil {
n.Meta = map[string]any{}
}
n.Meta["parse_errors"] = count
n.Meta["has_parse_errors"] = true
return
}
}