diff --git a/src/node/cli.ts b/src/node/cli.ts index b723417d..9683945d 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -496,3 +496,23 @@ async function copyOldMacOSDataDir(): Promise { await fs.copy(oldDataDir, paths.data) } } + +export const shouldRunVsCodeCli = (args: Args): boolean => { + return !!args["list-extensions"] || !!args["install-extension"] || !!args["uninstall-extension"] +} + +/** + * Determine if it looks like the user is trying to open a file or folder in an + * existing instance. The arguments here should be the arguments the user + * explicitly passed on the command line, not defaults or the configuration. + */ +export const shouldOpenInExistingInstance = async (args: Args): Promise => { + // Always use the existing instance if we're running from VS Code's terminal. + if (process.env.VSCODE_IPC_HOOK_CLI) { + return process.env.VSCODE_IPC_HOOK_CLI + } + + // TODO: implement + + return undefined +} diff --git a/src/node/entry.ts b/src/node/entry.ts index dfd78cda..0d16c250 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -11,12 +11,21 @@ import { ProxyHttpProvider } from "./app/proxy" import { StaticHttpProvider } from "./app/static" import { UpdateHttpProvider } from "./app/update" import { VscodeHttpProvider } from "./app/vscode" -import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli" +import { + Args, + bindAddrFromAllSources, + optionDescriptions, + parse, + readConfigFile, + setDefaults, + shouldOpenInExistingInstance, + shouldRunVsCodeCli, +} from "./cli" import { coderCloudBind } from "./coder-cloud" import { AuthType, HttpServer, HttpServerOptions } from "./http" import { loadPlugins } from "./plugin" import { generateCertificate, hash, humanPath, open } from "./util" -import { ipcMain, wrap } from "./wrapper" +import { ipcMain, WrapperProcess } from "./wrapper" let pkg: { version?: string; commit?: string } = {} try { @@ -28,6 +37,86 @@ try { const version = pkg.version || "development" const commit = pkg.commit || "development" +export const runVsCodeCli = (args: Args): void => { + logger.debug("forking vs code cli...") + const vscode = cp.fork(path.resolve(__dirname, "../../lib/vscode/out/vs/server/fork"), [], { + env: { + ...process.env, + CODE_SERVER_PARENT_PID: process.pid.toString(), + }, + }) + vscode.once("message", (message: any) => { + logger.debug("got message from VS Code", field("message", message)) + if (message.type !== "ready") { + logger.error("Unexpected response waiting for ready response", field("type", message.type)) + process.exit(1) + } + const send: CliMessage = { type: "cli", args } + vscode.send(send) + }) + vscode.once("error", (error) => { + logger.error("Got error from VS Code", field("error", error)) + process.exit(1) + }) + vscode.on("exit", (code) => process.exit(code || 0)) +} + +export const openInExistingInstance = async (args: Args, socketPath: string): Promise => { + const pipeArgs: OpenCommandPipeArgs & { fileURIs: string[] } = { + type: "open", + folderURIs: [], + fileURIs: [], + forceReuseWindow: args["reuse-window"], + forceNewWindow: args["new-window"], + } + + const isDir = async (path: string): Promise => { + try { + const st = await fs.stat(path) + return st.isDirectory() + } catch (error) { + return false + } + } + + for (let i = 0; i < args._.length; i++) { + const fp = path.resolve(args._[i]) + if (await isDir(fp)) { + pipeArgs.folderURIs.push(fp) + } else { + pipeArgs.fileURIs.push(fp) + } + } + + if (pipeArgs.forceNewWindow && pipeArgs.fileURIs.length > 0) { + logger.error("--new-window can only be used with folder paths") + process.exit(1) + } + + if (pipeArgs.folderURIs.length === 0 && pipeArgs.fileURIs.length === 0) { + logger.error("Please specify at least one file or folder") + process.exit(1) + } + + const vscode = http.request( + { + path: "/", + method: "POST", + socketPath, + }, + (response) => { + response.on("data", (message) => { + logger.debug("got message from VS Code", field("message", message.toString())) + }) + }, + ) + vscode.on("error", (error: unknown) => { + logger.error("got error from VS Code", field("error", error)) + }) + vscode.write(JSON.stringify(pipeArgs)) + vscode.end() +} + const main = async (args: Args, configArgs: Args): Promise => { if (args.link) { // If we're being exposed to the cloud, we listen on a random address and disable auth. @@ -92,7 +181,7 @@ const main = async (args: Args, configArgs: Args): Promise => { await loadPlugins(httpServer, args) - ipcMain().onDispose(() => { + ipcMain.onDispose(() => { httpServer.dispose().then((errors) => { errors.forEach((error) => logger.error(error.message)) }) @@ -132,7 +221,9 @@ const main = async (args: Args, configArgs: Args): Promise => { if (serverAddress && !options.socket && args.open) { // The web socket doesn't seem to work if browsing with 0.0.0.0. const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost") - await open(openAddress).catch(console.error) + await open(openAddress).catch((error: Error) => { + logger.error("Failed to open", field("address", openAddress), field("error", error)) + }) logger.info(`Opened ${openAddress}`) } @@ -141,27 +232,32 @@ const main = async (args: Args, configArgs: Args): Promise => { await coderCloudBind(serverAddress!, args.link.value) } catch (err) { logger.error(err.message) - ipcMain().exit(1) + ipcMain.exit(1) } } } async function entry(): Promise { - const tryParse = async (): Promise<[Args, Args]> => { - try { - const cliArgs = parse(process.argv.slice(2)) - const configArgs = await readConfigFile(cliArgs.config) - // This prioritizes the flags set in args over the ones in the config file. - let args = Object.assign(configArgs, cliArgs) - args = await setDefaults(args) - return [args, configArgs] - } catch (error) { - console.error(error.message) - process.exit(1) - } + const tryParse = async (): Promise<[Args, Args, Args]> => { + const cliArgs = parse(process.argv.slice(2)) + const configArgs = await readConfigFile(cliArgs.config) + // This prioritizes the flags set in args over the ones in the config file. + let args = Object.assign(configArgs, cliArgs) + args = await setDefaults(args) + return [args, cliArgs, configArgs] + } + + const [args, cliArgs, configArgs] = await tryParse() + + // There's no need to check flags like --help or to spawn in an existing + // instance for the child process because these would have already happened in + // the parent and the child wouldn't have been spawned. + if (ipcMain.isChild) { + await ipcMain.handshake() + ipcMain.preventExit() + return main(args, configArgs) } - const [args, configArgs] = await tryParse() if (args.help) { console.log("code-server", version, commit) console.log("") @@ -171,7 +267,10 @@ async function entry(): Promise { optionDescriptions().forEach((description) => { console.log("", description) }) - } else if (args.version) { + return + } + + if (args.version) { if (args.json) { console.log({ codeServer: version, @@ -181,83 +280,23 @@ async function entry(): Promise { } else { console.log(version, commit) } - process.exit(0) - } else if (args["list-extensions"] || args["install-extension"] || args["uninstall-extension"]) { - logger.debug("forking vs code cli...") - const vscode = cp.fork(path.resolve(__dirname, "../../lib/vscode/out/vs/server/fork"), [], { - env: { - ...process.env, - CODE_SERVER_PARENT_PID: process.pid.toString(), - }, - }) - vscode.once("message", (message: any) => { - logger.debug("Got message from VS Code", field("message", message)) - if (message.type !== "ready") { - logger.error("Unexpected response waiting for ready response") - process.exit(1) - } - const send: CliMessage = { type: "cli", args } - vscode.send(send) - }) - vscode.once("error", (error) => { - logger.error(error.message) - process.exit(1) - }) - vscode.on("exit", (code) => process.exit(code || 0)) - } else if (process.env.VSCODE_IPC_HOOK_CLI) { - const pipeArgs: OpenCommandPipeArgs = { - type: "open", - folderURIs: [], - forceReuseWindow: args["reuse-window"], - forceNewWindow: args["new-window"], - } - const isDir = async (path: string): Promise => { - try { - const st = await fs.stat(path) - return st.isDirectory() - } catch (error) { - return false - } - } - for (let i = 0; i < args._.length; i++) { - const fp = path.resolve(args._[i]) - if (await isDir(fp)) { - pipeArgs.folderURIs.push(fp) - } else { - if (!pipeArgs.fileURIs) { - pipeArgs.fileURIs = [] - } - pipeArgs.fileURIs.push(fp) - } - } - if (pipeArgs.forceNewWindow && pipeArgs.fileURIs && pipeArgs.fileURIs.length > 0) { - logger.error("new-window can only be used with folder paths") - process.exit(1) - } - if (pipeArgs.folderURIs.length === 0 && (!pipeArgs.fileURIs || pipeArgs.fileURIs.length === 0)) { - logger.error("Please specify at least one file or folder argument") - process.exit(1) - } - const vscode = http.request( - { - path: "/", - method: "POST", - socketPath: process.env["VSCODE_IPC_HOOK_CLI"], - }, - (res) => { - res.on("data", (message) => { - logger.debug("Got message from VS Code", field("message", message.toString())) - }) - }, - ) - vscode.on("error", (err) => { - logger.debug("Got error from VS Code", field("error", err)) - }) - vscode.write(JSON.stringify(pipeArgs)) - vscode.end() - } else { - wrap(() => main(args, configArgs)) + return } + + if (shouldRunVsCodeCli(args)) { + return runVsCodeCli(args) + } + + const socketPath = await shouldOpenInExistingInstance(cliArgs) + if (socketPath) { + return openInExistingInstance(args, socketPath) + } + + const wrapper = new WrapperProcess(require("../../package.json").version) + return wrapper.start() } -entry() +entry().catch((error) => { + logger.error(error.message) + ipcMain.exit(error) +}) diff --git a/src/node/wrapper.ts b/src/node/wrapper.ts index 2f1eb037..ea29eb28 100644 --- a/src/node/wrapper.ts +++ b/src/node/wrapper.ts @@ -32,19 +32,13 @@ export class IpcMain { public readonly onMessage = this._onMessage.event private readonly _onDispose = new Emitter() public readonly onDispose = this._onDispose.event - public readonly processExit: (code?: number) => never + public readonly processExit: (code?: number) => never = process.exit - public constructor(public readonly parentPid?: number) { + public constructor(private readonly parentPid?: number) { process.on("SIGINT", () => this._onDispose.emit("SIGINT")) process.on("SIGTERM", () => this._onDispose.emit("SIGTERM")) process.on("exit", () => this._onDispose.emit(undefined)) - // Ensure we control when the process exits. - this.processExit = process.exit - process.exit = function (code?: number) { - logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`) - } as (code?: number) => never - this.onDispose((signal) => { // Remove listeners to avoid possibly triggering disposal again. process.removeAllListeners() @@ -71,6 +65,19 @@ export class IpcMain { } } + /** + * Ensure we control when the process exits. + */ + public preventExit(): void { + process.exit = function (code?: number) { + logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`) + } as (code?: number) => never + } + + public get isChild(): boolean { + return typeof this.parentPid !== "undefined" + } + public exit(error?: number | ProcessError): never { if (error && typeof error !== "number") { this.processExit(typeof error.code === "number" ? error.code : 1) @@ -127,17 +134,12 @@ export class IpcMain { } } -let _ipcMain: IpcMain -export const ipcMain = (): IpcMain => { - if (!_ipcMain) { - _ipcMain = new IpcMain( - typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" - ? parseInt(process.env.CODE_SERVER_PARENT_PID) - : undefined, - ) - } - return _ipcMain -} +/** + * Channel for communication between the child and parent processes. + */ +export const ipcMain = new IpcMain( + typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" ? parseInt(process.env.CODE_SERVER_PARENT_PID) : undefined, +) export interface WrapperOptions { maxMemory?: number @@ -162,14 +164,11 @@ export class WrapperProcess { this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts) this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts) - ipcMain().onDispose(() => { - if (this.process) { - this.process.removeAllListeners() - this.process.kill() - } + ipcMain.onDispose(() => { + this.disposeChild() }) - ipcMain().onMessage((message) => { + ipcMain.onMessage((message) => { switch (message.type) { case "relaunch": logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`) @@ -181,28 +180,35 @@ export class WrapperProcess { break } }) - - process.on("SIGUSR1", async () => { - logger.info("Received SIGUSR1; hotswapping") - this.relaunch() - }) } - private async relaunch(): Promise { + private disposeChild(): void { this.started = undefined if (this.process) { this.process.removeAllListeners() this.process.kill() } + } + + private async relaunch(): Promise { + this.disposeChild() try { await this.start() } catch (error) { logger.error(error.message) - ipcMain().exit(typeof error.code === "number" ? error.code : 1) + ipcMain.exit(typeof error.code === "number" ? error.code : 1) } } public start(): Promise { + // If we have a process then we've already bound this. + if (!this.process) { + process.on("SIGUSR1", async () => { + logger.info("Received SIGUSR1; hotswapping") + this.relaunch() + }) + } + if (!this.started) { this.started = this.spawn().then((child) => { // Log both to stdout and to the log directory. @@ -215,14 +221,12 @@ export class WrapperProcess { child.stderr.pipe(process.stderr) } logger.debug(`spawned inner process ${child.pid}`) - ipcMain() - .handshake(child) - .then(() => { - child.once("exit", (code) => { - logger.debug(`inner process ${child.pid} exited unexpectedly`) - ipcMain().exit(code || 0) - }) + ipcMain.handshake(child).then(() => { + child.once("exit", (code) => { + logger.debug(`inner process ${child.pid} exited unexpectedly`) + ipcMain.exit(code || 0) }) + }) this.process = child }) } @@ -251,7 +255,7 @@ export class WrapperProcess { // It's possible that the pipe has closed (for example if you run code-server // --version | head -1). Assume that means we're done. if (!process.stdout.isTTY) { - process.stdout.on("error", () => ipcMain().exit()) + process.stdout.on("error", () => ipcMain.exit()) } // Don't let uncaught exceptions crash the process. @@ -261,21 +265,3 @@ process.on("uncaughtException", (error) => { logger.error(error.stack) } }) - -export const wrap = (fn: () => Promise): void => { - if (ipcMain().parentPid) { - ipcMain() - .handshake() - .then(() => fn()) - .catch((error: ProcessError): void => { - logger.error(error.message) - ipcMain().exit(error) - }) - } else { - const wrapper = new WrapperProcess(require("../../package.json").version) - wrapper.start().catch((error) => { - logger.error(error.message) - ipcMain().exit(error) - }) - } -}