Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 118 additions & 13 deletions integrations/code/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* IN THE SOFTWARE.
*/

import * as vscode from "vscode";
import type { ExtensionContext } from "vscode";
import type { LanguageClient } from "vscode-languageclient/node";

Expand All @@ -28,6 +29,7 @@ import { createLanguageClient } from "./extension/client";
import { Context } from "./extension/context";
import { activateProjectMarkdown } from "./extension/project";
import { getStudio } from "./extension/studio";
import { NetworkError } from "./extension/studio/fetch";

/* ----------------------------------------------------------------------------
* State
Expand All @@ -38,6 +40,21 @@ import { getStudio } from "./extension/studio";
*/
let client: LanguageClient | undefined;

/**
* Startup timer.
*/
let retryTimer: ReturnType<typeof setTimeout> | undefined;

/**
* Startup retry delay.
*/
let retryDelay = 5000;

/**
* Whether startup is already in progress.
*/
let starting = false;

/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
Expand All @@ -51,32 +68,120 @@ export async function activate(extension: ExtensionContext): Promise<void> {
const context = new Context(extension);
void activateProjectMarkdown(extension, context);

// Obtain Zensical studio configuration
const studio = await getStudio(context);
if (typeof studio === "undefined") {
return;
}

// Register commands
registerCommands(extension);
extension.subscriptions.push(
// The timer below is the primary recovery mechanism. These hooks only make
// 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") {
void startStudio(context);
}
}),
vscode.workspace.onDidOpenTextDocument((document) => {
if (
document.languageId === "python-markdown" &&
typeof retryTimer !== "undefined"
) {
void startStudio(context);
}
}),
);

// Start Zensical Studio
void startStudio(context);
}

/**
* Deactivate extension.
*/
export async function deactivate(): Promise<void> {
clearRetry();
if (typeof client !== "undefined") {
await client.stop();
client = undefined;
}
}

/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */

/**
* Start Zensical Studio.
*
* @param context - Context
*/
async function startStudio(context: Context): Promise<void> {
if (typeof client !== "undefined" || starting) {
return;
}

// Create and start the language client
context.log("Starting Zensical Studio");
// Clear any scheduled retry
clearRetry();
starting = true;
try {
// Obtain Zensical studio configuration
const studio = await getStudio(context);
if (typeof studio === "undefined") {
retryDelay = 5000;
return;
}

// Create and start the language client
retryDelay = 5000;
context.log("Starting Zensical Studio");
client = createLanguageClient(context, studio);
client.start();
} catch (error) {
if (error instanceof NetworkError) {
scheduleRetry(context);
return;
}

// Log the error
const message = error instanceof Error ? error.message : String(error);
context.log(`Failed to start Zensical Studio: ${message}`);
} finally {
starting = false;
}
}

/**
* Deactivate extension.
* Schedule startup retry.
*
* @param context - Context
*/
export async function deactivate(): Promise<void> {
if (typeof client !== "undefined") {
await client.stop();
client = undefined;
function scheduleRetry(context: Context): void {
const delay = retryDelay;
const seconds = Math.round(delay / 1000);
context.log(`Network unavailable; retrying in ${seconds}s`);

// Schedule retry with exponential backoff and jitter
retryTimer = setTimeout(() => {
void startStudio(context);
}, jitter(delay));
retryDelay = Math.min(delay * 2, 5 * 60 * 1000);
}

/**
* Clear scheduled startup retry.
*/
function clearRetry(): void {
if (typeof retryTimer !== "undefined") {
clearTimeout(retryTimer);
retryTimer = undefined;
}
}

/**
* Add jitter to a retry delay.
*
* @param delay - Delay in milliseconds
*
* @returns Jittered delay
*/
function jitter(delay: number): number {
return Math.round(delay * (0.8 + Math.random() * 0.4));
}
2 changes: 1 addition & 1 deletion integrations/code/src/extension/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function createLanguageClient(
// Initialize client options
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: "file", language: "python-markdown" }],
outputChannel: context.output,
outputChannel: context.getOutput(),
initializationOptions: { token: studio.token },
};

Expand Down
9 changes: 9 additions & 0 deletions integrations/code/src/extension/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ export class Context {
return this.context.globalStorageUri.fsPath;
}

/**
* Get output channel.
*
* @returns Output channel
*/
public getOutput(): OutputChannel {
return this.output;
}

/**
* Get extension state for a given key.
*
Expand Down
47 changes: 37 additions & 10 deletions integrations/code/src/extension/studio/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ export interface Message {
confirm: boolean;
}

/* ----------------------------------------------------------------------------
* Class
* ------------------------------------------------------------------------- */

/**
* Retryable network failure.
*/
export class NetworkError extends Error {
/**
* Create error.
*
* @param message - Message
*/
public constructor(message: string) {
super(message);
this.name = "NetworkError";
}
}

/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
Expand Down Expand Up @@ -131,16 +150,24 @@ export async function fetchToken(
token?: string,
): Promise<Token | undefined> {
context.log("Renewing token for beta access");

// Fetch a new token
const url = "https://get.zensical.org/studio/token/";
const res = await request(context, url, {
...(token && { headers: { authorization: `Bearer ${token}` } }),
});
if (typeof res !== "undefined") {
return (await res.json()) as Token;
} else {
return;
try {
const res = await request(context, url, {
...(token && { headers: { authorization: `Bearer ${token}` } }),
});

// Return the new token if available
if (typeof res !== "undefined") {
return (await res.json()) as Token;
} else {
return;
}
} catch (error) {
if (typeof token !== "undefined" && error instanceof NetworkError) {
context.log("Using cached token");
return { token };
}
throw error;
}
}

Expand Down Expand Up @@ -181,7 +208,7 @@ async function request(
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
context.log(`Fetching failed: ${reason}`);
return;
throw new NetworkError(reason);
}
}

Expand Down
29 changes: 20 additions & 9 deletions integrations/code/src/extension/studio/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { coerce, satisfies, validRange } from "semver";

import type { Context } from "../context";
import { extract } from "./archive";
import { fetchArchive, fetchRelease, Release } from "./fetch";
import { fetchArchive, fetchRelease, NetworkError, Release } from "./fetch";

/* ----------------------------------------------------------------------------
* Functions
Expand All @@ -46,14 +46,6 @@ export async function getStudioPathFromInstallation(
): Promise<string | undefined> {
let archive = "";
try {
const release = await fetchRelease(context);
if (typeof release === "undefined") {
return;
}
if (!(await checkRelease(context, release))) {
return;
}

// Determine extension storage and ensure it exists
const storage = path.join(context.getStorage(), "studio");
await fs.mkdir(storage, { recursive: true });
Expand All @@ -66,6 +58,25 @@ export async function getStudioPathFromInstallation(
: "zensical-studio",
);

// Try to fetch the latest release
let release: Release | undefined;
try {
release = await fetchRelease(context);
} catch (error) {
if (!existsSync(executable) || !(error instanceof NetworkError)) {
throw error;
}
context.log("Using installed Zensical Studio");
}

// If we don't have a release, just return the installed executable
if (typeof release === "undefined") {
return existsSync(executable) ? executable : undefined;
}
if (!(await checkRelease(context, release))) {
return;
}

// Check if we already have the latest version available
const version = context.getState("version");
if (existsSync(executable) && version === release.version) {
Expand Down