Skip to content

LSP recheck can panic after an export change propagates through a deep dependency chain #4171

Description

@teerthsharma

LSP recheck can panic after an export change propagates through a deep dependency chain

Pyrefly commit: c195e9fbb5c47394987dfd9d1221b1ff27a205ec (1.2.0-dev.2), release build, Windows, four worker threads.

No matching open issue or PR was found for the panic text, epoch warning, or incremental dependency propagation failure.

Observed behavior

A saved type change propagated through a generated chain of modules. With 192 two-module SCC layers, the update completes. At 200 layers, the first updated diagnostic arrives, but the following save panics. At 208 or more layers, the first save panics.

The terminal warning and panic are:

Exceeded maximum epochs (100) without stabilizing. ... Forcing invalidation.
Transaction has uncommitted changes

The panic is at State::commit_transaction in pyrefly/lib/state/state.rs.

Runtime trace

With 32 layers, verbose logging shows 35 epochs and one changed export per epoch:

leaf -> a_0 -> a_1 -> ... -> a_31 -> main -> stabilization

With 192 layers, the first run reaches epoch 100 after a_98, invokes the fallback, and a second run needs 94 epochs to reach main and stabilize.

The coarse fallback has already transitively marked a_99 through main dirty, but its single run_step changes only a_99. The remaining closure was processed before its dependencies became current and cannot be re-dirtied in that same epoch.

The same 100 + 94 pattern occurs with one worker, ruling out parallel races as a necessary cause and making a sequential dependency-first fallback a viable minimum-code experiment.

Source-level cause

When demand observes changed exports, affected direct rdeps are added to TransactionData.dirty. run_step transfers that set into TaskHeap only at the beginning of the next epoch, so a long path advances roughly one edge per epoch.

After MAX_EPOCHS, run_internal calls invalidate_rdeps and performs one final run_step. That final step can record more changed exports, but the fallback returns without draining them. The later commit assertion then rejects the transaction.

The cap and fallback were introduced by c6ca82ee2 as defense-in-depth for fine-grained cycle detection. Existing tests cover overlapping cycles, independent export changes, and short re-export chains. They do not cover a finite acyclic chain that exceeds the cap.

Expected behavior

Deep but finite acyclic propagation should converge or fail with a controlled error; it should not leave a committable transaction in an internally inconsistent state.

Design question before a PR

Would maintainers prefer this scoped as:

  1. a regression test plus the smallest correctness fix that guarantees the fallback drains changed before commit;
  2. repeating the current unordered coarse fallback until stable; or
  3. traversing the already-invalidated closure sequentially by dependency-first SCC condensation, benchmarked against option 2 and an importer-first same-cost null?

I have a deterministic long-lived LSP harness and successful/failing traces, but have intentionally not prepared a source patch pending agreement on scope.

Sandbox / IDE information

This reproduces through a direct stdio LSP client rather than the VS Code extension. A Pyrefly Sandbox link cannot model the required sequence of opening two files, changing the leaf in memory, writing it, and sending didSave across hundreds of generated modules. The reproducer is deterministic and can be translated into an LspInteraction regression test once scope is agreed.

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions