Skip to content

Commit

Permalink
Add Timeout to Sandbox (#307)
Browse files Browse the repository at this point in the history
* WIP: add sandboxing features

* WIP: sandboxing types

* Fix up support for timeouts via workers

* Bump version and include docs
  • Loading branch information
taybenlor authored Jan 5, 2025
1 parent b610e3b commit 52db0b2
Show file tree
Hide file tree
Showing 16 changed files with 585 additions and 69 deletions.
10 changes: 9 additions & 1 deletion packages/runtime/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ export type TerminatedResult = {
resultType: "terminated";
};

export type RunResult = CompleteResult | CrashResult | TerminatedResult;
export type TimeoutResult = {
resultType: "timeout";
};

export type RunResult =
| CompleteResult
| CrashResult
| TerminatedResult
| TimeoutResult;

export type RuntimeMethods = {
showControls: () => void;
Expand Down
3 changes: 3 additions & 0 deletions packages/wasi/lib/worker/wasi-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export class WASIWorkerHost {
fs: this.context.fs,
isTTY: this.context.isTTY,
});
}).then((result) => {
this.worker?.terminate();
return result;
});

return this.result;
Expand Down
20 changes: 19 additions & 1 deletion sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ If you want to process files within your sandbox, you can include them in the
file system by using the `run_fs` method.

```python
from runno import run_code, WASIFS, StringFile
from runno import run_fs, WASIFS, StringFile, WASITimestamps

fs: WASIFS = {
"/program.py": StringFile(
path="/program.py",
Expand Down Expand Up @@ -63,3 +64,20 @@ await run_fs(runtime, "/program", fs)

You can use the same technique to include pure python packages as dependencies.
The interface for this is not super nice right now, but it's on the way.

## Limiting Execution Time

You can limit how much time is allocated for execution using an optional
`timeout` kwarg (measured in seconds). Like:

```
from runno import run_code
code = "while True: pass"
result = await run_code("python", code, timeout=5)
if result.result_type == "timeout":
print("Timed out.")
else:
print("Wow, it ran forever.")
```
28 changes: 20 additions & 8 deletions sandbox/cli/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CrashResult,
Runtime,
TerminatedResult,
TimeoutResult,
} from "../lib/types.ts";
import { fetchWASIFS } from "../lib/main.ts";
import { extractTarGz } from "../lib/tar.ts";
Expand All @@ -32,14 +33,18 @@ const command = new Command()
.description(
`A CLI for running code in a sandbox environment, powered by Runno & WASI.
Supports python, ruby, quickjs, php-cgi, clang, and clangpp.
Entry name is the name of the entrypoint in the base filesystem.
Entry path is the path of the entrypoint in the base filesystem.
`
)
.arguments("<runtime:string> <entry-path:string>")
.arguments("<runtime:string> [entry-path:string]")
.option(
"-f, --filesystem <filesystem:string>",
"A tgz file to use as the base filesystem"
)
.option(
"-t --timeout <timeout:number>",
"The maximum amount of time to allow the code to run (in seconds)"
)
.option(
"--filesystem-stdin",
"Read the base filesystem from stdin as a tgz file (useful for piping)"
Expand All @@ -56,17 +61,17 @@ Entry name is the name of the entrypoint in the base filesystem.
filesystemStdin?: true;
entryStdin?: true;
json?: true;
timeout?: number;
},
...args: [string, string]
...args: [string, string | undefined]
) => {
const [runtimeString, entry] = args;
let [runtimeString, entry] = args;
if (!isRuntime(runtimeString)) {
throw new Error(`Unsupported runtime: ${runtimeString}`);
}
const runtime: Runtime = runtimeString;

// TODO: Read the entry file from stdin

entry = entry ?? "/program";
const entryPath = entry.startsWith("/") ? entry : `/${entry}`;
let fs: WASIFS = {};

Expand Down Expand Up @@ -95,7 +100,9 @@ Entry name is the name of the entrypoint in the base filesystem.
};
}

const result = await runFS(runtime, entryPath, fs);
const result = await runFS(runtime, entryPath, fs, {
timeout: options.timeout,
});
if (options.json) {
let jsonResult: JSONResult;
if (result.resultType === "complete") {
Expand All @@ -115,6 +122,10 @@ Entry name is the name of the entrypoint in the base filesystem.
console.error(result.stderr);
console.log(result.stdout);
break;
case "timeout":
console.error("Timeout");
Deno.exit(1);
break;
case "crash":
console.error(result.error);
Deno.exit(1);
Expand Down Expand Up @@ -173,4 +184,5 @@ export type StringWASIFile = {
export type JSONResult =
| (Omit<CompleteResult, "fs"> & { fs: Base64WASIFS })
| CrashResult
| TerminatedResult;
| TerminatedResult
| TimeoutResult;
2 changes: 1 addition & 1 deletion sandbox/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test": "deno test --allow-read",
"cli": "deno --allow-net --allow-read src/main.ts",
"bootstrap": "cp -R ../../langs .",
"compile": "deno compile --frozen --include langs --output runno bin/main.ts"
"compile": "deno compile --frozen --include langs --include lib/worker.ts --output runno bin/main.ts"
},
"lock": {
"path": "./deno.lock",
Expand Down
8 changes: 4 additions & 4 deletions sandbox/cli/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 128 additions & 0 deletions sandbox/cli/lib/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { WASIExecutionResult, WASIContextOptions } from "@runno/wasi";
import type { HostMessage, WorkerMessage } from "./worker.ts";

function sendMessage(worker: Worker, message: WorkerMessage) {
worker.postMessage(message);
}

type WASIWorkerHostContext = Partial<Omit<WASIContextOptions, "stdin">>;

export class WASIWorkerHostKilledError extends Error {}

export class WASIWorkerHost {
binaryURL: string;

// 8kb should be big enough
stdinBuffer: SharedArrayBuffer = new SharedArrayBuffer(8 * 1024);

context: WASIWorkerHostContext;

result?: Promise<WASIExecutionResult>;
worker?: Worker;
reject?: (reason?: unknown) => void;

constructor(binaryURL: string, context: WASIWorkerHostContext) {
this.binaryURL = binaryURL;
this.context = context;
}

start() {
if (this.result) {
throw new Error("WASIWorker Host can only be started once");
}

this.result = new Promise<WASIExecutionResult>((resolve, reject) => {
this.reject = reject;
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});

this.worker.addEventListener("message", (messageEvent) => {
const message: HostMessage = messageEvent.data;
switch (message.type) {
case "stdout":
this.context.stdout?.(message.text);
break;
case "stderr":
this.context.stderr?.(message.text);
break;
case "debug":
this.context.debug?.(
message.name,
message.args,
message.ret,
message.data
);
break;
case "result":
resolve(message.result);
break;
case "crash":
reject(message.error);
break;
}
});

sendMessage(this.worker, {
target: "client",
type: "start",
binaryURL: this.binaryURL,
stdinBuffer: this.stdinBuffer,

// Unfortunately can't just splat these because it includes types
// that can't be sent as a message.
args: this.context.args,
env: this.context.env,
fs: this.context.fs,
isTTY: this.context.isTTY,
});
}).then((result) => {
this.worker?.terminate();
return result;
});

return this.result;
}

kill() {
if (!this.worker) {
throw new Error("WASIWorker has not started");
}
this.worker.terminate();
this.reject?.(new WASIWorkerHostKilledError("WASI Worker was killed"));
}

async pushStdin(data: string) {
const view = new DataView(this.stdinBuffer);

// Wait until the stdinbuffer is fully consumed at the other end
// before pushing more data on.

// first four bytes (Int32) is the length of the text
while (view.getInt32(0) !== 0) {
// TODO: Switch to Atomics.waitAsync when supported by firefox
await new Promise((resolve) => setTimeout(resolve, 0));
}

// Store the encoded text offset by 4 bytes
const encodedText = new TextEncoder().encode(data);
const buffer = new Uint8Array(this.stdinBuffer, 4);
buffer.set(encodedText);

// Store how long the text is in the first 4 bytes
view.setInt32(0, encodedText.byteLength);
Atomics.notify(new Int32Array(this.stdinBuffer), 0);
}

async pushEOF() {
const view = new DataView(this.stdinBuffer);

// TODO: Switch to Atomics.waitAsync when supported by firefox
while (view.getInt32(0) !== 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}

view.setInt32(0, -1);
Atomics.notify(new Int32Array(this.stdinBuffer), 0);
}
}
Loading

0 comments on commit 52db0b2

Please sign in to comment.