expect-pty 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eda03bcece07a45c1c1f4f4010ae389928db6954b44624d072ce0e4249ee5a4d
4
- data.tar.gz: df16127b3002243113a3f4bb6f1945ebb5430a4a1ba614e1926f5c4be17c6a0b
3
+ metadata.gz: fb3b136c8eba8bbce544e17de1d65fd533e0bdd96a57be3c32ae070dfb7a4dfb
4
+ data.tar.gz: 1133f3426854d0d5f7b9c3695d571fae273ae77bf39e3b218b17d683f5a677e2
5
5
  SHA512:
6
- metadata.gz: 0aab6a9174549cac6cbdfd33eb658b4027c6509bc9a8f246d48d76329fd9c8594e38bfc0f03430f2121bbdc82b7810f89b329c5f05051ea2657c2d0498e12825
7
- data.tar.gz: d819201280329ce39678db25c32d4657336b2e2e0288945285377b90d309e30aa616a60847024a1aeccf157eedbfd32165e6b01caba7e4c8c66b357c72656e3b
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
- # Protocol state machines and integration scenarios are reviewed by behavior,
11
- # rather than split merely to meet a line or branch count.
12
- Metrics:
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,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.2 - 2026-09-26
6
+
7
+ - **匹配与转接**:正则直接使用字节偏移,重复来源在单轮扫描中共享缓冲快照;转义正则复用本轮组合文本,保留回调修改规则后的重新扫描语义。`send_slow` 只读取已经可读的回复。
8
+ - **配置与清理**:并发 `Expect.configure` 串行发布,避免丢失更新;关闭句柄或日志失败时继续清理其他资源和直属子进程,并保留首个清理错误。转接读取显式保留未交付缓冲。
9
+ - **维护与验证**:增加状态归属文档、性能基准及 smoke、分支复杂度检查、贡献指南和安全说明;补充匹配、转接、清理及并发配置回归测试。
10
+
11
+ ## 0.3.1 - 2026-09-26
12
+
13
+ - **安装依赖**:显式声明五个运行时标准库 gem,开发工具依赖单独管理,并补齐应用 Gemfile 中的 `require: "expect/pty"` 用法。
14
+ - **本地发布**:新增 `--rubygems-only`,可复用本机 Gem 登录单独发布 RubyGems,构建包和可重试的发布候选包统一保存到 `tmp/`。
15
+ - **安装验证**:增加普通安装和最小 Bundler 应用的依赖及 PTY 检查,发布测试覆盖本地与 CI 环境,不需要真实发布凭据。
16
+
5
17
  ## 0.3.0 - 2026-09-26
6
18
 
7
19
  - **终端模式调整**:通用 `interconnect` 改由调用方设置和恢复终端模式,需要自动切换本地输入终端时使用 `interact`,直接转接终端的现有代码需要相应调整。
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
@@ -3,8 +3,25 @@
3
3
  source "https://rubygems.org"
4
4
  gemspec
5
5
 
6
- gem "minitest", "~> 5.0"
7
- # RuboCop's worker dependency must also support the minimum Ruby version.
8
- gem "parallel", "~> 1.27", require: false
9
- gem "rake", "~> 13.0"
10
- gem "rubocop", "~> 1.89", require: false
6
+ group :development, :test do
7
+ gem "minitest", "~> 5.0", require: false
8
+ # RuboCop 的依赖也必须支持最低 Ruby 版本。
9
+ gem "parallel", "~> 1.27", require: false
10
+ gem "rake", "~> 13.0", require: false
11
+ # 固定已审阅的规则集,避免不同 CI 环境自动启用新 cop 改变发布门槛。
12
+ gem "rubocop", "= 1.89.0", require: false
13
+
14
+ # 测试、示例和发布工具直接使用的标准库 gem,不依赖其他开发工具间接引入。
15
+ gem "digest", require: false
16
+ gem "etc", require: false
17
+ gem "fileutils", require: false
18
+ gem "json", require: false
19
+ gem "net-http", require: false
20
+ gem "open3", require: false
21
+ gem "optparse", require: false
22
+ gem "securerandom", require: false
23
+ gem "tempfile", require: false
24
+ gem "time", require: false
25
+ gem "timeout", require: false
26
+ gem "tmpdir", require: false
27
+ end
data/README.md CHANGED
@@ -4,27 +4,30 @@
4
4
 
5
5
  用 Ruby 自动操作交互式程序:启动拥有控制终端的子进程,等待文本或正则,发送输入,处理超时、EOF 和回调,也能接管已有 IO、同时监听多个会话和转接人工交互。交互能力参考 [Expect.pm](https://github.com/jacoby/expect.pm),接口采用 Ruby 的属性、关键字参数和代码块。
6
6
 
7
- 要求 **Ruby 3.2+、POSIX 系统(Linux/macOS)**。运行时仅使用 Ruby 标准库。推荐入口 **`require "expect/pty"`**;本项目提供独立的 `Expect` 类,不修改标准库的 `IO#expect`。
7
+ 要求 **Ruby 3.2+、POSIX 系统(Linux/macOS)**。运行时仅使用 Ruby 标准库,其中可独立安装的 gem 已在 gemspec 中声明,由 RubyGems/Bundler 解析。推荐入口 **`require "expect/pty"`**;本项目提供独立的 `Expect` 类,不修改标准库的 `IO#expect`。
8
+
9
+ 源码中的解释性注释主要使用中文;欢迎用中文或英文提交 issue 和 PR,参与方式见 [贡献指南](CONTRIBUTING.md)。
8
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.2.0"
16
+ gem "expect-pty", "~> 0.3.2", require: "expect/pty"
15
17
  ```
16
18
 
17
19
  也可直接执行 `gem install expect-pty`。需要跟随开发分支时,可从 GitHub 安装:
18
20
 
19
21
  ```ruby
20
- gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "main"
22
+ gem "expect-pty", git: "https://github.com/gatework/expect-ruby.git", branch: "main", require: "expect/pty"
21
23
  ```
22
24
 
23
- 本地开发可改用 `gem "expect-pty", path: "/path/to/expect-ruby"`,也可在源码目录构建安装:
25
+ 本地开发可改用 `gem "expect-pty", path: "/path/to/expect-ruby", require: "expect/pty"`,也可在源码目录构建安装:
24
26
 
25
27
  ```sh
26
- gem build expect-pty.gemspec
27
- gem install ./expect-pty-0.2.0.gem
28
+ mkdir -p tmp
29
+ gem build expect-pty.gemspec --output tmp/expect-pty-0.3.2.gem
30
+ gem install ./tmp/expect-pty-0.3.2.gem
28
31
  ```
29
32
 
30
33
  ```ruby
@@ -66,7 +69,7 @@ Expect.spawn("/bin/sh", "-i", timeout: 3) do |session|
66
69
  end
67
70
  ```
68
71
 
69
- `configure` 校验后发布冻结的配置对象;块异常不会发布部分修改。每个会话独立持有配置,优先使用构造参数;修改默认值不会改变已有会话,修改一个会话也不会影响其他会话。子类继承父类默认配置,可独立覆盖。
72
+ `configure` 校验后发布冻结的配置对象;块异常不会发布部分修改。并发调用按顺序完成读改写,不会互相覆盖不同属性。配置块在锁内执行,应保持简短,不要在块内再次调用 `configure`(会抛出 `ThreadError`)或等待其他配置线程。每个会话独立持有配置,优先使用构造参数;修改默认值不会改变已有会话,修改一个会话也不会影响其他会话。子类继承父类默认配置,可独立覆盖。
70
73
 
71
74
  | 属性 | 默认值 | 行为 |
72
75
  | --- | --- | --- |
@@ -186,6 +189,8 @@ ready = Expect.readable_sessions(first, second, timeout: 5)
186
189
  | `winsize` / `winsize=` | 读取/修改 `[rows, cols]`,由内核通知前台进程 |
187
190
  | `slave` / `tty_name` / `to_io` / `writer` / `fileno` / `tty?` | 底层 IO 和终端信息 |
188
191
 
192
+ `send_slow` 在每次写入后只检查已经可读的回复,不附加固定等待;返回时不保证收齐最后一个字符引发的回复,完整对话请继续使用 `expect`。
193
+
189
194
  大块写入遇到背压时同时读取输出,避免双向传输互相阻塞。超过 `write_timeout` 抛出 `Expect::WriteTimeout`,`error.bytes_written` 给出本次 `write` 已被底层接受的字节数;这些字节不回滚,不要从头重发整个命令。写入、等待和背压读取中的 `EINTR` 均保留原期限重试。控制字符可直接发送,例如 `session.write("\x03")`,其信号作用取决于终端设置。`send`、`public_send`、`__send__` 保留 Ruby 反射语义。
190
195
 
191
196
  ## 日志与人工交互
@@ -248,6 +253,13 @@ session.close(graceful: true) # 先软关闭,必要时继续硬关闭
248
253
 
249
254
  关闭只负责会话直接启动的子进程;垃圾回收提供非阻塞的强制清理兜底,不执行软关闭等待。优先使用块或 `ensure` 管理资源。
250
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
+
251
263
  ## 示例和验证
252
264
 
253
265
  ```sh
@@ -265,7 +277,18 @@ ruby examples/ssh_interact.rb --auto
265
277
 
266
278
  [GitHub Actions](https://github.com/gatework/expect-ruby/actions/workflows/ci.yml) 在推送 `main`、推送 `v*` 标签、提交到 `main` 的 Pull Request 或手动触发时运行。流水线覆盖 Ubuntu 24.04 / macOS 15 与 Ruby 3.2、3.3、3.4、4.0 的 8 种组合;每个环境执行 `script/ci`,包括真实 PTY 测试和构建包的隔离安装验证。Ubuntu / Ruby 4.0 作业保留已验证的 Gem 构建产物 14 天,可从该次工作流的 Artifacts 下载。
267
279
 
268
- 运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的 `tmp/`,Gem 构建产物位于 `tmp/ci/`,发布候选包位于 `tmp/release/`。更新工作流中的 Action 时,应同步更新固定的提交 SHA 和版本注释。
280
+ 运行前先执行 `bundle install`。Gem 库的开发锁文件 `Gemfile.lock` 保留在本地,各 Ruby 环境按 `Gemfile` 解析兼容依赖。生成文件写入已忽略的 `tmp/`,Gem 构建产物位于 `tmp/ci/`,发布候选包位于 `tmp/release/`。安装验证会清除外部 Bundler 环境,分别运行普通 RubyGems 加载和只声明 `expect-pty` 的 Bundler 应用,检查运行时依赖、终端模式、窗口大小及真实 PTY 对话。更新工作流中的 Action 时,应同步更新固定的提交 SHA 和版本注释。
281
+
282
+ 运行时依赖只在 `expect-pty.gemspec` 声明,开发依赖放在 Gemfile 的 `development` / `test` 组;安装或使用本库不会引入 Minitest、Rake、RuboCop 及发布工具的依赖。
283
+
284
+ | 运行时模块 | Gem | 用途 |
285
+ | --- | --- | --- |
286
+ | `forwardable` | `forwardable` | 会话配置委托 |
287
+ | `io/console` | `io-console` | 终端模式和窗口大小 |
288
+ | `io/wait` | `io-wait` | IO 可读等待 |
289
+ | `shellwords` | `shellwords` | `stty` 参数拆分 |
290
+ | `stringio` | `stringio` | Ruby `puts` 语义 |
291
+ | `pty` | Ruby 自带扩展 | POSIX 伪终端,无独立 gem |
269
292
 
270
293
  仅发布 RubyGems 使用 `ruby script/release.rb --rubygems-only`,直接复用本机已有的 Gem 登录状态;添加 `--dry-run` 可先完成本地检查、测试、构建和安装验证。需要同时创建 GitHub Release 时使用 `ruby script/release.rb`,也可以在 GitHub Actions 手动运行 Release 工作流。版本准备、Actions 凭据和失败重试见 [发布说明](docs/RELEASING.md)。
271
294
 
@@ -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
@@ -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 期限合同;仅为去重而统一事件循环会扩大上述状态交接的影响范围。
@@ -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.2.0`。
8
- 2. 把 `CHANGELOG.md` 的 `Unreleased` 内容移到对应版本标题下,例如 `## 0.2.0 - 2026-09-13`;可以保留空的 `Unreleased` 标题。
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.2.0 -m 'Release v0.2.0'
44
- git push origin v0.2.0
45
- gh workflow run release.yml --ref v0.2.0 --repo gatework/expect-ruby
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.2.0 --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.0.gem
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、转义回调内匹配与异常尾部恢复、嵌套写入超时进度。新用例先在修复前失败,再验证修复;仅内部转义处理器的两项测试随职责调整更新调用方式。
@@ -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__