synctest 0.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 791c0e995c5d51950dfe86772b692e4c9469d486e3fafe1f949e53158ab25fff
4
+ data.tar.gz: 6b02c7a17c5e35d9e8052a799024f8fda70fe0aa327b760ef049766475fd6980
5
+ SHA512:
6
+ metadata.gz: 00a002d3e36c47deb23dff71b4bec28ecc999702e768ee47ddf9f4996b19924364d7a2ed88dba986cef105d86ed3aeac4418752d16f88cbcc9f142ec32ba0003
7
+ data.tar.gz: f3d64f3dfdf55706a0cf26c3a277ad99bba2f7befbe30e9e4bb1e5d1033f9646b104c96da169b336fe6f59b402583ff242023af75138b5d159466363f6b36cec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Baygeldin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # synctest.rb
2
+
3
+ > [!WARNING]
4
+ > - This is still very much an experiment :)
5
+ > - It currently supports only threads—no fibers or Ractors!
6
+
7
+ > If testing concurrent code feels painful, perhaps it's not you. It's the *tooling*.
8
+
9
+ The Synctest gem is inspired by Go's [`testing/synctest`](https://pkg.go.dev/testing/synctest), a package that provides support for testing concurrent code.
10
+
11
+ It makes it easier to reason about the ordering of events in concurrent programs by providing convenient synchronization points (or, in other words, "quiescence" points). At these points, you can be sure that your test code is not interfering with any background activity, and you can observe the state of the system with confidence.
12
+
13
+ ## Installation
14
+
15
+ Add Synctest to the test group in your `Gemfile`:
16
+
17
+ ```ruby
18
+ group :test do
19
+ gem "synctest"
20
+ end
21
+ ```
22
+
23
+ Then run `bundle install` and load the library:
24
+
25
+ ```ruby
26
+ require "synctest"
27
+ ```
28
+
29
+ Synctest has no RSpec or Minitest integration layer. `Synctest.run` is plain Ruby, so it can wrap an example in either framework—or any other test runner.
30
+
31
+ Assertions may be placed directly inside the block. Use the same wrapper in an RSpec example, a Minitest test method, or a plain Ruby test:
32
+
33
+ ```ruby
34
+ Synctest.run do
35
+ # Start threads, use Synctest.wait, and assert.
36
+ end
37
+ ```
38
+
39
+ ## Examples
40
+
41
+ ### Using queues
42
+
43
+ ```ruby
44
+ require "synctest"
45
+
46
+ Synctest.run do
47
+ inbox = Queue.new
48
+ events = []
49
+
50
+ worker = Thread.new do
51
+ events << :started
52
+ inbox.pop
53
+ sleep 30
54
+ events << :finished
55
+ end
56
+
57
+ # Let the worker run until it is blocked on inbox. This does not move time.
58
+ Synctest.wait
59
+ raise "worker did not start" unless events == [:started]
60
+
61
+ inbox << :continue
62
+ worker.join
63
+
64
+ # The 30-second delay took no corresponding wall-clock time.
65
+ raise "worker did not finish" unless events == [:started, :finished]
66
+ end
67
+ ```
68
+
69
+ ### Using mutexes
70
+
71
+ ```ruby
72
+ require "synctest"
73
+
74
+ Synctest.run do
75
+ mutex = Mutex.new
76
+ events = []
77
+ mutex.lock
78
+
79
+ worker = Thread.new do
80
+ events << :waiting
81
+ mutex.synchronize { events << :entered }
82
+ end
83
+
84
+ # The worker has reached Mutex#lock and is durably blocked.
85
+ Synctest.wait
86
+ raise "worker entered too early" unless events == [:waiting]
87
+
88
+ mutex.unlock
89
+ worker.join
90
+
91
+ raise "worker did not enter" unless events == [:waiting, :entered]
92
+ end
93
+ ```
94
+
95
+ ## Bubbles and virtual time
96
+
97
+ `Synctest.run` associates the calling thread, recursively created child threads, and supported synchronization objects with one bubble:
98
+
99
+ ```ruby
100
+ result = Synctest.run(
101
+ start_at: Time.utc(2030, 1, 1),
102
+ timeout: 5.0
103
+ ) do
104
+ sleep 10
105
+ [Time.now, Process.clock_gettime(Process::CLOCK_MONOTONIC)]
106
+ end
107
+
108
+ # => [2030-01-01 00:00:10 UTC, 10.0]
109
+ ```
110
+
111
+ The default wall-clock origin is `2000-01-01 00:00:00 UTC`; the monotonic clock starts at zero. `start_at` must be a `Time`. `timeout` is a positive number of real seconds used to diagnose missing progress, or `nil` to disable that watchdog. The block's value is returned.
112
+
113
+ Virtual time advances to the earliest timer deadline only when every active bubble thread is durably blocked. It does not advance while a tracked thread can still make progress. This makes long sleeps and timeouts effectively instant while preserving their ordering.
114
+
115
+ The following clock APIs observe bubble time:
116
+
117
+ - `Time.now` and argumentless `Time.new`, including their `in:` keyword;
118
+ - `Process.clock_gettime(Process::CLOCK_MONOTONIC, unit)`;
119
+ - `Process.clock_gettime(Process::CLOCK_REALTIME, unit)`.
120
+
121
+ Only `CLOCK_MONOTONIC` and `CLOCK_REALTIME` are virtualized. Other `Process.clock_gettime` clocks, such as process and thread CPU clocks, continue to report real OS measurements. Explicitly constructed `Time` values also retain normal Ruby behavior.
122
+
123
+ Virtual time stops advancing once the root block returns. Join child threads before returning; leaving a sleeping or otherwise durably blocked child behind is reported as a deadlock rather than silently advancing time during teardown.
124
+
125
+ ## Waiting for quiescence
126
+
127
+ `Synctest.wait` waits until every *other* bubble thread has exited or is durably blocked:
128
+
129
+ ```ruby
130
+ Synctest.run do
131
+ events = []
132
+ worker = Thread.new do
133
+ events << :before
134
+ sleep 60
135
+ events << :after
136
+ end
137
+
138
+ Synctest.wait
139
+ raise unless events == [:before]
140
+ raise unless Process.clock_gettime(Process::CLOCK_MONOTONIC) == 0.0
141
+
142
+ worker.join
143
+ raise unless events == [:before, :after]
144
+ raise unless Process.clock_gettime(Process::CLOCK_MONOTONIC) == 60.0
145
+ end
146
+ ```
147
+
148
+ Unlike `sleep`, `join`, or another timed wait, `Synctest.wait` never advances the virtual clock. It is useful for proving that all immediately runnable work has drained and for checking state just before a timer fires.
149
+
150
+ Only one `Synctest.wait` may be active in a bubble. Calling it outside `Synctest.run` is an error.
151
+
152
+ ## Supported Ruby primitives
153
+
154
+ Requiring `synctest` installs process-wide shims, but they dispatch through thread-local bubble membership. Outside a bubble, calls immediately delegate to Ruby's original behavior.
155
+
156
+ Inside a bubble, Synctest tracks:
157
+
158
+ - `Thread.new`, `Thread.start`, and `Thread.fork` (the thread factory, not a
159
+ process fork), including recursively created descendants;
160
+ - `Thread.stop`, `Thread#wakeup`, `Thread#run`, `Thread#kill`,
161
+ `Thread#terminate`, `Thread#exit`, and `Thread#raise`;
162
+ - `Thread#join(timeout)` and `Thread#value`;
163
+ - `Kernel#sleep`;
164
+ - `Mutex` locking, `#synchronize`, `#try_lock`, and timed `#sleep`;
165
+ - `ConditionVariable#wait`, `#signal`, and `#broadcast`;
166
+ - `Queue` operations, including blocking pops, pop timeouts, pushes, closing, and clearing;
167
+ - `SizedQueue` operations, including blocking pops and pushes, timeouts, closing, clearing, and capacity changes;
168
+ - `Monitor` reentrant synchronization and condition variables;
169
+ - `Timeout.timeout`.
170
+
171
+ Timeouts on sleep, join, condition waits, queue operations, and `Timeout.timeout` all use the virtual clock. Thread exceptions are propagated through `Thread#join` and `Thread#value`; a descendant failure that is never observed through either method is propagated from the bubble itself.
172
+
173
+ This is behavioral instrumentation, not a deterministic thread scheduler. The order among simultaneously runnable threads is still MRI's choice. Use bubble-owned queues or locks to express required phase ordering, and use `Synctest.wait` when the assertion is about quiescence.
174
+
175
+ ## Ownership and isolation
176
+
177
+ Create threads and synchronization primitives inside `Synctest.run`. Supported objects created there belong to that bubble. Their instrumented blocking and mutating operations can be used only by threads in the same bubble while it is active; using those operations after the run finishes or from another bubble raises `Synctest::IsolationError`. Native read-only observers that need no scheduling—such as `Queue#length`—remain ordinary Ruby calls.
178
+
179
+ Objects created outside a bubble are not retroactively associated with it. Their methods retain ordinary Ruby behavior, and a blocking call on one is invisible to Synctest. Ordinary data objects can cross bubble boundaries. Independent bubbles may run in parallel, but bubbles cannot be nested.
180
+
181
+ These rules are what make a supported wait *durable*: only another thread in the same bubble can release it.
182
+
183
+ ## Failures and diagnostics
184
+
185
+ Synctest distinguishes among failures that would otherwise tend to leave tests hanging:
186
+
187
+ - `Synctest::DeadlockError` means every active tracked thread is durably blocked and no virtual timer can make progress.
188
+ - `Synctest::StalledError` means no tracked progress occurred during a coordinator wait or bubble teardown for `timeout` real seconds. Unsupported I/O, native code, or an unassociated primitive is a common cause.
189
+ - `Synctest::IsolationError` prevents a bubble-owned object from escaping its owner.
190
+ - `Synctest::NestedBubbleError` and `Synctest::ConcurrentWaitError` reject ambiguous bubble lifecycles.
191
+ - `Synctest::NotInBubbleError` reports bubble-only API calls made outside a run.
192
+ - `Synctest::UnsupportedOperationError` reports explicitly unsupported concurrency mechanisms.
193
+
194
+ Deadlock and stall messages include virtual time, active thread states, the blocking operation when known, and abbreviated backtraces. The real-time stall watchdog is not a VM-level preemptive timeout: if the root thread calls an unsupported blocking operation directly, ordinary Ruby blocking behavior can still apply.
195
+
196
+ ## Current limitations
197
+
198
+ Synctest is pure Ruby and observes patched Ruby methods rather than instrumenting the VM. MRI 3.4+ is the supported target; other Ruby engines are currently unverified.
199
+
200
+ The bubble cannot classify a wait as durable when one of its possible wakers is not a tracked thread. In particular:
201
+
202
+ - real file, pipe, socket, and subprocess I/O is not tracked;
203
+ - waits performed wholly inside native extensions are not tracked;
204
+ - fibers are not tracked as independent actors, and Fiber schedulers are explicitly rejected inside a bubble;
205
+ - Ractors and process forks are outside the bubble model;
206
+ - a child cannot call `Thread#value` on the bubble's root thread because the root cannot acquire its final native value until bubble teardown has waited for the child; use a timed `Thread#join` or an explicit result queue instead.
207
+
208
+ For networked code, split transport integration from the concurrent state machine. A test fake backed by bubble-owned `Queue`s lets Synctest account for both sides of every wait; a real socket, including `Socket.pair`, does not. Keep focused real-time integration tests for the actual transport.
209
+
210
+ ## API
211
+
212
+ ```ruby
213
+ Synctest.run(start_at: Synctest::DEFAULT_START_TIME,
214
+ timeout: Synctest::DEFAULT_TIMEOUT) { ... }
215
+ Synctest.wait
216
+ Synctest.active?
217
+ Synctest::VERSION
218
+ ```
219
+
220
+ `Synctest.active?` reports whether the calling thread belongs to a live bubble.
221
+
222
+ ## Development
223
+
224
+ ```sh
225
+ bin/setup # install dependencies
226
+ bundle exec rake # run the specs and Standard
227
+ bundle exec rake spec # run only the specs
228
+ bundle exec standardrb # run only the formatter/linter
229
+ bin/console # open Pry with Synctest loaded
230
+ bundle exec rake build # build the gem
231
+ ```
232
+
233
+ ## License
234
+
235
+ Synctest is available under the terms of the [MIT License](LICENSE).