Featured image of post コードテイマー【Race Condition】連打が荷を次元の狭間へ送った夜〜実行中フラグで、二度目の詠唱を締め出す〜

コードテイマー【Race Condition】連打が荷を次元の狭間へ送った夜〜実行中フラグで、二度目の詠唱を締め出す〜

祭りの夜、住民が転送印を連打すると同じ荷が二度送られ、片方が次元の狭間へ消えた。鍵は実行中フラグを置く"位置"だった。awaitの中断点に滑り込む二重送信を、再入ガードで締め出すTypeScriptの契約を、模擬戦で検証する物語。

連打の夜

その魔物使いの工房は、街外れの石塀の奥にあった。

紹介してくれたのは、前職で世話になった同僚だ。彼の現場でも一度、術式(コード)が手に負えなくなったことがあって、そのとき呼んだのが「カイナ」という女だったらしい。「力でねじ伏せるんじゃない。術式の生態を、しばらく黙って見てから手を入れる。変わった魔物使いだよ」——半信半疑のまま、私はその名刺の住所を頼りに、夜の石畳を歩いてきた。

私は、街の転送陣の管理人をしている。前任者から引き継いだ古い術式だ。住民が荷を台に載せ、転送印に手をかざせば、荷は遠くの受け取り所へ飛ぶ。日々の暮らしを支える、ごくありふれた魔法だった。先月までは。

私は、この陣を、ささやかに誇っていた。朝いちばんに転送印の魔力を検め、台と受け取り所の符牒を合わせ、その日の荷の流れを整える。派手な術じゃない。だが、誰かの荷を、確かに、向こう側へ渡す。それだけのことを、何年も、抜かりなくやってきた。——その自負が、ある祭りの夜に、崩れた。

重い扉を押すと、低いランタンの灯りと、羊皮紙の匂いがした。壁には魔獣の輪郭を写した契約録がいくつも貼られ、卓には符牒と、細い鎖が幾本か。灯りの届かない壁の上のほうで、契約録の獣たちが、いくつもの目で、こちらを見下ろしているようだった。物音ひとつしない、しんとした奥行きだ。場違いなところへ来てしまった、と思った。奥から現れたカイナは、あいさつもそこそこに、私の手元の巻物(ソースコード)へ目をやった。

「祭りの夜だけ、荷が割れるんです」と私は切り出した。泣きつくつもりはなかった。手は打ってある。それを先に言いたかった。

すでに、鎖はかけてある

ことの起こりは、先々週の収穫祭だった。受け取り所から苦情が殺到した。同じ荷が二度届く。あるいは、片方がどこにも届かず、台帳にだけ二件の転送記録が残って、荷の実体は次元の狭間——どこへ消えたとも知れぬ虚空——へ落ちていた。住民が祭りの高揚で転送印を二度、三度と連打したのが引き金らしい、とそこまでは私にも分かった。

苦情の山の中に、忘れられない一通があった。嫁入りの祝いに贈るはずだった反物が、二重に送られ、片方が次元の狭間へ落ちた、という。受け取り所の台帳には、確かに二件の転送が記されている。なのに、届いた現物は、片方きり。私は、台帳の記録と、手元に残った控えを、何度も照らした。記録は、嘘をついていない。荷だけが、消えていた。記したものと、現にあるものが食い違う——その薄ら寒さを、私はその朝、初めて知った。住民の荷を、虚空へ落とした。管理人になって、初めてのことだった。その重さは、まだ、手のひらに残っている。

だから私は、術式に鎖を一本縫い込んだ。「転送中は、次を受け付けない」。実行中であることを示す札(フラグ)を立て、立っている間は弾く。理屈は通っているはずだった。

 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
type Cargo = { id: string; destination: string };
type Receipt = { transferId: string };

type Validate = (cargo: Cargo) => Promise<void>;
type Send = (cargo: Cargo) => Promise<Receipt>;

class TransferGate {
  private busy = false;
  private validate: Validate;
  private send: Send;

  constructor(validate: Validate, send: Send) {
    this.validate = validate;
    this.send = send;
  }

  async dispatch(cargo: Cargo): Promise<Receipt | undefined> {
    if (this.busy) return;        // 札を見る(この時点では false のまま)
    await this.validate(cargo);   // 荷を検める
    this.busy = true;             // 検め終えたら「転送中」の札を立てる
    try {
      return await this.send(cargo);
    } finally {
      this.busy = false;          // 終わったら札を下ろす
    }
  }
}

if (this.busy) return で弾いています」私は巻物の一行を指でなぞった。我ながら、不安そうな手つきだったと思う。「なのに、祭りの夜だけ、すり抜ける。平日は何ともない。連打が殺到する日だけ、二頭の荷が走るんです」

カイナは巻物を受け取り、最初から最後まで、ゆっくり目を走らせた。すぐには何も言わなかった。やがて、this.busy = true の行を指の背で軽く叩いて、低い声で言った。

「お前のかけた鎖は、確かに鎖だ。——だが、掛ける"刻"を見ていない」

刻、と私は繰り返した。意味が掴めなかった。札は立てている。弾いてもいる。何が遅いというのか。

「言葉で説明するより、見せたほうが早い」カイナは卓の鎖を一本、手に取った。「俺が、祭りの夜をこの檻の中で起こす」

模擬戦——祭りの夜を、檻の中で起こす

カイナのいう檻とは、模擬戦——つまりテストのことだった。暴走が牙をむくのは、検めの最中に二頭目が割り込む、その一瞬の隙だ。祭りの夜を待たずとも、その隙さえ狙えれば、檻の中で必ず再現できる。彼女は荷を送る術(send)と、荷を検める術(validate)を、外から差し替えられる偽物(モック)に置き換えた。こうすれば、術が「いつ終わるか」を、こちらの手で握れる。

私は、その手際を、横で見ていた。カイナは、私の術式を、一行も書き換えない。ただ、その周りに、検めと送りの偽物を据えていく。暴れる獣そのものには、まだ手を出さず、捕らえる罠のほうを、先に、静かに組み上げていく。同僚の言っていた「しばらく黙って見てから手を入れる」とは、こういうことかと、私は思った。

仕掛けの肝は、一頭目の検め(validate)を途中で凍らせることだった。検めが終わらないうちに、二頭目の転送印を撃つ。そして、荷を送る術が何度走ったかを数える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 検めの「終わり」を外から握るための小道具
function deferred<T>() {
  let resolve!: (v: T) => void;
  const promise = new Promise<T>((res) => { resolve = res; });
  return { promise, resolve };
}

it("連打すると送信が二重に走る(二重送信の再現)", async () => {
  const v = deferred<void>();
  const validate = mock.fn(() => v.promise);          // 検めは凍ったまま
  const send = mock.fn(async (c: Cargo) => ({ transferId: "tx-" + c.id }));
  const gate = new TransferGate(validate, send);
  const cargo: Cargo = { id: "1", destination: "north" };

  const first = gate.dispatch(cargo);   // 一頭目(検めで凍る)
  const second = gate.dispatch(cargo);  // 二頭目(祭りの連打)
  v.resolve();                          // ここで初めて検めを解く
  await Promise.all([first, second]);   // 両 dispatch の解決を待てば、その時点で送信まで進んでいる

  assert.equal(send.mock.callCount(), 2);     // 送信が二度走る
  assert.equal(validate.mock.callCount(), 2); // 二頭目も検めを通り抜けている
});

カイナが模擬戦を走らせると、結果は無慈悲だった。send——荷を送る術が、二回呼ばれていた。「祭りの夜だけ」「たまに」だった暴走が、檻の中では、狙えば必ず起きる。数えているのは送信の回数だけだ。どちらの首が先に送ったかには依らない——二度走った、その事実だけで競合は確定する。

私は、自分のかけた鎖の前で、立ち尽くした。縫い込んだはずの鎖が、目の前で、こうもあっさり破られる。祭りの朝に覚えた、あの薄ら寒さが、明るい工房の中に、もう一度、立ち上がってきた。

「待ってください。札は縫ってあるんですよ。if (this.busy) return を、ちゃんと最初に置いてある。なのに、なぜ二頭目はすり抜けるんですか」

なぜ、二頭目はすり抜ける

カイナは答える前に、壁の契約録の一枚を外して卓に広げた。二つの首を持つ、黒い猟犬の絵だった。

「これはガルム。双頭の猟犬だ。右の首と左の首が、別々の獲物を追って同時に駆け出すと、首が絡んで、両方が同じ獲物に殺到する」彼女は絵の、右の首を指した。「お前の転送も同じだ。一度目の詠唱が右の首。連打で割り込む二度目が、左の首だ」

そして、私の術式の一行を指でたどった。

「お前は、荷を検め終えてから『転送中』の札を立てた。だが二頭目は、一頭目が検めている最中に入ってくる。その刻、札はまだ立っていない。だから二頭目も『今は空いている』と読む」

——右の首に目隠しをするのが、左の首がもう走り出した後だった。私は絵の二つの首を見て、ようやく、自分の鎖の形が見えた気がした。

カイナは少し間を置いて、こんどは術の理屈そのものを、低く、淡々と続けた。聞き取れた範囲で書き留めておく。

「この術式(JavaScript/TypeScript)を回すテイマーは、一人だ。同時に二箇所は見られない。処理を順に巡回していく——これをイベントループ(テイマーの巡回路)という。一人で回るから、if (this.busy) return; this.busy = true; のように await を挟まずに続く区間は、途中で巡回が他へ逸れない。割り込まれない。この同期の区間は、原子的に通り抜ける」

私は眉を寄せた。原子的、という言葉を頭の中で転がす。割り込まれない、ひとかたまり。

「だが」とカイナは私の札の位置を指した。「await this.validate(cargo) で、巡回は一度、他へ回る。await は、術の手を一旦止めて『巡回路を譲る』合図だ。ここが中断点(サスペンションポイント)——関数の実行が一旦止まり、巡回路が他のコードを動かせるようになる地点だ。その隙に、二頭目の dispatch が入ってくる。そして、まだ立っていない busy を見て、同じ false を読む。——勘違いするな。一人で回るから安全なんじゃない。お前の壊れた術式も、同じ一人で回っている。安全と暴走を分けるのは、確認と札立ての“間”に、この中断点が挟まるかどうか。それだけだ」

結果が、複数の処理の「どちらが先か」というタイミング次第で変わってしまう。これがRace Condition(歩調の乱れ)——処理の結果が、複数の操作の実行順序やタイミングに依存してしまう状態だ、とカイナは言った。

「魔物使いの世界じゃ、歩調の乱れた魔獣ほど厄介なものはない。同じ命令を、同じ刻に、二頭が別々に実行する」

彼女は卓に、刻の流れを線で描いてみせた。後で図に起こしたのが、これだ。

Infographic sequence diagram showing double-submission race condition due to await suspension point

図の上から下へ、刻が流れている。二頭が並んで busy? → false を読んでいる二行。私はそこを指でたどって、声を落として言い直した。

「……つまり、悪いのは札そのものじゃない。札を掛ける位置か」

カイナは、わずかに頷いたように見えた。

「型(コンパイル時の検め)は、この競合からはお前を守れない。型は『ありえない状態の同居』なら弾ける。だが『二頭が同じ刻に入ってくる』という実行順序の競合は、型には見えない。時間の競合は、実行時の設計——契約で締め出すしかない」

型では守れない領域がある。これを聞いたとき、私は初めて、自分の鎖がなぜ夜だけ破られたのかを、自分の言葉で説明できる気がした。

契約——札を、入口で立てる

カイナの手当ては、拍子抜けするほど静かだった。鎖を増やすでも、術を組み直すでもない。彼女は札を立てる一行——this.busy = true——を、検める前へ、つまり入口へ移しただけだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class TransferGate {
  private busy = false;
  private validate: Validate;
  private send: Send;

  constructor(validate: Validate, send: Send) {
    this.validate = validate;
    this.send = send;
  }

  async dispatch(cargo: Cargo): Promise<Receipt | undefined> {
    if (this.busy) return;        // 札を見る
    this.busy = true;             // 入口で即、札を立てる(最初の await より前)
    try {
      await this.validate(cargo); // 二頭目はここに来る前に弾かれる
      return await this.send(cargo);
    } finally {
      this.busy = false;          // 何があっても札を下ろす
    }
  }
}

差分は、たった一つ。this.busy = true の位置が、await this.validate() の後から前へ動いた。それだけだ。検めて、送って、札を下ろす——術のすることは何も変わっていない。

私は、その一行を、しばらく見つめた。あれほど受け取り所を騒がせ、誰かの祝いを虚空へ落とした暴走の根が、たった一行の、置き場所だった。鎖の数でも、術の出来でもない。札を、いつ立てるか——その一点に、すべてが懸かっていた。あまりに小さなその差に、私は、すぐには言葉が出なかった。

if (this.busy) return; this.busy = true; の間には、await がない」カイナはその二行を指でくくった。「だから、ここは割り込まれない原子的な区間だ。一頭目がこの入口を通り抜けた瞬間に、札は立つ。最初の await より前に立つ。二頭目がやってきたときには、もう札が立っている。だから、検めにすらたどり着けずに弾かれる」

処理中の再呼び出しを、入口の早期 return で締め出す。この仕掛けを再入ガードという、とカイナは言い添えた。鍵をかけるのは、走り出す前。それが契約の肝だった。

カイナは炭を取り、卓の羊皮紙に、新しく整えられた刻の絵を描き加えた。

Infographic sequence diagram showing Reentrancy Guard blocking a duplicate submission before validation

今度の絵では、二頭目は門の入口で静かに引き返していた。一頭目が検めに差し掛かるよりも早く、札が立っているからだ。

「これなら……」私は絵の線を指でなぞった。「二頭目が busy? を見るときには、すでに true になっている。だから、検めに入る前に弾き返せるんですね」

カイナは私の手元を見つめたまま、静かに頷いた。

「一度目の詠唱が await の中断点で巡回路を譲る前に、すでに門は閉ざされている。二度目の詠唱が滑り込む隙間は、もうない」

私は try の範囲が、send だけから validate まで広がっているのに気づいた。

try が、検めも巻き込んでますね。これも直したんですか」

「直したんじゃない。札を入口で立てたら、こうなるだけだ」カイナは finally の行を指した。「札を立てた以上、何があっても下ろさねばならん。下ろし忘れれば、二度と誰も通れなくなる。だから、札を立てた入口から、下ろす finally までが、ひとつながりの臨界区間になる。検めはその中に入った。位置を動かした、その帰結だ。新しい鎖を足したわけじゃない」

直したのは一箇所。札の位置だけ。それ以外は何も足していない——彼女はそこを、二度、念を押した。

もう一度、檻の中で

カイナは、さっきと寸分違わぬ模擬戦を、今度は手懐けた術式へ仕掛けた。一頭目を検めで凍らせ、二頭目を撃つ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
it("連打しても送信は一度きり(再入ガードで封印)", async () => {
  const v = deferred<void>();
  const validate = mock.fn(() => v.promise);
  const send = mock.fn(async (c: Cargo) => ({ transferId: "tx-" + c.id }));
  const gate = new TransferGate(validate, send);
  const cargo: Cargo = { id: "1", destination: "north" };

  const first = gate.dispatch(cargo);   // 一頭目(検めで凍る)
  const second = gate.dispatch(cargo);  // 二頭目(busy=true で弾かれる)
  v.resolve();
  const [, secondResult] = await Promise.all([first, second]); // 両 dispatch の解決待ちで送信まで終わる

  assert.equal(send.mock.callCount(), 1);     // 送信は一度きり
  assert.equal(validate.mock.callCount(), 1); // 二頭目は検めにすら届かない
  assert.equal(secondResult, undefined);      // 弾かれた呼び出しは undefined を返す
});

今度は、send は一回。validate も一回。二頭目は検めに届く前に弾かれ、何も実行せず、undefined を返して静かに引き返した。

二頭目が、何もせず、静かに引き返す。その一部始終を、私は息を詰めて見ていた。さっきは二度走った送りが、同じ連打で、今度は一度きり。札の位置を、入口へ動かしただけで。檻の中の小さな出来事だったが、私には、あの祭りの夜が、やり直されたように見えた。

戻り値が Promise<Receipt | undefined> になっているのは、その正直さの表れだ、とカイナは言った。「弾かれた呼び出しは、荷を送らない。送らないなら、受け取り証(Receipt)は無い。だから undefined が返る。『実行されないことがある』という事実が、型に書いてある」——もっとも、呼び出し側で『弾かれた』と『送った』を確実に見分けたいなら、undefined ではなく判別できる戻り値にする手もある。今宵の転送陣に、そこまでは要らなかった。

私は、もう一つ気になっていたことを訊いた。札を下ろし忘れたら——finally がなかったら、どうなるのか。検めの途中で何かが失敗したら。

カイナは、検めをわざと一度だけ失敗させる模擬戦を足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
it("検めに失敗しても札は下りる(次の転送を妨げない)", async () => {
  let n = 0;
  const validate = mock.fn(async () => { n++; if (n === 1) throw new Error("検め失敗"); });
  const send = mock.fn(async (c: Cargo) => ({ transferId: "tx-" + c.id }));
  const gate = new TransferGate(validate, send);

  await assert.rejects(() => gate.dispatch({ id: "1", destination: "north" }), { message: "検め失敗" });
  await gate.dispatch({ id: "2", destination: "south" }); // 札が下りていれば、次は通る

  assert.equal(send.mock.callCount(), 1); // 1回目は検めで落ち送らず、2回目だけ送る
});

検めが失敗しても、finally が札を確実に下ろす。だから次の転送はちゃんと通る。「臨界区間が入口から始まっている——try が検めを含んでいるからこそ、検めの失敗でも札は下りる」とカイナ。もし trysend だけを包んでいて、札を立てた後の検めが try の外で失敗していたら、札は立ったまま、二度と誰も通れなくなっていた。位置を動かしたことが、ここでも効いていた。

この鎖が縛れないもの

「これで——もう二度と、荷は割れないと、言い切れるんですか」私は最後に、それを確かめたかった。

カイナは即答しなかった。鎖をひとつ、卓に置いて、こう言った。

「この鎖が締め出すのは、『次の二度目』だけだ。まだ起きていない連打を、起こさせない。それは確かにやった」彼女は指を一本ずつ立てた。

「だが、すでに走り出した一頭は、この鎖では止まらない。一頭目が荷を送っている最中に『やっぱりやめろ』と言っても、この鎖にその力はない。走っている処理そのものを断ち切りたいなら、それは符牒を断つ別の獣——AbortController(進行中の処理を中断する取り決め)の領分だ」

「古い結果を捨てて、最新だけを採る、というのも別の獣だ。検索の先読みのように、何度も投げて『最後の答えだけ要る』なら、古い詠唱の結果を破棄する手がある。だが、荷を送る術のように副作用がある処理——一度送れば取り消せない処理——に、それは効かない。だから今宵は、この再入の鎖を選んだ」

「そして」カイナは扉の方へ目をやった。「扉を閉じて、開け直して、もう一度転送印を押す住民——画面を再読み込みした者や、別の入口(別タブ)から来た者は、この工房の鎖では縛れない。この鎖は、一つの陣の中でしか効かない。そこまで縛りたいなら、転送先の砦——受け取り側で『同じ依頼は一度しか処理しない』と保証する仕組み(サーバ側の冪等キー)が要る。冪等とは、何度実行しても結果が変わらない性質のことだ。それは、また別の話になる」

縛れるもの。縛れないもの。その線が引かれて、私はようやく、自分が今夜手に入れたものの大きさを、正しく測れた気がした。万能の鎖ではない。だが、祭りの夜の連打は、もう荷を割らない。

試運転

カイナは、Before と After の両方の模擬戦を、まとめて走らせた。暴走する転送陣には「二度走る」ことを、手懐けた転送陣には「一度きり」を、それぞれ記録として残してある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ node --test
▶ After: TransferGate(手懐けた転送陣)
  ✔ 連打しても送信は一度きり(再入ガードで封印)
  ✔ 一度の転送が終われば、次の転送は通る(finally 解放)
  ✔ 検めに失敗しても札は下りる(次の転送を妨げない)
▶ Before: TransferGate(暴走する転送陣)
  ✔ 連打すると送信が二重に走る(二重送信の再現)
  ✔ 一度の転送が終われば、次の転送は通る(finally 解放)
  ✔ 検めに失敗しても次の転送は通る(Before 固有の理由: busy が立たない)
ℹ tests 6
ℹ pass 6
ℹ fail 0

試運転、合格。暴走の再現も、その封印も、これからはこの模擬戦が見張り続ける。次の祭りの前夜に誰かが術式へ手を入れても、二度目の詠唱が滑り込めば、檻の中で必ず捕まる。

ふと、あの反物のことが、頭をよぎった。次元の狭間へ落ちた、嫁入りの祝い。あれは、もう、戻らない。それは、私が背負っていくものだ。だが——次の祭りの夜、同じ悲しみは、もう生まれない。失くしたものは戻らずとも、これから先、誰の荷も、虚空へは落とさせない。私に残された償いは、その一点だけだった。

私が札の下りる所作を確かめて、ようやく肩の力が抜けたのが、自分でも分かった。カイナは羊皮紙に、今宵の契約を書きつけていた。

「今日縛ったのは、一頭だ」彼女は顔を上げずに言った。「お前の陣には、まだ別の気配が眠っている。走り出した一頭を止める鎖。古い詠唱を捨てる鎖。——いずれ、それぞれに名前を付けることになる」

「……次に何かが暴れたら、また来ます」私がそう返すと、カイナは答えず、ランタンの芯を少し下げた。工房の影が、契約録の猟犬の絵を、静かに覆った。


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

  • 魔獣名(クラス/パターン名): ガルム(双頭の猟犬)/ Race Condition・二重送信(再入ガードで馴致)
  • 危険度(難易度/バグの影響度): ★★★★☆(平時は無害。連打・高負荷の刻にだけ牙をむき、荷=データの整合を割る)
  • 主な生態(アンチパターンの特徴):
    • 実行中フラグ(busy)を、最初の await の後に立てている
    • 一頭目が await validate の中断点で巡回路を譲った隙に、二頭目が同じ busy = false を読み、両者が send を実行する=二重送信
    • 平日(連打のない日)は中断点に誰も滑り込まないため再現せず、原因の特定が遅れる
  • 契約のポイント(設計の要点):
    • 札(busy = true)を if (busy) return の直後=最初の await より前へ移す。この同期区間は割り込まれず原子的に通るため、二頭目は必ず立った札を見て弾かれる
    • 札を立てた入口から finally までが臨界区間。try が検めも含むので、検めの失敗でも札は確実に下りる(送還)
    • 変えたのは札の位置 1点のみ。検め→送る→下ろすのロジックは Before と同一
  • 契約外事項(保証しないこと):
    • すでに走り出した処理は止められない(中断は AbortController の領分)
    • 古い結果の上書き(last-write-wins)はしない。副作用のある送信には不適
    • リロード・別タブ・再接続からの再送は縛れない(受け取り側のサーバ冪等キーが受け持つ領分)
  • 現在のステータス: 🟢 一頭テイム成立(再入を封印)/残る二頭は別の獣として後日
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。