魔力が漏れる夕暮れ
その魔物使いを呼んだのは、僕自身の名でも、誰かの紹介でもなかった。図書館の本が、彼女を呼んだ。
僕は、この魔法図書館の司書をしている。半年前、利用者のために検索術を組んだ。閲覧机に手をかざして探したい言葉を念じれば、書庫の奥から探索妖精が頁を一枚ずつ手繰り、見つけた端から机へ結果を流す。打ち込むそばから候補がせり上がってくる、あの感触が評判になった。みんな喜んでくれた。僕も、誇らしかった。
ただ、夕暮れになると、図書館が痩せる。
朝は軽い。昼を過ぎると、検索の応答がわずかに遅れる。日が傾くほど、通路の魔力灯——壁に埋めた魔力結晶の灯り——が目に見えて翳り、夜には、念じても結果が返ってこない。図書館全体の稼働魔力が、どこかへ漏れていく。一日かけて、確かに漏れている。なのに、どこから漏れているのか、僕には突き止められなかった。
カイナの名は、封じ書架——危険な術が綴られた古い蔵書を隔離した一角——の、一冊の奥付で見つけた。何年も前、この図書館で暴れた別の魔獣を鎮めた記録。署名入りの契約録が、本に挟まれたまま残っていた。藁にもすがる思いで、僕はその名を頼った。来てくれたこと自体が、まだ信じられない。
カイナは会釈ひとつで閲覧机に向かい、一度検索し、すぐ別の言葉に打ち直した。また打ち直した。それを何度か繰り返し、翳りはじめた魔力灯へ目をやって、低く言った。「これは、一度叩いて出るものじゃない。出るまで、積む」。
閉じたのは、机の上だけ
僕は、自分の組んだ術式(コード)を巻物に写して差し出した。利用者が言葉を打ち直すたび、机の上の結果は新しいものに差し替わる。閉じたように見える。だから僕は、ずっと「ちゃんと閉じている」と思っていた。
| |
「妖精は、last の頁にたどり着くまで潜り続けます」と僕は説明した。「書庫の鍵を一本握って、一枚ずつ手繰って、最後まで読み切ったら、鍵を返す(finally)。普通の術です。どこにも、おかしいところは……」
カイナは巻物を最後まで目で追うと、口を開く代わりに、指を上げた。閲覧机ではなく、書架の奥の暗がり——もう誰も覗いていないはずの方角——を、まっすぐ指した。そこには、細い光の糸が、まだ机へ向かって流れ込んでいるように、僕には見えた。
「お前が片付けたのは、机の上だけだ」と彼女は言った。「机の下では、まだ誰かが書庫を潜っている」。
意味が掴めなかった。打ち直せば、前の検索は消える。消えたものが、どうして潜り続けるというのか。
残影を積む
カイナは書架の間に、模擬戦の仕掛けを組みはじめた。書庫への問い合わせ(query)、机への書き戻し(onResult)、書庫の鍵(cursor)を、外から握れる作り物に差し替える。妖精が「いつ頁を手繰り終えるか」を、こちらの手で止めたり進めたりできるようにするためだ。鍵については、今いくつ握られたままかを数えられるようにした。以下、握られている鍵の数を live()、書庫の頁を一枚だけ返す手を give()、止まった妖精をほんの一歩——マイクロタスクを一段——だけ進める小道具を tick() と書く。
「一度叩いても、漏れは出ない」とカイナは言った。「だから、積む」。
彼女はまず検索「ドラゴン」を起こした。妖精が頁0を手繰り、結果が机へ。そこで——利用者がそうするように——打ち直した。検索「ドラゴンの巣」。前の検索は閉じたはず。だが古い妖精を止める合図は、どこにもない。古い妖精は、頁1へ、頁2へと、なおも潜っていく。
カイナは鍵を数えた。一本。打ち直す。二本。古い妖精が握った鍵が、書庫に刺さったまま、戻ってこない。さらに打ち直す。三本、四本。検索を切り替えるたび、見捨てられた妖精が一頭ずつ増え、それぞれが鍵を握り、書庫の底で頁を手繰り続ける。僕は、刺さったまま増えていく鍵を、ただ見ていた。これが——夕暮れにかけて、図書館から魔力が抜けていく、その速さだった。
| |
「live() が、二本」とカイナ。「片付けたはずの検索Aが、まだ鍵を握っている。これが、お前の言う"漏れ"だ」。使い終えたものが解放されず、握ったまま積もって、やがて図書館を枯らす——これをメモリリーク(魔力の継続流出)という。鍵だけじゃない。見捨てた妖精は、もう誰も見ていない机へ、見つけた結果を流し続ける。閉じた画面へ、古い答えを書き込む。
帰し方を、決めていなかった
「閉じたのに」と僕は言った。「なぜ妖精は、まだ潜るんですか」。
カイナは書見台の古い図譜を引き寄せ、一頁をひらいて机に伏せた。輪郭の曖昧な、霧でできた妖の絵だった。「ファントム。残影の霧妖だ」と彼女は言った。「実体を消しても、残影は残る。お前は机を片付けた。だが、机の下に潜らせた妖精に、戻れと言っていない」。
——呼んだのに。帰し方を、決めていなかった。僕は、自分の作った機能の形を、初めて裏側から見た気がした。
カイナは、こんどは術の理屈そのものを、低く、淡々と続けた。聞き取れた範囲で書き留めておく。「この妖精——async の関数だ——には、中断の合図が一本も通っていない。while は last が立つまで回り続ける。打ち直しは新しい妖精を呼ぶだけで、古い妖精には、何も届かない」。
僕は、鍵の刺さった書庫を見た。確かに、古い妖精へ「やめろ」と告げる線は、どこにも引かれていない。
「型(コンパイル時の検め)は、これを守れない」とカイナは続けた。「型は『鍵を二度握る』ような、状態の矛盾なら弾ける。だが『誰も見ていない処理が、裏で生き続ける』のは、時間の問題だ。型には見えない。これは実行時の設計——合図で締めるしかない」。
書庫に渡しただけでは
合図、と僕は繰り返した。聞いたことはある。AbortController。確か、fetch のような外部の問い合わせに signal を渡しておけば、中断できる仕組みだ。AbortController(鎖を断つ符牒)とは、進行中の処理を中断するための取り決めで、受け手が合図を監視して初めて効く。
「なら」と僕は、自分から踏み込んだ。「書庫への問い合わせに、signal を渡せばいいんじゃないですか」。
カイナは止めなかった。僕の言うとおりに、暫定の鎖を組んだ。query にだけ signal を渡す。
| |
模擬戦を回す。潜行(query 待ち)の最中に打ち直すと、確かに、その潜行は弾けた。「ほら、中断できて……」と言いかけて、僕は鍵を数えた。
まだ、刺さっている。
「妖精が、戻ってくるまで」とカイナ。「合図を書庫に渡しただけだ。妖精自身は、潜ったまま、手が塞がっている。手が戻るまで、鍵は握られたままだ」。彼女は、僕の組んだ暫定の一行を指した。「これが『渡したつもりで、見ていない』だ。signal は、受け手が自分の手で確かめて初めて効く。妖精のループは、まだ一度も合図を見ていない」。
——渡すことと、見ることは、違う。打ち直しが頻繁で、書庫が深いほど、この「手が戻るまで鍵を握る」妖精が積み重なる。漏れは細るが、止まらない。そもそも、その「手が戻るまで」がどれだけ長いかを決めるのは、妖精ではなく、潜った先の書庫だ。律儀に合図を聞いて即座に潜行を投げ返す相手なら、手はすぐ戻る。だが、合図を聞かない——あるいは応えるのが遅い——相手に潜れば、鍵はその分だけ握られたままになる。こちらの都合では、縮められない。
「じゃあ」と僕は、半分見えかけた像をたぐった。「潜っている最中でも、鍵を手放させるには——どうすれば」。
三つの刻に、一本の鎖
カイナの施した手当ては、驚くほど慎ましいものだった。妖精を組み直すでも、鎖を何本も増やすでもない。彼女は合図を一本、妖精の通り道の三つの刻に通しただけだった。
| |
違いは、ただ一点きり。signal が通っているか否か。鍵を握り、頁を手繰り、机へ流し、最後に鍵を返す——妖精のすることは何も変わっていない。変わったのは、その妖精が、合図を見るようになったことだけだ。
カイナは三つの刻を、順に指でなぞった。signal.throwIfAborted()——中断済みなら即座に例外を投げて処理を脱出させる一行だ——を、潜る前に置く。「見捨てられていれば、次の頁へは潜らない」。同じ一行を、潜って戻った直後にもう一度置く。「戻った刻に既に見捨てられていれば、古い結果を机へ載せない」。中断したはずの処理が後から完了し、消えた画面を古い答えで上書きする事故——これを破壊的な遅延更新という——を、この一行が封じる。
自分の手で signal.aborted を確かめて回ってもいい、とカイナは付け加えた。「だが await を一つ跨ぐたびに、型の検めは、さっき確かめたことを忘れる。戻ってきた妖精に、もう一度『お前は中断済みか』と型へ教え直す手間が要る。投げて降りる throwIfAborted なら、確かめと脱出が一行で済んで、取りこぼしがない」。
「だが、それだけでは、潜っている最中の妖精は止まらない」と彼女は、暫定で僕が数えた鍵のことを言った。「throwIfAborted は、妖精の手が戻った刻にしか効かない。潜ったまま手が塞がっていれば、見るに見られん」。だから三つめ。合図そのものに、「鍵を手放す」所作を結びつける。signal.addEventListener("abort", ...) だ。「これで、妖精が書庫に潜ったまま見捨てられても、手の戻りを待たず、合図が直に鍵を抜く」。使い終えた鍵をその場で書庫から抜くこと、これを送還という。{ once: true } を添えれば、一度使った見張りの符牒も、そのまま消える——見張り自体が残って漏れることもない。
僕は、三つの一行を見比べて、別々の鎖が三本あるように見えた。そう言うと、カイナは小さく首を振った。
「三つに見えるが、鎖は一本だ。『中断の合図を honor する』——それを、潜る前と、戻った後と、潜っている最中の、三つの刻に通しただけだ」。中断は、こちらが命じれば強制的に止まる類のものではない。受け手が合図を見て、自ら降りる。これを協調的キャンセルという、とカイナは言い添えた。「合図を honor する、というのは、受け手の側で、自分の手で合図を確かめることだ。三つの刻のどれを抜いても、その刻だけ、妖精は合図を見ない」。
呼び出す側は、検索のたびに合図の親(AbortController)を一つ握り、打ち直すときに前のものへ abort() を送る。
| |
「AbortController は使い捨てだ」とカイナ。「一度断った合図は、元に戻らない。打ち直すたび、新しい合図を渡す」。中断で投げられる例外は、err.name === "AbortError" で見分ける。検索の打ち直しは、書庫の不調ではなく、こちらの都合での幕引きだから、ここで静かに飲み込んでおく。
鍵が、即座に戻る
カイナは、先ほどそっくりそのままの手順を、こんどは合図の通った妖精へ仕掛けた。検索Aを起こし、頁0を流させ、打ち直しの代わりにAの合図へ abort() を送る。
| |
今度は、鍵が即座に戻った。live() が、abort() の直後に0へ落ちる。abort() は、合図を見張る所作(リスナ)を、その場で——await を一つも挟まない、同じ手番のうちに——呼ぶからだ。だから妖精がまだ書庫に潜ったまま、query の答えを待っている最中でも、送還の所作が、手の戻りを待たずに鍵を抜く。このとき、リスナと、throwIfAborted が投げた例外でたどり着く finally の、両方が鍵を抜きにかかるが、送還は冪等にしてあるので、二度抜いても数を二重には減らさない。暫定で僕が数えた「刺さったままの鍵」が、ここでは一本も残らない。
僕は、もう一つ気になっていたことを訊いた。潜って戻ってきた、ちょうどその瞬間に、もう見捨てられていたら。古い結果は、机に載ってしまわないのか。
カイナは、頁を戻してから合図を送る模擬戦を足した。
| |
机は、空のままだった。頁は確かに戻ってきた。だが妖精は、机へ載せる前に、もう一度合図を見た(await の直後の throwIfAborted)。見捨てられていると分かったから、古い結果を置かずに、そのまま降りた。消えた机に、古い答えが書き込まれることはない。頁を返す(give)のは、妖精の続きを"あとで動かす予定"として列(マイクロタスク)に並べるだけだ。だから、その直後に同じ手番で送る abort() が、続きより先に確定する。仮に順序が逆で、続きが先に走り切って机へ載ったとしても、それは中断より前の、正当な書き込みだ。どちらが先でも、消えた机に古い答えが残ることはない。
そして、中断がなければ——普通に最後まで検索すれば——妖精は今までどおり、全部の頁を机へ流し、鍵を返して、何事もなく終わる。合図を通したことで、正常なときの振る舞いは、一切変わっていない。

僕が描いた術の流れは、書庫の底の惨状をまざまざと示していた。机の上を綺麗に片付けたつもりでも、書庫の暗がりでは見捨てられた妖精たちが鍵を握りしめたまま、呪文のように潜り続けている。
「これでは、打ち直すたびに図書館の魔力が痩せるわけです……」 「そうだ。合図がなければ、古い残影を断つ術がない」カイナは、もう一枚の図式を取り出して重ねた。「だが、合図が通った世界では、妖精の去り際はこう変わる」。

「打ち直した瞬間に、鍵が手放されている……!」
「ああ。abort() の合図が届いた瞬間、潜行の最中であろうとも即座に鍵が抜かれる。古い妖精は未練を残さず消え去り、書庫に残る鍵は常に最新の『一本』だけになる」
図の通り、合図のない世界では、見捨てた検索Aが裏で潜り続け、鍵を握ったまま、Bと並んで魔力を啜る。合図を三つの刻に通すと、Aは打ち直しの瞬間に降り、鍵を手放す。残るのは、最新のBの一本だけになる。
合図を見ない者には、届かない
「これで」と僕は、最後に確かめたかった。「魔力の漏れは、止まりますか。完全に」。
カイナは、答えの代わりに、合図の鎖を一本、机に置いた。そうしてから、ようやく口を開いた。
「この契約が止めるのは『見捨てた処理が、裏で生き続ける』漏れだ。妖精が合図を honor している限り、打ち直せば、即座に降りる。それは確かにやった」。彼女は指を立てた。
「だが、合図を見ない者は、この鎖では縛れない。お前の妖精ではなく、書庫の奥の、合図を honor しない古い術式に潜らせれば、それは戻らない。中断は強制じゃない。受け手が合図を見て、自分から降りる取り決めだ。見ない相手には、届かない」。
「それから」と彼女は、もう一つ釘を刺した。中断と時限を一つの符牒で束ねる便利な道具(AbortSignal.any())もあるが、書庫の精霊(Node)の側に、それがかえって魔力を漏らす不具合や、時限が効かない癖が残っている。「期限だけが要るなら、時限の符牒(AbortSignal.timeout())を、束ねず直に使え。束ねる道具は、今はまだ目を離すな」。
そして、最後に。「書庫が嵐——一時の不調で答えを返せない刻、妖精をすぐまた潜らせれば、書庫はますます応えなくなる。失敗したときに『どれだけ待って、もう一度放つか』。それは、中断とは別の獣だ」。彼女は翳りかけた魔力灯を見上げた。「焦って羽ばたく、雷鳥の話になる」。
honor される処理と、honor されぬ相手。その二つが、はっきりと分かれて見えた。僕が今日授かったのは、万能の符牒じゃない。だが、合図さえ隅々まで通る限り、夕暮れの図書館は、もう痩せない。
通し稽古
カイナは、暴れる検索と手懐けた検索の、両方の模擬戦を、まとめて走らせた。漏れる方には「鍵が積もる」ことを、手懐けた方には「即座に戻る」ことを、それぞれ記録として残してある。
| |
通し稽古、合格。漏れの再現も、その封じも、これからはこの模擬戦が見張り続ける。次に誰かが検索術へ手を入れても、合図を honor し損ねれば、鍵が積もって、檻の中で捕まる。
僕が魔力灯を見上げると、夕暮れなのに、翳っていない。澱んでいた検索が、また打ち込むそばから流れていく。図書館が、息を吹き返していた。
「嵐が来て書庫が黙り込んだとき、焦って妖精を放ち続けたら、もっと応えなくなる」と、僕は声に出して確かめた。作ることだけでなく、畳むところまで考えながら言うのは、初めてだった。「次はきっと、その待ち方の話ですね」。
カイナは、答えを引き取らなかった。代わりに、今宵の契約を書きつけた一枚を、僕の手ではなく、封じ書架の古い一冊へ——彼女自身の署名が眠る、あの本へ——静かに挟み直した。「ここへ戻しておけ。次に困った司書が、お前のように見つける」。
霧の晴れた書架の間に、灯りが戻っていた。
📜 カイナの魔獣契約録(Tamer’s Registry)
- 魔獣名(クラス/パターン名): ファントム(残影の霧妖)/ メモリリーク・破壊的な遅延更新(
AbortControllerで馴致) - 危険度(難易度/バグの影響度): ★★★★☆(一回では露見しない。打ち直し・画面遷移が積もる現場で、じわじわ魔力=メモリと接続を握りつぶす)
- 主な生態(アンチパターンの特徴):
- 探索ループに中断の合図(
AbortSignal)が一本も通っておらず、whileはlastまで回り続ける - 打ち直し(再検索)で見捨てられても、古い妖精は鍵(カーソル)を握ったまま書庫を潜り続け、消えた机へ古い結果を書き戻す
queryにだけsignalを渡しても、ループ自身がthrowIfAbortedで見ていなければ「渡したつもりで honor していない」=手が戻るまで鍵が残る
- 探索ループに中断の合図(
- 契約のポイント(設計の要点):
- 合図を三つの刻に honor させる単一契約。(a) 潜行前
throwIfAborted=次の潜行を出さない、(b)await直後throwIfAborted=古い結果を机へ載せない(遅延更新の封じ)、(c)addEventListener("abort", release, {once:true})=待機中でも即送還 - 変えたのは「合図が通っているか」1点のみ。鍵取得→ループ→
finally送還のロジックは Before と同一。送還は冪等で、abort 時の即送還とfinally送還が重なっても安全 - 同時保持カーソル数を模擬戦の物差しに据え、測りにくいリークを決定的に可視化した
- 合図を三つの刻に honor させる単一契約。(a) 潜行前
- 契約外事項(保証しないこと):
- 協調的キャンセルゆえ、合図を honor しない処理(受け手が見ない第三者の術式)は止められない
AbortControllerは使い捨て(abort後は再利用不可、打ち直しは新調)- 一時障害からの賢い再送(待って放つ Retry/Backoff)は別の獣=サンダーバードの領分
- 中断と時限を束ねる
AbortSignal.any()は Node 側に既知の不具合があり、時限のみならAbortSignal.timeout()を直に使う
- 現在のステータス: 🟢 残影を送還(合図が隅々まで通る限り)/合図を見ぬ術式は別領分・嵐への再送は後日
