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,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module 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,17 +101,23 @@ class Expect
77
101
  self
78
102
  end
79
103
 
104
+ # 会话的业务相等性不能合并不同读取源;来源对象及排列都必须相同。
105
+ def same_sessions?(previous, sessions)
106
+ !previous.nil? && 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
- # 拒绝空来源和非 Expect 对象,在任何 IO 读取之前暴露调用错误。
116
+ # 拒绝空来源和非 Session 对象,在任何 IO 读取之前暴露调用错误。
87
117
  def validate_sessions!(sessions)
88
- return if !sessions.empty? && sessions.all?(Expect)
118
+ return if !sessions.empty? && sessions.all?(Session)
89
119
 
90
- raise ArgumentError, "patterns require Expect sessions; specify from: for class-level waits"
120
+ raise ArgumentError, "patterns require Session objects; specify from: for class-level waits"
91
121
  end
92
122
  end
93
123
  end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module Expect
4
4
  # 单个日志字节流的过滤器。保留最长秘密长度减一的尾部,跨 write/read 分片仍可识别。
5
5
  # 掩码与原字节一起留存;重叠命中的区间取并集,已经输出的掩码不重复生成。
6
6
  # 不依赖会话或 IO;每个日志目标或诊断方向使用独立实例。
7
7
  class Redactor
8
8
  # 完整诊断文本只匹配完整秘密;流边界的疑似秘密前缀由 finish 的默认策略保护。
9
9
  def self.redact(data, patterns, replacement: "[FILTERED]")
10
- filter = new(patterns, replacement: replacement)
10
+ filter = new(patterns, replacement:)
11
11
  filter.append(data) + filter.finish(partial: false)
12
12
  end
13
13
 
@@ -62,11 +62,18 @@ class Expect
62
62
 
63
63
  # 流关闭时无法再等待后续字节,默认隐藏与秘密开头一致的未完成尾部。
64
64
  def mark_partial_secrets
65
+ tail = @pending.byteslice(-1, 1)
66
+ return unless tail
67
+
65
68
  @patterns.each do |pattern|
66
- [pattern.bytesize - 1, @pending.bytesize].min.downto(1) do |length|
67
- next unless @pending.end_with?(pattern.byteslice(0, length))
69
+ length = [pattern.bytesize - 1, @pending.bytesize].min
70
+ # 前缀必须以当前尾字节结束;只收紧最高候选长度,密集候选仍沿用简单倒序扫描。
71
+ next unless length.positive? && (offset = pattern.rindex(tail, length - 1))
72
+
73
+ (offset + 1).downto(1) do |candidate|
74
+ next unless @pending.end_with?(pattern.byteslice(0, candidate))
68
75
 
69
- @hidden[-length, length] = "\1" * length
76
+ @hidden[-candidate, candidate] = "\1" * candidate
70
77
  break
71
78
  end
72
79
  end
data/lib/expect/relay.rb CHANGED
@@ -1,170 +1,195 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module Expect
4
4
  # 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
5
5
  # 本对象只暂借未处理输入;读缓冲、发送进度与转义回调分开保存,避免重入时重复交付。
6
+ # @api private
6
7
  class Relay
8
+ # 只串行化来源归属交接;Session 的其他操作仍须由调用方避免并发修改。
7
9
  OWNERSHIP_MUTEX = Mutex.new
8
10
  private_constant :OWNERSHIP_MUTEX
9
11
 
10
12
  # 这里只校验参数;run 取得所有来源后才转移缓冲,不关闭或接管任何外部 IO。
11
13
  def initialize(sessions, timeout)
12
- @sessions = sessions.uniq
14
+ @sessions = sessions.uniq(&:object_id)
13
15
  @active = @sessions.dup
14
16
  period = Expect.duration(timeout)
15
17
  @deadline = period && (Expect.monotonic + period)
16
18
  @token = Object.new
17
- @buffers = {}
18
- @previous = {}
19
+ @buffers = {}.compare_by_identity
20
+ @previous = {}.compare_by_identity
19
21
  end
20
22
 
21
23
  # 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
22
24
  # 真实 IO 的写入由非阻塞游标推进,用户日志和回调仍同步执行,须由调用方保证及时返回。
23
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
24
25
  def run
25
26
  prepare_sources
26
- outputs.each(&:restart_timeout)
27
+ pending_writes.each(&:restart_timeout)
27
28
  polled = false
28
29
  loop do
29
- idle = []
30
- queued = false
31
- @active.dup.each do |session|
32
- next if session.pending_output?
33
-
34
- if (callback = session.__send__(:relay_callback))
35
- session.__send__(:relay_callback=, nil)
36
- return session unless callback.first&.call
37
- end
30
+ stopped, idle, queued = dispatch_sources
31
+ return stopped if stopped
38
32
 
39
- result = Expect.__send__(:relay_buffer, session, @buffers, final: session.eof?) do |data|
40
- session.__send__(:queue_output, data)
41
- end
42
- return session unless result
33
+ return nil if @active.empty?
43
34
 
44
- if result == :pending
45
- queued = true
46
- next
47
- end
35
+ return finish_timeout(idle) if polled && expired?
36
+
37
+ progress = advance_outputs || queued
38
+
39
+ polled = true
40
+ return finish_timeout(idle) if select_and_read(progress) == :timeout
41
+ end
42
+ ensure
43
+ restore_sources
44
+ end
48
45
 
49
- idle << session
50
- next unless session.eof?
46
+ private
51
47
 
52
- callback = session.__send__(:sequences)[:eof]
53
- return session unless callback&.call
48
+ # 先交付转义前缀再派发回调;EOF 只在待发送数据处理完后生效。
49
+ def dispatch_sources
50
+ idle = []
51
+ queued = false
52
+ @active.dup.each do |session|
53
+ next if session.pending_output?
54
54
 
55
- @active.delete(session)
55
+ if (callback = session.relay_callback)
56
+ session.relay_callback = nil
57
+ return [session, idle, queued] unless callback.call
56
58
  end
57
- return nil if @active.empty?
58
59
 
59
- return finish_timeout(idle) if polled && expired?
60
+ result = Interaction.queue_input(session, @buffers.fetch(session), final: session.eof?)
61
+ return [session, idle, queued] if result == :stopped
60
62
 
61
- progress = advance_outputs || queued
63
+ if result == :queued
64
+ queued = true
65
+ next
66
+ end
67
+
68
+ idle << session
69
+ next unless session.eof?
70
+
71
+ callback = session.sequences[:eof]
72
+ return [session, idle, queued] unless callback&.call
73
+
74
+ @active.delete_if { |active| active.equal?(session) }
75
+ end
76
+ [nil, idle, queued]
77
+ end
78
+
79
+ # 共同等待来源与目标,每轮读每个就绪来源一次;EINTR 返回原期限循环。
80
+ def select_and_read(progress)
81
+ pending = pending_writes
82
+ readers = read_sources(pending)
83
+ deadlines = [@deadline, *pending.map(&:deadline)].compact
84
+ remaining = deadlines.empty? ? nil : [deadlines.min - Expect.monotonic, 0].max
85
+ # 有进展时轮询输入再继续发送,避免大块输出饿死其他来源。
86
+ remaining = 0 if progress
87
+ begin
88
+ ready = IO.select(readers.map(&:to_io), pending.filter_map(&:io).uniq(&:object_id), nil, remaining)
89
+ if ready
90
+ read_ready(readers, ready.first)
91
+ elsif !progress
92
+ return :timeout if expired?
93
+
94
+ pending.each(&:check_timeout!)
95
+ end
96
+ rescue Errno::EINTR
97
+ nil
98
+ end
99
+ nil
100
+ end
101
+
102
+ # 同源转接期间读取到借用缓冲;目标会话的背压读取沿用自身状态。
103
+ def read_ready(readers, readable)
104
+ return if readable.empty?
105
+
106
+ if readable.size > 1
107
+ ready = {}.compare_by_identity
108
+ readable.each { |io| ready[io] = true }
109
+ end
110
+ readers.each do |session|
111
+ next unless ready ? ready.key?(session.to_io) : readable.first.equal?(session.to_io)
62
112
 
63
- pending = outputs
64
- readers = read_sources(pending)
65
- deadlines = [@deadline, *pending.map(&:deadline)].compact
66
- remaining = deadlines.empty? ? nil : [deadlines.min - Expect.monotonic, 0].max
67
- # 有进展时轮询输入再继续发送,避免大块输出饿死其他来源。
68
- remaining = 0 if progress
69
113
  begin
70
- polled = true
71
- ready = IO.select(readers.map(&:to_io), pending.filter_map(&:io).uniq, nil, remaining)
72
- if ready
73
- readers.each do |session|
74
- next unless ready[0].include?(session.to_io)
75
-
76
- begin
77
- if @buffers.key?(session)
78
- session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session), trim: false)
79
- else
80
- session.__send__(:read_available, propagate: false)
81
- end
82
- rescue Errno::EINTR
83
- next
84
- end
85
- end
86
- elsif !progress
87
- return finish_timeout(idle) if expired?
88
-
89
- pending.each(&:check_timeout!)
114
+ if @buffers.key?(session)
115
+ session.read_available(propagate: false, buffer: @buffers.fetch(session), trim: false)
116
+ else
117
+ session.read_available(propagate: false)
90
118
  end
91
119
  rescue Errno::EINTR
92
120
  next
93
121
  end
94
122
  end
95
- ensure
96
- restore_sources
97
123
  end
98
124
 
99
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
100
-
101
- private
102
-
103
125
  # 锁仅保护标记检查/登记,不跨 IO 或用户回调;任一来源被占用时整组拒绝,不动缓冲和期限。
104
126
  def prepare_sources
105
127
  OWNERSHIP_MUTEX.synchronize do
106
- if @sessions.any? { |session| session.__send__(:relay_owner) }
107
- raise ReentrancyError, "source session already has an active relay"
108
- end
128
+ raise ReentrancyError, "source session already has an active relay" if @sessions.any?(&:relay_owner)
109
129
 
110
- @sessions.each { |session| session.__send__(:relay_owner=, @token) }
130
+ @sessions.each { |session| session.relay_owner = @token }
111
131
  end
112
132
  @sessions.each do |session|
113
- @previous[session] = session.__send__(:interaction_buffer)
133
+ @previous[session] = session.interaction_buffer
114
134
  @buffers[session] = session.clear_buffer
115
- session.__send__(:interaction_buffer=, @buffers.fetch(session))
135
+ session.interaction_buffer = @buffers.fetch(session)
116
136
  end
117
137
  end
118
138
 
119
139
  # 未处理输入还给会话,已排队字节仍归游标;构造中断也只恢复已转移的缓冲及自己的标记。
120
140
  def restore_sources
121
- @previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
122
- @buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
141
+ @previous.each { |session, buffer| session.interaction_buffer = buffer }
142
+ @buffers.each { |session, buffer| session.restore_relay_buffer(buffer) }
123
143
  ensure
124
144
  OWNERSHIP_MUTEX.synchronize do
125
145
  @sessions.each do |session|
126
- session.__send__(:relay_owner=, nil) if session.__send__(:relay_owner).equal?(@token)
146
+ session.relay_owner = nil if session.relay_owner.equal?(@token)
127
147
  end
128
148
  end
129
149
  end
130
150
 
131
151
  # 只收集尚未完成的目标,供共同 select 以及最早写入期限计算使用。
132
- def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
152
+ def pending_writes = @sessions.flat_map(&:pending_writes).reject(&:done?)
133
153
 
134
154
  # 每个目标每轮最多推进一个片段;移除已完成游标,并告知主循环是否值得立即继续轮询。
135
155
  def advance_outputs(check_timeout: true)
136
156
  progress = false
137
157
  @sessions.each do |session|
138
- pending = session.__send__(:relay_outputs)
139
- pending.each { |output| progress = output.advance(check_timeout: check_timeout) || progress }
158
+ pending = session.pending_writes
159
+ pending.each { |output| progress = output.advance(check_timeout:) || progress }
140
160
  pending.reject!(&:done?)
141
161
  end
142
162
  progress
143
163
  end
144
164
 
145
165
  # 转接总期限不因持续输入或某个目标的写入进展而重置。
146
- def expired? = @deadline && Expect.monotonic >= @deadline
166
+ def expired? = !@deadline.nil? && Expect.monotonic >= @deadline
147
167
 
148
168
  # 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
149
169
  def read_sources(pending)
150
170
  # 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
151
- targets = pending.map(&:target).grep(Expect)
152
- sources = @active.reject { |session| session.pending_output? && !targets.include?(session) }
153
- (sources + targets).uniq(&:to_io).reject(&:eof?)
171
+ targets = pending.filter_map do |output|
172
+ output.target if output.target.is_a?(Session)
173
+ end
174
+ sources = @active.reject do |session|
175
+ session.pending_output? && targets.none? { |target| target.equal?(session) }
176
+ end
177
+ (sources + targets).uniq { |session| session.to_io.object_id }.reject(&:eof?)
154
178
  end
155
179
 
156
180
  # 总期限结束时将可交付尾部转为发送游标,至多尝试一轮交付;余量由源会话保存。
157
181
  # 目标的写期限更早到达时保留 WriteTimeout 语义,不能被转接的普通超时掩盖。
158
182
  def finish_timeout(idle)
159
183
  # 调度延迟可能让两种期限均已到达,仍按先到的期限决定结果。
160
- outputs.each { |output| output.check_timeout! if output.deadline && output.deadline < @deadline }
184
+ pending_writes.each { |output| output.check_timeout! if output.deadline && output.deadline < @deadline }
161
185
  idle.each do |session|
162
186
  next if session.pending_output?
163
187
 
164
188
  buffer = @buffers.fetch(session)
165
189
  next if buffer.empty?
166
190
 
167
- session.__send__(:queue_output, buffer.dup)
191
+ session.queue_output(buffer.dup)
192
+ Interaction.remember_output(session, buffer)
168
193
  buffer.clear
169
194
  end
170
195
  # 到期后只尝试一次非阻塞写入,剩余游标留给下一次 interconnect。
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
3
+ module Expect
4
4
  # 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
5
5
  # 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
6
+ # @api private
6
7
  class RelayWriter
8
+ # target 始终借用;deadline 是本轮背压预算,不表示整块数据已交付。
7
9
  attr_reader :target, :deadline
8
10
 
9
11
  # data 在本游标存活期间由上层保持不变,offset 始终以实际交付的字节数计量。
@@ -18,13 +20,13 @@ class Expect
18
20
  # 初次排队或重新进入 interconnect 时开始新的写入预算;普通短写不会刷新期限。
19
21
  # 只有 Expect 目标提供 write_timeout,原生 IO 和自定义目标只受转接总期限约束。
20
22
  def restart_timeout
21
- period = target.write_timeout if target.is_a?(Expect)
23
+ period = target.write_timeout if target.is_a?(Session)
22
24
  @deadline = period && (Expect.monotonic + period)
23
25
  end
24
26
 
25
27
  # 真实 IO 可参与共同 select;自定义可写对象返回 nil,沿用其同步 write/flush 协议。
26
28
  def io
27
- return target.writer if target.is_a?(Expect)
29
+ return target.writer if target.is_a?(Session)
28
30
 
29
31
  target if target.is_a?(IO)
30
32
  end
@@ -36,12 +38,12 @@ class Expect
36
38
  # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
37
39
  def advance(check_timeout: true)
38
40
  return false if done?
39
- raise IOError, "closed Expect session" if target.is_a?(Expect) && target.closed?
41
+ raise IOError, "closed Expect session" if target.is_a?(Session) && target.closed?
40
42
 
41
43
  if @offset < @data.bytesize
42
44
  bytes = @data.byteslice(@offset, READ_SIZE)
43
45
  count = io ? io.write_nonblock(bytes, exception: false) : target.write(bytes)
44
- if count == :wait_writable
46
+ if io && count == :wait_writable
45
47
  check_timeout! if check_timeout
46
48
  return false
47
49
  end
data/lib/expect/result.rb CHANGED
@@ -1,18 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Expect
4
- # 保存一次等待的结果,保留 Struct 原生数组、Hash 转换和模式解构能力。
5
- # number 仅在文本匹配时存在;error 为 :timeout、:eof 或原始 IO 异常,文本字段均为字节串。
6
- # before/match/after 是产生结果时的快照,后续读取不追加到旧结果;captures 保留未参与捕获的 nil。
7
- # 结果对象仍采用普通可变 Struct 语义;session 只引用来源,不取得 IO 或子进程所有权。
8
- Result = Struct.new(:number, :error, :match, :before, :after, :session, :captures, keyword_init: true) do
9
- # 是否命中文本模式;事件不会返回模式序号。
3
+ module Expect
4
+ # 一次等待的不可变快照;session 与 error 只引用来源和原始异常,不取得其所有权。
5
+ # 文本和捕获值单独复制冻结,后续读取或调用者修改输入不会改变结果。
6
+ # @!method self.new(**fields)
7
+ # 按字段关键字或成员顺序的位置参数构造不可变结果。
8
+ # @!method self.[](**fields)
9
+ # 使用与 new 相同的参数构造不可变结果。
10
+ # @!method self.members
11
+ # 返回按声明顺序排列的字段名。
12
+ # @!method self.inspect
13
+ # 返回结果类的说明。
14
+ # @!method members
15
+ # 返回按声明顺序排列的字段名。
16
+ # @!method with(**fields)
17
+ # 复制结果并替换指定字段,新文本仍独立冻结。
18
+ # @!method to_h
19
+ # 返回字段 Hash;有块时按 Ruby Data 协议转换键值对。
20
+ # @!method deconstruct
21
+ # 按成员声明顺序返回字段数组。
22
+ # @!method deconstruct_keys(keys)
23
+ # 按指定键解构结果,nil 表示全部字段。
24
+ Result = Data.define(:number, :error, :match, :before, :after, :session, :captures) do
25
+ # 构造独立不可变快照,复制字符串和捕获数组。
26
+ # @param captures [Array<String, nil>] 按声明顺序保存捕获组,未参与的组保留 nil。
27
+ def initialize(number: nil, error: nil, match: nil, before: nil, after: nil, session: nil, captures: [])
28
+ super(number:, error:, session:, match: match&.dup&.freeze,
29
+ before: before&.dup&.freeze, after: after&.dup&.freeze,
30
+ captures: captures.map { |value| value&.dup&.freeze }.freeze)
31
+ end
32
+
33
+ # 是否命中文本;EOF 和超时没有模式序号。
10
34
  def matched? = !number.nil?
11
35
 
12
- # 是否因本次等待期限到达而返回。
36
+ # 是否因等待期限到达而返回。
13
37
  def timeout? = error == :timeout
14
38
 
15
- # 是否读取源已经结束;子进程是否退出仍应查询会话的 process_status。
39
+ # 是否读取源已经结束,子进程状态由会话单独查询。
16
40
  def eof? = error == :eof
17
41
  end
18
42
  end