From f5ea50b1f8fa85dbf631d8c1dd8d9f04bc38987a Mon Sep 17 00:00:00 2001 From: squidfunk Date: Sun, 13 Sep 2026 14:25:00 +0200 Subject: [PATCH 1/2] fix: avoid repeated Studio resolution during recovery Signed-off-by: squidfunk --- integrations/code/src/extension.ts | 47 +++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/integrations/code/src/extension.ts b/integrations/code/src/extension.ts index 47e5d3a..4d6f6c3 100644 --- a/integrations/code/src/extension.ts +++ b/integrations/code/src/extension.ts @@ -34,6 +34,7 @@ import { createLanguageClient } from "./extension/client"; import { Context } from "./extension/context"; import { activateProjectMarkdown } from "./extension/project"; import { getStudio } from "./extension/studio"; +import type { Studio } from "./extension/studio"; import { NetworkError } from "./extension/studio/fetch"; import { WordCount } from "./word-count"; @@ -46,6 +47,11 @@ import { WordCount } from "./word-count"; */ let client: LanguageClient | undefined; +/** + * Runtime resolved for the current startup or recovery episode. + */ +let studio: Studio | undefined; + /** * Listeners owned by the current language client. */ @@ -66,6 +72,16 @@ let connections: ConnectionsView | undefined; */ let retryTimer: ReturnType | undefined; +/** + * Whether editor activity may bring the current retry forward. + */ +let retryOnActivity = false; + +/** + * Whether Studio is recovering from an unexpected server stop. + */ +let recovering = false; + /** * Startup retry delay. */ @@ -113,7 +129,11 @@ export async function activate(extension: ExtensionContext): Promise { // retry more responsive when the user returns to the window or opens a // Python Markdown document after VPN/proxy startup has completed. vscode.window.onDidChangeWindowState((state) => { - if (state.focused && typeof retryTimer !== "undefined") { + if ( + state.focused && + typeof retryTimer !== "undefined" && + retryOnActivity + ) { void startStudio(extension, context); } }), @@ -123,7 +143,8 @@ export async function activate(extension: ExtensionContext): Promise { } if ( document.languageId === "python-markdown" && - typeof retryTimer !== "undefined" + typeof retryTimer !== "undefined" && + retryOnActivity ) { void startStudio(extension, context); } @@ -179,10 +200,10 @@ async function startStudio( starting = true; let next: LanguageClient | undefined; try { - // Obtain Zensical studio configuration - const studio = await getStudio(context); + // Resolve once, then reuse the same runtime throughout this retry episode. + studio ??= await getStudio(context); if (typeof studio === "undefined") { - scheduleRetry(extension, context, "Studio unavailable"); + scheduleRetry(extension, context, "Studio unavailable", true); return; } @@ -223,6 +244,7 @@ async function startStudio( extension, context, error instanceof NetworkError ? "Network unavailable" : "Startup failed", + error instanceof NetworkError, ); } finally { starting = false; @@ -241,6 +263,8 @@ async function restartStudio( clearRetry(); clearRetryReset(); retryDelay = 5000; + recovering = false; + studio = undefined; const previous = client; if (typeof previous === "undefined") { await startStudio(extension, context); @@ -276,12 +300,15 @@ function recoverStudio( if (typeof stopped === "undefined" || client !== stopped) { return; } - - context.log("Studio stopped; resolving the runtime before restart"); + context.log("Studio stopped; preparing to restart"); client = undefined; clearRetryReset(); disposeClientDisposables(); stopped.dispose(); + if (!recovering) { + recovering = true; + studio = undefined; + } scheduleRetry(extension, context, "Studio stopped"); } @@ -342,15 +369,19 @@ function waitForProcessExit( * * @param extension - Extension context * @param context - Context + * @param reason - Retry reason shown in the output channel + * @param onActivity - Whether editor activity may bring the retry forward */ function scheduleRetry( extension: ExtensionContext, context: Context, reason: string, + onActivity = false, ): void { if (typeof retryTimer !== "undefined") { return; } clearRetryReset(); + retryOnActivity = onActivity; const delay = retryDelay; const seconds = Math.round(delay / 1000); context.log(`${reason}; retrying in ${seconds}s`); @@ -375,6 +406,7 @@ function markStudioStable(): void { retryResetTimer = setTimeout(() => { retryResetTimer = undefined; retryDelay = 5000; + recovering = false; }, 3 * 60 * 1000); } @@ -386,6 +418,7 @@ function clearRetry(): void { clearTimeout(retryTimer); retryTimer = undefined; } + retryOnActivity = false; } /** From eb867d7d26b5d14353aefa13a56b3a9cdbab1956 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Sun, 13 Sep 2026 15:47:27 +0200 Subject: [PATCH 2/2] fix: report repeated Studio startup failures Signed-off-by: squidfunk --- integrations/code/src/extension.ts | 142 ++++++++++++++++++++- integrations/code/src/extension/context.ts | 39 ++++++ integrations/code/src/extension/studio.ts | 4 - 3 files changed, 177 insertions(+), 8 deletions(-) diff --git a/integrations/code/src/extension.ts b/integrations/code/src/extension.ts index 4d6f6c3..302108a 100644 --- a/integrations/code/src/extension.ts +++ b/integrations/code/src/extension.ts @@ -26,6 +26,7 @@ import * as vscode from "vscode"; import type { Disposable, ExtensionContext, TextDocument } from "vscode"; import type { ChildProcess } from "node:child_process"; +import { release as getOsRelease } from "node:os"; import type { LanguageClient } from "vscode-languageclient/node"; import { registerCommands } from "./commands"; @@ -82,6 +83,26 @@ let retryOnActivity = false; */ let recovering = false; +/** + * Number of consecutive runtime availability failures. + */ +let availabilityFailures = 0; + +/** + * Whether the current availability failure has been reported. + */ +let availabilityNoticeShown = false; + +/** + * Number of consecutive process startup or runtime failures. + */ +let startupFailures = 0; + +/** + * Whether the current startup failure has been reported. + */ +let startupNoticeShown = false; + /** * Startup retry delay. */ @@ -203,15 +224,18 @@ async function startStudio( // Resolve once, then reuse the same runtime throughout this retry episode. studio ??= await getStudio(context); if (typeof studio === "undefined") { + recordAvailabilityFailure(context); scheduleRetry(extension, context, "Studio unavailable", true); return; } + availabilityFailures = 0; + availabilityNoticeShown = false; // Create and start the language client context.log("Starting Zensical Studio"); next = createLanguageClient(context, studio, () => { setTimeout(() => { - recoverStudio(extension, context, next); + void recoverStudio(extension, context, next); }, 0); }); client = next; @@ -240,6 +264,11 @@ async function startStudio( // Log the error const message = error instanceof Error ? error.message : String(error); context.log(`Failed to start Zensical Studio: ${message}`); + if (error instanceof NetworkError) { + recordAvailabilityFailure(context); + } else { + recordStartupFailure(context, message); + } scheduleRetry( extension, context, @@ -265,6 +294,7 @@ async function restartStudio( retryDelay = 5000; recovering = false; studio = undefined; + resetFailureNotices(); const previous = client; if (typeof previous === "undefined") { await startStudio(extension, context); @@ -293,18 +323,23 @@ async function restartStudio( * @param context - Context * @param stopped - Language client that stopped unexpectedly */ -function recoverStudio( +async function recoverStudio( extension: ExtensionContext, context: Context, stopped: LanguageClient | undefined, -): void { +): Promise { if (typeof stopped === "undefined" || client !== stopped) { return; } - context.log("Studio stopped; preparing to restart"); + + const serverProcess = stopped.serverProcess; + const reason = describeServerStop(serverProcess); + context.log(`Studio stopped unexpectedly: ${reason}`); + recordStartupFailure(context, reason); client = undefined; clearRetryReset(); disposeClientDisposables(); stopped.dispose(); + await terminateServerProcess(serverProcess, context); if (!recovering) { recovering = true; studio = undefined; @@ -407,9 +442,108 @@ function markStudioStable(): void { retryResetTimer = undefined; retryDelay = 5000; recovering = false; + resetFailureNotices(); }, 3 * 60 * 1000); } +/** + * Record that Studio's runtime could not be resolved. + * + * @param context - Context + */ +function recordAvailabilityFailure(context: Context): void { + availabilityFailures += 1; + if (availabilityFailures < 3 || availabilityNoticeShown) { + return; + } + + availabilityNoticeShown = true; + logStartupEnvironment(context); + void context.promptStudioUnavailable(); +} + +/** + * Record that Studio failed to start or stopped unexpectedly. + * + * @param context - Context + * @param reason - Failure reason + */ +function recordStartupFailure(context: Context, reason: string): void { + startupFailures += 1; + context.log( + `Startup attempt ${startupFailures} failed: ${singleLine(reason)}`, + ); + if (startupFailures < 3 || startupNoticeShown) { + return; + } + + startupNoticeShown = true; + logStartupEnvironment(context); + void context.promptStudioStartupFailure(); +} + +/** + * Log the environment needed for a startup issue report. + * + * @param context - Context + */ +function logStartupEnvironment(context: Context): void { + const remote = vscode.env.remoteName ?? "local"; + const configured = context.getConfiguration().get("path")?.trim(); + const version = configured + ? "custom" + : context.getState("version") ?? "unknown"; + context.log( + "Startup environment: " + + `extension=${context.getVersion()}, ` + + `Studio=${version}, ` + + `editor=${vscode.env.appName} ${vscode.version}, ` + + `platform=${process.platform} ${getOsRelease()}/${process.arch}, ` + + `remote=${remote}`, + ); +} + +/** + * Describe how the server process stopped. + * + * @param serverProcess - Server process + * + * @returns Stop reason + */ +function describeServerStop(serverProcess: ChildProcess | undefined): string { + if (!serverProcess) { + return "language server connection closed"; + } + if (serverProcess.signalCode !== null) { + return `server process exited with signal ${serverProcess.signalCode}`; + } + if (serverProcess.exitCode !== null) { + return `server process exited with code ${serverProcess.exitCode}`; + } + return "language server connection closed while the process was running"; +} + +/** + * Reset failure counters after a manual restart or stable session. + */ +function resetFailureNotices(): void { + availabilityFailures = 0; + availabilityNoticeShown = false; + startupFailures = 0; + startupNoticeShown = false; +} + +/** + * Collapse a failure reason into one log line. + * + * @param value - Failure reason + * + * @returns Single-line failure reason + */ +function singleLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + /** * Clear scheduled startup retry. */ diff --git a/integrations/code/src/extension/context.ts b/integrations/code/src/extension/context.ts index e463d8f..716ebdc 100644 --- a/integrations/code/src/extension/context.ts +++ b/integrations/code/src/extension/context.ts @@ -147,6 +147,45 @@ export class Context { void show("info", message); } + /** + * Explain that Studio remains unavailable after several attempts. + */ + public async promptStudioUnavailable(): Promise { + const action = "Show Logs"; + const result = await vscode.window.showWarningMessage( + "Zensical Studio is still unavailable. Check your network connection " + + "and configured Studio path. Studio will keep retrying.", + action, + ); + if (result === action) { + this.output.show(); + } + } + + /** + * Explain a repeated startup failure and offer a reporting path. + */ + public async promptStudioStartupFailure(): Promise { + const logs = "Show Logs"; + const report = "Report Issue"; + const result = await vscode.window.showErrorMessage( + "Zensical Studio could not be started after several attempts. " + + "If the problem persists, report an issue and include the Studio logs.", + logs, + report, + ); + if (result === logs) { + this.output.show(); + } else if (result === report) { + this.output.show(); + await vscode.env.openExternal(vscode.Uri.parse( + "https://github.com/zensical/studio/issues/new" + + "?template=01-report-a-bug.yml" + + "&title=Studio%20fails%20to%20start", + )); + } + } + /** * Prompt the user to update the extension. * diff --git a/integrations/code/src/extension/studio.ts b/integrations/code/src/extension/studio.ts index 6fbf5c8..324a0fb 100644 --- a/integrations/code/src/extension/studio.ts +++ b/integrations/code/src/extension/studio.ts @@ -67,10 +67,6 @@ export async function getStudio(context: Context): Promise { const token = await getToken(context); if (typeof token === "undefined") { context.log("Token not found or expired."); - context.showError( - "Zensical Studio could not refresh the token. " + - "Connect to the internet and reload the window.", - ); return; }