rudy4 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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +114 -0
  3. data/LICENSE +21 -0
  4. data/Makefile +137 -0
  5. data/README.md +400 -0
  6. data/bin/rudy4 +6 -0
  7. data/examples/hello_gem/README.md +91 -0
  8. data/examples/hello_gem/ext/hello_gem/extconf.rb +75 -0
  9. data/examples/hello_gem/ext/hello_gem/hello_gem.d +122 -0
  10. data/examples/hello_gem/hello_gem.gemspec +35 -0
  11. data/examples/hello_gem/lib/hello_gem/version.rb +5 -0
  12. data/examples/hello_gem/lib/hello_gem.rb +32 -0
  13. data/examples/hello_gem/test/smoke.sh +81 -0
  14. data/experiments/exp_compact.d +178 -0
  15. data/experiments/exp_convert.d +93 -0
  16. data/experiments/exp_cross.d +144 -0
  17. data/experiments/exp_defargs.d +301 -0
  18. data/experiments/exp_exc.d +265 -0
  19. data/experiments/exp_gcstorm.d +169 -0
  20. data/experiments/exp_http.d +254 -0
  21. data/experiments/exp_nonregression.d +209 -0
  22. data/experiments/exp_objproto.d +123 -0
  23. data/experiments/exp_perf.d +81 -0
  24. data/experiments/exp_wrap.d +186 -0
  25. data/lib/rudy4/cli.rb +204 -0
  26. data/lib/rudy4/doctor.rb +295 -0
  27. data/lib/rudy4/error.rb +10 -0
  28. data/lib/rudy4/generator.rb +172 -0
  29. data/lib/rudy4/templates.rb +484 -0
  30. data/lib/rudy4/vendor.rb +36 -0
  31. data/lib/rudy4/version.rb +5 -0
  32. data/lib/rudy4.rb +16 -0
  33. data/scripts/abi_audit.sh +64 -0
  34. data/scripts/memcheck.sh +77 -0
  35. data/scripts/vendor_check.sh +33 -0
  36. data/scripts/vendor_sync.sh +25 -0
  37. data/src/dexitest.d +278 -0
  38. data/src/dextest.d +119 -0
  39. data/src/dtypetest.d +159 -0
  40. data/src/rudy/args.d +253 -0
  41. data/src/rudy/bind/value.d +306 -0
  42. data/src/rudy/convert.d +221 -0
  43. data/src/rudy/defs.d +440 -0
  44. data/src/rudy/objproto.d +282 -0
  45. data/src/rudy/typeddata.d +143 -0
  46. data/test/dextest_loader.rb +6 -0
  47. data/test/exitest.rb +113 -0
  48. data/test/rudy4_test.rb +291 -0
  49. data/test/run.rb +74 -0
  50. data/test/typedtest.rb +69 -0
  51. data/vendor/rudy/args.d +253 -0
  52. data/vendor/rudy/bind/value.d +306 -0
  53. data/vendor/rudy/convert.d +221 -0
  54. data/vendor/rudy/defs.d +440 -0
  55. data/vendor/rudy/objproto.d +282 -0
  56. data/vendor/rudy/typeddata.d +143 -0
  57. metadata +106 -0
@@ -0,0 +1,265 @@
1
+ /// EXPERIMENT 6 — D <-> Ruby exception ping-pong torture.
2
+ ///
3
+ /// Torture elements:
4
+ /// - rb_rescue / rb_ensure / rb_protect nested inside each other, with
5
+ /// bodies that call BACK into Ruby (rb_funcall) and raise from both sides
6
+ /// of the boundary in every combination.
7
+ /// - Mutual recursion Ruby -> D -> Ruby -> D ... 50 levels deep, unwinding
8
+ /// through raises at the deepest level: longjmp must cleanly skip D
9
+ /// frames without corrupting D state.
10
+ /// - State checks after each unwind: module vars survive, D-GC memory
11
+ /// allocated before the raise is still reachable (no collection of live
12
+ /// objects due to the longjmp), and the Ruby exception object surfacing
13
+ /// in Ruby is the SAME object raised inside the D call (identity check).
14
+ /// - rb_protect with nested rb_protect: inner raise caught by inner
15
+ /// protect, outer protect sees state == 0.
16
+ /// - rb_ensure: the ensure callback runs even when the body raises through
17
+ /// two nested rescues.
18
+ ///
19
+ /// Layout:
20
+ /// DExc.echo_or_raise(n) -> calls Ruby method (n times recursively via
21
+ /// Ruby-level recursion into this function)
22
+ /// DExc.rescue_case -> rb_rescue around a raising body
23
+ /// DExc.ensure_case -> rb_ensure: body raises, ensure counts
24
+ /// DExc.protect_case -> rb_protect with nested protect inside
25
+ /// DExc.deep_ping_pong -> the recursion torture (see test)
26
+ /// DExc.state -> survival checks after unwinds
27
+ /// DExc.identical_exc -> raise inside rb_protect, re-raise SAME VALUE
28
+
29
+ module exp_exc;
30
+
31
+ import rudy.bind.value;
32
+ import rudy.convert;
33
+
34
+ import core.stdc.stdlib : malloc, free;
35
+
36
+ private:
37
+
38
+ // All module state __gshared: D module vars are thread-local by default
39
+ // (torture finding #1) — Ruby threads are OS pthreads.
40
+ __gshared:
41
+ long calls_made; // every entry into a D function
42
+ long ensures_run; // every rb_ensure ensure callback
43
+ long rescues_run; // every rb_rescue rescue callback
44
+ long protects_caught; // every rb_protect that observed state != 0
45
+ long max_depth_seen; // deepest recursion observed (mutual D<->Ruby)
46
+ VALUE exc_kept; // exception VALUE held across a longjmp
47
+ VALUE exc_reseen; // exception VALUE re-observed after re-raise
48
+ int ping_pong_failed; // set when deep recursion returns wrong value
49
+
50
+ // ---------------------------------------------------------- helpers
51
+
52
+ extern (C) VALUE keep_exc(VALUE exc)
53
+ {
54
+ // Store the pending VALUE (from rb_errinfo after a protect catch).
55
+ exc_kept = exc;
56
+ return Qnil_;
57
+ }
58
+
59
+ extern (C) void note_exception(VALUE exc) { exc_kept = exc; }
60
+
61
+ // ---------------------------------------------------------- echo_or_raise
62
+ // Ruby test calls: DExc.echo_or_raise(depth)
63
+ // - if depth > 0: call Ruby-side DExc.ping_pong(depth - 1) — Ruby method
64
+ // defined in the test file calls DExc.echo_or_raise back. Mutual
65
+ // recursion through the boundary.
66
+ // - if depth == 0: raise Ruby RuntimeError (longjmp from D code).
67
+ // - if depth < 0: return a string; no raise (clean path).
68
+
69
+ extern (C) VALUE echo_or_raise(VALUE self, VALUE depth)
70
+ {
71
+ calls_made += 1;
72
+ long d = d_type!long(depth);
73
+ if (d > 0)
74
+ {
75
+ // record depth for the max-depth assertion
76
+ if (d > max_depth_seen) max_depth_seen = d;
77
+ // Mutual recursion: D -> Ruby method -> D again.
78
+ return rb_funcall(rb_mKernel, rb_intern("DExc_ping_pong"), 1, rb_int2inum(d - 1));
79
+ }
80
+ else if (d == 0)
81
+ {
82
+ // raise from D: longjmp straight out of this D frame (never returns)
83
+ rb_raise(rb_eRuntimeError, "bottom of recursion");
84
+ assert(0);
85
+ }
86
+ else
87
+ {
88
+ return rb_str_new_cstr("clean");
89
+ }
90
+ assert(0); // rb_raise is noreturn; silence D's no-return analysis
91
+ }
92
+
93
+ // ---------------------------------------------------------- rescue_case
94
+ // rb_rescue around a Ruby-method body that raises. The rescue callback
95
+ // runs with the exception VALUE in hand — we store it and return a marker.
96
+
97
+ extern (C) VALUE raising_body(VALUE data) { return rb_funcall(rb_mKernel, rb_intern("DExc_raise"), 0); }
98
+ extern (C) VALUE rescue_body(VALUE data, VALUE exc)
99
+ {
100
+ rescues_run += 1;
101
+ note_exception(exc);
102
+ return rb_str_new_cstr("rescued");
103
+ }
104
+
105
+ extern (C) VALUE rescue_case(VALUE self)
106
+ {
107
+ calls_made += 1;
108
+ return rb_rescue(&raising_body, Qnil_, &rescue_body, Qnil_);
109
+ }
110
+
111
+ // ---------------------------------------------------------- ensure_case
112
+ // rb_ensure: body raises; the ensure callback must still run (its counter
113
+ // is the assertion) and the exception must propagate to the Ruby caller.
114
+
115
+ extern (C) VALUE ensure_ensure(VALUE data)
116
+ {
117
+ ensures_run += 1;
118
+ return Qnil_;
119
+ }
120
+
121
+ extern (C) VALUE ensure_case(VALUE self)
122
+ {
123
+ calls_made += 1;
124
+ return rb_ensure(&raising_body, Qnil_, &ensure_ensure, Qnil_);
125
+ }
126
+
127
+ // ---------------------------------------------------------- protect_case
128
+ // Nested rb_protect: the inner protect catches an inner raise; the outer
129
+ // protect runs a clean body and must see state == 0. If the inner raise
130
+ // leaked outward, the whole VM would die (this actually happened with
131
+ // early def! trampolines — chunk-1 finding).
132
+
133
+ extern (C) VALUE inner_protect_body(VALUE data) { return rb_funcall(rb_mKernel, rb_intern("DExc_raise"), 0); }
134
+
135
+ extern (C) VALUE inner_protect(VALUE data)
136
+ {
137
+ int state = 0;
138
+ VALUE v = rb_protect(&inner_protect_body, Qnil_, &state);
139
+ if (state != 0)
140
+ {
141
+ protects_caught += 1;
142
+ // clear the pending exception: we HANDLED it (else the next Ruby
143
+ // API call spuriously raises at an arbitrary point)
144
+ rb_set_errinfo(Qnil_);
145
+ return rb_str_new_cstr("inner caught");
146
+ }
147
+ return v;
148
+ }
149
+
150
+ extern (C) VALUE protect_body(VALUE data) { return rb_funcall(rb_mKernel, rb_intern("DExc_inner"), 0); }
151
+
152
+ // The Ruby-side stepper for the outer protect body calls THIS (registered
153
+ // as DExc.protect_inner): nested protect, raise contained at the inner.
154
+ extern (C) VALUE protect_inner(VALUE self)
155
+ {
156
+ return inner_protect(Qnil_);
157
+ }
158
+
159
+ extern (C) VALUE protect_case(VALUE self)
160
+ {
161
+ calls_made += 1;
162
+ int state = 0;
163
+ VALUE v = rb_protect(&protect_body, Qnil_, &state);
164
+ if (state != 0)
165
+ {
166
+ protects_caught += 1;
167
+ rb_set_errinfo(Qnil_);
168
+ return rb_str_new_cstr("outer caught unexpectedly");
169
+ }
170
+ return v;
171
+ }
172
+
173
+ // ---------------------------------------------------------- deep ping-pong
174
+ // Called from Ruby with a big depth and a block: recurses D -> Ruby -> D
175
+ // with the block called at every level, then raises at the bottom. The
176
+ // Ruby rescue wraps the whole thing; we assert the unwind depth bookkeeping.
177
+
178
+ extern (C) VALUE deep_ping_pong(VALUE self, VALUE depth)
179
+ {
180
+ calls_made += 1;
181
+ long d = d_type!long(depth);
182
+ if (d > max_depth_seen) max_depth_seen = d;
183
+ if (d == 0)
184
+ {
185
+ rb_raise(rb_eRuntimeError, "deep bottom");
186
+ assert(0);
187
+ }
188
+ // call the Ruby-side stepper (defined in the test file); a dedicated
189
+ // name so the deep test does not route through echo_or_raise's stepper
190
+ return rb_funcall(rb_mKernel, rb_intern("DExc_deep_ping_pong"), 1, rb_int2inum(d - 1));
191
+ }
192
+
193
+ // ---------------------------------------------------------- identical_exc
194
+ // Prove that the exception object raised inside a protected body is the
195
+ // SAME object that later appears in Ruby's rescue: identity via rb_obj_id.
196
+ // (also exercises rb_exc_raise: D re-raises the SAME exception VALUE after
197
+ // the protect catch — re-raising with rb_raise would build a new exc.)
198
+
199
+ extern (C) VALUE bomb_body(VALUE data) { return rb_funcall(rb_mKernel, rb_intern("DExc_raise"), 0); }
200
+
201
+ extern (C) VALUE identical_exc(VALUE self)
202
+ {
203
+ calls_made += 1;
204
+ int state = 0;
205
+ VALUE v = rb_protect(&bomb_body, Qnil_, &state);
206
+ if (state != 0)
207
+ {
208
+ protects_caught += 1;
209
+ exc_reseen = rb_errinfo();
210
+ rb_set_errinfo(Qnil_);
211
+ // return the exception itself; the Ruby test compares object ids
212
+ // after catching a re-raise of this same VALUE via rb_exc_raise
213
+ return exc_reseen;
214
+ }
215
+ return v;
216
+ }
217
+
218
+ extern (C) VALUE reraise(VALUE self, VALUE exc)
219
+ {
220
+ calls_made += 1;
221
+ exc_reseen = exc;
222
+ rb_exc_raise(exc); // never returns
223
+ assert(0);
224
+ }
225
+
226
+ // ---------------------------------------------------------- state
227
+
228
+ extern (C) VALUE get_calls(VALUE self) { return rb_int2inum(calls_made); }
229
+ extern (C) VALUE get_ensures(VALUE self) { return rb_int2inum(ensures_run); }
230
+ extern (C) VALUE get_rescues(VALUE self) { return rb_int2inum(rescues_run); }
231
+ extern (C) VALUE get_protects(VALUE self) { return rb_int2inum(protects_caught); }
232
+ extern (C) VALUE get_max_depth(VALUE self) { return rb_int2inum(max_depth_seen); }
233
+ extern (C) VALUE get_ping_fail(VALUE self) { return ping_pong_failed ? Qtrue_ : Qfalse_; }
234
+ extern (C) VALUE get_exc_kept(VALUE self) { return exc_kept; }
235
+ extern (C) VALUE get_exc_reseen(VALUE self) { return exc_reseen; }
236
+
237
+ // ---------------------------------------------------------- init
238
+
239
+ pragma(mangle, "Init_exp_exc")
240
+ extern (C) void Init_exp_exc()
241
+ {
242
+ // The two __gshared VALUE slots hold exceptions across D calls; Ruby's
243
+ // GC would otherwise be free to collect them mid-experiment (a VALUE
244
+ // stored only in a D global is invisible to the collector).
245
+ rb_gc_register_address(&exc_kept);
246
+ rb_gc_register_address(&exc_reseen);
247
+
248
+ VALUE m = rb_define_module("DExc");
249
+ rb_define_module_function(m, "echo_or_raise", cast(AnyMethod) &echo_or_raise, 1);
250
+ rb_define_module_function(m, "rescue_case", cast(AnyMethod) &rescue_case, 0);
251
+ rb_define_module_function(m, "ensure_case", cast(AnyMethod) &ensure_case, 0);
252
+ rb_define_module_function(m, "protect_case", cast(AnyMethod) &protect_case, 0);
253
+ rb_define_module_function(m, "protect_inner", cast(AnyMethod) &protect_inner, 0);
254
+ rb_define_module_function(m, "deep_ping_pong", cast(AnyMethod) &deep_ping_pong, 1);
255
+ rb_define_module_function(m, "identical_exc", cast(AnyMethod) &identical_exc, 0);
256
+ rb_define_module_function(m, "reraise", cast(AnyMethod) &reraise, 1);
257
+ rb_define_module_function(m, "calls", cast(AnyMethod) &get_calls, 0);
258
+ rb_define_module_function(m, "ensures", cast(AnyMethod) &get_ensures, 0);
259
+ rb_define_module_function(m, "rescues", cast(AnyMethod) &get_rescues, 0);
260
+ rb_define_module_function(m, "protects", cast(AnyMethod) &get_protects, 0);
261
+ rb_define_module_function(m, "max_depth", cast(AnyMethod) &get_max_depth, 0);
262
+ rb_define_module_function(m, "ping_pong_failed", cast(AnyMethod) &get_ping_fail, 0);
263
+ rb_define_module_function(m, "exc_kept", cast(AnyMethod) &get_exc_kept, 0);
264
+ rb_define_module_function(m, "exc_reseen", cast(AnyMethod) &get_exc_reseen, 0);
265
+ }
@@ -0,0 +1,169 @@
1
+ /// EXPERIMENT 5 — TypedData allocation/free storm under GC pressure.
2
+ ///
3
+ /// Torture elements:
4
+ /// - Ruby threads hammer DExpStorm.make(n) -> n malloc'd TypedData wrappers,
5
+ /// dropped immediately (Ruby GC churns them), each dfree bumps an ATOMIC
6
+ /// D-side counter: allocation/free must balance exactly at the end.
7
+ /// - While the Ruby storm runs, a D-side native thread churns D-GC memory
8
+ /// (large struct arrays) and forces GC.collect()s — exercising the D
9
+ /// collector while Ruby's collector runs in other OS threads.
10
+ /// - Cross-thread free counting proves dfree callbacks are actually invoked
11
+ /// (freed == allocated) and that typed-data free is thread-safe w.r.t.
12
+ /// a concurrently-running D GC.
13
+ ///
14
+ /// Layout:
15
+ /// DExpStorm.make(n) -> allocate n wrappers, return the last one
16
+ /// DExpStorm.alloc_count -> total wrappers allocated (monotonic)
17
+ /// DExpStorm.free_count -> total dfree callbacks run
18
+ /// DExpStorm.start_churn / stop_churn -> D background GC churn on/off
19
+ /// DExpStorm.churn_cycles -> how many churn cycles completed
20
+ /// DExpStorm.balance -> alloc_count - free_count (== live wrappers)
21
+ ///
22
+ /// What can go wrong (the point of the torture):
23
+ /// - dfree never called (leak) -> free_count stalls below alloc_count
24
+ /// - dfree called twice -> free_count > alloc_count (corruption)
25
+ /// - race on the counters between Ruby threads + D thread -> torn reads
26
+ /// (counters must be shared, atomic ops via core.atomic)
27
+
28
+ module exp_gcstorm;
29
+
30
+ import rudy.bind.value;
31
+ import rudy.convert;
32
+ import rudy.typeddata;
33
+
34
+ import core.atomic : atomicOp, atomicLoad;
35
+ import core.thread : Thread;
36
+ import core.time : msecs;
37
+ import core.memory : GC;
38
+ import core.runtime : rt_init;
39
+ import core.stdc.stdlib : malloc, free;
40
+ import std.conv : to;
41
+
42
+ private:
43
+
44
+ // A struct with real payload so malloc'd memory is actually used
45
+ struct StormCell
46
+ {
47
+ double[16] payload;
48
+ ulong serial;
49
+ }
50
+
51
+ // dfree for this experiment: count the free, then release the malloc'd
52
+ // cell. The free runs on Ruby's GC thread — a DIFFERENT OS thread than
53
+ // make() — which is exactly the cross-thread race the storm tests.
54
+ extern (C) void stormcell_free(void* p)
55
+ {
56
+ atomicOp!"+="(free_count, 1);
57
+ free(p);
58
+ }
59
+
60
+ // The mixin wires our counting dfree into the descriptor directly (third
61
+ // template param) — no need for a hand-built second descriptor, which the
62
+ // first version of this experiment did (and which was silently unused:
63
+ // stormcell_wrap always wrapped with the ORIGINAL descriptor, so free_count
64
+ // stayed 0 — torture finding #6: the mixin's wrap function hard-codes
65
+ // `&name_type`, so overriding .dfree on a copied descriptor does nothing).
66
+ mixin RudyTypedData!(StormCell, "stormcell", &stormcell_free);
67
+
68
+ shared long alloc_count; // bumped by make() under GVL (no race with dfree)
69
+ shared long free_count; // bumped by dfree from Ruby GC threads
70
+ shared long churn_cycles; // bumped by the D background thread
71
+
72
+ // ---------------------------------------------------------- Ruby API
73
+
74
+ extern (C) VALUE make(VALUE self, VALUE n)
75
+ {
76
+ long count = d_type!long(n);
77
+ VALUE last = Qnil_;
78
+ foreach (i; 0 .. count)
79
+ {
80
+ auto cell = cast(StormCell*) malloc(StormCell.sizeof);
81
+ cell.serial = cast(ulong) atomicOp!"+="(alloc_count, 1);
82
+ cell.payload[] = 1.5; // touch the memory
83
+ last = stormcell_wrap(rb_cObject, cell);
84
+ // immediately drop `last` from Ruby's perspective: not stored
85
+ // anywhere, next iteration overwrites the reference.
86
+ }
87
+ return last;
88
+ }
89
+
90
+ extern (C) VALUE get_alloc_count(VALUE self) { return rb_int2inum(atomicLoad(alloc_count)); }
91
+ extern (C) VALUE get_free_count(VALUE self) { return rb_int2inum(atomicLoad(free_count)); }
92
+ extern (C) VALUE get_balance(VALUE self)
93
+ {
94
+ return rb_int2inum(atomicLoad(alloc_count) - atomicLoad(free_count));
95
+ }
96
+
97
+ // ---------------------------------------------------------- D churn thread
98
+
99
+ shared bool churning; // D keyword-safe flag read by the thread
100
+ __gshared Thread churn_thread;
101
+
102
+ // Plain D function (NOT extern (C)): core.thread.Thread's ctor wants
103
+ // `void function()` and an extern (C) function has a different D type even
104
+ // though the machine code is identical.
105
+ void churn_loop()
106
+ {
107
+ while (atomicLoad(churning))
108
+ {
109
+ // churn: allocate and drop D-GC memory, forcing collections
110
+ auto junk = new StormCell[10_000];
111
+ foreach (ref c; junk) c.serial = 42;
112
+ atomicOp!"+="(churn_cycles, 1);
113
+ // D GC runs while Ruby GC frees wrappers in other threads
114
+ if (atomicLoad(churning))
115
+ GC.collect();
116
+ Thread.getThis.sleep(10.msecs);
117
+ }
118
+ }
119
+
120
+ extern (C) VALUE start_churn(VALUE self)
121
+ {
122
+ if (churn_thread !is null) return Qfalse_;
123
+ churning = true;
124
+ churn_thread = new Thread(&churn_loop);
125
+ churn_thread.isDaemon = true;
126
+ churn_thread.start();
127
+ return Qtrue_;
128
+ }
129
+
130
+ extern (C) VALUE stop_churn(VALUE self)
131
+ {
132
+ churning = false;
133
+ if (churn_thread !is null)
134
+ {
135
+ churn_thread.join();
136
+ churn_thread = null;
137
+ }
138
+ return Qnil_;
139
+ }
140
+
141
+ extern (C) VALUE get_churn_cycles(VALUE self) { return rb_int2inum(atomicLoad(churn_cycles)); }
142
+
143
+ // ---------------------------------------------------------- init
144
+
145
+ pragma(mangle, "Init_exp_gcstorm")
146
+ extern (C) void Init_exp_gcstorm()
147
+ {
148
+ // TORTURE FINDING #5: a GDC-compiled .so dlopened by Ruby NEVER runs
149
+ // rt_init(). The D GC lazy-initializes on first `new`, but core.time's
150
+ // _ticksPerSecond is filled only by _d_initMonoTime() from rt_init().
151
+ // Calling MonoTime/Thread.sleep from a D thread then hard-aborts with
152
+ // "MonoTimeImpl failed to get the frequency of the system's monotonic
153
+ // clock." (druntime core/time.d abort) — seen as a process-wide SIGABRT
154
+ // racing the Ruby VM. Any D extension that spawns threads, sleeps, or
155
+ // uses GC.collect() must call rt_init() itself in its Init hook.
156
+ rt_init();
157
+
158
+ // descriptor is generated by the mixin with the counting free func
159
+ // wired in via template parameter — nothing to patch here.
160
+
161
+ VALUE m = rb_define_module("DExpStorm");
162
+ rb_define_module_function(m, "make", cast(AnyMethod) &make, 1);
163
+ rb_define_module_function(m, "alloc_count", cast(AnyMethod) &get_alloc_count, 0);
164
+ rb_define_module_function(m, "free_count", cast(AnyMethod) &get_free_count, 0);
165
+ rb_define_module_function(m, "balance", cast(AnyMethod) &get_balance, 0);
166
+ rb_define_module_function(m, "start_churn", cast(AnyMethod) &start_churn, 0);
167
+ rb_define_module_function(m, "stop_churn", cast(AnyMethod) &stop_churn, 0);
168
+ rb_define_module_function(m, "churn_cycles", cast(AnyMethod) &get_churn_cycles, 0);
169
+ }
@@ -0,0 +1,254 @@
1
+ /// EXPERIMENT 4 — A tiny single-endpoint HTTP server: D owns the socket,
2
+ /// Ruby owns the handler.
3
+ ///
4
+ /// Torture elements:
5
+ /// - POSIX socket code entirely in D (std.socket), accept() runs with the
6
+ /// GVL RELEASED via rb_thread_call_without_gvl + RUBY_UBF_IO — the same
7
+ /// pattern any real blocking C extension must use.
8
+ /// - The response body is produced by a Ruby block (proc) handed in from
9
+ /// Ruby and invoked via rb_funcallv — D <-> Ruby boundary crossing on a
10
+ /// hot path, with fresh Ruby objects (request string) each call.
11
+ /// - A Ruby side thread stays alive while D blocks in accept(): proves the
12
+ /// GVL is actually released (side thread's heartbeats would stall it).
13
+ /// - rb_ensure wraps the whole serve-one-request cycle so the client socket
14
+ /// is closed even if the handler block raises mid-response.
15
+ ///
16
+ /// Layout (port chosen by kernel, reported back to Ruby via serve.port):
17
+ ///
18
+ /// DExpHTTP.serve_one(port_block_latch) — binds 127.0.0.1:0, accepts ONE
19
+ /// request, calls the registered handler block with the request
20
+ /// string, writes "HTTP/1.1 200 OK\r\nContent-Length: N\r\n\r\n" +
21
+ /// body, closes, returns the port used.
22
+ /// DExpHTTP.port — the bound port after serve_one.
23
+ /// DExpHTTP.register_handler(&blk) — stores the handler proc.
24
+ /// DExpHTTP.heartbeat — tick counter bumped by a Ruby
25
+ /// side thread; serve_one returns it alongside the port so the test
26
+ /// DExpHTTP.handler_ok — true if the Ruby handler was
27
+ /// actually called and returned a String.
28
+ ///
29
+ /// What can go wrong (the point of the torture):
30
+ /// - handler block raising with client fd open (ensure must close it)
31
+ /// - GC moving the request string while D holds a slice (we .idup)
32
+ /// - accept() blocking forever with no UBF (we pass RUBY_UBF_IO; without
33
+ /// releasing the GVL a Ruby watchdog thread could never run)
34
+
35
+ module exp_http;
36
+
37
+ import rudy.bind.value;
38
+ import rudy.convert;
39
+ import rudy.defs;
40
+
41
+ import std.socket;
42
+ import std.string : toStringz, fromStringz;
43
+ import std.conv : to;
44
+ import std.format : format;
45
+
46
+ private:
47
+
48
+ // ---- state kept alive across calls ------------------------------------
49
+ // TORTURE FINDING #1: D module-level variables are THREAD-LOCAL by default.
50
+ // Ruby threads are OS pthreads — a variable set by one Ruby thread is NOT
51
+ // visible to another unless declared `__gshared` (C-style global). D1-era
52
+ // Rudy never hit this because D1 had no TLS globals. Without __gshared
53
+ // the port/heartbeat handshakes below silently read 0 from other threads.
54
+ __gshared:
55
+ TcpSocket listening_sock;
56
+ Socket client_sock; // NOT TcpSocket: Socket.accept() returns a base
57
+ // Socket instance; cast(TcpSocket) is a checked
58
+ // downcast in D and would yield null (torture
59
+ // finding #3), giving a null-deref on receive().
60
+ ushort bound_port;
61
+ int heartbeats; // bumped via DExpHTTP.heartbeat from Ruby
62
+ int handler_called; // set when the Ruby handler block ran
63
+ int accept_failed; // set by do_accept on a D exception
64
+ VALUE handler_blk = Qnil_; // the Ruby handler proc
65
+
66
+ // ubf for interrupting a blocking accept when the Ruby thread is killed
67
+ extern (C) void ubf_accept(void* unused)
68
+ {
69
+ // RUBY_UBF_IO is what real extensions use for socket waits: it makes
70
+ // the GVL-released call interruptible on Thread#kill/timeout without
71
+ // us implementing a real unblocker (the fd gets closed by the OS on
72
+ // process exit; for a one-shot experiment this is acceptable).
73
+ }
74
+
75
+ extern (C) void* do_accept(void* data)
76
+ {
77
+ // running WITHOUT the GVL: pure D socket code, no Ruby API calls.
78
+ // accept() returns a Socket (D GC-allocated), not TcpSocket — cast.
79
+ //
80
+ // TORTURE FINDING #2: when the process gets a signal (timeout(1) sends
81
+ // SIGTERM), accept(2) fails with EINTR and Phobos throws
82
+ // SocketAcceptException — and a D exception unwinding out of a
83
+ // rb_thread_call_without_gvl callback aborts the runtime (gcc.deh
84
+ // terminate: no D handler up-stack). All D exceptions must be
85
+ // swallowed inside the no-GVL region and reported after re-acquiring.
86
+ try
87
+ {
88
+ client_sock = listening_sock.accept();
89
+ accept_failed = false;
90
+ }
91
+ catch (Exception e)
92
+ {
93
+ accept_failed = true;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ---- the Ruby handler invocation (GVL held again here) ----------------
99
+
100
+ extern (C) VALUE call_handler_protected(VALUE data)
101
+ {
102
+ // data = the request String built by D
103
+ VALUE blk = handler_blk;
104
+ VALUE[1] av = [data];
105
+ handler_called = 1;
106
+ return rb_funcallv(blk, rb_intern("call"), 1, av.ptr);
107
+ }
108
+
109
+ extern (C) VALUE ensure_close_client(VALUE data)
110
+ {
111
+ // runs on the way out no matter what: success, handler raise, or a
112
+ // transport error. Closing twice is fine (second is a no-op).
113
+ if (client_sock !is null)
114
+ {
115
+ client_sock.close();
116
+ client_sock = null;
117
+ }
118
+ return Qnil_;
119
+ }
120
+
121
+ extern (C) VALUE serve_one_body(VALUE self)
122
+ {
123
+ // bind 127.0.0.1:0 -> kernel picks a free port
124
+ auto addr = new InternetAddress("127.0.0.1", 0);
125
+ listening_sock = new TcpSocket(AddressFamily.INET);
126
+ listening_sock.bind(addr);
127
+ listening_sock.listen(1);
128
+ bound_port = (cast(InternetAddress) listening_sock.localAddress()).port;
129
+
130
+ // accept() with the GVL released — Ruby threads stay alive while we wait
131
+ rb_thread_call_without_gvl(&do_accept, null, &ubf_accept, null);
132
+ if (accept_failed)
133
+ rudy_raise(rb_eRuntimeError, "accept() failed (interrupted or bad fd)");
134
+
135
+ // read the request (curl/ruby client sends one small request; we read
136
+ // whatever arrived, up to a sane cap). Plain default-init is fine here.
137
+ ubyte[4096] buf;
138
+ size_t got = client_sock.receive(buf[]);
139
+ if (got == 0) // client closed without sending
140
+ rudy_raise(rb_eRuntimeError, "client closed before sending");
141
+
142
+ string req = cast(string) buf[0 .. got].idup;
143
+
144
+ // hand the request to the Ruby handler block
145
+ VALUE reqv = rb_str_new(req.ptr, cast(C_long) req.length);
146
+ int state;
147
+ VALUE body = rb_protect(&call_handler_protected, reqv, &state);
148
+ if (state)
149
+ {
150
+ // re-raise as a plain RuntimeError carrying the original message:
151
+ // proves the longjmp out of a D frame stack didn't corrupt state.
152
+ VALUE e = rb_errinfo();
153
+ VALUE msg = rb_funcallv(e, rb_intern("message"), 0, null);
154
+ string m = "handler raised: " ~ d_type!string(msg);
155
+ rudy_raise(rb_eRuntimeError, m);
156
+ }
157
+ VALUE body_s = rb_str_to_str(body); // enforce String contract
158
+ auto len = rb_str_strlen(body_s);
159
+ auto bptr = rb_string_value_ptr(&body_s); // may swap body_s in-place
160
+
161
+ // build + write the response
162
+ string head = format!"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"(len);
163
+ client_sock.send(head);
164
+ client_sock.send(bptr[0 .. len]);
165
+
166
+ // return [port, request_first_line] to Ruby
167
+ VALUE res = rb_ary_new_capa(2);
168
+ rb_ary_push(res, rb_int2inum(bound_port));
169
+ size_t line1_len = req.indexOf('\n'); // index of first '\n', or full len
170
+ VALUE line1 = rb_str_new(req.ptr, cast(C_long) line1_len);
171
+ rb_ary_push(res, line1);
172
+ return res;
173
+ }
174
+
175
+ extern (C) VALUE serve_one(VALUE self)
176
+ {
177
+ handler_called = 0;
178
+ bound_port = 0; // so Ruby's port>0 poll waits for THIS round's listener
179
+ // also drop any leftover listener from a previous round
180
+ if (listening_sock !is null)
181
+ {
182
+ try { listening_sock.close(); } catch (Exception e) {}
183
+ listening_sock = null;
184
+ }
185
+ // rb_ensure: body, body_data, ensure, ensure_data
186
+ return rb_ensure(&serve_one_body, Qnil_, &ensure_close_client, Qnil_);
187
+ }
188
+
189
+ extern (C) VALUE reset(VALUE self)
190
+ {
191
+ // close any leftover listener (e.g. after a raise round) and zero the
192
+ // port so a new round's client poll waits for a fresh bind
193
+ if (listening_sock !is null)
194
+ {
195
+ try { listening_sock.close(); } catch (Exception e) {}
196
+ listening_sock = null;
197
+ }
198
+ if (client_sock !is null)
199
+ {
200
+ try { client_sock.close(); } catch (Exception e) {}
201
+ client_sock = null;
202
+ }
203
+ bound_port = 0;
204
+ return Qnil_;
205
+ }
206
+
207
+ extern (C) VALUE get_port(VALUE self) { return rb_int2inum(bound_port); }
208
+ extern (C) VALUE get_heartbeats(VALUE self) { return rb_int2inum(heartbeats); }
209
+ extern (C) VALUE heartbeat(VALUE self) { heartbeats += 1; return Qnil_; }
210
+ extern (C) VALUE handler_ok(VALUE self) { return handler_called ? Qtrue_ : Qfalse_; }
211
+
212
+ extern (C) VALUE register_handler(VALUE self)
213
+ {
214
+ // Called as DExpHTTP.register_handler { |req| ... } — arity 0, the block
215
+ // is materialized via rb_block_proc (raises if no block was given).
216
+ handler_blk = rb_block_proc();
217
+ return handler_blk;
218
+ }
219
+
220
+ // so the side-thread test can bump the counter from Ruby
221
+ extern (C) VALUE exphttp_note(VALUE self, VALUE note)
222
+ {
223
+ VALUE m = rb_define_module("ExpHTTPNotes");
224
+ rb_iv_set(m, "@note", note);
225
+ return note;
226
+ }
227
+
228
+ // VOID_INIT helper removed — plain `ubyte[4096] buf;` works and avoids
229
+ // template-instantiation pitfalls under GDC.
230
+
231
+ // min/indexOf shims (avoid pulling whole std.algorithm for two names)
232
+ private size_t indexOf(string s, char c)
233
+ {
234
+ foreach (i, dchar ch; s)
235
+ if (ch == c) return i;
236
+ return s.length;
237
+ }
238
+
239
+ pragma(mangle, "Init_exp_http")
240
+ extern (C) void Init_exp_http()
241
+ {
242
+ VALUE m = rb_define_module("DExpHTTP");
243
+ rb_define_module_function(m, "serve_one",
244
+ cast(AnyMethod) &serve_one, 0);
245
+ rb_define_module_function(m, "reset",
246
+ cast(AnyMethod) &reset, 0);
247
+ rb_define_module_function(m, "register_handler",
248
+ cast(AnyMethod) &register_handler, 0);
249
+ rb_define_module_function(m, "port", cast(AnyMethod) &get_port, 0);
250
+ rb_define_module_function(m, "heartbeats", cast(AnyMethod) &get_heartbeats, 0);
251
+ rb_define_module_function(m, "heartbeat", cast(AnyMethod) &heartbeat, 0);
252
+ rb_define_module_function(m, "handler_ok", cast(AnyMethod) &handler_ok, 0);
253
+ rb_gc_register_address(&handler_blk);
254
+ }