From 95a2a9c0bc81b0e13d6b000f966d7a3ef3cb6f82 Mon Sep 17 00:00:00 2001 From: tdstein Date: Fri, 19 Jan 2024 14:54:10 -0500 Subject: [PATCH] fix(vscode): recovers server process if terminated --- extensions/vscode/package.json | 8 +- extensions/vscode/src/assistants.ts | 52 ------ extensions/vscode/src/extension.ts | 11 +- extensions/vscode/src/panels.ts | 26 +-- extensions/vscode/src/ports.ts | 18 -- extensions/vscode/src/servers.ts | 70 ++++++++ extensions/vscode/src/services.ts | 160 +++--------------- extensions/vscode/src/terminals.ts | 23 +++ .../vscode/src/test/suite/extension.test.ts | 2 +- extensions/vscode/src/test/suite/index.ts | 3 +- 10 files changed, 136 insertions(+), 237 deletions(-) delete mode 100644 extensions/vscode/src/assistants.ts create mode 100644 extensions/vscode/src/servers.ts create mode 100644 extensions/vscode/src/terminals.ts diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index b45bb0391..c04cb94cc 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -17,12 +17,14 @@ "Machine Learning", "Other" ], - "activationEvents": [], + "activationEvents": [ + "onStartupFinished" + ], "main": "./out/extension.js", "contributes": { "commands": [ { - "command": "posit.publisher.start", + "command": "posit.publisher.open", "title": "Open Publisher", "category": "Posit", "icon": { @@ -48,7 +50,7 @@ "menus": { "editor/title": [ { - "command": "posit.publisher.start", + "command": "posit.publisher.open", "group": "navigation" } ] diff --git a/extensions/vscode/src/assistants.ts b/extensions/vscode/src/assistants.ts deleted file mode 100644 index 392b7a62f..000000000 --- a/extensions/vscode/src/assistants.ts +++ /dev/null @@ -1,52 +0,0 @@ - -import * as retry from 'retry'; -import * as vscode from 'vscode'; - -import * as commands from './commands'; -import * as ports from './ports'; -import { IPanel } from './panels'; - -export class Assistant { - - private readonly name: string = "Publisher"; - - private readonly context: vscode.ExtensionContext; - private readonly panel: IPanel; - private readonly path: string; - private readonly port: number; - private readonly terminal: vscode.Terminal; - - constructor (context: vscode.ExtensionContext, panel: IPanel, path: string, port: number) { - this.context = context; - this.panel = panel; - this.path = path; - this.port = port; - this.terminal = vscode.window.createTerminal({ name: this.name, hideFromUser: false }); - } - - show = async () => { - return this.panel.show(); - }; - - start = async (): Promise => { - const command: commands.Command = await commands.create(this.context, this.path, this.port); - this.terminal.sendText(command); - if (!(await ports.ping(this.port))) { - throw Error("publisher failed to start"); - } - }; - - stop = async (): Promise => { - // close the panel - this.panel.dispose(); - const operation = retry.operation(); - operation.attempt(async () => { - // send "CTRL+C" command - this.terminal.sendText("\u0003"); - const pong = await ports.ping(this.port, 1000); - if (pong) { - throw Error("application is still running"); - } - }); - }; -} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index b935251b2..edf9ec03f 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -2,6 +2,7 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; +import * as ports from './ports'; import { Service } from './services'; // Once the extension is activate, hang on to the service so that we can stop it on deactivation. @@ -11,16 +12,18 @@ let service: Service; // Your extension is activated the very first time the command is executed export async function activate(context: vscode.ExtensionContext) { + const port = await ports.acquire(); + service = new Service(port); + await service.start(context); + context.subscriptions.push( - vscode.commands.registerCommand('posit.publisher.start', async () => { - service = await Service.get(context); - await service.start(); + vscode.commands.registerCommand('posit.publisher.open', async () => { + await service.open(context); }) ); context.subscriptions.push( vscode.commands.registerCommand('posit.publisher.close', async () => { - service = await Service.get(context); await service.stop(); }) ); diff --git a/extensions/vscode/src/panels.ts b/extensions/vscode/src/panels.ts index f5d4e02fd..f1187d81d 100644 --- a/extensions/vscode/src/panels.ts +++ b/extensions/vscode/src/panels.ts @@ -1,31 +1,21 @@ import * as vscode from 'vscode'; -const DEFAULT_COLUMN = vscode.ViewColumn.Beside; +import { HOST } from '.'; -export interface IPanel extends vscode.Disposable { - show: () => Promise; -} +const DEFAULT_COLUMN = vscode.ViewColumn.Beside; -export class Panel implements IPanel { +export class Panel { - private readonly context: vscode.ExtensionContext; private readonly url: string; private column: vscode.ViewColumn = DEFAULT_COLUMN; private panel?: vscode.WebviewPanel; - /** - * Creates a Panel implementation. - * - * @param {vscode.ExtensionContext} context - The extension content - * @param {string} url - The server url (i.e., http://localhost:8080) - */ - constructor(context: vscode.ExtensionContext, url: string) { - this.context = context; - this.url = url; + constructor(port: number) { + this.url = `http://${HOST}:${port}`; } - async show(): Promise { + async show(context: vscode.ExtensionContext): Promise { // reveal panel if defined if (this.panel !== undefined) { this.panel.reveal(this.column); @@ -55,7 +45,7 @@ export class Panel implements IPanel { this.column = event.webviewPanel.viewColumn || DEFAULT_COLUMN; }, null, - this.context.subscriptions + context.subscriptions ); // register dispose @@ -65,7 +55,7 @@ export class Panel implements IPanel { this.panel = undefined; }, null, - this.context.subscriptions + context.subscriptions ); } diff --git a/extensions/vscode/src/ports.ts b/extensions/vscode/src/ports.ts index ec8d291bf..76b9e50b6 100644 --- a/extensions/vscode/src/ports.ts +++ b/extensions/vscode/src/ports.ts @@ -1,23 +1,5 @@ import getPort = require('get-port'); -import * as wait from 'wait-on'; - -import { HOST } from '.'; export const acquire = async (): Promise => { return getPort(); }; - -export const ping = async (port: number, timeout: number = 30 * 1000): Promise => { - try { - await wait({ - resources: [ - `http-get://${HOST}:${port}` - ], - timeout: timeout - }); - return true; - } catch (e) { - console.warn("failed waiting for port", e); - return false; - } -}; diff --git a/extensions/vscode/src/servers.ts b/extensions/vscode/src/servers.ts new file mode 100644 index 000000000..8fea99ea8 --- /dev/null +++ b/extensions/vscode/src/servers.ts @@ -0,0 +1,70 @@ +import * as retry from 'retry'; +import * as vscode from 'vscode'; +import * as wait from 'wait-on'; + +import { HOST } from '.'; + +import * as commands from './commands'; +import { Terminal } from './terminals'; +import * as workspaces from './workspaces'; + +export class Server implements Server, vscode.Disposable { + + readonly port: number; + readonly terminal: Terminal; + + constructor (port: number) { + this.port = port; + this.terminal = new Terminal(); + } + + async start(context: vscode.ExtensionContext): Promise { + const isRunning = await this.isRunning(); + if (!isRunning) { + const message = vscode.window.setStatusBarMessage("Starting Posit Publisher. Please wait..."); + const path = workspaces.path(); + const command: commands.Command = await commands.create(context, path!, this.port); + this.terminal.get().sendText(command); + await this.isRunning(); + // The server will respond as ready before the API has fully initialized. Wait an additional second for good measure. + await new Promise(_ => setTimeout(_, 1000)); + message.dispose(); + } + } + + async stop(): Promise { + const message = vscode.window.setStatusBarMessage("Shutting down Posit Publisher. Please wait..."); + const operation = retry.operation(); + operation.attempt(async () => { + // send "CTRL+C" command + this.terminal.get().sendText("\u0003"); + const isRunning = await this.isRunning(); + + if (isRunning) { + // throw error to invoke retry + throw Error("application is still running"); + } + }); + message.dispose(); + } + + dispose() { + this.terminal.dispose(); + } + + private async isRunning(): Promise { + try { + await wait({ + resources: [ + `http-get://${HOST}:${this.port}` + ], + timeout: 1000 + }); + return true; + } catch (e) { + console.warn("failed waiting for port", e); + return false; + } + } + +} diff --git a/extensions/vscode/src/services.ts b/extensions/vscode/src/services.ts index 905342258..66a5c4821 100644 --- a/extensions/vscode/src/services.ts +++ b/extensions/vscode/src/services.ts @@ -3,158 +3,38 @@ var mutexify = require('mutexify/promise'); import * as vscode from 'vscode'; import { HOST } from '.'; -import { Assistant } from './assistants'; import { Panel } from './panels'; -import * as ports from './ports'; -import * as workspaces from './workspaces'; +import { Server } from './servers'; -type State = "NEW" | "STARTING" | "RUNNING" | "STOPPING" | "TERMINATED" | "FAILED"; +export class Service implements vscode.Disposable { -class StateManager { + private panel: Panel; + private server: Server; - private lock; - private state: State = "NEW"; - - constructor () { - this.lock = mutexify(); + constructor(port: number) { + this.panel = new Panel(port); + this.server = new Server(port); } - // Checks if the expected current state matches the internal state. - // If the states match, the callback is executed. If successful, true is returned. - // Otherwise, false is returned. - check = async (...current: State[]): Promise => { - // acquire the lock - const release = await this.lock(); - try { - if (current.includes(this.state)) { - return true; - } - return false; - } catch (e: unknown) { - if (e instanceof Error) { - console.error(e.message); - throw e; - } - this.state = "FAILED"; - console.warn("unhandled error", e); - vscode.window.showInformationMessage("Posit Publisher failed. Please try again."); - return false; - } finally { - // always release the lock - release(); - } + start = async (context: vscode.ExtensionContext) => { + await this.server.start(context); }; - // Transitions the internal state from the current state the next state. - // If the internal state does not match the current state, an error is thrown. - // Otherwise, the callback is executed and the state is set to the provided next state. - transition = async (current: State, next: State, callback: Function): Promise => { - // acquire the lock - const release = await this.lock(); - try { - if (this.state === current) { - await callback(); - this.state = next; - return this.state; - } - throw Error(`current state (${current}) does not match internal state (${this.state}).`); - } catch (e: unknown) { - if (e instanceof Error) { - console.error(e.message); - throw e; - } - this.state = "FAILED"; - console.warn("unhandled error", e); - vscode.window.showInformationMessage("Posit Publisher failed. Please try again."); - return this.state; - } finally { - // always release the lock - release(); - } + open = async (context: vscode.ExtensionContext) => { + // re-run the start sequence in case the server has stopped. + await this.server.start(context); + this.panel.show(context); }; -} - -export class Service { - - private static instance: Service | undefined = undefined; - - private manager: StateManager = new StateManager(); - - private assistant: Assistant; - static get = async (context: vscode.ExtensionContext): Promise => { - if (Service.instance !== undefined) { - return Service.instance; - } - - // create panel - const port = await ports.acquire(); - const url = `http://${HOST}:${port}`; - const panel = new Panel(context, url); - - // create assistant - const path = workspaces.path(); - if (path === undefined) { - throw new Error("workspace path is undefined"); - } - const assistant = new Assistant(context, panel, path, port); - - // create service - const service = new Service(assistant); - Service.instance = service; - return service; + stop = async () => { + await this.server.stop(); + this.panel.dispose(); + this.server.dispose(); }; - private constructor(assistant: Assistant) { - this.assistant = assistant; + dispose() { + this.panel.dispose(); + this.server.dispose(); } - start = async () => { - const isRunning = await this.manager.check("RUNNING"); - - if (isRunning) { - console.debug("the service is already running"); - this.assistant.show(); - return; - } - - let message: vscode.Disposable; - await this.manager.transition("NEW", "STARTING", async () => { - console.debug("the service is starting"); - message = vscode.window.setStatusBarMessage("Starting Posit Publisher. Please wait..."); - await this.assistant.start(); - }); - - await this.manager.transition("STARTING", "RUNNING", async () => { - console.debug("the service is running"); - this.assistant.show(); - if (message) { - message.dispose(); - } - }); - }; - - stop = async () => { - const isStopped = await this.manager.check("NEW", "TERMINATED", "FAILED"); - - if (isStopped) { - console.debug("the service isn't running"); - return; - } - - let message: vscode.Disposable; - await this.manager.transition("RUNNING", "STOPPING", async () => { - console.debug("the service is stopping"); - message = vscode.window.setStatusBarMessage("Shutting down Posit Publisher. Please wait..."); - await this.assistant.stop(); - }); - - await this.manager.transition("STOPPING", "NEW", async () => { - console.debug("the service is terminated"); - if (message) { - message.dispose(); - } - }); - }; - } diff --git a/extensions/vscode/src/terminals.ts b/extensions/vscode/src/terminals.ts new file mode 100644 index 000000000..2c5f8b08b --- /dev/null +++ b/extensions/vscode/src/terminals.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; + +export class Terminal implements vscode.Disposable { + + private terminal: vscode.Terminal | undefined; + + get (): vscode.Terminal { + if (this.terminal === undefined) { + this.terminal = vscode.window.createTerminal({ hideFromUser: false }); + // register callbacks + vscode.window.onDidCloseTerminal(() => { + this.terminal = undefined; + }); + } + return this.terminal; + } + + dispose(): void { + if (this.terminal !== undefined) { + this.terminal.dispose(); + } + } +} diff --git a/extensions/vscode/src/test/suite/extension.test.ts b/extensions/vscode/src/test/suite/extension.test.ts index 2d4990b04..f3ad8e896 100644 --- a/extensions/vscode/src/test/suite/extension.test.ts +++ b/extensions/vscode/src/test/suite/extension.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; // import * as myExtension from '../../extension'; -suite('Extension Test Suite', () => { +suite('Extension Test Suite', async () => { test('extension can activate', async () => { const extension: vscode.Extension = vscode.extensions.getExtension("posit.publisher")!; assert.ok(!extension.isActive); diff --git a/extensions/vscode/src/test/suite/index.ts b/extensions/vscode/src/test/suite/index.ts index dd50e87d7..dfc546df2 100644 --- a/extensions/vscode/src/test/suite/index.ts +++ b/extensions/vscode/src/test/suite/index.ts @@ -7,7 +7,8 @@ export function run(): Promise { // Create the mocha test const mocha = new Mocha({ ui: 'tdd', - color: true + color: true, + timeout: 10000 }); const testsRoot = path.resolve(__dirname, '..');