wineole 0.1.0 → 0.2.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.
@@ -0,0 +1,534 @@
1
+ require 'weakref'
2
+ require_relative 'errors'
3
+
4
+ module WineOLE
5
+ # The one dispatcher thread of a CONNECTION, and the queue it parks on.
6
+ #
7
+ # One thread per connection is a promise the README makes to the caller
8
+ # ("Callbacks run on one dispatcher thread per connection, in arrival
9
+ # order, one at a time"), and the whole value of it is that a caller never
10
+ # needs a lock BETWEEN callbacks: a Hash shared by an Application callback
11
+ # and a Workbook callback is safe because the two can never be inside their
12
+ # blocks at the same time. A thread per `Events` object breaks exactly that
13
+ # and nothing else -- measured, with two `Events` on one connection: both
14
+ # callbacks were inside their blocks simultaneously, on 2 distinct threads,
15
+ # while every other event assertion in the suite still passed.
16
+ #
17
+ # Everything here is DERIVED from there being a registered callback
18
+ # somewhere on the connection, the same way `Events` derives its
19
+ # subscription and its Advise from one: the thread and the sink go up at
20
+ # the first `attach` and come back down after the last `detach`. An
21
+ # `ole_events` nobody registered on costs no thread, and a connection whose
22
+ # callbacks have all been removed goes back to costing none.
23
+ #
24
+ # THE MUTEX IS NEVER HELD WHILE USER CODE RUNS. A callback that registers a
25
+ # callback on ANOTHER object -- `sheet.ole_events.on(...)` from inside an
26
+ # Application callback -- reaches `attach` on this very thread, and would
27
+ # deadlock against a lock its own delivery still held.
28
+ class Dispatcher
29
+ def initialize(client)
30
+ @client = client
31
+ # handle -> Events, at most ONE Events per handle per connection. That
32
+ # invariant is what makes a handle a sufficient routing key, and
33
+ # `Proxy#ole_events` is what guarantees it in shipped code: it memoizes,
34
+ # and a bridge id is unique per session, so two Proxies never share a
35
+ # handle. `Events.new(client, handle)` is public, though, so `attach`
36
+ # enforces it rather than trusting it -- a second Events silently
37
+ # unseating the first would leave a registered callback that never
38
+ # fires, and either object's last `off` would then stop the other.
39
+ #
40
+ # Held STRONGLY and on purpose. This is what keeps an
41
+ # `Events` alive exactly as long as it has registrations, even when the
42
+ # caller kept no reference to the Proxy it came from
43
+ # (`xl.ole_events.on('Click') { }` leaves the caller holding nothing).
44
+ # The chain is Client -> Dispatcher -> @targets -> Events. Hold them
45
+ # weakly and the Events is collected out from under a live connection:
46
+ # callbacks stop firing, the bridge stays advised, and every event it
47
+ # goes on sending leaks its argument handles because nobody is left to
48
+ # release them. Nothing here pins the Client from outside, because the
49
+ # Client holds this Dispatcher and the Events holds the Client -- the
50
+ # whole ring is collected together or not at all.
51
+ @targets = {}
52
+ # handle -> on_cleanup closure. Registered by a Proxy on the on_cleanup
53
+ # path; a $cleanup frame for that handle runs the closure here, on the
54
+ # dispatcher thread, the same COM-safe context every other callback runs
55
+ # on. Kept beside @targets because a client that uses on_cleanup but
56
+ # subscribes to no events still needs the sink and thread up, so this
57
+ # counts toward "is there a registered callback on the connection" the
58
+ # same way @targets does.
59
+ @cleanups = {}
60
+ @mutex = Mutex.new
61
+ @queue = Queue.new
62
+ @sink = nil
63
+ @thread = nil
64
+ # A collected Dispatcher must not leave its thread parked on a queue
65
+ # nothing can ever push to again. The finalizer pushes the same :stop
66
+ # the end of the stream does, and captures the QUEUE ONLY: capturing
67
+ # `self` would keep this object reachable from ObjectSpace's finalizer
68
+ # table forever, so it could never be collected and the finalizer could
69
+ # never run -- the same rule as Client.finalizer.
70
+ ObjectSpace.define_finalizer(self, self.class.stopper(@queue))
71
+ end
72
+
73
+ def self.stopper(queue)
74
+ proc { queue << :stop }
75
+ end
76
+
77
+ # The connection's one sink, built on the class so it captures the queue
78
+ # and nothing else. It is registered once, at the first `attach`, and
79
+ # does no filtering: every frame on the connection goes on the one queue
80
+ # and is routed by handle on the dispatcher thread, which is what makes
81
+ # "in arrival order, one at a time" true across objects rather than
82
+ # within one.
83
+ #
84
+ # This runs on the reader thread -- or, for a client whose stream has
85
+ # already ended, on the thread that is registering right now. It must
86
+ # enqueue and return either way: it may not block and it may not raise.
87
+ def self.sink(queue)
88
+ proc do |frame|
89
+ # nil means the connection is gone.
90
+ queue << (frame.nil? ? :stop : frame)
91
+ end
92
+ end
93
+
94
+ # Registers `events` as the target for its handle, and puts up whatever
95
+ # the connection does not have yet. Called from `Events#arm` under that
96
+ # object's @wire_mutex; two `Events` on one connection have two of those,
97
+ # so @mutex is what makes this safe between them.
98
+ #
99
+ # The calls out of this class made under @mutex are `Client#on_event`
100
+ # here and `Client#off_event` in `detach`, and both are safe by
101
+ # inspection: neither runs user code (the sink above is ours, and only
102
+ # enqueues), and the locks they take -- the Client's sink list and the
103
+ # Mailbox's -- are ordered against @mutex, not disjoint from it. This
104
+ # class does wait for the Mailbox's lock, in `release`, but only with
105
+ # @mutex NOT held; and nothing holding either of those locks ever waits
106
+ # for @mutex: `Client#on_event` lets its sink list go before it calls
107
+ # the Mailbox, and the Mailbox drops its lock before it calls a sink.
108
+ # The one thing either can do on this thread -- `on_event` handing an
109
+ # end-of-stream nil straight back when the stream has already ended --
110
+ # only pushes :stop on the queue.
111
+ #
112
+ # That hand-off reaches the FIRST object to arm on a dead connection and
113
+ # not a second one, because by then the sink is already registered: the
114
+ # second object's thread parks on a queue nothing will push to until the
115
+ # Dispatcher's finalizer pushes :stop as the ring is collected. Bounded,
116
+ # and no ordinary path reaches it -- `client.close` still ends the
117
+ # stream through the one sink that is up.
118
+ def attach(handle, events)
119
+ @mutex.synchronize do
120
+ current = @targets[handle]
121
+ if current && !current.equal?(events)
122
+ # Only a DIFFERENT object is refused: the caller holding two
123
+ # `Events` for one object. The same one is let through so that a
124
+ # re-attach can never be mistaken for that (an ordinary `on` after
125
+ # `close` finds the slot empty, since `detach` cleared it). See
126
+ # @targets.
127
+ raise ArgumentError,
128
+ "handle #{handle} already has an Events on this connection; one object's " \
129
+ 'events belong to one Events (Proxy#ole_events memoizes for this reason)'
130
+ end
131
+
132
+ @targets[handle] = events
133
+ if @sink.nil?
134
+ @sink = Dispatcher.sink(@queue)
135
+ @client.on_event(&@sink)
136
+ end
137
+ next if @thread&.alive?
138
+
139
+ # `&Dispatcher.method(:run)`, not `{ run_loop }`. A block captures its
140
+ # whole binding, `self` included, so a thread started from a block
141
+ # written in an instance method is a permanent GC root for this
142
+ # Dispatcher -- and through @client, for the Client. Measured on
143
+ # exactly that code, one layer down: the Client was never collected,
144
+ # its finalizer never ran, and its socket stayed open for the life of
145
+ # the process. That is the same leak the Mailbox was extracted to fix
146
+ # (see Client::Mailbox), walked back in through this thread. A Method
147
+ # object on the class captures the class and nothing else, so what
148
+ # the thread holds is a WeakRef and a queue.
149
+ @thread = Thread.new(WeakRef.new(self), @queue, &Dispatcher.method(:run))
150
+ @thread.abort_on_exception = false
151
+ end
152
+ self
153
+ end
154
+
155
+ # The other half of `attach`, for one object. The connection's thread and
156
+ # sink only come down with the LAST one: an Application whose callbacks
157
+ # are all removed must not stop the Workbook's events on the same
158
+ # connection.
159
+ def detach(handle, events)
160
+ @mutex.synchronize do
161
+ # By identity, and for the reason `Client#off_event` uses identity:
162
+ # removing a routing entry that belongs to somebody else silently
163
+ # stops a live consumer. With `attach` refusing a second Events on
164
+ # one handle this can only be a stale detach -- `disarm` on an object
165
+ # that has already left -- but "it cannot happen" is not a reason to
166
+ # write the unguarded delete.
167
+ @targets.delete(handle) if @targets[handle].equal?(events)
168
+ sink = @sink
169
+ # A registered cleanup keeps the sink and thread up even with no
170
+ # events left: the $cleanup frame it is waiting for still has to be
171
+ # delivered on this connection's dispatcher.
172
+ next unless @targets.empty? && @cleanups.empty? && sink
173
+
174
+ @sink = nil
175
+ @client.off_event(sink)
176
+ # After the sink comes off, never before: the marker means "nothing
177
+ # more arrives behind this through the sink", and a frame the reader
178
+ # pushed between the two is behind the marker, drained by
179
+ # `confirm_idle` and released. Not quite airtight: a reader ALREADY
180
+ # inside `dispatch_to_sinks` when `off_event` ran still holds the old
181
+ # sink, and can push after the drain too. Such a frame stays on the
182
+ # queue with no thread, until the next `attach` starts one -- which
183
+ # delivers it if that object is back for the event, and releases it
184
+ # otherwise. Bounded by that one dispatch, and unseen in a 400-cycle
185
+ # detach/attach stress (2572 frames), but it is there. Never a join,
186
+ # either -- the thread reaching here is very often the dispatcher
187
+ # itself, in a callback that called `off`.
188
+ @queue << :idle
189
+ end
190
+ self
191
+ end
192
+
193
+ # Register a client closure to run when the bridge asks (a $cleanup frame
194
+ # for `handle`). Arms the connection's sink and thread the same way
195
+ # `attach` does, so a client that uses on_cleanup but subscribes to no
196
+ # events still has a dispatcher to deliver the frame on. Runs no user code
197
+ # under @mutex -- storing a closure and installing our own sink are the
198
+ # only things done here -- so holding it across the whole body is safe,
199
+ # exactly as it is in `attach`.
200
+ def register_cleanup(handle, block)
201
+ @mutex.synchronize do
202
+ @cleanups[handle] = block
203
+ if @sink.nil?
204
+ @sink = Dispatcher.sink(@queue)
205
+ @client.on_event(&@sink)
206
+ end
207
+ next if @thread&.alive?
208
+
209
+ # A Method object on the class, never a block: see `attach` for why a
210
+ # block started as a thread here is a permanent GC root for the Client.
211
+ @thread = Thread.new(WeakRef.new(self), @queue, &Dispatcher.method(:run))
212
+ @thread.abort_on_exception = false
213
+ end
214
+ self
215
+ end
216
+
217
+ # The other half of `register_cleanup`. Brings the sink and thread down
218
+ # with the `@queue << :idle` hand-off, exactly as `detach`'s last-target
219
+ # case does -- but only when nothing is left to deliver to: a cleanup
220
+ # removed while events are still registered must not stop them, and vice
221
+ # versa, which is why the guard is the same `@targets.empty? &&
222
+ # @cleanups.empty?` teardown condition.
223
+ def unregister_cleanup(handle)
224
+ @mutex.synchronize do
225
+ @cleanups.delete(handle)
226
+ sink = @sink
227
+ next unless @targets.empty? && @cleanups.empty? && sink
228
+
229
+ @sink = nil
230
+ @client.off_event(sink)
231
+ @queue << :idle
232
+ end
233
+ self
234
+ end
235
+
236
+ # The dispatcher thread's body, on the class so it captures no instance.
237
+ # The Dispatcher is reached weakly and never held across a `pop`, which
238
+ # is what lets a Client with events on it still be collected.
239
+ #
240
+ # THIS THREAD SURVIVES EVERYTHING. A dead dispatcher is permanent and
241
+ # silent: the callbacks stay registered, the bridge stays advised, every
242
+ # later event's argument handles leak on the bridge for the life of the
243
+ # connection, the queue grows without bound, and `on_error` is never
244
+ # told. Measured on the code before this rescue existed -- a callback
245
+ # raising outside StandardError, or a frame whose `args` was not an
246
+ # array -- 10 later events were queued and never delivered, and 1 of 11
247
+ # release_events was sent. So the rescue is `Exception`, not
248
+ # StandardError: the narrow rescue exists to let a fatal exception reach
249
+ # a thread that can act on it, and there is no such thread here -- this
250
+ # one dying takes the whole feature down without a word. Reported through
251
+ # `report`, which cannot raise, and the loop goes on to the next frame.
252
+ def self.run(ref, queue)
253
+ while (item = queue.pop)
254
+ break if item == :stop
255
+
256
+ dispatcher = begin
257
+ ref.__getobj__
258
+ rescue WeakRef::RefError
259
+ break # there is nobody left to deliver to
260
+ end
261
+
262
+ begin
263
+ break if step(dispatcher, item)
264
+ rescue Exception => e # rubocop:disable Lint/RescueException
265
+ dispatcher.__send__(:report, e, item)
266
+ end
267
+ # Dropped before parking again: a local still pointing at the
268
+ # Dispatcher would pin it for as long as this thread waits, which is
269
+ # precisely what the WeakRef is here to avoid.
270
+ dispatcher = nil
271
+ end
272
+ ensure
273
+ # However this thread ended, the Dispatcher must not go on believing it
274
+ # has one: `attach` decides whether to start a thread from exactly that.
275
+ begin
276
+ ref.__getobj__.__send__(:thread_finished, Thread.current)
277
+ rescue WeakRef::RefError
278
+ nil
279
+ end
280
+ end
281
+
282
+ # One queued item. Answers whether the thread is to end. On the class,
283
+ # like `run`, so it captures no instance -- and it no longer needs the
284
+ # queue: what is left on it when this thread goes is taken off under
285
+ # @mutex by `confirm_idle`, which is the whole of the fix that made the
286
+ # hand-off safe.
287
+ def self.step(dispatcher, item)
288
+ case item
289
+ when :idle
290
+ # `detach` left this behind when the last target went away. It only
291
+ # ends the thread if that is still true: an `attach` that got in
292
+ # first answers no and the SAME thread carries on, which is what
293
+ # keeps "one dispatcher, arrival order" true across a detach/attach
294
+ # cycle instead of briefly running two.
295
+ #
296
+ # When the answer is yes, `confirm_idle` hands back everything that
297
+ # was still queued -- taken off the queue under the same lock, at the
298
+ # same instant, as the thread slot was cleared. Releasing it is a
299
+ # round trip per frame, so it is done HERE, out of that lock: holding
300
+ # @mutex across a round trip would stall every attach and detach on
301
+ # the connection behind it. A thread that has decided to go can
302
+ # therefore still be RELEASING while its successor runs, but it can
303
+ # no longer take anything off the queue, which is what it must not
304
+ # do: a frame pushed after that instant came from a later `attach`'s
305
+ # sink, and that sink was installed under the same lock.
306
+ leftover = dispatcher.__send__(:confirm_idle)
307
+ return false if leftover.nil?
308
+
309
+ # Whatever happens in here, the answer is "end": the slot is already
310
+ # clear, so a successor may be running, and a raise escaping to `run`'s
311
+ # rescue would keep THIS thread on the queue beside it. `release`
312
+ # rescues StandardError itself; this is for the rest.
313
+ begin
314
+ leftover.each { |left| dispatcher.__send__(:release, left) }
315
+ rescue Exception => e # rubocop:disable Lint/RescueException
316
+ dispatcher.__send__(:report, e, item)
317
+ end
318
+ true
319
+ when Array
320
+ item.last.call if item.first == :barrier
321
+ false
322
+ when Hash
323
+ # Real frames are always Hashes here (:idle/:stop/[:barrier,..] are the
324
+ # non-Hash items). A $cleanup frame goes to the client's on_cleanup
325
+ # closure and is acked; every other frame is routed by handle to its
326
+ # Events, exactly as before.
327
+ if item['event'] == '$cleanup'
328
+ dispatcher.__send__(:run_cleanup, item)
329
+ else
330
+ dispatcher.__send__(:route, item)
331
+ end
332
+ false
333
+ end
334
+ end
335
+
336
+ # Is `thread` this dispatcher's own callback thread? Used by
337
+ # Client#await_cleanup to avoid a self-wait when `ole_release` is called
338
+ # from inside a callback -- the $cleanup frame that release triggers is
339
+ # queued behind the very callback asking the question, so waiting for it
340
+ # here would deadlock the dispatcher against itself.
341
+ def on_thread?(thread)
342
+ @mutex.synchronize { @thread }&.equal?(thread) || false
343
+ end
344
+
345
+ # Tests only. Production code never needs any of these, because callbacks
346
+ # are the delivery mechanism.
347
+ def stopped_for_test?(seconds)
348
+ thread = @mutex.synchronize { @thread }
349
+ return true if thread.nil?
350
+
351
+ !thread.join(seconds).nil?
352
+ end
353
+
354
+ # The Thread itself, for the collectability tests: a Thread holds only
355
+ # what was handed to it -- a WeakRef and the queue -- so carrying one out
356
+ # of a `weak_ref_to` block pins neither this Dispatcher nor its Client.
357
+ def thread_for_test
358
+ @mutex.synchronize { @thread }
359
+ end
360
+
361
+ # Blocks until the dispatcher has finished everything queued so far.
362
+ # Bounded, because the dispatcher not finishing is exactly what a test
363
+ # using this is hunting: an unbounded wait would hang the suite instead
364
+ # of reporting it.
365
+ #
366
+ # A Mutex/ConditionVariable barrier rather than the shorter
367
+ # `Queue#pop(timeout:)`: that keyword arrived in Ruby 3.2 and this gem
368
+ # declares `required_ruby_version >= 3.0` (wineole.gemspec), so on the
369
+ # oldest Ruby it supports the shorter form is an ArgumentError raised
370
+ # from shipped code.
371
+ def drain_for_test(seconds = 5)
372
+ done = false
373
+ lock = Mutex.new
374
+ cond = ConditionVariable.new
375
+ @queue << [:barrier, -> { lock.synchronize { done = true; cond.broadcast } }]
376
+
377
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
378
+ lock.synchronize do
379
+ until done
380
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
381
+ raise Error, "the dispatcher did not drain within #{seconds}s" if left <= 0
382
+
383
+ cond.wait(lock, left)
384
+ end
385
+ end
386
+ self
387
+ end
388
+
389
+ private
390
+
391
+ # A $cleanup frame: run the client closure for its handle on THIS thread
392
+ # (COM-safe, like every other callback), then tell the bridge the closure
393
+ # is done and wake whoever is blocked in await_cleanup. The closure's own
394
+ # exception must not stop any of that -- the bridge runs the steps
395
+ # regardless (choice B), so a raising closure still ends with release_event
396
+ # and the waiter signalled.
397
+ #
398
+ # The closure is looked up UNDER @mutex and called WITHOUT it: like a
399
+ # target's callback, it is free to `on`/`off`/`leave_open` anything on this
400
+ # connection, each of which re-enters this class and would deadlock against
401
+ # a lock its own delivery still held.
402
+ def run_cleanup(frame)
403
+ handle = frame['handle']
404
+ seq = frame['seq']
405
+ block = @mutex.synchronize { @cleanups[handle] }
406
+ begin
407
+ block&.call
408
+ rescue StandardError => e
409
+ warn "wineole: on_cleanup closure raised #{e.class}: #{e.message}"
410
+ ensure
411
+ begin
412
+ @client.call('release_event', {seq: seq})
413
+ rescue StandardError
414
+ nil
415
+ end
416
+ @client.signal_cleanup_done(seq)
417
+ @mutex.synchronize { @cleanups.delete(handle) }
418
+ end
419
+ end
420
+
421
+ # One frame, to the object it names. The target is looked up under
422
+ # @mutex and called WITHOUT it: `deliver` runs user code, and the
423
+ # callback is free to `on` or `off` anything on this connection, which
424
+ # comes straight back here as an `attach` or a `detach`.
425
+ #
426
+ # `__send__` rather than making `Events#deliver` public: delivering is
427
+ # not something a caller may do -- that a registered callback is the only
428
+ # way in is the whole claim of that class -- and a public `deliver` would
429
+ # say otherwise in the one place a user reads. The Dispatcher is the
430
+ # other half of the same feature, and this is the only place it reaches
431
+ # across.
432
+ def route(frame)
433
+ target = target_for(frame)
434
+ # A frame for a handle with no target was minted before the
435
+ # unsubscribe reached the bridge. Not delivered -- `off` means off --
436
+ # but still released by the ensure below, because the COM objects
437
+ # behind those handles would otherwise sit on the bridge until the
438
+ # connection closed.
439
+ target.__send__(:deliver, frame) if target
440
+ ensure
441
+ # The arguments are valid for the callback and no longer. Releasing in
442
+ # an ensure is what makes that true even when the frame never reached a
443
+ # callback at all -- a malformed `args` raises inside `deliver` before
444
+ # any callback is reached, and its handles would otherwise leak for the
445
+ # life of the connection.
446
+ release(frame)
447
+ end
448
+
449
+ # The frames of one event are released together, whether they reached a
450
+ # callback, reached one that raised, reached no target at all, or were
451
+ # still queued when the last target left. One statement of the rule --
452
+ # `route`'s ensure and the `:idle` arm both come here -- because "when is
453
+ # there something to give back" answered in two places is how the two
454
+ # answers drift apart.
455
+ def release(frame)
456
+ # `args: null` says the bridge minted NOTHING for this event: it is
457
+ # serialized as null rather than left out precisely so the client can
458
+ # tell that from "this event had zero arguments" (protocol.rs), and
459
+ # nothing is inserted in the bridge's event table for it. Sending a
460
+ # release anyway is a synchronous round trip from the dispatcher, which
461
+ # caps the event rate at one RTT -- in exactly the `args: false` case a
462
+ # caller reaches for to keep up with a high-frequency event.
463
+ return if !frame.is_a?(Hash) || frame['args'].nil?
464
+
465
+ @client.call('release_event', {seq: frame['seq']})
466
+ rescue StandardError
467
+ # The connection is going away; there is nothing left to release.
468
+ nil
469
+ end
470
+
471
+ def target_for(item)
472
+ return nil unless item.is_a?(Hash)
473
+
474
+ @mutex.synchronize { @targets[item['handle']] }
475
+ end
476
+
477
+ # Never raises. It is called from the dispatcher's own rescue, so an
478
+ # exception out of here is the one thing that could still end the thread.
479
+ # Routed to the object the frame names, so that an `on_error` registered
480
+ # on it is told; a frame that names nobody has no handler to reach.
481
+ def report(err, item)
482
+ target = target_for(item)
483
+ if target
484
+ target.__send__(:report, err, item)
485
+ else
486
+ warn "wineole: dispatcher raised #{err.class}: #{err.message}"
487
+ end
488
+ rescue Exception # rubocop:disable Lint/RescueException
489
+ nil # even $stderr being gone must not end the dispatcher
490
+ end
491
+
492
+ # The dispatcher's question when it reaches an :idle marker: is it still
493
+ # true that there is nothing to deliver to? Answering it under the same
494
+ # lock `attach` decides in is what makes "start a thread only if there is
495
+ # none" safe -- either this clears @thread first and `attach` starts a
496
+ # fresh one, or `attach` registers its target first and this thread keeps
497
+ # running.
498
+ #
499
+ # nil when the answer is no; otherwise everything still on the queue,
500
+ # drained HERE rather than by the caller. That is the whole point of it
501
+ # being here: clearing the thread slot lets an `attach` start a
502
+ # successor, and the successor's sink is installed under this same lock,
503
+ # so a queue emptied in the same critical section as the slot is cleared
504
+ # provably holds nothing of the successor's. Draining afterwards instead,
505
+ # outside the lock, is what let a departing thread pop a frame belonging
506
+ # to an object that had just attached and give it back rather than
507
+ # deliver it -- measured: two dispatcher threads alive at once, and a
508
+ # live subscription's event released, never delivered.
509
+ def confirm_idle
510
+ @mutex.synchronize do
511
+ # A registered cleanup counts the same as a target: the thread must
512
+ # stay to deliver the $cleanup frame it is waiting for.
513
+ next nil unless @targets.empty? && @cleanups.empty?
514
+
515
+ @thread = nil
516
+ drain_queue
517
+ end
518
+ end
519
+
520
+ # Everything on the queue, without blocking on it -- which is what makes
521
+ # it safe to call under @mutex. Whatever comes back was minted before the
522
+ # unsubscribe reached the bridge, with no callback left to hand it to.
523
+ def drain_queue
524
+ leftover = []
525
+ loop { leftover << @queue.pop(true) }
526
+ rescue ThreadError
527
+ leftover # the queue is empty, which is the only way out of that loop
528
+ end
529
+
530
+ def thread_finished(thread)
531
+ @mutex.synchronize { @thread = nil if @thread.equal?(thread) }
532
+ end
533
+ end
534
+ end
@@ -3,6 +3,12 @@ module WineOLE
3
3
  class NotSerializableError < Error; end
4
4
  class StaleReferenceError < Error; end
5
5
  class ProtocolError < Error; end
6
+ # Raised by a call that arrived after the bridge decided this instance's
7
+ # root proxy is on its way out (a client `$cleanup` closure is running, or
8
+ # has already run). Distinguished from a generic RemoteError so a caller
9
+ # can rescue "this instance is closing" specifically rather than pattern-
10
+ # matching on RemoteError#remote_class.
11
+ class InstanceClosingError < Error; end
6
12
 
7
13
  class RemoteError < Error
8
14
  attr_reader :remote_class