Skip to content

Commit 9a0e559

Browse files
committed
fix(server): harden managed LSP probing and timeouts on Windows
A run of cross-language smoke tests (python, go, rust, vue) on a real Windows host turned up three classes of failures that all looked like "LSP unavailable" / "request timed out" but actually pointed at three different gaps in our probe + timeout logic. Fix them together so the Install / Retry flow tells the user something true. - `resolveManagedPythonCommand` now actively probes `<candidate> --version` on win32. The zero-byte Microsoft Store app execution aliases at `%LOCALAPPDATA%\Microsoft\WindowsApps\python(3).exe` pass `where.exe` but silently exit when invoked; before this change the install would crash opaquely at `python -m venv ...`. Now the prereq check rejects the stub and the UI shows `missing_prerequisite: python3, python`. - `LspToolManager.resolve` runs the same `--version` probe before accepting a system-PATH command on win32. This handles the symmetric case for rust-analyzer: `~/.cargo/bin/rust-analyzer.exe` is a rustup proxy that prints "Unknown binary 'rust-analyzer.exe'" to stderr and exits when the `rust-analyzer` rustup component is not installed. Before this fix the manager picked the broken proxy as the system source, never fell through to the managed download, and waited for an initialize response that never came. - `LspSession` now distinguishes `initializeTimeoutMs` (default `requestTimeoutMs * 10`, raised to 60_000 in `server.ts`) from `requestTimeoutMs` (raised from 2_000 to 8_000). rust-analyzer's `initialize` returns within ~70ms but background workspace indexing can take 25s on a real repo; the single 2-second budget would kill the child mid-init on cold start and never recover. Hover / definition keep the short fail-fast budget so the editor's "Loading..." popup doesn't linger when the server is wedged. Adds 11 unit tests covering the stub rejection paths and the initialize-vs-request timeout split, plus a `lsp-test/` directory of single-file fixtures (probe.{py,go,rs,vue}) for repeating the same smoke check by hand. Files an issue at `docs/issue/rust-analyzer-indexing-no-progress-feedback.md` describing the remaining UX gap — rust-analyzer silently returns `null` for hover/definition during its first ~25s of indexing, and we don't yet surface its `$/progress` notifications in the LSP status notice. Test sweep on Windows host: 16 server LSP test files, 118 passed + 3 POSIX-only skipped.
1 parent e6b9f84 commit 9a0e559

21 files changed

Lines changed: 894 additions & 20 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,8 @@ tsconfig.tsbuildinfo
6868

6969
# Stitch design files
7070
.stitch/
71+
72+
# Rust build artefacts (from lsp-test/ fixture or any ad-hoc cargo)
73+
target/
74+
Cargo.lock
75+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# rust-analyzer 启动期间 hover/definition 静默无响应,UI 无进度反馈
2+
3+
## 标题
4+
5+
`feat(web): surface rust-analyzer indexing progress in the LSP status notice`
6+
7+
## 问题描述
8+
9+
打开第一个 `.rs` 文件时,rust-analyzer 会进入 `PrimeCaches` 阶段对工作区做初始化索引。这个阶段在 coder-studio 仓库根(中等仓库 + 大量 `node_modules`)下实测**会持续 ~25 秒**
10+
11+
期间:
12+
13+
- `initialize` LSP 请求几十毫秒就返回(rust-analyzer 设计上立刻确认 capabilities,workspace 加载是异步的)
14+
- 我们的 `LspManager.ensureSession` 拿到 `summary.status === "ready"`,前端把 hover/definition provider 都注册好
15+
- 但用户**任何** hover/definition 请求都会被 rust-analyzer **立刻返回 `null`**——不是 hang、不是 timeout,而是它故意在 indexing 期间不给语义答案
16+
- Monaco 拿到 null 就什么都不显示
17+
- 用户感受:开了 `.rs` 文件之后随便点点都"完全没反应",像 LSP 没起来
18+
19+
25 秒后 rust-analyzer 发 `$/progress { kind: "end" }` 通知,从此 hover 正常工作。但**这中间的等待期对用户完全不可见**
20+
21+
## 复现步骤
22+
23+
1. 干净环境,无 rust-analyzer 缓存。
24+
2. 在 coder-studio 仓库根新建 `Cargo.toml` + `probe.rs`(最小 bin 项目即可)。
25+
3. 重启 dev server,让 LSP 会话从干净状态启动。
26+
4. 浏览器中打开 `probe.rs`,立刻 hover 任一标识符。
27+
5. 观察前 ~25 秒所有 hover/definition/references 都没反应。
28+
29+
可以用 `scripts/probe-rust.mjs probe.rs` 直接复现 ——
30+
它会同时记录 initialize 用时、首次 hover 响应、`$/progress end` 用时。
31+
32+
## 实际行为
33+
34+
- 前 25 秒:hover 返回 null,UI 安静
35+
- 之后:hover 工作,但用户多半已经放弃尝试了
36+
37+
## 桌面终端对比
38+
39+
VS Code 的官方 rust-analyzer 扩展会在 status bar 上显示
40+
`rust-analyzer: indexing X/Y` 进度条;Helix 会在底部状态栏显示同样信息。两者都监听 rust-analyzer
41+
`$/progress` LSP 通知。
42+
43+
我们目前没监听任何 LSP 进度通知。
44+
45+
## 已确认事实
46+
47+
- `initialize` 响应快(~70ms 量级,与 indexing 解耦)
48+
- rust-analyzer 通过标准 LSP `$/progress`
49+
notification 通报进度,token 是 `"rustAnalyzer/Indexing"` 或类似
50+
- `LspSession``packages/server/src/lsp/session.ts`)目前没有 `connection.onNotification("$/progress", ...)`
51+
处理器
52+
- 前端 `LspStatusNotice` 目前只有 ready / installing / failed / disabled 四种状态显示
53+
54+
## 后续排查方向
55+
56+
- **server**`LspSession` 监听 `$/progress` 通知,把 `WorkDoneProgressBegin` /
57+
`WorkDoneProgressReport` / `WorkDoneProgressEnd` 转成 `lsp.progress.updated` 事件
58+
via `eventBus`
59+
- **core / shared**:在 `LspEnsureSessionResult` 或独立 `LspProgress` 类型里加一个 "indexing" 状态
60+
- **web**`LspStatusNotice` 渲染 "Indexing 12 / 47 …" 或简单的 spinner + percentage
61+
- 范围只对 rust-analyzer + 任何主动发 `$/progress` 的 server(pylsp、gopls 通常不发)
62+
63+
## 临时缓解
64+
65+
- 文档里告诉用户:"首次打开 `.rs` 文件需要等 ~30s 完成索引"
66+
- 或检测 rust-analyzer 没回有效 hover 时,在编辑器里给一个 transient toast 提示

lsp-test/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[package]
2+
name = "probe"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[[bin]]
8+
name = "probe"
9+
path = "probe.rs"

lsp-test/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# LSP smoke-test fixtures
2+
3+
A small set of single-file projects used to verify each managed LSP works end-to-end against the editor (hover / definition / references / diagnostics). Each file has a TYPE ERROR at the bottom on purpose so the diagnostic provider also gets exercised.
4+
5+
| File | Language server | What to verify |
6+
| --- | --- | --- |
7+
| `probe.vue` | `@vue/language-server` (Volar 3) + `typescript-language-server` companion | hover on `ref`/`computed`/`defineProps`, F12 to `Props`, red squiggle on the type error |
8+
| `probe.py` | `python-lsp-server` (pylsp, managed) | hover on `multiply_by`/`Greeter`, F12 across functions, pyflakes-level diagnostic |
9+
| `probe.go` | `gopls` (managed) | hover on `MultiplyBy`/`Greeter.Greet`, F12 across functions, type-mismatch diagnostic |
10+
| `probe.rs` + `Cargo.toml` | `rust-analyzer` (system rustup component or managed download) | hover on `multiply_by`/`Greeter`/`greet`, F12 across functions, type-mismatch diagnostic |
11+
12+
## Why a `Cargo.toml`
13+
14+
Unlike the other servers, **rust-analyzer refuses to provide semantic info for `.rs` files that don't belong to a Cargo project**. The minimal `Cargo.toml` in this directory declares `probe.rs` as a bin so rust-analyzer treats the directory as a workspace.
15+
16+
> rust-analyzer also takes ~25s on cold start to finish `PrimeCaches` indexing, during which all hover/definition requests silently return `null`. See `docs/issue/rust-analyzer-indexing-no-progress-feedback.md`.
17+
18+
## How to run
19+
20+
1. Open coder-studio in the editor (dev or built).
21+
2. Open any file in this directory.
22+
3. First open triggers the LSP install if needed — look for the `Install` button in the inline notice.
23+
4. Once the notice disappears, exercise the four LSP features listed above.
24+
25+
For protocol-level debugging without the editor in the loop, see `scripts/probe-vue-bridge.mjs` and `scripts/probe-rust.mjs` — they spawn the language server directly and assert specific LSP responses.
26+
27+
## Cleanup
28+
29+
These fixtures are intentionally checked in so a new contributor can repeat the same smoke check on day one. Feel free to leave them in place; they don't affect any production build.

lsp-test/probe.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Quick LSP smoke probe for gopls.
2+
//
3+
// Try in the editor once this file is open:
4+
//
5+
// 1. Hover over `numbers`, `total`, `MultiplyBy`, `Greeter`, `Greet` —
6+
// each should show its inferred Go signature with package context.
7+
// 2. Ctrl-Click (or F12) on `MultiplyBy` inside `ComputeTotal` to jump
8+
// to its definition.
9+
// 3. Shift+F12 on `Greet` to see references.
10+
// 4. The line marked `// TYPE ERROR` should get a gopls diagnostic
11+
// (passing a string where an int is expected).
12+
//
13+
// Note: gopls expects a real module to fully analyze; we declare a
14+
// throwaway one here so the file is self-contained.
15+
16+
package main
17+
18+
import "fmt"
19+
20+
func MultiplyBy(value, factor int) int {
21+
return value * factor
22+
}
23+
24+
func ComputeTotal(numbers []int, factor int) int {
25+
total := 0
26+
for _, n := range numbers {
27+
total += MultiplyBy(n, factor)
28+
}
29+
return total
30+
}
31+
32+
type Greeter struct {
33+
Name string
34+
}
35+
36+
func (g Greeter) Greet() string {
37+
return fmt.Sprintf("Hello, %s!", g.Name)
38+
}
39+
40+
func main() {
41+
fmt.Println(ComputeTotal([]int{1, 2, 3}, 4))
42+
fmt.Println(Greeter{Name: "Vue"}.Greet())
43+
// TYPE ERROR: passing a string where an int is expected.
44+
fmt.Println(MultiplyBy("not a number", 2))
45+
}

lsp-test/probe.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Quick LSP smoke probe for python-lsp-server (pylsp).
2+
3+
Try the following in the editor once this file is open:
4+
5+
1. Hover over `numbers`, `total`, `multiply_by`, `Greeter`, `greet` —
6+
each should show its inferred type / signature.
7+
2. Ctrl-Click (or F12) on `multiply_by` inside `compute_total` to jump
8+
to its definition.
9+
3. Shift+F12 on `greet` to see references.
10+
4. The line marked `# TYPE ERROR` should get a pylsp diagnostic
11+
(pylsp ships with pyflakes / pycodestyle by default; the call passes
12+
a string to a parameter typed as `int`).
13+
"""
14+
15+
from dataclasses import dataclass
16+
17+
18+
def multiply_by(value: int, factor: int) -> int:
19+
return value * factor
20+
21+
22+
def compute_total(numbers: list[int], factor: int) -> int:
23+
total = 0
24+
for n in numbers:
25+
total += multiply_by(n, factor)
26+
return total
27+
28+
29+
@dataclass
30+
class Greeter:
31+
name: str
32+
33+
def greet(self) -> str:
34+
return f"Hello, {self.name}!"
35+
36+
37+
if __name__ == "__main__":
38+
print(compute_total([1, 2, 3], 4))
39+
print(Greeter("Vue").greet())
40+
# TYPE ERROR: passing a string where an int is expected.
41+
print(multiply_by("not a number", 2))

lsp-test/probe.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//! Quick LSP smoke probe for rust-analyzer.
2+
//!
3+
//! Try in the editor once this file is open:
4+
//!
5+
//! 1. Hover over `numbers`, `total`, `multiply_by`, `Greeter`, `greet` —
6+
//! each should show its inferred Rust type or signature.
7+
//! 2. Ctrl-Click (or F12) on `multiply_by` inside `compute_total` to jump
8+
//! to its definition.
9+
//! 3. Shift+F12 on `greet` to see references.
10+
//! 4. The line marked `// TYPE ERROR` should get a rust-analyzer
11+
//! diagnostic (passing a `&str` where `i64` is expected).
12+
//!
13+
//! Note: rust-analyzer is happiest inside a Cargo workspace, so a few
14+
//! features may behave slightly differently here than they would in a
15+
//! real crate, but hover / definition / references still work.
16+
17+
fn multiply_by(value: i64, factor: i64) -> i64 {
18+
value * factor
19+
}
20+
21+
fn compute_total(numbers: &[i64], factor: i64) -> i64 {
22+
let mut total = 0;
23+
for n in numbers {
24+
total += multiply_by(*n, factor);
25+
}
26+
total
27+
}
28+
29+
struct Greeter {
30+
name: String,
31+
}
32+
33+
impl Greeter {
34+
fn greet(&self) -> String {
35+
format!("Hello, {}!", self.name)
36+
}
37+
}
38+
39+
fn main() {
40+
println!("{}", compute_total(&[1, 2, 3], 4));
41+
println!("{}", Greeter { name: "Vue".to_string() }.greet());
42+
// TYPE ERROR: passing a &str where i64 is expected.
43+
println!("{}", multiply_by("not a number", 2));
44+
}

lsp-test/probe.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from "vue";
3+
4+
const count = ref(0);
5+
const doubled = computed(() => count.value * 2);
6+
7+
interface Props {
8+
label: string;
9+
}
10+
const props = defineProps<Props>();
11+
</script>
12+
13+
<template>
14+
<button @click="count++">{{ props.label }} {{ doubled }}</button>
15+
</template>

packages/server/src/__tests__/fixtures/fake-lsp-server.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ const connection = createMessageConnection(
1616
const docs = new Map();
1717
const exitAfterInitMs = Number(process.env.CODER_STUDIO_FAKE_LSP_EXIT_AFTER_INIT_MS ?? "0");
1818
const hoverDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_HOVER_DELAY_MS ?? "0");
19+
const initDelayMs = Number(process.env.CODER_STUDIO_FAKE_LSP_INIT_DELAY_MS ?? "0");
1920
const stderrOnInit = process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT ?? "";
2021

21-
connection.onRequest("initialize", () => {
22+
connection.onRequest("initialize", async () => {
2223
if (stderrOnInit) {
2324
process.stderr.write(`${stderrOnInit}\n`);
2425
}
@@ -28,6 +29,13 @@ connection.onRequest("initialize", () => {
2829
timer.unref?.();
2930
}
3031

32+
if (initDelayMs > 0) {
33+
await new Promise((resolve) => {
34+
const timer = setTimeout(resolve, initDelayMs);
35+
timer.unref?.();
36+
});
37+
}
38+
3139
return {
3240
capabilities: {
3341
definitionProvider: true,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { resolveManagedPythonCommand } from "./definitions.js";
3+
4+
describe("resolveManagedPythonCommand", () => {
5+
it("returns the first available candidate on POSIX hosts without probing", async () => {
6+
const commandExists = vi.fn(async (cmd: string) => cmd === "python3");
7+
const runCommand = vi.fn();
8+
9+
await expect(
10+
resolveManagedPythonCommand(commandExists, "linux", runCommand as never)
11+
).resolves.toBe("python3");
12+
// POSIX hosts do not have Microsoft Store stubs; the helper must NOT
13+
// execute the candidate just to check the version.
14+
expect(runCommand).not.toHaveBeenCalled();
15+
});
16+
17+
it("returns null when no candidate is on PATH", async () => {
18+
await expect(
19+
resolveManagedPythonCommand(
20+
vi.fn(async () => false),
21+
"linux"
22+
)
23+
).resolves.toBeNull();
24+
});
25+
26+
it("on Windows, rejects a candidate whose `--version` prints nothing (Store stub)", async () => {
27+
// Windows ships zero-byte App Execution Aliases for `python` /
28+
// `python3`. `where.exe` reports them as present, but invoking them
29+
// silently exits with empty stdout/stderr because Python is not
30+
// actually installed.
31+
const commandExists = vi.fn(async () => true);
32+
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
33+
34+
await expect(
35+
resolveManagedPythonCommand(commandExists, "win32", runCommand)
36+
).resolves.toBeNull();
37+
expect(runCommand).toHaveBeenCalledTimes(2);
38+
expect(runCommand).toHaveBeenCalledWith(
39+
"python3",
40+
["--version"],
41+
expect.objectContaining({ windowsHide: true })
42+
);
43+
expect(runCommand).toHaveBeenCalledWith(
44+
"python",
45+
["--version"],
46+
expect.objectContaining({ windowsHide: true })
47+
);
48+
});
49+
50+
it("on Windows, accepts a candidate whose --version prints output on stdout", async () => {
51+
const commandExists = vi.fn(async () => true);
52+
const runCommand = vi.fn(async () => ({ stdout: "Python 3.12.0\n", stderr: "" }));
53+
54+
await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe(
55+
"python3"
56+
);
57+
});
58+
59+
it("on Windows, accepts a candidate whose --version prints to stderr (older Pythons)", async () => {
60+
// Pythons < 3.4 print the version banner to stderr instead of stdout.
61+
const commandExists = vi.fn(async () => true);
62+
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "Python 2.7.18\n" }));
63+
64+
await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe(
65+
"python3"
66+
);
67+
});
68+
69+
it("on Windows, falls through to the next candidate when the first one's probe fails to spawn", async () => {
70+
const commandExists = vi.fn(async () => true);
71+
const runCommand = vi.fn(async (file: string) => {
72+
if (file === "python3") {
73+
// simulate spawn failure (file is a stub that can't be executed)
74+
throw new Error("spawn python3 ENOENT");
75+
}
76+
return { stdout: "Python 3.12.0\n", stderr: "" };
77+
});
78+
79+
await expect(resolveManagedPythonCommand(commandExists, "win32", runCommand)).resolves.toBe(
80+
"python"
81+
);
82+
});
83+
84+
it("on Windows, returns null when both candidates are stubs that print nothing", async () => {
85+
const commandExists = vi.fn(async () => true);
86+
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
87+
88+
await expect(
89+
resolveManagedPythonCommand(commandExists, "win32", runCommand)
90+
).resolves.toBeNull();
91+
});
92+
});

0 commit comments

Comments
 (0)