Skip to content

fix: grow the ts_lsp AST walk stacks so wide files keep every declaration - #2091

Merged
DeusData merged 7 commits into
DeusData:mainfrom
CaptainMittens:fix/ts-lsp-walk-stack-drops-nodes
Oct 25, 2026
Merged

DeusData merged 7 commits into
DeusData:mainfrom
CaptainMittens:fix/ts-lsp-walk-stack-drops-nodes

Conversation

@CaptainMittens

@CaptainMittens CaptainMittens commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

The problem

Three AST walks in internal/cbm/lsp/ts_lsp.c held at most 256 nodes in a fixed TSNode stack[256] and stopped pushing when it filled. Nothing reported the stop, so each pass answered as if it had seen the whole file.

In rebuild_signatures_from_ast and convert_signature_type_params the seeding loop alone can fill all 256 slots, because it pushes every top-level child of the file. A file with more than 256 top-level declarations therefore skipped every declaration past that point. Each one kept whatever signature the first extraction pass had guessed, and a generic function past the cap kept NAMED where it needed TYPE_PARAM, so calls through it stopped resolving.

In infer_return_type_from_body the same cap made a wide function body answer "unknown return type" for a return the walk had never reached.

The fix

The repo already carries the module that solves this: internal/cbm/extract_node_stack.h, written for #199 to replace fixed TSNode stack[] arrays that silently drop AST subtrees. Nine extract files use it. ts_lsp.c was never converted. This converts it.

The header gains ts_nstack_init_arena, which takes an arena directly, because the LSP passes hold a TSLSPContext rather than a CBMExtractCtx. ts_nstack_init keeps its context argument — that argument is what makes handing over the wrong arena a type error — and now delegates to the new function, so its behaviour is unchanged.

Two things follow from using the shared module:

  • Traversal order flips from last-child-first to first-child-first, because ts_nstack_push_children reverses on push. For infer_return_type_from_body the new order matches the comment the function already carried: it finds the first return, where the old code found the last.
  • One O(N^2) loop goes away. ts_nstack_push_children does a single cursor pass instead of ts_node_child(n, i) per child.

Which arena

The stacks are cut from ctx->arena. collect_children at ts_lsp.c:201 already allocates the same kind of per-file scratch from that arena, and the per-file scratch arena added in #1997 never reaches cbm_run_ts_lsp — cbm.c:1690 passes a. Threading a second arena into this file would touch every allocation in it, which is a larger change than this fix needs.

Test

Two tests, one per observable site. Each repeats an existing passing test's claim, moved past the old cap:

  • tslsp_stress_declarations_past_stack_cap — the claim of tslsp_generic_identity_inference, 300 top-level declarations deeper. Covers convert_signature_type_params.
  • tslsp_stress_return_past_stack_cap — the claim of tslsp_return_inferred_local, with the return behind 300 statements. Covers infer_return_type_from_body.

Without this change the suite is 303 passed, 2 failed, both failures being these two. With it, 305 passed, 0 failed.

rebuild_signatures_from_ast has no test of its own: it shares the seeding shape with convert_signature_type_params, and I could not find an observable difference it produces alone that the first test does not already cover.

The full local suite is 7949 passed, 2 failed, 7 skipped. Both failures are subprocess_run_clean and subprocess_run_exit_nonzero, which return CBM_PROC_SPAWN_FAILED because my local sandbox blocks fork/exec of /bin/sh. Nothing in this diff reaches fork or exec, so they are unrelated to it; I did not re-run the full suite on the reverted tree, only the ts_lsp suite.

…tion

Three walks in internal/cbm/lsp/ts_lsp.c held at most 256 AST nodes in a
fixed array and stopped pushing when it filled. Nothing reported the stop,
so the passes answered as if they had seen the whole file.

In rebuild_signatures_from_ast and convert_signature_type_params the
seeding loop alone can fill all 256 slots, because it pushes every
top-level child of the file. A file with more than 256 top-level
declarations therefore skipped every declaration past that point: each one
kept whatever signature the first extraction pass had guessed, and a
generic function past the cap kept NAMED instead of TYPE_PARAM, so calls
through it stopped resolving. In infer_return_type_from_body the same cap
made a wide function body answer "unknown return type" for a return the
walk had never reached.

The repo already carries the module that solves this:
internal/cbm/extract_node_stack.h, written for GitHub issue DeusData#199 to
replace fixed TSNode stack[] arrays that silently drop subtrees. Nine
extract files use it; ts_lsp.c was never converted. This converts it.

The header gains ts_nstack_init_arena, which takes an arena directly,
because the LSP passes hold a TSLSPContext rather than a CBMExtractCtx.
ts_nstack_init keeps its context argument, which is what makes handing
over the wrong arena a type error, and now delegates to the new function.

Traversal order changes from last-child-first to first-child-first,
because ts_nstack_push_children reverses on push. That also drops an
O(N^2) ts_node_child(n, i) loop for one cursor pass. For
infer_return_type_from_body the new order matches the comment the
function already carried: it finds the first return, where the old code
found the last.

New test tslsp_stress_declarations_past_stack_cap makes the same claim as
tslsp_generic_identity_inference, 300 declarations deeper in the file.
Without this change it fails with "MISSING resolved call: caller~.go ->
callee~use"; with it the ts_lsp suite is 304 passed, 0 failed.

Signed-off-by: Joshua Richter <[email protected]>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

The first commit converted three walks in ts_lsp.c but tested only two of
them. infer_return_type_from_body was changed without a test of its own.

tslsp_stress_return_past_stack_cap makes the same claim as the existing
tslsp_return_inferred_local, with the return placed after 300 statements
— more than the 256 slots the old fixed array held, so the walk used to
stop before reaching it and answer "unknown return type".

Without the fix the suite is 303 passed, 2 failed, the second failure
being this test. With it, 305 passed.

Signed-off-by: Joshua Richter <[email protected]>
@CaptainMittens

CaptainMittens commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor Author

test / test-windows-guards is red on ec56ea8f. It is not this branch — it is the section_cold_storm race in tests/windows/test_daemon_stability.py, which is already open as #2057:

RED: cold-storm client 0 failed (racing daemon spawn):
codebase-memory-mcp: secure CLI coordination could not be created (endpoint)

Three things say it is unrelated:

  1. The same job passed on this branch's previous commit b974adf6, which already carried the whole ts_lsp.c and header change. The only difference in ec56ea8f is 30 lines inside tests/test_ts_lsp.c.
  2. The other six sections of that guard passed in the same run, including the permanent-daemon one.
  3. The identical failure appears on two other recent PR runs on unrelated branches — 34118689934 and 34098390879 — each with RED: cold-storm client 0 failed (racing daemon spawn) as the only red. That is the pattern test-windows-guards: test_daemon_stability turns setup failures and timeouts into REGRESSION verdicts on unrelated PRs #2057 describes.

I cannot re-run the job myself (gh run rerun needs admin rights here). Could you re-run that one job when convenient?

@DeusData DeusData added bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Sep 9, 2026
@DeusData

Copy link
Copy Markdown
Owner

Thank you for isolating these walks and supplying the before/after regressions. The capped pushes are still present on main, so this addresses a concrete omission. Please make allocation failure fail the two new tests rather than pass, and pin the acknowledged return-traversal-order change with a focused multiple-return case or preserve the previous order. The Windows guard result still needs maintainer attribution; it is not a reason to request unrelated source changes from you.

@DeusData

Copy link
Copy Markdown
Owner

Thanks again, @CaptainMittens. This is a careful fix, and the before/after tests plus the "Which arena" write-up made it easy to verify. The capped walks are still on main (ts_lsp.c ~4647/4693/4857), so nothing here is superseded. Our recent stack work went to other walkers: #2373 (merged) is the definitions walk in extract_defs.c, and #2375 covers the complexity/branching walkers and the body-token sampler. Neither touches the three ts_lsp.c walks you converted, and your branch still merges cleanly with main.

Still open from the 19 September review:

  1. Allocation failure should fail the new tests, not pass them. In both tests, replace if (!buf) PASS(); with ASSERT_NOT_NULL(buf);. You followed the house style here: four existing tests in this file use the same pattern, and we'll fix those ourselves.
  2. The return-walk order. Either pin the new first-return behaviour with a small multiple-return case (for example function pick(f) { if (f) { return new A(); } return new B(); }, then assert that go -> a resolves; main's reverse walk picks B), or keep the old order. See also the suggestion below, which settles this.

One suggestion now that #2375 exists: once it lands, please rebase onto it and use its cbm_walk_stack_reserve / cbm_walk_stack_release (in internal/cbm/helpers.h) instead of TSNodeStack. The other LSPs already include "../helpers.h". There are two reasons, neither of which was visible when you opened this:

  • Retention. ctx->arena in the per-file pass is result->arena (cbm.c:2089), and the pipeline keeps it until the whole result cache is freed. That is the Full index build allocates ~11.5 GB on a ~14k-file TypeScript monorepo regardless of the configured memory budget (0.10.8) #1997 retention the header warns about: about 8 KB per walk, and infer_implicit_returns runs the return walk once per untyped top-level function, which in plain JS means every function. The reserve/release pair keeps the first chunk on the C stack and frees at the end of the walk.
  • Silent drop. ts_nstack_push silently stops pushing on out-of-memory, which is the same silent drop this PR removes. cbm_walk_stack_reserve logs and returns false, so the walker can bail out loudly ("unknown" is a safe answer for the return walk).

count_branching_iter in #2375 is the pattern to copy. To keep your O(N) enumeration, reserve top + ts_node_child_count(n) and fill it with a cursor pass. Pushing in cursor order without reversing gives back the old pop order exactly, which settles point 2. With that, the ts_nstack_init_arena header change is no longer needed.

The red Windows guard job is the #2057 cold-storm race you identified. That one's ours to attribute, and nothing on your side depends on it. Thanks for your patience with the queue!

@DeusData

Copy link
Copy Markdown
Owner

Thanks again for the wide-file regressions. Following up on the last review: allocation failure must fail the tests, and the walker update needs explicit traversal-order and scratch-lifetime handling. The reserve/release suggestion in that review is still the recorded path to evaluate; Windows guard attribution remains on the maintainer side.

DeusData and others added 2 commits October 20, 2026 19:27
Brings the branch up to date with main a222715. One conflict, in the
include block of internal/cbm/lsp/ts_lsp.c: main added
"../../../src/foundation/platform.h" where this branch added
"extract_node_stack.h". Resolved by keeping both; the next commit
replaces the second.

Signed-off-by: Martin Vogel <[email protected]>
Follow-up to the review of DeusData#2091, on top of the contributor's change.

The three ts_lsp.c walks (infer_return_type_from_body,
rebuild_signatures_from_ast, convert_signature_type_params) now keep
their first 256 slots on the C stack, as the old fixed arrays did, and
grow on the heap through cbm_walk_stack_reserve / cbm_walk_stack_release
(DeusData#2375) instead of cutting TSNodeStack storage from ctx->arena:

- Scratch lifetime: ctx->arena in the per-file pass is the result
  arena, kept until the result cache is freed, and the return walk runs
  once per untyped top-level function. The heap chunk is now released
  when each walk ends.
- No silent drop: when the stack cannot grow, cbm_walk_stack_reserve
  logs extract.walk_stack_alloc_failed and the walk stops. The return
  walk then answers "unknown"; the signature walks leave the
  declarations not yet reached as extract_defs registered them.
- Traversal order: children are filled in cursor order (still one O(N)
  pass) and popped last-child first, exactly as main's walks did, so of
  several returns the later one is still tried first.
  tslsp_return_walk_order_prefers_later_return pins it; it fails under
  a first-child-first walk.

The TSNodeStack arena initializer in extract_node_stack.h is no longer
needed and is dropped, so that header is unchanged from main. The two
stack-cap tests now fail instead of passing when their buffer
allocation fails.

Co-authored-by: Joshua Richter <[email protected]>
Signed-off-by: Martin Vogel <[email protected]>
@DeusData

Copy link
Copy Markdown
Owner

Thank you, @CaptainMittens. This was a careful fix: you found that the seeding loop alone fills the old 256-slot stacks on a wide file, you pinned both observable sites with tests that fail on the old code, and the "Which arena" write-up made the review easy. Since the review points were already settled and we can push to your branch, we finished them here instead of asking you for another round.

What we changed, on top of your two commits (which are untouched):

  1. Merged current main in (one merge commit). main had added an include next to yours at the top of ts_lsp.c; we kept both, then replaced yours in the next commit.
  2. The three walks now use cbm_walk_stack_reserve / cbm_walk_stack_release (from fix(extract): AST walker stacks grow instead of silently capping content #2375), following count_branching_iter. The first 256 slots stay on the C stack, as before, and only a wider walk grows on the heap, which is freed when the walk ends. That answers the scratch-lifetime point: nothing is cut from the per-file result arena any more. If the stack cannot grow, the failure is logged and the walk stops: the return walk answers "unknown", and the signature walks leave the remaining declarations as extract_defs registered them. Your ts_nstack_init_arena helper is no longer needed, so extract_node_stack.h is back to its main version.
  3. Traversal order is unchanged from main. Children are filled with a single cursor pass, so your O(N) enumeration stays, but in cursor order, so they are still visited last-child first. A new test, tslsp_return_walk_order_prefers_later_return (the multiple-return shape from the review), pins that: it passes on main and with this change, and fails under a first-child-first walk.
  4. Your two stack-cap tests now fail if their buffer allocation fails: ASSERT_NOT_NULL(buf) instead of PASS(). The four older tests with the same pattern are left for a separate change, as the review said.

How we checked it: both of your tests fail on the old code and pass now; the full ts_lsp suite and the neighbouring LSP and complexity suites pass; and indexing the TypeScript compiler's own src/ (709 files, including compiler/utilities.ts with roughly 890 top-level declarations) produces exactly the same graph as main, so nothing that used to resolve has moved.

We will merge once CI is green on the new head. Thank you again for the patience, and for a fix that was easy to build on.

@DeusData

Copy link
Copy Markdown
Owner

Thanks @CaptainMittens for fixing the truncated TypeScript walks and adding the wide-file regressions! I added a small follow-up in 060b56a: the two signature passes now share a temporary lookup index, so visiting every declaration no longer repeats a full registry scan. It preserves the first local match and traversal order, and adds checked stack-size arithmetic.

The deterministic 300/600-declaration test failed on the original implementation, passed with the fix, and failed again when the index was reverted. The final TypeScript LSP suite passed all 318 tests on macOS arm64 with ASan/UBSan. Thanks again for the contribution—this keeps the larger walks complete without the extra lookup cost.

@DeusData

Copy link
Copy Markdown
Owner

I also merged current main in 22f41f1 to bring in the shared CI fixes and obtain a fresh hosted result. The PR patch relative to main is byte-for-byte unchanged by that merge, and all of your commits remain intact. Thanks @CaptainMittens! The required GitHub checks are the remaining gate.

@DeusData

Copy link
Copy Markdown
Owner

Thank you again for the wide-file TypeScript regressions. The October updates record the traversal, allocation-failure, and lookup-cost updates as completed on our side, so no repeat contributor work is requested. Sorry for the delay in the remaining maintainer follow-up—I’ll follow up soon.

@DeusData
DeusData merged commit 4a1e6d1 into DeusData:main Oct 25, 2026
26 checks passed
@DeusData

Copy link
Copy Markdown
Owner

Merged -- thank you, @CaptainMittens! You found that the seeding loop alone fills the old 256-slot stacks on any file with more than 256 top-level declarations, so everything past that point was silently skipped, and you pinned both observable sites with regressions that fail on the old code. That made the fix easy to verify and easy to build on.

What landed, for the record:

  1. Your two wide-file regressions: declarations past the old cap, and a return placed behind 300 statements.
  2. The three walks in ts_lsp.c now grow past 256 entries instead of stopping. The first 256 slots stay on the C stack, the traversal order is unchanged, and a walk that cannot grow is logged instead of being cut short without a word.
  3. A shared lookup index for the two signature passes, so visiting every declaration of a wide file stays linear in the size of the file.

Before merging we also indexed the TypeScript compiler's own src/ (709 files, including compiler/utilities.ts with roughly 890 top-level declarations) with and without the change: the graph came out identical, node for node and edge for edge, so nothing that already resolved has moved. Thanks again for a careful fix and for your patience with the queue!

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

Labels

bug Something isn't working parsing/quality Graph extraction bugs, false positives, missing edges priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants