zaniah 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 (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +70 -0
  5. data/assets/fonts/Abel-Regular.ttf +0 -0
  6. data/assets/fonts/OFL.txt +93 -0
  7. data/docs/adr/000-template.md +24 -0
  8. data/docs/adr/001-native-gpu-backends.md +36 -0
  9. data/docs/adr/002-objective-c-aggregates.md +34 -0
  10. data/docs/adr/003-fiddle-call-boundary.md +33 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/native.md +66 -0
  13. data/docs/process_pool.md +67 -0
  14. data/docs/text.md +90 -0
  15. data/docs/vector_and_list.md +52 -0
  16. data/examples/ffi_bench.rb +16 -0
  17. data/examples/linux_smoke.rb +53 -0
  18. data/examples/mac_event_check.rb +61 -0
  19. data/examples/mac_poll_bench.rb +24 -0
  20. data/examples/metal_state_check.rb +72 -0
  21. data/examples/native_bench.rb +18 -0
  22. data/examples/native_drop.rb +82 -0
  23. data/examples/native_font.rb +15 -0
  24. data/examples/native_ime.rb +50 -0
  25. data/examples/native_smoke.rb +75 -0
  26. data/examples/native_text_bench.rb +83 -0
  27. data/examples/native_watch.rb +25 -0
  28. data/examples/vulkan_triangle.rb +20 -0
  29. data/examples/word_count_worker.rb +8 -0
  30. data/lib/zaniah/anchored.rb +10 -0
  31. data/lib/zaniah/app.rb +130 -0
  32. data/lib/zaniah/bounds.rb +22 -0
  33. data/lib/zaniah/canvas.rb +15 -0
  34. data/lib/zaniah/color.rb +35 -0
  35. data/lib/zaniah/configuration.rb +5 -0
  36. data/lib/zaniah/corners.rb +7 -0
  37. data/lib/zaniah/data_compat.rb +25 -0
  38. data/lib/zaniah/device_pixels.rb +7 -0
  39. data/lib/zaniah/div.rb +5 -0
  40. data/lib/zaniah/edges.rb +7 -0
  41. data/lib/zaniah/element.rb +106 -0
  42. data/lib/zaniah/entity.rb +9 -0
  43. data/lib/zaniah/entity_context.rb +17 -0
  44. data/lib/zaniah/error.rb +5 -0
  45. data/lib/zaniah/ffi/com.rb +24 -0
  46. data/lib/zaniah/ffi/library.rb +29 -0
  47. data/lib/zaniah/ffi/objc.rb +86 -0
  48. data/lib/zaniah/ffi/struct/signature.rb +46 -0
  49. data/lib/zaniah/ffi/struct.rb +61 -0
  50. data/lib/zaniah/ffi/wayland.rb +140 -0
  51. data/lib/zaniah/frame_context.rb +14 -0
  52. data/lib/zaniah/geometry.rb +16 -0
  53. data/lib/zaniah/gpu/buffer.rb +23 -0
  54. data/lib/zaniah/gpu/frame_encoder.rb +43 -0
  55. data/lib/zaniah/gpu/instance_packing.rb +53 -0
  56. data/lib/zaniah/gpu/metal.rb +209 -0
  57. data/lib/zaniah/gpu/open_gl.rb +210 -0
  58. data/lib/zaniah/gpu/pipeline.rb +7 -0
  59. data/lib/zaniah/gpu/software.rb +126 -0
  60. data/lib/zaniah/gpu/texture.rb +33 -0
  61. data/lib/zaniah/gpu/vulkan.rb +233 -0
  62. data/lib/zaniah/gpu.rb +18 -0
  63. data/lib/zaniah/hsla.rb +14 -0
  64. data/lib/zaniah/image.rb +19 -0
  65. data/lib/zaniah/input/action_registry.rb +12 -0
  66. data/lib/zaniah/input/composition.rb +7 -0
  67. data/lib/zaniah/input/context_predicate.rb +87 -0
  68. data/lib/zaniah/input/dispatcher.rb +59 -0
  69. data/lib/zaniah/input/file_drop.rb +7 -0
  70. data/lib/zaniah/input/focus_handle.rb +24 -0
  71. data/lib/zaniah/input/key_down.rb +7 -0
  72. data/lib/zaniah/input/key_up.rb +7 -0
  73. data/lib/zaniah/input/keymap/binding.rb +9 -0
  74. data/lib/zaniah/input/keymap.rb +55 -0
  75. data/lib/zaniah/input/keystroke.rb +20 -0
  76. data/lib/zaniah/input/mouse_down.rb +7 -0
  77. data/lib/zaniah/input/mouse_move.rb +7 -0
  78. data/lib/zaniah/input/mouse_up.rb +7 -0
  79. data/lib/zaniah/input/scroll_wheel.rb +7 -0
  80. data/lib/zaniah/input/text_input.rb +7 -0
  81. data/lib/zaniah/input.rb +17 -0
  82. data/lib/zaniah/layout/available_space.rb +11 -0
  83. data/lib/zaniah/layout/engine.rb +214 -0
  84. data/lib/zaniah/layout/node.rb +36 -0
  85. data/lib/zaniah/layout/style.rb +19 -0
  86. data/lib/zaniah/layout/tree.rb +16 -0
  87. data/lib/zaniah/layout.rb +11 -0
  88. data/lib/zaniah/length.rb +14 -0
  89. data/lib/zaniah/length_units.rb +9 -0
  90. data/lib/zaniah/list/height_index.rb +68 -0
  91. data/lib/zaniah/list.rb +101 -0
  92. data/lib/zaniah/overlay.rb +10 -0
  93. data/lib/zaniah/platform/display.rb +7 -0
  94. data/lib/zaniah/platform/file_event.rb +7 -0
  95. data/lib/zaniah/platform/headless/window.rb +185 -0
  96. data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
  97. data/lib/zaniah/platform/linux/displays.rb +55 -0
  98. data/lib/zaniah/platform/linux/free_type.rb +80 -0
  99. data/lib/zaniah/platform/linux/watcher.rb +70 -0
  100. data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
  101. data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
  102. data/lib/zaniah/platform/linux/window.rb +418 -0
  103. data/lib/zaniah/platform/linux.rb +3 -0
  104. data/lib/zaniah/platform/mac/app.rb +67 -0
  105. data/lib/zaniah/platform/mac/core_text.rb +94 -0
  106. data/lib/zaniah/platform/mac/displays.rb +23 -0
  107. data/lib/zaniah/platform/mac/watcher.rb +63 -0
  108. data/lib/zaniah/platform/mac/window.rb +298 -0
  109. data/lib/zaniah/platform/mac.rb +19 -0
  110. data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
  111. data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
  112. data/lib/zaniah/platform/tui/window.rb +91 -0
  113. data/lib/zaniah/platform/windows/terminal.rb +127 -0
  114. data/lib/zaniah/platform/windows/watcher.rb +91 -0
  115. data/lib/zaniah/platform/windows/window.rb +407 -0
  116. data/lib/zaniah/platform/windows.rb +3 -0
  117. data/lib/zaniah/platform.rb +34 -0
  118. data/lib/zaniah/png.rb +118 -0
  119. data/lib/zaniah/point.rb +8 -0
  120. data/lib/zaniah/process_pool/worker.rb +7 -0
  121. data/lib/zaniah/process_pool.rb +239 -0
  122. data/lib/zaniah/process_wire.rb +127 -0
  123. data/lib/zaniah/quad_packer.rb +15 -0
  124. data/lib/zaniah/scene/sprite_batch.rb +7 -0
  125. data/lib/zaniah/scene.rb +167 -0
  126. data/lib/zaniah/scene_renderer.rb +8 -0
  127. data/lib/zaniah/size.rb +5 -0
  128. data/lib/zaniah/subscription.rb +14 -0
  129. data/lib/zaniah/svg/path.rb +134 -0
  130. data/lib/zaniah/svg/rasterizable.rb +186 -0
  131. data/lib/zaniah/svg.rb +295 -0
  132. data/lib/zaniah/task/cancelled.rb +7 -0
  133. data/lib/zaniah/task/timeout.rb +7 -0
  134. data/lib/zaniah/task.rb +51 -0
  135. data/lib/zaniah/task_executor.rb +105 -0
  136. data/lib/zaniah/text.rb +24 -0
  137. data/lib/zaniah/text_system/atlas/entry.rb +9 -0
  138. data/lib/zaniah/text_system/atlas.rb +102 -0
  139. data/lib/zaniah/text_system/atlas_cache.rb +146 -0
  140. data/lib/zaniah/text_system/font_db/face.rb +9 -0
  141. data/lib/zaniah/text_system/font_db.rb +193 -0
  142. data/lib/zaniah/text_system/glyph.rb +7 -0
  143. data/lib/zaniah/text_system/line_layout.rb +20 -0
  144. data/lib/zaniah/text_system/renderer.rb +196 -0
  145. data/lib/zaniah/text_system/shaper.rb +430 -0
  146. data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
  147. data/lib/zaniah/text_system/typesetter.rb +168 -0
  148. data/lib/zaniah/text_system.rb +9 -0
  149. data/lib/zaniah/unicode.rb +13 -0
  150. data/lib/zaniah/uniform_list.rb +30 -0
  151. data/lib/zaniah/version.rb +5 -0
  152. data/lib/zaniah/view.rb +13 -0
  153. data/lib/zaniah.rb +29 -0
  154. data/sig/app.rbs +80 -0
  155. data/sig/elements.rbs +129 -0
  156. data/sig/gpu.rbs +108 -0
  157. data/sig/input.rbs +114 -0
  158. data/sig/layout.rbs +53 -0
  159. data/sig/native.rbs +88 -0
  160. data/sig/platform.rbs +94 -0
  161. data/sig/scene.rbs +52 -0
  162. data/sig/shaper.rbs +8 -0
  163. data/sig/text.rbs +137 -0
  164. data/sig/zaniah.rbs +120 -0
  165. data/tools/check_dependencies.rb +52 -0
  166. metadata +249 -0
data/lib/zaniah/png.rb ADDED
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Zaniah
6
+ # Runtime headless output uses only the Ruby standard distribution.
7
+ module PNG
8
+ SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
9
+ module_function
10
+
11
+ def encode(width, height, rgba)
12
+ raise ArgumentError, "invalid image dimensions" unless width.positive? && height.positive? && rgba.bytesize == width * height * 4
13
+ scanlines = String.new(encoding: Encoding::BINARY)
14
+ height.times { |row| scanlines << "\0" << rgba.byteslice(row * width * 4, width * 4) }
15
+ SIGNATURE + chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5")) +
16
+ chunk("IDAT", Zlib::Deflate.deflate(scanlines)) + chunk("IEND", "".b)
17
+ end
18
+
19
+ def write(path, width, height, rgba) = File.binwrite(path, encode(width, height, rgba))
20
+
21
+ def decode(bytes, max_pixels: 16_777_216)
22
+ raise ArgumentError, "not a PNG" unless bytes.start_with?(SIGNATURE)
23
+
24
+ header, compressed = parse_chunks(bytes)
25
+ width, height, type, channels = decode_header(header, max_pixels)
26
+ stride = width * channels
27
+ raw = inflate(compressed, (stride + 1) * height)
28
+ [width, height, decode_scanlines(raw, width, height, type, channels)]
29
+ end
30
+
31
+ def parse_chunks(bytes)
32
+ offset, compressed, header, ended = 8, String.new(encoding: Encoding::BINARY), nil, false
33
+ while offset + 12 <= bytes.bytesize
34
+ size = bytes.byteslice(offset, 4).unpack1("N")
35
+ raise ArgumentError, "truncated PNG" if offset + 12 + size > bytes.bytesize
36
+ kind = bytes.byteslice(offset + 4, 4)
37
+ data = bytes.byteslice(offset + 8, size)
38
+ crc = bytes.byteslice(offset + 8 + size, 4).unpack1("N")
39
+ raise ArgumentError, "PNG checksum mismatch" unless Zlib.crc32(kind + data) == crc
40
+ case kind
41
+ when "IHDR" then header = data.unpack("NNC5")
42
+ when "IDAT" then compressed << data
43
+ when "IEND" then ended = true; break
44
+ end
45
+ offset += size + 12
46
+ end
47
+ raise ArgumentError, "missing PNG header or end" unless header && ended
48
+
49
+ [header, compressed]
50
+ end
51
+
52
+ def decode_header(header, max_pixels)
53
+ width, height, depth, type, compression, filter, interlace = header
54
+ raise ArgumentError, "unsupported PNG encoding" unless depth == 8 && compression == 0 && filter == 0 && interlace == 0 && [0, 2, 4, 6].include?(type)
55
+ raise ArgumentError, "PNG too large" unless width.positive? && height.positive? && width * height <= max_pixels
56
+ channels = {0 => 1, 2 => 3, 4 => 2, 6 => 4}.fetch(type)
57
+
58
+ [width, height, type, channels]
59
+ end
60
+
61
+ def inflate(compressed, expected)
62
+ raw = String.new(encoding: Encoding::BINARY)
63
+ inflater = Zlib::Inflate.new
64
+ begin
65
+ inflater.inflate(compressed) do |part|
66
+ raise ArgumentError, "excess PNG data" if raw.bytesize + part.bytesize > expected
67
+ raw << part
68
+ end
69
+ ensure
70
+ inflater.close
71
+ end
72
+ raise ArgumentError, "incorrect PNG data length" unless raw.bytesize == expected
73
+
74
+ raw
75
+ end
76
+
77
+ def decode_scanlines(raw, width, height, type, channels)
78
+ stride = width * channels
79
+ previous = Array.new(stride, 0)
80
+ rgba = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
81
+ height.times do |row|
82
+ method = raw.getbyte(row * (stride + 1))
83
+ raise ArgumentError, "invalid PNG filter" unless (0..4).cover?(method)
84
+ current = raw.byteslice(row * (stride + 1) + 1, stride).bytes
85
+ stride.times do |i|
86
+ left = i < channels ? 0 : current[i - channels]
87
+ up, upper_left = previous[i], i < channels ? 0 : previous[i - channels]
88
+ prediction = case method
89
+ when 0 then 0
90
+ when 1 then left
91
+ when 2 then up
92
+ when 3 then (left + up) / 2
93
+ when 4
94
+ p = left + up - upper_left
95
+ [left, up, upper_left].min_by { |v| (p - v).abs }
96
+ end
97
+ current[i] = (current[i] + prediction) & 255
98
+ end
99
+ current.each_slice(channels) do |pixel|
100
+ rgba << case type
101
+ when 0 then [pixel[0], pixel[0], pixel[0], 255].pack("C4")
102
+ when 2 then [*pixel, 255].pack("C4")
103
+ when 4 then [pixel[0], pixel[0], pixel[0], pixel[1]].pack("C4")
104
+ when 6 then pixel.pack("C4")
105
+ end
106
+ end
107
+ previous = current
108
+ end
109
+ rgba
110
+ end
111
+
112
+ def chunk(kind, data)
113
+ [data.bytesize].pack("N") + kind + data + [Zlib.crc32(kind + data)].pack("N")
114
+ end
115
+
116
+ private_class_method :parse_chunks, :decode_header, :inflate, :decode_scanlines
117
+ end
118
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ Point = Data.define(:x, :y) do
5
+ def +(other) = Point.new(x + other.x, y + other.y)
6
+ def -(other) = Point.new(x - other.x, y - other.y)
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class ProcessPool
5
+ Worker = Struct.new(:pid, :reader, :writer, :task, :retire, :thread)
6
+ end
7
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "process_wire"
5
+
6
+ module Zaniah
7
+ # Fixed parallelism, bounded data-only IPC, portable spawn or legacy fork.
8
+ class ProcessPool
9
+ def initialize(workers: 2, handler: nil, requires: [], load_paths: [], max_pending: workers * 2, max_bytes: 16 << 20, &block)
10
+ raise ArgumentError, "workers must be in 1..32" unless workers.is_a?(Integer) && workers.between?(1, 32)
11
+ raise ArgumentError, "max_pending must be in 1..1024" unless max_pending.is_a?(Integer) && max_pending.between?(1, 1024)
12
+ raise ArgumentError, "max_bytes must be in 4096..67108864" unless max_bytes.is_a?(Integer) && max_bytes.between?(4096, 64 << 20)
13
+ raise ArgumentError, "provide a named handler or a block, not both" if !!handler == !!block
14
+ if handler
15
+ raise ArgumentError, "handler must be a constant name" unless handler.is_a?(String) && handler.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
16
+ elsif !Process.respond_to?(:fork)
17
+ raise Error, "block workers require fork; use handler: and requires: on this platform"
18
+ end
19
+ @requires = checked_paths(requires, file: true)
20
+ @load_paths = checked_paths(load_paths, file: false)
21
+ @handler, @block, @max_pending, @max_bytes = handler&.dup&.freeze, block, max_pending, max_bytes
22
+ @mutex, @condition, @queue, @pending, @control = Mutex.new, ConditionVariable.new, [], {}, Queue.new
23
+ @workers = Array.new(workers) { Worker.new }
24
+ @supervisor = Thread.new { supervise }
25
+ @workers.each { |entry| entry.thread = Thread.new { run_worker(entry) } }
26
+ end
27
+
28
+ # Snapshot before enqueueing, so later caller mutations cannot race IPC.
29
+ # Large snapshots should be submitted from a TaskExecutor background task.
30
+ def submit(data)
31
+ @mutex.synchronize { ensure_accepting_tasks }
32
+ bytes = ProcessWire.encode_frame(data, @max_bytes)
33
+ task = Task.new
34
+ task.on_complete do
35
+ @mutex.synchronize do
36
+ @pending.delete(task)
37
+ @queue.delete_if { |queued, _| queued.equal?(task) }
38
+ end
39
+ @control << task # Cancellation never waits on a pipe or child process.
40
+ end
41
+ @mutex.synchronize do
42
+ ensure_accepting_tasks
43
+ @pending[task] = true
44
+ @queue << [task, bytes]
45
+ @condition.signal
46
+ end
47
+ task
48
+ end
49
+
50
+ def shutdown(timeout: 2)
51
+ raise ArgumentError, "timeout must be finite and positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
52
+ tasks = @mutex.synchronize do
53
+ @closed = true
54
+ saved = @pending.keys
55
+ @queue.clear
56
+ @condition.broadcast
57
+ saved
58
+ end
59
+ @control << :shutdown
60
+ tasks.each { |task| complete_task(task, error: Task::Cancelled.new("process pool shut down")) }
61
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
62
+ [@supervisor, *@workers.map(&:thread)].each do |thread|
63
+ next if thread.equal?(Thread.current)
64
+ remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
65
+ raise Task::Timeout, "process pool cleanup is still running" unless thread.join(remaining)
66
+ end
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def checked_paths(paths, file:)
73
+ raise ArgumentError, "worker paths must be an array" unless paths.is_a?(Array)
74
+ paths.map do |path|
75
+ raise ArgumentError, "worker paths must be absolute existing paths" unless path.is_a?(String) &&
76
+ File.absolute_path(path) == path && (file ? File.file?(path) : File.directory?(path))
77
+ path.dup.freeze
78
+ end.freeze
79
+ end
80
+
81
+ def ensure_accepting_tasks
82
+ raise Error, "process pool is shut down" if @closed
83
+ raise Error, "process pool queue is full" if @pending.length >= @max_pending
84
+ end
85
+
86
+ def take_job(entry)
87
+ @mutex.synchronize do
88
+ @condition.wait(@mutex) while @queue.empty? && !@closed
89
+ return if @closed
90
+ task, bytes = @queue.shift
91
+ entry.task = task
92
+ [task, bytes]
93
+ end
94
+ end
95
+
96
+ def run_worker(entry)
97
+ while (job = take_job(entry))
98
+ task, bytes = job
99
+ value = error = nil
100
+ begin
101
+ unless task.done?
102
+ start_worker(entry) unless entry.pid
103
+ unless task.done? || @closed
104
+ entry.writer.write(bytes)
105
+ body = ProcessWire.read_payload(entry.reader, @max_bytes)
106
+ raise Error, "process worker exited without a response" unless body
107
+ response = ProcessWire.decode_payload(body)
108
+ raise Error, "invalid process worker response" unless response.is_a?(Array) && response.length == 2 && [true, false].include?(response[0])
109
+ unless response[0]
110
+ raise Error, "invalid process worker error" unless response[1].is_a?(String)
111
+ raise Error, response[1]
112
+ end
113
+ value = response[1]
114
+ end
115
+ end
116
+ rescue StandardError => failure
117
+ error = failure
118
+ ensure
119
+ retire = @mutex.synchronize do
120
+ entry.task = nil
121
+ @pending.delete(task)
122
+ entry.retire || @closed || task.done? || error
123
+ end
124
+ stop_worker(entry) if retire
125
+ end
126
+ complete_task(task, value, error: error)
127
+ end
128
+ ensure
129
+ stop_worker(entry)
130
+ end
131
+
132
+ def complete_task(task, value = nil, error: nil)
133
+ task.resolve(value, error: error)
134
+ rescue StandardError => failure
135
+ begin
136
+ warn "Process task callback failed: #{ProcessWire.error_message(failure)}"
137
+ rescue StandardError
138
+ nil
139
+ end
140
+ end
141
+
142
+ def supervise
143
+ loop do
144
+ task = @control.pop
145
+ @mutex.synchronize do
146
+ @workers.each do |entry|
147
+ next unless task == :shutdown || entry.task.equal?(task)
148
+ next if entry.retire
149
+ entry.retire = true
150
+ terminate_worker_process(entry.pid) if entry.pid
151
+ end
152
+ end
153
+ break if task == :shutdown
154
+ end
155
+ end
156
+
157
+ def start_worker(entry)
158
+ child_in, writer = IO.pipe
159
+ reader, child_out = IO.pipe
160
+ [reader, writer, child_in, child_out].each(&:binmode)
161
+ writer.sync = true
162
+ pid = if @handler
163
+ jit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? ["--yjit"] : []
164
+ command = [RbConfig.ruby, *jit, *@load_paths.flat_map { |path| ["-I", path] },
165
+ File.expand_path("process_wire.rb", __dir__), @handler, @max_bytes.to_s, *@requires]
166
+ options = {in: child_in, out: child_out, err: STDERR, close_others: true}
167
+ options[windows? ? :new_pgroup : :pgroup] = true
168
+ Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, *command, **options)
169
+ else
170
+ Process.fork do
171
+ Process.setpgrp
172
+ reader.close
173
+ writer.close
174
+ @workers.each { |other| [other.reader, other.writer].compact.each { |io| io.close unless io.closed? } }
175
+ begin
176
+ ProcessWire.serve(child_in, child_out, @max_bytes, @block)
177
+ exit! 0
178
+ rescue Exception
179
+ exit! 1
180
+ end
181
+ end
182
+ end
183
+ @mutex.synchronize do
184
+ entry.pid, entry.reader, entry.writer, entry.retire = pid, reader, writer, false
185
+ terminate_worker_process(pid) if @closed || entry.task.done?
186
+ end
187
+ rescue Exception
188
+ [reader, writer].compact.each { |io| io.close unless io.closed? }
189
+ raise
190
+ ensure
191
+ [child_in, child_out].compact.each { |io| io.close unless io.closed? }
192
+ end
193
+
194
+ def windows? = /mswin|mingw/.match?(RbConfig::CONFIG.fetch("host_os"))
195
+
196
+ def terminate_worker_process(pid)
197
+ Process.kill("KILL", windows? ? pid : -pid)
198
+ rescue Errno::EPERM => error
199
+ # macOS can report EPERM for a group whose last process just exited.
200
+ # Do not mask a real permission failure against a still-existing child.
201
+ begin
202
+ windows? ? Process.kill(0, pid) : Process.getpgid(pid)
203
+ rescue Errno::ESRCH
204
+ return
205
+ end
206
+ raise error
207
+ rescue Errno::ESRCH
208
+ begin
209
+ Process.kill("KILL", pid) # A just-forked child may not have set its group yet.
210
+ rescue Errno::ESRCH
211
+ nil
212
+ end
213
+ end
214
+
215
+ def stop_worker(entry)
216
+ pid = @mutex.synchronize do
217
+ saved, entry.pid = entry.pid, nil
218
+ saved
219
+ end
220
+ return unless pid
221
+ unless Process.waitpid(pid, Process::WNOHANG)
222
+ terminate_worker_process(pid)
223
+ Process.wait(pid)
224
+ end
225
+ rescue Errno::ECHILD
226
+ nil
227
+ rescue StandardError
228
+ # Retain the exact child identity if the OS denies termination/reaping;
229
+ # a later shutdown must not mistake a failed cleanup for no child.
230
+ @mutex.synchronize { entry.pid = pid } if pid
231
+ raise
232
+ ensure
233
+ [entry.reader, entry.writer].compact.each { |io| io.close unless io.closed? }
234
+ entry.reader = entry.writer = nil
235
+ end
236
+ end
237
+ end
238
+
239
+ require_relative "process_pool/worker"
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Zaniah
6
+ # Only primitive JSON data crosses this local, trusted-code boundary. No
7
+ # Marshal hooks, constant reconstruction, source text or eval is involved.
8
+ module ProcessWire
9
+ MAX_DEPTH = 64
10
+ MAX_NODES = 1_000_000
11
+
12
+ def self.validate_payload(value, depth = 0, budget = [MAX_NODES, nil, nil], freeze: false)
13
+ raise ArgumentError, "IPC data is too deeply nested" if depth > MAX_DEPTH
14
+ raise ArgumentError, "IPC data has too many values" if (budget[0] -= 1).negative?
15
+ case value.class.name
16
+ when "NilClass", "TrueClass" then charge_budget(budget, 4)
17
+ when "FalseClass" then charge_budget(budget, 5)
18
+ when "Integer"
19
+ raise ArgumentError, "IPC integer exceeds byte budget" if budget[1] && value.bit_length > budget[1] * 4
20
+ charge_budget(budget, value.to_s.bytesize)
21
+ when "Float"
22
+ raise ArgumentError, "IPC numbers must be finite" unless value.finite?
23
+ charge_budget(budget, value.to_s.bytesize)
24
+ when "String"
25
+ raise ArgumentError, "IPC strings must be valid UTF-8" unless value.valid_encoding? &&
26
+ (value.encoding == Encoding::UTF_8 || value.ascii_only?)
27
+ if budget[1]
28
+ charge_budget(budget, value.bytesize + 2)
29
+ # JSON escapes quotes/backslashes and control characters. Count in
30
+ # native String scans before generating any potentially large JSON.
31
+ charge_budget(budget, value.count('"') + value.count("\\") +
32
+ value.count("\x00-\x1f") * 5 - value.count("\b\t\n\f\r") * 4)
33
+ end
34
+ when "Array"
35
+ charge_budget(budget, 2 + [value.length - 1, 0].max)
36
+ value.each { |item| validate_payload(item, depth + 1, budget, freeze: freeze) }
37
+ when "Hash"
38
+ charge_budget(budget, 2 + value.length + [value.length - 1, 0].max)
39
+ value.each do |key, item|
40
+ raise ArgumentError, "IPC object keys must be strings" unless key.instance_of?(String)
41
+ validate_payload(key, depth + 1, budget, freeze: freeze)
42
+ validate_payload(item, depth + 1, budget, freeze: freeze)
43
+ end
44
+ else
45
+ raise ArgumentError, "IPC values must be plain JSON data"
46
+ end
47
+ value.freeze if freeze
48
+ value
49
+ end
50
+
51
+ def self.charge_budget(budget, bytes)
52
+ return unless budget[1]
53
+ raise ArgumentError, "IPC frame exceeds #{budget[2]} bytes" if (budget[1] -= bytes).negative?
54
+ end
55
+
56
+ def self.encode_frame(value, max_bytes)
57
+ validate_payload(value, 0, [MAX_NODES, max_bytes, max_bytes])
58
+ body = JSON.generate(value, max_nesting: MAX_DEPTH)
59
+ raise ArgumentError, "IPC frame exceeds #{max_bytes} bytes" if body.bytesize > max_bytes
60
+ [body.bytesize].pack("N") + body.b
61
+ end
62
+
63
+ def self.read_payload(io, max_bytes)
64
+ header = io.read(4)
65
+ return if header.nil?
66
+ raise IOError, "truncated IPC header" unless header.bytesize == 4
67
+ length = header.unpack1("N")
68
+ raise IOError, "invalid IPC frame length" unless length.between?(1, max_bytes)
69
+ body = io.read(length)
70
+ raise IOError, "truncated IPC payload" unless body && body.bytesize == length
71
+ body.force_encoding(Encoding::UTF_8)
72
+ raise IOError, "invalid IPC UTF-8" unless body.valid_encoding?
73
+ body
74
+ end
75
+
76
+ def self.decode_payload(body)
77
+ validate_payload(JSON.parse(body, max_nesting: MAX_DEPTH, create_additions: false), freeze: true)
78
+ end
79
+
80
+ def self.error_message(error)
81
+ "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace).byteslice(0, 2048).scrub("")
82
+ end
83
+
84
+ private_class_method :validate_payload, :charge_budget
85
+
86
+ def self.serve(input, output, max_bytes, handler)
87
+ input.binmode
88
+ output.binmode
89
+ output.sync = true
90
+ while (body = read_payload(input, max_bytes))
91
+ response = begin
92
+ encode_frame([true, handler.call(decode_payload(body))], max_bytes)
93
+ rescue StandardError => error
94
+ encode_frame([false, error_message(error)], max_bytes)
95
+ end
96
+ output.write(response)
97
+ end
98
+ end
99
+
100
+ def self.run
101
+ # Keep protocol output private; even a required file's `puts` is harmless.
102
+ output = STDOUT.dup
103
+ STDOUT.reopen(STDERR)
104
+ $stdout = STDERR
105
+ name, maximum, *requires = ARGV
106
+ raise ArgumentError, "invalid worker handler" unless name&.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
107
+ max_bytes = Integer(maximum)
108
+ requires.each { |path| require path }
109
+ handler = name.split("::").reduce(Object) { |scope, part| scope.const_get(part, false) }
110
+ raise ArgumentError, "worker handler must respond to .call" unless handler.is_a?(Module) && handler.respond_to?(:call)
111
+ serve(STDIN, output, max_bytes, handler)
112
+ rescue StandardError => error
113
+ output.write(encode_frame([false, error_message(error)], max_bytes)) if output && max_bytes
114
+ ensure
115
+ output&.close
116
+ end
117
+ end
118
+ end
119
+
120
+ if $PROGRAM_NAME == __FILE__
121
+ # Ruby does not put its entry script in $LOADED_FEATURES. A handler may load
122
+ # zaniah, which requires this file again through ProcessPool; register it
123
+ # before loading handlers so that require cannot re-enter worker startup.
124
+ path = File.realpath(__FILE__)
125
+ $LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
126
+ Zaniah::ProcessWire.run
127
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class QuadPacker
5
+ attr_reader :bytes
6
+
7
+ def initialize = @bytes = String.new(capacity: 1 << 20, encoding: Encoding::BINARY)
8
+
9
+ def pack(scene)
10
+ @bytes.clear
11
+ @bytes << scene.quads.pack("f*")
12
+ @bytes
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Scene
5
+ SpriteBatch = Data.define(:bytes, :texture)
6
+ end
7
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ # Primitive payloads are flat arrays; command order preserves alpha compositing.
5
+ class Scene
6
+ QUAD_STRIDE = 17
7
+ SPRITE_INSTANCE_BYTES = 24 * 4
8
+ attr_reader :quads, :paths, :commands, :textures, :sprite_batches
9
+
10
+ def initialize
11
+ @quads, @sprites, @paths, @commands, @textures = [], [], [], [], []
12
+ @sprite_batches, @expanded_batches = [], {}
13
+ @clips, @layers = [], [0]
14
+ @colors, @last_layer, @ordered = {}, -Float::INFINITY, true
15
+ end
16
+
17
+ def clear
18
+ [@quads, @sprites, @paths, @commands, @textures, @clips].each(&:clear)
19
+ @sprite_batches.clear
20
+ @expanded_batches.clear
21
+ @layers.replace([0])
22
+ @last_layer, @ordered = -Float::INFINITY, true
23
+ self
24
+ end
25
+
26
+ def quad(x, y, width, height, color:, radius: 0, border_width: 0, border_color: "#0000")
27
+ return self if width <= 0 || height <= 0
28
+ offset = @quads.length
29
+ if radius.is_a?(Numeric)
30
+ @quads.push(x, y, width, height, *color_values(color), radius, radius, radius, radius, border_width, *color_values(border_color))
31
+ else
32
+ corners = radius.to_a
33
+ raise ArgumentError, "four radii required" unless corners.length == 4
34
+ @quads.push(x, y, width, height, *color_values(color), *corners, border_width, *color_values(border_color))
35
+ end
36
+ command(:quad, offset)
37
+ self
38
+ end
39
+
40
+ def push_quad(x, y, width, height, bg, radius, border_width, border_color, order = 0)
41
+ layer(order) { quad(x, y, width, height, color: bg, radius: radius,
42
+ border_width: border_width, border_color: border_color) }
43
+ end
44
+
45
+ def sprite(x, y, width, height, texture:, color: "#fff", source: nil)
46
+ source ||= Bounds.new(0, 0, texture.width, texture.height)
47
+ id = @textures.index(texture) || @textures.push(texture).length - 1
48
+ offset = @sprites.length
49
+ @sprites.push(x, y, width, height, *color_values(color), id,
50
+ source.x, source.y, source.width, source.height)
51
+ command(:sprite, offset)
52
+ self
53
+ end
54
+
55
+ # Prepacked GPU instances retain ordinary sprite alpha/clip/layer ordering.
56
+ def sprite_batch(bytes, texture: nil)
57
+ batch = bytes if bytes.is_a?(SpriteBatch)
58
+ bytes, texture = batch.bytes, batch.texture if batch
59
+ raise ArgumentError, "invalid packed sprite bytes" unless bytes.is_a?(String) && bytes.bytesize % SPRITE_INSTANCE_BYTES == 0
60
+ raise ArgumentError, "packed sprites need a texture" unless texture.is_a?(GPU::Texture)
61
+ return self if bytes.empty?
62
+ @textures << texture unless @textures.include?(texture)
63
+ offset = @sprite_batches.length
64
+ @sprite_batches << (batch && bytes.frozen? ? batch : SpriteBatch.new(bytes.frozen? ? bytes : bytes.dup.freeze, texture))
65
+ command(:sprite_batch, offset)
66
+ self
67
+ end
68
+
69
+ # Preserve the public flat-sprite inspection API; native packing does not
70
+ # materialize these arrays, while the software renderer can reuse them.
71
+ def sprites
72
+ @sprite_batches.each_index { |index| expand_sprite_batch(index) }
73
+ @sprites
74
+ end
75
+ def sprite_data = @sprites
76
+ def expand_sprite_batch(index)
77
+ @expanded_batches[index] ||= begin
78
+ batch = @sprite_batches.fetch(index)
79
+ texture = batch.texture
80
+ id = @textures.index(texture)
81
+ offset = @sprites.length
82
+ batch.bytes.unpack("f*").each_slice(24) do |values|
83
+ @sprites.push(*values[0, 8], id, values[20] * texture.width, values[21] * texture.height,
84
+ values[22] * texture.width, values[23] * texture.height)
85
+ end
86
+ [offset, batch.bytes.bytesize / SPRITE_INSTANCE_BYTES]
87
+ end
88
+ end
89
+
90
+ def triangle(points, color:)
91
+ raise ArgumentError, "three points required" unless points.length == 6
92
+ offset = @paths.length
93
+ @paths.push(*points, *color_values(color))
94
+ command(:triangle, offset)
95
+ self
96
+ end
97
+
98
+ def shadow(x, y, width, height, color: "#0006", blur: 8, radius: 0)
99
+ raise ArgumentError, "blur must be positive" unless blur.positive?
100
+ # A small sequence of expanding translucent rounded rectangles approximates
101
+ # a Gaussian convolution without a temporary render target.
102
+ 8.downto(1) do |step|
103
+ spread = blur * step / 8.0
104
+ alpha = Math.exp(-2.0 * (step / 8.0)**2) / 10.0
105
+ quad(x - spread, y - spread, width + 2 * spread, height + 2 * spread,
106
+ color: Color.parse(color).opacity(alpha), radius: radius + spread)
107
+ end
108
+ self
109
+ end
110
+
111
+ def underline(x, y, width, color:, thickness: 1, wave: false)
112
+ return quad(x, y, width, thickness, color: color) unless wave
113
+ width.ceil.times do |i|
114
+ quad(x + i, y + Math.sin(i * Math::PI / 4) * thickness, 1, thickness, color: color)
115
+ end
116
+ self
117
+ end
118
+
119
+ def clip(bounds)
120
+ @clips << (@clips.empty? ? bounds : @clips.last.intersect(bounds))
121
+ yield
122
+ ensure
123
+ @clips.pop
124
+ end
125
+ def current_clip = @clips.last
126
+
127
+ def layer(order)
128
+ @layers << order
129
+ yield
130
+ ensure
131
+ @layers.pop
132
+ end
133
+
134
+ def each_command
135
+ return enum_for(__method__) unless block_given?
136
+ if @ordered
137
+ index = 0
138
+ while index < @commands.length
139
+ yield @commands[index], @commands[index + 1], @commands[index + 3]
140
+ index += 4
141
+ end
142
+ return
143
+ end
144
+ indices = (0...@commands.length).step(4).to_a
145
+ indices.sort_by! { |i| [@commands[i + 2], i] }
146
+ indices.each { |i| yield @commands[i], @commands[i + 1], @commands[i + 3] }
147
+ end
148
+
149
+ private
150
+
151
+ def color_values(value)
152
+ @colors.clear if @colors.length > 256
153
+ @colors[value] ||= Color.parse(value).to_a.freeze
154
+ end
155
+
156
+ def command(kind, offset)
157
+ @ordered = false if @layers.last < @last_layer
158
+ @last_layer = @layers.last
159
+ @commands.push(kind, offset, @layers.last, @clips.last)
160
+ end
161
+ end
162
+
163
+ end
164
+
165
+ require_relative "scene/sprite_batch"
166
+ require_relative "quad_packer"
167
+ require_relative "scene_renderer"