From 76470e5a8d13d2386281a47bae350c518d5c5e5d Mon Sep 17 00:00:00 2001 From: Armand Thuillart Date: Fri, 19 Jun 2026 09:29:43 +0200 Subject: [PATCH] fix(linter): cap checker pool and workers to 4, add panic recovery per-file Cap checker pool and file-processing workers to 4 (min of configured value and runtime.GOMAXPROCS(0)). This prevents excessive goroutine count and reduces GC stack scanner pressure during concurrent TypeScript type checking, which was observed causing a stuck runtime.(*unwinder).next spinning at 100% CPU. Wrap per-file processing in the linter with a deferred panic recovery that logs the file name and rule name, then clears listener state so a panic in one file does not crash the entire lint run. --- internal/linter/linter.go | 153 ++++++++++-------- ...-project-service-for-single-run-mode.patch | 2 +- 2 files changed, 89 insertions(+), 66 deletions(-) diff --git a/internal/linter/linter.go b/internal/linter/linter.go index 647f50c7..e895f41b 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -462,7 +462,8 @@ func RunLinterOnProgram(options RunLinterOnProgramOptions) error { logLevel := options.LogLevel program := options.Program files := options.Files - workers := options.Workers + // Cap workers to prevent excessive goroutine count and GC pressure + workers := min(options.Workers, 4) getRulesForFile := options.GetRulesForFile onDiagnostic := options.OnDiagnostic onInternalDiagnostic := options.OnInternalDiagnostic @@ -501,38 +502,49 @@ func RunLinterOnProgram(options RunLinterOnProgramOptions) error { ctx.TypeChecker = w.checker for file := range w.queue { - if logLevel == utils.LogLevelDebug { - log.Print(file.FileName()) - } - ctxBuilder.file = file - ctx.SourceFile = file + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("panic in rule %q while processing %s: %v", ctxBuilder.ruleName, file.FileName(), r) + for k := range registeredListeners { + registeredListeners[k] = registeredListeners[k][:0] + } + } + }() - rules := getRulesForFile(file) - for _, r := range rules { - ctxBuilder.ruleName = r.Name - for kind, listener := range r.Run(ctx) { - listeners, ok := registeredListeners[kind] - if !ok { - listeners = make([]taggedListener, 0, len(rules)) + if logLevel == utils.LogLevelDebug { + log.Print(file.FileName()) + } + ctxBuilder.file = file + ctx.SourceFile = file + + rules := getRulesForFile(file) + for _, r := range rules { + ctxBuilder.ruleName = r.Name + for kind, listener := range r.Run(ctx) { + listeners, ok := registeredListeners[kind] + if !ok { + listeners = make([]taggedListener, 0, len(rules)) + } + registeredListeners[kind] = append(listeners, taggedListener{ruleName: r.Name, fn: listener}) } - registeredListeners[kind] = append(listeners, taggedListener{ruleName: r.Name, fn: listener}) } - } - runListeners := func(kind ast.Kind, node *ast.Node) { - if listeners, ok := registeredListeners[kind]; ok { - for _, listener := range listeners { - ctxBuilder.ruleName = listener.ruleName - listener.fn(node) + runListeners := func(kind ast.Kind, node *ast.Node) { + if listeners, ok := registeredListeners[kind]; ok { + for _, listener := range listeners { + ctxBuilder.ruleName = listener.ruleName + listener.fn(node) + } } } - } - visitLintNodes(file, runListeners) - // Instead of clearing the map, we clear the slices in-place to avoid re-allocating memory for the listeners on each file. - for k := range registeredListeners { - registeredListeners[k] = registeredListeners[k][:0] - } + visitLintNodes(file, runListeners) + // Instead of clearing the map, we clear the slices in-place to avoid re-allocating memory for the listeners on each file. + for k := range registeredListeners { + registeredListeners[k] = registeredListeners[k][:0] + } + }() } } @@ -559,52 +571,63 @@ func RunLinterOnProgram(options RunLinterOnProgramOptions) error { ctx.TypeChecker = w.checker for file := range w.queue { - if logLevel == utils.LogLevelDebug { - log.Print(file.FileName()) - } - ctxBuilder.file = file - ctx.SourceFile = file - - rules := getRulesForFile(file) - timingStats := make([]RuleTimingStat, len(rules)) - for ruleIdx, r := range rules { - ctxBuilder.ruleName = r.Name - start := time.Now() - listenersByKind := r.Run(ctx) - recordTiming(&timingStats[ruleIdx], time.Since(start)) - for kind, listener := range listenersByKind { - listeners, ok := registeredListeners[kind] - if !ok { - listeners = make([]timedTaggedListener, 0, len(rules)) + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("panic in rule %q while processing %s: %v", ctxBuilder.ruleName, file.FileName(), r) + for k := range registeredListeners { + registeredListeners[k] = registeredListeners[k][:0] + } + } + }() + + if logLevel == utils.LogLevelDebug { + log.Print(file.FileName()) + } + ctxBuilder.file = file + ctx.SourceFile = file + + rules := getRulesForFile(file) + timingStats := make([]RuleTimingStat, len(rules)) + for ruleIdx, r := range rules { + ctxBuilder.ruleName = r.Name + start := time.Now() + listenersByKind := r.Run(ctx) + recordTiming(&timingStats[ruleIdx], time.Since(start)) + for kind, listener := range listenersByKind { + listeners, ok := registeredListeners[kind] + if !ok { + listeners = make([]timedTaggedListener, 0, len(rules)) + } + registeredListeners[kind] = append(listeners, timedTaggedListener{ruleName: r.Name, ruleIdx: ruleIdx, fn: listener}) } - registeredListeners[kind] = append(listeners, timedTaggedListener{ruleName: r.Name, ruleIdx: ruleIdx, fn: listener}) } - } - runListeners := func(kind ast.Kind, node *ast.Node) { - if listeners, ok := registeredListeners[kind]; ok { - for _, listener := range listeners { - ctxBuilder.ruleName = listener.ruleName - start := time.Now() - listener.fn(node) - recordTiming(&timingStats[listener.ruleIdx], time.Since(start)) + runListeners := func(kind ast.Kind, node *ast.Node) { + if listeners, ok := registeredListeners[kind]; ok { + for _, listener := range listeners { + ctxBuilder.ruleName = listener.ruleName + start := time.Now() + listener.fn(node) + recordTiming(&timingStats[listener.ruleIdx], time.Since(start)) + } } } - } - visitLintNodes(file, runListeners) - for idx, stat := range timingStats { - if stat.Calls == 0 { - continue + visitLintNodes(file, runListeners) + for idx, stat := range timingStats { + if stat.Calls == 0 { + continue + } + merged := localTimings[rules[idx].Name] + merged.add(stat) + localTimings[rules[idx].Name] = merged } - merged := localTimings[rules[idx].Name] - merged.add(stat) - localTimings[rules[idx].Name] = merged - } - // Instead of clearing the map, we clear the slices in-place to avoid re-allocating memory for the listeners on each file. - for k := range registeredListeners { - registeredListeners[k] = registeredListeners[k][:0] - } + // Instead of clearing the map, we clear the slices in-place to avoid re-allocating memory for the listeners on each file. + for k := range registeredListeners { + registeredListeners[k] = registeredListeners[k][:0] + } + }() } } diff --git a/patches/0001-Adapt-project-service-for-single-run-mode.patch b/patches/0001-Adapt-project-service-for-single-run-mode.patch index ef5ac7bd..fa13eb11 100644 --- a/patches/0001-Adapt-project-service-for-single-run-mode.patch +++ b/patches/0001-Adapt-project-service-for-single-run-mode.patch @@ -24,7 +24,7 @@ index 37abaff97..19dac4dfc 100644 // stored in the old program's options. createCheckerPool := func(program *compiler.Program) compiler.CheckerPool { - checkerPool = newCheckerPool(4, program, nil) -+ checkerPool = newCheckerPool(runtime.GOMAXPROCS(0), program, p.log) ++ checkerPool = newCheckerPool(min(4, runtime.GOMAXPROCS(0)), program, p.log) return checkerPool }