Skip to content

Commit 8fba7e4

Browse files
committed
fix(lsp): skip didOpen when server is stopped
1 parent 647f1cd commit 8fba7e4

4 files changed

Lines changed: 273 additions & 34 deletions

File tree

src/crates/core/src/service/lsp/workspace_manager.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
//! - Push real-time events to the frontend
1010
1111
use anyhow::{anyhow, Result};
12-
use log::{debug, error, info, warn};
12+
use log::{debug, error, info, trace, warn};
1313
use serde::{Deserialize, Serialize};
1414
use std::collections::HashMap;
1515
use std::path::PathBuf;
@@ -310,7 +310,7 @@ impl WorkspaceLspManager {
310310
let server_language = match self.get_running_server_for_language(&language).await {
311311
Some(lang) => lang,
312312
None => {
313-
debug!(
313+
trace!(
314314
"LSP server not running for language: {}, skipping didOpen",
315315
language
316316
);

src/web-ui/src/tools/lsp/services/LspDocumentService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,15 @@ class LspDocumentService {
117117
const manager = WorkspaceLspManager.getOrCreate(workspacePath);
118118

119119
try {
120-
await manager.openDocument(
120+
const openResult = await manager.openDocument(
121121
event.uri,
122122
state.language,
123123
event.content
124124
);
125+
126+
if (!openResult.opened) {
127+
return;
128+
}
125129

126130
state.isOpen = true;
127131
state.lastSyncedContent = event.content;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
const invokeMock = vi.hoisted(() => vi.fn());
4+
const listenMock = vi.hoisted(() => vi.fn(async () => () => {}));
5+
6+
vi.mock('@tauri-apps/api/core', () => ({
7+
invoke: invokeMock
8+
}));
9+
10+
vi.mock('@tauri-apps/api/event', () => ({
11+
listen: listenMock
12+
}));
13+
14+
vi.mock('@/shared/notification-system', () => ({
15+
notificationService: {
16+
progress: vi.fn(),
17+
error: vi.fn(),
18+
loading: vi.fn(() => ({
19+
updateMessage: vi.fn(),
20+
complete: vi.fn()
21+
}))
22+
}
23+
}));
24+
25+
vi.mock('@/infrastructure/i18n', () => ({
26+
i18nService: {
27+
t: vi.fn((key: string) => key)
28+
}
29+
}));
30+
31+
import { WorkspaceLspManager } from './WorkspaceLspManager';
32+
33+
function serverState(status: 'stopped' | 'starting' | 'running' | 'failed' | 'restarting') {
34+
return {
35+
status,
36+
language: 'rust',
37+
startedAt: null,
38+
lastError: null,
39+
restartCount: 0,
40+
documentCount: 0
41+
};
42+
}
43+
44+
describe('WorkspaceLspManager', () => {
45+
afterEach(() => {
46+
invokeMock.mockReset();
47+
listenMock.mockReset();
48+
listenMock.mockResolvedValue(() => {});
49+
(WorkspaceLspManager as unknown as {
50+
instances: Map<string, WorkspaceLspManager>;
51+
}).instances.clear();
52+
});
53+
54+
it('skips didOpen when the language server is stopped', async () => {
55+
invokeMock.mockImplementation(async (command: string) => {
56+
if (command === 'lsp_open_workspace') {
57+
return undefined;
58+
}
59+
if (command === 'lsp_get_server_state') {
60+
return serverState('stopped');
61+
}
62+
if (command === 'lsp_open_document') {
63+
throw new Error('didOpen should not be sent for a stopped server');
64+
}
65+
return undefined;
66+
});
67+
68+
const manager = WorkspaceLspManager.getOrCreate('D:\\workspace\\BitFun');
69+
70+
const result = await manager.openDocument(
71+
'file:///D:/workspace/BitFun/src/main.rs',
72+
'rust',
73+
'fn main() {}'
74+
);
75+
76+
expect(result).toEqual({
77+
language: 'rust',
78+
opened: false,
79+
skippedReason: 'server-not-running',
80+
serverStatus: 'stopped'
81+
});
82+
expect(invokeMock).not.toHaveBeenCalledWith('lsp_open_document', expect.anything());
83+
});
84+
85+
it('sends didOpen when the language server is running', async () => {
86+
invokeMock.mockImplementation(async (command: string) => {
87+
if (command === 'lsp_open_workspace') {
88+
return undefined;
89+
}
90+
if (command === 'lsp_get_server_state') {
91+
return serverState('running');
92+
}
93+
if (command === 'lsp_open_document') {
94+
return undefined;
95+
}
96+
return undefined;
97+
});
98+
99+
const manager = WorkspaceLspManager.getOrCreate('D:\\workspace\\BitFun');
100+
101+
const result = await manager.openDocument(
102+
'file:///D:/workspace/BitFun/src/main.rs',
103+
'rust',
104+
'fn main() {}'
105+
);
106+
107+
expect(result).toEqual({ language: 'rust', opened: true });
108+
expect(invokeMock).toHaveBeenCalledWith('lsp_open_document', {
109+
request: {
110+
workspacePath: 'D:\\workspace\\BitFun',
111+
uri: 'file:///D:/workspace/BitFun/src/main.rs',
112+
language: 'rust',
113+
content: 'fn main() {}'
114+
}
115+
});
116+
});
117+
118+
it('caches stopped server state to avoid repeated state queries during UI remounts', async () => {
119+
invokeMock.mockImplementation(async (command: string) => {
120+
if (command === 'lsp_open_workspace') {
121+
return undefined;
122+
}
123+
if (command === 'lsp_get_server_state') {
124+
return serverState('stopped');
125+
}
126+
if (command === 'lsp_open_document') {
127+
throw new Error('didOpen should not be sent for a stopped server');
128+
}
129+
return undefined;
130+
});
131+
132+
const manager = WorkspaceLspManager.getOrCreate('D:\\workspace\\BitFun');
133+
134+
await manager.openDocument('file:///D:/workspace/BitFun/src/main.rs', 'rust', 'fn main() {}');
135+
await manager.openDocument('file:///D:/workspace/BitFun/src/lib.rs', 'rust', 'pub fn lib() {}');
136+
137+
expect(invokeMock).toHaveBeenCalledTimes(2);
138+
expect(invokeMock).toHaveBeenCalledWith('lsp_open_workspace', {
139+
request: { workspacePath: 'D:\\workspace\\BitFun' }
140+
});
141+
expect(invokeMock).toHaveBeenCalledWith('lsp_get_server_state', {
142+
request: {
143+
workspacePath: 'D:\\workspace\\BitFun',
144+
language: 'rust'
145+
}
146+
});
147+
expect(invokeMock).not.toHaveBeenCalledWith('lsp_open_document', expect.anything());
148+
});
149+
});

src/web-ui/src/tools/lsp/services/WorkspaceLspManager.ts

Lines changed: 117 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,29 @@ interface ServerState {
3838
documentCount: number;
3939
}
4040

41+
type OpenDocumentSkippedReason = 'server-not-running';
42+
43+
export interface OpenDocumentResult {
44+
language: string;
45+
opened: boolean;
46+
skippedReason?: OpenDocumentSkippedReason;
47+
serverStatus?: ServerState['status'];
48+
}
49+
50+
interface CachedServerStatus {
51+
status: ServerState['status'];
52+
updatedAt: number;
53+
}
54+
4155
export class WorkspaceLspManager {
4256
private static instances = new Map<string, WorkspaceLspManager>();
4357

4458
private workspacePath: string;
4559
private eventUnlisten?: UnlistenFn;
4660
private isInitialized = false;
47-
48-
49-
private startingLanguages = new Set<string>();
50-
private languageReadyPromises = new Map<string, Promise<void>>();
61+
private serverStatusByLanguage = new Map<string, CachedServerStatus>();
62+
private skippedOpenNoticeLanguages = new Set<string>();
63+
private readonly SERVER_STATUS_CACHE_TTL_MS = 30000;
5164

5265

5366
private diagnosticsCallbacks = new Map<string, Array<(diagnostics: any[]) => void>>();
@@ -155,7 +168,81 @@ export class WorkspaceLspManager {
155168
return uri.toLowerCase();
156169
}
157170

158-
171+
172+
private normalizeLanguage(language: string): string {
173+
return language.trim().toLowerCase();
174+
}
175+
176+
177+
private isServerStatus(status?: string): status is ServerState['status'] {
178+
return status === 'stopped'
179+
|| status === 'starting'
180+
|| status === 'running'
181+
|| status === 'failed'
182+
|| status === 'restarting';
183+
}
184+
185+
186+
private rememberServerStatus(language: string, status: ServerState['status']): void {
187+
const key = this.normalizeLanguage(language);
188+
this.serverStatusByLanguage.set(key, {
189+
status,
190+
updatedAt: Date.now()
191+
});
192+
193+
if (status === 'running') {
194+
this.skippedOpenNoticeLanguages.delete(key);
195+
}
196+
}
197+
198+
199+
private getFreshCachedServerStatus(language: string): ServerState['status'] | undefined {
200+
const cached = this.serverStatusByLanguage.get(this.normalizeLanguage(language));
201+
if (!cached) {
202+
return undefined;
203+
}
204+
205+
if (Date.now() - cached.updatedAt > this.SERVER_STATUS_CACHE_TTL_MS) {
206+
return undefined;
207+
}
208+
209+
return cached.status;
210+
}
211+
212+
213+
private async getDocumentOpenAvailability(language: string): Promise<{
214+
canOpen: boolean;
215+
status?: ServerState['status'];
216+
skippedReason?: OpenDocumentSkippedReason;
217+
}> {
218+
const cachedStatus = this.getFreshCachedServerStatus(language);
219+
if (cachedStatus) {
220+
return cachedStatus === 'running'
221+
? { canOpen: true, status: cachedStatus }
222+
: {
223+
canOpen: false,
224+
status: cachedStatus,
225+
skippedReason: 'server-not-running'
226+
};
227+
}
228+
229+
const state = await this.getServerState(language);
230+
if (!state) {
231+
return { canOpen: true };
232+
}
233+
234+
this.rememberServerStatus(language, state.status);
235+
236+
return state.status === 'running'
237+
? { canOpen: true, status: state.status }
238+
: {
239+
canOpen: false,
240+
status: state.status,
241+
skippedReason: 'server-not-running'
242+
};
243+
}
244+
245+
159246
private onDiagnosticsReceived(data: LspEvent['data']) {
160247
const { uri, diagnostics } = data;
161248

@@ -186,6 +273,9 @@ export class WorkspaceLspManager {
186273

187274
if (!language) return;
188275

276+
if (this.isServerStatus(status)) {
277+
this.rememberServerStatus(language, status);
278+
}
189279

190280
switch (status) {
191281
case 'starting':
@@ -282,21 +372,10 @@ export class WorkspaceLspManager {
282372
progressNotif.complete();
283373
this.indexingProgressNotifications.delete(language);
284374
}
285-
286-
287-
this.startingLanguages.delete(language);
288375
}
289376

290377

291-
async openDocument(uri: string, language: string, content: string): Promise<string> {
292-
293-
if (this.startingLanguages.has(language)) {
294-
const readyPromise = this.languageReadyPromises.get(language);
295-
if (readyPromise) {
296-
await readyPromise;
297-
}
298-
}
299-
378+
async openDocument(uri: string, language: string, content: string): Promise<OpenDocumentResult> {
300379
if (!this.isInitialized) {
301380
try {
302381
await this.initialize();
@@ -305,21 +384,28 @@ export class WorkspaceLspManager {
305384
throw initError;
306385
}
307386
}
308-
309387

310-
if (!this.startingLanguages.has(language)) {
311-
this.startingLanguages.add(language);
312-
313-
314-
const readyPromise = new Promise<void>((resolve) => {
388+
// didOpen is only meaningful when a language server is actually running.
389+
// Checking the state here prevents layout/remount churn (for example window
390+
// fullscreen transitions) from generating batches of backend no-op logs.
391+
const availability = await this.getDocumentOpenAvailability(language);
392+
if (!availability.canOpen) {
393+
const key = this.normalizeLanguage(language);
394+
if (!this.skippedOpenNoticeLanguages.has(key)) {
395+
log.debug('Skipped LSP didOpen because language server is not running', {
396+
workspacePath: this.workspacePath,
397+
language,
398+
status: availability.status
399+
});
400+
this.skippedOpenNoticeLanguages.add(key);
401+
}
315402

316-
setTimeout(() => {
317-
this.startingLanguages.delete(language);
318-
this.languageReadyPromises.delete(language);
319-
resolve();
320-
}, 5000);
321-
});
322-
this.languageReadyPromises.set(language, readyPromise);
403+
return {
404+
language,
405+
opened: false,
406+
skippedReason: availability.skippedReason,
407+
serverStatus: availability.status
408+
};
323409
}
324410

325411
try {
@@ -341,7 +427,7 @@ export class WorkspaceLspManager {
341427

342428

343429

344-
return language;
430+
return { language, opened: true };
345431
} catch (error) {
346432
log.error('Failed to open document', { workspacePath: this.workspacePath, uri, language, error });
347433
throw error;

0 commit comments

Comments
 (0)