teek 0.1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +139 -0
  5. data/Rakefile +316 -0
  6. data/ext/teek/extconf.rb +79 -0
  7. data/ext/teek/stubs.h +33 -0
  8. data/ext/teek/tcl9compat.h +211 -0
  9. data/ext/teek/tcltkbridge.c +1597 -0
  10. data/ext/teek/tcltkbridge.h +42 -0
  11. data/ext/teek/tkfont.c +218 -0
  12. data/ext/teek/tkphoto.c +477 -0
  13. data/ext/teek/tkwin.c +144 -0
  14. data/lib/teek/background_none.rb +158 -0
  15. data/lib/teek/background_ractor4x.rb +410 -0
  16. data/lib/teek/background_thread.rb +272 -0
  17. data/lib/teek/debugger.rb +742 -0
  18. data/lib/teek/demo_support.rb +150 -0
  19. data/lib/teek/ractor_support.rb +246 -0
  20. data/lib/teek/version.rb +5 -0
  21. data/lib/teek.rb +540 -0
  22. data/sample/calculator.rb +260 -0
  23. data/sample/debug_demo.rb +45 -0
  24. data/sample/goldberg.rb +1803 -0
  25. data/sample/goldberg_helpers.rb +170 -0
  26. data/sample/minesweeper/assets/MINESWEEPER_0.png +0 -0
  27. data/sample/minesweeper/assets/MINESWEEPER_1.png +0 -0
  28. data/sample/minesweeper/assets/MINESWEEPER_2.png +0 -0
  29. data/sample/minesweeper/assets/MINESWEEPER_3.png +0 -0
  30. data/sample/minesweeper/assets/MINESWEEPER_4.png +0 -0
  31. data/sample/minesweeper/assets/MINESWEEPER_5.png +0 -0
  32. data/sample/minesweeper/assets/MINESWEEPER_6.png +0 -0
  33. data/sample/minesweeper/assets/MINESWEEPER_7.png +0 -0
  34. data/sample/minesweeper/assets/MINESWEEPER_8.png +0 -0
  35. data/sample/minesweeper/assets/MINESWEEPER_F.png +0 -0
  36. data/sample/minesweeper/assets/MINESWEEPER_M.png +0 -0
  37. data/sample/minesweeper/assets/MINESWEEPER_X.png +0 -0
  38. data/sample/minesweeper/minesweeper.rb +452 -0
  39. data/sample/threading_demo.rb +499 -0
  40. data/teek.gemspec +32 -0
  41. metadata +179 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ # Synchronous "background" work - runs on main thread, blocks UI.
5
+ #
6
+ # FOR TESTING AND DEMONSTRATION ONLY.
7
+ #
8
+ # This mode exists to show what happens WITHOUT background processing:
9
+ # the UI freezes during work. Use it in demos to contrast with :thread
10
+ # and :ractor modes which keep the UI responsive.
11
+ #
12
+ # Not registered by default. To use:
13
+ # require 'teek/background_none'
14
+ # Teek::BackgroundWork.register_background_mode :none, Teek::BackgroundNone::BackgroundWork
15
+ #
16
+ # @api private
17
+ module BackgroundNone
18
+ class BackgroundWork
19
+ def initialize(app, data, worker: nil, &block)
20
+ @app = app
21
+ @data = data
22
+ @work_block = block || (worker && proc { |t, d| worker.new.call(t, d) })
23
+ @callbacks = { progress: nil, done: nil, message: nil }
24
+ @message_queue = []
25
+ @started = false
26
+ @done = false
27
+ @paused = false
28
+ end
29
+
30
+ def on_progress(&block)
31
+ @callbacks[:progress] = block
32
+ maybe_start
33
+ self
34
+ end
35
+
36
+ def on_done(&block)
37
+ @callbacks[:done] = block
38
+ maybe_start
39
+ self
40
+ end
41
+
42
+ def on_message(&block)
43
+ @callbacks[:message] = block
44
+ self
45
+ end
46
+
47
+ def send_message(msg)
48
+ @message_queue << msg
49
+ self
50
+ end
51
+
52
+ def pause
53
+ @paused = true
54
+ send_message(:pause)
55
+ self
56
+ end
57
+
58
+ def resume
59
+ @paused = false
60
+ send_message(:resume)
61
+ self
62
+ end
63
+
64
+ def stop
65
+ send_message(:stop)
66
+ self
67
+ end
68
+
69
+ def close
70
+ self
71
+ end
72
+
73
+ def done?
74
+ @done
75
+ end
76
+
77
+ def paused?
78
+ @paused
79
+ end
80
+
81
+ def start
82
+ maybe_start
83
+ self
84
+ end
85
+
86
+ private
87
+
88
+ def maybe_start
89
+ return if @started
90
+ @started = true
91
+ @app.after(0) { do_work }
92
+ end
93
+
94
+ def do_work
95
+ task = TaskContext.new(@app, @callbacks, @message_queue)
96
+ begin
97
+ @work_block.call(task, @data)
98
+ rescue StopIteration
99
+ # Worker requested stop
100
+ rescue => e
101
+ warn "[None] Background work error: #{e.class}: #{e.message}"
102
+ end
103
+
104
+ @done = true
105
+ @callbacks[:done]&.call
106
+ end
107
+
108
+ # Synchronous task context - callbacks fire immediately
109
+ class TaskContext
110
+ def initialize(app, callbacks, message_queue)
111
+ @app = app
112
+ @callbacks = callbacks
113
+ @message_queue = message_queue
114
+ @paused = false
115
+ end
116
+
117
+ def yield(value)
118
+ @callbacks[:progress]&.call(value)
119
+ end
120
+
121
+ def check_message
122
+ msg = @message_queue.shift
123
+ handle_control_message(msg) if msg
124
+ msg
125
+ end
126
+
127
+ def wait_message
128
+ check_message
129
+ end
130
+
131
+ def send_message(msg)
132
+ @callbacks[:message]&.call(msg)
133
+ end
134
+
135
+ def check_pause
136
+ while @paused
137
+ @app.update
138
+ msg = check_message
139
+ break unless @paused
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def handle_control_message(msg)
146
+ case msg
147
+ when :pause
148
+ @paused = true
149
+ when :resume
150
+ @paused = false
151
+ when :stop
152
+ raise StopIteration
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+
5
+ # Ruby 4.x Ractor-based background work for Teek applications.
6
+ # Uses Ractor::Port for streaming and Ractor.shareable_proc for blocks.
7
+ # Uses thread-inside-ractor pattern for non-blocking message handling.
8
+ module BackgroundRactor4x
9
+ # Poll interval when paused (slower to save CPU)
10
+ PAUSED_POLL_MS = 500
11
+
12
+ # Why Ractor mode requires Ruby 4.x:
13
+ #
14
+ # Ruby 3.x Ractor support was attempted but abandoned due to fundamental issues:
15
+ #
16
+ # | Aspect | 3.x Problem | 4.x Solution |
17
+ # |-----------------------------|------------------------------|-------------------------------|
18
+ # | Output mechanism | Ractor.yield BLOCKS caller | Port.send is non-blocking |
19
+ # | Block support | Cannot pass blocks to Ractor | Ractor.shareable_proc works |
20
+ # | close_incoming after yield | Bug: doesn't wake threads | Works correctly |
21
+ # | Orphaned threads on exit | Hangs in rb_ractor_terminate | Exits cleanly |
22
+ # | Non-blocking receive | No API exists | Ractor::Port with select |
23
+ #
24
+ # What we tried on Ruby 3.x (all failed):
25
+ #
26
+ # 1. Yielder thread pattern: Separate thread does blocking Ractor.yield while
27
+ # worker pushes to a Queue. Works but adds complexity and has shutdown bugs.
28
+ #
29
+ # 2. Timeout-based polling for messages: Create thread, call Ractor.receive,
30
+ # join with timeout, kill thread. Very expensive (~10ms per check) and
31
+ # causes severe performance degradation.
32
+ #
33
+ # 3. Long-lived receiver thread: One thread continuously receives and pushes
34
+ # to Queue. UI hangs due to thread interaction issues.
35
+ #
36
+ # 4. IO.pipe for signaling: Considered but IO objects aren't Ractor-shareable.
37
+ #
38
+ # The fundamental problem is Ruby 3.x has no non-blocking Ractor.receive,
39
+ # and all workarounds either kill performance or cause hangs/crashes.
40
+ #
41
+ # On Ruby 3.x, use :thread mode instead - it works reliably with the GVL.
42
+ # This 4.x implementation is simpler because Ruby 4.x Ractors just work.
43
+
44
+ # Ractor-based background work using Ruby 4.x Ractor::Port for streaming
45
+ # and Ractor.shareable_proc for blocks.
46
+ #
47
+ # @example Block form
48
+ # work = BackgroundWork.new(app, urls) do |task, data|
49
+ # data.each { |url| task.yield(fetch(url)) }
50
+ # end
51
+ # work.on_progress { |r| update_ui(r) }.on_done { puts "Done!" }
52
+ #
53
+ # @example Worker class form
54
+ # work = BackgroundWork.new(app, data, worker: MyWorker)
55
+ # work.on_progress { |r| update_ui(r) }
56
+ class BackgroundWork
57
+ # @param app [Teek::App] the application instance (for +after+ scheduling)
58
+ # @param data [Object] data passed to the worker block/class
59
+ # @param worker [Class, nil] optional worker class (must respond to +#call(task, data)+)
60
+ # @yield [task, data] block executed inside a Ractor
61
+ # @yieldparam task [TaskContext] context for yielding results and checking messages
62
+ # @yieldparam data [Object] the data passed to the constructor
63
+ def initialize(app, data, worker: nil, &block)
64
+ @app = app
65
+ @data = data
66
+ @work_block = block || (worker && proc { |t, d| worker.new.call(t, d) })
67
+ @callbacks = { progress: nil, done: nil, message: nil }
68
+ @started = false
69
+ @done = false
70
+ @paused = false
71
+
72
+ # Communication
73
+ @output_queue = Thread::Queue.new
74
+ @control_port = nil # Set by worker, received back
75
+ @pending_messages = [] # Queued until control_port ready
76
+ @worker_ractor = nil
77
+ @bridge_thread = nil
78
+ end
79
+
80
+ # Register a callback for progress updates from the worker.
81
+ # Auto-starts the task if not already started.
82
+ # @yield [value] called on the main thread each time the worker yields a result
83
+ # @return [self]
84
+ def on_progress(&block)
85
+ @callbacks[:progress] = block
86
+ maybe_start
87
+ self
88
+ end
89
+
90
+ # Register a callback for when the worker finishes.
91
+ # Auto-starts the task if not already started.
92
+ # @yield called on the main thread when the worker completes
93
+ # @return [self]
94
+ def on_done(&block)
95
+ @callbacks[:done] = block
96
+ maybe_start
97
+ self
98
+ end
99
+
100
+ # Register a callback for custom messages sent by the worker via
101
+ # {TaskContext#send_message}.
102
+ # @yield [msg] called on the main thread with the message
103
+ # @return [self]
104
+ def on_message(&block)
105
+ @callbacks[:message] = block
106
+ self
107
+ end
108
+
109
+ # Send a message to the worker. The worker can receive it via
110
+ # {TaskContext#check_message} or {TaskContext#wait_message}.
111
+ # Messages are queued if the worker's control port isn't ready yet.
112
+ # @param msg [Object] any Ractor-shareable value
113
+ # @return [self]
114
+ def send_message(msg)
115
+ if @control_port
116
+ begin
117
+ @control_port.send(msg)
118
+ rescue Ractor::ClosedError
119
+ # Port already closed, task is done - ignore
120
+ end
121
+ else
122
+ @pending_messages << msg
123
+ end
124
+ self
125
+ end
126
+
127
+ # Pause the worker. The worker will block on the next {TaskContext#yield}
128
+ # or {TaskContext#check_pause} until {#resume} is called.
129
+ # @return [self]
130
+ def pause
131
+ @paused = true
132
+ send_message(:pause)
133
+ self
134
+ end
135
+
136
+ # Resume a paused worker.
137
+ # @return [self]
138
+ def resume
139
+ @paused = false
140
+ send_message(:resume)
141
+ self
142
+ end
143
+
144
+ # Request the worker to stop. Raises +StopIteration+ inside the worker
145
+ # on the next {TaskContext#check_message} or {TaskContext#yield}.
146
+ # @return [self]
147
+ def stop
148
+ send_message(:stop)
149
+ self
150
+ end
151
+
152
+ # Force-close the Ractor and all associated resources.
153
+ # @return [self]
154
+ def close
155
+ @done = true
156
+ @control_port = nil # Prevent further message sends
157
+ begin
158
+ @worker_ractor&.close_incoming
159
+ @worker_ractor&.close_outgoing
160
+ rescue Ractor::ClosedError
161
+ # Already closed
162
+ end
163
+ self
164
+ end
165
+
166
+ # Explicitly start the background work. Called automatically by
167
+ # {#on_progress} and {#on_done}; only needed when using {#on_message}
168
+ # alone.
169
+ # @return [self]
170
+ def start
171
+ return self if @started
172
+ @started = true
173
+
174
+ # Wrap in isolated proc for Ractor sharing. The block can only access
175
+ # its parameters (task, data), not outer-scope variables.
176
+ shareable_block = Ractor.shareable_proc(&@work_block)
177
+
178
+ start_ractor(shareable_block)
179
+ start_polling
180
+ self
181
+ end
182
+
183
+ # @return [Boolean] whether the worker has finished
184
+ def done?
185
+ @done
186
+ end
187
+
188
+ # @return [Boolean] whether the worker is paused
189
+ def paused?
190
+ @paused
191
+ end
192
+
193
+ private
194
+
195
+ def maybe_start
196
+ start unless @started
197
+ end
198
+
199
+ def start_ractor(shareable_block)
200
+ data = @data
201
+ output_port = Ractor::Port.new
202
+
203
+ @worker_ractor = Ractor.new(data, output_port, shareable_block) do |d, out, blk|
204
+ # Worker creates its own control port for receiving messages
205
+ control_port = Ractor::Port.new
206
+ msg_queue = Thread::Queue.new
207
+
208
+ # Send control port back to main thread
209
+ out.send([:control_port, control_port])
210
+
211
+ # Background thread receives from control port, forwards to queue
212
+ Thread.new do
213
+ loop do
214
+ begin
215
+ msg = control_port.receive
216
+ msg_queue << msg
217
+ break if msg == :stop
218
+ rescue Ractor::ClosedError
219
+ break
220
+ end
221
+ end
222
+ end
223
+
224
+ Thread.current[:tk_in_background_work] = true
225
+ task = TaskContext.new(out, msg_queue)
226
+ begin
227
+ blk.call(task, d)
228
+ out.send([:done])
229
+ rescue StopIteration
230
+ out.send([:done])
231
+ rescue => e
232
+ out.send([:error, "#{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"])
233
+ out.send([:done])
234
+ end
235
+ end
236
+
237
+ # Bridge thread: Port.receive -> Queue
238
+ @bridge_thread = Thread.new do
239
+ loop do
240
+ begin
241
+ result = output_port.receive
242
+ if result.is_a?(Array) && result[0] == :control_port
243
+ @control_port = result[1]
244
+ @pending_messages.each { |m| @control_port.send(m) }
245
+ @pending_messages.clear
246
+ else
247
+ @output_queue << result
248
+ break if result[0] == :done
249
+ end
250
+ rescue Ractor::ClosedError
251
+ @output_queue << [:done]
252
+ break
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ def start_polling
259
+ @dropped_count = 0
260
+ @choke_warned = false
261
+
262
+ poll = proc do
263
+ next if @done
264
+
265
+ drop_intermediate = Teek::BackgroundWork.drop_intermediate
266
+ # Drain queue. If drop_intermediate, only use LATEST progress value.
267
+ # This prevents UI choking when worker yields faster than UI polls.
268
+ last_progress = nil
269
+ results_this_poll = 0
270
+ until @output_queue.empty?
271
+ msg = @output_queue.pop(true)
272
+ type, value = msg
273
+ case type
274
+ when :done
275
+ @done = true
276
+ @control_port = nil # Clear to prevent send to closed port
277
+ # Call progress with final value before done callback
278
+ @callbacks[:progress]&.call(last_progress) if last_progress
279
+ last_progress = nil # Prevent duplicate call after loop
280
+ warn_if_choked
281
+ @callbacks[:done]&.call
282
+ break
283
+ when :result
284
+ results_this_poll += 1
285
+ if drop_intermediate
286
+ last_progress = value # Keep only latest
287
+ else
288
+ @callbacks[:progress]&.call(value) # Call for every value
289
+ end
290
+ when :message
291
+ @callbacks[:message]&.call(value)
292
+ when :error
293
+ if Teek::BackgroundWork.abort_on_error
294
+ raise RuntimeError, "[Ractor] Background work error: #{value}"
295
+ else
296
+ warn "[Ractor] Background work error: #{value}"
297
+ end
298
+ end
299
+ end
300
+
301
+ # Track dropped messages (all but the last one we processed)
302
+ if drop_intermediate && results_this_poll > 1
303
+ dropped = results_this_poll - 1
304
+ @dropped_count += dropped
305
+ warn_choke_start(dropped) unless @choke_warned
306
+ end
307
+
308
+ # Call progress callback once with latest value (only if dropping)
309
+ @callbacks[:progress]&.call(last_progress) if drop_intermediate && last_progress && !@done
310
+
311
+ unless @done
312
+ # Use slower polling when paused to save CPU
313
+ interval = @paused ? PAUSED_POLL_MS : Teek::BackgroundWork.poll_ms
314
+ @app.after(interval, &poll)
315
+ end
316
+ end
317
+
318
+ @app.after(0, &poll)
319
+ end
320
+
321
+ def warn_choke_start(dropped)
322
+ @choke_warned = true
323
+ warn "[Teek::BackgroundWork] UI choking: worker yielding faster than UI can poll. " \
324
+ "#{dropped} progress values dropped this cycle. " \
325
+ "Consider yielding less frequently or increasing Tk.background_work_poll_ms."
326
+ end
327
+
328
+ def warn_if_choked
329
+ return unless @dropped_count > 0
330
+ warn "[Teek::BackgroundWork] Total #{@dropped_count} progress values dropped during task. " \
331
+ "Only latest values were shown to UI."
332
+ end
333
+
334
+ # Context object passed to the worker block inside the Ractor.
335
+ # Provides methods for yielding results, sending/receiving messages,
336
+ # and responding to pause/stop signals.
337
+ class TaskContext
338
+ # @api private
339
+ def initialize(output_port, msg_queue)
340
+ @output_port = output_port
341
+ @msg_queue = msg_queue
342
+ @paused = false
343
+ end
344
+
345
+ # Send a result to the main thread. Blocks while paused.
346
+ # The value arrives in the {BackgroundWork#on_progress} callback.
347
+ # @param value [Object] any Ractor-shareable value
348
+ def yield(value)
349
+ check_pause_loop
350
+ @output_port.send([:result, value])
351
+ end
352
+
353
+ # Non-blocking check for a message from the main thread.
354
+ # Handles built-in control messages (+:pause+, +:resume+, +:stop+)
355
+ # automatically; +:stop+ raises +StopIteration+.
356
+ # @return [Object, nil] the message, or +nil+ if none pending
357
+ def check_message
358
+ return nil if @msg_queue.empty?
359
+ msg = @msg_queue.pop(true)
360
+ handle_control_message(msg)
361
+ msg
362
+ rescue ThreadError
363
+ nil
364
+ end
365
+
366
+ # Blocking wait for the next message from the main thread.
367
+ # Handles control messages automatically.
368
+ # @return [Object] the message
369
+ def wait_message
370
+ msg = @msg_queue.pop
371
+ handle_control_message(msg)
372
+ msg
373
+ end
374
+
375
+ # Send a custom message back to the main thread.
376
+ # Arrives in the {BackgroundWork#on_message} callback.
377
+ # @param msg [Object] any Ractor-shareable value
378
+ def send_message(msg)
379
+ @output_port.send([:message, msg])
380
+ end
381
+
382
+ # Block while paused, returning immediately if not paused.
383
+ # Call this periodically in long-running loops to honor pause requests.
384
+ def check_pause
385
+ check_pause_loop
386
+ end
387
+
388
+ private
389
+
390
+ def handle_control_message(msg)
391
+ case msg
392
+ when :pause
393
+ @paused = true
394
+ when :resume
395
+ @paused = false
396
+ when :stop
397
+ raise StopIteration
398
+ end
399
+ end
400
+
401
+ def check_pause_loop
402
+ while @paused
403
+ msg = @msg_queue.pop
404
+ handle_control_message(msg)
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end