Skip to content

Commit 960ddda

Browse files
committed
fix(lsp): bound restart shutdown, drop stale messages, de-flake tests
Restart was awaiting the graceful LSP shutdown handshake unbounded, so a busy or cold server could stall a project-switch restart. Bound it to 3s then hard-kill (the graceful path is kept for servers that need it). Also drop inbound messages for a server that is stopping, so stale diagnostics from a dying instance can't leak into the fresh one that replaces it. Tests: warm up tsserver once in the TypeScript LSP integration beforeAll (its one-time cold start can exceed a spec timeout on slow CI runners) and add per-spec timeout headroom; replace the fixed-time wait in the jump-to-definition spec with a retry until the LSP result lands.
1 parent 5e55f52 commit 960ddda

3 files changed

Lines changed: 87 additions & 32 deletions

File tree

src/extensions/default/TypeScriptSupport/unittests.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,18 @@ define(function (require, exports, module) {
5959
await awaitsFor(function () {
6060
return testWindow._TypeScriptSupportReadyToIntegTest;
6161
}, "TypeScript LSP server to start", 30000);
62-
}, 30000);
62+
63+
// Warm up tsserver. Its very first request pays a large one-time cost - spawning node,
64+
// launching vtsls, and loading the TypeScript library + project - which on a slow/loaded
65+
// CI runner can exceed a single spec's timeout (fast dev machines never see it). Pay it
66+
// once here with a generous budget so every spec below talks to an already-primed server;
67+
// later project-switch restarts reuse the warm process and are fast.
68+
await SpecRunnerUtils.loadProjectInTestWindow(testFolder + "ts/");
69+
await awaitsForDone(SpecRunnerUtils.openProjectFiles(["type-error.ts"]), "warm-up: open type-error.ts");
70+
await awaitsFor(function () {
71+
return $("#problems-panel").text().includes("not assignable");
72+
}, "tsserver to warm up on first cold start", 90000);
73+
}, 100000);
6374

6475
afterAll(async function () {
6576
testWindow = null;
@@ -85,34 +96,34 @@ define(function (require, exports, module) {
8596
// type-error.ts assigns a string to a `number` -> TS2322 "... not assignable ...".
8697
await awaitsFor(function () {
8798
return panelText().includes("not assignable");
88-
}, "TypeScript type error to be reported", 20000);
89-
}, 30000);
99+
}, "TypeScript type error to be reported", 30000);
100+
}, 45000);
90101

91102
it("should report implicit-any in a JS project that opts into checkJs", async function () {
92103
// js-checkjs has a jsconfig.json with checkJs + noImplicitAny, so the untyped parameter
93104
// in implicit.js IS flagged - and our diagnostic filter keeps it (the project opted in).
94105
await _openInProject("js-checkjs/", "implicit.js");
95106
await awaitsFor(function () {
96107
return panelText().includes(IMPLICIT_ANY_MESSAGE);
97-
}, "implicit-any to be reported under checkJs", 20000);
98-
}, 30000);
108+
}, "implicit-any to be reported under checkJs", 30000);
109+
}, 45000);
99110

100111
it("should NOT report implicit-any in a plain JS project", async function () {
101112
// Precondition: confirm the server actually produces implicit-any for this exact code
102113
// (under checkJs), so the plain-project assertion below reflects gating, not just timing.
103114
await _openInProject("js-checkjs/", "implicit.js");
104115
await awaitsFor(function () {
105116
return panelText().includes(IMPLICIT_ANY_MESSAGE);
106-
}, "implicit-any under checkJs (precondition)", 20000);
117+
}, "implicit-any under checkJs (precondition)", 30000);
107118

108119
// Same code in a plain JS project (no jsconfig / no @ts-check): the "go add types" nag
109120
// must not appear. Wait for inspection to settle clean, then assert it is absent.
110121
await _openInProject("js-plain/", "implicit.js");
111122
await awaitsFor(function () {
112123
return $("#status-inspection").hasClass("inspection-valid");
113-
}, "plain JS inspection to settle with no problems", 20000);
124+
}, "plain JS inspection to settle with no problems", 30000);
114125
expect(panelText().includes(IMPLICIT_ANY_MESSAGE)).toBe(false);
115-
}, 30000);
126+
}, 75000);
116127

117128
// ----- hover quick-actions (Go to Definition / Find Usages) -------------------------------
118129

@@ -138,7 +149,7 @@ define(function (require, exports, module) {
138149
await awaitsFor(async function () {
139150
popover = await _hoverPopoverAt(editor, CALL_LINE, CALL_CH);
140151
return !!(popover && popover.content && popover.content.find(".lsp-hover-action").length === 2);
141-
}, "hover quick actions to appear", 20000);
152+
}, "hover quick actions to appear", 30000);
142153

143154
const labels = popover.content.find(".lsp-hover-action-label").map(function () {
144155
return $(this).text();
@@ -158,16 +169,16 @@ define(function (require, exports, module) {
158169
$act.trigger("click");
159170
}
160171
return EditorManager.getCurrentFullEditor().getCursorPos().line === DECL_LINE;
161-
}, "Go to Definition to navigate to the declaration", 25000);
172+
}, "Go to Definition to navigate to the declaration", 30000);
162173
expect(EditorManager.getCurrentFullEditor().getCursorPos().line).toBe(DECL_LINE);
163-
}, 40000);
174+
}, 75000);
164175

165176
it("hover Find Usages opens the references panel (" + tc.ext + ")", async function () {
166177
await _openInProject(tc.folder, tc.file);
167178
const editor = EditorManager.getCurrentFullEditor();
168179
await awaitsFor(async function () {
169180
return !!(await _hoverPopoverAt(editor, CALL_LINE, CALL_CH));
170-
}, "hover popover to be available", 20000);
181+
}, "hover popover to be available", 30000);
171182

172183
// "Find Usages" is the right-aligned action; clicking it opens the references panel.
173184
// Retry through the hover until the panel opens (the server may still be indexing).
@@ -181,9 +192,9 @@ define(function (require, exports, module) {
181192
$end.trigger("click");
182193
}
183194
return $("#reference-in-files-results").is(":visible");
184-
}, "references panel to open", 25000);
195+
}, "references panel to open", 30000);
185196
expect($("#reference-in-files-results").is(":visible")).toBe(true);
186-
}, 40000);
197+
}, 75000);
187198
});
188199
});
189200
});

src/languageTools/LSPClient.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ define(function (require, exports, module) {
167167
if (!client) {
168168
return;
169169
}
170+
if (client._stopping) {
171+
// The server is shutting down/restarting. Ignore any late messages it emits during the
172+
// teardown window so stale diagnostics from the dying instance don't leak into the fresh
173+
// one that replaces it (both share the same serverId).
174+
return;
175+
}
170176
if (data.method === "textDocument/publishDiagnostics" && client.lintingProvider) {
171177
const params = data.params || {};
172178
// Rewrite the URI to a VFS-based URI so the linting provider keys results by the
@@ -647,6 +653,27 @@ define(function (require, exports, module) {
647653
* @param {string} serverId
648654
* @return {Promise<void>}
649655
*/
656+
// How long to wait for a server to acknowledge a graceful `shutdown` before we hard-kill it.
657+
// Healthy servers reply in well under this; the cap is a failsafe so a slow/buggy/hung server
658+
// can't stall the restart indefinitely.
659+
const SHUTDOWN_TIMEOUT_MS = 3000;
660+
661+
// Resolve/reject with `promise`, but reject with a timeout error if it doesn't settle in `ms`.
662+
function _withTimeout(promise, ms) {
663+
return new Promise(function (resolve, reject) {
664+
const timer = setTimeout(function () {
665+
reject(new Error("timeout"));
666+
}, ms);
667+
promise.then(function (value) {
668+
clearTimeout(timer);
669+
resolve(value);
670+
}, function (err) {
671+
clearTimeout(timer);
672+
reject(err);
673+
});
674+
});
675+
}
676+
650677
async function restartLanguageServer(serverId) {
651678
const client = clients.get(serverId);
652679
if (!client) {
@@ -676,11 +703,19 @@ define(function (require, exports, module) {
676703
client.capabilities = null;
677704
client._completionCache = null;
678705
DocumentSync.clearServer(client);
706+
// Attempt a graceful LSP shutdown - some servers need it to flush state or clean up child
707+
// processes - but BOUND it. The `shutdown` request blocks until the server replies, and a
708+
// busy or cold server can be slow (or never reply), which would stall the restart; on a
709+
// project switch we'd end up waiting for the old server to finish booting just to tell it to
710+
// die, then cold-start a new one (a double penalty on slow CI). Give it a short budget, then
711+
// hard-kill regardless. The `exit` notification expects no reply, so it stays cheap.
679712
try {
680-
await conn.execPeer("sendRequest", { serverId: client.serverId, method: "shutdown", params: null });
713+
await _withTimeout(
714+
conn.execPeer("sendRequest", { serverId: client.serverId, method: "shutdown", params: null }),
715+
SHUTDOWN_TIMEOUT_MS);
681716
await conn.execPeer("sendNotification", { serverId: client.serverId, method: "exit", params: null });
682717
} catch (e) {
683-
// Server may already be dead; fall through to a hard stop.
718+
// Timed out, or the server is already dead - fall through to the hard stop.
684719
}
685720
await conn.execPeer("stopServer", { serverId: client.serverId });
686721
client._stopping = false;

test/spec/EditorCommandHandlers-integ-test.js

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,35 +191,44 @@ define(function (require, exports, module) {
191191

192192
describe("Editor Navigation Commands", function () {
193193
it("should jump to definition", async function () {
194-
var promise,
195-
selection;
196-
promise = CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, {fullPath: testPath + "/test.js"});
197-
await awaitsForDone(promise, "Open into working set");
198-
194+
var selection;
195+
await awaitsForDone(
196+
CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, {fullPath: testPath + "/test.js"}),
197+
"Open into working set");
199198
myEditor = EditorManager.getCurrentFullEditor();
200-
myEditor.setCursorPos({line: 5, ch: 8});
201-
await awaits(1500); // for the code intelligence framework to prime up
202-
promise = CommandManager.execute(Commands.NAVIGATE_JUMPTO_DEFINITION);
203-
await awaitsForDone(promise, "Jump To Definition");
204199

205-
selection = myEditor.getSelection();
206200
if (window.Phoenix.isNativeApp) {
207-
// Desktop has the LSP server (vtsls), which now provides jump-to-definition
208-
// for JavaScript (higher priority than the built-in Tern provider). vtsls
209-
// returns the full declaration range of testMe and we jump to its start
210-
// (the `function` keyword), giving a collapsed cursor at {0,0} - whereas Tern
211-
// selects the identifier name. Both correctly land on the testMe definition.
201+
// Desktop has the LSP server (vtsls), which provides jump-to-definition for
202+
// JavaScript at a higher priority than the built-in Tern provider. vtsls returns
203+
// testMe's full declaration range and we jump to its start (the `function`
204+
// keyword), giving a collapsed cursor at {0,0} - whereas Tern selects the
205+
// identifier name. The server can take a moment to prime after the file opens, so
206+
// retry the jump until the LSP result lands rather than using a fixed wait (which
207+
// flakes on slow CI: before vtsls is ready, Tern answers with {0,9}-{0,15}).
208+
await awaitsFor(async function () {
209+
myEditor.setCursorPos({line: 5, ch: 8});
210+
await awaitsForDone(CommandManager.execute(Commands.NAVIGATE_JUMPTO_DEFINITION),
211+
"Jump To Definition");
212+
var sel = myEditor.getSelection();
213+
return sel.start.line === 0 && sel.start.ch === 0 &&
214+
sel.end.line === 0 && sel.end.ch === 0;
215+
}, "LSP jump-to-definition to land on the testMe declaration", 30000);
216+
selection = myEditor.getSelection();
212217
expect(fixSel(selection)).toEql(fixSel({
213218
start: {line: 0, ch: 0},
214219
end: {line: 0, ch: 0}
215220
}));
216221
} else {
222+
myEditor.setCursorPos({line: 5, ch: 8});
223+
await awaitsForDone(CommandManager.execute(Commands.NAVIGATE_JUMPTO_DEFINITION),
224+
"Jump To Definition");
225+
selection = myEditor.getSelection();
217226
expect(fixSel(selection)).toEql(fixSel({
218227
start: {line: 0, ch: 9},
219228
end: {line: 0, ch: 15}
220229
}));
221230
}
222-
});
231+
}, 35000);
223232
});
224233

225234
if(window.Phoenix.isNativeApp) {

0 commit comments

Comments
 (0)