package tracing import ( "fmt" "maps" "math" "slices" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/scanner" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" "github.com/zeebo/xxh3" ) // Tracer is an interface for recording types during type checking. // Each checker should have its own Tracer instance to avoid sharing types between checkers. type Tracer interface { // RecordType records a type for later dumping. RecordType(t TracedType) // DumpTypes writes all recorded types to disk. DumpTypes() error } // TracedType is an interface that represents a type that can be traced. // This allows the tracing package to work with types from the checker package // without creating a circular dependency. type TracedType interface { Id() uint32 FormatFlags() []string IsConditional() bool Symbol() *ast.Symbol AliasSymbol() *ast.Symbol AliasTypeArguments() []TracedType // Type-specific data accessors IntrinsicName() string UnionTypes() []TracedType IntersectionTypes() []TracedType IndexType() TracedType IndexedAccessObjectType() TracedType IndexedAccessIndexType() TracedType ConditionalCheckType() TracedType ConditionalExtendsType() TracedType ConditionalTrueType() TracedType ConditionalFalseType() TracedType SubstitutionBaseType() TracedType SubstitutionConstraintType() TracedType ReferenceTarget() TracedType ReferenceTypeArguments() []TracedType ReferenceNode() *ast.Node ReverseMappedSourceType() TracedType ReverseMappedMappedType() TracedType ReverseMappedConstraintType() TracedType EvolvingArrayElementType() TracedType EvolvingArrayFinalType() TracedType IsTuple() bool Pattern() *ast.Node RecursionIdentity() any // Display is an optional string representation of the type Display() string } // TraceRecord represents metadata about a single trace file type TraceRecord struct { ConfigFilePath string `json:"configFilePath,omitzero"` TracePath string `json:"tracePath,omitzero"` TypesPath string `json:"typesPath,omitzero"` CheckerID int `json:"checkerId"` } type traceEvent struct { PID int `json:"pid"` TID int `json:"tid"` PH string `json:"ph"` Cat string `json:"cat"` TS float64 `json:"ts"` Name string `json:"name,omitzero"` S string `json:"s,omitzero"` // scope, only set for instant events ("g" = global) Dur *float64 `json:"dur,omitzero"` Args map[string]any `json:"args,omitzero"` } // sampleInterval matches TypeScript's 10ms sampling interval. // Events with separateBeginAndEnd=false are only recorded if their // duration crosses a 10ms sampling boundary. const sampleInterval = 10 * time.Millisecond const traceFileName = "trace.json" const ( mainThreadID = 1 firstSyntheticThreadID = 2 firstFileThreadID = 1_000_000 fileThreadIDHashRange = 1_000_000_000 ) var traceThreadArgKeys = [...]string{"path", "fileName", "containingFileName", "jsFilePath", "declarationFilePath"} // flushThreshold is the size at which buffered trace content is flushed to disk // via AppendFile. Keeps peak memory bounded for long-running compilations while // avoiding a syscall per event. const flushThreshold = 256 * 1024 // Tracing manages the overall tracing session including all checkers type Tracing struct { fs vfs.FS traceDir string tracePath string configFilePath string legend []TraceRecord tracers []*typeTracer traceContent strings.Builder traceStarted atomic.Bool threadIDs map[traceThreadKey]int threadKeys map[int]traceThreadKey metadataTS float64 deterministic bool // when true, use monotonic counter instead of real time timestampCounter uint64 // only used in deterministic mode startTime time.Time mu sync.Mutex // flushErr holds the first error encountered while appending the trace buffer // to disk. Once set, subsequent flushes become no-ops and the error is // surfaced from StopTracing so that transient I/O failures (disk full, // permission denied, etc.) don't crash the compiler. flushErr error } // Phase represents a tracing phase type Phase string const ( PhaseParse Phase = "parse" PhaseProgram Phase = "program" PhaseBind Phase = "bind" PhaseCheck Phase = "check" PhaseCheckTypes Phase = "checkTypes" PhaseEmit Phase = "emit" PhaseSession Phase = "session" ) // StartTracing creates a new tracing session. // When deterministic is true, timestamps use a monotonic counter instead of // real wall-clock time, producing stable output for test baselines. func StartTracing(fs vfs.FS, traceDir string, configFilePath string, deterministic bool) (*Tracing, error) { tr := &Tracing{ fs: fs, traceDir: traceDir, tracePath: tspath.CombinePaths(traceDir, traceFileName), configFilePath: configFilePath, legend: []TraceRecord{}, tracers: []*typeTracer{}, threadIDs: make(map[traceThreadKey]int), threadKeys: make(map[int]traceThreadKey), deterministic: deterministic, startTime: time.Now(), } tr.traceStarted.Store(true) // Write the trace file header with metadata events tr.traceContent.WriteString("[\n") // Write metadata events (matching TypeScript's format) metaTs := tr.timestamp() tr.metadataTS = metaTs tr.writeEvent(traceEvent{PID: 1, TID: mainThreadID, PH: "M", Cat: "__metadata", TS: metaTs, Name: "process_name", Args: map[string]any{"name": "tsgo"}}) tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: mainThreadID, PH: "M", Cat: "__metadata", TS: metaTs, Name: "thread_name", Args: map[string]any{"name": "Main"}}) tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: mainThreadID, PH: "M", Cat: "disabled-by-default-devtools.timeline", TS: metaTs, Name: "TracingStartedInBrowser"}) // Truncate any existing trace file with the header so subsequent AppendFile // calls extend a clean file. if err := tr.fs.WriteFile(tr.tracePath, tr.traceContent.String()); err != nil { return nil, fmt.Errorf("failed to write trace file header: %w", err) } tr.traceContent.Reset() return tr, nil } // timestamp returns the current timestamp in microseconds. // In deterministic mode it returns a monotonically increasing counter; // otherwise it returns the real elapsed wall-clock time since tracing started, // matching TypeScript's 1000 * timestamp() (microseconds). func (tr *Tracing) timestamp() float64 { if tr.deterministic { tr.timestampCounter++ return float64(tr.timestampCounter) } return float64(time.Since(tr.startTime).Nanoseconds()) / 1000.0 } func writeEventTo(buf *strings.Builder, event traceEvent) { if err := json.MarshalWrite(buf, event, json.Deterministic(true)); err != nil { panic(fmt.Sprintf("failed to marshal trace event: %v", err)) } } func (tr *Tracing) writeEvent(event traceEvent) { writeEventTo(&tr.traceContent, event) } // maybeFlushLocked appends the buffered trace content to disk if it has grown // past the flush threshold. Caller must hold tr.mu. If a previous flush failed, // or this flush fails, the error is recorded in tr.flushErr and subsequent // writes become no-ops; the error is surfaced from StopTracing. func (tr *Tracing) maybeFlushLocked() { if tr.flushErr != nil { tr.traceContent.Reset() return } if tr.traceContent.Len() < flushThreshold { return } if err := tr.fs.AppendFile(tr.tracePath, tr.traceContent.String()); err != nil { tr.flushErr = fmt.Errorf("failed to flush trace file: %w", err) } tr.traceContent.Reset() } // Instant records an instant event in the trace. // Safe to call on nil receiver. func (tr *Tracing) Instant(phase Phase, name string, args map[string]any) { if tr == nil || !tr.traceStarted.Load() { return } tr.mu.Lock() defer tr.mu.Unlock() // Re-check under the lock: StopTracing may have run between the load above // and acquiring the lock. Once stopped, further writes would land in a buffer // that has already been flushed and the closing "]" written. if !tr.traceStarted.Load() { return } ts := tr.timestamp() tid := tr.threadIDLocked(args) tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: tid, PH: "I", Cat: string(phase), TS: ts, Name: name, S: "g", Args: args}) tr.maybeFlushLocked() } // Push starts a trace event block on the shared trace buffer. // Safe to call on nil receiver. Safe to call from multiple goroutines. // // When separateBeginAndEnd is true, a "B" (begin) event is written immediately and // the returned function writes a matching "E" (end) event. This is used for events // that must always appear in the trace (e.g. checkSourceFile, createProgram, emit). // // When separateBeginAndEnd is false (the default in TypeScript), the event is only // recorded if its duration crosses a 10ms sampling boundary, matching TypeScript's // behavior of sampling short-lived events to avoid trace bloat. // // Returns a function that should be called (typically deferred) to end the event. // Each returned function is self-contained and does not depend on a shared stack, // making it safe for concurrent use across goroutines. func (tr *Tracing) Push(phase Phase, name string, args map[string]any, separateBeginAndEnd bool) func() { if tr == nil || !tr.traceStarted.Load() { return func() {} } if separateBeginAndEnd { tr.mu.Lock() if !tr.traceStarted.Load() { tr.mu.Unlock() return func() {} } ts := tr.timestamp() tid := tr.threadIDLocked(args) tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: tid, PH: "B", Cat: string(phase), TS: ts, Name: name, Args: args}) tr.maybeFlushLocked() tr.mu.Unlock() return func() { tr.mu.Lock() defer tr.mu.Unlock() if !tr.traceStarted.Load() { return } endTs := tr.timestamp() tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: tid, PH: "E", Cat: string(phase), TS: endTs, Name: name, Args: args}) tr.maybeFlushLocked() } } // Sampled event: only record if duration crosses a sampling boundary. // In deterministic mode, sampled events are skipped entirely to avoid flaky baselines, // so avoid the cost of cloning args / capturing the start time. if tr.deterministic { return func() {} } startTime := time.Now() args = maps.Clone(args) return func() { dur := float64(time.Since(startTime).Nanoseconds()) / 1000.0 startMicros := float64(startTime.Sub(tr.startTime).Nanoseconds()) / 1000.0 intervalMicros := float64(sampleInterval.Nanoseconds()) / 1000.0 if intervalMicros-math.Mod(startMicros, intervalMicros) > dur { return } tr.mu.Lock() defer tr.mu.Unlock() if !tr.traceStarted.Load() { return } tid := tr.threadIDLocked(args) tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: tid, PH: "X", Cat: string(phase), TS: startMicros, Name: name, Dur: &dur, Args: args}) tr.maybeFlushLocked() } } func (tr *Tracing) threadIDLocked(args map[string]any) int { key, ok := traceThreadKeyFromArgs(args) if !ok { return mainThreadID } if tid, ok := tr.threadIDs[key]; ok { return tid } tid := key.defaultThreadID() for { if existingKey, ok := tr.threadKeys[tid]; !ok || existingKey == key { break } tid++ } tr.threadIDs[key] = tid tr.threadKeys[tid] = key tr.writeThreadNameEventLocked(tid, key.displayName()) return tid } func (tr *Tracing) writeThreadNameEventLocked(tid int, name string) { tr.traceContent.WriteString(",\n") tr.writeEvent(traceEvent{PID: 1, TID: tid, PH: "M", Cat: "__metadata", TS: tr.metadataTS, Name: "thread_name", Args: map[string]any{"name": name}}) } type traceThreadKind string const ( traceThreadKindChecker traceThreadKind = "checker" traceThreadKindFile traceThreadKind = "file" ) type traceThreadKey struct { kind traceThreadKind text string index int hasIndex bool } func traceThreadKeyFromArgs(args map[string]any) (traceThreadKey, bool) { if len(args) == 0 { return traceThreadKey{}, false } if checkerID, ok := args["checkerId"].(int); ok { return traceThreadKey{kind: traceThreadKindChecker, index: checkerID, hasIndex: true}, true } for _, key := range traceThreadArgKeys { if value, ok := args[key]; ok { if path, ok := value.(string); ok && path != "" { return traceThreadKey{kind: traceThreadKindFile, text: path}, true } } } return traceThreadKey{}, false } func (key traceThreadKey) defaultThreadID() int { if key.kind == traceThreadKindChecker && key.hasIndex && key.index >= 0 { return firstSyntheticThreadID + key.index } return stableTraceThreadID(key) } func (key traceThreadKey) displayName() string { if key.hasIndex { return string(key.kind) + ":" + strconv.Itoa(key.index) } return string(key.kind) + ":" + key.text } func stableTraceThreadID(key traceThreadKey) int { hash := xxh3.New() _, _ = hash.WriteString(string(key.kind)) _, _ = hash.WriteString(":") if key.hasIndex { _, _ = hash.WriteString(strconv.Itoa(key.index)) } else { _, _ = hash.WriteString(key.text) } return firstFileThreadID + int(hash.Sum64()%fileThreadIDHashRange) } // NewTypeTracer creates a new tracer for a specific checker. // The checkerIndex is used to create unique filenames for each checker's output. func (tr *Tracing) NewTypeTracer(checkerIndex int) Tracer { tr.mu.Lock() defer tr.mu.Unlock() typesPath := tspath.CombinePaths(tr.traceDir, fmt.Sprintf("types_%d.json", checkerIndex)) tracer := &typeTracer{ fs: tr.fs, checkerIndex: checkerIndex, typesPath: typesPath, types: []TracedType{}, } tr.tracers = append(tr.tracers, tracer) tr.legend = append(tr.legend, TraceRecord{ ConfigFilePath: tr.configFilePath, TracePath: tr.tracePath, TypesPath: typesPath, CheckerID: checkerIndex, }) return tracer } // StopTracing finalizes the tracing session and writes all output files func (tr *Tracing) StopTracing() error { // Dump types from all tracers BEFORE acquiring the lock, because // DumpTypes → buildTypeDescriptor → Display() → TypeToString can // re-enter the checker which calls Push/Pop (which need tr.mu). for _, tracer := range tr.tracers { if err := tracer.DumpTypes(); err != nil { return fmt.Errorf("failed to dump types for checker %d: %w", tracer.checkerIndex, err) } } tr.mu.Lock() defer tr.mu.Unlock() // Close the trace file(s) if tr.traceStarted.Load() { // Surface any buffered flush failure before attempting the final write. if tr.flushErr != nil { tr.traceContent.Reset() tr.traceStarted.Store(false) return tr.flushErr } // Flush any remaining buffered content and close the JSON array. if err := tr.fs.AppendFile(tr.tracePath, tr.traceContent.String()+"\n]\n"); err != nil { return fmt.Errorf("failed to write trace file: %w", err) } tr.traceContent.Reset() tr.traceStarted.Store(false) } // Sort legend entries by typesPath for deterministic output slices.SortFunc(tr.legend, func(a, b TraceRecord) int { return strings.Compare(a.TypesPath, b.TypesPath) }) // Write the legend file legendPath := tspath.CombinePaths(tr.traceDir, "legend.json") legendData, err := json.MarshalIndent(tr.legend, "", " ") if err != nil { return fmt.Errorf("failed to marshal legend file: %w", err) } if err := tr.fs.WriteFile(legendPath, string(legendData)); err != nil { return fmt.Errorf("failed to write legend file: %w", err) } return nil } // typeTracer is the per-checker tracer implementation type typeTracer struct { fs vfs.FS checkerIndex int typesPath string types []TracedType mu sync.Mutex } func (t *typeTracer) RecordType(typ TracedType) { t.mu.Lock() defer t.mu.Unlock() t.types = append(t.types, typ) } func (t *typeTracer) DumpTypes() error { // Copy the types slice under lock, then release so Display() calls during // buildTypeDescriptor don't deadlock when they create new types t.mu.Lock() types := slices.Clone(t.types) t.mu.Unlock() if len(types) == 0 { return nil } var sb strings.Builder // Write opening bracket (no newline so type ID matches line number) sb.WriteString("[") recursionIdentityMap := make(map[any]int) for i, typ := range types { descriptor := t.buildTypeDescriptor(typ, recursionIdentityMap) if err := json.MarshalWrite(&sb, descriptor); err != nil { return fmt.Errorf("failed to marshal type %d: %w", typ.Id(), err) } if i < len(types)-1 { sb.WriteString(",\n") } } sb.WriteString("]\n") return t.fs.WriteFile(t.typesPath, sb.String()) } // TypeDescriptor represents a type in the output JSON type TypeDescriptor struct { ID uint32 `json:"id"` IntrinsicName string `json:"intrinsicName,omitzero"` SymbolName string `json:"symbolName,omitzero"` RecursionID *int `json:"recursionId,omitzero"` IsTuple bool `json:"isTuple,omitzero"` UnionTypes []uint32 `json:"unionTypes,omitzero"` IntersectionTypes []uint32 `json:"intersectionTypes,omitzero"` AliasTypeArguments []uint32 `json:"aliasTypeArguments,omitzero"` KeyofType *uint32 `json:"keyofType,omitzero"` IndexedAccessObjectType *uint32 `json:"indexedAccessObjectType,omitzero"` IndexedAccessIndexType *uint32 `json:"indexedAccessIndexType,omitzero"` ConditionalCheckType *uint32 `json:"conditionalCheckType,omitzero"` ConditionalExtendsType *uint32 `json:"conditionalExtendsType,omitzero"` // ConditionalTrueType and ConditionalFalseType are *int32 (not *uint32) because // unresolved conditional branches are serialized as -1, matching TypeScript's behavior. ConditionalTrueType *int32 `json:"conditionalTrueType,omitzero"` ConditionalFalseType *int32 `json:"conditionalFalseType,omitzero"` SubstitutionBaseType *uint32 `json:"substitutionBaseType,omitzero"` ConstraintType *uint32 `json:"constraintType,omitzero"` InstantiatedType *uint32 `json:"instantiatedType,omitzero"` TypeArguments []uint32 `json:"typeArguments,omitzero"` ReferenceLocation *Location `json:"referenceLocation,omitzero"` ReverseMappedSourceType *uint32 `json:"reverseMappedSourceType,omitzero"` ReverseMappedMappedType *uint32 `json:"reverseMappedMappedType,omitzero"` ReverseMappedConstraintType *uint32 `json:"reverseMappedConstraintType,omitzero"` EvolvingArrayElementType *uint32 `json:"evolvingArrayElementType,omitzero"` EvolvingArrayFinalType *uint32 `json:"evolvingArrayFinalType,omitzero"` DestructuringPattern *Location `json:"destructuringPattern,omitzero"` FirstDeclaration *Location `json:"firstDeclaration,omitzero"` Flags []string `json:"flags"` Display string `json:"display,omitzero"` } // Location represents a source code location type Location struct { Path string `json:"path"` Start *LineAndChar `json:"start,omitzero"` End *LineAndChar `json:"end,omitzero"` } // LineAndChar represents a line and character position (1-indexed) type LineAndChar struct { Line int `json:"line"` Character int `json:"character"` } func (t *typeTracer) buildTypeDescriptor(typ TracedType, recursionIdentityMap map[any]int) TypeDescriptor { symbol := typ.Symbol() aliasSymbol := typ.AliasSymbol() desc := TypeDescriptor{ ID: typ.Id(), Flags: typ.FormatFlags(), } // Assign a unique integer token per recursion identity, matching TypeScript's behavior. // This lets trace analysis tools detect which types share the same recursion identity. if identity := typ.RecursionIdentity(); identity != nil { token, ok := recursionIdentityMap[identity] if !ok { token = len(recursionIdentityMap) recursionIdentityMap[identity] = token } desc.RecursionID = &token } // Intrinsic name if name := typ.IntrinsicName(); name != "" { desc.IntrinsicName = name } // Symbol name - escape the internal symbol name prefix for valid JSON if sym := aliasSymbol; sym != nil { desc.SymbolName = ast.EscapeAllInternalSymbolNames(sym.Name) } else if symbol != nil { desc.SymbolName = ast.EscapeAllInternalSymbolNames(symbol.Name) } // Tuple flag if typ.IsTuple() { desc.IsTuple = true } // Union types if types := typ.UnionTypes(); len(types) > 0 { desc.UnionTypes = mapTypeIds(types) } // Intersection types if types := typ.IntersectionTypes(); len(types) > 0 { desc.IntersectionTypes = mapTypeIds(types) } // Alias type arguments if args := typ.AliasTypeArguments(); len(args) > 0 { desc.AliasTypeArguments = mapTypeIds(args) } // Index type (keyof) if indexType := typ.IndexType(); indexType != nil { desc.KeyofType = new(indexType.Id()) } // Indexed access type if objType := typ.IndexedAccessObjectType(); objType != nil { desc.IndexedAccessObjectType = new(objType.Id()) } if idxType := typ.IndexedAccessIndexType(); idxType != nil { desc.IndexedAccessIndexType = new(idxType.Id()) } // Conditional type if typ.IsConditional() { if checkType := typ.ConditionalCheckType(); checkType != nil { desc.ConditionalCheckType = new(checkType.Id()) } if extendsType := typ.ConditionalExtendsType(); extendsType != nil { desc.ConditionalExtendsType = new(extendsType.Id()) } if trueType := typ.ConditionalTrueType(); trueType != nil { desc.ConditionalTrueType = new(int32(trueType.Id())) } else { desc.ConditionalTrueType = new(int32(-1)) } if falseType := typ.ConditionalFalseType(); falseType != nil { desc.ConditionalFalseType = new(int32(falseType.Id())) } else { desc.ConditionalFalseType = new(int32(-1)) } } // Substitution type if baseType := typ.SubstitutionBaseType(); baseType != nil { desc.SubstitutionBaseType = new(baseType.Id()) } if constraint := typ.SubstitutionConstraintType(); constraint != nil { desc.ConstraintType = new(constraint.Id()) } // Reference type if target := typ.ReferenceTarget(); target != nil { desc.InstantiatedType = new(target.Id()) } if args := typ.ReferenceTypeArguments(); len(args) > 0 { desc.TypeArguments = mapTypeIds(args) } if node := typ.ReferenceNode(); node != nil { desc.ReferenceLocation = getLocation(node) } // Reverse mapped type if sourceType := typ.ReverseMappedSourceType(); sourceType != nil { desc.ReverseMappedSourceType = new(sourceType.Id()) } if mappedType := typ.ReverseMappedMappedType(); mappedType != nil { desc.ReverseMappedMappedType = new(mappedType.Id()) } if constraintType := typ.ReverseMappedConstraintType(); constraintType != nil { desc.ReverseMappedConstraintType = new(constraintType.Id()) } // Evolving array type if elemType := typ.EvolvingArrayElementType(); elemType != nil { desc.EvolvingArrayElementType = new(elemType.Id()) } if finalType := typ.EvolvingArrayFinalType(); finalType != nil { desc.EvolvingArrayFinalType = new(finalType.Id()) } // Pattern (destructuring) if pattern := typ.Pattern(); pattern != nil { desc.DestructuringPattern = getLocation(pattern) } // First declaration - prefer aliasSymbol, matching TypeScript's `aliasSymbol ?? symbol` firstDeclSymbol := aliasSymbol if firstDeclSymbol == nil { firstDeclSymbol = symbol } if firstDeclSymbol != nil && len(firstDeclSymbol.Declarations) > 0 { desc.FirstDeclaration = getLocation(firstDeclSymbol.Declarations[0]) } // Display text if display := typ.Display(); display != "" { desc.Display = display } return desc } func mapTypeIds(types []TracedType) []uint32 { if len(types) == 0 { return nil } ids := make([]uint32, len(types)) for i, t := range types { if t != nil { ids[i] = t.Id() } } return ids } func getLocation(node *ast.Node) *Location { if node == nil { return nil } file := ast.GetSourceFileOfNode(node) if file == nil { return nil } startPos := scanner.GetTokenPosOfNode(node, file, false) startLine, startChar := scanner.GetECMALineAndUTF16CharacterOfPosition(file, startPos) endLine, endChar := scanner.GetECMALineAndUTF16CharacterOfPosition(file, node.End()) return &Location{ Path: string(tspath.ToPath(file.FileName(), "", false)), Start: &LineAndChar{ Line: startLine + 1, Character: int(startChar) + 1, }, End: &LineAndChar{ Line: endLine + 1, Character: int(endChar) + 1, }, } }