Fix crash when a call signature's type parameter cannot be reused - #4846
Fix crash when a call signature's type parameter cannot be reused#4846Nicolaev Eduard (nikeedw) wants to merge 2 commits into
Conversation
The PseudoTypeKindSingleCallSignature branch of pseudoTypeToNode appended the result of reuseNode into the type parameter list without checking it. reuseNode returns nil whenever the recovery boundary in tryReuseExistingNodeHelper fails, and that nil survived into the NodeList, where the printer dereferenced it in NodeList.HasTrailingComma while deciding whether to write a trailing comma. Serialize the type parameter from the checker instead, mirroring the fallback reuseTypeNode already performs for type nodes. Fixes microsoft#4748 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One observation that may be useful for judging the "symptom vs root cause" question I raised earlier — this crash site has been fixed once before, and the two bugs have exactly the same shape. #3467 (April, hover path) had identical bottom frames: } else if t.symbol != nil && b.ch.IsSymbolAccessibleByFlags(t.symbol, b.ctx.enclosingDeclaration, flags) {
reference = b.symbolToExpression(t.symbol, ast.SymbolFlagsType)
+ } else if t.symbol != nil && t.symbol.Name == ast.InternalSymbolNameClass {
+ reference = b.f.NewIdentifier(b.getNameOfSymbolAsWritten(t.symbol))
}i.e. one more branch so that a specific producer stops yielding a nil that ends up inside a This PR is the same move on a different producer: for _, tp := range d.TypeParameters {
- res = append(res, b.reuseNode(tp.AsNode()))
+ reused := b.reuseNode(tp.AsNode())
+ if reused == nil {
+ // fall back to serializing from the checker
+ }
+ res = append(res, reused)
}Same three-step failure both times: a node-builder path leaves a func (list *NodeList) HasTrailingComma() bool {
if len(list.Nodes) == 0 {
return false
}
last := list.Nodes[len(list.Nodes)-1]
return last.End() < list.End()
}So the honest framing is: #3485 closed one producer, this PR closes a second, and nothing prevents a third. There are ~410 If you want to close the class rather than the instance, the structural option is to reject nils at |
|
Follow-up on the Static survey.
I was not able to reach either one. A method-shorthand analogue of this PR's repro ( Dynamic survey. I added an env-gated check to if nilCensusFile != "" { // TSGO_NIL_CENSUS
for i, n := range nodes {
if n == nil {
// append stack to the census file; behavior otherwise unchanged
break
}
}
}Results:
What this supports, and no more than this. On current I can add that assert to this PR or as a separate follow-up, or leave it with you — whichever you prefer. |
| }, | ||
| }, | ||
| { | ||
| // The declaration signature computed for b.ts inlines `setField` structurally. Its type |
There was a problem hiding this comment.
This should be a compiler test in testdata\tests\cases\compiler and not a full stack test, since it's not concerned with incremental/watch/CLI stuff.
There was a problem hiding this comment.
I tried that first and verified against the unpatched code — as a compiler test this repro is green, because it never reaches the failing path. The shape necessarily carries declaration diagnostics (TS2527/TS4023), and declaration emit skips printing a file that has them:
declBlocked := len(diags) > 0 && !e.forceEmit && e.emitOnly != EmitOnlyForcedDtsThe malformed list is still built — the diagnostics come from that same transform — but only EmitOnlyForcedDts prints in spite of them, and its sole production caller is the incremental d.ts signature computation in affectedfileshandler.go. The compiler runner sets neither forceEmit nor emitOnly, so the same two files under cases/compiler pass with and without the fix, while this scenario panics without it.
Agreed the defect itself is in the node builder/printer, not in anything incremental — incremental is just the only path that prints the result. If there's a diagnostics-free shape that makes type-parameter reuse fail (I went looking via the silent markError(nil) paths in the reuse visitor and couldn't build one), I'll gladly move this to a compiler test; otherwise I'd prefer to keep the incremental regression test, since it's the only harness that reaches the crash.
| // type nodes. Appending the nil would leave it in the type parameter list, where | ||
| // the printer dereferences it while looking for a trailing comma. | ||
| b.ctx.tracker.ReportInferenceFallback(tp.AsNode()) | ||
| reused = b.typeParameterToDeclaration(b.ch.getDeclaredTypeOfTypeParameter(b.ch.getSymbolOfDeclaration(tp.AsNode()))) |
There was a problem hiding this comment.
This is a pretty suspect way to get the type from the node - is there a reason a simple ch.getTypeFromTypeNode(tp) doesn't suffice?
There was a problem hiding this comment.
tp is a TypeParameterDeclaration, not a type node — getTypeFromTypeNodeWorker has no KindTypeParameter case, so getTypeFromTypeNode(tp.AsNode()) returns errorType. I tried it: errorType has no symbol, so it crashes in the node builder instead (typeParameterToName → symbolToName → lookupSymbolChain with a nil symbol), before the printer is even reached. tp.Constraint doesn't fit either — that yields the constraint's type, while typeParameterToDeclaration needs the type parameter itself (it reads the name, constraint and default off it).
The indirection was a fair point though: the declaration's symbol is already bound, so this is now getDeclaredTypeOfTypeParameter(node.Symbol()) — the same spelling getTypeParametersFromDeclaration uses. Pushed in 620995b; baselines unchanged.
Review feedback: the getSymbolOfDeclaration hop was needless indirection. The declaration's symbol is already bound, and this is the same spelling getTypeParametersFromDeclaration uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #4748.
The crash
The
PseudoTypeKindSingleCallSignaturebranch ofpseudoTypeToNodeappends the result ofreuseNodeinto a call signature's type parameter list without checking it:reuseNodereturns nil whenever the recovery boundary intryReuseExistingNodeHelperfails. The nil is stored in theNodeListand survives all the way to the printer, which dereferences the list's last element while deciding whether to write a trailing comma:Every other caller of
reuseNodecopes with the nil —reuseTypeNodein particular reports an inference fallback and re-serializes the node from the checker. This one call site does not.When it happens
Four things have to line up:
SingleCallSignatureand tries to reuse the original type parameter nodes.unique symbolfrom the test case below and you get a perfectly good<K extends "count" | "name">. It only returns nil when the rewrite itself reaches something unnameable..d.ts.The original report came from a mobx-state-tree codebase, where all four fall out of ordinary usage: a generic
<Key extends keyof typeof self>setter declared inside.actions(self => ({ ... })), MST'sunique symbolbrands, andtypes.composepulling models in across files.Why it only reproduces on the incremental path
For a
noEmitordeclaration: falseproject the declaration printer runs in exactly one place — computing d.ts shape signatures. InaffectedFilesHandler.updateShapeSignature:A cold run takes the
useFileVersionAsSignature: truepath and hashes the file text, so nothing is ever printed. A warm run computes the real d.ts signature for each affected file, prints the malformed tree, and crashes.A direct
--declaration --emitDeclarationOnlybuild hides the bug from the other side: files in this shape always carry declaration diagnostics (TS2527 / TS4023), and their emit is skipped. So the malformed node list is built on every run; only the signature path ever prints it.The crash is not limited to
noEmit. Onmainthe repro below also crashes with a plain JS-emittingincrementalbuild and withcomposite: true.The fix
Fall back to serializing the type parameter from the checker, mirroring what
reuseTypeNodealready does for type nodes.typeParameterToDeclarationalways returns a node, so the list can no longer contain a nil.Test
internal/execute/tsctestsgains a two-file incremental scenario underTestTscDeclarationEmit. Without the fix it panics; with it the baseline shows the signature being computed correctly:Both declaration diagnostics (TS2527 and TS4023) are still reported, so the only behavioural change is that a crash becomes a correctly serialized type parameter.
Verification
npx hereby build,npx hereby lint(0 issues),npx hereby format(no changes),npx hereby test— all pass.internal/testrunneragainst the TypeScript submodule, produces no changed baselines other than the new one added here.AI assistance disclosure
Per CONTRIBUTING.md: this patch was authored with the help of Claude Code. This is not a queue-driven contribution — #4748 is my own bug report against my own codebase, and I drove this investigation myself. I have read and understand the change, and I will be the one handling review feedback.