expect-pty 0.3.1 → 0.3.3

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.
data/docs/RELEASING.md CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  ## 准备版本
6
6
 
7
- 1. 更新 `lib/expect/version.rb` 的 `Expect::VERSION`,例如 `0.3.1`。
8
- 2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.3.1 - 2026-09-26`;可以保留空的 `Unreleased` 标题。
7
+ 1. 更新 `lib/expect/version.rb` 的 `Expect::VERSION`,例如 `0.3.3`。
8
+ 2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.3.3 - 2026-09-27`;可以保留空的 `Unreleased` 标题。
9
9
  3. 提交源码,发布时工作区必须干净。若同时发布 GitHub Release,还需推送到 `main`,远端 `main` 必须包含该提交,已有同名标签必须指向该提交。
10
10
 
11
11
  发布脚本只接受正式版 `X.Y.Z`;未归档的变更会阻止发布。
@@ -22,6 +22,8 @@ ruby script/release.rb --rubygems-only
22
22
 
23
23
  默认执行 `script/ci` 的检查、完整测试、构建和隔离安装验证,构建包保存在 `tmp/ci/expect-pty-版本号.gem`。随后复制到 `tmp/release/版本号/candidate-*/` 的独占目录,核对包内文件并生成发布说明和校验文件。该副本贯穿后续发布,目录会保留供失败重试。正式发布前再次确认源码未变,上传 RubyGems 后下载远端包核对 SHA256。
24
24
 
25
+ 正式发布逐个核对包内普通文件在目标 Git 提交中的存在性、原始字节和执行权限,同时核对 gemspec 的安装元数据及主页。`git status` 为空不能替代这一检查:忽略规则可能隐藏被 glob 收入包的本地文件,`assume-unchanged` 和 `core.filemode` 也可能隐藏差异。候选包必须与已提交源码一致。
26
+
25
27
  `--dry-run` 只做本地验证,可在提交前使用。它仍要求版本号和发布说明完整。
26
28
 
27
29
  ## 同时发布 GitHub Release
@@ -40,9 +42,9 @@ ruby script/release.rb
40
42
  GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables → Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
41
43
 
42
44
  ```sh
43
- git tag -a v0.3.1 -m 'Release v0.3.1'
44
- git push origin v0.3.1
45
- gh workflow run release.yml --ref v0.3.1 --repo gatework/expect-ruby
45
+ git tag -a v0.3.3 -m 'Release v0.3.3'
46
+ git push origin v0.3.3
47
+ gh workflow run release.yml --ref v0.3.3 --repo gatework/expect-ruby
46
48
  ```
47
49
 
48
50
  也可以在 Actions → Release → Run workflow 选择对应版本标签。工作流仅支持手动触发,避免本地发布时出现第二次并发上传。
@@ -56,7 +58,7 @@ gh workflow run release.yml --ref v0.3.1 --repo gatework/expect-ruby
56
58
  保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从 CI 或 Release 下载的原包:
57
59
 
58
60
  ```sh
59
- ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.1.gem
61
+ ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.3.gem
60
62
  ```
61
63
 
62
64
  将示例路径替换为实际输出的 `Artifact` 路径。`--artifact` 会跳过构建和测试,但仍核对包与当前源码是否一致;需要同时恢复 GitHub Release 时去掉 `--rubygems-only`。
data/docs/VERIFICATION.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # 验证记录
2
2
 
3
+ ## 模块审查、资源清理与正则输入复用(2026-09-27)
4
+
5
+ 基线为 `b9fc077`,以下为 0.3.3 发布准备前的源码审查记录。覆盖会话、配置、模式声明与定位、匹配结果及期限、资源所有权、日志、终端、人工接管、转接恢复、示例、发布和打包模块。修复工厂/构造失败后的子进程回收、部分句柄清理、原始异常保留、EOF 继续期限和发布包提交来源验证;新行为均有先失败后通过的回归。EOF 补充多源同时结束和重置期限用例,保留已知 EOF 的派发顺序。
6
+
7
+ 日志与终端方法拆入独立能力文件,公共入口保持 `expect/pty`;打包检查确认两文件随 Gem 分发。正则输入复用通过冻结输入、编码分片、二进制、字节偏移及匹配/转接回归,实测数据见 [性能基准](PERFORMANCE.md#正则输入复用验证2026-09-27)。
8
+
9
+ | 当前运行环境 | 完整 `bash script/ci` |
10
+ | --- | --- |
11
+ | macOS arm64,Ruby 4.0.6,Bundler 4.0.17,原工作区 | 269 项 / 1,356 断言,0 失败、错误、跳过;RuboCop 51 文件无违规 |
12
+ | macOS arm64,同一运行时,隔离 worktree | 同上;从基线加本轮补丁及新增文件重建 |
13
+ | Linux aarch64,Ruby 3.2.11,Bundler 4.0.17,独立容器副本 | 同上;冻结开发锁文件,容器独立安装依赖 |
14
+
15
+ 三次均完成对话示例、三组基准 smoke、Gem 构建、普通 RubyGems 与最小 Bundler 应用的隔离安装及真实 PTY 对话。另检查包内 69 个文件及新增模块,无构建目录或日志;工作流 actionlint 通过(未启用 shellcheck),发布与示例 `--help` 正常。macOS 构建仍有宿主 RDoc 7/8 重复常量警告,退出状态为 0。
16
+
17
+ 发布测试使用临时 Git 仓库,验证合法包通过,忽略文件、`assume-unchanged` 隐藏内容、忽略执行权限变化及主页元数据不一致均被拒绝,未连接发布服务。审查时版本仍为 0.3.2,改动位于 `Unreleased`,这些本地检查不代替发布验证;当时未运行真实 SSH、远端 CI 或 Ruby 3.3/3.4,也未提交、推送或发布。0.3.3 的远端平台结果以发布提交对应的 GitHub Actions 为准,以下历史结果不作为本轮验证证据。
18
+
19
+ ## 配置并发与模块审查改进(2026-09-26)
20
+
21
+ 当前工作树在既有未提交的匹配、转接和清理改动上,增加 `configure` 的串行读改写与回归测试:旧实现的并发用例先失败,修复后通过;嵌套调用显式抛出 `ThreadError`,不会陷入死锁或发布部分配置。`read_available` 的裁剪选择改为显式参数,转接调用明确禁止裁剪其待处理缓冲。RuboCop 对生产代码启用分支复杂度检查,既有状态机只在对应方法局部豁免;补充内部调用契约、贡献指南、issue/PR 模板与安全说明。
22
+
23
+ macOS arm64、Ruby 4.0.6、Bundler 4.0.17 下执行 `bash script/ci`:249 项测试、1,242 条断言,0 失败、错误或跳过;RuboCop 检查 49 个文件无违规。对话示例、三组基准 smoke、Gem 构建、普通 RubyGems 与 Bundler 隔离安装及真实 PTY 对话均通过。构建时本机 RDoc 7/8 重复常量警告仍存在,但命令成功完成。
24
+
25
+ 本轮未运行 Linux/Ruby 3.2、远端 CI 或真实 SSH;未提交、推送或发布。下文历史结果不作为本轮检查证据。
26
+
3
27
  ## 0.3.0 发布前复核(2026-09-26)
4
28
 
5
29
  以 0.2.0 为基线导入源码更新,发布前增加 8 项回归,覆盖退出正则切换、同规则跨次匹配、待发送恢复、跨次 CRLF、转义回调内匹配与异常尾部恢复、嵌套写入超时进度。新用例先在修复前失败,再验证修复;仅内部转义处理器的两项测试随职责调整更新调用方式。
@@ -51,6 +51,7 @@ module Kibitz
51
51
  value
52
52
  end
53
53
 
54
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- CLI 建连、终端接管和 ensure 清理共享一次生命周期。
54
55
  def self.run(argv, input: $stdin, output: $stdout)
55
56
  options = { escape: ESCAPE }
56
57
  parser = OptionParser.new do |opts|
@@ -134,6 +135,7 @@ module Kibitz
134
135
  File.unlink(socket_path) if socket_path && File.socket?(socket_path)
135
136
  Dir.rmdir(directory) if directory && Dir.exist?(directory)
136
137
  end
138
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
137
139
  end
138
140
 
139
141
  exit Kibitz.run(ARGV) if $PROGRAM_NAME == __FILE__
data/expect-pty.gemspec CHANGED
@@ -15,7 +15,8 @@ Gem::Specification.new do |spec|
15
15
  spec.files = Dir[
16
16
  "lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
17
17
  "test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
18
- ".rubocop.yml", "expect-pty.gemspec", "README.md", "LICENSE", "CHANGELOG.md", "script/ci", "script/release.rb"
18
+ ".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
19
+ "script/ci", "script/release.rb"
19
20
  ]
20
21
  spec.require_paths = ["lib"]
21
22
  # 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
@@ -8,7 +8,7 @@ class Expect
8
8
  REGEXP_ESCAPE_HISTORY_LIMIT = 65_536
9
9
  private_constant :REGEXP_ESCAPE_HISTORY_LIMIT
10
10
 
11
- # Presents remote text on a raw local terminal without changing existing CRLF sequences.
11
+ # 在 raw 本地终端显示远端文本,补齐 LF 所需的 CR,同时保留已有 CRLF。
12
12
  class InteractOutput
13
13
  def initialize(target)
14
14
  @target = target
@@ -87,14 +87,20 @@ class Expect
87
87
 
88
88
  # 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
89
89
  # 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
90
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 转义匹配、前缀暂存和回调消费属于同一轮扫描。
90
91
  def self.relay_buffer(session, buffers, final: false)
91
92
  buffer = buffers.fetch(session)
92
93
  loop do
93
94
  sequences = session.__send__(:sequences).except(:eof)
94
95
  history = session.__send__(:relay_history)
96
+ text = nil
97
+ utf8_regexp = false
95
98
  matches = sequences.filter_map do |key, handler|
96
99
  if key.is_a?(Regexp)
97
- position = Pattern.new(value: key).locate(history + buffer, final: final)
100
+ # 仅在本轮扫描复用组合文本;回调可能更换规则或消费缓冲,下一轮必须重新构造。
101
+ text ||= history + buffer
102
+ utf8_regexp ||= key.fixed_encoding? && key.encoding == Encoding::UTF_8
103
+ position = Pattern.new(value: key).locate(text, final: final)
98
104
  if position
99
105
  offset, length, = position
100
106
  raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
@@ -148,12 +154,12 @@ class Expect
148
154
  data = buffer.byteslice(0, count)
149
155
  block_given? ? yield(data) : session.__send__(:propagate, data)
150
156
  end
151
- if sequences.keys.any?(Regexp)
157
+ if text
152
158
  history << buffer.byteslice(0, count)
153
159
  limit = session.buffer_limit || REGEXP_ESCAPE_HISTORY_LIMIT
154
160
  if history.bytesize > limit
155
161
  history.replace(history.byteslice(-limit, limit))
156
- if sequences.keys.any? { |key| key.is_a?(Regexp) && key.fixed_encoding? && key.encoding == Encoding::UTF_8 }
162
+ if utf8_regexp
157
163
  # 窗口可能从 UTF-8 续字节开始;丢弃不完整的首字符后再交给固定编码正则。
158
164
  history.slice!(0) while (byte = history.getbyte(0)) && (0x80..0xBF).cover?(byte)
159
165
  end
@@ -163,6 +169,7 @@ class Expect
163
169
  return block_given? && count.positive? ? :pending : true
164
170
  end
165
171
  end
172
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
166
173
 
167
174
  private_class_method :relay_buffer
168
175
 
@@ -189,7 +196,7 @@ class Expect
189
196
 
190
197
  private :interact_source, :queue_output
191
198
 
192
- # Only interact knows which stream is the local keyboard. Generic interconnect leaves terminals alone.
199
+ # 只有 interact 知道哪个流是本地键盘;通用 interconnect 不修改终端模式。
193
200
  def prepare_interact_terminal(source)
194
201
  return unless source.raw_terminal? && source.tty?
195
202
 
@@ -197,7 +204,7 @@ class Expect
197
204
  state = [io, io.console_mode]
198
205
  io.raw!
199
206
  state
200
- rescue Exception # rubocop:disable Lint/RescueException -- Restore a partially changed terminal on interrupts.
207
+ rescue Exception # rubocop:disable Lint/RescueException -- 中断时也恢复可能已部分修改的终端。
201
208
  restore_interact_terminal(state)
202
209
  raise
203
210
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 管理日志目标、监听器及同步输出;资源所有权仍由会话的 SessionResources 统一保存。
4
+ class Expect
5
+ # 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
6
+ attr_reader :log_output
7
+
8
+ # 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
9
+ def log_output=(target)
10
+ unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
11
+ raise ArgumentError, "log output must support write or call, or be nil"
12
+ end
13
+
14
+ replace_log(target)
15
+ end
16
+
17
+ # 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
18
+ def log_to(target = nil, mode: "a", &block)
19
+ raise ArgumentError, "provide a log target or a block, not both" if block && target
20
+
21
+ target = block if block
22
+ if target.respond_to?(:to_path) || target.is_a?(String)
23
+ raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
24
+
25
+ # 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
26
+ replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
27
+ else
28
+ raise ArgumentError, "provide a log target or a block" unless target
29
+
30
+ self.log_output = target
31
+ end
32
+ end
33
+
34
+ # 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
35
+ def write_log(*objects)
36
+ target = log_output
37
+ return unless target
38
+
39
+ data = objects.map { |object| object.to_s.b }.join
40
+ target.respond_to?(:call) ? target.call(data) : emit(target, data)
41
+ end
42
+
43
+ # 返回监听器列表副本,避免外部原地修改转发关系。
44
+ def listeners = @listeners.dup
45
+
46
+ # 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
47
+ def listeners=(outputs)
48
+ outputs = Array(outputs)
49
+ raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
50
+
51
+ @listeners = outputs.dup
52
+ end
53
+
54
+ private
55
+
56
+ # 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
57
+ def propagate(data)
58
+ emit($stdout, data) if log_stdout?
59
+ @listeners.each { |listener| emit(listener, data) } if log_listeners?
60
+ end
61
+
62
+ # 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
63
+ def emit(target, data)
64
+ offset = 0
65
+ while offset < data.bytesize
66
+ count = target.write(data.byteslice(offset..))
67
+ unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
68
+ raise IOError, "write must return the number of accepted bytes"
69
+ end
70
+
71
+ offset += count
72
+ end
73
+ target.flush if target.respond_to?(:flush)
74
+ end
75
+
76
+ # 按诊断级别向 stderr 输出会话标识和消息。
77
+ def trace(message, level: 1)
78
+ warn("#{inspect}: #{message}") if debug_level >= level
79
+ end
80
+
81
+ # 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
82
+ def replace_log(target, owned: false)
83
+ previous = log_output
84
+ # 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
85
+ return target if previous.equal?(target)
86
+
87
+ previous.close if @resources.owned_log && previous && !previous.closed?
88
+ # 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
89
+ @resources.owned_log = owned ? target : nil
90
+ @log_output = target
91
+ target
92
+ rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
93
+ target.close if owned && target && !target.closed?
94
+ raise
95
+ end
96
+ end
@@ -12,6 +12,7 @@ class Expect
12
12
  @handled_eof = []
13
13
  @stalled_matches = {}
14
14
  @polled = false
15
+ @expired_eof_continuation = false
15
16
  end
16
17
 
17
18
  # 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
@@ -30,10 +31,12 @@ class Expect
30
31
  end
31
32
  loop do
32
33
  # 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
33
- result = if (matched = find_match)
34
+ result = if !@expired_eof_continuation && (matched = find_match)
34
35
  handle_match(*matched)
35
36
  elsif (session = unhandled_eof)
36
37
  handle_eof(session)
38
+ elsif @expired_eof_continuation
39
+ handle_timeout
37
40
  else
38
41
  read_next
39
42
  end
@@ -49,12 +52,18 @@ class Expect
49
52
  private
50
53
 
51
54
  # 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
55
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 声明优先级与单轮快照须在同一次扫描保持一致。
52
56
  def find_match
53
- @patterns.groups.each do |sessions, patterns|
57
+ # 单组且来源不重复时无需缓存;重复来源才为本轮扫描建立快照表。
58
+ groups = @patterns.groups
59
+ if groups.size > 1 || (groups.first && groups.first.first.size > @sessions.size)
60
+ snapshots = {}.compare_by_identity
61
+ end
62
+ groups.each do |sessions, patterns|
54
63
  sessions.each do |session|
55
64
  next if @handled_eof.include?(session)
56
65
 
57
- buffer = session.buffer
66
+ buffer = snapshots ? (snapshots[session] ||= session.buffer) : session.buffer
58
67
  stalled = @stalled_matches[session]
59
68
  if stalled && stalled[:buffer] != buffer
60
69
  @stalled_matches.delete(session)
@@ -70,6 +79,7 @@ class Expect
70
79
  end
71
80
  nil
72
81
  end
82
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
73
83
 
74
84
  # 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
75
85
  def handle_match(session, pattern, position)
@@ -102,7 +112,12 @@ class Expect
102
112
  return result unless actions.any? { |action| continuing?(action) }
103
113
 
104
114
  @deadline = next_deadline if actions.include?(CONTINUE)
105
- @sessions.all? { |candidate| @handled_eof.include?(candidate) } ? result : :retry
115
+ return result if @sessions.all? { |candidate| @handled_eof.include?(candidate) }
116
+
117
+ # 期限已过时不再扫描文本,但先派发已知 EOF;最后一个源结束不能被误报为超时。
118
+ @expired_eof_continuation = !actions.include?(CONTINUE) && expired?
119
+
120
+ :retry
106
121
  end
107
122
 
108
123
  # 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
@@ -126,8 +141,13 @@ class Expect
126
141
 
127
142
  # 每个就绪 IO 读取一次,将异常归属到对应会话;仅显式启用时按接收数据刷新期限。
128
143
  def read_ready(readable, sessions)
144
+ # select 返回 IO 对象本身;共享同一 IO 时仍选择声明顺序中的首个会话。
145
+ if readable.size > 1
146
+ by_io = {}.compare_by_identity
147
+ sessions.each { |session| by_io[session.to_io] ||= session }
148
+ end
129
149
  readable.each do |io|
130
- session = sessions.find { |candidate| candidate.to_io.equal?(io) }
150
+ session = by_io ? by_io.fetch(io) : sessions.find { |candidate| candidate.to_io.equal?(io) }
131
151
  begin
132
152
  # 转接回调消费匹配内容,余下字节交回 Relay,不能在这里提前转发两次。
133
153
  data = session.__send__(:read_available, propagate: !@relay_buffers.key?(session))
@@ -170,6 +190,7 @@ class Expect
170
190
 
171
191
  @deadline = next_deadline
172
192
  @polled = false
193
+ @expired_eof_continuation = false
173
194
  :retry
174
195
  end
175
196
  end
@@ -18,8 +18,12 @@ class Expect
18
18
  offset = buffer.index(value)
19
19
  return [offset, value.bytesize, []] if offset
20
20
  when Regexp
21
- text = buffer.dup
22
- text.force_encoding(value.encoding) if value.fixed_encoding?
21
+ # 正则只读取输入;只有编码标记不同才复制,避免每个模式额外分配缓冲对象。
22
+ text = if value.fixed_encoding? && value.encoding != buffer.encoding
23
+ buffer.dup.force_encoding(value.encoding)
24
+ else
25
+ buffer
26
+ end
23
27
  unless text.valid_encoding?
24
28
  # 不完整的尾字符可能改变锚点或前瞻结果,必须等字符收齐后再匹配。
25
29
  validate_incomplete_suffix!(text, final: final)
@@ -28,9 +32,9 @@ class Expect
28
32
  found = value.match(text)
29
33
  return unless found
30
34
 
31
- # Ruby 正则偏移按字符计算,缓冲切片按字节计算,必须转换;捕获组也统一返回字节串。
32
- offset = text[0...found.begin(0)].bytesize
33
- return [offset, found[0].bytesize, found.captures.map { |capture| capture&.b }]
35
+ # 直接使用正则的字节范围,避免为转换字符偏移创建前缀切片;捕获组也返回字节串。
36
+ offset, finish = found.byteoffset(0)
37
+ return [offset, finish - offset, found.captures.map { |capture| capture&.b }]
34
38
  end
35
39
  nil
36
40
  end
data/lib/expect/relay.rb CHANGED
@@ -12,6 +12,7 @@ class Expect
12
12
  @previous = @sessions.to_h { |session| [session, session.__send__(:interaction_buffer)] }
13
13
  end
14
14
 
15
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 共享读写循环统一维护来源和目标期限。
15
16
  def run
16
17
  @sessions.each { |session| session.__send__(:interaction_buffer=, @buffers.fetch(session)) }
17
18
  outputs.each(&:restart_timeout)
@@ -66,7 +67,7 @@ class Expect
66
67
 
67
68
  begin
68
69
  if @buffers.key?(session)
69
- session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session))
70
+ session.__send__(:read_available, propagate: false, buffer: @buffers.fetch(session), trim: false)
70
71
  else
71
72
  session.__send__(:read_available, propagate: false)
72
73
  end
@@ -87,6 +88,7 @@ class Expect
87
88
  @previous.each { |session, buffer| session.__send__(:interaction_buffer=, buffer) }
88
89
  @buffers.each { |session, buffer| session.__send__(:restore_relay_buffer, buffer) }
89
90
  end
91
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
90
92
 
91
93
  private
92
94
 
@@ -27,6 +27,7 @@ class Expect
27
27
  def done? = @offset == @data.bytesize && @flushed
28
28
 
29
29
  # 每轮每目标至多一次写入;真实 IO 永不在此等待,交给 Relay 的共同 select。
30
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 游标、短写、flush 和期限按单个目标推进。
30
31
  def advance(check_timeout: true)
31
32
  return false if done?
32
33
  raise IOError, "closed Expect session" if target.is_a?(Expect) && target.closed?
@@ -54,6 +55,7 @@ class Expect
54
55
  check_timeout! if check_timeout
55
56
  true
56
57
  end
58
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
57
59
 
58
60
  def check_timeout!
59
61
  return unless deadline && Expect.monotonic >= deadline
@@ -19,8 +19,14 @@ class Expect
19
19
  def close_handles
20
20
  return unless own
21
21
 
22
- # PTY 的读写端可能是同一个对象,先去重,重复关闭也保持安全。
23
- [reader, writer, slave].compact.uniq.each { |io| io.close unless io.closed? }
22
+ # 初始化校验失败时可能含无效参数,只关闭真实 IO;PTY 读写端也需要去重。
23
+ failure = nil
24
+ [reader, writer, slave].grep(IO).uniq.each do |io|
25
+ io.close unless io.closed?
26
+ rescue IOError, SystemCallError => error
27
+ failure ||= error
28
+ end
29
+ raise failure if failure
24
30
  end
25
31
 
26
32
  # 非阻塞回收直属子进程,缓存 Process::Status,成功后清空 PID 以支持重复查询。
@@ -42,15 +48,27 @@ class Expect
42
48
  def finalize
43
49
  return unless owner == Process.pid
44
50
 
45
- close_handles
46
- owned_log.close if owned_log && !owned_log.closed?
47
- reap
48
- return unless pid
49
-
50
- Process.kill("KILL", pid)
51
- # 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
52
- Process.detach(pid)
53
- @pid = nil
51
+ begin
52
+ close_handles
53
+ ensure
54
+ begin
55
+ owned_log.close if owned_log && !owned_log.closed?
56
+ ensure
57
+ # 每个阶段独立收尾;句柄或日志关闭失败不能跳过进程回收。
58
+ reap
59
+ if pid
60
+ begin
61
+ Process.kill("KILL", pid)
62
+ rescue Errno::ESRCH
63
+ # 子进程可能刚好退出,仍需尝试 wait,不能留下僵尸进程。
64
+ nil
65
+ end
66
+ # 将最终 wait 交给后台回收线程,避免在 GC 终结器中阻塞等待。
67
+ Process.detach(pid)
68
+ @pid = nil
69
+ end
70
+ end
71
+ end
54
72
  rescue IOError, SystemCallError
55
73
  nil
56
74
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ # 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
6
+ class Expect
7
+ # 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
8
+ def stty(*modes)
9
+ return "" unless tty?
10
+
11
+ modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
12
+ modes = ["-g"] if modes.empty?
13
+ reader, sink = IO.pipe
14
+ child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
15
+ sink.close
16
+ output = reader.read
17
+ _, status = Process.waitpid2(child)
18
+ raise IOError, "stty failed: #{output.strip}" unless status.success?
19
+
20
+ output.strip
21
+ rescue Errno::ENOENT
22
+ raise IOError, "stty executable not found in PATH; install the system terminal utilities"
23
+ ensure
24
+ reader&.close unless reader&.closed?
25
+ sink&.close unless sink&.closed?
26
+ end
27
+
28
+ # 读取终端的 [行数, 列数]。
29
+ def winsize = to_io.winsize
30
+
31
+ # 更新终端尺寸,由内核通知前台进程。
32
+ def winsize=(size)
33
+ to_io.winsize = size
34
+ end
35
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Expect
4
4
  # Gem 与库共用的版本号;独立文件使 gemspec 无需加载完整会话实现。
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.3"
6
6
  end