Skip to content

Fix crash when a call signature's type parameter cannot be reused - #4842

Closed
Nicolaev Eduard (nikeedw) wants to merge 1 commit into
microsoft:mainfrom
nikeedw:fix-4748-nil-type-parameter
Closed

Fix crash when a call signature's type parameter cannot be reused#4842
Nicolaev Eduard (nikeedw) wants to merge 1 commit into
microsoft:mainfrom
nikeedw:fix-4748-nil-type-parameter

Conversation

@nikeedw

Copy link
Copy Markdown

Fixes #4748.

The crash

The PseudoTypeKindSingleCallSignature branch of pseudoTypeToNode appends the result of reuseNode into a call signature's type parameter list without checking it:

for _, tp := range d.TypeParameters {
    res = append(res, b.reuseNode(tp.AsNode()))
}
typeParams = b.f.NewNodeList(res)

reuseNode returns nil whenever the recovery boundary in tryReuseExistingNodeHelper fails. The nil is stored in the NodeList and survives all the way to the printer, which dereferences the list's last element while deciding whether to write a trailing comma:

ast.(*Node).End                                 ast.go:193
ast.(*NodeList).HasTrailingComma                ast.go:142
printer.(*Printer).hasTrailingComma             printer.go:4774
printer.(*Printer).emitListRange                printer.go:4755
printer.(*Printer).emitTypeParameters           printer.go:1506
printer.(*Printer).emitFunctionType             printer.go:1928
...
compiler.(*emitter).emitDeclarationFile         emitter.go:269

Every other caller of reuseNode copes with the nil — reuseTypeNode in 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:

  1. A generic arrow-function property in an object literal, so the pseudochecker classifies it as SingleCallSignature and tries to reuse the original type parameter nodes.
  2. The type parameter's constraint references a binding that cannot be named from the file being emitted.
  3. Rewriting that constraint has to fail as well. Normally reuse does not fail here, it rewrites — drop the unique symbol from 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.
  4. The member has to be inlined structurally into another file's declaration. A single-file version does not reproduce, because the local is nameable in its own .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's unique symbol brands, and types.compose pulling models in across files.

Why it only reproduces on the incremental path

For a noEmit or declaration: false project the declaration printer runs in exactly one place — computing d.ts shape signatures. In affectedFilesHandler.updateShapeSignature:

if !file.IsDeclarationFile && !useFileVersionAsSignature {
    update.signature = h.computeDtsSignature(file)
}

A cold run takes the useFileVersionAsSignature: true path 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 --emitDeclarationOnly build 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. On main the repro below also crashes with a plain JS-emitting incremental build and with composite: true.

The fix

Fall back to serializing the type parameter from the checker, mirroring what reuseTypeNode already does for type nodes. typeParameterToDeclaration always returns a node, so the list can no longer contain a nil.

Test

internal/execute/tsctests gains a two-file incremental scenario under TestTscDeclarationEmit. Without the fix it panics; with it the baseline shows the signature being computed correctly:

export declare const merged: {
    setField: <K extends "count" | "name" | unique symbol>(key: K, value: ({
        name: string;
        count: number;
        [brand]: boolean;
    })[K]) => void;
};

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

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.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Note

This error may be related to your runner configuration. You can now configure runners for Copilot code review separately from Copilot cloud agent by creating a copilot-code-review.yml file with your setup steps. Read the docs for details.

@nikeedw

Copy link
Copy Markdown
Author

Thanks for running CI — everything came back green, including the Windows and race-mode legs I could not run locally.

On the CLA: I am not going to sign it, so please do not hold this open waiting on that. It is nothing about the agreement itself — I simply do not want to enter into it for something that started as a bug report against my own codebase.

Practically that means this PR should not be merged as it stands. I would suggest treating it as a written-up proposal rather than a contribution. The diagnosis, the minimal repro and the reasoning for why the nil survives into the printer are all in #4748 and in the description above, and the regression test is small enough to re-derive from scratch.

One caveat I would rather state myself than have someone find later: this may only be treating the symptom. The change makes the type-parameter path handle a nil from reuseNode the same way reuseTypeNode already does. That is consistent with the surrounding code and it does remove the crash — but it does not answer why tryReuseExistingNodeHelper fails to finalize the boundary here in the first place. If the real defect sits upstream of that, in when the pseudochecker fast path is entered at all or in the recovery boundary itself, then the correct fix belongs somewhere else and this one merely hides it. I do not know the codebase well enough to judge that. You do.

So it is entirely your call — adopt the approach, pick a different one, or close this. Either way the crash in #4748 is real and reproduces from the two files above.

@nikeedw

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@nikeedw

Copy link
Copy Markdown
Author

CLA signed above (I reconsidered — my mistake, apologies). Could not reopen this PR myself; superseded by #4846, same branch unchanged. Context in #4748 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Panic: nil pointer in NodeList.HasTrailingComma during incremental rebuild (build-mode declaration printer) — 7.0.2 and current nightly

3 participants