Compare commits

...

31 Commits

Author SHA1 Message Date
Anmol Sethi
a0db6723c1 v3.7.2 2020-11-19 17:28:22 -05:00
Asher
23ead21b1d Merge pull request #2340 from cdr/vscode-1.51.1
Update VS Code to 1.51.1
2020-11-19 15:52:40 -06:00
Asher
42390da097 Don't persist terminals for now 2020-11-19 15:51:37 -06:00
Asher
d0f6cbb02d Use resolverEnv to get exec path
This is the last unused variable in the create terminal payload.
2020-11-19 15:51:36 -06:00
Asher
fa59156a2a Implement remaining resolver methods 2020-11-19 15:51:35 -06:00
Asher
8ffe599796 Add notes on unimplemented terminal events 2020-11-19 15:51:34 -06:00
Asher
a6f8840009 Add timeout for disposing detached terminals 2020-11-19 15:51:33 -06:00
Asher
1feb30a7ff Send back workspace ID and name in terminal list
This makes it re-connect automatically.
2020-11-19 15:51:32 -06:00
Asher
182aca6490 Only replay terminals when detached 2020-11-19 15:51:31 -06:00
Asher
8311cf5657 Handle non-persistent terminals 2020-11-19 15:51:30 -06:00
Asher
4de2511162 Implement terminal replay event 2020-11-19 15:51:30 -06:00
Asher
3f7b91e2e2 Implement most of remote terminal service
It works, at least, but there are still some missing parts.
2020-11-19 15:51:29 -06:00
Asher
431137da45 Add new (unimplemented) terminal service 2020-11-19 15:51:28 -06:00
Asher
4d276b88c0 Add new logger service
The telemetry service depends on this now. I had to move it into
invokeFunction and use accessor.get otherwise getLogger on the service
was undefined.

I also had to move some the extension management service because it
depends on the moved telemetry service. I moved a few other services as
well to better match VS Code (sharedProcessMain.ts).

I swapped some this.services.get with accessor.get since that seems to
be the correct method although for these other services either method
seems to work.
2020-11-19 15:51:27 -06:00
Asher
e28c9ab287 Update VS Code to 1.51.1 2020-11-19 15:51:23 -06:00
Anmol Sethi
b540737b10 Merge pull request #2339 from cdr/ios-input-3cf7
login.css: Disable webkit appearance for input elements
2020-11-19 11:31:07 -05:00
Asher
4380356e0c Merge pull request #2334 from cdr/wrappers
Separate process wrappers and pass arguments
2020-11-19 10:28:54 -06:00
Asher
72caafe8b0 Fix service worker not loading (#2335)
I removed this under the impression the default was to allow it anywhere
but that's not the case. Since the service worker was already registered
in my browser I never got the error during testing.
2020-11-19 10:18:15 -06:00
Asher
08b9e9ad1f Merge pull request #2336 from cdr/webview-404
Fix 404 webviews and tar endpoint
2020-11-19 10:14:54 -06:00
Anmol Sethi
2dc7863ec3 login.css: Disable webkit appearance for input elements
Not sure why Safari does these things...

Closes #2247
2020-11-19 10:43:26 -05:00
Anmol Sethi
30100caf0c Revert "login.css: Fix button styling on iOS"
This reverts commit f79bb210ec.
2020-11-19 10:41:37 -05:00
Jacky
f79bb210ec login.css: Fix button styling on iOS 2020-11-19 10:37:51 -05:00
Asher
182791319a Fix tar authentication
It was checking the request path but for tars the path is in the query
variable so the request path is irrelevant.
2020-11-18 17:15:53 -06:00
Asher
624cd9d44f Fix webview 404s
An extra slash caused a 404 (was /webview//vscode-resource).
2020-11-18 17:10:53 -06:00
Asher
95ef6dbf2f Remove unused wrapper options
Also move our memory default to the beginning of NODE_OPTIONS so it can
be overidden. The version of the flag with dashes seems to be the more
correct one now so use that instead of underscores.

Related: #2113.
2020-11-18 13:23:06 -06:00
Asher
016daf2fdd Parse arguments once
Fixes #2316.
2020-11-18 13:01:46 -06:00
Asher
247c4ec776 Move onMessage so it can be used in the wrappers 2020-11-18 12:28:43 -06:00
Asher
d55e06936b Split child and parent wrappers
I think having them combined and relying on if statements was getting
confusing especially if we want to add additional messages with
different payloads (which will soon be the case).
2020-11-18 12:28:42 -06:00
Asher
2a3608df53 Skip heartbeat on /healthz endpoint (#2333)
I managed to lose this in the rewrite.

Fixes #2327.
2020-11-18 12:19:08 -06:00
piousdeer
c6062c3d0a Fix log message (#2331) 2020-11-18 10:41:32 -06:00
Anmol Sethi
9ff535eddc Merge pull request #2312 from cdr/v3.7.1
v3.7.1
2020-11-16 18:14:15 -05:00
14 changed files with 967 additions and 358 deletions

View File

@@ -18,6 +18,7 @@ Make sure you have `$GITHUB_TOKEN` set and [hub](https://github.com/github/hub)
1. Update in `package.json` 1. Update in `package.json`
2. Update in [./doc/install.md](../doc/install.md) 2. Update in [./doc/install.md](../doc/install.md)
3. Update in [./ci/helm-chart/README.md](../ci/helm-chart/README.md) 3. Update in [./ci/helm-chart/README.md](../ci/helm-chart/README.md)
- Remember to update the chart version as well on top of appVersion in `Chart.yaml`.
2. GitHub actions will generate the `npm-package`, `release-packages` and `release-images` artifacts. 2. GitHub actions will generate the `npm-package`, `release-packages` and `release-images` artifacts.
1. You do not have to wait for these. 1. You do not have to wait for these.
3. Run `yarn release:github-draft` to create a GitHub draft release from the template with 3. Run `yarn release:github-draft` to create a GitHub draft release from the template with

File diff suppressed because it is too large Load Diff

View File

@@ -15,9 +15,9 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.0 version: 1.0.1
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
appVersion: 3.7.1 appVersion: 3.7.2

View File

@@ -1,6 +1,6 @@
# code-server # code-server
![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.7.1](https://img.shields.io/badge/AppVersion-3.7.1-informational?style=flat-square) ![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.7.2](https://img.shields.io/badge/AppVersion-3.7.2-informational?style=flat-square)
[code-server](https://github.com/cdr/code-server) code-server is VS Code running [code-server](https://github.com/cdr/code-server) code-server is VS Code running
on a remote server, accessible through the browser. on a remote server, accessible through the browser.
@@ -72,7 +72,7 @@ and their default values.
| hostnameOverride | string | `""` | | | hostnameOverride | string | `""` | |
| image.pullPolicy | string | `"Always"` | | | image.pullPolicy | string | `"Always"` | |
| image.repository | string | `"codercom/code-server"` | | | image.repository | string | `"codercom/code-server"` | |
| image.tag | string | `"3.7.1"` | | | image.tag | string | `"3.7.2"` | |
| imagePullSecrets | list | `[]` | | | imagePullSecrets | list | `[]` | |
| ingress.enabled | bool | `false` | | | ingress.enabled | bool | `false` | |
| nameOverride | string | `""` | | | nameOverride | string | `""` | |

View File

@@ -6,7 +6,7 @@ replicaCount: 1
image: image:
repository: codercom/code-server repository: codercom/code-server
tag: '3.7.1' tag: '3.7.2'
pullPolicy: Always pullPolicy: Always
imagePullSecrets: [] imagePullSecrets: []

View File

@@ -80,8 +80,8 @@ commands presented in the rest of this document.
## Debian, Ubuntu ## Debian, Ubuntu
```bash ```bash
curl -fOL https://github.com/cdr/code-server/releases/download/v3.7.1/code-server_3.7.1_amd64.deb curl -fOL https://github.com/cdr/code-server/releases/download/v3.7.2/code-server_3.7.2_amd64.deb
sudo dpkg -i code-server_3.7.1_amd64.deb sudo dpkg -i code-server_3.7.2_amd64.deb
sudo systemctl enable --now code-server@$USER sudo systemctl enable --now code-server@$USER
# Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml # Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml
``` ```
@@ -89,8 +89,8 @@ sudo systemctl enable --now code-server@$USER
## Fedora, CentOS, RHEL, SUSE ## Fedora, CentOS, RHEL, SUSE
```bash ```bash
curl -fOL https://github.com/cdr/code-server/releases/download/v3.7.1/code-server-3.7.1-amd64.rpm curl -fOL https://github.com/cdr/code-server/releases/download/v3.7.2/code-server-3.7.2-amd64.rpm
sudo rpm -i code-server-3.7.1-amd64.rpm sudo rpm -i code-server-3.7.2-amd64.rpm
sudo systemctl enable --now code-server@$USER sudo systemctl enable --now code-server@$USER
# Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml # Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml
``` ```
@@ -159,10 +159,10 @@ Here is an example script for installing and using a standalone `code-server` re
```bash ```bash
mkdir -p ~/.local/lib ~/.local/bin mkdir -p ~/.local/lib ~/.local/bin
curl -fL https://github.com/cdr/code-server/releases/download/v3.7.1/code-server-3.7.1-linux-amd64.tar.gz \ curl -fL https://github.com/cdr/code-server/releases/download/v3.7.2/code-server-3.7.2-linux-amd64.tar.gz \
| tar -C ~/.local/lib -xz | tar -C ~/.local/lib -xz
mv ~/.local/lib/code-server-3.7.1-linux-amd64 ~/.local/lib/code-server-3.7.1 mv ~/.local/lib/code-server-3.7.2-linux-amd64 ~/.local/lib/code-server-3.7.2
ln -s ~/.local/lib/code-server-3.7.1/bin/code-server ~/.local/bin/code-server ln -s ~/.local/lib/code-server-3.7.2/bin/code-server ~/.local/bin/code-server
PATH="~/.local/bin:$PATH" PATH="~/.local/bin:$PATH"
code-server code-server
# Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml # Now visit http://127.0.0.1:8080. Your password is in ~/.config/code-server/config.yaml

View File

@@ -1,7 +1,7 @@
{ {
"name": "code-server", "name": "code-server",
"license": "MIT", "license": "MIT",
"version": "3.7.1", "version": "3.7.2",
"description": "Run VS Code on a remote server.", "description": "Run VS Code on a remote server.",
"homepage": "https://github.com/cdr/code-server", "homepage": "https://github.com/cdr/code-server",
"bugs": { "bugs": {

View File

@@ -37,3 +37,7 @@ body {
.login-form > .field > .submit { .login-form > .field > .submit {
margin-left: 20px; margin-left: 20px;
} }
input {
-webkit-appearance: none;
}

View File

@@ -19,7 +19,7 @@ import { coderCloudBind } from "./coder-cloud"
import { commit, version } from "./constants" import { commit, version } from "./constants"
import { register } from "./routes" import { register } from "./routes"
import { humanPath, isFile, open } from "./util" import { humanPath, isFile, open } from "./util"
import { ipcMain, WrapperProcess } from "./wrapper" import { isChild, wrapper } from "./wrapper"
export const runVsCodeCli = (args: DefaultedArgs): void => { export const runVsCodeCli = (args: DefaultedArgs): void => {
logger.debug("forking vs code cli...") logger.debug("forking vs code cli...")
@@ -121,7 +121,7 @@ const main = async (args: DefaultedArgs): Promise<void> => {
} }
if (args.cert) { if (args.cert) {
logger.info(" - Using certificate for HTTPS: ${humanPath(args.cert.value)}") logger.info(` - Using certificate for HTTPS: ${humanPath(args.cert.value)}`)
} else { } else {
logger.info(" - Not serving HTTPS") logger.info(" - Not serving HTTPS")
} }
@@ -137,7 +137,7 @@ const main = async (args: DefaultedArgs): Promise<void> => {
logger.info(" - Connected to cloud agent") logger.info(" - Connected to cloud agent")
} catch (err) { } catch (err) {
logger.error(err.message) logger.error(err.message)
ipcMain.exit(1) wrapper.exit(1)
} }
} }
@@ -154,19 +154,22 @@ const main = async (args: DefaultedArgs): Promise<void> => {
} }
async function entry(): Promise<void> { async function entry(): Promise<void> {
// 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. We also get the
// arguments from the parent so we don't have to parse twice and to account
// for environment manipulation (like how PASSWORD gets removed to avoid
// leaking to child processes).
if (isChild(wrapper)) {
const args = await wrapper.handshake()
wrapper.preventExit()
return main(args)
}
const cliArgs = parse(process.argv.slice(2)) const cliArgs = parse(process.argv.slice(2))
const configArgs = await readConfigFile(cliArgs.config) const configArgs = await readConfigFile(cliArgs.config)
const args = await setDefaults(cliArgs, configArgs) const args = await setDefaults(cliArgs, configArgs)
// 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)
}
if (args.help) { if (args.help) {
console.log("code-server", version, commit) console.log("code-server", version, commit)
console.log("") console.log("")
@@ -201,11 +204,10 @@ async function entry(): Promise<void> {
return openInExistingInstance(args, socketPath) return openInExistingInstance(args, socketPath)
} }
const wrapper = new WrapperProcess(require("../../package.json").version) return wrapper.start(args)
return wrapper.start()
} }
entry().catch((error) => { entry().catch((error) => {
logger.error(error.message) logger.error(error.message)
ipcMain.exit(error) wrapper.exit(error)
}) })

View File

@@ -66,7 +66,11 @@ export const register = async (
app.use(bodyParser.urlencoded({ extended: true })) app.use(bodyParser.urlencoded({ extended: true }))
const common: express.RequestHandler = (req, _, next) => { const common: express.RequestHandler = (req, _, next) => {
heart.beat() // /healthz|/healthz/ needs to be excluded otherwise health checks will make
// it look like code-server is always in use.
if (!/^\/healthz\/?$/.test(req.url)) {
heart.beat()
}
// Add common variables routes can use. // Add common variables routes can use.
req.args = args req.args = args

View File

@@ -7,13 +7,33 @@ import * as tarFs from "tar-fs"
import * as zlib from "zlib" import * as zlib from "zlib"
import { HttpCode, HttpError } from "../../common/http" import { HttpCode, HttpError } from "../../common/http"
import { rootPath } from "../constants" import { rootPath } from "../constants"
import { authenticated, replaceTemplates } from "../http" import { authenticated, ensureAuthenticated, replaceTemplates } from "../http"
import { getMediaMime, pathToFsPath } from "../util" import { getMediaMime, pathToFsPath } from "../util"
export const router = Router() export const router = Router()
// The commit is for caching. // The commit is for caching.
router.get("/(:commit)(/*)?", async (req, res) => { router.get("/(:commit)(/*)?", async (req, res) => {
// Used by VS Code to load extensions into the web worker.
const tar = Array.isArray(req.query.tar) ? req.query.tar[0] : req.query.tar
if (typeof tar === "string") {
ensureAuthenticated(req)
let stream: Readable = tarFs.pack(pathToFsPath(tar))
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
logger.debug("gzipping tar", field("path", tar))
const compress = zlib.createGzip()
stream.pipe(compress)
stream.on("error", (error) => compress.destroy(error))
stream.on("close", () => compress.end())
stream = compress
res.header("content-encoding", "gzip")
}
res.set("Content-Type", "application/x-tar")
stream.on("close", () => res.end())
return stream.pipe(res)
}
// If not a tar use the remainder of the path to load the resource.
if (!req.params[0]) { if (!req.params[0]) {
throw new HttpError("Not Found", HttpCode.NotFound) throw new HttpError("Not Found", HttpCode.NotFound)
} }
@@ -32,24 +52,9 @@ router.get("/(:commit)(/*)?", async (req, res) => {
res.header("Cache-Control", "public, max-age=31536000") res.header("Cache-Control", "public, max-age=31536000")
} }
/** // Without this the default is to use the directory the script loaded from.
* Used by VS Code to load extensions into the web worker. if (req.headers["service-worker"]) {
*/ res.header("service-worker-allowed", "/")
const tar = Array.isArray(req.query.tar) ? req.query.tar[0] : req.query.tar
if (typeof tar === "string") {
let stream: Readable = tarFs.pack(pathToFsPath(tar))
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
logger.debug("gzipping tar", field("path", resourcePath))
const compress = zlib.createGzip()
stream.pipe(compress)
stream.on("error", (error) => compress.destroy(error))
stream.on("close", () => compress.end())
stream = compress
res.header("content-encoding", "gzip")
}
res.set("Content-Type", "application/x-tar")
stream.on("close", () => res.end())
return stream.pipe(res)
} }
res.set("Content-Type", getMediaMime(resourcePath)) res.set("Content-Type", getMediaMime(resourcePath))

View File

@@ -1,4 +1,4 @@
import { field, logger } from "@coder/logger" import { logger } from "@coder/logger"
import * as cp from "child_process" import * as cp from "child_process"
import * as net from "net" import * as net from "net"
import * as path from "path" import * as path from "path"
@@ -8,19 +8,18 @@ import { rootPath } from "./constants"
import { settings } from "./settings" import { settings } from "./settings"
import { SocketProxyProvider } from "./socket" import { SocketProxyProvider } from "./socket"
import { isFile } from "./util" import { isFile } from "./util"
import { ipcMain } from "./wrapper" import { onMessage, wrapper } from "./wrapper"
export class VscodeProvider { export class VscodeProvider {
public readonly serverRootPath: string public readonly serverRootPath: string
public readonly vsRootPath: string public readonly vsRootPath: string
private _vscode?: Promise<cp.ChildProcess> private _vscode?: Promise<cp.ChildProcess>
private timeoutInterval = 10000 // 10s, matches VS Code's timeouts.
private readonly socketProvider = new SocketProxyProvider() private readonly socketProvider = new SocketProxyProvider()
public constructor() { public constructor() {
this.vsRootPath = path.resolve(rootPath, "lib/vscode") this.vsRootPath = path.resolve(rootPath, "lib/vscode")
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server") this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
ipcMain.onDispose(() => this.dispose()) wrapper.onDispose(() => this.dispose())
} }
public async dispose(): Promise<void> { public async dispose(): Promise<void> {
@@ -69,10 +68,13 @@ export class VscodeProvider {
vscode, vscode,
) )
const message = await this.onMessage(vscode, (message): message is ipc.OptionsMessage => { const message = await onMessage<ipc.VscodeMessage, ipc.OptionsMessage>(
// There can be parallel initializations so wait for the right ID. vscode,
return message.type === "options" && message.id === id (message): message is ipc.OptionsMessage => {
}) // There can be parallel initializations so wait for the right ID.
return message.type === "options" && message.id === id
},
)
return message.options return message.options
} }
@@ -104,61 +106,13 @@ export class VscodeProvider {
dispose() dispose()
}) })
this._vscode = this.onMessage(vscode, (message): message is ipc.ReadyMessage => { this._vscode = onMessage<ipc.VscodeMessage, ipc.ReadyMessage>(vscode, (message): message is ipc.ReadyMessage => {
return message.type === "ready" return message.type === "ready"
}).then(() => vscode) }).then(() => vscode)
return this._vscode return this._vscode
} }
/**
* Listen to a single message from a process. Reject if the process errors,
* exits, or times out.
*
* `fn` is a function that determines whether the message is the one we're
* waiting for.
*/
private onMessage<T extends ipc.VscodeMessage>(
proc: cp.ChildProcess,
fn: (message: ipc.VscodeMessage) => message is T,
): Promise<T> {
return new Promise((resolve, reject) => {
const cleanup = () => {
proc.off("error", onError)
proc.off("exit", onExit)
proc.off("message", onMessage)
clearTimeout(timeout)
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error("timed out"))
}, this.timeoutInterval)
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onExit = (code: number | null) => {
cleanup()
reject(new Error(`VS Code exited unexpectedly with code ${code}`))
}
const onMessage = (message: ipc.VscodeMessage) => {
logger.trace("got message from vscode", field("message", message))
if (fn(message)) {
cleanup()
resolve(message)
}
}
proc.on("message", onMessage)
proc.on("error", onError)
proc.on("exit", onExit)
})
}
/** /**
* VS Code expects a raw socket. It will handle all the web socket frames. * VS Code expects a raw socket. It will handle all the web socket frames.
*/ */

View File

@@ -1,11 +1,70 @@
import { field, logger } from "@coder/logger" import { field, Logger, logger } from "@coder/logger"
import * as cp from "child_process" import * as cp from "child_process"
import * as path from "path" import * as path from "path"
import * as rfs from "rotating-file-stream" import * as rfs from "rotating-file-stream"
import { Emitter } from "../common/emitter" import { Emitter } from "../common/emitter"
import { DefaultedArgs } from "./cli"
import { paths } from "./util" import { paths } from "./util"
interface HandshakeMessage { const timeoutInterval = 10000 // 10s, matches VS Code's timeouts.
/**
* Listen to a single message from a process. Reject if the process errors,
* exits, or times out.
*
* `fn` is a function that determines whether the message is the one we're
* waiting for.
*/
export function onMessage<M, T extends M>(
proc: cp.ChildProcess | NodeJS.Process,
fn: (message: M) => message is T,
customLogger?: Logger,
): Promise<T> {
return new Promise((resolve, reject) => {
const cleanup = () => {
proc.off("error", onError)
proc.off("exit", onExit)
proc.off("message", onMessage)
clearTimeout(timeout)
}
const timeout = setTimeout(() => {
cleanup()
reject(new Error("timed out"))
}, timeoutInterval)
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onExit = (code: number) => {
cleanup()
reject(new Error(`exited unexpectedly with code ${code}`))
}
const onMessage = (message: M) => {
;(customLogger || logger).trace("got message", field("message", message))
if (fn(message)) {
cleanup()
resolve(message)
}
}
proc.on("message", onMessage)
// NodeJS.Process doesn't have `error` but binding anyway shouldn't break
// anything. It does have `exit` but the types aren't working.
;(proc as cp.ChildProcess).on("error", onError)
;(proc as cp.ChildProcess).on("exit", onExit)
})
}
interface ParentHandshakeMessage {
type: "handshake"
args: DefaultedArgs
}
interface ChildHandshakeMessage {
type: "handshake" type: "handshake"
} }
@@ -14,9 +73,10 @@ interface RelaunchMessage {
version: string version: string
} }
export type Message = RelaunchMessage | HandshakeMessage type ChildMessage = RelaunchMessage | ChildHandshakeMessage
type ParentMessage = ParentHandshakeMessage
export class ProcessError extends Error { class ProcessError extends Error {
public constructor(message: string, public readonly code: number | undefined) { public constructor(message: string, public readonly code: number | undefined) {
super(message) super(message)
this.name = this.constructor.name this.name = this.constructor.name
@@ -25,16 +85,26 @@ export class ProcessError extends Error {
} }
/** /**
* Allows the wrapper and inner processes to communicate. * Wrapper around a process that tries to gracefully exit when a process exits
* and provides a way to prevent `process.exit`.
*/ */
export class IpcMain { abstract class Process {
private readonly _onMessage = new Emitter<Message>() /**
public readonly onMessage = this._onMessage.event * Emit this to trigger a graceful exit.
private readonly _onDispose = new Emitter<NodeJS.Signals | undefined>() */
public readonly onDispose = this._onDispose.event protected readonly _onDispose = new Emitter<NodeJS.Signals | undefined>()
public readonly processExit: (code?: number) => never = process.exit
public constructor(private readonly parentPid?: number) { /**
* Emitted when the process is about to be disposed.
*/
public readonly onDispose = this._onDispose.event
/**
* Uniquely named logger for the process.
*/
public abstract logger: Logger
public constructor() {
process.on("SIGINT", () => this._onDispose.emit("SIGINT")) process.on("SIGINT", () => this._onDispose.emit("SIGINT"))
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM")) process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
process.on("exit", () => this._onDispose.emit(undefined)) process.on("exit", () => this._onDispose.emit(undefined))
@@ -43,42 +113,27 @@ export class IpcMain {
// Remove listeners to avoid possibly triggering disposal again. // Remove listeners to avoid possibly triggering disposal again.
process.removeAllListeners() process.removeAllListeners()
// Try waiting for other handlers run first then exit. // Try waiting for other handlers to run first then exit.
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal)) this.logger.debug("disposing", field("code", signal))
wait.then(() => this.exit(0)) wait.then(() => this.exit(0))
setTimeout(() => this.exit(0), 5000) setTimeout(() => this.exit(0), 5000)
}) })
// Kill the inner process if the parent dies. This is for the case where the
// parent process is forcefully terminated and cannot clean up.
if (parentPid) {
setInterval(() => {
try {
// process.kill throws an exception if the process doesn't exist.
process.kill(parentPid, 0)
} catch (_) {
// Consider this an error since it should have been able to clean up
// the child process unless it was forcefully killed.
logger.error(`parent process ${parentPid} died`)
this._onDispose.emit(undefined)
}
}, 5000)
}
} }
/** /**
* Ensure we control when the process exits. * Ensure control over when the process exits.
*/ */
public preventExit(): void { public preventExit(): void {
process.exit = function (code?: number) { ;(process.exit as any) = (code?: number) => {
logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`) this.logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`)
} as (code?: number) => never }
} }
public get isChild(): boolean { private readonly processExit: (code?: number) => never = process.exit
return typeof this.parentPid !== "undefined"
}
/**
* Will always exit even if normal exit is being prevented.
*/
public exit(error?: number | ProcessError): never { public exit(error?: number | ProcessError): never {
if (error && typeof error !== "number") { if (error && typeof error !== "number") {
this.processExit(typeof error.code === "number" ? error.code : 1) this.processExit(typeof error.code === "number" ? error.code : 1)
@@ -86,48 +141,59 @@ export class IpcMain {
this.processExit(error) this.processExit(error)
} }
} }
}
public handshake(child?: cp.ChildProcess): Promise<void> { /**
return new Promise((resolve, reject) => { * Child process that will clean up after itself if the parent goes away and can
const target = child || process * perform a handshake with the parent and ask it to relaunch.
const onMessage = (message: Message): void => { */
logger.debug( class ChildProcess extends Process {
`${child ? "wrapper" : "inner process"} ${process.pid} received message from ${ public logger = logger.named(`child:${process.pid}`)
child ? child.pid : this.parentPid
}`, public constructor(private readonly parentPid: number) {
field("message", message), super()
)
if (message.type === "handshake") { // Kill the inner process if the parent dies. This is for the case where the
target.removeListener("message", onMessage) // parent process is forcefully terminated and cannot clean up.
target.on("message", (msg) => this._onMessage.emit(msg)) setInterval(() => {
// The wrapper responds once the inner process starts the handshake. try {
if (child) { // process.kill throws an exception if the process doesn't exist.
if (!target.send) { process.kill(this.parentPid, 0)
throw new Error("child not spawned with IPC") } catch (_) {
} // Consider this an error since it should have been able to clean up
target.send({ type: "handshake" }) // the child process unless it was forcefully killed.
} this.logger.error(`parent process ${parentPid} died`)
resolve() this._onDispose.emit(undefined)
}
} }
target.on("message", onMessage) }, 5000)
if (child) {
child.once("error", reject)
child.once("exit", (code) => {
reject(new ProcessError(`Unexpected exit with code ${code}`, code !== null ? code : undefined))
})
} else {
// The inner process initiates the handshake.
this.send({ type: "handshake" })
}
})
} }
/**
* Initiate the handshake and wait for a response from the parent.
*/
public async handshake(): Promise<DefaultedArgs> {
this.send({ type: "handshake" })
const message = await onMessage<ParentMessage, ParentHandshakeMessage>(
process,
(message): message is ParentHandshakeMessage => {
return message.type === "handshake"
},
this.logger,
)
return message.args
}
/**
* Notify the parent process that it should relaunch the child.
*/
public relaunch(version: string): void { public relaunch(version: string): void {
this.send({ type: "relaunch", version }) this.send({ type: "relaunch", version })
} }
private send(message: Message): void { /**
* Send a message to the parent.
*/
private send(message: ChildMessage): void {
if (!process.send) { if (!process.send) {
throw new Error("not spawned with IPC") throw new Error("not spawned with IPC")
} }
@@ -136,28 +202,31 @@ export class IpcMain {
} }
/** /**
* Channel for communication between the child and parent processes. * Parent process wrapper that spawns the child process and performs a handshake
* with it. Will relaunch the child if it receives a SIGUSR1 or is asked to by
* the child. If the child otherwise exits the parent will also exit.
*/ */
export const ipcMain = new IpcMain( export class ParentProcess extends Process {
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" ? parseInt(process.env.CODE_SERVER_PARENT_PID) : undefined, public logger = logger.named(`parent:${process.pid}`)
)
export interface WrapperOptions { private child?: cp.ChildProcess
maxMemory?: number
nodeOptions?: string
}
/**
* Provides a way to wrap a process for the purpose of updating the running
* instance.
*/
export class WrapperProcess {
private process?: cp.ChildProcess
private started?: Promise<void> private started?: Promise<void>
private readonly logStdoutStream: rfs.RotatingFileStream private readonly logStdoutStream: rfs.RotatingFileStream
private readonly logStderrStream: rfs.RotatingFileStream private readonly logStderrStream: rfs.RotatingFileStream
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) { protected readonly _onChildMessage = new Emitter<ChildMessage>()
protected readonly onChildMessage = this._onChildMessage.event
private args?: DefaultedArgs
public constructor(private currentVersion: string) {
super()
process.on("SIGUSR1", async () => {
this.logger.info("Received SIGUSR1; hotswapping")
this.relaunch()
})
const opts = { const opts = {
size: "10M", size: "10M",
maxFiles: 10, maxFiles: 10,
@@ -165,19 +234,19 @@ export class WrapperProcess {
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts) 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) this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)
ipcMain.onDispose(() => { this.onDispose(() => {
this.disposeChild() this.disposeChild()
}) })
ipcMain.onMessage((message) => { this.onChildMessage((message) => {
switch (message.type) { switch (message.type) {
case "relaunch": case "relaunch":
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`) this.logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`)
this.currentVersion = message.version this.currentVersion = message.version
this.relaunch() this.relaunch()
break break
default: default:
logger.error(`Unrecognized message ${message}`) this.logger.error(`Unrecognized message ${message}`)
break break
} }
}) })
@@ -185,30 +254,26 @@ export class WrapperProcess {
private disposeChild(): void { private disposeChild(): void {
this.started = undefined this.started = undefined
if (this.process) { if (this.child) {
this.process.removeAllListeners() this.child.removeAllListeners()
this.process.kill() this.child.kill()
} }
} }
private async relaunch(): Promise<void> { private async relaunch(): Promise<void> {
this.disposeChild() this.disposeChild()
try { try {
await this.start() this.started = this._start()
await this.started
} catch (error) { } catch (error) {
logger.error(error.message) this.logger.error(error.message)
ipcMain.exit(typeof error.code === "number" ? error.code : 1) this.exit(typeof error.code === "number" ? error.code : 1)
} }
} }
public start(): Promise<void> { public start(args: DefaultedArgs): Promise<void> {
// If we have a process then we've already bound this. // Store for relaunches.
if (!this.process) { this.args = args
process.on("SIGUSR1", async () => {
logger.info("Received SIGUSR1; hotswapping")
this.relaunch()
})
}
if (!this.started) { if (!this.started) {
this.started = this._start() this.started = this._start()
} }
@@ -217,7 +282,7 @@ export class WrapperProcess {
private async _start(): Promise<void> { private async _start(): Promise<void> {
const child = this.spawn() const child = this.spawn()
this.process = child this.child = child
// Log both to stdout and to the log directory. // Log both to stdout and to the log directory.
if (child.stdout) { if (child.stdout) {
@@ -229,45 +294,75 @@ export class WrapperProcess {
child.stderr.pipe(process.stderr) child.stderr.pipe(process.stderr)
} }
logger.debug(`spawned inner process ${child.pid}`) this.logger.debug(`spawned inner process ${child.pid}`)
await ipcMain.handshake(child) await this.handshake(child)
child.once("exit", (code) => { child.once("exit", (code) => {
logger.debug(`inner process ${child.pid} exited unexpectedly`) this.logger.debug(`inner process ${child.pid} exited unexpectedly`)
ipcMain.exit(code || 0) this.exit(code || 0)
}) })
} }
private spawn(): cp.ChildProcess { private spawn(): cp.ChildProcess {
// Flags to pass along to the Node binary.
let nodeOptions = `${process.env.NODE_OPTIONS || ""} ${(this.options && this.options.nodeOptions) || ""}`
if (!/max_old_space_size=(\d+)/g.exec(nodeOptions)) {
nodeOptions += ` --max_old_space_size=${(this.options && this.options.maxMemory) || 2048}`
}
// Use spawn (instead of fork) to use the new binary in case it was updated. // Use spawn (instead of fork) to use the new binary in case it was updated.
return cp.spawn(process.argv[0], process.argv.slice(1), { return cp.spawn(process.argv[0], process.argv.slice(1), {
env: { env: {
...process.env, ...process.env,
CODE_SERVER_PARENT_PID: process.pid.toString(), CODE_SERVER_PARENT_PID: process.pid.toString(),
NODE_OPTIONS: nodeOptions, NODE_OPTIONS: `--max-old-space-size=2048 ${process.env.NODE_OPTIONS || ""}`,
}, },
stdio: ["ipc"], stdio: ["ipc"],
}) })
} }
/**
* Wait for a handshake from the child then reply.
*/
private async handshake(child: cp.ChildProcess): Promise<void> {
if (!this.args) {
throw new Error("started without args")
}
await onMessage<ChildMessage, ChildHandshakeMessage>(
child,
(message): message is ChildHandshakeMessage => {
return message.type === "handshake"
},
this.logger,
)
this.send(child, { type: "handshake", args: this.args })
}
/**
* Send a message to the child.
*/
private send(child: cp.ChildProcess, message: ParentMessage): void {
child.send(message)
}
}
/**
* Process wrapper.
*/
export const wrapper =
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined"
? new ChildProcess(parseInt(process.env.CODE_SERVER_PARENT_PID))
: new ParentProcess(require("../../package.json").version)
export function isChild(proc: ChildProcess | ParentProcess): proc is ChildProcess {
return proc instanceof ChildProcess
} }
// It's possible that the pipe has closed (for example if you run code-server // It's possible that the pipe has closed (for example if you run code-server
// --version | head -1). Assume that means we're done. // --version | head -1). Assume that means we're done.
if (!process.stdout.isTTY) { if (!process.stdout.isTTY) {
process.stdout.on("error", () => ipcMain.exit()) process.stdout.on("error", () => wrapper.exit())
} }
// Don't let uncaught exceptions crash the process. // Don't let uncaught exceptions crash the process.
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error(`Uncaught exception: ${error.message}`) wrapper.logger.error(`Uncaught exception: ${error.message}`)
if (typeof error.stack !== "undefined") { if (typeof error.stack !== "undefined") {
logger.error(error.stack) wrapper.logger.error(error.stack)
} }
}) })