Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions internal/core/panic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package core

import (
"bytes"
"fmt"
"runtime/debug"
)

// PanicWithStack wraps a recovered panic value along with the original stack trace
// captured at the site of the panic. This allows re-panicking while preserving the
// original stack context, rather than losing it at the re-panic site.
type PanicWithStack struct {
Value any
Stack []byte
}

func (p *PanicWithStack) String() string {
return fmt.Sprintf("%v\n%s", p.Value, string(p.Stack))
}

// NewPanicWithStack creates a PanicWithStack from a recovered panic value.
// It captures the current stack trace via debug.Stack() and strips the
// recovery infrastructure frames (debug.Stack, the deferred recovery function,
// and the panic runtime frame) so that only the actual crash site frames remain.
func NewPanicWithStack(recovered any) *PanicWithStack {
return &PanicWithStack{
Value: recovered,
Stack: trimPanicRecoveryFrames(debug.Stack()),
}
}

// trimPanicRecoveryFrames strips recovery infrastructure frames from a stack
// trace captured by debug.Stack() inside a deferred recovery function.
// It removes everything up to and including the "panic(...)" frame and its
// file/line pair, leaving only the frames from the actual crash site onward.
func trimPanicRecoveryFrames(stack []byte) []byte {
// Find the panic() frame. In Go stack traces, it appears as a line
// starting with "panic(" (possibly with leading whitespace stripped).
lines := bytes.Split(stack, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmed, []byte("panic(")) {
// The panic frame is followed by its file/line pair on the next line.
// Skip both to get to the actual crash site.
startIdx := i + 2
if startIdx < len(lines) {
return bytes.Join(lines[startIdx:], []byte("\n"))
}
}
}
// If we couldn't find the panic frame, return the original stack.
return stack
}
44 changes: 44 additions & 0 deletions internal/core/panic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package core

import (
"testing"
)

func TestTrimPanicRecoveryFrames(t *testing.T) {
t.Parallel()

input := []byte(`goroutine 42 [running]:
runtime/debug.Stack()
runtime/debug/stack.go:26 +0x5e
github.com/microsoft/typescript-go/internal/ls.handleCrossProject[...].func1.1()
github.com/microsoft/typescript-go/internal/ls/crossproject.go:88 +0x70
panic({0xc323a0?, 0x1780b90?})
runtime/panic.go:783 +0x132
github.com/microsoft/typescript-go/internal/checker.(*Checker).checkExpression(0xc0045a8000)
github.com/microsoft/typescript-go/internal/checker/checker.go:5000 +0x1a0
github.com/microsoft/typescript-go/internal/ls.handleCrossProject[...].func1()
github.com/microsoft/typescript-go/internal/ls/crossproject.go:105 +0x150`)

expected := `github.com/microsoft/typescript-go/internal/checker.(*Checker).checkExpression(0xc0045a8000)
github.com/microsoft/typescript-go/internal/checker/checker.go:5000 +0x1a0
github.com/microsoft/typescript-go/internal/ls.handleCrossProject[...].func1()
github.com/microsoft/typescript-go/internal/ls/crossproject.go:105 +0x150`

result := string(trimPanicRecoveryFrames(input))
if result != expected {
t.Errorf("trimPanicRecoveryFrames result mismatch.\nGot:\n%s\n\nExpected:\n%s", result, expected)
}
}

func TestTrimPanicRecoveryFramesNoPanicFrame(t *testing.T) {
t.Parallel()

// If no panic() frame exists, the stack should be returned as-is.
input := []byte(`github.com/microsoft/typescript-go/internal/checker.(*Checker).checkExpression(0xc0045a8000)
github.com/microsoft/typescript-go/internal/checker/checker.go:5000 +0x1a0`)

result := string(trimPanicRecoveryFrames(input))
if result != string(input) {
t.Errorf("expected unchanged output when no panic frame, got:\n%s", result)
}
}
12 changes: 4 additions & 8 deletions internal/ls/crossproject.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package ls

import (
"context"
"fmt"
"iter"
"runtime/debug"
"sync"

"github.com/microsoft/typescript-go/internal/collections"
Expand Down Expand Up @@ -73,7 +71,7 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any](
wg := core.NewWorkGroup(false)
var errMu sync.Mutex
var enqueueItem func(item projectAndTextDocumentPosition)
var panicsOccured []string
var panicsOccurred []*core.PanicWithStack
var panicMu sync.Mutex
enqueueItem = func(item projectAndTextDocumentPosition) {
var response response[Resp]
Expand All @@ -86,10 +84,8 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any](
}
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
panicOccured := fmt.Sprintf("panic handling request: %v\n%s", r, string(stack))
panicMu.Lock()
panicsOccured = append(panicsOccured, panicOccured)
panicsOccurred = append(panicsOccurred, core.NewPanicWithStack(r))
panicMu.Unlock()
}
}()
Expand Down Expand Up @@ -206,8 +202,8 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any](
// Process existing known projects first
wg.RunAndWait()
// No need to use mu here since we are not in parallel at this point
if panicsOccured != nil {
panic(fmt.Sprintf("Panics occurred during cross-project handling: %v", panicsOccured))
if panicsOccurred != nil {
panic(panicsOccurred[0])
}
Comment on lines 202 to 207
if ctx.Err() != nil {
return resp, ctx.Err()
Expand Down
13 changes: 10 additions & 3 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -965,11 +965,18 @@ func (s *Server) getLanguageServiceAndCrossProjectOrchestrator(ctx context.Conte
func (s *Server) recover(req *lsproto.RequestMessage) {
if r := recover(); r != nil {
stack := debug.Stack()
s.logger.Errorf("panic handling request %s: %v\n%s", req.Method, r, string(stack))
// If the panic was wrapped with PanicWithStack (e.g., from cross-project handling),
// use the original stack trace to preserve the actual crash context.
panicValue := r
if pws, ok := r.(*core.PanicWithStack); ok {
stack = pws.Stack
panicValue = pws.Value
}
s.logger.Errorf("panic handling request %s: %v\n%s", req.Method, panicValue, string(stack))
if req.ID != nil {
_ = s.sendError(req.ID, fmt.Errorf("%w: panic handling request %s: %v", lsproto.ErrorCodeInternalError, req.Method, r))
_ = s.sendError(req.ID, fmt.Errorf("%w: panic handling request %s: %v", lsproto.ErrorCodeInternalError, req.Method, panicValue))
} else {
s.logger.Error("unhandled panic in notification", req.Method, r)
s.logger.Error("unhandled panic in notification", req.Method, panicValue)
}

if s.telemetryEnabled {
Expand Down
17 changes: 14 additions & 3 deletions internal/lsp/stack_sanitizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,21 @@ func sanitizeStackTrace(stack string) string {
// TODO: should we just look for the first '(' and
// just strip everything before the prior newline?
startIndex := strings.Index(stack, "runtime/debug.Stack()")
if startIndex < 0 {
return ""
if startIndex >= 0 {
stack = stack[startIndex:]
} else {
// For stacks that have already had recovery frames trimmed
// (e.g., from PanicWithStack), find the first line containing our module.
moduleIndex := strings.Index(stack, "typescript-go/internal")
if moduleIndex < 0 {
return ""
}
// Back up to the beginning of the line containing our module.
lineStart := strings.LastIndex(stack[:moduleIndex], "\n")
if lineStart >= 0 {
stack = stack[lineStart+1:]
}
}
stack = stack[startIndex:]

result := &strings.Builder{}

Expand Down
25 changes: 25 additions & 0 deletions internal/lsp/stack_sanitizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,31 @@ created by github.com/microsoft/typescript-go/internal/lsp.(*Server).dispatchLoo
})
}

// This test represents a stack trace captured via PanicWithStack from a cross-project
// worker goroutine. The stack originates from the goroutine where the panic occurred
// with recovery infrastructure frames (debug.Stack, deferred func, panic) already
// stripped by NewPanicWithStack, leaving only the actual crash site frames.
func TestSanitizedCrossProjectPanicStackTrace(t *testing.T) {
t.Parallel()

// This is the stack after NewPanicWithStack trims recovery overhead.
// It starts directly at the crash site without debug.Stack/panic frames.
input := `github.com/microsoft/typescript-go/internal/checker.(*Checker).checkExpression(0xc0045a8000, {0x10f6688, 0xc00c2871d0}, 0xc0001fe008, 0x0)
github.com/microsoft/typescript-go/internal/checker/checker.go:5000 +0x1a0
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).provideSymbolsAndEntries(0xc008329200, {0x10f6688, 0xc00c2871d0}, {0xc00b472030, 0x28}, {0x2, 0x4}, 0x0, 0x0)
github.com/microsoft/typescript-go/internal/ls/findallreferences.go:100 +0x200
github.com/microsoft/typescript-go/internal/ls.handleCrossProject[...].func1()
github.com/microsoft/typescript-go/internal/ls/crossproject.go:105 +0x150
github.com/microsoft/typescript-go/internal/core.(*WorkGroup).worker(0xc000120080)
github.com/microsoft/typescript-go/internal/core/workgroup.go:50 +0x80
created by github.com/microsoft/typescript-go/internal/core.(*WorkGroup).Queue in goroutine 35
github.com/microsoft/typescript-go/internal/core/workgroup.go:35 +0x60`

baseline.Run(t, "crossProjectPanicStackTrace.md", sanitizedStackTraceBaselineContents(t, input, sanitizeStackTrace(input)), baseline.Options{
Subfolder: "lsp/stackSanitizer/",
})
}

func sanitizedStackTraceBaselineContents(t *testing.T, input string, output string) string {
builder := strings.Builder{}
builder.WriteString("Test name: `")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Test name: `TestSanitizedCrossProjectPanicStackTrace`

# Unsanitized input:

````
github.com/microsoft/typescript-go/internal/checker.(*Checker).checkExpression(0xc0045a8000, {0x10f6688, 0xc00c2871d0}, 0xc0001fe008, 0x0)
github.com/microsoft/typescript-go/internal/checker/checker.go:5000 +0x1a0
github.com/microsoft/typescript-go/internal/ls.(*LanguageService).provideSymbolsAndEntries(0xc008329200, {0x10f6688, 0xc00c2871d0}, {0xc00b472030, 0x28}, {0x2, 0x4}, 0x0, 0x0)
github.com/microsoft/typescript-go/internal/ls/findallreferences.go:100 +0x200
github.com/microsoft/typescript-go/internal/ls.handleCrossProject[...].func1()
github.com/microsoft/typescript-go/internal/ls/crossproject.go:105 +0x150
github.com/microsoft/typescript-go/internal/core.(*WorkGroup).worker(0xc000120080)
github.com/microsoft/typescript-go/internal/core/workgroup.go:50 +0x80
created by github.com/microsoft/typescript-go/internal/core.(*WorkGroup).Queue in goroutine 35
github.com/microsoft/typescript-go/internal/core/workgroup.go:35 +0x60
````

# Sanitized output:

````
typescript-go|>internal|>checker.(*Checker).checkExpression()
typescript-go|>internal|>checker|>checker.go:5000
typescript-go|>internal|>ls.(*LanguageService).provideSymbolsAndEntries()
typescript-go|>internal|>ls|>findallreferences.go:100
typescript-go|>internal|>ls.handleCrossProject[...].func1()
typescript-go|>internal|>ls|>crossproject.go:105
typescript-go|>internal|>core.(*WorkGroup).worker()
typescript-go|>internal|>core|>workgroup.go:50
typescript-go|>internal|>core.(*WorkGroup).Queue
typescript-go|>internal|>core|>workgroup.go:35
````