Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
bc60e7b
feat(vscode): classify lint config dependency failures
fi3ework Sep 4, 2026
c127b19
feat(vscode): report lint config dependencies as not installed
fi3ework Sep 4, 2026
1677311
feat(vscode): suppress fmt config dependency toasts
fi3ework Sep 4, 2026
e21a62c
feat(vscode): poll for installed dependencies
fi3ework Sep 4, 2026
824ce6b
docs(vscode): record not-installed recovery decision
fi3ework Sep 4, 2026
bc6768b
fix(vscode): preserve config dependency failure states
fi3ework Sep 4, 2026
dc3825c
refactor(vscode): tighten recovery integration seams
fi3ework Sep 4, 2026
f7d3254
test(vscode): assert dependency recovery episodes
fi3ework Sep 7, 2026
4699bf7
fix(vscode): complete recovery episode contracts
fi3ework Sep 7, 2026
48fad68
test(vscode): spawn pnpm portably in recovery E2E
fi3ework Sep 7, 2026
876a02d
fix(vscode): preserve rejected config refresh errors
fi3ework Sep 7, 2026
e129dbf
fix(vscode): limit config retries and close failed workers
fi3ework Sep 7, 2026
81ec4ae
fix(vscode): attribute bridge failures to the root config
fi3ework Sep 7, 2026
6c0a9f0
fix(vscode): retain config source-change retries
fi3ework Sep 7, 2026
12d1747
fix(vscode): retry missing Rstest core installations
fi3ework Sep 7, 2026
9322471
fix(vscode): defer startup config race failures
fi3ework Sep 7, 2026
19a15d1
refactor(vscode): remove dependency poll test counter
fi3ework Sep 7, 2026
cf41f41
refactor(vscode): inline lint startup reporting guards
fi3ework Sep 7, 2026
da1cdd8
refactor(vscode): collapse fmt message presentation
fi3ework Sep 7, 2026
fcd7061
refactor(vscode): inline raw not-installed folds
fi3ework Sep 7, 2026
0c194d4
refactor(vscode): share config dependency failure type
fi3ework Sep 7, 2026
fdecb46
refactor(vscode): discriminate lint config refresh verdicts
fi3ework Sep 7, 2026
002e2a9
refactor(vscode): share not-installed warning episodes
fi3ework Sep 7, 2026
4a36aad
refactor(vscode): reset config refresh errors per request
fi3ework Sep 7, 2026
45eb638
refactor(vscode): set fmt config after construction
fi3ework Sep 7, 2026
f107e84
refactor(vscode): carry root bridge config in detection
fi3ework Sep 7, 2026
97e048e
refactor(vscode): import lint protocol directly
fi3ework Sep 7, 2026
d7f8df1
refactor(vscode): require config dependency observer
fi3ework Sep 7, 2026
412e167
refactor(vscode): make status report hook parameterless
fi3ework Sep 7, 2026
385651e
refactor(vscode): consume config dependency verdict on read
fi3ework Sep 7, 2026
64b2e40
refactor(vscode): record E2E warnings at output channels
fi3ework Sep 7, 2026
710a530
refactor(vscode): name formatting episode mutation explicitly
fi3ework Sep 7, 2026
e960657
fix(vscode): pick the root rstack config by loader precedence
fi3ework Sep 7, 2026
8f255d6
fix(vscode): retry a core lost after config load
fi3ework Sep 7, 2026
fc5f80f
fix(vscode): refresh bridge attribution on detection
fi3ework Sep 7, 2026
6593c86
fix(vscode): prefer real config errors over missing dependencies
fi3ework Sep 7, 2026
e364fd1
feat(vscode): poll every minute until every stack is running
fi3ework Sep 7, 2026
5cc33d1
fix(vscode): replace stopped runtimes on dependency polls
fi3ework Sep 7, 2026
0d4dfba
fix(vscode): keep failed fmt sessions polling, dedupe Rstest resolve …
fi3ework Sep 7, 2026
cda9a06
fix(vscode): poll raw Rstest failures, unblock lint reconcile from hu…
fi3ework Sep 7, 2026
653184f
refactor(vscode): ablate unneeded abstractions and mirror tests
fi3ework Sep 8, 2026
972f8de
fix(vscode): report raw Rstest failures to the shell, rebuild watcher…
fi3ework Sep 8, 2026
74d697b
refactor(vscode): simplify not-installed recovery paths
fi3ework Sep 8, 2026
27ec8f5
fix(vscode): recollect Rstest files after unchanged-config recovery, …
fi3ework Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ packages/vscode/.playground/
packages/vscode/e2e/fixtures/*/node_modules/
packages/vscode/e2e/fixtures/*/pnpm-lock.yaml
packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml
packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml
packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml

# Build-time copy of the workspace root LICENSE (see rslib.config.mts)
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-fmt-lsp-on-user-node-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ Falling back to the VS Code Node runtime stays rejected — ADR 0001's load-bear
- There is no cold path any more, so formatting is briefly unavailable after activation, after a restart and after a config change, while that folder's server starts. A format requested before the client has registered the server's capability finds no formatter for the document; nothing falls back to a fresh process.
- The server advertises document formatting only: no range or selection formatting, no format-on-type, no diagnostics. It also ignores the editor's `FormattingOptions` (tab size, spaces) and the client's language id — the file path picks the parser and the project's config decides the style. An editor setting that disagrees with the project config loses, which is the same answer `rs fmt` gives in a terminal.
- Failures stay per folder and stay statuses: no `rstack` installed is `disabled`, an `rstack` below `0.5.2` is `version mismatch`, no Node clearing the floor is `version mismatch` with fmt's own consequence appended to the shared preflight message ("until then rs fmt will not format"), and only a server that fails to launch or stops on its own is `crashed`. One folder in any of those states does not affect another folder's server, and the stack's single status report is the folder set folded by severity (`crashed` > `version mismatch`, which is also where a running folder's pin advisory ranks > `disabled` > `starting` > `running`; the pure fold lives in `stacks/fmt/status.ts`), so a healthy sibling starting or recovering never overwrites another folder's failure — and the status says `starting`, not `running`, until a server actually formats.
- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Detection notifies on lockfile events even when the folder set is unchanged precisely so that the install or upgrade that fixes a failed resolution is picked up without a manual restart. The remaining blind spot is an install that changes no lockfile (a fresh clone whose lockfile is already current): no file event fires, so the `disabled` status names the restart command as the way out. Watching `node_modules` for that case was rejected (unreliable under pnpm's layout and excluded by VS Code's default watcher excludes), and a bundled fallback formatter — the usual way editor extensions mask this blind spot — is ruled out by resolve-from-project.
- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Dependency installation recovery, including the unchanged-lockfile blind spot and the rejected watcher alternatives, is governed by ADR 0005.
- "A subproject becomes its own workspace folder" means a _sibling_ folder (or opening only the subproject). Keeping the parent **and** the nested subdirectory as workspace folders with fmt detected in both is a documented limitation: the parent's selector also matches the nested files, and which of the two servers VS Code asks is not defined. Per-document routing to the deepest folder was considered (lint carries a `WorkspaceDocumentRouter` for exactly this) and deferred — complexity the scenario does not yet justify.
- The extension now holds one long-lived Node process per detected folder for fmt. Each is owned by the same process owner the lint client uses, so a stop is bounded (SIGTERM, then SIGKILL) and the automatic restart vscode-languageclient performs cannot leave an orphan behind.
2 changes: 1 addition & 1 deletion docs/adr/0003-lint-through-editor-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,5 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na
- **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — has since been synced (issue #13): a **Lint runtime** is now one Rslint core inside one workspace folder, resolved per open document and refcounted by it, so a folder runs as many workers as its files have distinct cores (a bridged folder always exactly one, rstack's) and none at all while nothing is open. The worker never noticed: it still takes explicit `--core` / `--config` paths, which is precisely why that change did not touch it.
- **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (the supported config protocols lock `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does.
- **Config changes refresh, mode changes restart.** Rslint has a live refresh (`rslint/configRefresh` with the same `configPath`), unlike `rs fmt --lsp`, so the extension keeps its watcher-driven refresh — extended, for a bridged folder, with the root `rstack.config.*` — and the worker re-stamps `protocolVersion` and its `configPath` on every refresh (the extension does not know either). Only a native ↔ bridged flip, or a dependency change the refresh cannot absorb, restarts the server. This is the "diverge only when the tool forces it" rule: rslint can refresh, fmt cannot.
- **Failure states mirror fmt.** Bridged folder: no `rstack` → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Native folder missing `@rslint/core` stays `crashed` — the user asked for Rslint by name.
- **Failure states mirror fmt.** No `rstack`, no `@rslint/core`, or a config importing an absent package → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Config-import failures are classified where the worker still has the loader's structured error and carried to the editor as data; the live Go server remains available for a later refresh.
- The lint copy diverges further from upstream: the reverse-request adapter and plugin pool move into the worker unchanged in logic, and the extension-side `Rslint.ts` keeps only the language-client half. Recorded as an adaptation in `packages/vscode/AGENTS.md`.
39 changes: 39 additions & 0 deletions docs/adr/0005-not-installed-recovery-by-polling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
status: accepted
---

# Recover not-installed stacks by polling only while recovery is needed

Installing an already-locked project can populate `node_modules` without changing any config or lockfile. Detection's config and lockfile watchers then have no event to send, even though every stack deliberately resolves its toolchain from the project and needs another resolution pass. The status names the restart command as a fallback, but a fresh clone should recover without requiring it.

The lint stack previously added a direct watcher for `**/node_modules/@rslint/core/package.json`. A controlled VS Code Extension Host experiment opened a project with no `node_modules`, waited for `disabled`, installed with a frozen lockfile, and observed for 90 seconds without invoking a restart command. Lockfile bytes and nanosecond mtime were verified unchanged in every run:

| Install layout | Watcher event | Automatic recovery |
| -------------- | ------------- | ------------------ |
| npm flat | 2/2 | 2/2 |
| pnpm isolated | 0/2 | 0/2 |
| pnpm hoisted | 0/2 | 0/2 |

The result is not explained by pnpm symlinks: the hoisted core was an ordinary directory and still produced no matching event. Versions were VS Code 1.136.1, pnpm 11.20.0, and npm 11.17.0.

The watcher's original rationale was also factually wrong. At Microsoft VS Code commit [`008427a`](https://github.com/microsoft/vscode/commit/008427a901bf4aa79b47f175ccc8da1731750f78), the default `files.watcherExclude` contains only `.git/objects`, `.git/subtree-cache`, and `.hg/store`, each at the root and one directory below; it does not exclude `node_modules` ([`files.contribution.ts:294-310`](https://github.com/microsoft/vscode/blob/008427a901bf4aa79b47f175ccc8da1731750f78/src/vs/workbench/contrib/files/browser/files.contribution.ts#L294-L310)). The failure is the absent pnpm per-file event observed above, not a VS Code default exclude.

**Decision.** The extension shell owns one recursive 60-second timer. It exists while any live controller's raw folder/project/runtime state is disabled, crashed or version-mismatched, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes failed configs, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops when no failed state remains (running, starting or idle). Lockfile watchers stay as the lower-latency path.

The aggregate status is deliberately not the predicate: every owned raw failure needs recovery. A retry landing mid-install can read half-written `node_modules` and fail with a syntax error instead of a missing dependency. Continuing every minute through that real error makes the transient harmless without a provisional-error heuristic. Real errors still replace not-installed in status and Output; persistent error messages and not-installed warnings are deduplicated so retries do not log every minute. The restart hint remains in the status as an explicit fallback.

fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. After a missing dependency, a poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. A known real config error instead remains `crashed` across restarts until a format produces edits.

## Considered options

- **Direct `node_modules` watchers** — rejected by the experiment: they recovered npm but missed both pnpm layouts.
- **Package-manager marker files** such as `node_modules/.modules.yaml`, `node_modules/.package-lock.json`, or `.yarn-integrity` — rejected because each covers one installer/layout and makes recovery depend on private install artifacts rather than the state being recovered.
- **Retry on window focus** — rejected because an install can finish while focus never leaves VS Code, and unrelated focus changes would cause unbounded retries.
- **Bundled tool fallbacks** — rejected by the resolve-from-project contract: editor and CLI must run the same installed versions.

## Consequences

- Healthy workspaces incur no polling work. An unresolved workspace retries at most once per timer interval, through the existing serialized shell queue.
- Recovery no longer depends on installer-specific file events; lockfile watchers remain the faster path when they do fire.
- A real config error replaces not-installed without stopping recovery. Config events and the explicit restart command remain available alongside the minute poll.
- fmt cannot prove config recovery at initialize time. Only a later format producing edits ends its warning episode; empty edits are ambiguous because the server uses them for both no-op formatting and failures whose showMessage may already have been sent.
Loading