expressir 2.4.0 → 2.4.2

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bugs/01-stale-transformer-autoload.md +39 -0
  3. data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
  4. data/TODO.bugs/03-builder-mutable-state.md +43 -0
  5. data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
  6. data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
  7. data/TODO.bugs/06-collection-registry-single-source.md +53 -0
  8. data/TODO.bugs/07-require-relative-cleanup.md +42 -0
  9. data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
  10. data/TODO.bugs/09-parser-split.md +53 -0
  11. data/TODO.bugs/10-to-s-override.md +42 -0
  12. data/TODO.bugs/11-parser-class-variables.md +39 -0
  13. data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
  14. data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
  15. data/TODO.bugs/14-model-formatting-leak.md +30 -0
  16. data/TODO.bugs/15-expression-children-macro.md +27 -0
  17. data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
  18. data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
  19. data/TODO.bugs/18-const-get-private-constants.md +30 -0
  20. data/TODO.bugs/19-format-methods-public.md +22 -0
  21. data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
  22. data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
  23. data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
  24. data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
  25. data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
  26. data/TODO.bugs/25-debug-puts-in-production.md +21 -0
  27. data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
  28. data/TODO.bugs/27-package-build-god-method.md +19 -0
  29. data/TODO.bugs/28-package-god-class.md +30 -0
  30. data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
  31. data/TODO.bugs/30-unicode-map-extraction.md +19 -0
  32. data/TODO.bugs/README.md +43 -0
  33. data/TODO.max-perf/01-restore-ci-green.md +29 -0
  34. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  35. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  36. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  37. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  38. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  39. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  40. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  41. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  42. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  43. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  44. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  45. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  46. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  47. data/benchmark/srl_benchmark.rb +76 -17
  48. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  49. data/docs/_tutorials/index.adoc +10 -0
  50. data/docs/lychee.toml +3 -0
  51. data/expressir.gemspec +4 -2
  52. data/lib/expressir/cli.rb +3 -0
  53. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  54. data/lib/expressir/commands/changes_validate.rb +0 -2
  55. data/lib/expressir/commands/clean.rb +1 -1
  56. data/lib/expressir/commands/coverage.rb +6 -2
  57. data/lib/expressir/commands/file_violations.rb +70 -0
  58. data/lib/expressir/commands/format.rb +1 -1
  59. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  60. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  61. data/lib/expressir/commands/package.rb +143 -187
  62. data/lib/expressir/commands/validate_ascii.rb +0 -510
  63. data/lib/expressir/commands/version.rb +1 -1
  64. data/lib/expressir/commands.rb +3 -0
  65. data/lib/expressir/coverage.rb +49 -117
  66. data/lib/expressir/express/ast_key_converter.rb +114 -0
  67. data/lib/expressir/express/builder.rb +71 -194
  68. data/lib/expressir/express/builder_context.rb +22 -0
  69. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  70. data/lib/expressir/express/cache.rb +35 -8
  71. data/lib/expressir/express/error.rb +24 -0
  72. data/lib/expressir/express/formatter.rb +33 -18
  73. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  74. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  75. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  76. data/lib/expressir/express/grammar/parser.rb +705 -0
  77. data/lib/expressir/express/grammar.rb +11 -0
  78. data/lib/expressir/express/node_position_index.rb +215 -0
  79. data/lib/expressir/express/parallel_files.rb +229 -0
  80. data/lib/expressir/express/parser.rb +138 -950
  81. data/lib/expressir/express/pretty_formatter.rb +23 -5
  82. data/lib/expressir/express/remark_attacher.rb +497 -581
  83. data/lib/expressir/express/remark_scanner.rb +84 -19
  84. data/lib/expressir/express/schema_block_scanner.rb +137 -0
  85. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  86. data/lib/expressir/express/scope_resolver.rb +223 -0
  87. data/lib/expressir/express/source_formatter.rb +15 -0
  88. data/lib/expressir/express/streaming_builder.rb +147 -176
  89. data/lib/expressir/express.rb +10 -6
  90. data/lib/expressir/model/concerns.rb +3 -0
  91. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  92. data/lib/expressir/model/declarations/entity.rb +5 -0
  93. data/lib/expressir/model/declarations/function.rb +5 -0
  94. data/lib/expressir/model/declarations/procedure.rb +5 -0
  95. data/lib/expressir/model/declarations/rule.rb +6 -0
  96. data/lib/expressir/model/declarations/schema.rb +21 -8
  97. data/lib/expressir/model/declarations/type.rb +3 -0
  98. data/lib/expressir/model/exp_file.rb +2 -0
  99. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  100. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  101. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  102. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  103. data/lib/expressir/model/expressions/function_call.rb +1 -0
  104. data/lib/expressir/model/expressions/interval.rb +1 -0
  105. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  106. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  107. data/lib/expressir/model/model_element.rb +133 -14
  108. data/lib/expressir/model/remark_format.rb +17 -0
  109. data/lib/expressir/model/remark_info.rb +31 -3
  110. data/lib/expressir/model/remark_placement.rb +29 -0
  111. data/lib/expressir/model/repository.rb +18 -5
  112. data/lib/expressir/model/statements/alias.rb +2 -0
  113. data/lib/expressir/model/statements/assignment.rb +4 -0
  114. data/lib/expressir/model/statements/case.rb +21 -0
  115. data/lib/expressir/model/statements/case_action.rb +1 -0
  116. data/lib/expressir/model/statements/compound.rb +4 -0
  117. data/lib/expressir/model/statements/escape.rb +3 -0
  118. data/lib/expressir/model/statements/if.rb +5 -0
  119. data/lib/expressir/model/statements/null.rb +3 -0
  120. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  121. data/lib/expressir/model/statements/repeat.rb +3 -0
  122. data/lib/expressir/model/statements/return.rb +3 -0
  123. data/lib/expressir/model/statements/skip.rb +3 -0
  124. data/lib/expressir/model.rb +12 -3
  125. data/lib/expressir/package/builder.rb +2 -2
  126. data/lib/expressir/version.rb +6 -1
  127. data/lib/expressir.rb +56 -3
  128. metadata +81 -7
  129. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # EXPRESS grammar namespace. Houses the Parsanol grammar definition
6
+ # separate from the I/O orchestration on the outer Parser facade.
7
+ module Grammar
8
+ autoload :Parser, "#{__dir__}/grammar/parser"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Builds and queries an index of model nodes by source position.
6
+ #
7
+ # One interface (`nearest_node_to`, `node_for_end_scope_at`, `each_node`),
8
+ # one implementation. Extracted from RemarkAttacher so the tree-walk +
9
+ # byte→line translation + nearest-node heuristics live in one place,
10
+ # tested independently of any remark logic.
11
+ #
12
+ # Reference: ISO 10303-11 — the parser yields source spans on ModelElement
13
+ # instances; this module turns those spans into a line-keyed index that
14
+ # remark attachment can query.
15
+ class NodePositionIndex
16
+ # Single source of truth for "what collections does this node have"
17
+ # lives on the model — each class declares its own via the
18
+ # `collection_attributes` macro. Referenced by both RemarkAttacher
19
+ # and NodePositionIndex via this alias for backward compatibility.
20
+ COLLECTION_REGISTRY = Model::ModelElement.collection_registry
21
+
22
+ attr_reader :nodes
23
+
24
+ def initialize(model, line_map)
25
+ @model = model
26
+ @line_map = line_map
27
+ @nodes = build_sorted_nodes
28
+ end
29
+
30
+ # Returns the most-specific node whose span contains `remark_line`,
31
+ # preferring same-line starts/ends, then smallest containing span.
32
+ # Excludes Repository and Cache (not semantic scopes for remarks).
33
+ def nearest_node_to(remark_line)
34
+ same_start = nodes.select do |n|
35
+ n[:line] == remark_line && semantic?(n[:node])
36
+ end
37
+ return same_start.last[:node] if same_start.any?
38
+
39
+ same_end = nodes.select do |n|
40
+ n[:end_line] == remark_line && semantic?(n[:node])
41
+ end
42
+ return same_end.last[:node] if same_end.any?
43
+
44
+ containing = nodes.select do |n|
45
+ n[:line] && n[:end_line] &&
46
+ n[:line] <= remark_line && n[:end_line] >= remark_line &&
47
+ semantic?(n[:node])
48
+ end
49
+
50
+ if containing.any?
51
+ exp_file_node = containing.find { |n| n[:node].is_a?(Model::ExpFile) }
52
+ if exp_file_node
53
+ first_schema_offset = exp_file_node[:node].schemas&.first&.source_offset
54
+ if first_schema_offset && remark_line < @line_map.line_number(first_schema_offset)
55
+ return exp_file_node[:node]
56
+ end
57
+ end
58
+ # Prefer non-ExpFile nodes (Schema, Entity, etc.) over ExpFile
59
+ # when both span the same range, since ExpFile is a file-level
60
+ # container and remarks inside a schema belong to the schema.
61
+ candidates = containing.reject { |n| n[:node].is_a?(Model::ExpFile) }
62
+ candidates = containing if candidates.empty?
63
+ candidates.min_by { |n| n[:end_line] - n[:line] }[:node]
64
+ else
65
+ before = nodes.select do |n|
66
+ n[:end_line] && n[:end_line] < remark_line && semantic?(n[:node])
67
+ end
68
+ if before.any?
69
+ before.max_by { |n| n[:end_line] }[:node]
70
+ else
71
+ # Remark is before all nodes (e.g., preamble comment before SCHEMA).
72
+ # Attach to the first semantic node.
73
+ after = nodes.select { |n| n[:line] && semantic?(n[:node]) }
74
+ after.min_by { |n| n[:line] }[:node] if after.any?
75
+ end
76
+ end
77
+ end
78
+
79
+ # Returns the node whose END_XXX declaration is on `remark_line`, when
80
+ # the line text matches one of the END_XXX keywords. Used to attach
81
+ # remarks that appear immediately after a scope closes.
82
+ def node_for_end_scope_at(remark_line, line_content)
83
+ node_type = case line_content
84
+ when /END_SCHEMA/i then Model::Declarations::Schema
85
+ when /END_ENTITY/i then Model::Declarations::Entity
86
+ when /END_TYPE/i then Model::Declarations::Type
87
+ when /END_FUNCTION/i then Model::Declarations::Function
88
+ when /END_PROCEDURE/i then Model::Declarations::Procedure
89
+ when /END_RULE/i then Model::Declarations::Rule
90
+ end
91
+ return nil unless node_type
92
+
93
+ matching = nodes.select do |n|
94
+ n[:node].is_a?(node_type) &&
95
+ (n[:end_line] == remark_line ||
96
+ (n[:end_line] && n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2))
97
+ end
98
+
99
+ matching.first&.dig(:node) || nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
100
+ end
101
+
102
+ private
103
+
104
+ def semantic?(node)
105
+ !node.is_a?(Model::Repository) && !node.is_a?(Model::Cache)
106
+ end
107
+
108
+ def build_sorted_nodes
109
+ result = []
110
+ collect_nodes(@model, result, Set.new)
111
+ result.sort_by!.with_index { |n, i| [n[:position] || Float::INFINITY, i] }
112
+ result
113
+ end
114
+
115
+ def collect_nodes(node, result, visited, owner: nil, collection: nil)
116
+ return unless node
117
+ return if visited.include?(node.object_id)
118
+
119
+ visited.add(node.object_id)
120
+
121
+ if node.is_a?(Model::ModelElement) && node.source && node.source_offset
122
+ record_node_position(node, result, owner, collection)
123
+ else
124
+ result << { node: node, position: nil, line: nil, end_line: nil,
125
+ owner: owner, collection: collection }
126
+ end
127
+
128
+ collect_children(node, result, visited)
129
+ end
130
+
131
+ def record_node_position(node, result, owner, collection)
132
+ pos = node.source_offset
133
+ valid = position_valid?(pos, node)
134
+ unless valid
135
+ result << { node: node, position: nil, line: nil, end_line: nil,
136
+ owner: owner, collection: collection }
137
+ return
138
+ end
139
+
140
+ line = @line_map.line_number(pos)
141
+ source_end_line = @line_map.line_number(pos + node.source.length)
142
+ children_end_line = children_end_line_for(node)
143
+ end_line = [source_end_line, children_end_line].compact.max || source_end_line
144
+
145
+ result << { node: node, position: pos, line: line, end_line: end_line,
146
+ owner: owner, collection: collection }
147
+ end
148
+
149
+ # The parser returns source_offset=0 for leaf nodes (WhereRule) where
150
+ # it cannot determine the actual position. Accept position=0 only when
151
+ # the source is a declaration keyword line — those legitimately start
152
+ # at the beginning of the file.
153
+ def position_valid?(pos, node)
154
+ return true if pos.positive?
155
+ return false unless pos.zero? && node.source
156
+
157
+ node.source.to_s.start_with?(
158
+ "SCHEMA", "ENTITY", "TYPE", "FUNCTION",
159
+ "PROCEDURE", "RULE", "CONSTANT", "VARIABLE",
160
+ "USE", "REFERENCE", "END_SCHEMA", "END_ENTITY",
161
+ "END_TYPE", "END_FUNCTION", "END_PROCEDURE",
162
+ "END_RULE", "END_CONSTANT", "END_VARIABLE"
163
+ )
164
+ end
165
+
166
+ def children_end_line_for(node)
167
+ end_lines = []
168
+
169
+ if node.is_a?(Model::Declarations::Schema)
170
+ Array(node.children).each do |child|
171
+ next unless child.is_a?(Model::ModelElement) && child.source_offset && child.source
172
+
173
+ end_lines << @line_map.line_number(child.source_offset + child.source.length)
174
+ end
175
+ end
176
+
177
+ each_collection_on(node) do |item|
178
+ next unless item.is_a?(Model::ModelElement) && item.source_offset && item.source
179
+
180
+ end_lines << @line_map.line_number(item.source_offset + item.source.length)
181
+ end
182
+
183
+ end_lines.max
184
+ end
185
+
186
+ def collect_children(node, result, visited)
187
+ if node.is_a?(Model::Declarations::Schema)
188
+ Array(node.children).each do |c|
189
+ collect_nodes(c, result, visited, owner: node, collection: :children)
190
+ end
191
+ end
192
+
193
+ each_collection_on(node) do |item, attr|
194
+ collect_nodes(item, result, visited, owner: node, collection: attr)
195
+ end
196
+ end
197
+
198
+ # Yields each child in any declared collection on the node (with the
199
+ # collection attribute name), based on the type-driven
200
+ # COLLECTION_REGISTRY. Returns nothing for nodes whose class is not
201
+ # registered.
202
+ def each_collection_on(node)
203
+ attrs = COLLECTION_REGISTRY[node.class]
204
+ return unless attrs
205
+
206
+ attrs.each do |attr|
207
+ collection = node.public_send(attr)
208
+ next unless collection.is_a?(Array)
209
+
210
+ collection.each { |item| yield(item, attr) }
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,229 @@
1
+ require "etc"
2
+
3
+ module Expressir
4
+ module Express
5
+ # Fork-based worker pool for parsing many EXPRESS files in parallel.
6
+ # The native parser holds the GVL for the whole parse, so process-level
7
+ # parallelism is the only way to use multiple cores. Files are
8
+ # independent until reference resolution, which stays in the parent.
9
+ #
10
+ # Unlike sequential parsing, the progress block fires in file order
11
+ # only after all files have been parsed.
12
+ class ParallelFiles
13
+ DEFAULT_MAX_PROCESSES = 4
14
+ FRAME_HEADER_BYTES = 4
15
+
16
+ FORK_SUPPORTED = Process.respond_to?(:fork).freeze
17
+
18
+ # Forking is never the default: a library must not spawn processes on
19
+ # behalf of its host (forked children inherit broken thread and lock
20
+ # state, and fork does not exist on all Rubies). Parallelism requires
21
+ # an explicit max_processes > 1 from the caller and a platform that
22
+ # supports fork (e.g. not Windows); otherwise the request degrades
23
+ # to sequential parsing.
24
+ def self.sequential?(files, max_processes)
25
+ !FORK_SUPPORTED || max_processes.nil? || max_processes <= 1 ||
26
+ files.size < 3
27
+ end
28
+
29
+ # @param files [Array<String>] EXPRESS file paths
30
+ # @param max_processes [Integer, nil] worker cap; nil auto-selects
31
+ # @param parse [Proc] callback taking a file path, returning an ExpFile
32
+ # @param strict [Boolean] re-raise every error, including
33
+ # Error::SchemaParseFailure, instead of skipping the file
34
+ # @yield [file, exp_file, error] called in original file order
35
+ # @return [Array<Expressir::Model::ExpFile, nil>] parsed files in order;
36
+ # nil marks a file that failed with Error::SchemaParseFailure
37
+ def self.run(files, parse:, max_processes: nil, strict: false, &block)
38
+ new(files, max_processes, parse, block, strict).run
39
+ end
40
+
41
+ def initialize(files, max_processes, parse, block, strict)
42
+ @files = files
43
+ @parse = parse
44
+ @block = block
45
+ @strict = strict
46
+ @worker_count = [
47
+ files.size - 1,
48
+ max_processes || [Etc.nprocessors, DEFAULT_MAX_PROCESSES].min,
49
+ ].min
50
+ end
51
+
52
+ def run
53
+ job_pipes = Array.new(@worker_count) { IO.pipe }
54
+ result_pipes = Array.new(@worker_count) { IO.pipe }
55
+ pids = spawn_workers(job_pipes, result_pipes)
56
+
57
+ files_results = schedule_jobs(job_pipes, result_pipes)
58
+
59
+ ordered_pass(files_results)
60
+ ensure
61
+ cleanup(job_pipes, result_pipes, pids)
62
+ end
63
+
64
+ private
65
+
66
+ def spawn_workers(job_pipes, result_pipes)
67
+ job_pipes.each_index.map do |i|
68
+ job_r, job_w = job_pipes[i]
69
+ result_r, result_w = result_pipes[i]
70
+ fork do
71
+ job_w.close
72
+ result_r.close
73
+ other_pipes = (job_pipes + result_pipes).flatten -
74
+ [job_r, result_w]
75
+ other_pipes.each { |io| io.close unless io.closed? }
76
+ worker_loop(job_r, result_w)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Assigns one job at a time to whichever worker reports a result, so
82
+ # each file is parsed exactly once and each worker holds at most one
83
+ # in-flight job (keeping result pipes free of interleaved frames).
84
+ def schedule_jobs(job_pipes, result_pipes)
85
+ writers = job_pipes.map(&:last)
86
+ readers = result_pipes.map(&:first)
87
+ files_results = Array.new(@files.size)
88
+ next_job = 0
89
+ busy = {}
90
+
91
+ writers.each_index do |wi|
92
+ break if next_job == @files.size
93
+
94
+ dispatch(writers[wi], next_job)
95
+ busy[wi] = true
96
+ next_job += 1
97
+ end
98
+
99
+ until busy.empty?
100
+ ready, = IO.select(readers.values_at(*busy.keys))
101
+ ready.each do |io|
102
+ wi = readers.index(io)
103
+ payload = Marshal.load(read_frame(io)) # rubocop:disable Security/MarshalLoad
104
+ files_results[payload[:index]] = payload
105
+
106
+ if next_job < @files.size
107
+ dispatch(writers[wi], next_job)
108
+ next_job += 1
109
+ else
110
+ writers[wi].close unless writers[wi].closed?
111
+ busy.delete(wi)
112
+ end
113
+ end
114
+ end
115
+
116
+ files_results
117
+ end
118
+
119
+ def dispatch(writer, job_index)
120
+ write_frame(writer, pack_frame(Marshal.dump([job_index, @files[job_index]])))
121
+ end
122
+
123
+ def worker_loop(job_r, result_w)
124
+ until job_r.eof?
125
+ data = read_frame(job_r)
126
+ index, file = Marshal.load(data) # rubocop:disable Security/MarshalLoad
127
+ begin
128
+ payload = { index: index, exp_file: @parse.call(file) }
129
+ rescue StandardError => e
130
+ payload = { index: index, error: transferable_error(e) }
131
+ end
132
+ write_frame(result_w, pack_frame(Marshal.dump(payload)))
133
+ end
134
+ rescue Errno::EPIPE
135
+ # parent went away; nothing to report to
136
+ exit!(0)
137
+ end
138
+
139
+ # SIGTERM cannot interrupt a worker blocked in the native parser (the
140
+ # GVL is held), so escalate to SIGKILL after a grace period instead of
141
+ # blocking in waitpid forever.
142
+ def cleanup(job_pipes, result_pipes, pids)
143
+ (job_pipes.to_a + result_pipes.to_a).each do |r, w|
144
+ r.close unless r.closed?
145
+ w.close unless w.closed?
146
+ end
147
+ pids.to_a.each { |pid| Process.kill("TERM", pid) if alive?(pid) }
148
+
149
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
150
+ pids.to_a.each do |pid|
151
+ while alive?(pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
152
+ Process.waitpid(pid, Process::WNOHANG)
153
+ sleep 0.05
154
+ end
155
+ Process.kill("KILL", pid) if alive?(pid)
156
+ reap(pid)
157
+ end
158
+ end
159
+
160
+ def ordered_pass(files_results)
161
+ files_results.each_with_index do |payload, index|
162
+ error = payload[:error]
163
+ @block&.call(@files[index], payload[:exp_file], error)
164
+ raise error if error && (@strict ||
165
+ !error.is_a?(Error::SchemaParseFailure))
166
+ end
167
+
168
+ files_results.map do |payload|
169
+ payload[:error] ? nil : payload[:exp_file]
170
+ end
171
+ end
172
+
173
+ # Errors carrying native-parser state (e.g. Parsanol::ParseFailed with
174
+ # its cause tree) cannot cross a fork boundary; rebuild them without
175
+ # the untransferable internals, preserving class and message.
176
+ def transferable_error(error)
177
+ Marshal.dump(error)
178
+ error
179
+ rescue StandardError
180
+ if error.is_a?(Error::SchemaParseFailure)
181
+ Error::SchemaParseFailure.new(error.filename,
182
+ StandardError.new(error.message))
183
+ else
184
+ StandardError.new(error.message)
185
+ end
186
+ end
187
+
188
+ def pack_frame(data)
189
+ [data.bytesize].pack("N") + data
190
+ end
191
+
192
+ def write_frame(io, frame)
193
+ io.write(frame)
194
+ end
195
+
196
+ def read_frame(io)
197
+ header = read_exactly(io, FRAME_HEADER_BYTES)
198
+ read_exactly(io, header.unpack1("N"))
199
+ end
200
+
201
+ def read_exactly(io, count)
202
+ data = +""
203
+ while data.bytesize < count
204
+ chunk = io.read(count - data.bytesize)
205
+ unless chunk
206
+ raise Error::ParallelParseError,
207
+ "worker exited before sending its result"
208
+ end
209
+
210
+ data << chunk
211
+ end
212
+ data
213
+ end
214
+
215
+ def alive?(pid)
216
+ Process.kill(0, pid)
217
+ true
218
+ rescue Errno::ESRCH, Errno::EPERM
219
+ false
220
+ end
221
+
222
+ def reap(pid)
223
+ Process.waitpid(pid)
224
+ rescue Errno::ECHILD, Errno::EINVAL
225
+ nil
226
+ end
227
+ end
228
+ end
229
+ end