expect-pty 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +28 -19
  4. data/docs/API.md +88 -0
  5. data/docs/MIGRATION.md +15 -0
  6. data/lib/expect/cleanup.rb +33 -0
  7. data/lib/expect/configuration.rb +87 -66
  8. data/lib/expect/interaction.rb +226 -209
  9. data/lib/expect/logging.rb +157 -156
  10. data/lib/expect/matcher.rb +30 -29
  11. data/lib/expect/pattern.rb +8 -2
  12. data/lib/expect/pattern_list.rb +35 -5
  13. data/lib/expect/redactor.rb +11 -4
  14. data/lib/expect/relay.rb +97 -73
  15. data/lib/expect/relay_writer.rb +2 -1
  16. data/lib/expect/result.rb +32 -8
  17. data/lib/expect/session.rb +508 -0
  18. data/lib/expect/session_resources.rb +10 -2
  19. data/lib/expect/terminal.rb +78 -78
  20. data/lib/expect/version.rb +1 -1
  21. data/lib/expect.rb +333 -520
  22. data/sig/expect.rbs +355 -0
  23. metadata +13 -106
  24. data/.rubocop.yml +0 -66
  25. data/CONTRIBUTING.md +0 -29
  26. data/Gemfile +0 -28
  27. data/Rakefile +0 -38
  28. data/benchmark/matching.rb +0 -98
  29. data/benchmark/redactor.rb +0 -64
  30. data/benchmark/relay.rb +0 -48
  31. data/benchmark/scaling.rb +0 -70
  32. data/benchmark/send_slow.rb +0 -41
  33. data/benchmark/support.rb +0 -114
  34. data/docs/COMPATIBILITY.md +0 -98
  35. data/docs/INTERNAL_CONTRACTS.md +0 -154
  36. data/docs/PERFORMANCE.md +0 -165
  37. data/docs/RELEASING.md +0 -84
  38. data/docs/VERIFICATION.md +0 -456
  39. data/examples/dialogue.rb +0 -30
  40. data/examples/kibitz/README.md +0 -81
  41. data/examples/kibitz/kibitz.rb +0 -142
  42. data/examples/kibitz/test_kibitz.rb +0 -37
  43. data/examples/ssh_auto.rb +0 -94
  44. data/examples/ssh_interact.rb +0 -159
  45. data/examples/ssh_login.rb +0 -64
  46. data/expect-pty.gemspec +0 -33
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -5,196 +5,197 @@ require_relative "redactor"
5
5
  # 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
6
6
  # 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
7
7
  class Expect
8
- # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
9
- attr_reader :log_output, :diagnostic_output
10
-
11
- # 诊断目标仅借用;nil 沿用 stderr,Logger 使用 info/debug,回调接收冻结的事件 Hash。
12
- # 先校验再冲刷旧流;校验或冲刷失败时保留旧目标,避免把暂存尾部交给错误的接收方。
13
- def diagnostic_output=(target)
14
- unless target.nil? || (target.respond_to?(:info) && target.respond_to?(:debug)) ||
15
- target.respond_to?(:write) || target.respond_to?(:call)
16
- raise ArgumentError, "diagnostic output must support info/debug, write or call, or be nil"
8
+ # @api private
9
+ module Logging
10
+ # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
11
+ attr_reader :log_output, :diagnostic_output
12
+
13
+ # 诊断目标仅借用;nil 沿用 stderr,Logger 使用 info/debug,回调接收冻结的事件 Hash。
14
+ # 先校验再冲刷旧流;校验或冲刷失败时保留旧目标,避免把暂存尾部交给错误的接收方。
15
+ def diagnostic_output=(target)
16
+ unless target.nil? || (target.respond_to?(:info) && target.respond_to?(:debug)) ||
17
+ target.respond_to?(:write) || target.respond_to?(:call)
18
+ raise ArgumentError, "diagnostic output must support info/debug, write or call, or be nil"
19
+ end
20
+ return if @diagnostic_output.equal?(target)
21
+
22
+ flush_diagnostics
23
+ @diagnostic_output = target
17
24
  end
18
- return if @diagnostic_output.equal?(target)
19
25
 
20
- flush_diagnostics
21
- @diagnostic_output = target
22
- end
26
+ # 秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。
27
+ # 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
28
+ def redact(*secrets)
29
+ unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
30
+ raise ArgumentError, "secrets must be nonempty Strings"
31
+ end
23
32
 
24
- # 秘密仅作用于本会话的日志和诊断,不改写匹配、stdout 显示或 listeners 的协议字节。
25
- # 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
26
- def redact(*secrets)
27
- unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
28
- raise ArgumentError, "secrets must be nonempty Strings"
33
+ @secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
34
+ @log_redactor.patterns = @secrets if @log_redactor
35
+ @diagnostic_redactors.each_value { |redactor| redactor.patterns = @secrets }
36
+ connection
29
37
  end
30
38
 
31
- @secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
32
- @log_redactor.patterns = @secrets if @log_redactor
33
- @diagnostic_redactors&.each_value { |redactor| redactor.patterns = @secrets }
34
- self
35
- end
39
+ # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
40
+ def log_output=(target)
41
+ unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
42
+ raise ArgumentError, "log output must support write or call, or be nil"
43
+ end
36
44
 
37
- # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
38
- def log_output=(target)
39
- unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
40
- raise ArgumentError, "log output must support write or call, or be nil"
45
+ replace_log(target)
41
46
  end
42
47
 
43
- replace_log(target)
44
- end
45
-
46
- # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
47
- def log_to(target = nil, mode: "a", &block)
48
- raise ArgumentError, "provide a log target or a block, not both" if block && target
48
+ # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
49
+ def log_to(target = nil, mode: "a", &block)
50
+ raise ArgumentError, "provide a log target or a block, not both" if block && target
49
51
 
50
- target = block if block
51
- if target.respond_to?(:to_path) || target.is_a?(String)
52
- raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
52
+ target = block if block
53
+ if target.respond_to?(:to_path) || target.is_a?(String)
54
+ raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
53
55
 
54
- # 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
55
- flush_log
56
- # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
57
- replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
58
- else
59
- raise ArgumentError, "provide a log target or a block" unless target
56
+ # 先交付旧过滤尾部,再允许新路径截断;同一文件不能在截断后被旧句柄写回。
57
+ flush_log
58
+ # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
59
+ replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
60
+ else
61
+ raise ArgumentError, "provide a log target or a block" unless target
60
62
 
61
- self.log_output = target
63
+ self.log_output = target
64
+ end
62
65
  end
63
- end
64
66
 
65
- # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
66
- # 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
67
- def write_log(*objects)
68
- target = log_output
69
- return unless target
70
-
71
- data = objects.map { |object| object.to_s.b }.join
72
- if @secrets
73
- @log_redactor ||= Redactor.new(@secrets)
74
- data = @log_redactor.append(data)
75
- return if data.empty?
67
+ # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
68
+ # 启用脱敏后可能暂存末尾字节,因此一次调用不保证触发一次日志写入或回调。
69
+ def write_log(*objects)
70
+ target = log_output
71
+ return unless target
72
+
73
+ data = objects.map { |object| object.to_s.b }.join
74
+ if @secrets
75
+ @log_redactor ||= Redactor.new(@secrets)
76
+ data = @log_redactor.append(data)
77
+ return if data.empty?
78
+ end
79
+ target.respond_to?(:call) ? target.call(data) : emit(target, data)
76
80
  end
77
- target.respond_to?(:call) ? target.call(data) : emit(target, data)
78
- end
79
81
 
80
- # 返回监听器列表副本,避免外部原地修改转发关系。
81
- def listeners = @listeners.dup
82
+ # 返回监听器列表副本,避免外部原地修改转发关系。
83
+ def listeners = @listeners.dup
82
84
 
83
- # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
84
- def listeners=(outputs)
85
- outputs = Array(outputs)
86
- raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
85
+ # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
86
+ def listeners=(outputs)
87
+ outputs = Array(outputs)
88
+ raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
87
89
 
88
- @listeners = outputs.dup
89
- end
90
+ @listeners = outputs.dup
91
+ end
90
92
 
91
- private
93
+ # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
94
+ def propagate(data)
95
+ emit($stdout, data) if log_stdout?
96
+ @listeners.each { |listener| emit(listener, data) } if log_listeners?
97
+ end
92
98
 
93
- # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
94
- def propagate(data)
95
- emit($stdout, data) if log_stdout?
96
- @listeners.each { |listener| emit(listener, data) } if log_listeners?
97
- end
99
+ private
98
100
 
99
- # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
100
- # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
101
- # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
102
- def emit(target, data)
103
- offset = 0
104
- while offset < data.bytesize
105
- count = target.write(data.byteslice(offset..))
106
- unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
107
- raise IOError, "write must return the number of accepted bytes"
108
- end
101
+ # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
102
+ # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
103
+ # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
104
+ def emit(target, data)
105
+ offset = 0
106
+ while offset < data.bytesize
107
+ count = target.write(data.byteslice(offset..))
108
+ unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
109
+ raise IOError, "write must return the number of accepted bytes"
110
+ end
109
111
 
110
- offset += count
112
+ offset += count
113
+ end
114
+ target.flush if target.respond_to?(:flush)
111
115
  end
112
- target.flush if target.respond_to?(:flush)
113
- end
114
116
 
115
- # 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
116
- def trace_data(event, data, level:)
117
- if @secrets
118
- return trace("buffer [FILTERED]", level: level, event: event) if event == :buffer
117
+ # 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
118
+ def trace_data(event, data, level:)
119
+ if @secrets
120
+ return trace("buffer [FILTERED]", level:, event:) if event == :buffer
119
121
 
120
- @diagnostic_redactors ||= {}
121
- redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
122
- data = redactor.append(data)
123
- return if data.empty?
122
+ redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
123
+ data = redactor.append(data)
124
+ return if data.empty?
125
+ end
126
+ trace("#{event} #{data.inspect}", level:, event:)
124
127
  end
125
- trace("#{event} #{data.inspect}", level: level, event: event)
126
- end
127
128
 
128
- # 诊断与接收字节日志分开;回调元数据不含会话对象,避免格式化时意外展开原始缓冲。
129
- def trace(message, level: 1, event: :matched)
130
- return unless debug_level >= level
131
-
132
- severity = level == 1 ? :info : :debug
133
- target = diagnostic_output
134
- if target.respond_to?(:info) && target.respond_to?(:debug)
135
- target.public_send(severity, "#{inspect}: #{message}")
136
- elsif target.respond_to?(:call)
137
- target.call({ event: event, level: severity, pid: pid, fd: fileno, message: message.freeze }.freeze)
138
- elsif target
139
- emit(target, "#{inspect}: #{message}\n")
140
- else
141
- warn("#{inspect}: #{message}")
129
+ # 诊断与接收字节日志分开;回调元数据不含会话对象,避免格式化时意外展开原始缓冲。
130
+ def trace(message, level: 1, event: :matched)
131
+ return unless debug_level >= level
132
+
133
+ severity = level == 1 ? :info : :debug
134
+ target = diagnostic_output
135
+ if target.respond_to?(:info) && target.respond_to?(:debug)
136
+ target.public_send(severity, "#{inspect}: #{message}")
137
+ elsif target.respond_to?(:call)
138
+ target.call({ event:, level: severity, pid:, fd: fileno, message: message.freeze }.freeze)
139
+ elsif target
140
+ emit(target, "#{inspect}: #{message}\n")
141
+ else
142
+ warn("#{inspect}: #{message}")
143
+ end
142
144
  end
143
- end
144
145
 
145
- # 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
146
- def flush_log
147
- loop do
148
- return unless @log_redactor && log_output
146
+ # 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
147
+ def flush_log
148
+ loop do
149
+ return unless @log_redactor && log_output
149
150
 
150
- data = @log_redactor.finish
151
- return if data.empty?
151
+ data = @log_redactor.finish
152
+ return if data.empty?
152
153
 
153
- log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
154
- # 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
154
+ log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
155
+ # 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
156
+ end
155
157
  end
156
- end
157
-
158
- # EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
159
- # GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
160
- def flush_diagnostics(event = nil)
161
- return unless @diagnostic_redactors
162
-
163
- loop do
164
- delivered = false
165
- # 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
166
- @diagnostic_redactors.to_a.each do |name, redactor|
167
- next if event && name != event
168
-
169
- data = redactor.finish
170
- next if data.empty?
171
158
 
172
- delivered = true
173
- trace("#{name} #{data.inspect}", level: 2, event: name)
159
+ # EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
160
+ # GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
161
+ def flush_diagnostics(event = nil)
162
+ return unless @diagnostic_redactors
163
+
164
+ loop do
165
+ delivered = false
166
+ # 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
167
+ @diagnostic_redactors.to_a.each do |name, redactor|
168
+ next if event && name != event
169
+
170
+ data = redactor.finish
171
+ next if data.empty?
172
+
173
+ delivered = true
174
+ trace("#{name} #{data.inspect}", level: 2, event: name)
175
+ end
176
+ # 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
177
+ break unless delivered
174
178
  end
175
- # 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
176
- break unless delivered
177
179
  end
178
- end
179
180
 
180
- # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
181
- def replace_log(target, owned: false)
182
- # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
183
- return target if log_output.equal?(target)
184
-
185
- flush_log
186
- # 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
187
- return target if log_output.equal?(target)
188
-
189
- previous = @resources.owned_log
190
- previous.close if previous && !previous.closed?
191
- # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
192
- @resources.owned_log = owned ? target : nil
193
- @log_output = target
194
- @log_redactor = nil
195
- target
196
- rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
197
- target.close if owned && target && !target.closed?
198
- raise
181
+ # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
182
+ def replace_log(target, owned: false)
183
+ # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
184
+ return target if log_output.equal?(target)
185
+
186
+ Cleanup.on_failure(-> { target.close if owned && target && !target.closed? }) do
187
+ flush_log
188
+ # 尾部回调可以重入并替换目标;交接时重新读取目标和所有权,不能关闭旧的回调对象。
189
+ next target if log_output.equal?(target)
190
+
191
+ previous = @resources.owned_log
192
+ previous.close if previous && !previous.closed?
193
+ # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
194
+ @resources.owned_log = owned ? target : nil
195
+ @log_output = target
196
+ @log_redactor = nil
197
+ target
198
+ end
199
+ end
199
200
  end
200
201
  end
@@ -3,16 +3,18 @@
3
3
  class Expect
4
4
  # 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
5
5
  # 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
6
+ # @api private
6
7
  class Matcher
7
8
  # 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
8
9
  def initialize(patterns, timeout, deadline: nil)
9
- @patterns = patterns
10
- @sessions = patterns.sessions
10
+ @patterns = patterns.finalize!
11
+ @sessions = patterns.sessions.map { |connection| Session.for(connection) }
12
+ @groups = patterns.groups.map { |connections, entries| [connections.map { |c| Session.for(c) }, entries] }
11
13
  @timeout = Expect.duration(timeout)
12
14
  # 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
13
15
  @hard_deadline = deadline
14
16
  @deadline = next_deadline
15
- @handled_eof = []
17
+ @handled_eof = {}.compare_by_identity
16
18
  @stalled_matches = {}
17
19
  @polled = false
18
20
  @expired_eof_continuation = false
@@ -22,15 +24,15 @@ class Expect
22
24
  def run
23
25
  @relay_buffers = {}
24
26
  @sessions.each do |session|
25
- buffer = session.__send__(:interaction_buffer)
27
+ buffer = session.interaction_buffer
26
28
  if buffer
27
29
  # 转义回调中的显式匹配临时接管读取,先消费转接已经预读的尾部。
28
30
  @relay_buffers[session] = buffer
29
- session.__send__(:interaction_buffer=, nil)
30
- session.__send__(:restore_relay_buffer, buffer)
31
+ session.interaction_buffer = nil
32
+ session.restore_relay_buffer(buffer)
31
33
  buffer.clear
32
34
  end
33
- session.__send__(:reset_result)
35
+ session.reset_result
34
36
  end
35
37
  loop do
36
38
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
@@ -49,7 +51,7 @@ class Expect
49
51
  # 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
50
52
  @relay_buffers.each do |session, buffer|
51
53
  buffer.replace(session.clear_buffer)
52
- session.__send__(:interaction_buffer=, buffer)
54
+ session.interaction_buffer = buffer
53
55
  end
54
56
  end
55
57
 
@@ -59,13 +61,13 @@ class Expect
59
61
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
60
62
  def find_match
61
63
  # 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
62
- groups = @patterns.groups
64
+ groups = @groups
63
65
  if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
64
66
  snapshots = {}.compare_by_identity
65
67
  end
66
68
  groups.each do |sessions, patterns|
67
69
  sessions.each do |session|
68
- next if @handled_eof.include?(session)
70
+ next if @handled_eof.key?(session)
69
71
 
70
72
  buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
71
73
  stalled = @stalled_matches[session]
@@ -99,23 +101,22 @@ class Expect
99
101
  @literal_misses ||= {}.compare_by_identity
100
102
  misses = (@literal_misses[session] ||= {}.compare_by_identity)
101
103
  previous = misses[pattern]
102
- generation = session.__send__(:buffer_generation)
103
- # 缓冲代次未变意味着只有尾部追加;模式对象的 value 被替换时也不能沿用旧扫描位置。
104
- offset = if previous && previous[0] == generation && previous[2].equal?(pattern.value)
104
+ generation = session.buffer_generation
105
+ # 模式已冻结;缓冲代次未变意味着只有尾部追加。
106
+ offset = if previous && previous[0] == generation
105
107
  return nil if previous[1] == buffer.bytesize
106
108
 
107
109
  [previous[1] - pattern.value.bytesize + 1, 0].max
108
110
  else
109
111
  0
110
112
  end
111
- position = pattern.locate(buffer, offset: offset)
113
+ position = pattern.locate(buffer, offset:)
112
114
  if position
113
115
  misses.delete(pattern)
114
116
  else
115
117
  entry = (misses[pattern] ||= [])
116
118
  entry[0] = generation
117
119
  entry[1] = buffer.bytesize
118
- entry[2] = pattern.value
119
120
  end
120
121
  position
121
122
  end
@@ -123,8 +124,8 @@ class Expect
123
124
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
124
125
  def handle_match(session, pattern, position)
125
126
  previous_buffer = session.buffer
126
- result = session.__send__(:record_match, pattern, position)
127
- action = pattern.call(session)
127
+ result = session.record_match(pattern, position)
128
+ action = pattern.call(session.connection)
128
129
  return result unless continuing?(action)
129
130
 
130
131
  # 回调未改变缓冲时暂停当前模式,等待缓冲变化后再匹配,避免原地空转。
@@ -141,18 +142,18 @@ class Expect
141
142
 
142
143
  # 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
143
144
  def unhandled_eof
144
- @sessions.find { |session| session.eof? && !@handled_eof.include?(session) }
145
+ @sessions.find { |session| session.eof? && !@handled_eof.key?(session) }
145
146
  end
146
147
 
147
148
  # 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
148
149
  def handle_eof(session)
149
- result = session.__send__(:record_eof)
150
- @handled_eof << session
151
- actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
150
+ result = session.record_eof
151
+ @handled_eof[session] = true
152
+ actions = @patterns.eof_patterns_for(session.connection).map { |pattern| pattern.call(session.connection) }
152
153
  return result unless actions.any? { |action| continuing?(action) }
153
154
 
154
155
  @deadline = next_deadline if actions.include?(CONTINUE)
155
- return result if @sessions.all? { |candidate| @handled_eof.include?(candidate) }
156
+ return result if @handled_eof.size == @sessions.size
156
157
 
157
158
  # 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
158
159
  @expired_eof_continuation = !actions.include?(CONTINUE) && expired?
@@ -192,11 +193,11 @@ class Expect
192
193
  session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
193
194
  begin
194
195
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
195
- data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
196
+ data = session.read_available(propagate: !@relay_buffers.key?(session))
196
197
  rescue Errno::EINTR
197
198
  next
198
199
  rescue IOError, SystemCallError => error
199
- return session.__send__(:record_error, error)
200
+ return session.record_error(error)
200
201
  end
201
202
  @stalled_matches.delete(session) if data
202
203
  @deadline = next_deadline if data && session.reset_timeout_on_read?
@@ -205,7 +206,7 @@ class Expect
205
206
  end
206
207
 
207
208
  # 返回本次仍需监听的会话,供读取选择和超时回调使用。
208
- def active_sessions = @sessions.reject { |session| @handled_eof.include?(session) }
209
+ def active_sessions = @sessions.reject { |session| @handled_eof.key?(session) }
209
210
 
210
211
  # 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
211
212
  def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
@@ -229,16 +230,16 @@ class Expect
229
230
  # 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
230
231
  def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
231
232
 
232
- # select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
233
+ # select 失败时无法归属单个源,只更新仍监听的会话;已派发 EOF 的结果保持不变。
233
234
  def record_error(error)
234
- @sessions.map { |session| session.__send__(:record_error, error) }.first
235
+ active_sessions.map { |session| session.record_error(error) }.first
235
236
  end
236
237
 
237
238
  # 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
238
239
  # 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
239
240
  def handle_timeout
240
- results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
241
- action = @patterns.timeout_pattern&.call(active_sessions)
241
+ results = active_sessions.map { |session| session.record_error(:timeout) }
242
+ action = @patterns.timeout_pattern&.call(active_sessions.map(&:connection))
242
243
  return results.first unless action == CONTINUE && !hard_expired?
243
244
 
244
245
  @deadline = next_deadline
@@ -2,7 +2,12 @@
2
2
 
3
3
  class Expect
4
4
  # 一个已编号的文本模式或事件及其回调;匹配结果统一使用字节偏移,便于精确消费 IO 缓冲。
5
- Pattern = Struct.new(:number, :value, :callback, keyword_init: true) do
5
+ # @api private
6
+ Pattern = Data.define(:number, :value, :callback) do
7
+ def initialize(value:, number: nil, callback: nil)
8
+ super(value: value.is_a?(String) ? value.b.freeze : value, number:, callback:)
9
+ end
10
+
6
11
  # 将匹配会话或超时会话数组交给回调;额外上下文由调用方闭包保存。
7
12
  def call(subject)
8
13
  callback&.call(subject)
@@ -28,7 +33,7 @@ class Expect
28
33
  end
29
34
  unless text.valid_encoding?
30
35
  # 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
31
- validate_incomplete_suffix!(text, final: final)
36
+ validate_incomplete_suffix!(text, final:)
32
37
  return nil
33
38
  end
34
39
  found = value.match(text)
@@ -58,4 +63,5 @@ class Expect
58
63
  raise EncodingError, "received invalid #{text.encoding} data; use a binary regexp (/.../n) for binary streams"
59
64
  end
60
65
  end
66
+ private_constant :Pattern
61
67
  end
@@ -4,6 +4,8 @@ class Expect
4
4
  # 将 Ruby 模式和块回调整理为有序会话组;注册阶段不读取 IO、不执行匹配回调。
5
5
  # 分组结构直接决定 Matcher 的“声明组 → 会话 → 模式”优先级,不能按匹配位置重排。
6
6
  class PatternList
7
+ # 供匹配器读取的已编译分组和超时规则。
8
+ # @api private
7
9
  attr_reader :groups, :timeout_pattern
8
10
 
9
11
  # 建立默认来源,并将位置参数中的文本、正则、:eof、:timeout 转成统一模式。
@@ -26,7 +28,7 @@ class Expect
26
28
  def on(value, from: @default_sessions, &block)
27
29
  raise ArgumentError, "pattern must be a String or Regexp" unless value.is_a?(String) || value.is_a?(Regexp)
28
30
 
29
- add(value.is_a?(String) ? value.b.freeze : value, from, block)
31
+ add(value, from, block)
30
32
  end
31
33
 
32
34
  # 为指定来源注册 EOF 回调;实例 DSL 默认使用当前会话。
@@ -37,6 +39,8 @@ class Expect
37
39
  # 注册一次等待的唯一超时回调,重复定义直接报错,避免悄悄覆盖业务处理。
38
40
  # 超时是整次等待的事件,回调收到全部活跃会话,不归属于某一个来源组。
39
41
  def timeout(&block)
42
+ raise FrozenError, "patterns are finalized" if frozen?
43
+
40
44
  raise ArgumentError, "timeout callback already registered" if @timeout_pattern
41
45
 
42
46
  @timeout_pattern = build(:timeout, block)
@@ -45,22 +49,42 @@ class Expect
45
49
 
46
50
  # 汇总并去重读取源,同一会话出现在多个模式组时仍只读取一次。
47
51
  # 这里只去重会话对象;不同会话包装同一 IO 时的读取归属由 Matcher 决定。
48
- def sessions = groups.flat_map(&:first).uniq
52
+ # @api private
53
+ def sessions
54
+ groups.flat_map(&:first).each_with_object({}.compare_by_identity) do |session, unique|
55
+ unique[session] = true
56
+ end.keys
57
+ end
49
58
 
50
59
  # 收集指定会话的所有 EOF 处理器,保留原注册顺序。
60
+ # @api private
51
61
  def eof_patterns_for(session)
52
62
  groups.flat_map do |sessions, patterns|
53
- sessions.include?(session) ? patterns.select(&:eof?) : []
63
+ sessions.any? { |candidate| candidate.equal?(session) } ? patterns.select(&:eof?) : []
54
64
  end
55
65
  end
56
66
 
57
67
  # 启动引擎前确保存在读取源;类级等待必须通过 from: 明确来源。
68
+ # @api private
58
69
  def validate!
59
70
  raise ArgumentError, "at least one session is required" if groups.empty?
60
71
 
61
72
  self
62
73
  end
63
74
 
75
+ # 固定本次等待的规则;只冻结声明容器,不冻结借用的会话与回调。
76
+ # @api private
77
+ def finalize!
78
+ validate!
79
+ groups.each do |sessions, patterns|
80
+ sessions.freeze
81
+ patterns.freeze
82
+ end
83
+ groups.each(&:freeze).freeze
84
+ @default_sessions.freeze
85
+ freeze
86
+ end
87
+
64
88
  private
65
89
 
66
90
  # 校验并复制来源列表,将模式加入相邻的相同来源组或新建组。
@@ -69,7 +93,7 @@ class Expect
69
93
  validate_sessions!(sessions)
70
94
  pattern = build(value, callback)
71
95
  # 仅合并相邻的相同来源,保持声明顺序;跨组复用会话由引擎去重读取。
72
- if groups.last&.first == sessions
96
+ if same_sessions?(groups.last&.first, sessions)
73
97
  groups.last.last << pattern
74
98
  else
75
99
  groups << [sessions, [pattern]]
@@ -77,10 +101,16 @@ class Expect
77
101
  self
78
102
  end
79
103
 
104
+ # 会话的业务相等性不能合并不同读取源;来源对象及排列都必须相同。
105
+ def same_sessions?(previous, sessions)
106
+ previous && previous.size == sessions.size &&
107
+ previous.each_with_index.all? { |session, index| session.equal?(sessions[index]) }
108
+ end
109
+
80
110
  # 按注册顺序分配从 1 开始的序号,文本模式与事件共用编号。
81
111
  def build(value, callback)
82
112
  @number += 1
83
- Pattern.new(number: @number, value: value, callback: callback)
113
+ Pattern.new(number: @number, value:, callback:)
84
114
  end
85
115
 
86
116
  # 拒绝空来源和非 Expect 对象,在任何 IO 读取之前暴露调用错误。