expect-pty 0.3.1 → 0.3.2
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 +32 -3
- data/CHANGELOG.md +6 -0
- data/CONTRIBUTING.md +21 -0
- data/Gemfile +2 -1
- data/README.md +15 -4
- 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/INTERNAL_CONTRACTS.md +44 -0
- data/docs/PERFORMANCE.md +44 -0
- data/docs/RELEASING.md +6 -6
- data/docs/VERIFICATION.md +8 -0
- data/examples/kibitz/kibitz.rb +2 -0
- data/expect-pty.gemspec +2 -1
- data/lib/expect/interaction.rb +10 -3
- data/lib/expect/matcher.rb +15 -3
- data/lib/expect/pattern.rb +3 -3
- data/lib/expect/relay.rb +3 -1
- data/lib/expect/relay_writer.rb +2 -0
- data/lib/expect/session_resources.rb +28 -10
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +50 -16
- data/script/ci +5 -0
- data/test/cleanup_test.rb +163 -0
- data/test/configuration_test.rb +43 -0
- data/test/io_test.rb +35 -0
- data/test/pattern_offset_test.rb +41 -0
- data/test/relay_recovery_test.rb +37 -0
- data/test/scan_reuse_test.rb +109 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fb3b136c8eba8bbce544e17de1d65fd533e0bdd96a57be3c32ae070dfb7a4dfb
|
|
4
|
+
data.tar.gz: 1133f3426854d0d5f7b9c3695d571fae273ae77bf39e3b218b17d683f5a677e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11e8b10c1e47effa3c4d15c9d876b03964a3002b6caa1d24fa59ffcf6887e06142f8d54409efabd7b8bb794ce3c0be4209f1258b1f815383e662b9e978fd4e0a
|
|
7
|
+
data.tar.gz: 4afa9e166dba2912c399e9db58c4345fcc284509fbbcfa5306759ae0b31a323b0ae6b97d8c74f90ffede52480c3ed7ca75b6d543b44bfed5daf485c24af7c3ec
|
data/.rubocop.yml
CHANGED
|
@@ -7,11 +7,40 @@ AllCops:
|
|
|
7
7
|
- "tmp/**/*"
|
|
8
8
|
- "vendor/**/*"
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
10
|
+
# Length and ABC counts are poor signals for protocol loops and integration scenarios.
|
|
11
|
+
# Branch complexity remains checked for production code; reviewed state machines
|
|
12
|
+
# carry local exemptions at the method that needs them.
|
|
13
|
+
Metrics/AbcSize:
|
|
13
14
|
Enabled: false
|
|
14
15
|
|
|
16
|
+
Metrics/BlockLength:
|
|
17
|
+
Enabled: false
|
|
18
|
+
|
|
19
|
+
Metrics/BlockNesting:
|
|
20
|
+
Enabled: false
|
|
21
|
+
|
|
22
|
+
Metrics/ClassLength:
|
|
23
|
+
Enabled: false
|
|
24
|
+
|
|
25
|
+
Metrics/MethodLength:
|
|
26
|
+
Enabled: false
|
|
27
|
+
|
|
28
|
+
Metrics/ModuleLength:
|
|
29
|
+
Enabled: false
|
|
30
|
+
|
|
31
|
+
Metrics/ParameterLists:
|
|
32
|
+
Enabled: false
|
|
33
|
+
|
|
34
|
+
Metrics/CyclomaticComplexity:
|
|
35
|
+
Max: 13
|
|
36
|
+
Exclude:
|
|
37
|
+
- "test/**/*"
|
|
38
|
+
|
|
39
|
+
Metrics/PerceivedComplexity:
|
|
40
|
+
Max: 13
|
|
41
|
+
Exclude:
|
|
42
|
+
- "test/**/*"
|
|
43
|
+
|
|
15
44
|
Style/StringLiterals:
|
|
16
45
|
EnforcedStyle: double_quotes
|
|
17
46
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.2 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- **匹配与转接**:正则直接使用字节偏移,重复来源在单轮扫描中共享缓冲快照;转义正则复用本轮组合文本,保留回调修改规则后的重新扫描语义。`send_slow` 只读取已经可读的回复。
|
|
8
|
+
- **配置与清理**:并发 `Expect.configure` 串行发布,避免丢失更新;关闭句柄或日志失败时继续清理其他资源和直属子进程,并保留首个清理错误。转接读取显式保留未交付缓冲。
|
|
9
|
+
- **维护与验证**:增加状态归属文档、性能基准及 smoke、分支复杂度检查、贡献指南和安全说明;补充匹配、转接、清理及并发配置回归测试。
|
|
10
|
+
|
|
5
11
|
## 0.3.1 - 2026-09-26
|
|
6
12
|
|
|
7
13
|
- **安装依赖**:显式声明五个运行时标准库 gem,开发工具依赖单独管理,并补齐应用 Gemfile 中的 `require: "expect/pty"` 用法。
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 参与贡献
|
|
2
|
+
|
|
3
|
+
欢迎中文或英文 issue 和 PR。API、错误信息和示例中的可编辑命令保持直接清晰;库中的复杂生命周期和协议注释主要使用中文,英文说明同样欢迎。
|
|
4
|
+
|
|
5
|
+
## 本地检查
|
|
6
|
+
|
|
7
|
+
需要 Ruby 3.2+ 和 Linux/macOS。安装依赖后运行:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bundle install
|
|
11
|
+
bundle exec rake # RuboCop 和默认测试
|
|
12
|
+
script/ci # 另含示例、Gem 构建及隔离安装验证
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
修改转接或匹配时,先运行对应的 `test/*_test.rb`,再运行 `bundle exec rake`。默认测试不需要 SSH;真实 SSH 集成测试的环境要求见 [测试说明](test/integration/README.md)。发布操作和历史验证分别见 [发布说明](docs/RELEASING.md) 与 [验证记录](docs/VERIFICATION.md)。
|
|
16
|
+
|
|
17
|
+
## 提交问题或改动
|
|
18
|
+
|
|
19
|
+
问题报告请附 Ruby 版本、操作系统、最小复现代码、预期与实际结果,以及必要的异常信息;日志中请删去凭据和会话敏感内容。修复行为缺陷时,先加入能在旧实现复现问题的回归测试。涉及缓冲、转义、背压或进程清理的改动,请对照 [内部状态与数据归属](docs/INTERNAL_CONTRACTS.md),说明超时、EOF 和失败后的恢复行为。
|
|
20
|
+
|
|
21
|
+
PR 请说明行为变化、运行过的命令及结果。不要把本机生成的 `tmp/`、日志或凭据加入提交。
|
data/Gemfile
CHANGED
|
@@ -8,7 +8,8 @@ group :development, :test do
|
|
|
8
8
|
# RuboCop 的依赖也必须支持最低 Ruby 版本。
|
|
9
9
|
gem "parallel", "~> 1.27", require: false
|
|
10
10
|
gem "rake", "~> 13.0", require: false
|
|
11
|
-
|
|
11
|
+
# 固定已审阅的规则集,避免不同 CI 环境自动启用新 cop 改变发布门槛。
|
|
12
|
+
gem "rubocop", "= 1.89.0", require: false
|
|
12
13
|
|
|
13
14
|
# 测试、示例和发布工具直接使用的标准库 gem,不依赖其他开发工具间接引入。
|
|
14
15
|
gem "digest", require: false
|
data/README.md
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
要求 **Ruby 3.2+、POSIX 系统(Linux/macOS)**。运行时仅使用 Ruby 标准库,其中可独立安装的 gem 已在 gemspec 中声明,由 RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提供独立的 `Expect` 类,不修改标准库的 `IO#expect`。
|
|
8
8
|
|
|
9
|
+
源码中的解释性注释主要使用中文;欢迎用中文或英文提交 issue 和 PR,参与方式见 [贡献指南](CONTRIBUTING.md)。
|
|
10
|
+
|
|
9
11
|
## 安装和运行
|
|
10
12
|
|
|
11
13
|
项目和仓库名为 `expect-ruby`,Gem 名为 `expect-pty`。在应用的 Gemfile 中添加以下内容,然后运行 `bundle install`:
|
|
12
14
|
|
|
13
15
|
```ruby
|
|
14
|
-
gem "expect-pty", "~> 0.3.
|
|
16
|
+
gem "expect-pty", "~> 0.3.2", require: "expect/pty"
|
|
15
17
|
```
|
|
16
18
|
|
|
17
19
|
也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
|
|
@@ -24,8 +26,8 @@ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "m
|
|
|
24
26
|
|
|
25
27
|
```sh
|
|
26
28
|
mkdir -p tmp
|
|
27
|
-
gem build expect-pty.gemspec --output tmp/expect-pty-0.3.
|
|
28
|
-
gem install ./tmp/expect-pty-0.3.
|
|
29
|
+
gem build expect-pty.gemspec --output tmp/expect-pty-0.3.2.gem
|
|
30
|
+
gem install ./tmp/expect-pty-0.3.2.gem
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
```ruby
|
|
@@ -67,7 +69,7 @@ Expect.spawn("/bin/sh", "-i", timeout: 3) do |session|
|
|
|
67
69
|
end
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
`configure`
|
|
72
|
+
`configure` 校验后发布冻结的配置对象;块异常不会发布部分修改。并发调用按顺序完成读改写,不会互相覆盖不同属性。配置块在锁内执行,应保持简短,不要在块内再次调用 `configure`(会抛出 `ThreadError`)或等待其他配置线程。每个会话独立持有配置,优先使用构造参数;修改默认值不会改变已有会话,修改一个会话也不会影响其他会话。子类继承父类默认配置,可独立覆盖。
|
|
71
73
|
|
|
72
74
|
| 属性 | 默认值 | 行为 |
|
|
73
75
|
| --- | --- | --- |
|
|
@@ -187,6 +189,8 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
|
|
|
187
189
|
| `winsize` / `winsize=` | 读取/修改 `[rows, cols]`,由内核通知前台进程 |
|
|
188
190
|
| `slave` / `tty_name` / `to_io` / `writer` / `fileno` / `tty?` | 底层 IO 和终端信息 |
|
|
189
191
|
|
|
192
|
+
`send_slow` 在每次写入后只检查已经可读的回复,不附加固定等待;返回时不保证收齐最后一个字符引发的回复,完整对话请继续使用 `expect`。
|
|
193
|
+
|
|
190
194
|
大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。超过 `write_timeout` 抛出 `Expect::WriteTimeout`,`error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的 `EINTR` 均保留原期限重试。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、`__send__` 保留 Ruby 反射语义。
|
|
191
195
|
|
|
192
196
|
## 日志与人工交互
|
|
@@ -249,6 +253,13 @@ session.close(graceful: true) # 先软关闭,必要时继续硬关闭
|
|
|
249
253
|
|
|
250
254
|
关闭只负责会话直接启动的子进程;垃圾回收提供非阻塞的强制清理兜底,不执行软关闭等待。优先使用块或 `ensure` 管理资源。
|
|
251
255
|
|
|
256
|
+
## 安全注意事项
|
|
257
|
+
|
|
258
|
+
- 将不可信命令和参数分别传给 `spawn`,例如 `Expect.spawn("ssh", host)`;单个命令字符串会使用 Ruby 的 shell 语义。`stty` 参数经拆分后作为独立参数传给进程,不拼接 shell 命令。
|
|
259
|
+
- 会话日志可能记录密码回显、令牌和其他敏感字节。新日志文件以 `0600` 创建,已有文件保留原权限;请按需关闭 `log_to`、`log_stdout` 和调试输出,并管理日志留存。
|
|
260
|
+
- `spawn` 在子进程中使用 `fork` 后的 Ruby 操作与 `exec`。高度多线程的宿主进程,尤其使用第三方 C 扩展时,可能受到 fork 时其他线程持锁的影响;尽量在启动其他线程前创建会话,并在自己的运行环境中验证。
|
|
261
|
+
- 不可信正则可能耗费较长时间;使用带 `timeout:` 的 `Regexp` 实例,并为匹配缓冲设置合适的 `buffer_limit`。普通 `expect` 的 IO 期限不打断单次正则或同步回调。
|
|
262
|
+
|
|
252
263
|
## 示例和验证
|
|
253
264
|
|
|
254
265
|
```sh
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support"
|
|
4
|
+
|
|
5
|
+
runner = ExpectBenchmark::Runner.new("matching")
|
|
6
|
+
reader, writer = IO.pipe
|
|
7
|
+
session = Expect.open(reader, log_stdout: false)
|
|
8
|
+
begin
|
|
9
|
+
sizes = runner.smoke ? [4096] : [4096, 65_536, 1_048_576]
|
|
10
|
+
sizes.product([1, 8, 32], %i[first last miss]).each do |size, count, hit|
|
|
11
|
+
session.buffer = "#{"x" * (size - 3)}END"
|
|
12
|
+
patterns = Array.new(count) { |index| /missing#{index}/ }
|
|
13
|
+
index = hit == :first ? 0 : count - 1
|
|
14
|
+
patterns[index] = /END/ unless hit == :miss
|
|
15
|
+
matcher = Expect::Matcher.new(Expect::PatternList.new([session], patterns), 0)
|
|
16
|
+
verify = lambda do |result|
|
|
17
|
+
valid = if hit == :miss
|
|
18
|
+
result.nil?
|
|
19
|
+
else
|
|
20
|
+
result && result[0].equal?(session) && result[1].number == index + 1 && result[2] == [size - 3, 3, []]
|
|
21
|
+
end
|
|
22
|
+
ExpectBenchmark.check(valid)
|
|
23
|
+
end
|
|
24
|
+
runner.measure("scan/#{size}/#{count}/#{hit}", bytes: size, inputs: { size: size, patterns: count, hit: hit },
|
|
25
|
+
verify: verify) { matcher.__send__(:find_match) }
|
|
26
|
+
end
|
|
27
|
+
sizes.each do |size|
|
|
28
|
+
prefix = "中" * (size / 3)
|
|
29
|
+
session.buffer = "#{prefix}😀终tail"
|
|
30
|
+
matcher = Expect::Matcher.new(Expect::PatternList.new([session], [/😀(终)(x)?/]), 0)
|
|
31
|
+
verify = lambda do |result|
|
|
32
|
+
ExpectBenchmark.check(result && result[2] == [prefix.bytesize, 7, ["终".b, nil]])
|
|
33
|
+
end
|
|
34
|
+
runner.measure("utf8/#{size}", bytes: session.buffer.bytesize, inputs: { prefix_bytes: prefix.bytesize },
|
|
35
|
+
verify: verify) { matcher.__send__(:find_match) }
|
|
36
|
+
end
|
|
37
|
+
ensure
|
|
38
|
+
session.close
|
|
39
|
+
reader.close
|
|
40
|
+
writer.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
[1, 8, 32].each do |count|
|
|
44
|
+
pipes = Array.new(count) { IO.pipe }
|
|
45
|
+
sessions = pipes.map { |input, _| Expect.open(input, log_stdout: false) }
|
|
46
|
+
begin
|
|
47
|
+
sessions.each { |source| source.buffer = "x" * 4096 }
|
|
48
|
+
list = Expect::PatternList.new
|
|
49
|
+
# 每轮交替反转来源组,确保同一会话真实出现在多个非相邻组。
|
|
50
|
+
8.times { |index| list.on(/missing/, from: index.even? ? sessions : sessions.reverse) }
|
|
51
|
+
# 单会话的相邻相同组会合并;保持普通单会话路径作为对照。
|
|
52
|
+
matcher = Expect::Matcher.new(list, 0)
|
|
53
|
+
runner.measure("groups/#{count}", bytes: 4096 * count, inputs: { sessions: count, groups: list.groups.size },
|
|
54
|
+
verify: lambda { |result|
|
|
55
|
+
ExpectBenchmark.check(result.nil?)
|
|
56
|
+
}) { matcher.__send__(:find_match) }
|
|
57
|
+
matcher.run
|
|
58
|
+
verify = lambda do |result|
|
|
59
|
+
ExpectBenchmark.check(result == :retry && sessions.all? { |source| source.buffer == "r" })
|
|
60
|
+
end
|
|
61
|
+
runner.measure("ready/#{count}", bytes: count, inputs: { sessions: count }, verify: verify) do
|
|
62
|
+
sessions.each(&:clear_buffer)
|
|
63
|
+
pipes.each { |pipe| pipe.last.write("r") }
|
|
64
|
+
ready = IO.select(pipes.map(&:first), nil, nil, 0).first
|
|
65
|
+
matcher.__send__(:read_ready, ready, sessions)
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
sessions.each(&:close)
|
|
69
|
+
pipes.flatten.each(&:close)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
runner.finish
|
data/benchmark/relay.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support"
|
|
4
|
+
|
|
5
|
+
runner = ExpectBenchmark::Runner.new("relay")
|
|
6
|
+
reader, writer = IO.pipe
|
|
7
|
+
session = Expect.open(reader, log_stdout: false)
|
|
8
|
+
begin
|
|
9
|
+
size = runner.smoke ? 4096 : 65_536
|
|
10
|
+
payload = "x" * size
|
|
11
|
+
%i[none literal regexps].each do |kind|
|
|
12
|
+
session.__send__(:sequences=, {})
|
|
13
|
+
session.on_sequence("STOP") if kind == :literal
|
|
14
|
+
16.times { |index| session.on_sequence(/missing#{index}/) } if kind == :regexps
|
|
15
|
+
output = StringIO.new("".b)
|
|
16
|
+
session.listeners = [output]
|
|
17
|
+
verify = ->(result) { ExpectBenchmark.check(result == true && output.string == payload) }
|
|
18
|
+
runner.measure("escape/#{kind}", bytes: size, inputs: { size: size, regexps: kind == :regexps ? 16 : 0 },
|
|
19
|
+
verify: verify) do
|
|
20
|
+
output.string = "".b
|
|
21
|
+
session.__send__(:relay_history).clear
|
|
22
|
+
Expect.__send__(:relay_buffer, session, { session => payload.b })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
session.__send__(:sequences=, {})
|
|
27
|
+
writer.close
|
|
28
|
+
normal = StringIO.new("".b)
|
|
29
|
+
slow = StringIO.new("".b)
|
|
30
|
+
slow.define_singleton_method(:write) { |data| super(data.byteslice(0, 17)) }
|
|
31
|
+
session.listeners = [normal, slow]
|
|
32
|
+
verify = lambda do |result|
|
|
33
|
+
ExpectBenchmark.check(result.equal?(session) && normal.string == payload && slow.string == payload)
|
|
34
|
+
ExpectBenchmark.check(!session.pending_output? && session.buffer.empty?)
|
|
35
|
+
end
|
|
36
|
+
runner.measure("mixed_targets", bytes: size * 2, inputs: { size: size, short_write: 17 }, iterations: 5,
|
|
37
|
+
verify: verify) do
|
|
38
|
+
normal.string = "".b
|
|
39
|
+
slow.string = "".b
|
|
40
|
+
session.buffer = payload
|
|
41
|
+
Timeout.timeout(10) { Expect.interconnect(session, timeout: 5) }
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
session.close
|
|
45
|
+
reader.close
|
|
46
|
+
writer.close unless writer.closed?
|
|
47
|
+
end
|
|
48
|
+
runner.finish
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "support"
|
|
4
|
+
|
|
5
|
+
runner = ExpectBenchmark::Runner.new("send_slow")
|
|
6
|
+
size = runner.smoke ? 8 : 128
|
|
7
|
+
payload = "x" * size
|
|
8
|
+
[false, true].product([0, 0.001]).each do |echo, delay|
|
|
9
|
+
verify = lambda do |result|
|
|
10
|
+
count, received, reply = result
|
|
11
|
+
ExpectBenchmark.check(count == size && received == payload && reply == (echo ? payload : nil))
|
|
12
|
+
end
|
|
13
|
+
runner.measure("send/#{echo ? "echo" : "silent"}/#{delay}", bytes: size,
|
|
14
|
+
inputs: { size: size, echo: echo, delay: delay },
|
|
15
|
+
iterations: 1, verify: verify) do
|
|
16
|
+
client, peer = Socket.pair(:UNIX, :STREAM, 0)
|
|
17
|
+
session = Expect.open(client, log_stdout: false)
|
|
18
|
+
consumer = Thread.new do
|
|
19
|
+
received = "".b
|
|
20
|
+
while received.bytesize < size
|
|
21
|
+
data = peer.readpartial(size)
|
|
22
|
+
received << data
|
|
23
|
+
peer.write(data) if echo
|
|
24
|
+
end
|
|
25
|
+
received
|
|
26
|
+
end
|
|
27
|
+
begin
|
|
28
|
+
Timeout.timeout(10) do
|
|
29
|
+
count = session.send_slow(payload, delay: delay)
|
|
30
|
+
reply = session.expect_result(payload, timeout: 2).match if echo
|
|
31
|
+
[count, consumer.value, reply]
|
|
32
|
+
end
|
|
33
|
+
ensure
|
|
34
|
+
consumer.kill.join
|
|
35
|
+
session.close
|
|
36
|
+
client.close
|
|
37
|
+
peer.close
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
runner.finish
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "socket"
|
|
9
|
+
require "timeout"
|
|
10
|
+
|
|
11
|
+
# 标准库基准入口;每个工作负载先验证结果,采样后再核对,计时不包含断言。
|
|
12
|
+
module ExpectBenchmark
|
|
13
|
+
# 保存环境、输入规模、耗时、分配与 GC 样本,便于比较不同源码目录。
|
|
14
|
+
class Runner
|
|
15
|
+
attr_reader :smoke, :library
|
|
16
|
+
|
|
17
|
+
def initialize(name)
|
|
18
|
+
@library = File.expand_path("../lib", __dir__)
|
|
19
|
+
@samples = 5
|
|
20
|
+
@iterations = nil
|
|
21
|
+
@smoke = false
|
|
22
|
+
@output = "tmp/benchmark/#{name}.json"
|
|
23
|
+
OptionParser.new do |options|
|
|
24
|
+
options.banner = "Usage: ruby benchmark/#{name}.rb [options]"
|
|
25
|
+
options.on("--smoke", "Small correctness workload") { @smoke = true }
|
|
26
|
+
options.on("--library PATH", "Compare another checkout's lib directory") do |path|
|
|
27
|
+
@library = File.expand_path(path)
|
|
28
|
+
end
|
|
29
|
+
options.on("--samples N", Integer) { |count| @samples = count }
|
|
30
|
+
options.on("--iterations N", Integer) { |count| @iterations = count }
|
|
31
|
+
options.on("--output PATH") { |path| @output = path }
|
|
32
|
+
end.parse!
|
|
33
|
+
unless @samples.positive? && (!@iterations || @iterations.positive?)
|
|
34
|
+
raise ArgumentError, "counts must be positive"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
require File.join(@library, "expect")
|
|
38
|
+
@results = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def measure(name, bytes:, inputs:, verify:, iterations: 100, &operation)
|
|
42
|
+
iterations = @iterations || (smoke ? 1 : iterations)
|
|
43
|
+
samples = smoke ? 1 : @samples
|
|
44
|
+
verify.call(operation.call) # 同时预热;错误输出永远不能成为更快的样本。
|
|
45
|
+
measurements = Array.new(samples) do
|
|
46
|
+
GC.start
|
|
47
|
+
before = GC.stat
|
|
48
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
result = nil
|
|
50
|
+
iterations.times { result = operation.call }
|
|
51
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
52
|
+
after = GC.stat
|
|
53
|
+
verify.call(result)
|
|
54
|
+
{
|
|
55
|
+
seconds: elapsed, allocated_objects: after[:total_allocated_objects] - before[:total_allocated_objects],
|
|
56
|
+
gc_count: after[:count] - before[:count], processed_bytes: bytes * iterations
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
@results << { name: name, inputs: inputs, iterations: iterations, samples: measurements }
|
|
60
|
+
puts "#{name}: #{format("%.6f", measurements.map { |row| row[:seconds] }.sort[samples / 2])}s"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def finish
|
|
64
|
+
root = File.dirname(library)
|
|
65
|
+
sha = git_output(root, "rev-parse", "HEAD")
|
|
66
|
+
dirty = git_output(root, "status", "--porcelain", "--untracked-files=all")
|
|
67
|
+
digest = Digest::SHA256.new
|
|
68
|
+
Dir[File.join(library, "**/*.rb")].each do |path|
|
|
69
|
+
digest << path.delete_prefix(library) << File.binread(path)
|
|
70
|
+
end
|
|
71
|
+
report = {
|
|
72
|
+
ruby: RUBY_DESCRIPTION, platform: RUBY_PLATFORM, revision: sha&.strip,
|
|
73
|
+
dirty: dirty.nil? ? nil : !dirty.empty?, library_sha256: digest.hexdigest, smoke: smoke, results: @results
|
|
74
|
+
}
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
|
76
|
+
File.write(@output, "#{JSON.pretty_generate(report)}\n")
|
|
77
|
+
puts "Saved #{@output}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# 安装包和源码归档可能没有 Git;未知状态记录为 nil,不误报为干净提交。
|
|
83
|
+
def git_output(root, *)
|
|
84
|
+
output, _, status = Open3.capture3("git", "-C", root, *)
|
|
85
|
+
output if status.success?
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.check(condition, message = "incorrect benchmark output")
|
|
92
|
+
raise message unless condition
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 内部状态与数据归属
|
|
2
|
+
|
|
3
|
+
这些状态供 Matcher、Relay 和会话生命周期协作使用,不是公共接口。
|
|
4
|
+
|
|
5
|
+
跨对象调用使用 `__send__` 访问受保护或私有方法。变更以下入口时,应同时检查调用方与失败恢复路径:
|
|
6
|
+
|
|
7
|
+
| 会话内部入口 | 使用方 | 不变量 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `reset_result`、`record_match`、`record_eof`、`record_error` | Matcher | 先保存结果再执行回调;错误保留原始输入 |
|
|
10
|
+
| `read_available` | Matcher、Relay、写入背压 | 转接缓冲交接时禁止直接传播和裁剪;普通匹配缓冲需要裁剪 |
|
|
11
|
+
| `interaction_buffer`、`restore_relay_buffer` | Matcher、Relay | 嵌套匹配及转接退出时把未消费字节交还原所有者 |
|
|
12
|
+
| `sequences`、`relay_history`、`relay_callback` | Relay、转义扫描 | 已识别转义只回调一次,已发送前缀不重放 |
|
|
13
|
+
| `queue_output`、`relay_outputs`、`propagate` | Relay、转义扫描 | 每个目标独立保存短写进度;同步传播只用于普通匹配 |
|
|
14
|
+
|
|
15
|
+
| 状态 | 所有者与修改入口 | 交接、超时与关闭 |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `@buffer` | 会话的未消费匹配输入;`read_available` 追加,`record_match` 消费,`clear_buffer` 移交 | Matcher 用公开 `buffer` 的副本扫描;一次扫描的重复来源复用副本,下轮重新获取。超时保留字节。关闭不清空它,便于检查尾部 |
|
|
18
|
+
| `@interaction_buffer` | Relay 当前持有的待处理输入;转接背压读取也追加到这里 | 转义回调内的 Matcher 临时移回 `@buffer`;Matcher 的 ensure 将余下字节交回 Relay。Relay 的 ensure 恢复上层交互状态,并把未处理字节还给普通缓冲 |
|
|
19
|
+
| `@relay_outputs` | 源会话持有的各目标发送游标;`queue_output` 创建,RelayWriter 推进 | 超时或写失败后保留原目标和已交付位置;重入续发,不重放成功前缀。关闭放弃剩余交付并清空队列,不关闭借用目标 |
|
|
20
|
+
| `@relay_history` | 源会话的正则转义历史窗口;`relay_buffer` 更新 | 同一规则跨次转接保留,规则改变或转义消费后清空。窗口遵守 buffer_limit 或内部默认上限,固定 UTF-8 正则不保留开头的孤立续字节。关闭清空 |
|
|
21
|
+
| `@relay_callback` | 已识别转义但尚未交付完前缀时,源会话暂存的回调 | 所有前缀目标交付完成后执行一次;超时保留,关闭释放。不能在等待写入后重新扫描这段已识别转义 |
|
|
22
|
+
|
|
23
|
+
匹配优先级始终是“声明组 → 会话 → 模式”,不是文本位置;多个会话包装同一 IO 时,就绪处理使用对象身份选择首个声明会话。回调前后用于判断缓冲是否变化的快照仍独立保留,不能换成可变内部字符串。零长度或 preserve_buffer 的继续匹配必须遵守 stalled 保护。
|
|
24
|
+
|
|
25
|
+
转义扫描只在一轮内复用 `history + buffer`,只含字面规则时不构造它。回调继续后重新读取规则、历史和缓冲;不能跨回调保存文本快照。字面转义的潜在前缀暂存,完整前缀交付后才执行转义回调。
|
|
26
|
+
|
|
27
|
+
## 关闭与所有权
|
|
28
|
+
|
|
29
|
+
SessionResources 保存创建者 PID、所属句柄、直属子进程和所属日志。借用 IO 不关闭,fork 后的非创建者不发送信号或回收父进程的孩子。soft_close 最多发送 TERM 并保留未退出 PID;hard_close 可在有限等待后发送 KILL。
|
|
30
|
+
|
|
31
|
+
显式关闭遇到 IOError/SystemCallError 时,继续尝试其他句柄、交互包装器、日志和子进程清理,最后传播首个清理错误;已经在传播的其他异常保留。失败资源继续持有,后续关闭可重试。GC 终结器独立尝试句柄、日志、非阻塞回收,常规清理错误不向外传播。终结器不能强引用会话本身。
|
|
32
|
+
|
|
33
|
+
## 回归入口
|
|
34
|
+
|
|
35
|
+
| 不变量 | 测试 |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| 部分关闭失败、错误保留、借用 IO、非 owner、软硬关闭和 GC | cleanup_test、process_test、edge_case_test |
|
|
38
|
+
| 字节偏移、二进制和 UTF-8 分片、零长度、可选捕获 | pattern_offset_test、matching_test、ruby_api_test、edge_case_test |
|
|
39
|
+
| 一轮快照、同 IO 首来源、回调换规则、嵌套匹配 | scan_reuse_test、multi_session_test、interconnect_test |
|
|
40
|
+
| 短写、转义拆包、超时恢复、不重放、回调前缀顺序 | relay_recovery_test、interconnect_test |
|
|
41
|
+
| 无限/零/有限超时、继续重置/保留、EINTR、接收重置 | timeout_test(控制输入到达时间)、edge_case_test |
|
|
42
|
+
| 总期限和目标写期限、连续中断不延长写超时 | relay_recovery_test |
|
|
43
|
+
|
|
44
|
+
保留独立的 Matcher、Relay、write、wait 期限合同;仅为去重而统一事件循环会扩大上述状态交接的影响范围。
|
data/docs/PERFORMANCE.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 性能基准
|
|
2
|
+
|
|
3
|
+
从源码目录安装开发依赖后运行,基准本身仅使用标准库:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bundle exec ruby benchmark/matching.rb
|
|
7
|
+
bundle exec ruby benchmark/relay.rb
|
|
8
|
+
bundle exec ruby benchmark/send_slow.rb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`,该目录不提交。每个场景先核对非空输入的预期结果,再计时;每轮计时后再次核对最后一次执行的结果。输出包括 Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输入规模、迭代次数、处理字节数、墙钟耗时、分配对象数和 GC 次数。
|
|
12
|
+
|
|
13
|
+
## 工作负载与边界
|
|
14
|
+
|
|
15
|
+
| 脚本 | 范围 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| matching | 4 KiB、64 KiB、1 MiB;1、8、32 个正则;首个命中、末个命中、全未命中;UTF-8 前缀和可选捕获;1、8、32 个会话、多组重复来源和同时就绪的真实管道 |
|
|
18
|
+
| relay | 无转义、字面转义、16 个正则转义;正常目标与每次只接受 17 字节的目标共同接收相同数据 |
|
|
19
|
+
| send_slow | 真实本地 socket;无回显、持续回显;零延迟和每字符 1ms 延迟 |
|
|
20
|
+
|
|
21
|
+
`matching` 的扫描用内部 `find_match` 单独度量缓冲扫描,排除 PTY 启动和回调开销;就绪场景包含管道写入、选择和读取。`relay` 的前三项单独度量转义扫描,混合目标项运行完整转接循环。短写模拟目标吞吐受限,不等同于真实慢网络。`send_slow` 包含 socket、接收线程和完整回显校验的成本;无回显时计时止于接收方收齐数据。
|
|
22
|
+
|
|
23
|
+
处理字节数表示每轮提供给场景的输入字节数(混合目标为两份交付量),不是正则引擎实际访问内存的次数。分配量是整个 Ruby 进程的计数,socket 场景包含接收线程的分配。基准不提供 CPU 或网络隔离;耗时波动不能直接归因于代码变化。
|
|
24
|
+
|
|
25
|
+
## 同环境对照
|
|
26
|
+
|
|
27
|
+
对照目录必须包含已知提交的完整源码。两个版本使用同一套基准脚本、同一 Ruby 和依赖,在机器空闲时交替运行,比较原始样本的中位数与分配量:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
bundle exec ruby benchmark/matching.rb --library /path/to/baseline/lib --samples 5 --output tmp/benchmark/before.json
|
|
31
|
+
bundle exec ruby benchmark/matching.rb --samples 5 --output tmp/benchmark/after.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
另外两份脚本支持相同参数。`--iterations N` 可增加单个样本工作量;比较双方必须使用相同参数。源码 SHA-256 用于区分同一提交上的未提交修改。修改工作负载后,应对两个版本重新取样。
|
|
35
|
+
|
|
36
|
+
没有 Git 的源码归档或安装目录仍可运行;提交和工作区状态记为 `null`,保留源码 SHA-256。
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bundle exec ruby benchmark/matching.rb --smoke
|
|
40
|
+
bundle exec ruby benchmark/relay.rb --smoke
|
|
41
|
+
bundle exec ruby benchmark/send_slow.rb --smoke
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`script/ci` 运行这些小规模正确性检查,不设置墙钟性能阈值。热点优化必须有实际收益证据;减少对象分配不代表所有输入都会变快,也不能替代完整测试和安装验证。
|
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.2`。
|
|
8
|
+
2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.3.2 - 2026-09-26`;可以保留空的 `Unreleased` 标题。
|
|
9
9
|
3. 提交源码,发布时工作区必须干净。若同时发布 GitHub Release,还需推送到 `main`,远端 `main` 必须包含该提交,已有同名标签必须指向该提交。
|
|
10
10
|
|
|
11
11
|
发布脚本只接受正式版 `X.Y.Z`;未归档的变更会阻止发布。
|
|
@@ -40,9 +40,9 @@ ruby script/release.rb
|
|
|
40
40
|
GitHub Runner 不会继承本机的 Gem 登录状态。要在 Actions 发布 RubyGems,需在仓库的 Settings → Secrets and variables → Actions 中配置 `RUBYGEMS_API_KEY`,使用具有 `Push rubygem` 权限的发布 Key。
|
|
41
41
|
|
|
42
42
|
```sh
|
|
43
|
-
git tag -a v0.3.
|
|
44
|
-
git push origin v0.3.
|
|
45
|
-
gh workflow run release.yml --ref v0.3.
|
|
43
|
+
git tag -a v0.3.2 -m 'Release v0.3.2'
|
|
44
|
+
git push origin v0.3.2
|
|
45
|
+
gh workflow run release.yml --ref v0.3.2 --repo gatework/expect-ruby
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
也可以在 Actions → Release → Run workflow 选择对应版本标签。工作流仅支持手动触发,避免本地发布时出现第二次并发上传。
|
|
@@ -56,7 +56,7 @@ gh workflow run release.yml --ref v0.3.1 --repo gatework/expect-ruby
|
|
|
56
56
|
保留脚本输出的 `Artifact` 路径,用该候选 Gem 重试发布;更换 RubyGems 工具版本或重新构建可能得到不同字节,同一个版本不得覆盖已有内容。也可以直接指定从 CI 或 Release 下载的原包:
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
|
-
ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.
|
|
59
|
+
ruby script/release.rb --rubygems-only --artifact tmp/ci/expect-pty-0.3.2.gem
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
将示例路径替换为实际输出的 `Artifact` 路径。`--artifact` 会跳过构建和测试,但仍核对包与当前源码是否一致;需要同时恢复 GitHub Release 时去掉 `--rubygems-only`。
|
data/docs/VERIFICATION.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# 验证记录
|
|
2
2
|
|
|
3
|
+
## 配置并发与模块审查改进(2026-09-26)
|
|
4
|
+
|
|
5
|
+
当前工作树在既有未提交的匹配、转接和清理改动上,增加 `configure` 的串行读改写与回归测试:旧实现的并发用例先失败,修复后通过;嵌套调用显式抛出 `ThreadError`,不会陷入死锁或发布部分配置。`read_available` 的裁剪选择改为显式参数,转接调用明确禁止裁剪其待处理缓冲。RuboCop 对生产代码启用分支复杂度检查,既有状态机只在对应方法局部豁免;补充内部调用契约、贡献指南、issue/PR 模板与安全说明。
|
|
6
|
+
|
|
7
|
+
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 重复常量警告仍存在,但命令成功完成。
|
|
8
|
+
|
|
9
|
+
本轮未运行 Linux/Ruby 3.2、远端 CI 或真实 SSH;未提交、推送或发布。下文历史结果不作为本轮检查证据。
|
|
10
|
+
|
|
3
11
|
## 0.3.0 发布前复核(2026-09-26)
|
|
4
12
|
|
|
5
13
|
以 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
|
@@ -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
|
|