From 0f64e7e9729b6cfaa679a907e6819e059d76d22e Mon Sep 17 00:00:00 2001 From: kajaaz Date: Mon, 21 Sep 2026 17:05:52 +0200 Subject: [PATCH 1/2] parser: bound recursion depth to avoid stack overflow on deeply nested input. Ref #64370 --- .../diagnostics/diagnosticMessages.json | 4 ++ .../diagnostics/diagnostics_generated.go | 4 ++ tsc/internal/parser/parser.go | 60 ++++++++++++++++++ tsc/internal/parser/parser_test.go | 61 +++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/tsc/internal/diagnostics/diagnosticMessages.json b/tsc/internal/diagnostics/diagnosticMessages.json index 8fef6d37ff389..91110b81f77df 100644 --- a/tsc/internal/diagnostics/diagnosticMessages.json +++ b/tsc/internal/diagnostics/diagnosticMessages.json @@ -319,6 +319,10 @@ "category": "Error", "code": 1110 }, + "Expression or type is too deeply nested. Simplify or split it into smaller parts.": { + "category": "Error", + "code": 1700 + }, "Private field '{0}' must be declared in an enclosing class.": { "category": "Error", "code": 1111 diff --git a/tsc/internal/diagnostics/diagnostics_generated.go b/tsc/internal/diagnostics/diagnostics_generated.go index abab44d7a6fab..0327c31b55d72 100644 --- a/tsc/internal/diagnostics/diagnostics_generated.go +++ b/tsc/internal/diagnostics/diagnostics_generated.go @@ -946,6 +946,8 @@ var X_0_is_not_a_valid_key_for_an_import_attributes_type = &Message{code: 1557, var An_import_attributes_property_cannot_have_a_readonly_modifier = &Message{code: 1558, category: CategoryError, key: "An_import_attributes_property_cannot_have_a_readonly_modifier_1558", text: "An import attributes property cannot have a 'readonly' modifier."} +var Expression_or_type_is_too_deeply_nested_Simplify_or_split_it_into_smaller_parts = &Message{code: 1700, category: CategoryError, key: "Expression_or_type_is_too_deeply_nested_Simplify_or_split_it_into_smaller_parts_1700", text: "Expression or type is too deeply nested. Simplify or split it into smaller parts."} + var The_types_of_0_are_incompatible_between_these_types = &Message{code: 2200, category: CategoryError, key: "The_types_of_0_are_incompatible_between_these_types_2200", text: "The types of '{0}' are incompatible between these types."} var The_types_returned_by_0_are_incompatible_between_these_types = &Message{code: 2201, category: CategoryError, key: "The_types_returned_by_0_are_incompatible_between_these_types_2201", text: "The types returned by '{0}' are incompatible between these types."} @@ -5374,6 +5376,8 @@ func keyToMessage(key Key) *Message { return X_0_is_not_a_valid_key_for_an_import_attributes_type case "An_import_attributes_property_cannot_have_a_readonly_modifier_1558": return An_import_attributes_property_cannot_have_a_readonly_modifier + case "Expression_or_type_is_too_deeply_nested_Simplify_or_split_it_into_smaller_parts_1700": + return Expression_or_type_is_too_deeply_nested_Simplify_or_split_it_into_smaller_parts case "The_types_of_0_are_incompatible_between_these_types_2200": return The_types_of_0_are_incompatible_between_these_types case "The_types_returned_by_0_are_incompatible_between_these_types_2201": diff --git a/tsc/internal/parser/parser.go b/tsc/internal/parser/parser.go index f54b4bcebbdf3..8ffe1aaf36f4a 100644 --- a/tsc/internal/parser/parser.go +++ b/tsc/internal/parser/parser.go @@ -83,6 +83,8 @@ type Parser struct { statementHasAwaitIdentifier bool hasDeprecatedTag bool hasParseError bool + nestingDepth int + nestingLimitHit bool identifierCount int notParenthesizedArrow collections.Set[int] @@ -109,6 +111,51 @@ func newParser() *Parser { var viableKeywordSuggestions = scanner.GetViableKeywordSuggestions() +// maxNestingDepth bounds recursive-descent nesting (expressions and types) so that +// pathologically nested input (e.g. hundreds of thousands of nested "(", "[", or "A<") +// is reported as a diagnostic instead of overflowing the goroutine stack with a fatal, +// unrecoverable runtime error. The limit is far above any human-authored or realistic +// machine-generated source, yet well below the depth that exhausts the stack. +const maxNestingDepth = 40000 + +// enterNesting increments the recursion-depth counter and reports whether the caller is +// still allowed to descend. Callers that receive false must not recurse further and +// should return a missing node so parsing can unwind cleanly. Every successful +// enterNesting (returning true) must be paired with a leaveNesting. +// +// The first time the limit is reached it records a single diagnostic, then fast-forwards +// the scanner to end-of-file via skipToEndOfFile. Positioning at EOF makes every enclosing +// construct terminate (isListTerminator and parseExpected both stop at EOF), so the parser +// unwinds and finishes in time linear in the nesting depth. This deliberately abandons the +// remainder of the file: it avoids pathological O(n^2) error recovery that would otherwise +// occur, for example, when a long chain of unterminated "A<" is re-scanned as a generic +// call in expression position. The parser is pooled and fully reset in putParser, so the +// nestingLimitHit flag never leaks across source files. +func (p *Parser) enterNesting() bool { + if p.nestingDepth >= maxNestingDepth { + if !p.nestingLimitHit { + p.parseErrorAtCurrentToken(diagnostics.Expression_or_type_is_too_deeply_nested_Simplify_or_split_it_into_smaller_parts) + p.nestingLimitHit = true + p.skipToEndOfFile() + } + return false + } + p.nestingDepth++ + return true +} + +func (p *Parser) leaveNesting() { + p.nestingDepth-- +} + +// skipToEndOfFile advances the scanner to the end of the source and sets the current token +// to EOF. It is used only for error recovery after maxNestingDepth is exceeded, to unwind +// the recursive descent quickly instead of re-parsing the pathological remainder. +func (p *Parser) skipToEndOfFile() { + p.scanner.ResetPos(len(p.sourceText)) + p.token = p.scanner.Scan() +} + // missingListNodes is a sentinel backing array used to distinguish "missing" node lists // (where the expected opening token was not found) from ordinary empty node lists. var missingListNodes = make([]*ast.Node, 0, 1) @@ -2657,6 +2704,10 @@ func (p *Parser) parseExportSpecifier() *ast.Node { // TYPES func (p *Parser) parseType() *ast.TypeNode { + if !p.enterNesting() { + return p.createMissingTypeNode() + } + defer p.leaveNesting() saveContextFlags := p.contextFlags p.setContextFlags(ast.NodeFlagsTypeExcludesFlags, false) var typeNode *ast.TypeNode @@ -3030,6 +3081,11 @@ func (p *Parser) createMissingIdentifier() *ast.Node { return p.finishNode(p.newIdentifier(""), p.nodePos()) } +func (p *Parser) createMissingTypeNode() *ast.TypeNode { + pos := p.nodePos() + return p.finishNode(p.factory.NewTypeReferenceNode(p.createMissingIdentifier(), nil), pos) +} + func (p *Parser) parsePrivateIdentifier() *ast.Node { pos := p.nodePos() text := p.scanner.TokenValue() @@ -4128,6 +4184,10 @@ func (p *Parser) parseExpressionAllowIn() *ast.Expression { } func (p *Parser) parseAssignmentExpressionOrHigher() *ast.Expression { + if !p.enterNesting() { + return p.createMissingIdentifier() + } + defer p.leaveNesting() return p.parseAssignmentExpressionOrHigherWorker(true /*allowReturnTypeInArrowFunction*/) } diff --git a/tsc/internal/parser/parser_test.go b/tsc/internal/parser/parser_test.go index 200064160228b..4e59122d8ec55 100644 --- a/tsc/internal/parser/parser_test.go +++ b/tsc/internal/parser/parser_test.go @@ -155,6 +155,67 @@ func FuzzParser(f *testing.F) { }) } +// TestDeeplyNestedInputDoesNotOverflow verifies that pathologically nested input is +// reported as a recoverable diagnostic (TS1700) instead of aborting the process with a +// fatal "stack overflow" runtime error. Reaching the end of ParseSourceFile at all is the +// core assertion: a stack overflow is a fatal, unrecoverable runtime error that would +// terminate the test binary rather than fail this test. See microsoft/TypeScript#64370. +func TestDeeplyNestedInputDoesNotOverflow(t *testing.T) { + t.Parallel() + + // A depth comfortably beyond the parser's nesting cap so recovery is exercised, but + // small enough that the test stays fast. + const depth = 60000 + + tests := []struct { + name string + source string + }{ + {name: "parenthesized expressions", source: strings.Repeat("(", depth)}, + {name: "array literals", source: strings.Repeat("[", depth)}, + {name: "type arguments", source: "type T = " + strings.Repeat("A<", depth)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + opts := ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + } + file := parser.ParseSourceFile(opts, test.source, core.ScriptKindTS) + + const tooDeeplyNestedCode = 1700 + found := false + for _, d := range file.Diagnostics() { + if d.Code() == tooDeeplyNestedCode { + found = true + break + } + } + assert.Assert(t, found, "expected a 'too deeply nested' (TS1700) diagnostic") + }) + } +} + +// TestModeratelyNestedInputIsAccepted guards against false positives: nesting well under +// the cap must parse without emitting the "too deeply nested" diagnostic. +func TestModeratelyNestedInputIsAccepted(t *testing.T) { + t.Parallel() + + const depth = 1000 + sourceText := "const x = " + strings.Repeat("(", depth) + "1" + strings.Repeat(")", depth) + ";" + opts := ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + } + file := parser.ParseSourceFile(opts, sourceText, core.ScriptKindTS) + + for _, d := range file.Diagnostics() { + assert.Assert(t, d.Code() != 1700, "unexpected 'too deeply nested' diagnostic on valid input") + } +} + func TestHeritageClauseElementKinds(t *testing.T) { t.Parallel() sourceText := ` From eda7cec60b8ff0160d5e5aae6d8c19d5a2024943 Mon Sep 17 00:00:00 2001 From: kajaaz Date: Mon, 21 Sep 2026 17:40:21 +0200 Subject: [PATCH 2/2] parser: bound remaining recursive paths and make the nesting guard speculation-safe --- tsc/internal/parser/parser.go | 32 ++++++++++++++++++++++++++++-- tsc/internal/parser/parser_test.go | 4 ++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tsc/internal/parser/parser.go b/tsc/internal/parser/parser.go index 8ffe1aaf36f4a..86b699a6257f0 100644 --- a/tsc/internal/parser/parser.go +++ b/tsc/internal/parser/parser.go @@ -123,14 +123,24 @@ const maxNestingDepth = 40000 // should return a missing node so parsing can unwind cleanly. Every successful // enterNesting (returning true) must be paired with a leaveNesting. // +// The guard is applied at the recursive-descent entry points that can otherwise grow the +// stack without bound: parseType and parseTypeOperatorOrHigher for types, and +// parseAssignmentExpressionOrHigher, parseSimpleUnaryExpression and parsePrimaryExpression +// for expressions (covering e.g. "(((", "[[[", "!!!", "typeof typeof", "keyof keyof" and +// "new new" chains). +// // The first time the limit is reached it records a single diagnostic, then fast-forwards // the scanner to end-of-file via skipToEndOfFile. Positioning at EOF makes every enclosing // construct terminate (isListTerminator and parseExpected both stop at EOF), so the parser // unwinds and finishes in time linear in the nesting depth. This deliberately abandons the // remainder of the file: it avoids pathological O(n^2) error recovery that would otherwise // occur, for example, when a long chain of unterminated "A<" is re-scanned as a generic -// call in expression position. The parser is pooled and fully reset in putParser, so the -// nestingLimitHit flag never leaks across source files. +// call in expression position. +// +// nestingDepth and nestingLimitHit are part of ParserState (see mark/rewind), so a limit +// reached inside a discarded speculative lookahead is rolled back together with the scanner +// and diagnostics; the committed parse then re-hits the limit and reports it exactly once. +// The parser is also fully reset in putParser, so neither field leaks across source files. func (p *Parser) enterNesting() bool { if p.nestingDepth >= maxNestingDepth { if !p.nestingLimitHit { @@ -394,6 +404,8 @@ type ParserState struct { reparsedClonesLen int statementHasAwaitIdentifier bool hasParseError bool + nestingDepth int + nestingLimitHit bool } func (p *Parser) mark() ParserState { @@ -406,6 +418,8 @@ func (p *Parser) mark() ParserState { reparsedClonesLen: len(p.reparsedClones), statementHasAwaitIdentifier: p.statementHasAwaitIdentifier, hasParseError: p.hasParseError, + nestingDepth: p.nestingDepth, + nestingLimitHit: p.nestingLimitHit, } } @@ -419,6 +433,8 @@ func (p *Parser) rewind(state ParserState) { p.reparsedClones = p.reparsedClones[0:state.reparsedClonesLen] p.statementHasAwaitIdentifier = state.statementHasAwaitIdentifier p.hasParseError = state.hasParseError + p.nestingDepth = state.nestingDepth + p.nestingLimitHit = state.nestingLimitHit } func (p *Parser) lookAhead(callback func(p *Parser) bool) bool { @@ -2774,6 +2790,10 @@ func (p *Parser) createUnionOrIntersectionTypeNode(operator ast.Kind, types *ast } func (p *Parser) parseTypeOperatorOrHigher() *ast.TypeNode { + if !p.enterNesting() { + return p.createMissingTypeNode() + } + defer p.leaveNesting() operator := p.token switch operator { case ast.KindKeyOfKeyword, ast.KindUniqueKeyword, ast.KindReadonlyKeyword: @@ -5175,6 +5195,10 @@ func (p *Parser) parseJsxClosingFragment(inExpressionContext bool) *ast.Node { } func (p *Parser) parseSimpleUnaryExpression() *ast.Expression { + if !p.enterNesting() { + return p.createMissingIdentifier() + } + defer p.leaveNesting() switch p.token { case ast.KindPlusToken, ast.KindMinusToken, ast.KindTildeToken, ast.KindExclamationToken: return p.parsePrefixUnaryExpression() @@ -5664,6 +5688,10 @@ func (p *Parser) parseTemplateSpan(isTaggedTemplate bool) *ast.Node { } func (p *Parser) parsePrimaryExpression() *ast.Expression { + if !p.enterNesting() { + return p.createMissingIdentifier() + } + defer p.leaveNesting() switch p.token { case ast.KindNoSubstitutionTemplateLiteral: if p.scanner.TokenFlags()&ast.TokenFlagsIsInvalid != 0 { diff --git a/tsc/internal/parser/parser_test.go b/tsc/internal/parser/parser_test.go index 4e59122d8ec55..f6c474a989a3c 100644 --- a/tsc/internal/parser/parser_test.go +++ b/tsc/internal/parser/parser_test.go @@ -174,6 +174,10 @@ func TestDeeplyNestedInputDoesNotOverflow(t *testing.T) { {name: "parenthesized expressions", source: strings.Repeat("(", depth)}, {name: "array literals", source: strings.Repeat("[", depth)}, {name: "type arguments", source: "type T = " + strings.Repeat("A<", depth)}, + {name: "type operators", source: "type T = " + strings.Repeat("keyof ", depth) + "X"}, + {name: "prefix unary operators", source: "const x = " + strings.Repeat("!", depth) + "y"}, + {name: "typeof operators", source: "const x = " + strings.Repeat("typeof ", depth) + "y"}, + {name: "new expressions", source: "const x = " + strings.Repeat("new ", depth) + "C"}, } for _, test := range tests {