🐢

AsyncDisposableStack でリソース確保処理を書く

2025/03/13に公開

やりたいこと

動機: 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.suppressedError | 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
    が入れ子になっている。

おわり

Go やんけ

Discussion