-
Notifications
You must be signed in to change notification settings - Fork 201
/
Copy pathsandbox.ts
342 lines (303 loc) · 10.8 KB
/
sandbox.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import * as cp from "child_process";
import { writeFileSync } from "fs";
import { readFile, stat } from "fs/promises";
import { url as inspectorUrl } from "inspector";
import { Bundle, createBundle } from "./bundling";
import { processStream } from "./stream-processor";
import { LogLevel } from "../std";
export interface SandboxOptions {
readonly env?: { [key: string]: string };
readonly context?: { [key: string]: any };
/**
* How long the sandbox is allowed to run code when `sandbox.call()` is called
* before the child process should be stopped (killed), in milliseconds.
*
* If an invocation returns successfully, the child process continues running
* and can be reused for subsequent invocations.
*/
readonly timeout?: number;
readonly log?: (internal: boolean, level: LogLevel, message: string) => void;
}
/**
* Shape of the messages sent to the child process.
*/
type ProcessRequest = {
fn: string;
args: any[];
};
/**
* Shape of the messages returned by the child process.
*/
type ProcessResponse =
| {
type: "ok";
value: any;
}
| {
type: "error";
reason: Error;
};
export class Sandbox {
public static async createBundle(
entrypoint: string,
log?: (message: string, level: LogLevel) => void
): Promise<Bundle> {
let contents = await readFile(entrypoint, "utf-8");
// log a warning if contents includes __dirname or __filename
if (contents.includes("__dirname") || contents.includes("__filename")) {
log?.(
`Warning: __dirname and __filename cannot be used within bundled cloud functions. There may be unexpected behavior.`,
LogLevel.WARNING
);
}
let debugShim = "";
if (inspectorUrl?.()) {
// If we're debugging, we need to make sure the debugger has enough time to attach
// to the child process. This gives enough time for the debugger load sourcemaps and set breakpoints.
// See https://github.com/microsoft/vscode-js-debug/issues/1510
debugShim =
"\n await new Promise((resolve) => setTimeout(resolve, 25));";
}
// wrap contents with a shim that handles the communication with the parent process
// we insert this shim before bundling to ensure source maps are generated correctly
contents += `
process.on("uncaughtException", (reason) => {
process.send({ type: "error", reason });
});
process.on("message", async (message) => {${debugShim}
const { fn, args } = message;
const value = await exports[fn](...args);
process.send({ type: "ok", value });
});
`;
const wrappedPath = entrypoint.replace(/\.cjs$/, ".sandbox.cjs");
writeFileSync(wrappedPath, contents); // async fsPromises.writeFile "flush" option is not available in Node 20
const bundle = createBundle(wrappedPath);
if (process.env.DEBUG) {
const fileStats = await stat(entrypoint);
log?.(`Bundled code (${fileStats.size} bytes).`, LogLevel.VERBOSE);
}
return bundle;
}
private readonly entrypoint: string;
private readonly options: SandboxOptions;
private child: cp.ChildProcess | undefined;
private childPid: number | undefined;
private onChildMessage: ((message: ProcessResponse) => void) | undefined;
private onChildError: ((error: Error) => void) | undefined;
private onChildExit:
| ((code: number | null, signal: NodeJS.Signals | null) => void)
| undefined;
private timeout: NodeJS.Timeout | undefined;
// Tracks whether the sandbox is available to process a new request
// When call() is called, it sets this to false, and when it's returning
// a response or error, it sets it back to true.
private available = true;
private cleaningUp = false;
constructor(entrypoint: string, options: SandboxOptions = {}) {
this.entrypoint = entrypoint;
this.options = options;
}
public async cleanup() {
this.cleaningUp = true;
if (this.timeout) {
clearTimeout(this.timeout);
} else {
}
if (this.child) {
this.debugLog(
`Terminating sandbox child process (PID ${this.childPid}).`
);
this.child.kill("SIGTERM");
this.child = undefined;
this.available = true;
}
}
public isAvailable(): boolean {
return this.available;
}
public async initialize() {
const childEnv = this.options.env ?? {};
if (inspectorUrl?.()) {
// We're exposing a debugger, let's attempt to ensure the child process automatically attaches
childEnv.NODE_OPTIONS =
(childEnv.NODE_OPTIONS ?? "") + (process.env.NODE_OPTIONS ?? "");
// If the child process is not already configured to attach a debugger, add a flag to do so
if (
!childEnv.NODE_OPTIONS.includes("--inspect") &&
!process.execArgv.includes("--inspect")
) {
childEnv.NODE_OPTIONS += " --inspect=0";
}
// VSCode's debugger adds some environment variables that we want to pass to the child process
for (const key in process.env) {
if (key.startsWith("VSCODE_")) {
childEnv[key] = process.env[key]!;
}
}
}
// start a Node.js process that runs the inflight code
// note: unlike the fork(2) POSIX system call, child_process.fork()
// does not clone the current process
this.child = cp.fork(this.entrypoint, {
env: childEnv,
stdio: "pipe",
// keep the process detached so in the case of cloud.Service, if the parent process is killed
// (e.g. someone presses Ctrl+C while using Wing Console),
// we can gracefully call any cleanup code in the child process
detached: true,
// this option allows complex objects like Error to be sent from the child process to the parent
serialization: "advanced",
});
this.childPid = this.child.pid;
this.debugLog(`Initialized sandbox (PID ${this.childPid}).`);
const log = (message: string) => {
let level = LogLevel.INFO;
if (message.startsWith("info:")) {
message = message.slice(5);
} else if (message.startsWith("error:")) {
message = message.slice(6);
level = LogLevel.ERROR;
} else if (message.startsWith("warning:")) {
message = message.slice(8);
level = LogLevel.WARNING;
} else if (message.startsWith("verbose:")) {
message = message.slice(8);
level = LogLevel.VERBOSE;
}
this.options.log?.(false, level, message);
};
const logError = (message: string) =>
this.options.log?.(false, LogLevel.ERROR, message);
// pipe stdout and stderr from the child process
if (this.child.stdout) {
processStream(this.child.stdout, log);
}
if (this.child.stderr) {
processStream(this.child.stderr, logError);
}
this.child.on("message", (message: ProcessResponse) => {
this.onChildMessage?.(message);
});
this.child!.on("error", (error) => {
this.onChildError?.(error);
});
this.child!.on("exit", (code, signal) => {
this.onChildExit?.(code, signal);
});
}
public async call(fn: string, ...args: any[]): Promise<any> {
if (!this.available) {
throw new SandboxMultipleConcurrentCallsError();
}
// Prevent multiple calls to the same sandbox running concurrently.
this.available = false;
// If this sandbox doesn't have a child process running (because it
// just got created, OR because the previous child process was killed due
// to timeout or an unexpected error), initialize one.
if (!this.child) {
await this.initialize();
}
// Send the function name and arguments to the child process.
// When a message is received, resolve or reject the promise.
return new Promise((resolve, reject) => {
this.child!.send({ fn, args } as ProcessRequest);
this.debugLog(
`Sent a message to the sandbox (PID ${this.childPid}): ${JSON.stringify(
{
fn,
args,
}
)}`
);
this.onChildMessage = (message: ProcessResponse) => {
this.debugLog(
`Received a message from the sandbox (PID ${
this.childPid
}): ${JSON.stringify(message)}`
);
this.available = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
if (message.type === "ok") {
resolve(message.value);
} else if (message.type === "error") {
reject(message.reason);
} else {
reject(
new Error(
`Unexpected message from the sandbox (PID ${this.childPid}): ${message}`
)
);
}
};
// "error" could be emitted for any number of reasons
// (e.g. the process couldn't be spawned or killed, or a message couldn't be sent).
// Since this is unexpected, we kill the process with SIGKILL to ensure it's dead, and reject the promise.
this.onChildError = (error: Error) => {
this.debugLog(
`Unexpected error from the sandbox (PID ${this.childPid}).`
);
this.child?.kill("SIGKILL");
this.child = undefined;
this.available = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
if (this.cleaningUp) {
resolve(undefined);
} else {
reject(error);
}
};
// "exit" could be emitted if the user code called process.exit(), or if we killed the process
// due to a timeout or unexpected error. In any case, we reject the promise.
this.onChildExit = (code: number | null, signal: unknown) => {
this.debugLog(`Sandbox (PID ${this.childPid}) stopped.`);
this.child = undefined;
this.available = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
if (this.cleaningUp) {
resolve(undefined);
} else {
reject(
new Error(`Process exited with code ${code}, signal ${signal}`)
);
}
};
if (this.options.timeout && !inspectorUrl?.()) {
this.timeout = setTimeout(() => {
this.debugLog(
`Killing sandbox (PID ${this.childPid}) after timeout.`
);
this.child?.kill("SIGTERM");
this.child = undefined;
this.available = true;
if (this.cleaningUp) {
resolve(undefined);
} else {
reject(new SandboxTimeoutError(this.options.timeout ?? 0));
}
}, this.options.timeout);
}
});
}
private debugLog(message: string) {
if (process.env.DEBUG) {
this.options.log?.(true, LogLevel.VERBOSE, message);
}
}
}
export class SandboxTimeoutError extends Error {
constructor(public readonly timeout: number) {
super("Timed out after " + timeout + "ms.");
}
}
export class SandboxMultipleConcurrentCallsError extends Error {
constructor() {
super("Cannot process multiple requests in parallel.");
}
}