Skip to content

LSP: after an on-disk edit to the module defining a generic class, an unchanged importer sees __getitem__ return the unsolved T #5012

Description

@gaborv

Describe the Bug

A generic class and a second class live in the same module. When that module is edited on disk (reported through workspace/didChangeWatchedFiles), an open file that uses the generic class through another module gets Indexer[Item].__getitem__ typed as returning the bare type parameter T instead of Item. Attribute access on the result then fails with Object of class 'object' has no attribute …. A fresh pyrefly check of the same files reports no error. Restarting the language server clears it.

We hit this in VS Code after a git pull fast-forwarded the checkout under a running server: a file nobody had touched showed a new error until the server was restarted.

Reproduction

The script below is self-contained (standard library only). It writes four small modules and an empty pyrefly.toml to a temp directory, starts pyrefly lsp, opens app.py, edits indexing.py on disk, reports the change through workspace/didChangeWatchedFiles, and prints app.py's diagnostics before and after. It then runs a fresh pyrefly check on the edited files.

python repro.py [path/to/pyrefly]
repro.py
"""Pyrefly LSP: after an on-disk edit to the module that defines a generic class, an unchanged,
open importer sees `Indexer[Item].__getitem__` return the unsolved `T` (reported as `object`).

Usage: python repro.py [path/to/pyrefly]     stdlib only; the default is `pyrefly` on PATH
"""

import json
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path

PYREFLY = sys.argv[1] if len(sys.argv) > 1 else "pyrefly"

FILES = {
    "pyrefly.toml": "",
    "indexing.py": """\
class Indexer[T]:
    def __getitem__(self, idx: int) -> T:
        raise NotImplementedError


class Provider:
    size: int = 0
""",
    "holder.py": """\
from indexing import Indexer


class Item:
    day: int = 0


class Holder:
    items: Indexer[Item]
""",
    "user.py": """\
from indexing import Provider


def use(provider: Provider) -> None:
    pass
""",
    "app.py": """\
from typing import reveal_type

from holder import Holder
from user import use


def first_day(holder: Holder, provider) -> int:
    day = holder.items[0].day
    reveal_type(holder.items.__getitem__)
    use(provider)
    return day
""",
}

# One line added above `Indexer`, and `Provider` loses its attribute. Either change alone does not reproduce.
EDITED_INDEXING = """\

class Indexer[T]:
    def __getitem__(self, idx: int) -> T:
        raise NotImplementedError


class Provider:
    pass
"""


class Client:
    def __init__(self, cwd: Path) -> None:
        self.proc = subprocess.Popen([PYREFLY, "lsp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                     stderr=subprocess.DEVNULL, cwd=cwd)
        self.lock = threading.Lock()
        self.pending: dict[int, threading.Event] = {}
        self.next_id = 1
        self.last_message = time.time()
        self.diagnostics: dict[str, list] = {}
        threading.Thread(target=self._read, daemon=True).start()

    def _send(self, msg: dict) -> None:
        body = json.dumps({"jsonrpc": "2.0", **msg}).encode()
        with self.lock:
            self.proc.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
            self.proc.stdin.flush()

    def request(self, method: str, params: dict) -> None:
        done = self.pending[self.next_id] = threading.Event()
        self._send({"id": self.next_id, "method": method, "params": params})
        self.next_id += 1
        done.wait(60)

    def notify(self, method: str, params: dict) -> None:
        self._send({"method": method, "params": params})

    def _read(self) -> None:
        out = self.proc.stdout
        while True:
            length = 0
            while line := out.readline().strip():
                if line.lower().startswith(b"content-length"):
                    length = int(line.split(b":")[1])
            if not length:
                return
            msg = json.loads(out.read(length))
            self.last_message = time.time()
            if "id" in msg and "method" in msg:  # a server request: defaults for configuration, null otherwise
                items = msg["params"].get("items") if msg["method"] == "workspace/configuration" else None
                self._send({"id": msg["id"], "result": [{}] * len(items) if items is not None else None})
            elif "id" in msg:
                self.pending.pop(msg["id"]).set()
            elif msg.get("method") == "textDocument/publishDiagnostics":
                self.diagnostics[msg["params"]["uri"]] = msg["params"]["diagnostics"]

    def settle(self) -> None:
        """Wait until the server has been quiet for two seconds."""
        while True:
            time.sleep(0.25)
            if time.time() - self.last_message > 2:
                return


root = Path(tempfile.mkdtemp()).resolve()
for rel, text in FILES.items():
    (root / rel).parent.mkdir(parents=True, exist_ok=True)
    (root / rel).write_text(text)
app, indexing = root / "app.py", root / "indexing.py"


def show(client: Client, label: str) -> None:
    print(label)
    for d in client.diagnostics.get(app.as_uri(), []):
        print(f"  app.py:{d['range']['start']['line'] + 1}: [{d.get('code')}] {d['message']}")


client = Client(cwd=root)
client.request("initialize", {
    "processId": None, "rootUri": root.as_uri(),
    "workspaceFolders": [{"uri": root.as_uri(), "name": "repro"}],
    "capabilities": {"workspace": {"configuration": True, "workspaceFolders": True,
                                   "didChangeWatchedFiles": {"dynamicRegistration": True}}},
})
client.notify("initialized", {})
client.notify("textDocument/didOpen", {"textDocument": {
    "uri": app.as_uri(), "languageId": "python", "version": 1, "text": app.read_text()}})
client.settle()
show(client, "before the edit:")

indexing.write_text(EDITED_INDEXING)  # on disk, as `git pull` or a branch switch would
client.notify("workspace/didChangeWatchedFiles", {"changes": [{"uri": indexing.as_uri(), "type": 2}]})
client.settle()
show(client, "after the edit, incremental:")
client.proc.kill()

fresh = subprocess.run([PYREFLY, "check", "--output-format", "min-text", "app.py"], cwd=root,
                       capture_output=True, text=True)
print("fresh `pyrefly check` of the edited files:")
print("  " + ("\n  ".join(fresh.stdout.split("\n")).strip() or "no errors"))

The modules:

# indexing.py
class Indexer[T]:
    def __getitem__(self, idx: int) -> T:
        raise NotImplementedError


class Provider:
    size: int = 0
# holder.py
from indexing import Indexer


class Item:
    day: int = 0


class Holder:
    items: Indexer[Item]
# user.py
from indexing import Provider


def use(provider: Provider) -> None:
    pass
# app.py  (open in the editor, never changed)
from typing import reveal_type

from holder import Holder
from user import use


def first_day(holder: Holder, provider) -> int:
    day = holder.items[0].day
    reveal_type(holder.items.__getitem__)
    use(provider)
    return day

The edit to indexing.py adds a blank line above Indexer and removes Provider's attribute:

class Indexer[T]:
    def __getitem__(self, idx: int) -> T:
        raise NotImplementedError


class Provider:
    pass

Output (1.3.1 and 1.4.0-dev.1 print the same)

before the edit:
  app.py:9: [reveal-type] revealed type: (idx: int) -> Item
after the edit, incremental:
  app.py:8: [missing-attribute] Object of class `object` has no attribute `day`
  app.py:9: [reveal-type] revealed type: (idx: int) -> T
fresh `pyrefly check` of the edited files:
  INFO app.py:9:16-42: revealed type: (idx: int) -> Item [reveal-type]

Expected: after the edit, __getitem__ is still (idx: int) -> Item, and there is no missing-attribute error, matching the fresh check.

What is needed to trigger it

Deterministic in our runs. Each of these is required; removing any one makes the error disappear:

  • Both parts of the edit. Only shifting Indexer down a line, or only changing Provider, does not reproduce.
  • app.py calling a function whose signature mentions Provider (use(provider) here), from a module other than indexing.py.
  • A config file. An empty pyrefly.toml is enough. Without one, it does not reproduce.

It does not depend on the interpreter or on the attribute's name. It also reproduces with the modules in a package under src/.

Possibly related

#4216 and #4171 also describe incremental rechecks that end in a different state from a fresh check. We could not tell whether the cause is shared.

Sandbox Link

Not possible: the bug needs an on-disk edit under a running language server. The self-contained script above reproduces it instead.

(Only applicable for extension issues) IDE Information

  • Pyrefly VS Code extension 1.3.1, with pyrefly.lspPath pointing at a pinned pyrefly 1.3.1 binary. The script reproduces it without the editor, against 1.3.1 and 1.4.0-dev.1.
  • macOS 15, arm64.
  • The output pane shows no panic or error around the recheck; the only warnings are unrelated config-migration messages.
  • The workspace is multi-root, but one folder is enough to reproduce.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    crashlanguage-serverIssues specific to our IDE integration rather than type checking

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions