Skip to content

Recover from shutdown timeout in tryRestart#3602

Merged
andrewbranch merged 1 commit intomicrosoft:mainfrom
ekazakov14:fix/restart-server-timeout
Apr 28, 2026
Merged

Recover from shutdown timeout in tryRestart#3602
andrewbranch merged 1 commit intomicrosoft:mainfrom
ekazakov14:fix/restart-server-timeout

Conversation

@ekazakov14
Copy link
Copy Markdown
Contributor

Summary

Make TypeScript Native Preview: Restart Server succeed on the first click when the language server is hung.

Fixes #3601.

Problem

Client.tryRestart in _extension/src/client.ts currently calls LanguageClient.restart() directly. LanguageClient.restart() is await this.stop(); await this.start();. stop() performs an LSP graceful shutdown with a hardcoded 2-second timeout in vscode-languageclient. When the server is hung and doesn't answer shutdown/exit within that window, stop() rejects, the rejection bubbles up, and await this.start() is never reached — so no new process is forked. The user sees a Stopping the server timed out popup and has to click Restart again to actually get a new server.

Change

Wrap client.restart() in try/catch. If the graceful shutdown rejects (which only happens when the server is unresponsive), fall back to a plain client.start(). By the time the catch runs, vscode-languageclient has already cleaned up its state to Stopped and scheduled a SIGKILL of the old process via its internal checkProcessDied — so start() cleanly forks a fresh server.

try {
    await this.client.restart();
}
catch (err) {
    this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
    await this.client.start();
}

The behavior on the happy path (server responds to shutdown normally) is unchanged — restart() returns successfully, the catch never runs.

Verification

Manual test using the deterministic reproduction from #3601:

  1. Add time.Sleep(5 * time.Second) at the top of handleShutdown in internal/lsp/server.go.
  2. Build (hereby tsgo + extension build), run extension dev host, open a TypeScript file.
  3. Run TypeScript Native Preview: Restart Server.

Before the fix:

Restarting language server...
[error] Stopping server timed out

Popup appears. Server is dead. User must click Restart a second time to get a working server.

After the fix:

Restarting language server...
[error] Stopping server timed out
[info]  Graceful shutdown failed, forcing restart: Error: Stopping the server timed out
[info]  Resolved client capabilities: { ... }

No popup. Server is back up after a single click, with a ~2s delay (the unavoidable vscode-languageclient shutdown timeout).

Scope

Per CONTRIBUTING.md, only PRs addressing 6.0/7.0 differences or crashes are being accepted before TS 7.0 ships. This change is strictly client-side (TypeScript-only, ~5 lines, no Go-parity surface) and improves the recovery path from server crashes/hangs — it doesn't change any compiler behavior. Happy to defer if it's still considered out of scope, but flagging that the bug actively degrades the experience whenever a crash or hang does occur.

When the language server is hung, LanguageClient.restart() rejects
because the LSP graceful shutdown exceeds vscode-languageclient's
hardcoded 2-second timeout. The rejection prevents start() from
running, so the user has to click Restart Server twice to actually
get a new server.

Catch the rejection from restart() and fall back to start(). By the
time the catch runs, vscode-languageclient has already cleaned up
its state, so a plain start() forks a fresh process cleanly.

Fixes microsoft#3601.
Copy link
Copy Markdown
Member

@andrewbranch andrewbranch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this is great, thank you! This has occasionally annoyed me but haven't had time to investigate.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the VS Code “TypeScript Native Preview: Restart Server” command so it can recover on the first invocation when vscode-languageclient’s graceful shutdown times out due to an unresponsive/hung server.

Changes:

  • Wraps LanguageClient.restart() in try/catch within Client.tryRestart.
  • On shutdown timeout failure, logs a message and falls back to LanguageClient.start() to force a new server process.

Comment thread _extension/src/client.ts
catch (err) {
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
await this.client.start();
}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tryRestart sets this.isInitialized = false but never sets it back to true (nor re-fires initializedEventEmitter) after a successful restart()/start(). After the first restart, extension.ts’s onLanguageServerInitialized will treat the server as uninitialized and may never invoke new listeners. Consider setting isInitialized back to true and firing the initialized event once the client has successfully started again (e.g., in a finally/post-success block).

Suggested change
}
}
this.isInitialized = true;
this.initializedEventEmitter.fire();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems right, but not related to this PR—I'll update separately.

Comment thread _extension/src/client.ts
Comment on lines +280 to +281
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
await this.client.start();
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch will also run if LanguageClient.restart() fails for reasons other than the shutdown timeout (e.g., a failure during the subsequent start()), in which case calling start() again may mask the real error or create confusing double-start behavior. Recommend narrowing the fallback to the specific timeout failure (or rethrowing for non-timeout errors).

Suggested change
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
await this.client.start();
if (err instanceof Error && /timed out/i.test(err.message)) {
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
await this.client.start();
}
else {
throw err;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's any other plausible reason for restart to throw, and the client library already guards against double-starting. This is fine.

Comment thread _extension/src/client.ts
Comment on lines +279 to +280
catch (err) {
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the log line, interpolating err directly can produce unhelpful output like [object Object] and loses stack details. Consider treating it as unknown and formatting via err instanceof Error ? err.message : String(err) (and/or logging via outputChannel.error/warn to reflect that shutdown failed).

Suggested change
catch (err) {
this.outputChannel.appendLine(`Graceful shutdown failed, forcing restart: ${err}`);
catch (err: unknown) {
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
this.outputChannel.error(`Graceful shutdown failed, forcing restart: ${message}`);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only Error objects are thrown in this path, which stringify as expected.

@andrewbranch andrewbranch added this pull request to the merge queue Apr 28, 2026
Merged via the queue into microsoft:main with commit 2a3f6d2 Apr 28, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restart Server: "Stopping timed out" on first click when server is hung

3 participants