Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit d55e069

Browse files
committedNov 18, 2020
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).
1 parent 2a3608d commit d55e069

File tree

3 files changed

+145
-102
lines changed

3 files changed

+145
-102
lines changed
 

‎src/node/entry.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { coderCloudBind } from "./coder-cloud"
1919
import { commit, version } from "./constants"
2020
import { register } from "./routes"
2121
import { humanPath, isFile, open } from "./util"
22-
import { ipcMain, WrapperProcess } from "./wrapper"
22+
import { isChild, wrapper } from "./wrapper"
2323

2424
export const runVsCodeCli = (args: DefaultedArgs): void => {
2525
logger.debug("forking vs code cli...")
@@ -137,7 +137,7 @@ const main = async (args: DefaultedArgs): Promise<void> => {
137137
logger.info(" - Connected to cloud agent")
138138
} catch (err) {
139139
logger.error(err.message)
140-
ipcMain.exit(1)
140+
wrapper.exit(1)
141141
}
142142
}
143143

@@ -161,9 +161,9 @@ async function entry(): Promise<void> {
161161
// There's no need to check flags like --help or to spawn in an existing
162162
// instance for the child process because these would have already happened in
163163
// the parent and the child wouldn't have been spawned.
164-
if (ipcMain.isChild) {
165-
await ipcMain.handshake()
166-
ipcMain.preventExit()
164+
if (isChild(wrapper)) {
165+
await wrapper.handshake()
166+
wrapper.preventExit()
167167
return main(args)
168168
}
169169

@@ -201,11 +201,10 @@ async function entry(): Promise<void> {
201201
return openInExistingInstance(args, socketPath)
202202
}
203203

204-
const wrapper = new WrapperProcess(require("../../package.json").version)
205204
return wrapper.start()
206205
}
207206

208207
entry().catch((error) => {
209208
logger.error(error.message)
210-
ipcMain.exit(error)
209+
wrapper.exit(error)
211210
})

‎src/node/vscode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { rootPath } from "./constants"
88
import { settings } from "./settings"
99
import { SocketProxyProvider } from "./socket"
1010
import { isFile } from "./util"
11-
import { ipcMain } from "./wrapper"
11+
import { wrapper } from "./wrapper"
1212

1313
export class VscodeProvider {
1414
public readonly serverRootPath: string
@@ -20,7 +20,7 @@ export class VscodeProvider {
2020
public constructor() {
2121
this.vsRootPath = path.resolve(rootPath, "lib/vscode")
2222
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
23-
ipcMain.onDispose(() => this.dispose())
23+
wrapper.onDispose(() => this.dispose())
2424
}
2525

2626
public async dispose(): Promise<void> {

‎src/node/wrapper.ts

Lines changed: 137 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { field, logger } from "@coder/logger"
1+
import { Logger, field, logger } from "@coder/logger"
22
import * as cp from "child_process"
33
import * as path from "path"
44
import * as rfs from "rotating-file-stream"
@@ -14,9 +14,9 @@ interface RelaunchMessage {
1414
version: string
1515
}
1616

17-
export type Message = RelaunchMessage | HandshakeMessage
17+
type Message = RelaunchMessage | HandshakeMessage
1818

19-
export class ProcessError extends Error {
19+
class ProcessError extends Error {
2020
public constructor(message: string, public readonly code: number | undefined) {
2121
super(message)
2222
this.name = this.constructor.name
@@ -25,16 +25,26 @@ export class ProcessError extends Error {
2525
}
2626

2727
/**
28-
* Allows the wrapper and inner processes to communicate.
28+
* Wrapper around a process that tries to gracefully exit when a process exits
29+
* and provides a way to prevent `process.exit`.
2930
*/
30-
export class IpcMain {
31-
private readonly _onMessage = new Emitter<Message>()
32-
public readonly onMessage = this._onMessage.event
33-
private readonly _onDispose = new Emitter<NodeJS.Signals | undefined>()
31+
abstract class Process {
32+
/**
33+
* Emit this to trigger a graceful exit.
34+
*/
35+
protected readonly _onDispose = new Emitter<NodeJS.Signals | undefined>()
36+
37+
/**
38+
* Emitted when the process is about to be disposed.
39+
*/
3440
public readonly onDispose = this._onDispose.event
35-
public readonly processExit: (code?: number) => never = process.exit
3641

37-
public constructor(private readonly parentPid?: number) {
42+
/**
43+
* Uniquely named logger for the process.
44+
*/
45+
public abstract logger: Logger
46+
47+
public constructor() {
3848
process.on("SIGINT", () => this._onDispose.emit("SIGINT"))
3949
process.on("SIGTERM", () => this._onDispose.emit("SIGTERM"))
4050
process.on("exit", () => this._onDispose.emit(undefined))
@@ -43,90 +53,89 @@ export class IpcMain {
4353
// Remove listeners to avoid possibly triggering disposal again.
4454
process.removeAllListeners()
4555

46-
// Try waiting for other handlers run first then exit.
47-
logger.debug(`${parentPid ? "inner process" : "wrapper"} ${process.pid} disposing`, field("code", signal))
56+
// Try waiting for other handlers to run first then exit.
57+
this.logger.debug("disposing", field("code", signal))
4858
wait.then(() => this.exit(0))
4959
setTimeout(() => this.exit(0), 5000)
5060
})
51-
52-
// Kill the inner process if the parent dies. This is for the case where the
53-
// parent process is forcefully terminated and cannot clean up.
54-
if (parentPid) {
55-
setInterval(() => {
56-
try {
57-
// process.kill throws an exception if the process doesn't exist.
58-
process.kill(parentPid, 0)
59-
} catch (_) {
60-
// Consider this an error since it should have been able to clean up
61-
// the child process unless it was forcefully killed.
62-
logger.error(`parent process ${parentPid} died`)
63-
this._onDispose.emit(undefined)
64-
}
65-
}, 5000)
66-
}
6761
}
6862

6963
/**
70-
* Ensure we control when the process exits.
64+
* Ensure control over when the process exits.
7165
*/
7266
public preventExit(): void {
73-
process.exit = function (code?: number) {
74-
logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`)
75-
} as (code?: number) => never
67+
;(process.exit as any) = (code?: number) => {
68+
this.logger.warn(`process.exit() was prevented: ${code || "unknown code"}.`)
69+
}
7670
}
7771

78-
public get isChild(): boolean {
79-
return typeof this.parentPid !== "undefined"
80-
}
72+
private readonly processExit: (code?: number) => never = process.exit
8173

74+
/**
75+
* Will always exit even if normal exit is being prevented.
76+
*/
8277
public exit(error?: number | ProcessError): never {
8378
if (error && typeof error !== "number") {
8479
this.processExit(typeof error.code === "number" ? error.code : 1)
8580
} else {
8681
this.processExit(error)
8782
}
8883
}
84+
}
8985

90-
public handshake(child?: cp.ChildProcess): Promise<void> {
91-
return new Promise((resolve, reject) => {
92-
const target = child || process
86+
/**
87+
* Child process that will clean up after itself if the parent goes away and can
88+
* perform a handshake with the parent and ask it to relaunch.
89+
*/
90+
class ChildProcess extends Process {
91+
public logger = logger.named(`child:${process.pid}`)
92+
93+
public constructor(private readonly parentPid: number) {
94+
super()
95+
96+
// Kill the inner process if the parent dies. This is for the case where the
97+
// parent process is forcefully terminated and cannot clean up.
98+
setInterval(() => {
99+
try {
100+
// process.kill throws an exception if the process doesn't exist.
101+
process.kill(this.parentPid, 0)
102+
} catch (_) {
103+
// Consider this an error since it should have been able to clean up
104+
// the child process unless it was forcefully killed.
105+
this.logger.error(`parent process ${parentPid} died`)
106+
this._onDispose.emit(undefined)
107+
}
108+
}, 5000)
109+
}
110+
111+
/**
112+
* Initiate the handshake and wait for a response from the parent.
113+
*/
114+
public handshake(): Promise<void> {
115+
return new Promise((resolve) => {
93116
const onMessage = (message: Message): void => {
94-
logger.debug(
95-
`${child ? "wrapper" : "inner process"} ${process.pid} received message from ${
96-
child ? child.pid : this.parentPid
97-
}`,
98-
field("message", message),
99-
)
117+
logger.debug(`received message from ${this.parentPid}`, field("message", message))
100118
if (message.type === "handshake") {
101-
target.removeListener("message", onMessage)
102-
target.on("message", (msg) => this._onMessage.emit(msg))
103-
// The wrapper responds once the inner process starts the handshake.
104-
if (child) {
105-
if (!target.send) {
106-
throw new Error("child not spawned with IPC")
107-
}
108-
target.send({ type: "handshake" })
109-
}
119+
process.removeListener("message", onMessage)
110120
resolve()
111121
}
112122
}
113-
target.on("message", onMessage)
114-
if (child) {
115-
child.once("error", reject)
116-
child.once("exit", (code) => {
117-
reject(new ProcessError(`Unexpected exit with code ${code}`, code !== null ? code : undefined))
118-
})
119-
} else {
120-
// The inner process initiates the handshake.
121-
this.send({ type: "handshake" })
122-
}
123+
// Initiate the handshake and wait for the reply.
124+
process.on("message", onMessage)
125+
this.send({ type: "handshake" })
123126
})
124127
}
125128

129+
/**
130+
* Notify the parent process that it should relaunch the child.
131+
*/
126132
public relaunch(version: string): void {
127133
this.send({ type: "relaunch", version })
128134
}
129135

136+
/**
137+
* Send a message to the parent.
138+
*/
130139
private send(message: Message): void {
131140
if (!process.send) {
132141
throw new Error("not spawned with IPC")
@@ -135,59 +144,60 @@ export class IpcMain {
135144
}
136145
}
137146

138-
/**
139-
* Channel for communication between the child and parent processes.
140-
*/
141-
export const ipcMain = new IpcMain(
142-
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined" ? parseInt(process.env.CODE_SERVER_PARENT_PID) : undefined,
143-
)
144-
145147
export interface WrapperOptions {
146148
maxMemory?: number
147149
nodeOptions?: string
148150
}
149151

150152
/**
151-
* Provides a way to wrap a process for the purpose of updating the running
152-
* instance.
153+
* Parent process wrapper that spawns the child process and performs a handshake
154+
* with it. Will relaunch the child if it receives a SIGUSR1 or is asked to by
155+
* the child. If the child otherwise exits the parent will also exit.
153156
*/
154-
export class WrapperProcess {
155-
private process?: cp.ChildProcess
157+
export class ParentProcess extends Process {
158+
public logger = logger.named(`parent:${process.pid}`)
159+
160+
private child?: cp.ChildProcess
156161
private started?: Promise<void>
157162
private readonly logStdoutStream: rfs.RotatingFileStream
158163
private readonly logStderrStream: rfs.RotatingFileStream
159164

165+
protected readonly _onChildMessage = new Emitter<Message>()
166+
protected readonly onChildMessage = this._onChildMessage.event
167+
160168
public constructor(private currentVersion: string, private readonly options?: WrapperOptions) {
169+
super()
170+
161171
const opts = {
162172
size: "10M",
163173
maxFiles: 10,
164174
}
165175
this.logStdoutStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stdout.log"), opts)
166176
this.logStderrStream = rfs.createStream(path.join(paths.data, "coder-logs", "code-server-stderr.log"), opts)
167177

168-
ipcMain.onDispose(() => {
178+
this.onDispose(() => {
169179
this.disposeChild()
170180
})
171181

172-
ipcMain.onMessage((message) => {
182+
this.onChildMessage((message) => {
173183
switch (message.type) {
174184
case "relaunch":
175-
logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`)
185+
this.logger.info(`Relaunching: ${this.currentVersion} -> ${message.version}`)
176186
this.currentVersion = message.version
177187
this.relaunch()
178188
break
179189
default:
180-
logger.error(`Unrecognized message ${message}`)
190+
this.logger.error(`Unrecognized message ${message}`)
181191
break
182192
}
183193
})
184194
}
185195

186196
private disposeChild(): void {
187197
this.started = undefined
188-
if (this.process) {
189-
this.process.removeAllListeners()
190-
this.process.kill()
198+
if (this.child) {
199+
this.child.removeAllListeners()
200+
this.child.kill()
191201
}
192202
}
193203

@@ -196,16 +206,16 @@ export class WrapperProcess {
196206
try {
197207
await this.start()
198208
} catch (error) {
199-
logger.error(error.message)
200-
ipcMain.exit(typeof error.code === "number" ? error.code : 1)
209+
this.logger.error(error.message)
210+
this.exit(typeof error.code === "number" ? error.code : 1)
201211
}
202212
}
203213

204214
public start(): Promise<void> {
205215
// If we have a process then we've already bound this.
206-
if (!this.process) {
216+
if (!this.child) {
207217
process.on("SIGUSR1", async () => {
208-
logger.info("Received SIGUSR1; hotswapping")
218+
this.logger.info("Received SIGUSR1; hotswapping")
209219
this.relaunch()
210220
})
211221
}
@@ -217,7 +227,7 @@ export class WrapperProcess {
217227

218228
private async _start(): Promise<void> {
219229
const child = this.spawn()
220-
this.process = child
230+
this.child = child
221231

222232
// Log both to stdout and to the log directory.
223233
if (child.stdout) {
@@ -229,13 +239,13 @@ export class WrapperProcess {
229239
child.stderr.pipe(process.stderr)
230240
}
231241

232-
logger.debug(`spawned inner process ${child.pid}`)
242+
this.logger.debug(`spawned inner process ${child.pid}`)
233243

234-
await ipcMain.handshake(child)
244+
await this.handshake(child)
235245

236246
child.once("exit", (code) => {
237-
logger.debug(`inner process ${child.pid} exited unexpectedly`)
238-
ipcMain.exit(code || 0)
247+
this.logger.debug(`inner process ${child.pid} exited unexpectedly`)
248+
this.exit(code || 0)
239249
})
240250
}
241251

@@ -256,18 +266,52 @@ export class WrapperProcess {
256266
stdio: ["ipc"],
257267
})
258268
}
269+
270+
/**
271+
* Wait for a handshake from the child then reply.
272+
*/
273+
private handshake(child: cp.ChildProcess): Promise<void> {
274+
return new Promise((resolve, reject) => {
275+
const onMessage = (message: Message): void => {
276+
logger.debug(`received message from ${child.pid}`, field("message", message))
277+
if (message.type === "handshake") {
278+
child.removeListener("message", onMessage)
279+
child.on("message", (msg) => this._onChildMessage.emit(msg))
280+
child.send({ type: "handshake" })
281+
resolve()
282+
}
283+
}
284+
child.on("message", onMessage)
285+
child.once("error", reject)
286+
child.once("exit", (code) => {
287+
reject(new ProcessError(`Unexpected exit with code ${code}`, code !== null ? code : undefined))
288+
})
289+
})
290+
}
291+
}
292+
293+
/**
294+
* Process wrapper.
295+
*/
296+
export const wrapper =
297+
typeof process.env.CODE_SERVER_PARENT_PID !== "undefined"
298+
? new ChildProcess(parseInt(process.env.CODE_SERVER_PARENT_PID))
299+
: new ParentProcess(require("../../package.json").version)
300+
301+
export function isChild(proc: ChildProcess | ParentProcess): proc is ChildProcess {
302+
return proc instanceof ChildProcess
259303
}
260304

261305
// It's possible that the pipe has closed (for example if you run code-server
262306
// --version | head -1). Assume that means we're done.
263307
if (!process.stdout.isTTY) {
264-
process.stdout.on("error", () => ipcMain.exit())
308+
process.stdout.on("error", () => wrapper.exit())
265309
}
266310

267311
// Don't let uncaught exceptions crash the process.
268312
process.on("uncaughtException", (error) => {
269-
logger.error(`Uncaught exception: ${error.message}`)
313+
wrapper.logger.error(`Uncaught exception: ${error.message}`)
270314
if (typeof error.stack !== "undefined") {
271-
logger.error(error.stack)
315+
wrapper.logger.error(error.stack)
272316
}
273317
})

0 commit comments

Comments
 (0)
Please sign in to comment.