expect-pty 0.5.2 → 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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +37 -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 -207
  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 +122 -68
  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 +49 -16
  19. data/lib/expect/terminal.rb +78 -78
  20. data/lib/expect/version.rb +1 -1
  21. data/lib/expect.rb +336 -486
  22. data/sig/expect.rbs +355 -0
  23. metadata +12 -100
  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 -133
  36. data/docs/PERFORMANCE.md +0 -165
  37. data/docs/RELEASING.md +0 -84
  38. data/docs/VERIFICATION.md +0 -358
  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 -311
  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/integration/README.md +0 -109
  62. data/test/integration/ssh_scripts.rb +0 -94
  63. data/test/interact_test.rb +0 -253
  64. data/test/interconnect_test.rb +0 -425
  65. data/test/io_test.rb +0 -321
  66. data/test/kibitz_test.rb +0 -45
  67. data/test/lifecycle_contract_test.rb +0 -71
  68. data/test/literal_scan_test.rb +0 -97
  69. data/test/matching_test.rb +0 -211
  70. data/test/multi_session_test.rb +0 -66
  71. data/test/pattern_offset_test.rb +0 -43
  72. data/test/process_test.rb +0 -291
  73. data/test/redactor_test.rb +0 -183
  74. data/test/relay_recovery_test.rb +0 -451
  75. data/test/release_test.rb +0 -328
  76. data/test/ruby_api_test.rb +0 -515
  77. data/test/scan_reuse_test.rb +0 -109
  78. data/test/script_logging_test.rb +0 -124
  79. data/test/support/interact_probe.rb +0 -125
  80. data/test/support/kibitz_probe.rb +0 -177
  81. data/test/support/script_probe.rb +0 -158
  82. data/test/terminal_cleanup_test.rb +0 -349
  83. data/test/test_helper.rb +0 -58
  84. data/test/timeout_test.rb +0 -289
@@ -7,7 +7,7 @@ class Expect
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
@@ -3,110 +3,161 @@
3
3
  class Expect
4
4
  # 一次转接的共同读写循环;发送游标留在源会话中,调用结束后仍可继续。
5
5
  # 本对象只暂借未处理输入;读缓冲、发送进度与转义回调分开保存,避免重入时重复交付。
6
+ # @api private
6
7
  class Relay
7
- # 将普通匹配缓冲移入本轮转接,并记住上层交互缓冲;不关闭或接管任何外部 IO。
8
+ OWNERSHIP_MUTEX = Mutex.new
9
+ private_constant :OWNERSHIP_MUTEX
10
+
11
+ # 这里只校验参数;run 取得所有来源后才转移缓冲,不关闭或接管任何外部 IO。
8
12
  def initialize(sessions, timeout)
9
13
  @sessions = sessions.uniq
10
14
  @active = @sessions.dup
11
15
  period = Expect.duration(timeout)
12
16
  @deadline = period && (Expect.monotonic + period)
13
- @buffers = @sessions.to_h { |session| [session, session.clear_buffer] }
14
- @previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
17
+ @token = Object.new
18
+ @buffers = {}
19
+ @previous = {}
15
20
  end
16
21
 
17
22
  # 每轮先处理转义和已排队输出,再共同选择读写;停止返回来源会话,总期限到达返回 nil。
18
23
  # 真实 IO 的写入由非阻塞游标推进,用户日志和回调仍同步执行,须由调用方保证及时返回。
19
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
20
24
  def run
21
- @sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
25
+ prepare_sources
22
26
  outputs.each(&:restart_timeout)
23
27
  polled = false
24
28
  loop do
25
- idle = []
26
- queued = false
27
- @active.dup.each do |session|
28
- next if session.pending_output?
29
-
30
- if (callback = session.__send__(:relay_callback))
31
- session.__send__(:relay_callback=, nil)
32
- return session unless callback.first&.call
33
- end
29
+ stopped, idle, queued = dispatch_sources
30
+ return stopped if stopped
34
31
 
35
- result = Expect.__send__(:relay_buffer, session, @buffers, final: session.eof?) do |data|
36
- session.__send__(:queue_output, data)
37
- end
38
- return session unless result
32
+ return nil if @active.empty?
39
33
 
40
- if result == :pending
41
- queued = true
42
- next
43
- end
34
+ return finish_timeout(idle) if polled && expired?
44
35
 
45
- idle << session
46
- next unless session.eof?
36
+ progress = advance_outputs || queued
37
+
38
+ polled = true
39
+ return finish_timeout(idle) if select_and_read(progress) == :timeout
40
+ end
41
+ ensure
42
+ restore_sources
43
+ end
47
44
 
48
- callback = session.__send__(:sequences)[:eof]
49
- return session unless callback&.call
45
+ private
50
46
 
51
- @active.delete(session)
47
+ # 先交付转义前缀再派发回调;EOF 只在待发送数据处理完后生效。
48
+ def dispatch_sources
49
+ idle = []
50
+ queued = false
51
+ @active.dup.each do |session|
52
+ next if session.pending_output?
53
+
54
+ if (callback = session.relay_callback)
55
+ session.relay_callback = nil
56
+ return [session, idle, queued] unless callback.first&.call
52
57
  end
53
- return nil if @active.empty?
54
58
 
55
- return finish_timeout(idle) if polled && expired?
59
+ result = Interaction.relay_buffer(session, @buffers, final: session.eof?) do |data|
60
+ session.queue_output(data)
61
+ end
62
+ return [session, idle, queued] unless result
56
63
 
57
- progress = advance_outputs || queued
64
+ if result == :pending
65
+ queued = true
66
+ next
67
+ end
68
+
69
+ idle << session
70
+ next unless session.eof?
71
+
72
+ callback = session.sequences[:eof]
73
+ return [session, idle, queued] unless callback&.call
74
+
75
+ @active.delete(session)
76
+ end
77
+ [nil, idle, queued]
78
+ end
79
+
80
+ # 共同等待来源与目标,每轮读每个就绪来源一次;EINTR 返回原期限循环。
81
+ def select_and_read(progress)
82
+ pending = outputs
83
+ readers = read_sources(pending)
84
+ deadlines = [@deadline, *pending.map(&:deadline)].compact
85
+ remaining = deadlines.empty? ? nil : [deadlines.min - Expect.monotonic, 0].max
86
+ # 有进展时轮询输入再继续发送,避免大块输出饿死其他来源。
87
+ remaining = 0 if progress
88
+ begin
89
+ ready = IO.select(readers.map(&:to_io), pending.filter_map(&:io).uniq(&:object_id), nil, remaining)
90
+ if ready
91
+ read_ready(readers, ready.first)
92
+ elsif !progress
93
+ return :timeout if expired?
94
+
95
+ pending.each(&:check_timeout!)
96
+ end
97
+ rescue Errno::EINTR
98
+ nil
99
+ end
100
+ nil
101
+ end
102
+
103
+ # 同源转接期间读取到借用缓冲;目标会话的背压读取沿用自身状态。
104
+ def read_ready(readers, readable)
105
+ return if readable.empty?
106
+
107
+ if readable.size > 1
108
+ ready = {}.compare_by_identity
109
+ readable.each { |io| ready[io] = true }
110
+ end
111
+ readers.each do |session|
112
+ next unless ready ? ready.key?(session.to_io) : readable.first.equal?(session.to_io)
58
113
 
59
- pending = outputs
60
- readers = read_sources(pending)
61
- deadlines = [@deadline, *pending.map(&:deadline)].compact
62
- remaining = deadlines.empty? ? nil : [deadlines.min - Expect.monotonic, 0].max
63
- # 有进展时轮询输入再继续发送,避免大块输出饿死其他来源。
64
- remaining = 0 if progress
65
114
  begin
66
- polled = true
67
- ready = IO.select(readers.map(&:to_io), pending.filter_map(&:io).uniq, nil, remaining)
68
- if ready
69
- readers.each do |session|
70
- next unless ready[0].include?(session.to_io)
71
-
72
- begin
73
- if @buffers.key?(session)
74
- session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session), trim: false)
75
- else
76
- session.__send__(:read_available, propagate: false)
77
- end
78
- rescue Errno::EINTR
79
- next
80
- end
81
- end
82
- elsif !progress
83
- return finish_timeout(idle) if expired?
84
-
85
- pending.each(&:check_timeout!)
115
+ if @buffers.key?(session)
116
+ session.read_available(propagate: false, buffer: @buffers.fetch(session), trim: false)
117
+ else
118
+ session.read_available(propagate: false)
86
119
  end
87
120
  rescue Errno::EINTR
88
121
  next
89
122
  end
90
123
  end
91
- ensure
92
- # 未处理输入还给会话,已排队字节仍归发送游标;两者不能合并,否则恢复会重放前缀。
93
- @previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
94
- @buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
95
124
  end
96
125
 
97
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
126
+ # 锁仅保护标记检查/登记,不跨 IO 或用户回调;任一来源被占用时整组拒绝,不动缓冲和期限。
127
+ def prepare_sources
128
+ OWNERSHIP_MUTEX.synchronize do
129
+ raise ReentrancyError, "source session already has an active relay" if @sessions.any?(&:relay_owner)
98
130
 
99
- private
131
+ @sessions.each { |session| session.relay_owner = @token }
132
+ end
133
+ @sessions.each do |session|
134
+ @previous[session] = session.interaction_buffer
135
+ @buffers[session] = session.clear_buffer
136
+ session.interaction_buffer = @buffers.fetch(session)
137
+ end
138
+ end
139
+
140
+ # 未处理输入还给会话,已排队字节仍归游标;构造中断也只恢复已转移的缓冲及自己的标记。
141
+ def restore_sources
142
+ @previous.each { |session, buffer| session.interaction_buffer = buffer }
143
+ @buffers.each { |session, buffer| session.restore_relay_buffer(buffer) }
144
+ ensure
145
+ OWNERSHIP_MUTEX.synchronize do
146
+ @sessions.each do |session|
147
+ session.relay_owner = nil if session.relay_owner.equal?(@token)
148
+ end
149
+ end
150
+ end
100
151
 
101
152
  # 只收集尚未完成的目标,供共同 select 以及最早写入期限计算使用。
102
- def outputs = @sessions.flat_map { |session| session.__send__(:relay_outputs) }.reject(&:done?)
153
+ def outputs = @sessions.flat_map(&:relay_outputs).reject(&:done?)
103
154
 
104
155
  # 每个目标每轮最多推进一个片段;移除已完成游标,并告知主循环是否值得立即继续轮询。
105
156
  def advance_outputs(check_timeout: true)
106
157
  progress = false
107
158
  @sessions.each do |session|
108
- pending = session.__send__(:relay_outputs)
109
- pending.each { |output| progress = output.advance(check_timeout: check_timeout) || progress }
159
+ pending = session.relay_outputs
160
+ pending.each { |output| progress = output.advance(check_timeout:) || progress }
110
161
  pending.reject!(&:done?)
111
162
  end
112
163
  progress
@@ -118,9 +169,11 @@ class Expect
118
169
  # 背压源暂停吸收新输入,限制排队增长;作为写入目标的会话仍需读取以解除双向等待。
119
170
  def read_sources(pending)
120
171
  # 输出目标可能也在等待我们读取;这些来源即使有待发送数据也必须继续排空。
121
- targets = pending.map(&:target).grep(Expect)
172
+ targets = pending.filter_map do |output|
173
+ Session.for(output.target) if output.target.is_a?(Expect)
174
+ end
122
175
  sources = @active.reject { |session| session.pending_output? && !targets.include?(session) }
123
- (sources + targets).uniq(&:to_io).reject(&:eof?)
176
+ (sources + targets).uniq { |session| session.to_io.object_id }.reject(&:eof?)
124
177
  end
125
178
 
126
179
  # 总期限结束时将可交付尾部转为发送游标,至多尝试一轮交付;余量由源会话保存。
@@ -134,7 +187,8 @@ class Expect
134
187
  buffer = @buffers.fetch(session)
135
188
  next if buffer.empty?
136
189
 
137
- session.__send__(:queue_output, buffer.dup)
190
+ session.queue_output(buffer.dup)
191
+ Interaction.remember_output(session, buffer)
138
192
  buffer.clear
139
193
  end
140
194
  # 到期后只尝试一次非阻塞写入,剩余游标留给下一次 interconnect。
@@ -3,6 +3,7 @@
3
3
  class Expect
4
4
  # 向单个转接目标写入数据,维护发送进度与期限;恢复时不重放已成功写出的前缀。
5
5
  # 只借用目标,不 dup 描述符,也不负责读取;所有就绪等待统一交给 Relay 调度。
6
+ # @api private
6
7
  class RelayWriter
7
8
  attr_reader :target, :deadline
8
9
 
@@ -41,7 +42,7 @@ class Expect
41
42
  if @offset < @data.bytesize
42
43
  bytes = @data.byteslice(@offset, READ_SIZE)
43
44
  count = io ? io.write_nonblock(bytes, exception: false) : target.write(bytes)
44
- if count == :wait_writable
45
+ if io && count == :wait_writable
45
46
  check_timeout! if check_timeout
46
47
  return false
47
48
  end
data/lib/expect/result.rb CHANGED
@@ -1,18 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
- # 是否命中文本模式;事件不会返回模式序号。
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