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.
- checksums.yaml +4 -4
- data/.rubocop.yml +41 -4
- data/CHANGELOG.md +14 -0
- data/CONTRIBUTING.md +23 -0
- data/Gemfile +2 -1
- data/README.md +23 -6
- data/benchmark/matching.rb +72 -0
- data/benchmark/relay.rb +48 -0
- data/benchmark/send_slow.rb +41 -0
- data/benchmark/support.rb +94 -0
- data/docs/COMPATIBILITY.md +1 -1
- data/docs/INTERNAL_CONTRACTS.md +65 -0
- data/docs/PERFORMANCE.md +60 -0
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +24 -0
- data/examples/kibitz/kibitz.rb +2 -0
- data/expect-pty.gemspec +2 -1
- data/lib/expect/interaction.rb +13 -6
- data/lib/expect/logging.rb +96 -0
- data/lib/expect/matcher.rb +26 -5
- data/lib/expect/pattern.rb +9 -5
- data/lib/expect/relay.rb +3 -1
- data/lib/expect/relay_writer.rb +2 -0
- data/lib/expect/session_resources.rb +29 -11
- data/lib/expect/terminal.rb +35 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +85 -145
- data/script/ci +5 -0
- data/script/release.rb +37 -5
- data/test/cleanup_test.rb +251 -0
- data/test/configuration_test.rb +43 -0
- data/test/io_test.rb +35 -0
- data/test/pattern_offset_test.rb +43 -0
- data/test/process_test.rb +46 -0
- data/test/relay_recovery_test.rb +37 -0
- data/test/release_test.rb +94 -16
- data/test/scan_reuse_test.rb +109 -0
- data/test/timeout_test.rb +119 -0
- metadata +15 -3
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.
|
|
8
|
-
2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.3.
|
|
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.
|
|
44
|
-
git push origin v0.3.
|
|
45
|
-
gh workflow run release.yml --ref v0.3.
|
|
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.
|
|
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、转义回调内匹配与异常尾部恢复、嵌套写入超时进度。新用例先在修复前失败,再验证修复;仅内部转义处理器的两项测试随职责调整更新调用方式。
|
data/examples/kibitz/kibitz.rb
CHANGED
|
@@ -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", "
|
|
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 解析完整运行时依赖。
|
data/lib/expect/interaction.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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 --
|
|
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
|
data/lib/expect/matcher.rb
CHANGED
|
@@ -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
|
-
|
|
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) }
|
|
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
|
data/lib/expect/pattern.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
text.
|
|
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
|
-
#
|
|
32
|
-
offset =
|
|
33
|
-
return [offset,
|
|
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
|
|
data/lib/expect/relay_writer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
data/lib/expect/version.rb
CHANGED