Skip to content

Commit 72eb624

Browse files
committed
Fix remaining LSP and restore regressions
1 parent 035fa44 commit 72eb624

15 files changed

Lines changed: 496 additions & 38 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ 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 stderrOnInit = process.env.CODER_STUDIO_FAKE_LSP_STDERR_ON_INIT ?? "";
1920

2021
connection.onRequest("initialize", () => {
22+
if (stderrOnInit) {
23+
process.stderr.write(`${stderrOnInit}\n`);
24+
}
25+
2126
if (exitAfterInitMs > 0) {
2227
const timer = setTimeout(() => process.exit(0), exitAfterInitMs);
2328
timer.unref?.();

packages/server/src/lsp-tools/definitions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,28 @@ export function getLspToolDefinition(serverKind: LspServerKind): LspToolDefiniti
7373
export function getLspCommandOverridePrefix(serverKind: LspServerKind): string {
7474
return `CODER_STUDIO_LSP_${serverKind.toUpperCase()}`;
7575
}
76+
77+
export function getManagedPrerequisites(
78+
serverKind: LspServerKind,
79+
platform: NodeJS.Platform = process.platform
80+
): string[] {
81+
if (serverKind === "python" && platform === "win32") {
82+
return ["python3", "python"];
83+
}
84+
85+
return getLspToolDefinition(serverKind).managed?.prerequisites ?? [];
86+
}
87+
88+
export async function resolveManagedPythonCommand(
89+
commandExists: (command: string) => Promise<boolean>,
90+
platform: NodeJS.Platform = process.platform
91+
): Promise<string | null> {
92+
const candidates = getManagedPrerequisites("python", platform);
93+
for (const candidate of candidates) {
94+
if (await commandExists(candidate)) {
95+
return candidate;
96+
}
97+
}
98+
99+
return null;
100+
}

packages/server/src/lsp-tools/install-manager.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const workspace: Workspace = {
1818
describe("LspToolInstallManager", () => {
1919
afterEach(() => {
2020
vi.restoreAllMocks();
21+
vi.unstubAllGlobals();
2122
});
2223

2324
it("returns missing_prerequisite when python3 is unavailable", async () => {
@@ -39,6 +40,61 @@ describe("LspToolInstallManager", () => {
3940
});
4041
});
4142

43+
it("allows managed Python install on Windows when python is available but python3 is not", async () => {
44+
const root = mkdtempSync(join(tmpdir(), "lsp-tools-"));
45+
let installed = false;
46+
const venvRoot = join(root, "python", "1.14.0", "venv");
47+
const pipPath = join(venvRoot, "Scripts", "pip.exe");
48+
const executablePath = join(venvRoot, "Scripts", "pylsp.exe");
49+
50+
const manager = new LspToolInstallManager({
51+
manifestStore: new FileManifestStore(root),
52+
platform: "win32",
53+
commandExists: vi.fn(async (command: string) => {
54+
if (command === "python3") {
55+
return false;
56+
}
57+
if (command === "python") {
58+
return true;
59+
}
60+
if (command === executablePath) {
61+
return installed;
62+
}
63+
return false;
64+
}),
65+
runCommand: vi.fn(async (file: string, args: string[]) => {
66+
if (file === "python" && args[0] === "-m" && args[1] === "venv") {
67+
return { stdout: "created venv", stderr: "" };
68+
}
69+
70+
if (file === pipPath) {
71+
installed = true;
72+
return { stdout: "installed pylsp", stderr: "" };
73+
}
74+
75+
throw new Error(`unexpected command: ${file}`);
76+
}),
77+
});
78+
79+
const started = await manager.start({
80+
workspace,
81+
serverKind: "python",
82+
});
83+
84+
await vi.waitFor(() => {
85+
expect(manager.get(started.jobId)?.status).toBe("succeeded");
86+
});
87+
88+
expect(manager.get(started.jobId)?.steps).toEqual(
89+
expect.arrayContaining([
90+
expect.objectContaining({
91+
id: "create-python-venv",
92+
command: "python",
93+
}),
94+
])
95+
);
96+
});
97+
4298
it("returns unsupported_platform for WSL workspaces", async () => {
4399
const manager = new LspToolInstallManager({
44100
manifestStore: new FileManifestStore(mkdtempSync(join(tmpdir(), "lsp-tools-"))),

packages/server/src/lsp-tools/install-manager.ts

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
checkCommandAvailable,
1515
} from "../provider-runtime/command-check.js";
1616
import { type CommandRunner, runCommandAsString } from "../provider-runtime/command-runner.js";
17-
import { getLspToolDefinition } from "./definitions.js";
17+
import {
18+
getLspToolDefinition,
19+
getManagedPrerequisites,
20+
resolveManagedPythonCommand,
21+
} from "./definitions.js";
1822
import { FileManifestStore } from "./manifest-store.js";
1923

2024
const EXCERPT_LIMIT = 400;
@@ -35,6 +39,7 @@ const RUST_ANALYZER_RELEASE_TAG = "2026-05-18";
3539
export interface LspToolInstallManagerDeps extends CommandCheckDeps {
3640
manifestStore: FileManifestStore;
3741
commandExists?: CommandAvailabilityCheck;
42+
platform?: NodeJS.Platform;
3843
runCommand?: CommandRunner;
3944
}
4045

@@ -76,6 +81,7 @@ export class LspToolInstallManager {
7681
const definition = getLspToolDefinition(input.serverKind);
7782
const managed = definition.managed;
7883
const jobId = randomUUID();
84+
const platform = this.deps.platform ?? process.platform;
7985

8086
if (!managed || input.workspace.targetRuntime !== "native") {
8187
return {
@@ -107,9 +113,17 @@ export class LspToolInstallManager {
107113
const commandExists =
108114
this.deps.commandExists ?? ((command: string) => checkCommandAvailable(command, this.deps));
109115
const missingPrerequisites: string[] = [];
110-
for (const prerequisite of managed.prerequisites) {
111-
if (!(await commandExists(prerequisite))) {
112-
missingPrerequisites.push(prerequisite);
116+
let pythonCommand: string | null = null;
117+
if (input.serverKind === "python") {
118+
pythonCommand = await resolveManagedPythonCommand(commandExists, platform);
119+
if (!pythonCommand) {
120+
missingPrerequisites.push(...getManagedPrerequisites("python", platform));
121+
}
122+
} else {
123+
for (const prerequisite of getManagedPrerequisites(input.serverKind, platform)) {
124+
if (!(await commandExists(prerequisite))) {
125+
missingPrerequisites.push(prerequisite);
126+
}
113127
}
114128
}
115129

@@ -146,21 +160,19 @@ export class LspToolInstallManager {
146160
? join(
147161
installRoot,
148162
"venv",
149-
process.platform === "win32" ? "Scripts" : "bin",
150-
process.platform === "win32" ? "pylsp.exe" : "pylsp"
163+
platform === "win32" ? "Scripts" : "bin",
164+
platform === "win32" ? "pylsp.exe" : "pylsp"
151165
)
152166
: input.serverKind === "go"
153-
? join(installRoot, "bin", process.platform === "win32" ? "gopls.exe" : "gopls")
154-
: join(
155-
installRoot,
156-
"bin",
157-
process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"
158-
);
167+
? join(installRoot, "bin", platform === "win32" ? "gopls.exe" : "gopls")
168+
: join(installRoot, "bin", platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer");
159169

160170
const plannedSteps = this.planInstallSteps({
161171
serverKind: input.serverKind,
162172
installRoot,
163173
executablePath,
174+
platform,
175+
pythonCommand,
164176
version: managed.version,
165177
});
166178

@@ -183,6 +195,7 @@ export class LspToolInstallManager {
183195
if (!managed) {
184196
return;
185197
}
198+
const platform = this.deps.platform ?? process.platform;
186199

187200
job.status = "running";
188201
this.jobs.set(job.jobId, job);
@@ -193,16 +206,17 @@ export class LspToolInstallManager {
193206
? join(
194207
installRoot,
195208
"venv",
196-
process.platform === "win32" ? "Scripts" : "bin",
197-
process.platform === "win32" ? "pylsp.exe" : "pylsp"
209+
platform === "win32" ? "Scripts" : "bin",
210+
platform === "win32" ? "pylsp.exe" : "pylsp"
198211
)
199212
: serverKind === "go"
200-
? join(installRoot, "bin", process.platform === "win32" ? "gopls.exe" : "gopls")
201-
: join(
202-
installRoot,
203-
"bin",
204-
process.platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer"
205-
);
213+
? join(installRoot, "bin", platform === "win32" ? "gopls.exe" : "gopls")
214+
: join(installRoot, "bin", platform === "win32" ? "rust-analyzer.exe" : "rust-analyzer");
215+
216+
const commandExists =
217+
this.deps.commandExists ?? ((command: string) => checkCommandAvailable(command, this.deps));
218+
const pythonCommand =
219+
serverKind === "python" ? await resolveManagedPythonCommand(commandExists, platform) : null;
206220

207221
mkdirSync(dirname(executablePath), { recursive: true });
208222

@@ -217,6 +231,8 @@ export class LspToolInstallManager {
217231
serverKind,
218232
installRoot,
219233
executablePath,
234+
platform,
235+
pythonCommand,
220236
version: managed.version,
221237
}).find((candidate) => candidate.id === step.id);
222238

@@ -225,9 +241,6 @@ export class LspToolInstallManager {
225241
}
226242

227243
if (step.kind === "verify") {
228-
const commandExists =
229-
this.deps.commandExists ??
230-
((command: string) => checkCommandAvailable(command, this.deps));
231244
const available = await commandExists(executablePath);
232245
if (!available) {
233246
throw Object.assign(new Error(`Verification failed for ${definition.displayName}`), {
@@ -269,7 +282,7 @@ export class LspToolInstallManager {
269282
executablePath,
270283
installedAt: Date.now(),
271284
source: "managed",
272-
platform: process.platform,
285+
platform,
273286
});
274287

275288
job.status = "succeeded";
@@ -282,18 +295,20 @@ export class LspToolInstallManager {
282295
serverKind: LspServerKind;
283296
installRoot: string;
284297
executablePath: string;
298+
platform: NodeJS.Platform;
299+
pythonCommand: string | null;
285300
version: string;
286301
}): InstallPlanStep[] {
287302
if (input.serverKind === "python") {
288303
const venvRoot = join(input.installRoot, "venv");
289-
const binDir = join(venvRoot, process.platform === "win32" ? "Scripts" : "bin");
290-
const pipPath = join(binDir, process.platform === "win32" ? "pip.exe" : "pip");
304+
const binDir = join(venvRoot, input.platform === "win32" ? "Scripts" : "bin");
305+
const pipPath = join(binDir, input.platform === "win32" ? "pip.exe" : "pip");
291306
return [
292307
{
293308
id: "create-python-venv",
294309
title: "Create Python virtual environment",
295310
kind: "install",
296-
command: "python3",
311+
command: input.pythonCommand ?? "python3",
297312
args: ["-m", "venv", venvRoot],
298313
cwd: input.installRoot,
299314
},

packages/server/src/lsp-tools/manager.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,66 @@ describe("LspToolManager.resolve", () => {
8585
});
8686
});
8787

88+
it("ignores a managed manifest when the JSON is corrupted", async () => {
89+
const root = mkdtempSync(join(tmpdir(), "lsp-tools-"));
90+
mkdirSync(join(root, "python"), { recursive: true });
91+
writeFileSync(join(root, "python", "manifest.json"), "{invalid json", "utf8");
92+
93+
const manager = new LspToolManager({
94+
manifestStore: new FileManifestStore(root),
95+
commandExists: vi.fn(async (command: string) => command === "python3"),
96+
resolveBundledCommand: vi.fn(() => null),
97+
});
98+
99+
const result = await manager.resolve({
100+
workspace,
101+
serverKind: "python",
102+
env: {},
103+
});
104+
105+
expect(result).toMatchObject({
106+
kind: "tool_missing",
107+
serverKind: "python",
108+
errorCode: "lsp_tool_missing",
109+
});
110+
});
111+
112+
it("ignores a managed manifest when the stored version no longer matches the definition", async () => {
113+
const root = mkdtempSync(join(tmpdir(), "lsp-tools-"));
114+
const executablePath = join(root, "python", "old", "bin", "pylsp");
115+
mkdirSync(dirname(executablePath), { recursive: true });
116+
writeFileSync(executablePath, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
117+
writeFileSync(
118+
join(root, "python", "manifest.json"),
119+
JSON.stringify({
120+
serverKind: "python",
121+
version: "0.0.1",
122+
executablePath,
123+
installedAt: 1,
124+
source: "managed",
125+
platform: process.platform,
126+
})
127+
);
128+
129+
const manager = new LspToolManager({
130+
manifestStore: new FileManifestStore(root),
131+
commandExists: vi.fn(async (command: string) => command === "python3"),
132+
resolveBundledCommand: vi.fn(() => null),
133+
});
134+
135+
const result = await manager.resolve({
136+
workspace,
137+
serverKind: "python",
138+
env: {},
139+
});
140+
141+
expect(result).toMatchObject({
142+
kind: "tool_missing",
143+
serverKind: "python",
144+
errorCode: "lsp_tool_missing",
145+
});
146+
});
147+
88148
it("uses the bundled TypeScript language server before system PATH", async () => {
89149
const root = mkdtempSync(join(tmpdir(), "lsp-tools-"));
90150
const manager = new LspToolManager({

0 commit comments

Comments
 (0)