ractor-wrapper 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/DESIGN.md ADDED
@@ -0,0 +1,1024 @@
1
+ # Ractor::Wrapper — Design Document
2
+
3
+ This document describes how `Ractor::Wrapper` is implemented. It is intended
4
+ for three audiences:
5
+
6
+ - **Power users** who want to understand how the gem behaves well enough to
7
+ make informed decisions about incorporating it into a system.
8
+ - **Contributors** who need to understand the internals before changing them.
9
+ - **Future maintainers** who want a permanent record of the architectural
10
+ decisions baked into the library.
11
+
12
+ The `README.md` focuses on how to _use_ the gem. In contrast, this document
13
+ focuses on how it _works_ and _why_ it was built that way.
14
+
15
+ Note: This document was largely reverse-engineered from the code and its
16
+ reference documentation by Claude Opus 4.7, with some human edits.
17
+
18
+ ---
19
+
20
+ ## 1. Problem statement
21
+
22
+ A Ractor-shareable object is one that can be referenced from more than one
23
+ Ractor at once. The current Ruby rules (as of 4.0) require shareable objects
24
+ to be deeply immutable, which rules out a large fraction of Ruby objects
25
+ that Ruby programs actually need to share. Examples include `Net::HTTP`
26
+ sessions, database connections, file handles, cache objects, and parsers
27
+ with internal state.
28
+
29
+ The canonical Ractor answer is "don't share it; write an actor." But writing
30
+ an actor from scratch means hand-crafting a message loop, a serialization
31
+ convention, block handling, lifecycle, error propagation, and everything
32
+ else. It also means rewriting every existing library in that style.
33
+
34
+ `Ractor::Wrapper` provides this plumbing once, so that any ordinary Ruby
35
+ object can be exposed to other Ractors through a shareable stub that
36
+ proxies calls back to a single controlled home. This allows both legacy
37
+ non-shareable objects to be used in a multi-Ractor environment, and new
38
+ Ractor-aware objects to be written easily.
39
+
40
+ The core design goal is to reproduce, as faithfully as reasonable, the
41
+ semantics of calling the object directly, including arbitrary arguments and
42
+ return values, keyword arguments, blocks, exceptions, and even re-entrant calls
43
+ from blocks. The gem deliberately accepts some limitations in service of that
44
+ goal, and those limitations are discussed throughout this document.
45
+
46
+ ---
47
+
48
+ ## 2. High-level structure
49
+
50
+ The entire library is in `lib/ractor/wrapper.rb`. The classes involved are:
51
+
52
+ - **`Ractor::Wrapper`** — the Ractor-shareable public-facing handle. Holds
53
+ the `Configuration`, a `Stub`, a `Ractor::Port` to send messages to the
54
+ server. It also provides the `call` method that provides the central
55
+ interface to invoke methods on the wrapped object, the main caller-side
56
+ message loop and block-yield handler, and wrapper lifecycle operations
57
+ (`async_stop`, `join`, `recover_object`).
58
+ - **`Ractor::Wrapper::Stub`** — a Ractor-shareable proxy that mimics the
59
+ method interface of the original object. Uses `method_missing` to forward
60
+ arbitrary method calls to the wrapper.
61
+ - **`Ractor::Wrapper::Configuration`** — builder for wrapper options,
62
+ including per-method settings.
63
+ - **`Ractor::Wrapper::MethodSettings`** — a frozen value object holding the
64
+ copy/move/void choices for a single method.
65
+ - **`Ractor::Wrapper::Server`** — the backend that runs the object and
66
+ services messages. In isolated mode it runs in its own Ractor; in local
67
+ mode it runs as one or more threads inside the Ractor that created the
68
+ wrapper.
69
+ - **`Ractor::Wrapper::Server::Dispatcher`** — a thread-safe work distributor
70
+ used only in threaded mode. Handles routing of new calls (via a shared
71
+ queue) and fiber resumes (via per-worker queues).
72
+ - **Message types** (all `Data.define` frozen structs):
73
+ `InitMessage`, `CallMessage`, `ReturnMessage`, `ExceptionMessage`,
74
+ `FiberYieldMessage`, `BlockingYieldMessage`, `FiberReturnMessage`,
75
+ `FiberExceptionMessage`, `StopMessage`, `JoinMessage`, `JoinReplyMessage`,
76
+ `WorkerStoppedMessage`.
77
+
78
+ Viewed from the outside, the runtime architecture looks like this:
79
+
80
+ ```
81
+ Caller Ractor(s) Wrapper Wrapped object
82
+ ┌──────────────────┐ ┌───────────────────────┐ ┌────────────┐
83
+ │ stub.some_method │───────▶│ Wrapper#call │ │ <object> │
84
+ │ │ (Stub) │ sends CallMessage │ │ │
85
+ │ │ └─────────┬─────────────┘ └──────▲─────┘
86
+ │ │ │ @port │
87
+ │ │ ┌─────────▼──────────────┐ │
88
+ │ │ │ Server main fiber / │ fibers / │
89
+ │ ◀── ReturnMsg ───│────────│ worker threads │ threads ◀─────┘
90
+ │ ◀── ExceptionMsg │ │ (dispatch, fiber mgmt) │
91
+ │ ◀── YieldMsg ────│──▶ run block ─▶ send result ─▶ │
92
+ └──────────────────┘ └────────────────────────┘
93
+ ```
94
+
95
+ The rest of this document expands each of these boxes.
96
+
97
+ ---
98
+
99
+ ## 3. Configuration
100
+
101
+ Configuration is a two-stage process: keyword arguments to `Wrapper.new`,
102
+ optionally followed by a block that yields a mutable `Configuration` instance.
103
+ The block runs _before_ the wrapper materializes its server, so it is the last
104
+ chance to adjust behavior before the wrapper is frozen for Ractor shareability.
105
+
106
+ Inside `Wrapper#initialize` the sequence is:
107
+
108
+ 1. Validate that the supplied object is not already a moved
109
+ `Ractor::MovedObject`.
110
+ 2. Build a fresh `Configuration`, seeding it with constructor kwargs.
111
+ 3. Yield it to the user's block, if any. Block-provided settings override
112
+ kwargs because they are applied later.
113
+ 4. Resolve `Configuration#final_method_settings` into a frozen hash.
114
+ 5. Create the `Stub`, freeze the wrapper, and start the server.
115
+
116
+ ### 3.1 Wrapper-level settings
117
+
118
+ | Setting | Type | Default | Purpose |
119
+ |---|---|---|---|
120
+ | `name` | `String` | `object_id.to_s` | Identifies the wrapper in log output and as the Ractor name. |
121
+ | `use_current_ractor` | `Boolean` | `false` | Selects the execution mode (see §4). |
122
+ | `threads` | `Integer` | `0` | Number of worker threads. `0` means sequential. |
123
+ | `enable_logging` | `Boolean` | `false` | Enables internal stderr logging. |
124
+
125
+ ### 3.2 Method-level settings — `MethodSettings`
126
+
127
+ `MethodSettings` governs how a single method communicates its data. Each
128
+ instance is frozen and carries five values:
129
+
130
+ | Field | Values | Meaning |
131
+ |---|---|---|
132
+ | `arguments` | `:copy` \| `:move` | How positional and keyword arguments are shipped to the server. |
133
+ | `results` | `:copy` \| `:move` \| `:void` | How return values come back. `:void` returns `nil`. |
134
+ | `block_arguments` | `:copy` \| `:move` | How arguments to a caller-side block are shipped to the caller. |
135
+ | `block_results` | `:copy` \| `:move` \| `:void` | How the block's return value is shipped back to the server. |
136
+ | `block_environment` | `:caller` \| `:wrapped` | Where the block runs (see §7). |
137
+
138
+ The settings are stored in `Configuration#@method_settings`, keyed by method
139
+ name (Symbol) or `nil` for the defaults. `final_method_settings` produces the
140
+ resolved hash by:
141
+
142
+ 1. Starting with a hard-coded fallback of all `:copy` and
143
+ `block_environment: :caller`.
144
+ 2. Overlaying the `nil` entry (defaults the user supplied or got from kwargs)
145
+ on top of that fallback.
146
+ 3. For each named-method entry, overlaying it on top of those defaults.
147
+
148
+ Looking a method up via `Wrapper#method_settings(name)` returns the
149
+ per-method `MethodSettings` if present, otherwise the defaults. Because
150
+ `MethodSettings` is frozen and the outer hash is frozen, this value is
151
+ safely shared between the caller's Ractor and the server's Ractor — it
152
+ travels inside each `CallMessage`.
153
+
154
+ ### 3.3 Why the config travels with every message
155
+
156
+ Embedding `settings` inside `CallMessage` is intentional. The server
157
+ needs to know, per call: whether to move or copy the return value,
158
+ whether to expect to run a block locally or to yield one, and whether to
159
+ void the return. Sending the settings with the message keeps the server
160
+ stateless with respect to configuration — it does not need a synchronized
161
+ copy of the settings hash, and configuration changes (were they ever
162
+ added) would not race with in-flight calls.
163
+
164
+ ### 3.4 Copy / move / void — what they really mean
165
+
166
+ - `:copy` — the value is `Marshal`-style deep-cloned by Ractor when it
167
+ crosses the boundary. Safe but potentially expensive for large payloads.
168
+ - `:move` — the value is transferred; the sender can no longer use it.
169
+ This is sent as the `move:` keyword of `Ractor::Port#send`. Useful for
170
+ large buffers and unique resources. The ownership model is subtle: if
171
+ the caller passes a large string as a `:move` argument, it becomes a
172
+ `Ractor::MovedObject` for the caller and must not be touched again.
173
+ - `:void` — the return value (or block result) is dropped and the
174
+ recipient sees `nil`. This exists because many Ruby methods return a value
175
+ "by accident"; they don't intend to return a specific value, but whatever
176
+ final object is evaluated at the end of the method still gets functionally
177
+ returned. If that object is large, the cost of shipping it could be both
178
+ substantial and unnecessary: `:copy` might be expensive, and `:move` could
179
+ be disastrous, making parts of the internal state unreachable. `:void` is
180
+ an escape hatch that simply disables returning any value in such cases.
181
+
182
+ ---
183
+
184
+ ## 4. Execution modes
185
+
186
+ There are two orthogonal axes of execution mode, giving four combinations:
187
+
188
+ | | Sequential (`threads: 0`) | Threaded (`threads: N>0`) |
189
+ |---|---|---|
190
+ | **Isolated** (`use_current_ractor: false`) | Object moved to a new Ractor; one method at a time. | Object moved to a new Ractor; N worker threads inside it. |
191
+ | **Local** (`use_current_ractor: true`) | Object stays in the current Ractor; one thread serves calls. | Object stays in the current Ractor; N worker threads serve calls. |
192
+
193
+ ### 4.1 Isolated mode
194
+
195
+ `Wrapper#setup_isolated_server` spawns a new `Ractor` that immediately
196
+ calls `Server.run_isolated`. The object is _not_ passed as a Ractor
197
+ constructor argument, as doing so would copy the object. Instead, once the
198
+ Ractor is running, the wrapper sends a `InitMessage` (containing the object
199
+ and the stub) with `move: true`, and the server's first action inside
200
+ `receive_remote_object` is to receive and unpack it.
201
+
202
+ Because the object now lives in the server's Ractor, the original caller
203
+ can no longer touch it directly. It can, however, retrieve the object at
204
+ the end of the wrapper's life via `Wrapper#recover_object`, which is
205
+ implemented as `@ractor.value` — the Ractor's terminal value is the
206
+ wrapped object, returned from `Server#run`.
207
+
208
+ ### 4.2 Local mode (`use_current_ractor: true`)
209
+
210
+ `Wrapper#setup_local_server` does not spawn a Ractor at all. It creates
211
+ a `Ractor::Port`, marks the wrapper frozen (so the stub is shareable),
212
+ and starts a regular `Thread` that runs `Server.run_local`. The wrapped
213
+ object is never moved; it stays with its creator.
214
+
215
+ This is the right mode for objects that cannot be moved between Ractors.
216
+ The canonical example is a SQLite3 database handle, which is bound to the
217
+ Ractor that created it. It's also appropriate when you want to keep
218
+ direct access to the object from the creating Ractor (say, for quick
219
+ synchronous probes that avoid the wrapper's message path entirely), which
220
+ is only safe to do outside method windows driven by the wrapper.
221
+
222
+ Tradeoffs vs isolated mode:
223
+
224
+ - No `recover_object` (the object was never moved; `recover_object` raises).
225
+ - No isolation: a bug in the wrapped object can corrupt state in the
226
+ host Ractor, since they share a Ractor.
227
+ - Slightly lower overhead per call: no cross-Ractor marshalling of
228
+ arguments / return values if both caller and server happen to be in
229
+ the same Ractor (but note that in local mode other Ractors can still
230
+ call through the stub, and _those_ calls pay the normal crossing cost).
231
+
232
+ ### 4.3 Sequential vs threaded
233
+
234
+ `threads: 0` (sequential) means no `Dispatcher` is created and calls are
235
+ executed directly by the server's main message-handling loop. One method
236
+ runs at a time.
237
+
238
+ `threads: N > 0` creates a `Dispatcher` and spawns N worker threads.
239
+ Workers pull `CallMessage`s off the dispatcher's shared queue and execute
240
+ them concurrently. The concurrency ceiling is N regardless of how many
241
+ callers are blocked on calls.
242
+
243
+ A sharp note from the constructor doc: the `threads` value should be
244
+ sized to the concurrency of _independent_ calls, not to the re-entrancy
245
+ depth. A suspended method (waiting for a block result) does not occupy a
246
+ worker — it only occupies a fiber. The worker returns to the dispatch
247
+ loop and can service another call. This is why the threading model costs
248
+ very little even for deeply re-entrant workloads: fibers are cheap,
249
+ threads are not, and the library deliberately spends the former.
250
+
251
+ ---
252
+
253
+ ## 5. The Stub
254
+
255
+ `Ractor::Wrapper::Stub` is minimal by design:
256
+
257
+ ```
258
+ Stub
259
+ @wrapper (frozen reference to Wrapper)
260
+ method_missing(name, ...) → @wrapper.call(name, ...)
261
+ respond_to_missing?(name, include_all) → @wrapper.call(:respond_to?, ...)
262
+ ```
263
+
264
+ It freezes itself in `initialize`. Because its only instance variable is
265
+ a frozen reference to a frozen `Wrapper`, and the `Wrapper` is itself
266
+ shareable after construction, the stub is transitively shareable. That
267
+ means you can pass it freely across Ractor boundaries and every Ractor
268
+ can call methods on the wrapped object through it.
269
+
270
+ A few design choices worth calling out:
271
+
272
+ - **Why `method_missing` instead of pre-generating methods?** The stub
273
+ must work for any wrapped object, including ones that define methods
274
+ dynamically at runtime. There is no way to introspect the wrapped
275
+ object's method list from another Ractor without paying a message
276
+ round-trip anyway.
277
+ - **`respond_to_missing?` proxies through.** If you ask the stub whether
278
+ the underlying object responds to `:foo`, the answer has to come from
279
+ the server, because only it can see the object. So `respond_to?` is
280
+ itself a round-trip call. This is slower than a direct lookup but
281
+ semantically faithful.
282
+ - **Return-value substitution.** If the wrapped object returns `self`,
283
+ the server substitutes the stub for the return value. This preserves
284
+ method chaining through the stub boundary — `stub.tap { ... }` works as
285
+ expected — even though `self` from the server's perspective is the
286
+ bare object, not the stub. The same substitution is performed for
287
+ block arguments (see §7).
288
+ - **No `call` method.** `Wrapper#call` is the low-level escape hatch; the
289
+ stub always goes through `method_missing`. This means you cannot use
290
+ `stub.call(:foo)` to bypass the proxy.
291
+
292
+ ---
293
+
294
+ ## 6. Messaging protocol
295
+
296
+ All messages are frozen `Data` structs, making them shareable and immutable.
297
+ This section catalogs them by direction.
298
+
299
+ ### 6.1 From caller to server
300
+
301
+ | Message | Sender | Receiver | Purpose |
302
+ |---|---|---|---|
303
+ | `InitMessage(object, stub)` | `Wrapper#setup_isolated_server` | Server's `receive_remote_object` | One-shot initialization for isolated mode. Sent with `move: true`. |
304
+ | `CallMessage(method_name, args, kwargs, block_arg, transaction, settings, reply_port)` | `Wrapper#call` | Server main loop | Request a method invocation. |
305
+ | `FiberReturnMessage(value, fiber_id)` | `Wrapper#send_block_result` | Server main loop | Block result (fiber-suspend path). |
306
+ | `FiberExceptionMessage(exception, fiber_id)` | `Wrapper#send_block_exception` | Server main loop | Block exception (fiber-suspend path). |
307
+ | `StopMessage()` | `Wrapper#async_stop` | Server main loop | Request graceful shutdown. |
308
+ | `JoinMessage(reply_port)` | `Wrapper#join` (local mode only) | Server main loop | Request notification when server has fully stopped. |
309
+
310
+ ### 6.2 From server to caller
311
+
312
+ | Message | Sender | Receiver | Purpose |
313
+ |---|---|---|---|
314
+ | `ReturnMessage(value)` | `Server#handle_method` (or main-loop refusal) | `Wrapper#call` | Normal method result. |
315
+ | `ExceptionMessage(exception)` | Server (several sites) | `Wrapper#call` | Method raised an exception; server-side refusal; or crash cleanup. |
316
+ | `FiberYieldMessage(args, kwargs, fiber_id)` | `Server#fiber_yield_block` | `Wrapper#call` / `handle_yield` | Request to run a block on the caller side (fiber-suspend path). |
317
+ | `BlockingYieldMessage(args, kwargs, reply_port)` | `Server#blocking_yield_block` | `Wrapper#call` / `handle_yield` | Same but blocking-fallback path. |
318
+ | `JoinReplyMessage()` | `Server#send_join_reply` | `Wrapper#join` | Terminal notification that the server has finished cleaning up. |
319
+
320
+ ### 6.3 Within the server
321
+
322
+ | Message | Sender | Receiver | Purpose |
323
+ |---|---|---|---|
324
+ | `WorkerStoppedMessage(worker_num)` | `Server#cleanup_worker` | Server main loop | A worker thread has terminated. Carried on the main `@port`. |
325
+
326
+ ### 6.4 Port topology
327
+
328
+ Each `CallMessage` carries its own `reply_port`, which is a fresh
329
+ `Ractor::Port` created by `Wrapper#call`. This gives the caller a private
330
+ channel for all replies to that call: return value, exceptions, and both
331
+ variants of yield message. The reply port is closed when the call returns
332
+ (success or exception) via the `ensure` block.
333
+
334
+ The server has a single main `@port` that it receives on. It multiplexes
335
+ everything: new calls, stop/join requests, fiber resumes, and worker
336
+ death notifications. This is why every message that the server needs to
337
+ dispatch internally carries enough information to route itself (e.g.
338
+ `FiberReturnMessage` includes the `fiber_id`).
339
+
340
+ ### 6.5 Observability using `transaction`
341
+
342
+ The `transaction` field on `CallMessage` is a 16-character base-36 random
343
+ string created by `Wrapper#make_transaction`. It exists to correlate log lines
344
+ across caller and server for a single call; it is not used for dispatch.
345
+
346
+ ---
347
+
348
+ ## 7. Blocks, re-entrancy, and fiber magic
349
+
350
+ This is the most subtle part of the library. The core problem: a caller
351
+ may pass a block (`stub.each { |x| stub.process(x) }`). Where does that
352
+ block's body _run_?
353
+
354
+ ### 7.1 The two block environments
355
+
356
+ `block_environment: :caller` (default) — the block runs in the caller's
357
+ Ractor, with full access to the caller's lexical scope. The server has
358
+ to ask the caller to run each invocation, wait for the result, and then
359
+ resume the method.
360
+
361
+ `block_environment: :wrapped` — the block runs in the server Ractor, in
362
+ the wrapped object's context. No inter-Ractor communication is needed per
363
+ block call. The block is captured as a `Ractor.shareable_proc`, which
364
+ means it can only reference shareable state. Closures over caller-side
365
+ mutable variables will fail at shareability-check time.
366
+
367
+ The tradeoff is: `:caller` is the common case because most blocks _do_
368
+ close over state (accumulators, config, etc.), but paying a round-trip
369
+ per invocation of a block called in a tight loop (think `each` over a
370
+ large collection) can be very expensive. `:wrapped` is the escape hatch
371
+ when you really want the block body to live alongside the method and the
372
+ block is self-contained. The README's Enumerator-over-SQLite example is
373
+ one such case.
374
+
375
+ ### 7.2 How the block arg is represented
376
+
377
+ `Wrapper#make_block_arg` looks at the `block_environment` setting and
378
+ constructs one of three things:
379
+
380
+ - `nil` — no block was given.
381
+ - `:send_block_message` (a sentinel symbol) — `:caller` mode. The server must
382
+ construct a local proc that forwards each invocation back across the wire.
383
+ - A `Ractor.shareable_proc` — `:wrapped` mode. The shareable proc travels
384
+ directly inside the `CallMessage` and is invoked in-Ractor.
385
+
386
+ On the server side, `Server#make_block` translates this into the actual
387
+ proc passed to the wrapped method:
388
+
389
+ - `nil` → no block is passed; `__send__(name, *args, **kwargs, &nil)` is
390
+ equivalent to calling without a block.
391
+ - A shareable proc → used directly.
392
+ - `:send_block_message` → a proc is constructed that, when invoked,
393
+ performs the round-trip yield dance described in §7.3.
394
+
395
+ ### 7.3 Caller-side block invocation — the fiber-suspend path
396
+
397
+ When the wrapped object invokes a `:caller`-environment block, the proc
398
+ created by `make_block` runs in the server. That proc needs to:
399
+
400
+ 1. Ship the arguments over to the caller.
401
+ 2. Wait for the caller to produce a result (or exception).
402
+ 3. Return that result (or raise that exception) to the wrapped method
403
+ so execution continues.
404
+
405
+ Naively, step 2 is a blocking wait. But the server cannot just block, because
406
+ other callers (or the same caller, via re-entrancy) might have messages
407
+ waiting, and we do not want to deadlock. Moreover, the server's concurrency
408
+ model is "handle one message at a time in the main loop," which cannot be
409
+ respected if methods can arbitrarily block it.
410
+
411
+ The solution is to run method bodies inside `Fiber`s. When a method needs to
412
+ yield to a caller-side block, its fiber calls `Fiber.yield`. Control returns to
413
+ the main loop, which processes further messages. When a matching
414
+ `FiberReturnMessage` / `FiberExceptionMessage` arrives, the main loop looks up
415
+ the suspended fiber by `fiber_id` and resumes it with the reply message. The
416
+ fiber picks up where it left off and continues the method.
417
+
418
+ Here is the full choreography, in sequence-diagram form, for one block
419
+ invocation (assume sequential mode, `:caller` block):
420
+
421
+ ```
422
+ Caller Ractor Wrapper owner / Server Wrapped object
423
+
424
+ Wrapper#call
425
+ reply_port = Port.new
426
+ send(CallMessage) ────────▶ main_loop
427
+ dispatch_call
428
+ start_method_fiber ──▶ Fiber F
429
+ handle_method
430
+ object.m(&block) ─▶ method runs
431
+ yield arg
432
+ block.call(arg) ◀──┘
433
+ (our synthetic proc)
434
+ fiber_yield_block
435
+ send(FiberYieldMessage
436
+ fiber_id=F.id) ────▶ reply_port
437
+ loop: receive Fiber.yield ←─ suspends F
438
+ FiberYieldMessage ◀────── reply_port
439
+ handle_yield
440
+ run block in caller
441
+ result = ...
442
+ send(FiberReturnMessage) ──▶ server @port
443
+ main_loop
444
+ dispatch_fiber_resume
445
+ resume_method_fiber(msg)
446
+ F.resume(msg) ──▶ fiber_yield_block returns value
447
+ method continues, returns result
448
+ handle_method sends ReturnMessage ▶ reply_port
449
+ loop: receive
450
+ ReturnMessage ◀──────────── reply_port
451
+ return value
452
+ ```
453
+
454
+ Critical invariants:
455
+
456
+ - **Fibers cannot migrate between threads.** A fiber can only be resumed from
457
+ the thread that last resumed it. In sequential mode this is trivially
458
+ satisfied, since the main loop is the only place that runs fibers. In
459
+ threaded mode, §8 explains how the `Dispatcher` preserves this invariant.
460
+ - **The main loop never blocks inside a method.** It only blocks on
461
+ `@port.receive`. All method work is delegated to a fiber (sequential)
462
+ or a worker thread (threaded).
463
+ - **Fiber ids are just `object_id`s.** They are unique while the fiber
464
+ is alive, which is long enough for routing. Once a fiber completes it
465
+ is removed from the `@pending_fibers` / per-worker `pending` hash, so
466
+ stale `fiber_id`s cannot collide with fresh fibers.
467
+
468
+ ### 7.4 Caller-side block invocation — the blocking-fallback path
469
+
470
+ The fiber-suspend path depends on one thing: the block-invoking proc being
471
+ called from the very same fiber that `handle_method` started in. That is true
472
+ for straightforward method bodies. It is _not_ true in two cases:
473
+
474
+ - The wrapped method invokes the block from a nested fiber. The classic
475
+ example is an `Enumerator`, whose `each` runs the user's block in a
476
+ generator fiber, not the outer fiber.
477
+ - The wrapped method invokes the block from a spawned thread.
478
+
479
+ Calling `Fiber.yield` from either context does something different from what we
480
+ need: it either yields the wrong fiber, or raises a `FiberError`. To stay
481
+ functional in these cases, `Server#make_block` captures the expected fiber at
482
+ construction time and checks at call time:
483
+
484
+ ```ruby
485
+ if Fiber.current.equal?(expected_fiber)
486
+ fiber_yield_block(...) # the fast path
487
+ else
488
+ blocking_yield_block(...) # the fallback
489
+ end
490
+ ```
491
+
492
+ The blocking fallback:
493
+
494
+ 1. Creates a fresh temporary `reply_port`.
495
+ 2. Sends a `BlockingYieldMessage` carrying that port.
496
+ 3. Calls `reply_port.receive` — a real, thread-level block.
497
+ 4. The caller's `handle_yield` sends the reply directly to the temporary port.
498
+
499
+ This path _does_ block the invoking thread (or spawned thread / nested fiber).
500
+ That is its defining limitation. Two consequences follow:
501
+
502
+ - **In sequential mode with a nested-fiber block invocation, the server main
503
+ loop is blocked.** No other messages can be processed while the block runs
504
+ in the caller. If the block tries to re-enter the wrapper, it will deadlock.
505
+ The re-entering call goes to the server's port, but the server cannot
506
+ handle it. This is the limitation the README's caveats warn about.
507
+ - **In threaded mode, only one worker is blocked.** Other workers continue to
508
+ service other calls. But the blocked worker still cannot service anything
509
+ else, so a long-running nested-fiber block still reduces effective
510
+ concurrency by one.
511
+
512
+ The hybrid design (fast path where possible, fallback where necessary)
513
+ is a deliberate trade-off: correctness in the common case, plus continued
514
+ functionality in the exotic case, at the cost of a deadlock hazard that
515
+ users have to be aware of when their block is re-entrant _and_ invoked
516
+ from a nested fiber or thread.
517
+
518
+ ### 7.5 `self`-substitution for blocks
519
+
520
+ When a `:caller` block is invoked, it may receive the wrapped object
521
+ itself as an argument (think `each_with_object(self) { ... }`). Before
522
+ shipping the block arguments over the port, `make_block`'s synthetic
523
+ proc replaces any argument that is `equal?(@object)` with `@stub`. This
524
+ keeps the caller from ever seeing the bare object and accidentally
525
+ performing direct operations on it from the wrong Ractor.
526
+
527
+ ---
528
+
529
+ ## 8. Worker thread dispatch — the `Dispatcher`
530
+
531
+ Threaded mode introduces the `Dispatcher` class, which solves two
532
+ problems at once: **work distribution** and **fiber affinity**.
533
+
534
+ ### 8.1 Why not a single shared queue?
535
+
536
+ The obvious design would be: one thread-safe queue, all workers pull.
537
+ That works for new calls. It does _not_ work for fiber resumes. If
538
+ worker A started fiber F, then F suspended, a `FiberReturnMessage` for F
539
+ arrives, and worker B dequeues it, then B cannot resume F, because Ruby
540
+ requires fibers to be resumed from their last resuming thread.
541
+
542
+ ### 8.2 Queue layout
543
+
544
+ The `Dispatcher` holds:
545
+
546
+ - A **shared queue** (`@shared_queue`) for new `CallMessage`s. Any
547
+ worker may dequeue.
548
+ - **Per-worker queues** (`@worker_queues`, indexed by `worker_num`) for
549
+ fiber resumes. Only worker `N` dequeues from `@worker_queues[N]`.
550
+ - A **fiber→worker map** (`@fiber_to_worker`) so the main loop can route
551
+ incoming `FiberReturnMessage` / `FiberExceptionMessage` to the correct
552
+ per-worker queue.
553
+ - Flags: `@closed` and `@crashed`, driving graceful vs abortive shutdowns.
554
+ - A single `@mutex` + `@cond` pair guarding all of the above.
555
+
556
+ Producers call `@cond.broadcast` rather than `@cond.signal` so a worker
557
+ waiting on its per-worker queue is not starved by shared-queue activity.
558
+
559
+ ### 8.3 `dequeue` priority
560
+
561
+ Each worker thread calls `@dispatcher.dequeue(worker_num, accept_calls:)`
562
+ in a loop. Inside the mutex, `dequeue` returns the first of:
563
+
564
+ 1. An item from **its own per-worker queue** (a fiber resume). Always
565
+ considered first, even after close — in-flight fibers must complete.
566
+ 2. `TERMINATE` if `@crashed` is set and the per-worker queue is empty.
567
+ 3. An item from the **shared queue**, but only if `accept_calls` is
568
+ `true` and the dispatcher is not closed.
569
+ 4. A one-shot `CLOSED` sentinel if `@closed` and this worker has not yet
570
+ been told. This wakes the worker so it can transition into a
571
+ "drain pending and exit" state.
572
+ 5. Otherwise, `@cond.wait`.
573
+
574
+ The `accept_calls` flag is the worker's way of saying "I've started
575
+ stopping; don't hand me new work." Once `CLOSED` has been delivered the
576
+ worker sets `stopping = true` and passes `accept_calls: false` on every
577
+ subsequent `dequeue`.
578
+
579
+ ### 8.4 Fiber lifecycle in threaded mode
580
+
581
+ When a worker picks up a `CallMessage`:
582
+
583
+ 1. `start_worker_fiber` creates a fiber whose body is `handle_method`.
584
+ 2. The fiber's `object_id` is the `fiber_id`. The worker registers it
585
+ with `@dispatcher.register_fiber(fiber_id, worker_num)` and stores it
586
+ in a local `pending` hash.
587
+ 3. The worker resumes the fiber. If the fiber completes synchronously
588
+ (no block yield), the worker removes it from `pending` and calls
589
+ `@dispatcher.unregister_fiber`.
590
+ 4. If the fiber suspends (via `Fiber.yield` in `fiber_yield_block`), it
591
+ remains in `pending` and is left registered. The worker goes back to
592
+ the dispatch loop.
593
+ 5. Eventually a `FiberReturnMessage` / `FiberExceptionMessage` arrives at the
594
+ server's main port. The main loop calls `@dispatcher.enqueue_fiber_resume`,
595
+ which looks up `@fiber_to_worker[fiber_id]` and pushes onto the right
596
+ per-worker queue.
597
+ 6. The owning worker dequeues it, resumes the fiber, and either completes it
598
+ or suspends it again.
599
+
600
+ If a fiber-resume arrives for a `fiber_id` that is no longer registered,
601
+ `enqueue_fiber_resume` returns `false` and the main loop logs
602
+ "Discarding orphan fiber resume." This can happen if the worker that
603
+ owned the fiber crashed, since `cleanup_worker` unregisters all pending
604
+ fibers before the server observes `WorkerStoppedMessage`.
605
+
606
+ ### 8.5 Why the main loop still routes fiber resumes
607
+
608
+ An alternative would be for workers to receive their fiber resumes
609
+ directly (e.g., each worker owning a port). The current design keeps
610
+ everything flowing through `@port` so there is exactly one place the
611
+ server receives messages. That simplifies:
612
+
613
+ - Shutdown: draining one port drains everything.
614
+ - Logging: one locus of message observation.
615
+ - Caller-side symmetry: callers only need to know one port.
616
+
617
+ The cost is an extra hop (main loop → dispatcher → worker), but this is
618
+ cheap because the main loop does no work beyond enqueue.
619
+
620
+ ---
621
+
622
+ ## 9. Server lifecycle
623
+
624
+ `Server#run` is the top of the state machine:
625
+
626
+ ```ruby
627
+ def run
628
+ receive_remote_object if @isolated
629
+ start_workers if @threads_requested
630
+ main_loop
631
+ stop_workers if @threads_requested
632
+ cleanup
633
+ @object
634
+ rescue Exception => e
635
+ @crash_exception = e
636
+ @object
637
+ ensure
638
+ crash_cleanup if @crash_exception
639
+ end
640
+ ```
641
+
642
+ Expressed as phases:
643
+
644
+ ```
645
+ ┌────────────────────┐
646
+ │ init (isolated) │ receive_remote_object
647
+ └─────────┬──────────┘
648
+
649
+ ┌────────────────────┐
650
+ │ start workers │ only if threads > 0
651
+ └─────────┬──────────┘
652
+
653
+ ┌────────────────────┐ accepts CallMessage, FiberReturn/Exception,
654
+ │ RUNNING │ StopMessage, JoinMessage, WorkerStoppedMessage.
655
+ │ (main_loop) │ Exits on: StopMessage, or unexpected worker death.
656
+ └─────────┬──────────┘
657
+
658
+ ┌────────────────────┐ sequential: drain_pending_fibers (inline);
659
+ │ STOPPING │ threaded: stop_workers (close dispatcher,
660
+ │ │ wait for WorkerStoppedMessage from each).
661
+ └─────────┬──────────┘ Refuses new calls with StoppedError.
662
+
663
+ ┌────────────────────┐ Close @port, drain remaining messages,
664
+ │ CLEANUP │ respond to outstanding join requests.
665
+ └─────────┬──────────┘
666
+
667
+ (return @object — terminal value for the Ractor in isolated mode)
668
+
669
+ ──────────────────────────── crash path ──────────────────────────────
670
+ Any uncaught exception from the above jumps to:
671
+ ┌────────────────────┐ crash_cleanup:
672
+ │ CRASH CLEANUP │ • abort_pending_fibers (sequential)
673
+ │ (ensure block) │ • drain_dispatcher_after_crash (threaded)
674
+ └────────────────────┘ • drain_inbox_after_crash
675
+ • join_workers_after_crash (threaded)
676
+ • respond to join requests
677
+ ```
678
+
679
+ ### 9.1 Running phase — `main_loop`
680
+
681
+ `Server#main_loop` reads from `@port.receive` and dispatches on message type:
682
+
683
+ - `CallMessage` → `dispatch_call`, which in sequential mode starts a new
684
+ fiber inline (`start_method_fiber`), or in threaded mode pushes onto
685
+ the dispatcher's shared queue (`@dispatcher.enqueue_call`).
686
+ - `FiberReturnMessage` / `FiberExceptionMessage` → `dispatch_fiber_resume`,
687
+ which resumes the fiber inline (sequential) or enqueues onto the
688
+ correct per-worker queue (threaded).
689
+ - `JoinMessage` → added to `@join_requests`; reply is sent when the
690
+ server finishes.
691
+ - `StopMessage` → initiates graceful shutdown. In sequential mode, the
692
+ main loop first calls `drain_pending_fibers` to let any suspended
693
+ methods complete.
694
+ - `WorkerStoppedMessage` → an _unexpected_ worker death during running
695
+ phase. Treat it as a fatal signal and break out of the loop. The
696
+ cleanup code down-stream takes over.
697
+
698
+ ### 9.2 Stopping phase — sequential vs threaded
699
+
700
+ In sequential mode, the stopping logic is just `drain_pending_fibers`:
701
+ continue to receive messages on `@port`, refuse any new `CallMessage`,
702
+ forward `FiberReturnMessage`/`FiberExceptionMessage` to their fibers,
703
+ queue any late `JoinMessage`s, and exit once `@pending_fibers` is empty.
704
+
705
+ In threaded mode, `stop_workers` is more involved:
706
+
707
+ 1. Call `@dispatcher.close`, which flips `@closed` and drains (returns)
708
+ any `CallMessage`s that were queued but never picked up. Those
709
+ messages are refused with `StoppedError`. This is important: without
710
+ this step, callers whose messages arrived _after_ stop but before a
711
+ worker could dequeue would hang forever.
712
+ 2. Each worker, on its next `dequeue`, receives the one-shot `CLOSED`
713
+ sentinel. It sets `stopping = true` and stops accepting new calls.
714
+ 3. The main loop continues to receive messages, but now it must:
715
+ - Refuse any `CallMessage` that still arrives.
716
+ - Forward `FiberReturnMessage`/`FiberExceptionMessage` through the
717
+ dispatcher so that workers can finish their suspended methods.
718
+ - Acknowledge `WorkerStoppedMessage` and decrement `@active_workers`.
719
+ - Queue late `JoinMessage`s.
720
+ 4. Loop until all workers have reported stopped.
721
+
722
+ ### 9.3 Cleanup phase
723
+
724
+ `cleanup` closes `@port` and then drains anything left in it. Callers
725
+ whose messages arrive after port close get `Ractor::ClosedError` on
726
+ send — nothing the server can do for them. But any `CallMessage` that
727
+ was already in the port before close is still refused, and any late
728
+ `JoinMessage` is answered immediately.
729
+
730
+ Finally, all queued join requests get a `JoinReplyMessage`.
731
+
732
+ ### 9.4 Why `main_loop` breaks out on unexpected worker death
733
+
734
+ The `WorkerStoppedMessage` in the running-phase branch of `main_loop` covers
735
+ the case where a worker thread dies _without_ going through the graceful stop
736
+ path, typically because it raised an exception we did not catch. When this
737
+ happens the server declares the situation unsafe: the invariant "every pending
738
+ fiber has a living worker to resume it" is broken, and rather than try to fix
739
+ it in place (by e.g. restarting the worker), the server shuts down. This is
740
+ opinionated: the author chose reliability of shutdown semantics over continued
741
+ availability of other workers.
742
+
743
+ ### 9.5 Join
744
+
745
+ `Wrapper#join` has two implementations:
746
+
747
+ - **Isolated mode:** `@ractor.join` — relies on the underlying
748
+ `Ractor#join` to block until the server Ractor terminates.
749
+ - **Local mode:** there is no Ractor to join, so the wrapper sends a
750
+ `JoinMessage` with a fresh reply port and waits for
751
+ `JoinReplyMessage`. The server adds the reply port to its
752
+ `@join_requests` list and replies in cleanup or crash cleanup.
753
+
754
+ The docstring on `Wrapper#join` notes an important deviation from
755
+ `Thread#join` / `Ractor#join`: a crashed wrapper does _not_ propagate
756
+ its exception out of `join`. The reasoning is that wrapper crashes are
757
+ typically internal bugs (in the server's dispatch code, not in the
758
+ wrapped object's methods), and we already deliver `CrashedError` to any
759
+ pending caller; re-raising in `join` would just produce a duplicate
760
+ error at an awkward point.
761
+
762
+ ### 9.6 Recovering the object
763
+
764
+ In isolated mode, `@ractor.value` returns the wrapped object after the
765
+ Ractor has terminated. `Server#run` is written to always return `@object`
766
+ from both the success and rescue paths, so even a crashed server
767
+ surrenders the object (modulo Ruby's own post-mortem rules). This is
768
+ intentional: the object is yours, you may want to clean it up yourself,
769
+ and the wrapper should not hold it hostage.
770
+
771
+ In local mode, `recover_object` raises. The object never moved, so there is
772
+ nothing to recover.
773
+
774
+ ---
775
+
776
+ ## 10. Graceful stop and crash cleanup in detail
777
+
778
+ Graceful stop is covered in §9. This section focuses on what happens
779
+ when something goes wrong.
780
+
781
+ ### 10.1 What constitutes a crash
782
+
783
+ Any uncaught exception inside `Server#run` ends up in the `rescue` clause.
784
+ This can originate from:
785
+
786
+ - A bug in the server's dispatch code itself.
787
+ - An unexpected `Ractor::ClosedError` when the port is already closed
788
+ (though most sites catch this explicitly).
789
+ - An exception during fiber management.
790
+
791
+ Notably, exceptions raised by the _wrapped object's methods_ are **not**
792
+ crashes. They are caught by `handle_method`'s `rescue ::Exception` clause and
793
+ converted into an `ExceptionMessage` sent to the caller. The server itself
794
+ stays alive.
795
+
796
+ A worker thread crash is detected via `WorkerStoppedMessage` (its normal stop
797
+ notification) arriving during the running phase, or via the worker's own
798
+ `ensure` block catching its exception (see `worker_loop`'s `crash_exception`
799
+ path).
800
+
801
+ ### 10.2 `crash_cleanup`
802
+
803
+ The goal of `crash_cleanup` is to deliver a `CrashedError` to every caller who
804
+ would otherwise hang, and to unblock any join waiters. It does as much as
805
+ possible, swallowing further errors, because by this point the server is
806
+ definitely going away and best-effort is the only realistic policy.
807
+
808
+ Steps:
809
+
810
+ 1. **Threaded mode:** `drain_dispatcher_after_crash` calls
811
+ `@dispatcher.crash_close`. This (a) sets `@crashed`, so future
812
+ `dequeue` calls return `TERMINATE` on empty per-worker queues,
813
+ causing workers to exit instead of waiting forever; and (b) returns
814
+ the shared queue's undispatched messages so the server can send
815
+ `CrashedError` to each.
816
+ 2. **Sequential mode:** `abort_pending_fibers` calls `fiber.raise(error)`
817
+ on each suspended fiber. The exception emerges from the fiber's
818
+ `Fiber.yield` call; `handle_method`'s `rescue ::Exception` catches it
819
+ and sends an `ExceptionMessage(CrashedError)` to the fiber's reply
820
+ port. So the caller observes `CrashedError` — the same error class
821
+ they would get in threaded mode.
822
+ 3. `drain_inbox_after_crash` closes `@port` and drains anything left,
823
+ sending `CrashedError` to any `CallMessage` senders and
824
+ `JoinReplyMessage` to any `JoinMessage` senders.
825
+ 4. **Threaded mode:** `join_workers_after_crash` waits for all workers to
826
+ finish. Workers' own `cleanup_worker` runs in their ensure blocks: they
827
+ abort _their_ pending fibers (delivering `CrashedError` to each caller),
828
+ unregister the fibers, and make a best effort to send
829
+ `WorkerStoppedMessage` back to the main loop.
830
+ 5. Any remaining `@join_requests` are answered.
831
+
832
+ ### 10.3 Why `fiber.raise` for sequential cleanup?
833
+
834
+ It might be simpler to iterate over `@pending_fibers` and send `CrashedError`
835
+ directly to each fiber's reply port. The reason `fiber.raise` is preferred is
836
+ that it runs the fiber's rescue and ensure blocks, allowing the method (and any
837
+ wrapper code around it) to clean up, e.g., releasing locks, closing file
838
+ handles opened inside the method. This better respects the wrapped object's
839
+ invariants at the cost of being slightly slower and more fallible.
840
+
841
+ ### 10.4 The best-effort nature of cleanup
842
+
843
+ `crash_cleanup` wraps almost everything in `rescue ::Exception`. This is
844
+ deliberate: we are already handling a crash and the priority is to get
845
+ through the cleanup steps without aborting partway. A lost `CrashedError`
846
+ delivery is regrettable but tolerable; a stuck wrapper is not.
847
+
848
+ ---
849
+
850
+ ## 11. Design trade-offs and known limitations
851
+
852
+ This section collects the trade-offs already mentioned throughout,
853
+ plus a few others, in one place for the benefit of readers deciding
854
+ whether the library fits their use case.
855
+
856
+ ### 11.1 Re-entrancy from nested fibers or spawned threads can deadlock
857
+
858
+ The fiber-suspend path is only available when the block invocation
859
+ happens on the same fiber that started the method. Nested fibers
860
+ (most visibly inside `Enumerator`) and spawned threads fall back to
861
+ the blocking path, which does not release the server to service
862
+ further messages. If such a block tries to re-enter the wrapper, the
863
+ re-entering call arrives at `@port` but nothing can pick it up — the
864
+ server is blocked inside the very call that sent it. This deadlocks.
865
+
866
+ Mitigations:
867
+ - Configure the method with `block_environment: :wrapped` if the
868
+ block is self-contained.
869
+ - Avoid re-entering the wrapper from blocks called from within
870
+ Enumerator generators or user-spawned threads.
871
+ - In threaded mode the blast radius is one worker, not the whole
872
+ server. With enough workers this is survivable.
873
+
874
+ ### 11.2 Blocks configured as `:caller` cannot outlive the method call
875
+
876
+ The synthetic proc generated by `make_block` relies on the caller still
877
+ being in its `Wrapper#call` reply loop. If the wrapped object saves the
878
+ block (as a callback, say) and invokes it later, the caller is long
879
+ gone and the fiber-yield / blocking-yield both have no one to reply.
880
+ The library does not currently detect this at save time — the failure
881
+ manifests at invocation time when the message goes nowhere.
882
+
883
+ If you need to register a callback, prefer `block_environment: :wrapped`
884
+ so the block travels as a `Ractor.shareable_proc` and is invoked
885
+ in-place.
886
+
887
+ ### 11.3 Exceptions lose their backtrace
888
+
889
+ As of Ruby 4.0, exceptions transferred between Ractors are always
890
+ copied (not moved) and the backtrace is cleared. This is a Ruby bug
891
+ (tracked at bugs.ruby-lang.org issue 21818) that the library cannot
892
+ work around, and it applies both to exceptions raised by the wrapped
893
+ method and to exceptions raised by caller-side blocks.
894
+
895
+ ### 11.4 Non-shareable, non-movable types cannot cross the boundary
896
+
897
+ Ractor's own rules apply. Threads, procs (non-shareable), backtraces,
898
+ and a few other types cannot be passed as arguments or returned as
899
+ values. `:move` can help with some cases (large strings, arrays of
900
+ mutable values), but some types cannot be moved at all.
901
+
902
+ ### 11.5 Worker count is a hard ceiling on parallelism
903
+
904
+ The `threads` setting is the maximum number of concurrent method bodies.
905
+ The library does not grow or shrink the pool. Sizing it right requires
906
+ knowing both the workload's natural concurrency and, if you are using
907
+ blocking-fallback paths, the expected number of simultaneously-blocked
908
+ workers.
909
+
910
+ Suspended fibers (the common re-entrancy case) do _not_ occupy a worker,
911
+ so re-entrancy depth does not need to be part of the calculation.
912
+
913
+ ### 11.6 No method-level timeouts
914
+
915
+ A misbehaving wrapped method that never returns will block its worker
916
+ (or the server itself, in sequential mode) indefinitely. There is no
917
+ built-in timeout. Callers can avoid their own indefinite wait by
918
+ implementing timeouts around their stub calls, but the server-side
919
+ work will still occupy the thread.
920
+
921
+ ### 11.7 Experimental status
922
+
923
+ The library is self-described as experimental, and the README repeats
924
+ this warning prominently. This is true both of the library and of
925
+ Ractors in general in Ruby 4.0. Expect behavior to evolve as Ruby's
926
+ Ractor implementation matures; internals may change in lock-step.
927
+
928
+ ---
929
+
930
+ ## 12. Putting it together — a worked walkthrough
931
+
932
+ To make all of the above concrete, here is the full story for a single
933
+ call to `stub.find_by_id(42)` against a SQLite3 wrapper configured with
934
+ `use_current_ractor: true, threads: 2`, from a caller in a different
935
+ Ractor. Assume `:caller` block environment and no block is passed in
936
+ this example.
937
+
938
+ 1. **Caller Ractor invokes `stub.find_by_id(42)`.** `Stub#method_missing`
939
+ forwards to `Wrapper#call(:find_by_id, 42)`.
940
+ 2. **`Wrapper#call` prepares a `CallMessage`.** It creates a fresh
941
+ `Ractor::Port` as `reply_port`, generates a `transaction` id, fetches
942
+ the per-method `MethodSettings`, computes `block_arg = nil` (no block
943
+ was given), and sends the `CallMessage` on `@port`.
944
+ 3. **Server's main loop (running in a Thread in the host Ractor)
945
+ receives the message.** It calls `dispatch_call`, which since threads
946
+ were requested, calls `@dispatcher.enqueue_call(message)`. The
947
+ dispatcher pushes onto its shared queue and broadcasts.
948
+ 4. **Some worker (say worker 0) dequeues `[:call, message]`.** It calls
949
+ `start_worker_fiber`, which creates a fiber around
950
+ `handle_method(message, worker_num: 0)`, registers the fiber with the
951
+ dispatcher, and resumes it.
952
+ 5. **Inside the fiber, `handle_method` calls
953
+ `@object.__send__(:find_by_id, 42)`.** No block is involved, so
954
+ `make_block` returns `nil`. The DB object does its work and returns a row.
955
+ 6. **`handle_method` sends a `ReturnMessage(row)` to
956
+ `message.reply_port`.** The fiber completes. The worker removes it
957
+ from its local `pending` and unregisters from the dispatcher. The
958
+ worker loops back to `@dispatcher.dequeue`.
959
+ 7. **Caller's `Wrapper#call` `receive`s the reply.** Its loop matches
960
+ `ReturnMessage`, returns `row`. The `ensure` block closes the
961
+ `reply_port`. `Stub#method_missing` returns `row` to the caller.
962
+
963
+ Now insert a block: `stub.find_by_id(42) { |r| transform(r) }`.
964
+
965
+ - Between steps 5 and 6, the wrapped method invokes the block. Because
966
+ `block_environment: :caller` is the default, `make_block` has wrapped
967
+ it in a proc that:
968
+ - Substitutes the stub for any argument equal to `@object`.
969
+ - Checks that `Fiber.current` equals the fiber that `handle_method`
970
+ started in. It does, so:
971
+ - Sends a `FiberYieldMessage(args: [row], kwargs: {}, fiber_id: F)`
972
+ to `reply_port`.
973
+ - Calls `Fiber.yield`.
974
+ - Worker 0 is now free and returns to the dispatch loop (the fiber is
975
+ suspended but registered).
976
+ - The caller's `call` loop receives `FiberYieldMessage`, runs `handle_yield`,
977
+ which calls the block, captures `transform(row)`, and sends a
978
+ `FiberReturnMessage(transformed, F)` to the server's main `@port`.
979
+ - The main loop receives the `FiberReturnMessage`, calls
980
+ `dispatch_fiber_resume`, which goes through the dispatcher:
981
+ `@dispatcher.enqueue_fiber_resume(message)` looks up
982
+ `@fiber_to_worker[F]` (which is worker 0), pushes onto worker 0's
983
+ per-worker queue, and broadcasts.
984
+ - Worker 0's `dequeue` finds the per-worker queue non-empty, returns
985
+ `[:resume, message]`. `resume_worker_fiber` resumes fiber F with
986
+ the message. Inside `fiber_yield_block`, `Fiber.yield` returns the
987
+ `FiberReturnMessage`; the block-proc unwraps `.value` and returns
988
+ `transformed` to the wrapped method.
989
+ - The wrapped method completes, returns, `handle_method` sends
990
+ `ReturnMessage` back to `reply_port`. The fiber ends, the worker
991
+ cleans it up and goes back to the dispatch loop.
992
+ - The caller's `call` loop finally receives `ReturnMessage` and returns.
993
+
994
+ This is the full dance for one call with one block invocation. Every step can
995
+ be logged, and the `transaction` id ties them together in the output.
996
+
997
+ ---
998
+
999
+ ## 13. Summary
1000
+
1001
+ `Ractor::Wrapper` achieves shared access to a non-shareable object by:
1002
+
1003
+ - Running the object in a controlled server (either a dedicated Ractor
1004
+ or a set of threads in the creating Ractor).
1005
+ - Exposing a frozen, shareable `Stub` that forwards calls via a
1006
+ message-passing protocol with per-call reply ports.
1007
+ - Using fibers to run method bodies so that they can suspend cleanly
1008
+ when caller-side blocks need to execute, without blocking the
1009
+ server's main message loop.
1010
+ - Using a custom `Dispatcher` in threaded mode that routes new calls
1011
+ through a shared queue but fiber resumes through per-worker queues,
1012
+ preserving Ruby's fiber-to-thread affinity.
1013
+ - Falling back to a blocking path when a block is invoked from a nested
1014
+ fiber or spawned thread, trading re-entrancy for continued functionality.
1015
+ - Modeling configuration as a frozen value object that travels with
1016
+ each call, keeping the server stateless with respect to settings.
1017
+ - Providing a carefully staged lifecycle (running → stopping →
1018
+ cleanup) with a separate crash-cleanup path that makes a
1019
+ best-effort attempt to unblock every pending caller and join
1020
+ waiter when something goes wrong.
1021
+
1022
+ The net effect is a library that tries to make using a non-shareable object
1023
+ from multiple Ractors look as close as possible to calling it directly, while
1024
+ being honest about the edges where the abstraction leaks.