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 +7 -0
- data/LICENSE +21 -0
- data/README.md +235 -0
- data/lib/synctest/bubble.rb +1225 -0
- data/lib/synctest/errors.rb +27 -0
- data/lib/synctest/patches.rb +608 -0
- data/lib/synctest/raw.rb +46 -0
- data/lib/synctest/version.rb +5 -0
- data/lib/synctest.rb +113 -0
- metadata +135 -0
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).
|