Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai 14674cab36 fix(lsp): offer an update when a language server build has expired
JetBrains preview builds of kotlin-lsp stop starting once their date passes
("This build of kotlin-server has expired", exit code 7). The server was
restarted three times and a manual restart only reported the same failure.

The backend no longer restarts a server that reported an expired build, and
the tools feature offers the latest release: Update for the IDE's own copy,
Install Latest for one found on PATH (the managed copy comes first), and the
Language Servers page for an executable chosen in settings.

Fixes #2925
2026-11-14 00:45:21 +08:00
7 змінених файлів з 178 додано та 5 видалено

No files matched your search

+41 -2
Переглянути файл
@@ -445,8 +445,12 @@ impl Server {
message.push_str(":\n");
message.push_str(&tail);
}
let delay = self.record_crash();
message.push_str(if delay.is_some() {
// Starting the same build again can't help: only an update can.
let expired = self.reported_expired_build();
let delay = if expired { None } else { self.record_crash() };
message.push_str(if expired {
"\nThis build has expired; update it to start it again."
} else if delay.is_some() {
"\nRestarting…"
} else {
"\nCrashed repeatedly; restart it manually."
@@ -757,6 +761,12 @@ impl Server {
crash_excerpt(&lines, n)
}
/// JetBrains preview builds (kotlin-lsp) refuse to run past their expiry date and
/// exit with "This build of kotlin-server has expired".
fn reported_expired_build(&self) -> bool {
self.stderr.lock().iter().any(|l| is_expired_build_line(l))
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
@@ -911,6 +921,10 @@ fn is_error_headline(line: &str) -> bool {
named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked")
}
fn is_expired_build_line(line: &str) -> bool {
line.contains("This build of ") && line.contains(" has expired")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1071,6 +1085,31 @@ mod tests {
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
}
#[test]
fn an_expired_build_is_not_restarted() {
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "idea.system.path=/tmp/idea-system".into());
s.push_stderr(
generation,
"This build of kotlin-server has expired. The IDE will now close. Please download a new build".into(),
);
assert_eq!(s.on_closed(generation, &sink, Some(7)), Some(None));
assert_eq!(s.status(), ServerStatus::Crashed);
assert!(s.status_message().unwrap().ends_with("This build has expired; update it to start it again."));
assert!(matches!(s.state.lock().conn, Conn::Failed));
// A manual restart (after an update) starts it again.
s.reset_for_restart();
assert!(s.begin_start().is_some());
// Any other crash is restarted.
let s = server();
let generation = s.begin_start().unwrap();
s.push_stderr(generation, "Exception in thread main".into());
assert_eq!(s.on_closed(generation, &sink, Some(1)), Some(Some(Duration::from_millis(500))));
}
#[test]
fn a_superseded_start_is_not_announced() {
let s = server();
+4 -1
Переглянути файл
@@ -104,11 +104,14 @@ export async function restartServer(id: ServerId) {
notifications().notify({ group: "lsp", level: "warning", title: tx`${entry.name} is still not installed` });
return;
}
const message = e instanceof Error ? e.message : String(e);
// features/tools offers the update for a build that has expired.
if (/This build of .+ has expired/.test(message) && getAction("tools.install")) return;
notifications().notify({
group: "lsp",
level: "error",
title: "Failed to restart language server",
body: e instanceof Error ? e.message : String(e),
body: message,
});
}
}
+70
Переглянути файл
@@ -0,0 +1,70 @@
/** A language server build past its expiry date: offer the latest release instead of restarting it. */
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolInfo } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
const store = vi.hoisted(() => ({ tool: undefined as Partial<ToolInfo> | undefined, installTool: vi.fn() }));
vi.mock("../store", async (orig) => ({
...(await orig<typeof import("../store")>()),
refreshTools: async () => {},
toolByName: () => store.tool,
installTool: store.installTool,
}));
const { isExpiredBuild, promptUpdateExpired } = await import("../expired");
const { provideNotificationService } = await import("@/workbench/contributions");
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
const KOTLIN: Partial<ToolInfo> = {
name: "kotlin-lsp",
displayName: "Kotlin LSP (JetBrains)",
command: "kotlin-lsp",
canInstall: true,
};
beforeEach(() => {
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
store.installTool.mockClear();
});
describe("expired language server builds", () => {
it("are recognised from the server's exit message", () => {
expect(
isExpiredBuild(
"kotlin-lsp exited with code 7:\nThis build of kotlin-server has expired. The IDE will now close.\nThis build has expired; update it to start it again.",
),
).toBe(true);
expect(isExpiredBuild("kotlin-lsp exited with code 1:\nOutOfMemoryError\nRestarting…")).toBe(false);
expect(isExpiredBuild(null)).toBe(false);
});
it("offer an update of the IDE's own copy", async () => {
store.tool = { ...KOTLIN, status: "installed" };
await promptUpdateExpired("kotlin-lsp");
const note = notes.at(-1)!;
expect(note).toMatchObject({
level: "warning",
title: "Kotlin LSP (JetBrains) has expired",
key: "lsp-expired-kotlin-lsp",
});
expect(note.actions?.map((a) => a.label)).toEqual(["Update", "Language Servers…"]);
note.actions![0]!.run();
expect(store.installTool).toHaveBeenCalledWith("kotlin-lsp", { notificationKey: "lsp-expired-kotlin-lsp" });
});
it("offer the latest release for the IDE over one found on PATH", async () => {
store.tool = { ...KOTLIN, status: "onPath", path: "/opt/homebrew/bin/kotlin-lsp" };
await promptUpdateExpired("kotlin-lsp");
const note = notes.at(-1)!;
expect(note.body).toContain("/opt/homebrew/bin/kotlin-lsp");
expect(note.actions?.map((a) => a.label)).toEqual(["Install Latest", "Language Servers…"]);
});
it("point a chosen executable to the settings", async () => {
store.tool = { ...KOTLIN, status: "custom", path: "/work/kotlin-lsp" };
await promptUpdateExpired("kotlin-lsp");
expect(notes.at(-1)!.actions?.map((a) => a.label)).toEqual(["Language Servers…"]);
});
});
+49
Переглянути файл
@@ -0,0 +1,49 @@
/**
* "kotlin-lsp has expired — Update": JetBrains preview builds stop starting once their date
* passes, and the backend then stops restarting them. Offer the latest release instead.
* A managed copy comes before `PATH` (Homebrew), so installing it also replaces one found there.
*/
import { executeAction } from "@/platform/actions";
import { onIdeEvent } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName } from "./store";
export const isExpiredBuild = (message: string | null) => !!message && /This build of .+ has expired/.test(message);
export async function promptUpdateExpired(name: string) {
await refreshTools();
const tool = toolByName(name);
const display = tool?.displayName ?? name;
const key = `lsp-expired-${name}`;
const installable = tool?.canInstall && (tool.status === "installed" || tool.status === "onPath");
notifications().notify({
group: "lsp",
level: "warning",
title: tx`${display} has expired`,
body: !installable
? tx`This build of ${display} no longer starts. Choose a newer build in Language Servers.`
: tool.status === "installed"
? tx`This build of ${display} no longer starts. Update it to the latest release?`
: tx`This build of ${display} no longer starts. Install the latest release for the IDE? It is used instead of ${tool.path ?? tool.command}.`,
key,
sticky: true,
actions: [
...(installable
? [
{
label: tool.status === "installed" ? "Update" : "Install Latest",
run: () => void installTool(name, { notificationKey: key }),
},
]
: []),
{ label: "Language Servers…", run: () => void executeAction("file.settings", SETTINGS_PAGE_ID) },
],
});
}
export function installExpiredServerPrompts(): () => void {
return onIdeEvent("languageServer", (e) => {
if (e.status === "crashed" && isExpiredBuild(e.message)) void promptUpdateExpired(e.name);
});
}
+2
Переглянути файл
@@ -8,6 +8,7 @@
import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { registerSettingsPage } from "@/workbench/contributions";
import { installExpiredServerPrompts } from "./expired";
import { installJdkPrompts, registerJdkActions } from "./jdk";
import { installMissingServerPrompts, installServerWarningPrompts, promptInstall } from "./missing";
import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store";
@@ -85,6 +86,7 @@ export function activate(): () => void {
]),
installMissingServerPrompts(),
installServerWarningPrompts(),
installExpiredServerPrompts(),
registerJdkActions(),
installJdkPrompts(),
];
+6 -1
Переглянути файл
@@ -1570,5 +1570,10 @@
"Top right": "Top right",
"Bottom left": "Bottom left",
"Bottom right": "Bottom right",
"Image files": "Image files"
"Image files": "Image files",
"{display} has expired": "{display} has expired",
"This build of {display} no longer starts. Choose a newer build in Language Servers.": "This build of {display} no longer starts. Choose a newer build in Language Servers.",
"This build of {display} no longer starts. Update it to the latest release?": "This build of {display} no longer starts. Update it to the latest release?",
"This build of {display} no longer starts. Install the latest release for the IDE? It is used instead of {path}.": "This build of {display} no longer starts. Install the latest release for the IDE? It is used instead of {path}.",
"Install Latest": "Install Latest"
}
+6 -1
Переглянути файл
@@ -7961,5 +7961,10 @@
"Top right": "右上",
"Bottom left": "左下",
"Bottom right": "右下",
"Image files": "图像文件"
"Image files": "图像文件",
"{display} has expired": "{display} 已过期",
"This build of {display} no longer starts. Choose a newer build in Language Servers.": "此版本的 {display} 已无法启动。请在“语言服务器”中选择更新的版本。",
"This build of {display} no longer starts. Update it to the latest release?": "此版本的 {display} 已无法启动。要更新到最新版本吗?",
"This build of {display} no longer starts. Install the latest release for the IDE? It is used instead of {path}.": "此版本的 {display} 已无法启动。要为 IDE 安装最新版本吗?安装后将代替 {path} 使用。",
"Install Latest": "安装最新版本"
}