expect-pty 0.5.3 → 0.7.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 +55 -1
  3. data/README.md +122 -130
  4. data/docs/API.md +113 -0
  5. data/docs/MIGRATION.md +68 -0
  6. data/lib/expect/cleanup.rb +34 -0
  7. data/lib/expect/interaction.rb +228 -210
  8. data/lib/expect/logging.rb +117 -162
  9. data/lib/expect/matcher.rb +42 -38
  10. data/lib/expect/pattern.rb +10 -3
  11. data/lib/expect/pattern_list.rb +39 -9
  12. data/lib/expect/redactor.rb +12 -5
  13. data/lib/expect/relay.rb +106 -81
  14. data/lib/expect/relay_writer.rb +7 -5
  15. data/lib/expect/result.rb +33 -9
  16. data/lib/expect/session.rb +554 -0
  17. data/lib/expect/session_resources.rb +19 -18
  18. data/lib/expect/version.rb +2 -2
  19. data/lib/expect.rb +68 -578
  20. data/sig/expect.rbs +279 -0
  21. metadata +14 -123
  22. data/.rubocop.yml +0 -66
  23. data/CONTRIBUTING.md +0 -29
  24. data/Gemfile +0 -28
  25. data/Rakefile +0 -38
  26. data/benchmark/matching.rb +0 -98
  27. data/benchmark/redactor.rb +0 -64
  28. data/benchmark/relay.rb +0 -48
  29. data/benchmark/scaling.rb +0 -70
  30. data/benchmark/send_slow.rb +0 -41
  31. data/benchmark/support.rb +0 -114
  32. data/docs/COMPATIBILITY.md +0 -98
  33. data/docs/INTERNAL_CONTRACTS.md +0 -154
  34. data/docs/PERFORMANCE.md +0 -165
  35. data/docs/RELEASING.md +0 -84
  36. data/docs/VERIFICATION.md +0 -456
  37. data/examples/dialogue.rb +0 -30
  38. data/examples/kibitz/README.md +0 -81
  39. data/examples/kibitz/kibitz.rb +0 -142
  40. data/examples/kibitz/test_kibitz.rb +0 -37
  41. data/examples/ssh_auto.rb +0 -94
  42. data/examples/ssh_interact.rb +0 -159
  43. data/examples/ssh_login.rb +0 -64
  44. data/expect-pty.gemspec +0 -33
  45. data/lib/expect/configuration.rb +0 -122
  46. data/lib/expect/terminal.rb +0 -102
  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
@@ -1,200 +1,155 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "logger"
3
4
  require_relative "redactor"
4
5
 
5
- # 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
6
- # 接收日志、诊断和协议转发是三条独立通道;脱敏只改变前两条,不改变匹配或转发字节。
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"
6
+ # 借用标准 Logger、接收记录 writer 和协议输出 writer,不接管它们的生命周期。
7
+ # 脱敏只作用于诊断与接收记录,协议输出和匹配缓冲始终保留原始字节。
8
+ module Expect
9
+ # 会话诊断、接收记录和协议转发的独立输出接口。
10
+ # 宿主 Session 初始化目标与过滤器状态,并提供 pid、fileno 作为诊断元数据。
11
+ # 三种输出各有边界:Logger 接收事件,transcript 接收脱敏字节,outputs 接收原协议字节。
12
+ module Logging
13
+ # 调用方提供的诊断 Logger 与原始接收记录 writer,均由调用方管理生命周期。
14
+ attr_reader :logger, :transcript
15
+
16
+ # Logger 自己决定级别、格式与失败策略;nil 禁用诊断,不隐式写入 stderr。
17
+ # 替换前先完成旧流脱敏,失败时仍保留旧 logger。
18
+ def logger=(target)
19
+ unless target.nil? || (target.respond_to?(:add) && target.respond_to?(:debug?))
20
+ raise ArgumentError, "logger must support add and debug?, or be nil"
21
+ end
22
+ return if @logger.equal?(target)
23
+
24
+ flush_diagnostics
25
+ @logger = target
26
+ @diagnostic_redactors = {}
17
27
  end
18
- return if @diagnostic_output.equal?(target)
19
28
 
20
- flush_diagnostics
21
- @diagnostic_output = target
22
- end
29
+ # 秘密仅作用于本会话的接收记录和诊断,不改写匹配或 outputs 的协议字节。
30
+ # 先校验全部值再发布;注册是追加操作,应在首次通信前完成,不能追溯已交付的日志。
31
+ def redact(*secrets)
32
+ unless secrets.any? && secrets.all? { |secret| secret.is_a?(String) && !secret.empty? }
33
+ raise ArgumentError, "secrets must be nonempty Strings"
34
+ end
23
35
 
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"
36
+ @secrets = ((@secrets || []) + secrets.map { |secret| secret.b.freeze }).uniq.freeze
37
+ @transcript_redactor.patterns = @secrets if @transcript_redactor
38
+ @diagnostic_redactors.each_value { |redactor| redactor.patterns = @secrets }
39
+ self
29
40
  end
30
41
 
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
42
+ # 接收记录只采用 write 协议;文件打开、权限和关闭由调用方管理。
43
+ def transcript=(target)
44
+ raise ArgumentError, "transcript must support write, or be nil" unless target.nil? || target.respond_to?(:write)
45
+ return if @transcript.equal?(target)
36
46
 
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"
47
+ flush_transcript
48
+ @transcript = target
49
+ @transcript_redactor = nil
41
50
  end
42
51
 
43
- replace_log(target)
44
- end
52
+ # 向接收记录补写内容,不发送给子进程或 outputs;脱敏尾部可能延迟交付,返回 nil。
53
+ def write_transcript(*objects)
54
+ target = transcript
55
+ return unless target
45
56
 
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
57
+ data = objects.map { |object| object.to_s.b }.join
58
+ if @secrets
59
+ @transcript_redactor ||= Redactor.new(@secrets)
60
+ data = @transcript_redactor.append(data)
61
+ return if data.empty?
62
+ end
63
+ emit(target, data)
64
+ nil
65
+ end
49
66
 
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)
67
+ # 返回副本,避免外部原地修改转发关系;stdout 与其他 writer 使用相同协议。
68
+ def outputs = @outputs.dup
53
69
 
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
70
+ # 先校验整个数组再替换,失败时保留原输出图。
71
+ def outputs=(targets)
72
+ unless targets.is_a?(Array) && targets.all? { |target| target.respond_to?(:write) }
73
+ raise ArgumentError, "outputs must be an Array of writers"
74
+ end
60
75
 
61
- self.log_output = target
76
+ @outputs = targets.dup
62
77
  end
63
- end
64
78
 
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?
79
+ # 原样转发协议字节,接收记录在读取时单独写入。
80
+ # @api private
81
+ def propagate(data)
82
+ @outputs.each { |output| emit(output, data) }
76
83
  end
77
- target.respond_to?(:call) ? target.call(data) : emit(target, data)
78
- end
79
-
80
- # 返回监听器列表副本,避免外部原地修改转发关系。
81
- def listeners = @listeners.dup
82
-
83
- # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
84
- def listeners=(outputs)
85
- outputs = Array(outputs)
86
- raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
87
-
88
- @listeners = outputs.dup
89
- end
90
84
 
91
- private
85
+ private
92
86
 
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
87
+ # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
88
+ # 这里遵循同步写入协议,不受 Matcher 的 IO 等待期限中断;慢目标转接应使用 Relay。
89
+ # 短写只推进已确认的字节数;目标抛错时不猜测它是否已经产生副作用。
90
+ def emit(target, data)
91
+ offset = 0
92
+ while offset < data.bytesize
93
+ count = target.write(data.byteslice(offset..))
94
+ unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
95
+ raise IOError, "write must return the number of accepted bytes"
96
+ end
98
97
 
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"
98
+ offset += count
108
99
  end
109
-
110
- offset += count
100
+ target.flush if target.respond_to?(:flush)
111
101
  end
112
- target.flush if target.respond_to?(:flush)
113
- end
114
102
 
115
- # 字节诊断在 inspect 转义之前过滤;缓冲快照可能只含秘密中间片段,启用脱敏时不展开。
116
- def trace_data(event, data, level:)
117
- if @secrets
118
- return trace("buffer [FILTERED]", level: level, event: event) if event == :buffer
103
+ # 字节诊断在 inspect 转义之前过滤,发送与接收各自保留分片状态。
104
+ def trace_data(event, data)
105
+ return unless logger&.debug?
119
106
 
120
- @diagnostic_redactors ||= {}
121
- redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
122
- data = redactor.append(data)
123
- return if data.empty?
107
+ if @secrets
108
+ redactor = (@diagnostic_redactors[event] ||= Redactor.new(@secrets))
109
+ data = redactor.append(data)
110
+ return if data.empty?
111
+ end
112
+ trace("#{event} #{data.inspect}", severity: Logger::DEBUG, event:)
124
113
  end
125
- trace("#{event} #{data.inspect}", level: level, event: event)
126
- end
127
114
 
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}")
115
+ # 向标准 Logger 提交不可变事件,不包含会话对象;格式化由 logger 完成。
116
+ def trace(message, severity: Logger::INFO, event: :matched)
117
+ logger&.add(severity, { event:, pid:, fd: fileno, message: message.freeze }.freeze, "Expect")
142
118
  end
143
- end
144
119
 
145
- # 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
146
- def flush_log
147
- loop do
148
- return unless @log_redactor && log_output
120
+ # 结束当前日志片段并交付过滤器尾部;重复调用不会重放已释放字节,写入异常原样传播。
121
+ def flush_transcript
122
+ loop do
123
+ return unless @transcript_redactor && transcript
149
124
 
150
- data = @log_redactor.finish
151
- return if data.empty?
125
+ data = @transcript_redactor.finish
126
+ return if data.empty?
152
127
 
153
- log_output.respond_to?(:call) ? log_output.call(data) : emit(log_output, data)
154
- # 回调可能轮换目标并追加新尾部;先结束它,再允许外层截断文件或关闭日志。
128
+ emit(transcript, data)
129
+ # writer 可能重入并追加尾部;交接前仍归当前记录流。
130
+ end
155
131
  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
132
 
169
- data = redactor.finish
170
- next if data.empty?
171
-
172
- delivered = true
173
- trace("#{name} #{data.inspect}", level: 2, event: name)
133
+ # EOF 只结束接收诊断;目标替换或显式关闭结束全部方向,发送与接收不能拼成一个秘密。
134
+ # GC 终结器不进入本方法,避免在回收阶段调用用户回调或持有会话对象。
135
+ def flush_diagnostics(event = nil)
136
+ return unless @diagnostic_redactors
137
+
138
+ loop do
139
+ delivered = false
140
+ # 回调可能首次创建另一个方向,也可能写入本轮已经处理的方向。
141
+ @diagnostic_redactors.to_a.each do |name, redactor|
142
+ next if event && name != event
143
+
144
+ data = redactor.finish
145
+ next if data.empty?
146
+
147
+ delivered = true
148
+ trace("#{name} #{data.inspect}", severity: Logger::DEBUG, event: name)
149
+ end
150
+ # 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
151
+ break unless delivered
174
152
  end
175
- # 目标尚未交接,新产生的尾部仍归旧诊断流;排空后才结束这次冲刷。
176
- break unless delivered
177
153
  end
178
154
  end
179
-
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
199
- end
200
155
  end
@@ -1,36 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module Expect
4
4
  # 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
5
5
  # 调度只在扫描、回调和 IO 操作之间检查期限,不强行中断用户代码或单次正则计算。
6
+ # @api private
6
7
  class Matcher
7
8
  # 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
8
- def initialize(patterns, timeout, deadline: nil)
9
- @patterns = patterns
9
+ def initialize(patterns, timeout, deadline: nil, consume: true, reset_timeout_on_read: false)
10
+ @patterns = patterns.finalize!
10
11
  @sessions = patterns.sessions
12
+ @groups = patterns.groups
13
+ @consume = consume
14
+ @reset_timeout_on_read = reset_timeout_on_read
11
15
  @timeout = Expect.duration(timeout)
12
16
  # 相对期限可因接收或 continue 重算,总期限始终固定;两者共用单调时钟。
13
17
  @hard_deadline = deadline
14
18
  @deadline = next_deadline
15
- @handled_eof = []
16
- @stalled_matches = {}
19
+ @handled_eof = {}.compare_by_identity
20
+ @stalled_matches = {}.compare_by_identity
17
21
  @polled = false
18
22
  @expired_eof_continuation = false
19
23
  end
20
24
 
21
25
  # 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
22
26
  def run
23
- @relay_buffers = {}
27
+ @relay_buffers = {}.compare_by_identity
24
28
  @sessions.each do |session|
25
- buffer = session.__send__(:interaction_buffer)
29
+ buffer = session.interaction_buffer
26
30
  if buffer
27
31
  # 转义回调中的显式匹配临时接管读取,先消费转接已经预读的尾部。
28
32
  @relay_buffers[session] = buffer
29
- session.__send__(:interaction_buffer=, nil)
30
- session.__send__(:restore_relay_buffer, buffer)
33
+ session.interaction_buffer = nil
34
+ session.restore_relay_buffer(buffer)
31
35
  buffer.clear
32
36
  end
33
- session.__send__(:reset_result)
37
+ session.reset_result
34
38
  end
35
39
  loop do
36
40
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
@@ -49,7 +53,7 @@ class Expect
49
53
  # 嵌套 expect 即使异常退出,也要把未消费尾部还给原 Relay 的同一个缓冲对象。
50
54
  @relay_buffers.each do |session, buffer|
51
55
  buffer.replace(session.clear_buffer)
52
- session.__send__(:interaction_buffer=, buffer)
56
+ session.interaction_buffer = buffer
53
57
  end
54
58
  end
55
59
 
@@ -59,13 +63,13 @@ class Expect
59
63
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
60
64
  def find_match
61
65
  # 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
62
- groups = @patterns.groups
66
+ groups = @groups
63
67
  if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
64
68
  snapshots = {}.compare_by_identity
65
69
  end
66
70
  groups.each do |sessions, patterns|
67
71
  sessions.each do |session|
68
- next if @handled_eof.include?(session)
72
+ next if @handled_eof.key?(session)
69
73
 
70
74
  buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
71
75
  stalled = @stalled_matches[session]
@@ -99,23 +103,22 @@ class Expect
99
103
  @literal_misses ||= {}.compare_by_identity
100
104
  misses = (@literal_misses[session] ||= {}.compare_by_identity)
101
105
  previous = misses[pattern]
102
- generation = session.__send__(:buffer_generation)
103
- # 缓冲代次未变意味着只有尾部追加;模式对象的 value 被替换时也不能沿用旧扫描位置。
104
- offset = if previous && previous[0] == generation && previous[2].equal?(pattern.value)
106
+ generation = session.buffer_generation
107
+ # 模式已冻结;缓冲代次未变意味着只有尾部追加。
108
+ offset = if previous && previous[0] == generation
105
109
  return nil if previous[1] == buffer.bytesize
106
110
 
107
111
  [previous[1] - pattern.value.bytesize + 1, 0].max
108
112
  else
109
113
  0
110
114
  end
111
- position = pattern.locate(buffer, offset: offset)
115
+ position = pattern.locate(buffer, offset:)
112
116
  if position
113
117
  misses.delete(pattern)
114
118
  else
115
119
  entry = (misses[pattern] ||= [])
116
120
  entry[0] = generation
117
121
  entry[1] = buffer.bytesize
118
- entry[2] = pattern.value
119
122
  end
120
123
  position
121
124
  end
@@ -123,7 +126,7 @@ class Expect
123
126
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
124
127
  def handle_match(session, pattern, position)
125
128
  previous_buffer = session.buffer
126
- result = session.__send__(:record_match, pattern, position)
129
+ result = session.record_match(pattern, position, consume: @consume)
127
130
  action = pattern.call(session)
128
131
  return result unless continuing?(action)
129
132
 
@@ -132,30 +135,31 @@ class Expect
132
135
  stalled = (@stalled_matches[session] ||= { buffer: previous_buffer, patterns: [] })
133
136
  stalled[:patterns] << pattern
134
137
  end
135
- @deadline = next_deadline if action == CONTINUE
138
+ @deadline = next_deadline if CONTINUE.equal?(action)
136
139
  # 总期限到达时回主循环先派发已知 EOF;仅相对期限延续原有立即超时语义。
137
- return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired? && !hard_expired?
140
+ return handle_timeout if CONTINUE_WITHOUT_RESET.equal?(action) && expired? && !hard_expired?
138
141
 
139
142
  :retry
140
143
  end
141
144
 
142
145
  # 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
143
146
  def unhandled_eof
144
- @sessions.find { |session| session.eof? && !@handled_eof.include?(session) }
147
+ @sessions.find { |session| session.eof? && !@handled_eof.key?(session) }
145
148
  end
146
149
 
147
150
  # 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
148
151
  def handle_eof(session)
149
- result = session.__send__(:record_eof)
150
- @handled_eof << session
152
+ result = session.record_eof
153
+ @handled_eof[session] = true
151
154
  actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
152
155
  return result unless actions.any? { |action| continuing?(action) }
153
156
 
154
- @deadline = next_deadline if actions.include?(CONTINUE)
155
- return result if @sessions.all? { |candidate| @handled_eof.include?(candidate) }
157
+ reset_timeout = actions.any? { |action| CONTINUE.equal?(action) }
158
+ @deadline = next_deadline if reset_timeout
159
+ return result if @handled_eof.size == @sessions.size
156
160
 
157
161
  # 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
158
- @expired_eof_continuation = !actions.include?(CONTINUE) && expired?
162
+ @expired_eof_continuation = !reset_timeout && expired?
159
163
 
160
164
  :retry
161
165
  end
@@ -192,23 +196,23 @@ class Expect
192
196
  session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
193
197
  begin
194
198
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
195
- data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
199
+ data = session.read_available(propagate: !@relay_buffers.key?(session))
196
200
  rescue Errno::EINTR
197
201
  next
198
202
  rescue IOError, SystemCallError => error
199
- return session.__send__(:record_error, error)
203
+ return session.record_error(error)
200
204
  end
201
205
  @stalled_matches.delete(session) if data
202
- @deadline = next_deadline if data && session.reset_timeout_on_read?
206
+ @deadline = next_deadline if data && @reset_timeout_on_read
203
207
  end
204
208
  :retry
205
209
  end
206
210
 
207
211
  # 返回本次仍需监听的会话,供读取选择和超时回调使用。
208
- def active_sessions = @sessions.reject { |session| @handled_eof.include?(session) }
212
+ def active_sessions = @sessions.reject { |session| @handled_eof.key?(session) }
209
213
 
210
214
  # 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
211
- def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
215
+ def continuing?(action) = CONTINUE.equal?(action) || CONTINUE_WITHOUT_RESET.equal?(action)
212
216
 
213
217
  # 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
214
218
  # 每次重置都重新与总期限取较早者,避免连续输入或继续回调无限推迟结束。
@@ -224,22 +228,22 @@ class Expect
224
228
  def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
225
229
 
226
230
  # 判断有限期限是否已到达;无限等待不会触发超时。
227
- def expired? = @deadline && Expect.monotonic >= @deadline
231
+ def expired? = !@deadline.nil? && Expect.monotonic >= @deadline
228
232
 
229
233
  # 绝对总期限不受接收数据和 continue 重置;已知 EOF 仍按原顺序派发。
230
- def hard_expired? = @hard_deadline && Expect.monotonic >= @hard_deadline
234
+ def hard_expired? = !@hard_deadline.nil? && Expect.monotonic >= @hard_deadline
231
235
 
232
- # select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
236
+ # select 失败时无法归属单个源,只更新仍监听的会话;已派发 EOF 的结果保持不变。
233
237
  def record_error(error)
234
- @sessions.map { |session| session.__send__(:record_error, error) }.first
238
+ active_sessions.map { |session| session.record_error(error) }.first
235
239
  end
236
240
 
237
241
  # 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
238
242
  # 已到总期限仍通知超时回调,但不接受继续请求,且不消费尚未匹配的字节。
239
243
  def handle_timeout
240
- results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
244
+ results = active_sessions.map { |session| session.record_error(:timeout) }
241
245
  action = @patterns.timeout_pattern&.call(active_sessions)
242
- return results.first unless action == CONTINUE && !hard_expired?
246
+ return results.first unless CONTINUE.equal?(action) && !hard_expired?
243
247
 
244
248
  @deadline = next_deadline
245
249
  @polled = false
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module 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
+ # 复制字符串模式;Regexp 和 callable 只引用,不取得外部对象的生命周期。
8
+ def initialize(value:, number: nil, callback: nil)
9
+ super(value: value.is_a?(String) ? value.b.freeze : value, number:, callback:)
10
+ end
11
+
6
12
  # 将匹配会话或超时会话数组交给回调;额外上下文由调用方闭包保存。
7
13
  def call(subject)
8
14
  callback&.call(subject)
@@ -28,7 +34,7 @@ class Expect
28
34
  end
29
35
  unless text.valid_encoding?
30
36
  # 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
31
- validate_incomplete_suffix!(text, final: final)
37
+ validate_incomplete_suffix!(text, final:)
32
38
  return nil
33
39
  end
34
40
  found = value.match(text)
@@ -58,4 +64,5 @@ class Expect
58
64
  raise EncodingError, "received invalid #{text.encoding} data; use a binary regexp (/.../n) for binary streams"
59
65
  end
60
66
  end
67
+ private_constant :Pattern
61
68
  end