Featured image of post コードテイマー【Graceful Shutdown】終いを急ぐ獣が、還らぬ客と開いた鍵を闇に残す〜受け入れを閉ざし、皆を還してから己を閉じる〜

コードテイマー【Graceful Shutdown】終いを急ぐ獣が、還らぬ客と開いた鍵を闇に残す〜受け入れを閉ざし、皆を還してから己を閉じる〜

湯守の信条は、訪れた客を一人残らず気持ちよく帰すことだった。夜明け前、街に早鐘が鳴って急な仕舞いを命じられたその折に、湯に浸かったままの客が、還らなくなった——出てもいない、中にもいない。だが仕舞いの術に欠陥は一つも無い。客のいない空の湯屋でいくら試しても、何も起きない。原因は術の中にはない。「客がまだ居る」のに「もう閉じる」を同じ一息にやらせた設計が、上がり際の客から上がり湯を奪い、火を還さず栓を開けたまま、その場を捨てさせた。空では暴れぬ獣を、客が湯に浸かっている最中の早鐘で初めて檻に再現し、上がり湯を断たれた客が倒れること、何も還さず抜ければ火は燻り栓は開き客は宙吊りのまま虚無へ落ちることを、決定的な模擬戦で確かめる。直し方は、たった一手——火と水を還す前に「客が皆、自分の足で上がるのを待つ」を挟むこと。二度目の早鐘は聞き流し、新規を断ち、皆の上がりを刻限つきで待ち、それから順に鍵を返す。行儀の悪い終端が残す還らぬもの——解放漏れの資源、死なぬ接続、宙吊りの約束——を、カイナは初めてその名で呼ぶ。NULL。皆を還してから己を閉じる、その小さな終い方の TypeScript の契約の物語。

還らぬ客

客が一人、還らなかった。湯に浸かったまま、上がってもいない。なのに、洗い場にも、番台にも、どこにもいない。——ただ、消えた。

俺は、夜通し開く湯屋「宵霧の湯」のあるじだ。炉に棲む火霊と、地の底から引いた水脈。その二つの術で、大きな湯船と洗い場を、夜っぴて温め続ける。湯気と石と、客の笑い声で満ちた、下町の一軒だ。客の背中を流すのが、俺の性分でね。訪ねてきた客は、一人残らず、いい湯加減で、気持ちよく帰す。それだけが、俺の自慢だった。

夜が明ければ、いつものように仕舞う。火を落とし、湯を抜き、戸を閉める。長年、抜かりなくやってきた、その手順を「仕舞いの術」と呼んでいる。難しいことは、何もない。火と水に「もう休め」と命じる、それだけの術だ。

なのに、近頃、客が消える。

きっかけは、街の夜盗騒ぎだった。物騒だってんで、見回りが夜じゅう巡って、怪しいと見れば早鐘を打つ。鐘が鳴ったら、どの店も、すぐ戸締まりしろ——そういうお達しだ。鐘が鳴れば、俺も慌てて仕舞いの術を唱える。火を落とし、湯を抜く。急いで、閉じる。

その、急いで閉じた夜に限って——湯に浸かったままの客が、一人、還らなくなる。

「あの魔物使いは、終い方を識る者だと聞いた」。客の一人が、いつか番台で言っていた。手がけたものを、何一つ虚無に落とさない。終わったものを、ちゃんと還す人だと。獣を、力でなく契約で鎮める、変わった人がいる、と。——終いでしくじって、客を失くした俺が、頼るあてなんざ、その人しか思いつかなかった。

カイナ、と名乗ったその人は、夜明け前の暖簾を、音もなくくぐってきた。俺が湯気の向こうから事情を話そうとすると、それを、軽く手で止めた。だが、俺の話を聞こうともしない代わりに、こう言った。

「先に、いつもの仕舞いを、見せてくれ。客のいない、今の湯屋で」

俺は、面食らった。話を聞かずに、まず実演しろ、と言う。急ぐふうは、まるでなかった。見る側に、徹するつもりらしい。回りくどいな、と思いながら、俺は、言われた通りにした。

空の湯屋では、何も起きない

俺は、客の引けた湯屋で、仕舞いの術を唱えてみせた。火霊に「休め」と命じ、炉の火を落とす。水脈の栓を開けて、湯を抜く。湯が、排水口へ吸い込まれて、消えていく。戸を閉める。——何も、起きない。

「ほら、この通りだ」と俺は言った。つい、自負が滲んだ。「滞りない。俺の仕舞いに、抜かりはねえんだ」

カイナは、頷かなかった。否定も、しなかった。「手順そのものは、間違っていない」。そう言ったきり、排水口へ消えていく湯を、見たまま、動かない。

俺は、本当の悩みを、ぶちまけた。

「分からねえんだ。近頃、街に夜盗が出る。見回りが早鐘を鳴らして、急な戸締まりを命じてくる。鐘が鳴りゃ、俺は急いで仕舞いの術を唱える。——すると、たまに、湯に浸かったままの客が、一人、還らねえ。出てもいねえ、中にもいねえ。火霊は燻ったまま、水脈の栓は開いたまま。術を、何度検めても、欠陥は一つもねえ。なのに、空で試すと、今みたいに、何ともねえ」

声が、掠れた。

「同じ術だ。同じ手順だ。なのに、客がいる夜の早鐘の時だけ、客が……消える。俺は、何を、し損なってる」

直し方を訊いたんじゃない。なぜ、を、差し出していた。俺には、それが分からなかった。

客のいる湯屋は、空とは違う

カイナが、初めて口を開いた。俺の実演を、否定しなかった。

「お前の手順は、壊れていない。空の湯屋なら、お前の言う通り、何の滞りもなく閉じる」

だが——と、声が続いた。

「空の湯屋を閉じるのと、客のいる湯屋を閉じるのは、同じじゃない。お前が今、見せたのは、空のほうだ。獣は、空では、出ない」

「獣……?」。俺は、訊き返した。「客がいると、何が変わるってんだ」

カイナは、湯船を指した。

「客が、湯に浸かっている。まだ、上がっていない。その最中に、早鐘が鳴る。お前は、急いで仕舞う。——その時、何が起きるかを、ここで起こす。客のいない実演では、永遠に掴めない」

俺は、戸惑った。「客を……模擬の湯に、入れるってのか」。半信半疑だった。だが、従うしかなかった。客が消える理由が、空の湯屋で何度実演しても掴めないことだけは、俺自身、嫌というほど分かっていたからだ。

仕舞いの術が、獣になる

カイナは、この暴走の正体を、一頭の獣に見立てて語った。

「お前の『仕舞いの術』。あれそのものが、獣だ」と言う。「普段——客のいない夜明け——は、おとなしい。命じられた通り、静かに火を落とし、湯を抜く。だが、客を抱えたまま発動すると、性質が変わる。客も、火も、水も、掴んだまま、一息に崩れ落ちる。崩れ際に、掴んでいたものを、還る先のない場所へ、道連れにする」

俺の、仕舞いの術が。客を、道連れに。背筋というより、湯に浸けた手のひらが、ふいに冷えるような心地がした。

「俺の術が……客を、消してたのか。俺が、自分の手で?」

「術が悪いんじゃない」とカイナは言った。声は、静かだった。「『速く閉じよ』と命じられた通りに、閉じているだけだ。咎があるとすれば——『客がまだ居る』のと、『もう閉じる』を、同じ一息にやらせたことだ。獣は、その一息の中で、暴れる」

そこから先を、カイナは、まだ言わなかった。客が倒れるのを、俺の目の前で、まず見せるつもりらしかった。

客がいるのに、幕が下りる

俺は、まず湯屋の造りを、板に写して見せた。難しいことは、何もしていない。

その土台に、下ごしらえの道具が、二つある。一つは、約束(Promise)の解決を、外から握る deferredresolve を呼ぶまで、約束は宙に浮いたままだ——客が「いつ上がるか」を、こちらの手で決めるための握りになる。もう一つは、還した順を刻む通し番号の Seq だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 約束(Promise)の解決権を、外から握る道具。resolve を呼ぶまで promise は宙にある。
export interface Deferred<T> {
  promise: Promise<T>;
  resolve: (value: T) => void;
  reject: (reason?: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

// 還した順を刻む通し番号(順序の観測窓)。
export class Seq {
  private n = 0;
  next(): number {
    return (this.n += 1);
  }
}

この二つを土台に、湯屋の造りは、こうだ。

 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
// 仕舞いに「還す」資源(水脈の栓・火霊)。release を観測窓で見る。
export class Conduit {
  readonly name: string;
  released = false;
  releasedOrder = -1; // 何番目に還されたか(順序の観測窓)
  releaseCount = 0; // 何度還そうとされたか(冪等の観測窓)
  constructor(name: string) {
    this.name = name;
  }
  async release(seq: Seq): Promise<void> {
    this.releaseCount += 1;
    this.released = true;
    this.releasedOrder = seq.next();
  }
}

// 湯屋=サーバ。客(接続/リクエスト)を受け入れ、湯中の客(in-flight)を数える。
export class Bathhouse {
  private accepting = true;
  private readonly inside = new Set<Deferred<void>>();

  // 新規の客を迎える(受付)。戸を立てた後は断る(null)。
  admit(): Deferred<void> | null {
    if (!this.accepting) return null; // = server が新規 listen を拒否
    const bath = deferred<void>();
    this.inside.add(bath);
    const leave = () => {
      this.inside.delete(bath);
    };
    bath.promise.then(leave, leave);
    return bath;
  }

  stopAdmitting(): void {
    this.accepting = false; // = server.close(新規拒否。中の客には触れない)
  }

  get bathersInside(): number {
    return this.inside.size; // 観測窓:今、湯に浸かっている客数
  }

  // 中の客が皆、自分の足で上がるのを待つ(drain=server.close のコールバック相当)。
  async drain(): Promise<void> {
    const baths = [...this.inside].map((b) => b.promise.catch(() => undefined));
    await Promise.all(baths);
  }
}

「水脈と火霊は、客のためにある」と俺は言った。「Conduit ってのが、それだ。release——還す——と、栓を閉じ、火を落とす。湯屋——Bathhouse——は、客を迎え入れて(admit)、戸を立てる(stopAdmitting)。今、湯に浸かってる客の数が、bathersInside だ」

そして、客一人の入浴を写した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 客一人の入浴(リクエストハンドラ)。上がり際に「上がり湯」(水脈)を使う。
// = in-flight のハンドラが、最後に共有資源(DB接続等)へ触れる構造。
export async function serveGuest(bath: Deferred<void>, conduit: Conduit): Promise<string> {
  await bath.promise; // 湯に浸かっている間(in-flight。test が resolve で「上がる」を制御)
  if (conduit.released) {
    // 上がろうとしたら、上がり湯の水脈はもう還されていた=クローズ済み資源への接触
    throw new Error("上がり湯が出ない(水脈はもう還されている)");
  }
  return "良い湯でした"; // 客は満足して帰る(=レスポンスが届く)
}

「客は、湯に浸かってる間は、ただ浸かってる。bath.promise ってやつが、その『浸かってる時間』だ。やがて、上がる。上がり際に、最後の一杯——上がり湯——を、水脈から汲む。それで、さっぱりして帰る。それが、良い湯でした だ」

カイナは、その serveGuest の中の、一行を指した。await bath.promise の、次の行。客が上がってから、水脈に触れる、その一行を。

「ここだ。客は、湯に浸かっている間は、水脈に触れない。触れるのは、上がる、その瞬間だ。——もし、その瞬間に、水脈がもう還されていたら?」

俺は、まだ、ぴんと来なかった。

上がり湯のない湯船で、客が立ち往生する

「鐘は、俺が鳴らす」とカイナは言った。「客も、俺が湯に入れる。肝は、その客が、いつ上がるかを、こちらの手で握ることだ。獣が露れるのは、客が湯にいる、その最中の早鐘だけ。なら、夜盗の鐘が鳴る夜を、いつまでも張り込む要はない。その巡り合わせのほうを、こちらで組んで、狙って起こせばいい」

握りの道具は、さっき見せた deferred だ。約束を外から保留しておき、好きな刻に「客が上がった」と決める。壁の時計も、夜回りの鐘も、現のものは使わない。客の上がりも、鐘の合図も、ぜんぶ手で起こすから、何度走らせても、一度として違わず同じことが起きる。試すたびに結果がぶれる、ということがない。

カイナは、まず、客のいない湯屋で、鐘を鳴らしてみせた。仕舞いの術——shutdownNow——が走り、火と水が、すんなり還る。何も起きない。

「空では、獣は出ない。だから、お前は今まで、気づけなかった」とカイナは言った。「獣がいないんじゃない。呼び出す客が、いなかっただけだ」

そして、湯に、客を一人、入れた。

1
2
3
4
5
6
7
8
// 客を一人入れる。客は湯中(bath 未 resolve)。
const bath = house.admit();
const served = serveGuest(bath, water);
await Promise.resolve();
assert.equal(house.bathersInside, 1);

// そこへ早鐘=shutdownNow(stopAdmitting → 即 release)。
await shutdownNow(house, [water, fire], seq);

その早鐘——shutdownNow——の中身は、こうだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Before: 早鐘で、新規を止めてすぐ火と水を還す。客の上がりを待たない(drain なし)。
//   =「客がまだ居る」のに「もう閉じる」を同じ一息にやる順序違反。
export async function shutdownNow(
  house: Bathhouse,
  conduits: readonly Conduit[],
  seq: Seq,
): Promise<void> {
  house.stopAdmitting();
  for (const c of conduits) await c.release(seq); // 客が湯にいても還す ← 順序違反
}

戸を立てて、すぐ、火と水を還す。客が、まだ湯にいようとお構いなしだ。檻の中で、それが、はっきり目に見えた。

1
2
3
4
5
6
7
// 客がまだ湯にいるのに、水脈はもう還される。
assert.equal(water.released, true);
assert.equal(fire.released, true);

// そこで客が上がろうとする。
bath.resolve();
await assert.rejects(served, /上がり湯が出ない/);

客は、まだ湯にいる。bathersInside は、一。なのに、水脈は、もう還された。water.released が、真になっている。そこで、客が上がろうとした。上がり湯を、汲もうとした。——だが、栓は、もう閉じられている。湯は、一滴も出ない。客は、良い湯でした と言えないまま、倒れた。

俺は、これを「まだ間に合うかも」と、半分、思っていた。客が、もう少し早く上がっていれば、と。だが、カイナは、それを潰した。

「これは『まだ上がり湯が出ていないだけ』じゃない。水脈は、もう還された。還した栓からは、二度と、湯は出ない。客がいつ上がろうと、結果は同じだ。早かろうと、遅かろうと、出ないものは、出ない。——だから、この客は『まだ送れていない』んじゃない。『もう、決して送れない』んだ」

その「決して」が、腹に、ずしりと来た。

急いで仕舞おうとする俺の焦りが、客の逃げ道を塞ぐ。その残酷なすれ違いが、カイナの指し示す盤面に、冷酷な流れとして浮かび上がっていた。

Infographic sequence diagram showing a shutdown trigger prematurely closing a resource conduit while a guest request is still active, causing a resource access error

俺は、ようやく、自分の言葉で、像を結んだ。

「客が、まだ湯にいるのに……俺は、水を抜いて、火を落としてた。客が上がる前に、客が要るものを、先に片付けてたんだ。だから客は——上がり湯のない湯船で、立ち往生した」

「これが、獣の正体だ」とカイナは言った。「獣は、お前の術の『中』にはいない。『客がまだ居る』のに『もう還す』——その、順番の中にいる」

触らず抜けても、客は宙に残る

俺は、つい、手を出した。素人なりの、思いつきだ。

「なら——客がいる時は、何も片付けずに、そのまま放って出りゃいい。水も火も、触らなけりゃ、客は、湯ん中で倒れたりしねえ。違うか」

カイナは、頷かなかった。代わりに、俺の言った通りの仕舞いを、もう一つ、檻に流して見せた。何も還さず、ただ、その場を抜ける——justExit という、使い捨ての反証だ。

1
2
3
4
5
// 却下案(使い捨ての反証): 「待てないなら、いっそ何も触らず即抜ける」。
//   客は倒れぬが、皆置き去り・資源は漏れる(process.exit(0) を直接呼ぶ相当)。
export function justExit(): void {
  return; // 新規も止めず・客も待たず・資源も還さず、ただ抜ける
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 客は倒れない(水脈は還されていないから上がり湯は出る)。
bath.resolve();
assert.equal(await served, "良い湯でした");

// だが——還されず漏れる(栓は開いたまま・火は燻ったまま=ゾンビ)。
assert.equal(water.released, false);
assert.equal(fire.released, false);

// 客は置き去り(終端の刻に、まだ中にいる)。
assert.equal(house.bathersInside, 1); // 客は宙吊りのまま、どこにも還れない

確かに、客は、倒れなかった。水脈は還されていないから、上がり湯は、出る。良い湯でした も、ちゃんと返る。

「だが、これは『無事』とは違う」とカイナは言った。「誰も上がりを待たず、誰も送り出さないまま、湯屋ごと、その場を捨てる。客は、湯に浸かったまま——bathersInside は、一のままだ——上がる合図も、見送りもないまま、宙吊りになる。果たされも、捨てられもしない。火霊は燻ったまま、fire.released は偽。水脈の栓は開いたまま、water.released も偽。火は燃え続け、水は流れ続け、誰一人、その客を迎えに来ない」

カイナは、続けた。

「倒れないことを、無事と呼ぶな。客は、倒れぬ代わりに、どこにも還れず、湯の中に取り残される。『倒さなかった』だけで、『送り出した』わけじゃない。……お前が一番恐れていた、『還ってこない』が、これだ。早く片付ければ客が倒れ、片付けずに抜ければ客が宙吊る。どちらも、客を送り切る前に、幕を下ろしている。——それが、同じ一つの病だ」

俺は、足元が、ぐらりとした。

「早く片付けても、駄目。片付けずに抜けても、駄目。客がいる、その間は——どうすりゃ、いいんだ」

「待つんだ」とカイナは言った。「客が、自分の足で上がるのを。それから、片付ける。順が、要る。それだけだ」

待つ。それだけは、掴めた。だが、どう待つのか、二度鐘が鳴ったらどうするのか、いつまで待つのか——そこまでは、まだ、何も見えていなかった。

客を送り切る前に、幕を下ろした

俺は、ここまでを、頭の中で、ひとまとめにした。

害は、仕舞いの術そのものじゃない。「客がまだ居る」と「もう閉じる」を、同じ一息にやらせたこと。順序と、待ちの、欠落だ。早く還しても客が倒れ、還さず抜ければ客が宙に残る。どっちも、客を送り切る前に、幕を下ろしたから起きる。

カイナが、その仕舞い方に、名を与えた。「客が皆、自分の足で上がるのを待ってから、火と水を還して閉じる。この行儀のいい幕引きには、ちゃんとした名前がある」と言う。

その作法を——Graceful Shutdown、美しき幕引き——という。新規の受付を止め、処理中の仕事の完了を待ち、資源を正しい順序で還してから終わる、その一連の手順のことだ、とカイナは言った。対して、客を抱えたまま一息に崩れ、進行中の仕事も握った資源も顧みず、できる限り速く閉じてしまうのが、行儀の悪い終端——あの、強制終了だ。

俺は、それを、湯屋の言葉に、言い直した。

「……客を、送り切ってから、戸を閉める。それだけのことが、名前を、持ってるのか」

「ああ」とカイナは言った。「名前を持つほど、皆がし損なう、ということでもある」

皆を還してから、己を閉じる

止めてから、待つ。待ってから、還す

カイナが、契約を施した。仕舞いの式には、四つの作法がある、と言う。素のままの言葉で、一つずつ。

「一つ、二度目の早鐘でも、仕舞いを始め直さない。二つ、新規の客を断つ——戸を立てる。三つ、中の客が、皆上がるのを待つ。四つ、それから、順に、水脈と火を還す。——肝は、三つ目だ。客が皆、上がってから、初めて、鍵を返す」

俺は、引っかかった。

「止めてから、待つ。待ってから、還す。……なんで、その順でなきゃ、駄目なんだ。戸を立てた時点で、もう新しい客は来ねえ。なら、待たずに、さっさと還したって、同じじゃねえのか」

「違う」とカイナは言った。「戸を立てるのは、『これから来る客』を断つだけだ。新規の受け入れを、止めること。だが、『もう中にいる客』は、それで上がるわけじゃない。皆の上がりを待つ、は、いま手の中にある仕事が終わるのを、見届けること。この二つを、混ぜるな。止めてから、待つ。待ってから、還す」

俺は、湯屋の言葉で、受けた。

「……暖簾を下ろすのと、中の客が上がるのは、別の話か。暖簾を下ろしても、中の客は、まだ湯にいる」

「そこだ」とカイナの声が、低くなった。「客が今、湯に浸かっているかどうかは、その時々の巡りだ。術の形では、決められん。形をいくら正しく書いても、『客がいる最中の早鐘』は、いつか巡ってくる。守るのは、形じゃない。『還す前に、上がりを待つ』——その一手で、客の上がりと、鍵の還しが、時の上で、交わらなくなる。交わらなければ、倒れようがない」

形では決められない。だから、形の外で——待つ、その一手で守る。俺には、まだ半分しか掴めなかったが、その半分は、確かに、腑に落ちた。

同じ早鐘で、客が「良い湯でした」と帰る

カイナが刻んだ、仕舞いの式は、これだった。

 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
// After: 仕舞いの式。Before との差は「③ 皆の上がりを待つ一手(drain)」を、還しの前に挟むこと。
export class ClosingRite {
  private started = false;
  private readonly house: Bathhouse;
  private readonly conduits: readonly Conduit[];
  private readonly seq: Seq;
  private readonly newDeadline: () => Promise<never>; // 注入:本番は unref タイマー、模擬戦は手で起こす

  constructor(
    house: Bathhouse,
    conduits: readonly Conduit[],
    seq: Seq,
    newDeadline: () => Promise<never>,
  ) {
    this.house = house;
    this.conduits = conduits;
    this.seq = seq;
    this.newDeadline = newDeadline;
  }

  async run(): Promise<"clean" | "forced"> {
    if (this.started) return "clean"; // ① 二度目の早鐘で仕舞いを始め直さない(冪等)
    this.started = true;
    this.house.stopAdmitting(); // ② 新規の客を断つ(戸を立てる)
    let clean = true;
    try {
      // ③ 皆の上がりを待つ。だが際限なくは待たない(刻限と競わせる)
      await Promise.race([this.house.drain(), this.newDeadline()]);
    } catch {
      clean = false; // 刻限切れ:上がらぬ客を促して退かせる(待ちきれなかった)
    }
    for (const c of this.conduits) await c.release(this.seq); // ④ 皆が出てから、順に還す
    return clean ? "clean" : "forced";
  }
}

「挟んだのは、一手だけだ」とカイナは言った。「前の早鐘——shutdownNow——は、戸を立てて、すぐ火と水を還した。house.stopAdmitting() の、すぐ次が、release のひと回しだった。今度の式は、その二つの間に、drain()——皆の上がりを待つ——を、一つ、挟んだ。客の入浴も、上がり湯も、湯屋も、何一つ、変えていない。挟んだのは、待ち、一つきりだ」

カイナは、Before と寸分同じ「客が湯中+早鐘」で、この式を流した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Before(テスト1)と寸分同じ「客が湯中+早鐘」。
const bath = house.admit();
const served = serveGuest(bath, water);
await Promise.resolve();
assert.equal(house.bathersInside, 1);

const rite = new ClosingRite(house, [water, fire], seq, neverDeadline());
const ritePromise = rite.run(); // まだ bath を resolve しない

// マイクロタスクを掃く→「未還の assert」: drain の await で止まっている。
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.equal(water.released, false);
assert.equal(fire.released, false);
assert.equal(house.bathersInside, 1);

ここで、式は、止まっていた。run() は走り出している。戸も、立てた。だが、火も水も、まだ還っていない。water.releasedfire.released も、偽のまま。客は、まだ中にいる。

途中で Promise.resolve() を三度まわしているのは、宙に浮いた仕事を、念のため掃いておくためだ。だが——三度だろうと、十度だろうと、結果は変わらない。客が上がらない限り、還しへ進む道は、一寸も開かないからだ。だからこの「未還」は、掃き足りないだけの途中経過じゃない。客が湯にいる間は、決して還されない、という確定した像だ。

俺は、その「止まり方」を、目で追った。

「水脈は、まだ開いてる。火も、まだ燻ってねえ。仕舞いが……止まってる。客が、上がるのを、待ってるのか。客が、まだ何もしてねえのに」

drain() の、await のところで、止まっている」とカイナは言った。「中の客が皆、上がるまで、その先——還しのひと回し——へは、一歩も進めない。進む道が、塞がれている。だから『まだ還していない』んじゃない。『客が上がるまで、還す処理へ進む道が、どこにもない』んだ」

そして、客を、上がらせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ここで客が上がる(resolve)→ serveGuest が water.released===false の間に通る。
bath.resolve();
assert.equal(await served, "良い湯でした"); // 送り切れた

// run() も "clean" に解決し、最後に水脈・火が還る。
assert.equal(await ritePromise, "clean");
assert.equal(water.released, true);
assert.equal(fire.released, true);

// 還す順序:conduits の並び(水脈→火)どおり、逐次に還る(releasedOrder の観測窓)。
assert.equal(water.releasedOrder, 1);
assert.equal(fire.releasedOrder, 2);

客が、上がる。bath が、解ける。その瞬間に、何が、どの順で動くのか——カイナは、そのひと続きを、ほどいて見せた。

「客が上がると、まず、湯に浸かっていた serveGuest が、続きを動かす。上がり湯を、汲もうとする。このとき、水脈は、まだ還されていない——released は、偽だ。だから、上がり湯は、出る。客は『良い湯でした』と言って、帰る。送り切れた。それを見届けてから、初めて、drain() の待ちが、解ける。Promise.race が、決着する。そこでようやく、還しのひと回しが、動き出す。水脈、火——順に、還る。客の上がりが、先。鍵の還しが、後。この順は、もう、決して入れ替わらない」

カイナが示した盤面には、今度は美しく噛み合った歯車のような、整然とした順序が描かれていた。早鐘が鳴っても、仕舞いの式は慌てず、客を見送るための時を刻む。

Infographic sequence diagram demonstrating a graceful shutdown sequence where the server stops new requests, waits for active requests to finish, and safely releases the resource conduit afterward

俺は、安堵で、息が漏れた。

「同じ早鐘だ。同じ、湯に浸かった客だ。なのに——客は、ちゃんと『良い湯でした』と言って、帰っていく。火も、水も、客が出てから、還る。誰も、置き去りにならねえ」

だが、俺は、もう一つ、引っかかった。客が先に上がって、それを追って還した——ただ順に並んだ、それだけのことなんじゃないか、と。——カイナは、首を振った。

「並んだんじゃない。並ばせたんだ」とカイナは言った。「『待つ一手』が、客の上がりより先には、鍵を一本も還させない。だから、客の上がり湯が、間に合う。さっき、見たろう。客が、まだ何もしていないのに、還しは、drainawait で止まって、客の上がりを待っていた」

「だが——」と俺は食い下がった。「客が上がった、その一瞬に、客の上がり湯と、あんたの『還し』。どっちが先に動くか、なんて、たまたま、ってことは、ねえのか」

カイナは、コードの一点を、指でなぞった。「それも、運じゃない。順は、結んだ順で決まる。客の serveGuest は、湯に入った時に、もう同じ bath.promise へ『上がったら、続きをやる』と段取りを結んでいる。drain が同じ約束へ『皆、上がったか』と尋ねるのは、その後だ。同じ約束が解けた一瞬、先に結ばれたほう——客の上がり湯——が、先に走る。だから released がまだ偽のうちに、客は通り抜ける。早い者勝ちじゃない。結んだ順だ。だから、何度やっても、客が、先だ」

俺は、半分だけ、頷いた。残りの半分は、後で何度もこのコードを読み返して、ようやく腹に落ちるんだろう。だが、肝は、分かった。待ち中は、まだ還っていない。そして、上がりが、還しより先。その二つは、運じゃない。仕掛けの中に、初めから、織り込まれている——それを、俺は、両方、目で見た。

「獣は、消えていない」とカイナは続けた。「獣は、『客がまだ居る最中の早鐘』——その巡りそのものだ。消せはしない。だが、待つ一手で、その一息を、二息に割った。『客がまだ居る』が終わってから、『もう閉じる』が始まる。一息に重ならなければ、獣は、暴れようがない」

ここで、俺は、ふと、訝った。Before との差は、待ち一つ、と言うが——目で見るかぎり、After の式には、Before になかったものが、いくつか足されている。「二度目を聞き流す」やら、「刻限」やら。一手、と言い切って、いいのか。

カイナは、それを、見透かしたように言った。

「お前が今、訝ったろう。『一手じゃない、いくつも足してる』と。——いい目だ。だが、見てみろ。今のこの模擬戦は、客は一人、早鐘は一度きり、その客はやがて上がる。この道では、『二度目を聞き流す』札は、一度も、出番がない。鐘が、二度鳴っていないからだ。『刻限』も、出番がない。客が、ちゃんと上がるからだ。——この二つは、今の道では、何もしていない。倒れを送り切りへ反転させたのは、ただ一つ、戸を立てた後に drain が在るか、無いか。それだけだ。残りの二つは、別の夜にだけ要る、別の備えだ。二度目の鐘が効く様子も、上がらぬ客の刻限も、後で、別の檻で確かめる」

足したものが、この道では何もしていない。だから、差は、待ち一つ。——その理屈は、確かに、通っていた。

待つにも、刻限が要る

俺には、どうしても、引っかかることがあった。職業柄だ。

「だが——客が、いつまでも上がらなかったら? のぼせて、動けなくなった客がいたら? 朝まで、待つのか。夜が、明けちまう」

「待つにも、刻限が要る」とカイナは言った。「上がらぬ客は、穏やかに促して、退いてもらう。力ずくで、湯から引きずり出すんじゃない。『もう仕舞いだ』と、声をかける。——なぜ、際限なく待てないか。お前の湯屋には、お前より強い時計があるからだ」

「俺より、強い時計?」

「夜明けだ」とカイナは言った。「見回りは、夜が明ければ、戸を壊してでも、閉める。その夜明けより前に、お前は、自分の手で、仕舞いを終えねばならん。だから、待つ刻限は、夜明けより、短く切る。外の強制より、内の刻限を、先に効かせる」

刻限の式は、こうだった。客が上がらなくても、刻限が勝てば、式は、無理にでも仕舞いを終える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 客を入れて湯中にする(bath を永遠に resolve しない=いつまでも上がらぬ客)。
const bath = house.admit();
void serveGuest(bath, water);
await Promise.resolve();

// newDeadline に即 reject の刻限を注入。
const rite = new ClosingRite(house, [water, fire], seq, instantDeadline());
const result = await rite.run();

// drain() は永遠に終わらないが、刻限が Promise.race で勝ち forced を返す。
assert.equal(result, "forced");

// forced でも資源は還される(上がらぬ客を促してでも、鍵は還す)。
assert.equal(water.released, true);
assert.equal(fire.released, true);

drain() は、永遠に終わらない。客が、上がらないからだ。だが、Promise.race で、刻限のほうが、先に決着した。式は、"forced"——促しての仕舞い——を返した。プロセスは、ハングしない。そして、forced でも、水脈と火は、ちゃんと還る。上がらぬ客を促してでも、鍵だけは、闇に残さない。

なぜ、刻限で打ち切っても、必ず鍵を還す段に辿り着けるのか。俺は、式の中の、release の置き場所を、目で追った。火と水を還すひと回しは、try の囲いの、外にある。皆を送り切った時(clean)も、刻限で打ち切った時(forced)も、必ず通る場所だ。それに——客を待つ drain は、中で個々の客の約束を握り潰している(b.promise.catch(() => undefined))から、それ自身は、しくじって投げ出すことがない。だから、囲いが拾うしくじりは、刻限のひとつきり。仕舞いが、途中で投げ出される道は、塞いである。

ただ、カイナは、forced の代償を、隠さなかった。「刻限で打ち切ったとき、まだ上がらなかった客は、鍵こそ巻き添えにせずに済む。だが、その客自身は、送り切れていない。湯に浸かったまま、約束を果たせず、残る。clean は、皆を送り切った証だ。forced は——鍵は守ったが、最後の一人は、まだ宙にいる、という証だ。刻限は、万能の救いじゃない。間に合わなかった時の、最後の落とし所だ」

その刻限の砂時計に、カイナは、もう一つ、細工をしていた。

1
2
3
4
5
6
7
8
// 本番の刻限:unref したタイマー。最後の一人になってまで湯屋(巡回路)を起こし続けない。
export function dawnDeadline(ms: number): () => Promise<never> {
  return () =>
    new Promise<never>((_, reject) => {
      const t = setTimeout(() => reject(new Error("刻限切れ:夜明けが来た")), ms);
      if (hasUnref(t)) t.unref(); // 待つ者がいなくなれば、この砂時計はプロセスの自然終了を妨げない
    });
}

「その砂時計が、最後の一人になってまで、湯屋を起こし続けてはいけない」とカイナは言った。「客が皆、上がってしまえば、砂時計は、用済みだ。なのに砂が落ちきるまで、皆を待たせては、本末転倒だろう。だから——待つ者がいなくなれば、砂時計のほうが、先に黙る。unref と呼ぶ。タイマー以外に、することが何もなくなれば、そのタイマーは、もう、終わりを妨げない」

待つ者がなくなれば、砂時計が、先に黙る。湯屋の終いを、急かしも、引き止めもしない。——うまくできている、と俺は思った。

そういえば、と思い当たった。さっきの檻に注いだ刻限は、この本番の砂時計の、模擬戦版だったのだ。清浄系の檻には、永遠に落ちない砂時計(neverDeadline——drain が必ず勝つ)。刻限切れの檻には、即座に落ちる砂時計(instantDeadline——決定的に刻限切れを起こす)。ここでも時の流れは手の内にある。刻限すら手で起こすから、走らせるたび、結末は同じ一点に着く。

1
2
3
4
5
6
7
// 模擬戦の刻限:清浄系は永遠に未解決(drain が必ず勝つ)、刻限系は即 reject。
export function neverDeadline(): () => Promise<never> {
  return () => new Promise<never>(() => {});
}
export function instantDeadline(): () => Promise<never> {
  return () => Promise.reject(new Error("刻限切れ(模擬戦)"));
}

そして、最後の作法——二度目の早鐘だ。

「鐘が、二度鳴ったら?」と俺は訊いた。「早鐘は、続けて鳴ることがある。慌てて、二度、仕舞うのか」

「いや」とカイナは言った。「一度始めた仕舞いを、二度目の鐘で、始め直してはいけない。さもないと、まだ湯にいる客を、二度数えたり、もう還した火を、もう一度還そうとして、事故になる。仕舞いは、一度きり。二度目の合図は、ただ、聞き流す」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 客のいない湯屋で run() を二度呼ぶ(早鐘が二度鳴る)。
const rite = new ClosingRite(house, [water, fire], seq, neverDeadline());
const first = await rite.run();
const second = await rite.run();

assert.equal(first, "clean");
assert.equal(second, "clean"); // 二度目は started===true で即 clean

// 各資源の releaseCount===1(二度還そうとしない)。
assert.equal(water.releaseCount, 1);
assert.equal(fire.releaseCount, 1);

二度目の run() は、started が真だから、すぐに引き返す。仕舞いを、始め直さない。だから、水脈も火も、還そうとされたのは、一度きり。releaseCount は、一だ。

俺は、引っかかった。「だが——今のは、一度目が済んでから、二度目を鳴らした。早鐘ってのは、そんなに行儀よく、待っちゃくれねえ。一度目の仕舞いが、まだ客を待ってる最中に、二度目が鳴ったら、どうなる」

「それでも、同じだ」とカイナは、その問いも、模擬戦にかけた。一度目の run() が、客を待って drain で止まっている——その最中に、二度目の run() を鳴らす。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const first = rite.run(); // 一度目:drain で止まる(まだ bath を resolve しない)
await Promise.resolve();

// 一度目が drain で止まっている最中に、二度目の早鐘(進行中の再入)。
const second = await rite.run();
assert.equal(second, "clean"); // started は同期で立つ→即引き返す
assert.equal(water.released, false); // 二度目は還しを始めない(一度目はまだ drain 中)

// 客が上がれば一度目が送り切ってから還す。還しは、一度きり。
bath.resolve();
assert.equal(await first, "clean");
assert.equal(water.releaseCount, 1);

started の札は、await を一つも挟まず、同期で立つ」とカイナは言った。「だから、一度目が drain で止まっている隙に、二度目が割り込んでも、その時にはもう、札は立っている。二度目は、何も始められず、引き返すしかない。——一度目が終わってからの二度目より、待っている最中の二度目こそ、この札の、本当の出番だ」

カイナは、その二度目を聞き流す札について、ぽつりと、一言だけ付け足した。冪等、という、聞き慣れない言葉を、説きながら。

「一度始まったなら、二度目は、入れるな。……俺が一等最初に締め出した獣も、これと同じ、一枚の札だった」

俺は、その「最初の獣」が何のことか、深くは訊かなかった。カイナの来し方に、軽々しく踏み込むものでもない。ただ、この人は、ずっと前から、同じ札を切ってきたんだな、と、それだけ、思った。

還らなかったものが、落ちる場所

俺には、まだ、聞けていないことが、一つ、残っていた。一番、聞くのが、怖かったことだ。

「あんたは、さっき、『還ってこない』と言った。客が、どこにも居ねえ。出てもいねえ、中にもいねえ。……あれは、どこへ、行っちまうんだ。俺の湯屋から、消えた客は」

カイナは、すぐには、答えなかった。湯屋の隅の、排水口——暗がりへ吸い込まれていく、湯——を、見た。手を止めたまま、声が、低くなった。

「行儀の悪い終端が、残すものだ。送り出されなかった客。還されなかった鍵。途中で断たれた、まだ続くはずだった仕事。——それらが落ちて、二度と返らない場所が、ある。何も返さないから、俺たちは、それを、NULL——虚無——と呼ぶ」

NULL。初めて聞く、その名を、カイナは、続けて、一つずつ、実体に結んでいった。還されなかった鍵は、解放され損ねた資源——開いたままの栓、leaked fd。燻ったままの火は、死なずに残る接続——ゾンビ接続。途中で断たれた仕事は、誰も待たなくなった、宙吊りの約束——dangling promise、果たされも、捨てられもしない、宙ぶらりんの Promise だ。NULL とは、その三つが落ちて、混じり合った場所の、名なのだ、とカイナは言った。

そして、カイナは、初めて、港の、あの大結界に、踏み込んで触れた。

「お前が毎朝、この湯屋で、こぼしかけている、その一杯の虚無。あれと、同じ名のものを、港の大結界は、幾重もの封印で、堰き止めている。行儀悪く終わったものが、ことごとく落ちて、積もり積もった——大きな、大きな、NULL を」

湯気の中にいるのに、うなじのあたりが、すうっと冷えた。

たかが、湯屋のあるじだ。十人かそこらの客を、湯に入れて、送り出すだけの男だ。その俺が、毎朝、早鐘のたびにこぼしかけていたもの——あの、還ってこない一杯——が、港のあの大結界が、幾重もの封印で堰き止めている、大きな虚無と、同じ名のものだったのか。

同時に、うまくは言えないが——妙な、静かな心持ちが、湯気の奥から、立ってきた。俺の、この小さな手仕事が、見たこともない、大きなものと、一本の筋で、繋がっていたような。

「お前の仕舞いの式は、小さい」とカイナは言った。「客は、せいぜい十人だ。だが、作法は、同じだ。皆を送り、鍵を還してから、閉じる。——その作法だけが、一杯の虚無も、大きな虚無も、同じように、堰き止める。港の大結界を保つのも、突き詰めれば、お前が今日覚えた、この終い方と、同じものだ」

カイナが、港の大結界のことを口にするのを、俺は、初めて聞いた。前に一度だけ、港の縁で、結界のほうを向いて立つ、後ろ姿を、遠くから見かけたことがある。あの時は、ただ、景色でも見ているのかと思った。——今、思う。あれは、毎朝、これと同じ虚無を、堰き止めている背中だったのかもしれない。

カイナは、それ以上は、語らなかった。排水口の暗がりを、見たまま、しばらく、動かなかった。

すべての模擬戦を、通す

カイナは、組んだ檻を、最初から最後まで、通して走らせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ node --test
▶ Before: shutdownNow(早鐘ですぐ水脈と火を還す=順序違反)
  ✔ 1【順序違反=湯客が倒れる】客がまだ湯にいるのに、水脈はもう還される→客は決して送り出せない
  ✔ 2【却下案・即抜け=漏れと置き去り】justExit は客を倒さないが、資源は漏れ客は置き去りになる
▶ After: ClosingRite.run(皆の上がりを待ってから還す=Graceful Shutdown)
  ✔ 3【送り切る・単一差分の核】Beforeと寸分同じ入力で、客は倒れず送り切れる
  ✔ 4【冪等=二度目の早鐘】二度目の run() は仕舞いを始め直さず、各資源は一度しか還されない
  ✔ 5【刻限=上がらぬ客】drainが終わらなくても刻限がPromise.raceで勝ち、forcedでも資源は還る
  ✔ 6【冪等・進行中の再入】一度目がdrainで止まっている最中の二度目も、始め直さない
ℹ tests 6
ℹ pass 6
ℹ fail 0

六つの模擬戦は、どれも緑だった。一つも、倒れなかった。

Before の早鐘は、客がまだ湯にいるのに、水脈を還して、客を倒した。何も還さず抜ければ、客は倒れぬ代わりに、栓は開き、火は燻り、客は宙に取り残された。After の式は、同じ早鐘を違えず流しても、客が皆上がるのを待ってから還し、客を『良い湯でした』と送り出した。二度目の鐘は聞き流し、上がらぬ客には、夜明けより短い刻限を切って、促してでも、鍵を還した。

唯一、挟んだのは、「客が皆、上がるのを待つ」一手。それが、倒れも、漏れと置き去りも、二度還しも、際限ない待ちも、まとめて防いだ。客の入浴も、上がり湯も、湯屋も、何一つ、変えていない。

仕舞いの式が、引き受けないもの

カイナは、最後に、この式が、引き受けないもの——守らないこと、誤って使えば仇になることを、一つずつ、線引きした。

「まず、刻限を、どれだけにするか。これは、式の中には、書いていない。短く切りすぎれば、まだ上がれた客を、追い出す。長く取りすぎれば、夜明けに、間に合わない。どれだけ待つかは、客の長湯の実績から、逆算するしかない。式は、待ち方を決めるが、待ち時間までは、決めてくれん」

「次に」とカイナは続けた。「お前の湯屋で客を送り切っても、その客が次に向かう先——隣の宿、表の通り——が、行儀悪く閉じれば、そこで、また虚無が出る。終い方は、連なる先まで貫いて、初めて効く。お前一人が綺麗に閉じても、繋がる先が崩れれば、虚無は、残る」

「それから、上がらぬ客を促す、その合図だ」。カイナは、言葉を選んだ。「力ずくで、湯から引きずり出すんじゃない。『もう仕舞いだ』と、合図を送って、自分から上がってもらう。進行中の仕事に、中断の合図を伝える符牒——前に診た、鎖を断つ符牒と、同じものだ。刻限が来たら、ただ待つだけでなく、能動的に、合図を送れる。だが、それは、待ちの代わりじゃない。待ちの、補いだ」

「最後に、二度目を聞き流す札——冪等は、終いだけの話じゃない」とカイナは言った。「二度効いては困る合図、すべてに要る。終いの獣も、始まりの獣も、これで締め出す」

俺は、その線引きを、一つずつ、受け取った。情の語で、思わず、こぼれた。

「客のことだけ考えてりゃいい、ってわけじゃ、ねえんだな」

「ああ」とカイナは言った。「だが、その客を、一人残らず送り切る——それが、根っこだ。根っこを、お前は、もう掴んでいる」

清く、空

カイナは、暖簾を、くぐって出ていった。俺は、見送りもそこそこに、夜明けの仕舞いに、取りかかった。今度は、慌てなかった。

明け方、街に、また早鐘が鳴った。見回りの、戸締まり令だ。だが、もう、俺は、慌てない。

暖簾を下ろし、新規を断つ。湯の客が、一人、また一人、自分の足で上がって、「良い湯でした」と、帰っていく。その背中を、一つずつ、見送る。最後の一人が、暖簾を出ていくのを、待つ。それから——湯を抜き、火を還し、湯札を、番台に、戻す。順に。一つずつ。慌てず、抜かりなく。

湯屋が、空になる。

だが——あの、急いで閉めたあとの、空とは、違った。客は皆、自分の足で帰った。火は還り、栓は閉じ、湯札は、一枚残らず、番台に戻っている。排水口の暗がりへ——何も、落ちていかない。澄んだ湯が、最後に、すうっと流れて、消える。還ってこないものが、一つも、ない。

清く、空だ。

訪れた客を、一人残らず、気持ちよく帰す。それが、俺の信条だった。長年、そう思って、やってきた。だが、本当にそれを全うできたのは——皆の上がりを、待つ作法を、知った、今日が、初めてだった。

肝は、たった一手だった。皆が上がるのを、待つ。それだけで、終いは、こんなにも、静かになる。カイナが言っていた、最初の獣の話が、今は、少しだけ、分かる気がした。


終い方を識る者

暖簾を出ると、夜明けの匂いがした。

あの湯守は、たいしたものだ。十人かそこらの客を湯に入れて、送り出すだけの仕事で、知らぬ間に、いちばん大きな結界の理に触れていた。皆を還してから、閉じる。たったそれだけの作法が、小さな湯屋も、港の大結界も、同じように虚無から守る。人は俺を「終い方を識る者」と呼ぶ。だが本当のことを言えば、俺自身、その終い方の最後の一片を、今朝あの小さな湯屋で、ようやく見届けたのだ。

下町を抜けて、港へ降りた。

潮の匂いの先に、それはある。港を丸ごと囲う、幾重もの封印。俺が護る、古い大結界だ。一枚岩に見えて、近づけば無数の仕切りで、いくつもの区画に分かれている。堅牢なものが堅牢なのは、一枚岩だからではない。区画で、仕切られているからだ——あの船の機関長に、俺が言ったのと、同じことだ。

結界の前に立つと、低い軋みが、足の裏から伝わってきた。

この軋みを、王宮の術師も、迷宮の中継係も、遠くに聞いていた。皆、別の獣を診ている最中に、ふと地の底のほうから聞こえる、と言った。正体を、俺は知っている。この封印の奥で堰き止められているものの、圧だ。行儀悪く終わったものが——送り出されなかった客、還されなかった鍵、途中で断たれた仕事が——ことごとく落ちて、積もりに積もった、大きな、大きな虚無。NULL。それが、押している。

封印の、いちばん古い印に、指を当てた。俺がここを継いだとき、この印を刻んだ手は、もうなかった。継いだ時の俺は、まだ、終い方を知らなかった。だから、旅に出た。

旅で出会った獣を、一頭ずつ、この結界に重ねていく。双頭の犬の首を繋いだ、一本の鎖。二つの釜の睨み合いを解いた、鍵を拾う順。倒れた相手への鎖を断つ、封印の自動結界。魔力の槽を機能ごとに隔てる、区画。——十二の獣、十二の作法。どれ一つ欠けても、この結界は、どこかから漏れる。そして最後まで欠けていた一片が、今朝の、あれだった。皆を還してから、己を閉じる。終い方だ。

NULLは、滅ぼせない。虚無を、剣で斬ることはできん。できるのは、ただ——落ちてくるものを、一つずつ、正しく終わらせ続けることだけだ。行儀よく終わったものは、虚無に落ちない。落ちなければ、積もらない。積もらなければ、この封印は、保つ。

俺は、最後の作法を、古い印に編み込んだ。

軋みが、少し、引いた。消えはしない。消すものではないからだ。今日もまた、落ちかけたものを一つ、正しく還した。明日も、明後日も、そうする。終わらせ続ける巡りを、一つ、整え直したのだ。

——思えば、可笑しなものだ。

俺がいちばん最初に締め出した獣は、二度目の詠唱を撥ねる、一枚の札だった。そして今朝、最後に覚えた作法も、二度目の早鐘を聞き流す、同じ一枚の札だった。入口で締め出すものと、出口で聞き流すもの。違う獣だと思っていた二頭は、裏返しの、同じ札だったのだ。

十二の旅は、これで、一巡した。

夜が明ける。港の大結界は、今日も、誰にも知られず、虚無を堰き止めている。それでいい。——あの湯守が、客を一人残らず還したことを、客自身は、ついぞ知らないように。


📜 カイナの魔獣契約録(Tamer’s Registry)

  • 魔獣名(クラス/パターン名): 終いを急ぐ幕引きの獣(行儀の悪い終端=強制切断・リソースリーク)/その残す虚無=NULL。契約=皆を還してから己を閉じる仕舞いの式(Graceful Shutdown=新規受付を止め、処理中の完了を待ち〔drain〕、資源を順に還してから終了する/二度目の合図は聞き流し〔冪等〕、待ちには刻限〔timeout〕を切る)
  • 危険度(難易度/バグの影響度): ★★★★★(プロセス終了時のみ露出し、平時には絶対に顕れない=客のいない空の湯屋でいくら試しても再現しない。ログにも例外にも残りにくい。一度の発動で、in-flight の喪失〔客が倒れる〕・資源リーク〔栓が開いたまま〕・ゾンビ接続〔火が燻ったまま〕・未コミット処理の喪失〔途中の仕事が無かったことに〕を同時に起こす。デプロイ・スケールイン・クラッシュなど、外部要因で不可避に発動する。シリーズの深淵 NULL の正体そのもの)
  • 主な生態(アンチパターンの特徴):
    • 終了シグナル(早鐘)受信時に、新規受付の停止・処理中の完了待ち・資源解放を伴わず、即座にプロセスを終える(process.exit(0) 直接呼び出し相当/シグナル無処理でのデフォルト強制終了)
    • 「客がまだ居る」(in-flight)のに「もう閉じる」(資源解放)を同じ一息でやる順序違反——処理中ハンドラがクローズ済み資源へ触れて倒れる(上がり湯を断たれた客)
    • 何も還さず抜ければ、資源はリークし(開いた栓=leaked fd)、接続は死なず残り(燻る火=ゾンビ接続)、処理は宙吊りのまま誰も待たなくなる(dangling promise)=還ってこない=NULL。なお塞ぐ場所は別レイヤ——前二者は資源ハンドルの解放漏れ(Conduit.release 側)、宙吊りの約束は完了しないハンドラ(serveGuest の未解決 bath.promise 側=刻限切れで送り切れなかった客そのもの)。落ちる先(NULL)は同じでも、塞ぐ手は別
    • 害は終了時のタイミング依存で、客のいない空の状態では決して再現しない=平時のテストをすり抜ける
  • 契約のポイント(設計の要点):
    • 1:1 単一差分=火と水を還す前に「皆の上がりを待つ一手(drain)」を挟む。stopAdmitting()→release に対し、stopAdmitting()→drain()→release とするだけ。資源・ハンドラ・サーバは不変。冪等ガード・刻限は同入力経路では no-op(鐘は一度・客は上がる)ゆえ、倒れ→送り切りの反転に効くのは drain 一手のみ
    • 論理的保証:客の上がり(bath.promise の resolve)と鍵の還し(release)の競合を、drain()await が「上がりが先、還しが後」と直列化する。並行のタイミング(どちらが先に走るか)は実行時の値ゆえ型では守れず、実行時設計(待ちの一手)で守る。一方で受付の可否(admit(): Deferred<void> | null)や仕舞いの結末(run(): Promise<"clean" | "forced">)は型で守っている——型で守れないのは「どちらが先か」の一点だけ、という二層構造
    • 二度目の合図は聞き流す(冪等ガード started)——二重実行で「もう還した資源を再度還す」事故を防ぐ
    • 待ちには刻限(Promise.race で drain と競わせる)。上がらぬ客は促して退かせ、"forced" でも資源は還す。刻限は外側の強制(夜明け=SIGKILL/terminationGracePeriodSeconds)より短く切る。刻限タイマーは unref し、待つ者が無くなれば自然終了を妨げない
    • 還す順序:新規停止→drain→資源(水脈→火)を逐次。逆順(資源を先に還す)は順序違反を招く
  • 契約外事項(保証しないこと):
    • 刻限の見積もり(sizing):短すぎれば上がれる客を追い出し、長すぎれば外の強制に間に合わない。過去の負荷実績からの逆算が要る
    • 下流の虚無:自プロセスを綺麗に閉じても、連なる下流(次のサービス・共有資源)が行儀悪く閉じればそこで虚無が出る。終い方は下流まで貫いて初めて効く
    • 断つ符牒(AbortController)との併用:刻限到達時、待つだけでなく AbortSignal で進行中処理へ中断の合図を能動的に送れる(待ちの補完であって代替ではない)
    • 冪等は終端専用ではない:二度効いては困るあらゆる合図に要る汎用の札
  • 現在のステータス: 🟢 火と水を還す前に「皆の上がりを待つ一手(drain)」を挟み、二度目の早鐘は聞き流し(冪等)、待ちには夜明けより短い刻限を切る仕舞いの式の契約成立(客を送り切ってから鍵を還す/倒れ・漏れ・置き去り・二度還しを単一差分で封じる)。刻限の見積もり・下流の虚無・断つ符牒の併用は契約外として残置。——アーク「堅牢な結界」、締めくくり。小さな湯屋の終い方が、大結界の堰き止める深淵 NULL を退ける作法と、同じものだった
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。