ensure と rescue を取り違えた話

はじめに

先日、Ruby の Web フレームワーク Rage にコントリビューションする機会がありました。Rage は Rails が Puma の上で動くのとは違い、Iodine サーバの上で動き、内部はファイバーベースで非同期に処理を進めるのが特徴です。最近 Server-Sent Events (SSE) のサポートが入ったのですが、そのコードを読んでいるうちに、ひとつ小さな引っかかりを見つけました。それを追いかけた結果、最初に出した修正は間違っていて、メンテナに丁寧に直してもらい、最終的に正しい形でマージされるところまで辿り着きました。

そして指摘の内容こそが、この記事で一番伝えたいことです。最初の修正はローカルでも CI でも通ったのに、メンテナからは「それは壊れる変更だ」と指摘されました。ensurerescue のどちらを使うかは、非同期の世界ではスタイルの好みではなく、正しさそのものを左右します。

English

I recently had the opportunity to contribute to the Ruby web framework Rage. Unlike Rails running on top of Puma, Rage runs on the Iodine server and processes work asynchronously through fibers internally. It recently gained support for Server-Sent Events (SSE), and while reading through that code I noticed something that bothered me a little. Chasing that small detail led me through a wrong first attempt, a kind correction from the maintainer, and eventually a fix that got merged in the right shape. My first fix passed both locally and in CI, yet the maintainer told me it was a breaking change. What he pointed out is exactly what I want to convey in this post: in an asynchronous world, choosing between ensure and rescue is not a matter of style but of correctness itself.

非対称性に気づく

問題のファイルは lib/rage/sse/application.rb です。SSE の Application クラスは二種類のストリームを扱います。

一つは start_formatted_stream による Enumerator ストリームで、イベントを順番に取り出して書き込みます。これは同期的です。each ループを抜けた時点で、ストリームは本当に終わっています。そのため、もともと接続を閉じる ensure ブロックが付いていました。

もう一つは start_raw_stream による Proc / raw ストリームで、ユーザが渡した proc を ConnectionProxy とともに呼び出します。こちらには後始末が一切ありませんでした。proc が例外を投げると、接続は閉じられず、Iodine がソケットのタイムアウトに達するまで残り続けます。同じファイルの中で、片方は ensure で後始末をしているのに、もう片方は何もしていない。この非対称性が、私が最初に気になった点でした。

English

The file in question is lib/rage/sse/application.rb. The SSE Application class handles two kinds of streams.

The first is the Enumerator stream via start_formatted_stream, which pulls events one by one and writes them. This is synchronous: when the each-loop returns, the stream really is finished, so it already had an ensure block that closed the connection.

The second is the Proc / raw stream via start_raw_stream, which calls a user-supplied proc together with a ConnectionProxy. This one had no cleanup at all. If the proc raised an exception, the connection was never closed and would linger until Iodine hit its socket timeout. Within the same file, one path cleaned up with ensure while the other did nothing. That asymmetry was what first caught my eye.

当たり前に見える、しかし間違った修正

直し方は明らかに思えました。start_formatted_stream をそのまま真似て、start_raw_stream にも ensure を足せばいい。

def start_raw_stream(connection)
  @stream.call(Rage::SSE::ConnectionProxy.new(connection))
ensure
  connection.close
end

ローカルのテストは通り、CI もすべてパスしました。これで大丈夫だろうと思って、私はプルリクエスト (#248) を開きました (commit 14bf349)。今振り返ると、ここで「テストが通った」ことを「正しい」ことと取り違えていたのです。

English

The fix looked obvious. Just mirror start_formatted_stream and add an ensure block to start_raw_stream as well. The local tests passed and CI was green. Confident it was fine, I opened a pull request (#248) with commit 14bf349. Looking back, this is where I mistook “the tests pass” for “this is correct.”

メンテナのレビュー

3月27日ごろ、メンテナの rsamoilov さんから返信が来ました。要点を意訳すると、次のような指摘でした(原文の英語はこのあとの英語ブロックに収めてあります)。

コントリビューションありがとうございます。ただ残念ながら、このコミットは破壊的変更です。raw な SSE ストリームが同期的だという、もっともらしいけれど誤った仮定を置いてしまっているからです。ストリームを別の fiber やスレッドで動かすのは、まったく正当なやり方です。その場合、render sse: に渡された proc はほぼ即座に実行を終えますが、それはバックグラウンドの fiber/スレッドが処理を行って接続に書き込むよりも、ずっと前のことです。[…] ensure ブロックを足すと、接続は何も書き込まれないうちに閉じられてしまいます。しかし、SSE ストリームが例外を投げたときだけ接続を閉じることに絞れば、あなたのアイデアは筋が通っていて、実際に既存の問題を修正できます。例外時にのみ接続を閉じるようにすればよいのです。

「reasonable but incorrect assumption(妥当だが誤った仮定)」という原文の表現が、私の間違いを正確に言い当てていました。私は raw ストリームが同期的だと暗黙のうちに思い込んでいたのです。

English

The maintainer’s original comment:

Thank you for the contribution! Unfortunately, this commit is a breaking change as it makes a reasonable but incorrect assumption about the synchronous nature of raw SSE streams. It is a completely valid approach to run the stream on a separate fiber or thread. In such case, the proc that is passed to render sse: finishes executing almost immediately but long before the background fiber/thread has a chance to do the work and write to the connection. […] Adding the ensure block means the connection is closed before anything has been written to it. However, if we focus on closing the connection when the SSE stream RAISES, your idea makes sense and actually fixes the existing issue. We just need to ensure the connection is closed only on exception.

The phrase “reasonable but incorrect assumption” pinpointed my mistake exactly. I had implicitly assumed that raw streams were synchronous.

なぜ ensure は非同期を壊すのか

ここがこの記事の核心です。raw な SSE ストリームは、しばしば非同期に書かれます。ユーザの proc はバックグラウンドのファイバーを起動し(たとえば Fiber.schedule を使って)、すぐに戻ります。そのあと、起動されたファイバーが接続にイベントを書き込み続けるのです。

ensure ブロックは proc が戻った瞬間に走ります。例外が出たかどうかは関係なく、正常に戻っても走ります。下のタブを切り替えて、同じ「接続を閉じる」処理が ensurerescue でどう変わるのかを見比べてみてください。

タブをタップ/クリックで切り替え

  1. proc がバックグラウンド Fiber を起動する
  2. proc はすぐに return する(まだ 1 イベントも書いていない)
  3. proc が戻ったので ensure が発火 → connection.close
  4. Fiber が書き込もうとする……が、接続はもう閉じている
接続:閉じている

→ イベントは 1 つも届かない

  1. proc がバックグラウンド Fiber を起動する
  2. proc は正常に return する(例外は出ない)
  3. rescue は発火しない → 接続は開いたまま
  4. Fiber がイベントを書き込む
  5. すべて送信したあとに close
接続:開いたまま

→ 13 イベントすべてが届く

同じキーワードでも、ensure は proc が戻った瞬間に必ず走り、rescue は例外時だけ走る。この違いが接続の運命を分けます。

対照的に、start_formatted_streamensure を使うのは正当です。Enumerator のパスは同期的で、each ループを抜けた時点で仕事は本当に終わっているからです。だから、そこで閉じるのは正しい。同じ ensure というキーワードでも、片方では正解で、もう片方では破壊的になる。違いはコードの見た目ではなく、その下にある実行モデルにありました。

English

This is the heart of the post. Raw SSE streams are frequently written asynchronously. The user’s proc spawns a background fiber (for example via Fiber.schedule) and returns immediately. After that, the spawned fiber keeps writing events to the connection.

An ensure block runs the moment the proc returns — whether or not an exception was raised. So with ensure: the proc spawns the fiber and returns immediately, ensure fires and calls connection.close, and only then does the background fiber start running, by which point the connection is already closed. Adding ensure closes the connection before anything is written. This is not just about the error case; it breaks the normal, success path of every async stream. The Datastar SDK drives SSE in exactly this pattern.

By contrast, start_formatted_stream legitimately uses ensure, because the Enumerator path is synchronous: when its each-loop returns, the work truly is done, so closing there is correct. The same ensure keyword is right in one place and breaking in the other. The difference lay not in how the code looked, but in the execution model beneath it.

正しい修正

メンテナの指摘の通り、私たちが本当にやりたかったのは「ストリームが例外を投げたときに接続を閉じる」ことでした。正常に戻ったときには、何もしてはいけません。バックグラウンドのファイバーが書き込みを続けられるよう、接続を開いたままにしておく必要があるからです。これは ensure ではなく rescue の仕事です。マージされた修正 (commit 819f747) はこうなりました。

lib/rage/sse/application.rb
def start_raw_stream(connection)
  @stream.call(Rage::SSE::ConnectionProxy.new(connection))
rescue => e
  connection.close if connection.open?
  raise e
end

3 行それぞれの意図を、タップして確かめてみてください。

この 3 行は、それぞれ別の役割を担っています。上のボタンをタップすると、その行が何のためにあるのかを表示します。

rescue => e は、proc が例外を投げたときだけ走ります。正常に return した場合(Fiber がまだ動いている非同期パターン)はこの分岐に入らないので、接続はバックグラウンドの Fiber のために開いたまま残ります。

connection.close if connection.open?open? ガードで二重 close を防ぎます。proc が例外を投げる前に、すでに自分で接続を閉じていた場合に備えるためです。

raise e で元の例外を再送出します。後始末を足しているだけで、エラーを握りつぶしてはいません。元のエラーはちゃんとログに残ります。

English

As the maintainer pointed out, what we actually wanted was to close the connection only when the stream raises. On a normal return we must do nothing, leaving the connection open so the background fiber can keep writing. That is a job for rescue, not ensure. The merged fix is commit 819f747 (shown above).

  • rescue => e runs only when the proc raises. On a normal return (the async pattern where the fiber is still running) we never enter this branch, so the connection is left open for the background fiber.
  • connection.close if connection.open? closes the connection, but the open? guard prevents a double close, in case the proc had already closed the connection before raising.
  • raise e re-raises the original exception. We are only adding cleanup, not swallowing the error, so the original error still surfaces in the logs.

検証する

理屈が正しいと信じるだけでは足りなかったので、実際に非同期のコンシューマで確かめたいと思いました。そこで rage-rb/datastar-example プロジェクトの Gemfile を、修正ブランチを指すように書き換えたのです。Datastar の SDK は SSE を非同期に駆動するので、私の ensure 版が本当に壊すパターンと、rescue 版が守るべきパターンの両方を踏みます。

すると Datastar の SDK は 13 個の SSE イベントをすべて正しくストリームしました(H, He, Hel, Hell, Hello, … , Hello, world!)。バックグラウンドのファイバーが書き込んでいる間、接続は開いたままで、完了後に正常に閉じられました。この結果を PR に報告しました。最初の ensure 版だったら、この 13 イベントは一つも届かなかったはずです。

テストとしては、spec/sse/application_spec.rb に小さな MockSSEConnection(write / close / open? を持つ)を使った三つのケースを追加しました。

  • (a) proc が例外を投げる → 接続が閉じられる。
  • (b) proc が接続を閉じずに戻る(非同期パターン) → 接続は開いたまま。
  • (c) proc が自分で接続を閉じる → 干渉せず、二重クローズもしない。

特に (b) のケースが重要です。最初の ensure 版では、このテストこそが「接続が閉じられてはいけないのに閉じられる」ことを暴いてくれたはずのものでした。

レビューは3月27日、rescue 版への修正と datastar-example での検証が3月28日、そして3月30日に rsamoilov さんが承認し、rage-rb:master にマージされました。

English

Believing the reasoning was correct was not enough, so I wanted to confirm it against a real asynchronous consumer. I pointed the Gemfile of the rage-rb/datastar-example project at the fix branch. The Datastar SDK drives SSE asynchronously, so it exercises exactly the pattern my ensure version would have broken and the rescue version should protect.

The Datastar SDK then streamed all 13 SSE events correctly (H, He, Hel, Hell, Hello, … , Hello, world!). The connection stayed open while background fibers wrote, and closed normally after completion. I reported this back in the PR. With the original ensure version, none of those 13 events would have arrived.

For tests, I added three cases in spec/sse/application_spec.rb using a small MockSSEConnection helper (with write / close / open?): (a) the proc raises → the connection is closed; (b) the proc returns without closing (the async pattern) → the connection stays open; (c) the proc closes the connection itself → no interference, no double close.

Case (b) matters most. Under the original ensure version, this is the very test that would have exposed the connection being closed when it must not be. The review came on March 27th, the revision and verification on March 28th, and on March 30th rsamoilov approved and merged it into rage-rb:master.

ensure と rescue の使い分け

この経験から得た一番の収穫は、シンプルな判断基準です。ファイバーベースや非同期の Ruby では、「ブロックが戻った」ことと「仕事が終わった」ことは同じではありません。

  • 仕事がブロックを抜けた時点で本当に終わっている(同期)なら、後始末には ensure を使う。
  • リソースがブロックより長く生き残る可能性があり(非同期)、失敗したときだけ掃除したいなら、rescue を使う。

どちらを選ぶかはスタイルの問題ではありません。ここで ensure を選ぶことは、すべての非同期コンシューマを静かに壊すことを意味します。

English

The biggest takeaway from this experience is a simple rule of judgment. In fiber-based or asynchronous Ruby, “the block returned” is not the same as “the work is done.”

  • If the work is genuinely finished when the block exits (synchronous), use ensure for cleanup.
  • If the resource may outlive the block (asynchronous) and you only want to clean up on failure, use rescue.

Picking between them is not a style question. Choosing ensure here means silently breaking every async consumer.

Rage の外にも広がる話

同じ罠は、Ruby がファイバーベースの非同期に触れるところならどこにでも潜んでいます。Falcon、Async gem、Roda + Async、ActionCable や WebSocket のライフサイクル、Iodine や libev、io_uring で駆動されるサーバ。リソースの生存期間がブロックのスコープと一致しなくなる場面では、同じ判断が必要になります。

接続のリークは、本番を一気に落とすような派手な壊れ方はしません。少しずつファイルディスクリプタを食いつぶし、誰かが「サーバがなんだか遅い」と気づくまで、静かに進行します。だからこそ、レビューで早めに捕まえてもらえたのは幸運でした。

English

The same trap lurks anywhere Ruby touches fiber-based async: Falcon, the Async gem, Roda + Async, ActionCable and WebSocket lifecycles, any server driven by Iodine, libev, or io_uring. Wherever a resource’s lifetime stops matching the block’s scope, the same judgment is required.

Connection leaks do not crash production all at once. They slowly exhaust file descriptors until someone notices the server getting sluggish. That is exactly why I was lucky to have this caught in review early.

学んだこと

正直に言うと、この記事で一番面白いのは私が間違っていた部分です。テストが通り、CI が緑だったことで、私は自分の前提を疑わなくなっていました。けれど、テストは私が想定したケースしか守ってくれません。raw ストリームが非同期に使われるという、私が知らなかった使い方は、テストには現れていなかったのです。

rsamoilov さんは、私の変更をただ却下するのではなく、「例外時に閉じる」という核のアイデアは正しいと認めたうえで、正しい形に導いてくれました。良いレビューは、修正されたコードよりも多くのことを教えてくれます。今回で言えば、私が得たのは数行のパッチではなく、非同期のリソース管理に対する見方そのものでした。この小さな非対称性を追いかけたことで、ドキュメントを読むだけでは得られない理解に辿り着けたと思います。

もし誰かが、自分のコントリビューションが拒否されるのを恐れて OSS への一歩をためらっているなら、こう伝えたいです。自分の修正をきちんと検証する姿勢さえあれば、間違いはむしろ学びの入り口になります。間違っている部分こそが、私にとっては一番面白い部分でした。間違いを恐れずに、まずは PR を投げてみてください。

English

To be honest, the most interesting part of this story is where I was wrong. Because the tests passed and CI was green, I stopped questioning my own premise. But tests only protect the cases I imagined. The asynchronous use of raw streams, a usage I did not know about, simply did not appear in my tests.

Rather than just rejecting my change, rsamoilov acknowledged that the core idea, “close on exception,” was right, and then guided it into the correct shape. A good review teaches more than the corrected code does. In this case, what I gained was not a few lines of patch but a way of seeing asynchronous resource management, an understanding I could not have gotten from just reading the docs.

If anyone is hesitating to take their first step into open source out of fear that their contribution will be rejected, I would say this: as long as you are willing to verify your own fix properly, a mistake becomes an entry point for learning. The part where I was wrong turned out to be the most interesting part. Do not be afraid to be wrong, and send the PR.

リンク

この記事をシェア

2020-2026
弊社では、一緒に会社を面白くしてくれる仲間を募集しています。
お気軽にお問い合わせください!
P.S. よろしければこちらもどうぞ
新明工業クラシックカーレストア blog — クラシックカーのレストアのお仕事の一部を公開しています。
新明工業コンベア blog — コンベアに関する技術情報を発信しています。