AsyncDisposableStack でリソース確保処理を書く
やりたいこと
動機: Puppteer はプロセス外部のリソースを触るので、正しく終了させないとプロセス終了後にChromeが起動しっぱなしになってしまう。
TS 5.2 から使える await using と AsyncDisposableStack でリソース開放を逐次予約する。
tl;dr
- 本当は
await using
で個別にリソースを確保/開放したいが、まだ諸々のライブラリが対応してない - 自分でラップするのが面倒
- なので、 AsyncDisposableStack.prototype.defer に逐次放り込むのが楽
準備
import "core-js/proposals/explicit-resource-management.js";
Deno で単純な await using
は使えるのだが、試した限りは AsyncDisposableStack
の型は存在するがコンストラクタが存在しなかった。
後述する SuppressedError
もなかった。なのでポリフィルが必要。
AsyncDisposableStack の defer で逐次開放処理を放り込む
import puppeteer from "puppeteer";
async function simple(
url: string,
{ headless = true }: { headless?: boolean } = {},
) {
await using d = new AsyncDisposableStack();
const browser = await puppeteer.launch({
headless,
});
d.defer(() => browser.close()); // => 1
const page = await browser.newPage();
d.defer(() => page.close()); // => 0
await page.goto(url, {
waitUntil: "networkidle0",
});
// => page.close()
// => browser.close()
}
defer の登録の逆順に発火するので、page.close()
, browser.close()
の順で呼ばれる。
まとめて次のように書きたくなるが、他の処理の例外で defer に登録されない可能性がある。
async function bad1(
url: string,
{ headless = true }: { headless?: boolean } = {},
) {
await using d = new AsyncDisposableStack();
const browser = await puppeteer.launch({ headless });
// Bad: browser.newPage() に失敗すると、 browser.close() を
// 登録する前に例外で脱出してしまう
const page = await browser.newPage();
d.defer(async () => {
await page.close();
await browser.close();
});
await page.goto(url, { waitUntil: "networkidle0" });
}
とにかくリソースを確保したら即座に開放処理を書く。これを徹底するとうまくいく。
また、逆順に呼ばれるもちゃんと意識する必要がある。
async function bad2(
url: string,
{ headless = true }: { headless?: boolean } = {},
) {
await using d = new AsyncDisposableStack();
const browser = await puppeteer.launch({ headless });
const page = await browser.newPage();
// BAD: サブリソースの保証のために、defer は登録の逆順で発火する
// page.close の前に browser.close が呼ばれてしまうので、page はリリース済み
d.defer(page.close); // => 1
d.defer(browser.close); // => 0
await page.goto(url, { waitUntil: "networkidle0" });
}
これは、page は browser のサブリソースを順番に制御してると考えるとしっくりくる。
pageのインスタンスを別の関数で分割する際に、browser.close()
が呼ばれない。
例えばこういう風に書いた場合
function getPageRunner(browser: puppeteer.Browser) {
let page: puppeteer.Page | undefined = undefined;
return {
run(url: string) {
page ??= await browser.newPage();
await page.goto(url, { waitUntil: "networkidle0" });
},
async [Symbol.asyncDispose]() {
await page?.close();
},
};
}
async function run(
url: string,
{ headless = true }: { headless?: boolean } = {},
) {
await using d = new AsyncDisposableStack();
const browser = await puppeteer.launch({ headless });
d.defer(browser.close); // => 0
await using runner = getpPageRunner(browser);
runner.run(url);
// => runner[Symbol.asyncDispose]()
// => browser.close()
}
スコープの寿命とリソースが一致している、と考えるといい。
これが必要になるのは puppeteer のように外部プロセスを管理したり DB を触ってるときだと思うが、メモリを大量に使う処理を書くときも Measure GC の頻度を下げられるかもしれない。
応用: あえてブラウザを停止させず、 Ctrl-C で明示的に止めたい
Puppeteer は デバッグの都合、あえて close せずに Ctrl-C で明示的に止めるまで処理を止めたくないことがある。
例えばヘッドフルモードで、認証操作情報を作るときなど。
Deno だと Deno.addSignalListener が来たタイミングで終了処理を挟んで、Deno.exit(1)
で止める。 (Node) でも process で同じことができる。
// Ctrl+C => Deno.exit で止める際はスタックを無視して終了するので
// Dispose を直接呼べるようにしておく
function trapCtrlC(d: AsyncDisposableStack): Disposable {
const existHandler = async () => {
console.log("Deno.signal: SIGINT");
try {
d.disposed || (await d[Symbol.asyncDispose]());
Deno.exit(0);
} catch (err) {
console.error("Error during cleanup", err);
Deno.exit(1);
}
};
return {
[Symbol.dispose]() {
return Deno.addSignalListener("SIGINT", existHandler);
},
};
}
async function runManually(
url: string,
{
headless = true,
manual = false,
}: { headless?: boolean; manual?: boolean } = {},
) {
await using d = new AsyncDisposableStack();
using _ = trapCtrlC(d);
const browser = await puppeteer.launch({
headless,
});
// manual がある場合、意図的にリソースを開放しない
!manual && d.defer(browser.close); // => 1
const page = await browser.newPage();
!manual && d.defer(page.close); // => 0
// run
await page.goto(url, {
waitUntil: "networkidle0",
timeout: 15000,
});
}
await runManually("https://example.com", {
headless: false,
manual: true,
});
これでブラウザを開いたまま操作し、操作が終わったら Ctrl-C
でプロセスを停止できる。
AsyncDisposableStack と SuppressedError
「Symbol.asyncDispose の非同期のリソースの開放自体に失敗したらどうなるんだろう?」と思って実験してみた。
結論
-
.defer()
に登録した処理は例外は、個別に非同期例外を吐いても止まらずに次の
defer に移る。 - 全部の asyncDispose が終わったあとに
SuppressedError
を throw する -
SuppressedError
の.suppressed
でError | SuppressedError
が入れ子になっている
検証コード
import "core-js/proposals/explicit-resource-management.js";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function runWithAsyncDisposeErrors() {
await using d = new AsyncDisposableStack();
d.defer(async () => {
console.log("0:enter");
await wait(100);
console.log("0:leave");
throw new Error("Error 0");
});
d.defer(async () => {
console.log("1:enter");
await wait(100);
console.log("1:leave");
});
d.defer(async () => {
console.log("2:enter");
await wait(100);
console.log("2:leave");
throw new Error("Error 2");
});
}
try {
await runWithAsyncDisposeErrors();
console.log("unreachable");
} catch (err) {
if (err instanceof SuppressedError) {
do {
if (err instanceof SuppressedError) {
console.error("SuppressedError", err.error);
err = err.suppressed;
continue;
}
console.error("FinalError", err);
break;
} while (err instanceof Error);
} else {
console.error("Unexpected", err);
}
}
console.log("done");
実行結果
$ deno run -A r3/example.ts
3:enter
3:leave
2:enter
2:leave
1:enter
1:leave
0:enter
0:leave
SuppressedError Error: Error 0
at example.ts:104:11
at eventLoopTick (ext:core/01_core.js:216:9)
SuppressedError Error: Error 2
at example.ts:116:11
at eventLoopTick (ext:core/01_core.js:216:9)
FinalError Error: Error 3
at example.ts:123:11
at eventLoopTick (ext:core/01_core.js:216:9)
done
- wait を入れて、enter/leave を挟んだが、順番が入れ替わらないので前の dispose の終了を待っている。
- 例外を投げてみたが、止まらない
- あえて
Error1
は投げてないので、それ以外の SuppressedError
が入れ子になっている。
Discussion