@deno/sandbox による任意コード実行と強制停止

この記事は、Deno Advent Calendar 2025 の14日目の記事です。

前回、@deno/sandbox を使って Deno のサンドボックス環境の利用を試しました。

その中で、簡単なゲームも作成しました。
ユーザーが任意作成したコードを実行させるアプリを作りたいという方の参考になれば幸いです。

参考

結論

Promise.race を使い、指定時間以内に完了しなければ、呼び出し側で検知及び停止して後続処理をさせるのがよさそう。

検証

任意コード実行

sandbox を使うことにより任意コードの実行ができる。
できるがしかし、ユーザーのコード実行をさせながら「所定の出力を待つ」というのは中々難しい。

まず標準的な形から考える。

test1.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
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
console.log("process spawned script running");
`

await sandbox.writeTextFile(scriptName, scriptCode);

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
});

console.log(await child.status)

if(child.stdout === null) {
throw new Error("stdout is null");
}

for await (const chunk of child.stdout) {
console.log(new TextDecoder().decode(chunk));
}

for await (const chunk of child.stderr!) {
console.error(new TextDecoder().decode(chunk));
}

このような形であれば、以下のように結果が得られる。

1
2
3
$ deno run -EN --env-file=.env .\test1.ts.ts
{ success: true, code: 0, signal: null }
process spawned script running

問題は次のように呼び出しコードによって、無限ループ発生により結果が返されない場合です。

test2.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
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
console.log("process spawned script running");
while(true) {
console.log("still running...");
}
`

await sandbox.writeTextFile(scriptName, scriptCode);

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
});

console.log(await child.status)

if(child.stdout === null) {
throw new Error("stdout is null");
}

for await (const chunk of child.stdout) {
console.log(new TextDecoder().decode(chunk));
}

for await (const chunk of child.stderr!) {
console.error(new TextDecoder().decode(chunk));
}

これはコネクションが切れるまで待つということが発生してしまう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ deno run -EN --env-file=.env .\test2.ts
# => 応答なし

# 数分後
{
msg: "WebSocket closed with non-normal status",
code: 1006,
reason: ""
}
error: Uncaught (in promise) ConnectionClosedError: Connection to the sandbox was already closed
pending.reject(new ConnectionClosedError());
^
at WebSocket.<anonymous> (https://jsr.io/@deno/sandbox/0.1.0/transport.ts:232:22)
at WebSocket.emit (ext:deno_node/_events.mjs:436:20)
at WebSocket.emitClose (file:///C:/hogehoge/AppData/Local/deno/npm/registry.npmjs.org/ws/8.18.3/lib/websocket.js:272:10)
at Socket.socketOnClose (file:///C:/hogehoge/AppData/Local/deno/npm/registry.npmjs.org/ws/8.18.3/lib/websocket.js:1341:15)
at Socket.emit (ext:deno_node/_events.mjs:436:20)
at node:net:1036:12

ユーザーからの入力で実行し、一定の結果を待ちたいのであれば、何かしらタイムアウトして終了させる手段を取りたい。

実験1: AbortSignal

ここからは開始から終了までの時間計測も行いながら進めます。

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
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
console.log("process spawned script running");
while(true) {
console.log("still running...");
}
`

await sandbox.writeTextFile(scriptName, scriptCode);

console.time("MEASURE");
const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
signal: AbortSignal.timeout(3000),
});

console.log(await child.status)
console.timeEnd("MEASURE");

if(child.stdout === null) {
throw new Error("stdout is null");
}

実行すると、3000ms 程度で終了したいところ27832ms かかってしまう。

1
2
3
$ deno run -EN --env-file=.env test-abortcignal.ts
{ success: false, code: 143, signal: "SIGTERM" }
MEASURE: 27832ms

何回か試していくと、20000ms 〜 50000ms 程度で終了することが分かった。

実験1-2: AbortSignal + console.log なしコード

実は任意実行するコードから以下のように console.log を削除すると、3000ms 程度で終了することが確認できている。

1
2
3
4
5
6
const scriptCode = `
console.log("process spawned script running");
while(true) {
//console.log("still running...");
}
`

しかし、ユーザーが任意のコードを実行するということに条件を置きたいので、他の手段を探っていく。

実験2: timeout + 子プロセスを明示的にkill

test-timeout-kill.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
const scriptCode = `
console.log("process spawned script running");
while(true) {
console.log("still running...");
}
`
await sandbox.writeTextFile(scriptName, scriptCode);

console.time("MEASURE");
const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
});

setTimeout(() => {
child.kill()
}, 3000);

console.log(await child.status)
console.timeEnd("MEASURE");

if(child.stdout === null) {
throw new Error("stdout is null");
}

結果は、3000ms を大きくオーバーしてしまった。
これも幅はありながら、 12047ms ~ 46491ms が計測され、10000ms を切ることはなかった。

1
2
3
$ deno run -EN --env-file=.env .\test-timeout-kill.ts
{ success: false, code: 143, signal: "SIGTERM" }
MEASURE: 46491ms

実験3: 任意実行コードにtimeoutを仕込む

test-anysrc-timeout-kill.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
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
// 任意実行コード先頭に仕込む
setTimeout(() => {
Deno.exit(1);
},3000);

console.log("process spawned script running");
while(true) {
console.log("still running...");
}
`

await sandbox.writeTextFile(scriptName, scriptCode);

console.time("MEASURE");
const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
});

console.log(await child.status)
console.timeEnd("MEASURE");

if(child.stdout === null) {
throw new Error("stdout is null");
}

これは、結果が返ってこない。このような任意コードを手元で実行しても、3000ms 程度で終わることが無いのを確認できる。

1
2
3
4
5
6
7
8
setTimeout(() => {
Deno.exit(1);
},3000);

console.log("process spawned script running");
while(true) {
console.log("still running...");
}

以下のように直すと、3000ms 程度で終了することが確認できた。
しかし、ユーザーの任意実行コードの中に関わる手段を取ることは、目標とする姿ではないので模索を続ける。

1
2
3
4
5
6
7
8
9
10
11
import {delay} from "@std/async"

setTimeout(() => {
Deno.exit(1);
},3000);

console.log("process spawned script running");
while(true) {
console.log("still running...");
await delay(0);
}

この挙動をsandboxで行う場合には以下のようにする。

test-anysrc-timeout-async.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
import { Sandbox } from "@deno/sandbox";
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
import {delay} from "jsr:@std/async"

setTimeout(() => {
Deno.exit(1);
},3000);

console.log("process spawned script running");
while(true) {
console.log("still running...");
await delay(0);
}
`

await sandbox.writeTextFile(scriptName, scriptCode);
await sandbox.sh`deno cache ${scriptName}`;

console.time("MEASURE");
const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
})

console.log(await child.status)
console.timeEnd("MEASURE");

if(child.stdout === null) {
throw new Error("stdout is null");
}

これを実行すると概ね3000ms で終了できる。

実験4: Promise.race

ここまでの内容から与えられた任意のソースコードに干渉せず、spawn した子プロセス自体を指定時間で止めることは難しいことがうかがわれた。
別のアプローチを試みる。

Promise.race を使い、指定時間以内に完了しなければ、呼び出し側で検知する。

test-promise-race.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
import { Sandbox } from "@deno/sandbox";

async function userCodeRun(abort:AbortController):Promise<void>{
await using sandbox = await Sandbox.create();

const scriptName = `${crypto.randomUUID()}.ts`
const scriptCode = `
console.log("process spawned script running");
while(true) {
console.log("still running...");
}
`

await sandbox.writeTextFile(scriptName, scriptCode);

const child = await sandbox.spawn("deno", {
args: ["run", scriptName],
stdout: "piped",
stderr: "piped",
signal: abort.signal,
})

console.log(await child.status)

if(child.stdout === null) {
throw new Error("stdout is null");
}
}

function timeout(time: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("time out!")), time);
});
}

const abort = new AbortController();

// スクリプト全体の終了イベントを計測
addEventListener("unload", () => {
console.timeEnd("FULL MEASURE");
});

console.time("FULL MEASURE");
console.time("MEASURE");
try{
await Promise.race([userCodeRun(abort), timeout(3000)]);
}catch(e){
console.log(e);
// 実行されたコード自体は動いたままなので、ここでabortする
abort.abort();
}
console.timeEnd("MEASURE");

以下のように、必ず3000ms 程度で終了できることが確認できた。

1
2
3
4
5
6
7
8
9
10
11
12
$ deno run -EN --env-file=.env test-promise-race.ts
Error: time out!
at file:///hogehoge/test-promise-race.ts:32:29
at callback (ext:deno_web/02_timers.js:42:7)
at eventLoopTick (ext:core/01_core.js:223:13)
MEASURE: 3024ms
{
msg: "WebSocket closed with non-normal status",
code: 1006,
reason: ""
}
FULL MEASURE: 19659ms

しかし、実装の通り呼び出した子プロセスは動いたままなので、abort.abort()により非同期に停止させている。
この終了を待つ都合で、スクリプト全体の終了はもっと長く、6000ms~200000ms 程度かかっていた。
この場合も、無限ループ内でのconsole.log を消すと、3000ms 程度で終了できる。

非同期実行したものが残ってしまうことが許容できるのであれば、一番ユーザーコードに干渉せず一定時間以内で終了する方法だと考えられる。

番外1: テスト実装したゲームでは…

冒頭紹介したゲームでは、ユーザーの任意実行コードを受け取り実行させている。
しかし、生の while、生のfor を用いたコードを実行させることは断念した。
受け取った関数処理に、while や for が含まれているかをチェックし、実行前で弾くこととした。

それでも繰り返しを行う制御コードは必要なので独自に repeat 関数を用意し、そちらを使うようしている。
これは、ゲーム空間として時間進行するとき必要な一定の時間幅だけィミットを入れてループさせている。
このことで、実際には無限ループさせないこととした。

この配慮をしたうえで、Promise.race を用いて、指定時間内に完了しなければ強制停止させるようにしている。

番外2: KillController

@deno/sandbox では、KillController が提供されている。
これは、Similar to an AbortController, but for sending signals to commands. と説明がある。
AbortController と似ているが、コマンドにシグナルを送るためのものと記載がある。
しかし、
spawnの引数として、受け取るsignalは、SpawnOptions.signal?: AbortSignal | undefined のようになっていない。
エラー無視して実行すると、まったく違うエラーになる。
spawn の引数としては使えないようだ。


今回は、@deno/sandbox にでの任意コード実行における強制停止の手段についてまとめました。

要点としては以下の通りです。

  • 無限ループかつ console.~~ を含むようなコードを実行させる場合、タイムリーな強制停止は難しい
    • acbortsignal や kill() を使ってもすぐに終了できない
  • 入力されたソースに手を加えると、実行ソース自体で強制停止に近しい挙動が可能
  • Promise.race を使うと望ましい挙動に近いものが得られる

この辺りは、Micro task や Task queue 周りの知識がしっかりしているとより簡潔に述べられるものであるはずです。

ユーザーが任意作成したコードを実行させるアプリを作りたいという方の参考になれば幸いです。

では。