expect-pty 0.3.3 → 0.4.0

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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+
5
+ counts = [1, 16, 64]
6
+ runner = ExpectBenchmark::Runner.new("scaling") do |options|
7
+ options.on("--sessions N", Integer, "Measure one explicit session count (check the process fd limit)") do |count|
8
+ raise ArgumentError, "sessions must be positive" unless count.positive?
9
+
10
+ counts = [count]
11
+ end
12
+ end
13
+ counts = [1, 8] if runner.smoke
14
+ counts.each do |count|
15
+ if (count * 2) + 32 > Process.getrlimit(:NOFILE).first
16
+ raise ArgumentError, "#{count} sessions need a larger process fd limit; run ulimit -n before this benchmark"
17
+ end
18
+
19
+ pipes = []
20
+ sessions = []
21
+ begin
22
+ count.times do
23
+ pipes << IO.pipe
24
+ sessions << Expect.open(pipes.last.first)
25
+ end
26
+ marker = "ready\n"
27
+ ready_count = 0
28
+ list = Expect::PatternList.new(sessions)
29
+ list.on(marker) do
30
+ ready_count += 1
31
+ Expect.continue(reset_timeout: false) if ready_count < count
32
+ end
33
+ verify = lambda do |result|
34
+ ExpectBenchmark.check(result.matched? && ready_count == count && sessions.all? { |source| source.buffer.empty? })
35
+ end
36
+ runner.measure("all_ready/#{count}", bytes: count * marker.bytesize, inputs: { sessions: count },
37
+ iterations: 10, verify: verify) do
38
+ ready_count = 0
39
+ pipes.each { |pipe| pipe.last.write(marker) }
40
+ Timeout.timeout(10) { Expect::Matcher.new(list, 5).run }
41
+ end
42
+ ensure
43
+ sessions.each(&:close)
44
+ pipes.flatten.each { |io| io.close unless io.closed? }
45
+ end
46
+ end
47
+
48
+ # 真实管道填满后完全不消费;另一个来源的标记仍须被读到,重复进入不能增长描述符。
49
+ pipes = Array.new(3) { IO.pipe }
50
+ slow = Expect.open(pipes[0].first)
51
+ fast = Expect.open(pipes[1].first)
52
+ sink = pipes[2].last
53
+ begin
54
+ loop { break if sink.write_nonblock("x" * 4096, exception: false) == :wait_writable }
55
+ slow.listeners = [sink]
56
+ fast.on_sequence("PROBE")
57
+ verify = lambda do |result|
58
+ ExpectBenchmark.check(result.equal?(fast) && slow.pending_output? && fast.buffer.empty?)
59
+ end
60
+ runner.measure("blocked_target_probe", bytes: 5, inputs: { sources: 2, blocked_targets: 1 },
61
+ iterations: 100, verify: verify) do
62
+ slow.buffer = "blocked"
63
+ pipes[1].last.write("PROBE")
64
+ Timeout.timeout(10) { Expect.interconnect(slow, fast, timeout: 5) }
65
+ end
66
+ ensure
67
+ [slow, fast].each(&:close)
68
+ pipes.flatten.each { |io| io.close unless io.closed? }
69
+ end
70
+ runner.finish
data/benchmark/support.rb CHANGED
@@ -29,6 +29,7 @@ module ExpectBenchmark
29
29
  options.on("--samples N", Integer) { |count| @samples = count }
30
30
  options.on("--iterations N", Integer) { |count| @iterations = count }
31
31
  options.on("--output PATH") { |path| @output = path }
32
+ yield options if block_given?
32
33
  end.parse!
33
34
  unless @samples.positive? && (!@iterations || @iterations.positive?)
34
35
  raise ArgumentError, "counts must be positive"
@@ -44,6 +45,7 @@ module ExpectBenchmark
44
45
  verify.call(operation.call) # 同时预热;错误输出永远不能成为更快的样本。
45
46
  measurements = Array.new(samples) do
46
47
  GC.start
48
+ resources_before = resources
47
49
  before = GC.stat
48
50
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
51
  result = nil
@@ -53,7 +55,8 @@ module ExpectBenchmark
53
55
  verify.call(result)
54
56
  {
55
57
  seconds: elapsed, allocated_objects: after[:total_allocated_objects] - before[:total_allocated_objects],
56
- gc_count: after[:count] - before[:count], processed_bytes: bytes * iterations
58
+ gc_count: after[:count] - before[:count], processed_bytes: bytes * iterations,
59
+ resources_before: resources_before, resources_after: resources
57
60
  }
58
61
  end
59
62
  @results << { name: name, inputs: inputs, iterations: iterations, samples: measurements }
@@ -62,14 +65,18 @@ module ExpectBenchmark
62
65
 
63
66
  def finish
64
67
  root = File.dirname(library)
65
- sha = git_output(root, "rev-parse", "HEAD")
66
- dirty = git_output(root, "status", "--porcelain", "--untracked-files=all")
68
+ repository = git_output(root, "rev-parse", "--show-toplevel")&.strip
69
+ if repository && File.realpath(repository) == File.realpath(root)
70
+ sha = git_output(root, "rev-parse", "HEAD")
71
+ dirty = git_output(root, "status", "--porcelain", "--untracked-files=all")
72
+ end
67
73
  digest = Digest::SHA256.new
68
74
  Dir[File.join(library, "**/*.rb")].each do |path|
69
75
  digest << path.delete_prefix(library) << File.binread(path)
70
76
  end
71
77
  report = {
72
78
  ruby: RUBY_DESCRIPTION, platform: RUBY_PLATFORM, revision: sha&.strip,
79
+ fd_limit: Process.getrlimit(:NOFILE).first,
73
80
  dirty: dirty.nil? ? nil : !dirty.empty?, library_sha256: digest.hexdigest, smoke: smoke, results: @results
74
81
  }
75
82
  FileUtils.mkdir_p(File.dirname(@output))
@@ -79,6 +86,19 @@ module ExpectBenchmark
79
86
 
80
87
  private
81
88
 
89
+ # 资源采样在计时区间之外;RSS 是端点值,不冒充峰值。目录不可用时显式记录 nil。
90
+ def resources
91
+ directory = File.directory?("/proc/self/fd") ? "/proc/self/fd" : "/dev/fd"
92
+ descriptors = Dir.children(directory).size if File.directory?(directory)
93
+ rss = if File.file?("/proc/self/status")
94
+ File.read("/proc/self/status")[/^VmRSS:\s+(\d+)/, 1]&.to_i
95
+ else
96
+ output, _, status = Open3.capture3("ps", "-o", "rss=", "-p", Process.pid.to_s)
97
+ output.to_i if status.success?
98
+ end
99
+ { rss_bytes: rss && (rss * 1024), descriptors: descriptors }
100
+ end
101
+
82
102
  # 安装包和源码归档可能没有 Git;未知状态记录为 nil,不误报为干净提交。
83
103
  def git_output(root, *)
84
104
  output, _, status = Open3.capture3("git", "-C", root, *)
@@ -7,72 +7,81 @@
7
7
  以及 [`exp_inter.c`](https://core.tcl-lang.org/expect/raw/exp_inter.c?ci=trunk) 的 `intMatch` 与终端恢复流程。
8
8
  本库借鉴交互模型,保留以下明确差异,不承诺 Tcl 脚本或完整功能兼容。
9
9
 
10
- | Tcl Expect 能力 | 本库的 Ruby 表达与边界 |
11
- | --- | --- |
12
- | `spawn`、`send`、`expect`、`interact` | PTY 会话、`write` / `puts`、原生模式块和人工接管;`send` 保留 Ruby 反射语义 |
13
- | `exp_continue` / `-continue_timer` | `continue` / `continue(reset_timeout: false)`;使用单调时钟和秒数关键字参数 |
14
- | 匹配后消费输入、`-notransfer` | `before` / `match` / `after` 与会话级 `preserve_buffer`;无进展回调不会原地重复执行 |
15
- | glob、exact、regexp 模式 | 字符串只作字面匹配,正则采用 Ruby `Regexp`;锚点和多行行为遵守 Ruby |
16
- | `match_max`、`full_buffer` | `buffer_limit` 只保留尾部字节,默认无限;没有缓冲满事件或丢弃字节结果,不能作为完整输出存储 |
17
- | 前置/后置模式、后台匹配 | 没有全局隐式规则或后台读取器;调用方显式组合模式块,每个会话由一个读取者驱动 |
18
- | `interact` 的部分正则匹配 | Tcl 的 `CANMATCH` 可以暂存潜在匹配;Ruby 原生正则不提供该接口,本库使用有限历史窗口,已转发前缀不能撤回。需要完整过滤时使用字面转义 |
19
- | `close` 与 `wait` | IO 结束与进程退出仍分开判断;本库的块生命周期和 `close` 会额外回收直属子进程 |
20
-
21
- 长输出任务应通过日志或监听器流式保存,并按提示长度设置 `buffer_limit`。需要无损截断通知时,后续应单独设计缓冲满事件,不能把现有尾部裁剪当成该功能。后台匹配与共享前置/后置模式也需要独立的取消、优先级和资源归属约定,尚未实现。
10
+ | Tcl Expect 能力 | 本库的 Ruby 表达与边界 |
11
+ |---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
12
+ | `spawn`、`send`、`expect`、`interact` | PTY 会话、`write` / `puts`、原生模式块和人工接管;`send` 保留 Ruby 反射语义 |
13
+ | `exp_continue` / `-continue_timer` | `continue` / `continue(reset_timeout: false)`;使用单调时钟和秒数关键字参数 |
14
+ | 匹配后消费输入、`-notransfer` | `before` / `match` / `after` 与会话级 `preserve_buffer`;无进展回调不会原地重复执行 |
15
+ | glob、exact、regexp 模式 | 字符串只作字面匹配,正则采用 Ruby `Regexp`;锚点和多行行为遵守 Ruby |
16
+ | `match_max`、`full_buffer` | `buffer_limit` 只保留尾部字节,默认无限;`buffer_discarded_bytes` 统计累计裁剪量,无缓冲满事件 |
17
+ | 前置/后置模式、后台匹配 | 没有全局隐式规则或后台读取器;调用方显式组合模式块,每个会话由一个读取者驱动 |
18
+ | `interact` 的部分正则匹配 | Tcl 的 `CANMATCH` 可以暂存潜在匹配;Ruby 原生正则不提供该接口,本库使用有限历史窗口,已转发前缀不能撤回。需要完整过滤时使用字面转义 |
19
+ | `close` 与 `wait` | IO 结束与进程退出仍分开判断;本库的块生命周期和 `close` 会额外回收直属子进程 |
20
+
21
+ 长输出任务应通过日志或监听器流式保存,并按提示长度设置 `buffer_limit`。只读 `buffer_discarded_bytes` 提供会话累计裁剪字节数,正常匹配消费和清空不计入;它不是无损缓冲满事件。后台匹配与共享前置/后置模式也需要独立的取消、优先级和资源归属约定,尚未实现。
22
+
23
+ `expect` / `expect_result` 的 `deadline:` 是额外的绝对单调时钟总期限,限制相对 `timeout` 的所有重置,不改变已知 EOF 派发和未指定总期限时的零超时轮询。会话 `diagnostic_output` 与接收日志独立;显式 `redact` 仅过滤日志和诊断,协议转发与匹配仍使用原始字节。
22
24
 
23
25
  ## Expect.pm 接口迁移
24
26
 
25
- 交互能力参考 [jacoby/expect.pm](https://github.com/jacoby/expect.pm),源码基准为版本 1.38、提交 `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。此项目独立实现,使用 MIT 许可,没有复制 Perl 实现代码;原项目作者及维护者为 Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。
26
-
27
- 从 0.2.0 起采用 Ruby 原生接口。下表列出迁移关系,**旧方法、别名和参数语法已移除**;历史 0.1.1 安装包的接口见对应版本记录。
28
-
29
- | 原接口或状态 | 当前 Ruby 接口 |
30
- | --- | --- |
31
- | 包级默认值、`Expect.defaults` | `Expect.configure { |config| ... }`、冻结的 `Expect.configuration` |
32
- | `timeout(10)` 等读写合一方法 | `timeout`、`timeout = 10`;布尔查询使用 `name?` |
33
- | `exp_init`、`init` | `Expect.open(io, writer:, own:)`,支持块生命周期 |
34
- | `expect(seconds, ...)` | `expect(..., timeout: seconds)`;完整结果使用 `expect_result` |
35
- | `-ex`、`-re`、数组字符串正则 | 字符串字面匹配,正则使用原生 `Regexp` |
36
- | `-i` 分组 | `from: session` / `from: [sessions]`,或块内 `on(..., from:)` |
37
- | `[pattern, callback, *args]` | `on(pattern) { |session| ... }`,附加参数使用闭包 |
38
- | `exp_continue`、`exp_continue_timeout` | `continue`、`continue(reset_timeout: false)` |
39
- | 数字前缀错误字符串 | `Result#error` 为 `:timeout`、`:eof` 或原始 IO 异常对象 |
40
- | `matchlist`、`exp_matchlist` | `captures` |
41
- | `exp_pid`、`exp_match` 等别名 | `pid`、`match` 等属性 |
42
- | `get_accum`、`set_accum`、`clear_accum` | `buffer`、`buffer=`、`clear_buffer` |
43
- | `max_accum`、`match_max` | `buffer_limit`,正整数;`nil` 表示无限 |
44
- | `notransfer` | `preserve_buffer` |
45
- | `restart_timeout_upon_receive` | `reset_timeout_on_read` |
46
- | `log_user`、`log_group` | `log_stdout`、`log_listeners` |
47
- | `log_file`、`logfile`、`exp_logfile` | `log_output` / `log_output=`;路径和日志块使用 `log_to(..., mode:)` |
48
- | `print_log_file` | `write_log` |
49
- | `set_group` | `listeners` / `listeners=` |
50
- | `set_seq(sequence, callback, args)` | `on_sequence(sequence) { ... }` |
51
- | `send`、`print` | `write` 原样写入、`puts` 按行输出;`send` 保留 Ruby 反射语义 |
52
- | `send_slow(delay, *strings)`、`write_slow` | `send_slow(*objects, delay:)` |
53
- | `manual_stty = true` | `raw_terminal = false`;`stty` 保留,`exp_stty` 移除 |
54
- | `interact(input, escape)` | `interact(input:, escape:, output:, timeout:)` |
55
- | `test_handles` 的索引数组 | `readable_sessions(*sessions, timeout:)` 返回会话对象数组 |
56
- | `wait(seconds)` | `wait(timeout: seconds)`,返回 `Process::Status` |
57
- | `exitstatus`、`exp_exitstatus` 原始整数 | `process_status.to_i`;普通退出码用 `exit_code` |
58
- | `do_soft_close` | `graceful_close`,可通过 `close(graceful:)` 单次覆盖 |
59
- | `debug`、`exp_internal` | `debug_level`,整数 `0..3` |
60
- | `ttyname`、`pty_handle` | `tty_name`、标准 `inspect` |
61
- | `version` | 常量 `Expect::VERSION` |
62
- | `multiline_matching` | 原生正则的 `^` / `$`、`\A` / `\z` 和 `/m` |
63
- | `ignore_eintr` | 匹配等待自动重试 EINTR,并保留原期限 |
27
+ 交互能力参考 [jacoby/expect.pm](https://github.com/jacoby/expect.pm),源码基准为版本 1.38、提交
28
+ `2ea0e4ce20a896c95cb4c94e781f1b1f3145150d`。此项目独立实现,使用 MIT 许可,没有复制 Perl 实现代码;原项目作者及维护者为
29
+ Austin Schutz、Roland Giersig、Dave Jacoby,采用与 Perl 相同的许可。
30
+
31
+ 从 0.2.0 起采用 Ruby 原生接口。下表列出迁移关系, **旧方法、别名和参数语法已移除**;历史 0.1.1 安装包的接口见对应版本记录。
32
+
33
+ | 原接口或状态 | 当前 Ruby 接口 |
34
+ |--------------------------------------------|---------------------------------------------------------------------|
35
+ | 包级默认值、`Expect.defaults` | `Expect.configure { \|config\| ... }`、冻结的 `Expect.configuration` |
36
+ | `timeout(10)` 等读写合一方法 | `timeout`、`timeout = 10`;布尔查询使用 `name?` |
37
+ | `exp_init`、`init` | `Expect.open(io, writer:, own:)`,支持块生命周期 |
38
+ | `expect(seconds, ...)` | `expect(..., timeout: seconds)`;完整结果使用 `expect_result` |
39
+ | `-ex`、`-re`、数组字符串正则 | 字符串字面匹配,正则使用原生 `Regexp` |
40
+ | `-i` 分组 | `from: session` / `from: [sessions]`,或块内 `on(..., from:)` |
41
+ | `[pattern, callback, *args]` | `on(pattern) { \|session\| ... }`,附加参数使用闭包 |
42
+ | `exp_continue`、`exp_continue_timeout` | `continue`、`continue(reset_timeout: false)` |
43
+ | 数字前缀错误字符串 | `Result#error` 为 `:timeout`、`:eof` 或原始 IO 异常对象 |
44
+ | `matchlist`、`exp_matchlist` | `captures` |
45
+ | `exp_pid`、`exp_match` 等别名 | `pid`、`match` 等属性 |
46
+ | `get_accum`、`set_accum`、`clear_accum` | `buffer`、`buffer=`、`clear_buffer` |
47
+ | `max_accum`、`match_max` | `buffer_limit`,正整数;`nil` 表示无限 |
48
+ | `notransfer` | `preserve_buffer` |
49
+ | `restart_timeout_upon_receive` | `reset_timeout_on_read` |
50
+ | `log_user`、`log_group` | `log_stdout`、`log_listeners` |
51
+ | `log_file`、`logfile`、`exp_logfile` | `log_output` / `log_output=`;路径和日志块使用 `log_to(..., mode:)` |
52
+ | `print_log_file` | `write_log` |
53
+ | `set_group` | `listeners` / `listeners=` |
54
+ | `set_seq(sequence, callback, args)` | `on_sequence(sequence) { ... }` |
55
+ | `send`、`print` | `write` 原样写入、`puts` 按行输出;`send` 保留 Ruby 反射语义 |
56
+ | `send_slow(delay, *strings)`、`write_slow` | `send_slow(*objects, delay:)` |
57
+ | `manual_stty = true` | `raw_terminal = false`;`stty` 保留,`exp_stty` 移除 |
58
+ | `interact(input, escape)` | `interact(input:, escape:, output:, timeout:)` |
59
+ | `test_handles` 的索引数组 | `readable_sessions(*sessions, timeout:)` 返回会话对象数组 |
60
+ | `wait(seconds)` | `wait(timeout: seconds)`,返回 `Process::Status` |
61
+ | `exitstatus`、`exp_exitstatus` 原始整数 | `process_status.to_i`;普通退出码用 `exit_code` |
62
+ | `do_soft_close` | `graceful_close`,可通过 `close(graceful:)` 单次覆盖 |
63
+ | `debug`、`exp_internal` | `debug_level`,整数 `0..3` |
64
+ | `ttyname`、`pty_handle` | `tty_name`、标准 `inspect` |
65
+ | `version` | 常量 `Expect::VERSION` |
66
+ | `multiline_matching` | 原生正则的 `^` / `$`、`\A` / `\z` 和 `/m` |
67
+ | `ignore_eintr` | 匹配等待自动重试 EINTR,并保留原期限 |
64
68
 
65
69
  ## 保留的交互能力
66
70
 
67
- 仍支持真实控制终端、精确/正则匹配、模式优先级、捕获组、二进制与分片 UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、路径/IO/回调日志、监听组、人工接管、跨读取转义及终端恢复。
71
+ 仍支持真实控制终端、精确/正则匹配、模式优先级、捕获组、二进制与分片
72
+ UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、路径/IO/回调日志、监听组、人工接管、跨读取转义及终端恢复。
68
73
 
69
- `expect` 返回模式序号或 `nil`;`expect_result` / `last_result` 返回原生七字段 `Struct`。`to_a`、`to_h`、模式解构使用 Ruby 自带行为,多重赋值需要显式 `result.to_a`。日志按实际读取的原始字节记录,转接与匹配切换不重复写日志。
74
+ `expect` 返回模式序号或 `nil`;`expect_result` / `last_result` 返回原生七字段 `Struct`。`to_a`、`to_h`、模式解构使用 Ruby
75
+ 自带行为,多重赋值需要显式 `result.to_a`。日志按实际读取时记录,转接与匹配切换不重复写日志;显式启用 `redact` 时过滤已注册秘密。
70
76
 
71
77
  ## 软关闭与硬关闭
72
78
 
73
- 参考原版的策略边界:`soft_close` 先收集剩余输出,关闭所属句柄,等待退出并最多发送 TERM;**不发送 KILL**。未退出返回 `nil`,保留 PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。关闭会话 IO 不代表子进程已经退出。
79
+ 参考原版的策略边界:`soft_close` 先收集剩余输出,关闭所属句柄,等待退出并最多发送 TERM; **不发送 KILL**。未退出返回 `nil`,保留
80
+ PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。关闭会话 IO 不代表子进程已经退出。
74
81
 
75
- Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 和 `graceful_close = true` 先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC 兜底直接强制清理,不阻塞等待。
82
+ Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 和 `graceful_close = true`
83
+ 先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC
84
+ 兜底直接强制清理,不阻塞等待。
76
85
 
77
86
  ## 有意采用的 Ruby 语义
78
87
 
@@ -82,6 +91,8 @@ Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status`
82
91
  - 超时回调接收所有仍在监听的会话;重复注册超时回调抛出 `ArgumentError`。
83
92
  - `write` 返回字节数,`puts` 返回 `nil`,`<<` 返回会话。
84
93
  - 启动失败抛出 `Expect::SpawnError` 并回收;EOF 不主动终止仍存活的进程。借用 IO 不关闭,接管 IO 在初始化失败或会话关闭时释放。
85
- - Perl 特有的正则、IO::Pty 继承 API、全局信号 handler 和 Solaris 字节删除补丁不移植。Ruby 使用 `to_io`、`slave`、`io/console` 和原生正则。
94
+ - Perl 特有的正则、IO::Pty 继承 API、全局信号 handler 和 Solaris 字节删除补丁不移植。Ruby 使用 `to_io`、`slave`、`io/console`
95
+ 和原生正则。
86
96
 
87
- `test/compare_upstream.rb` 仅对共同的交互行为作可选差分验证,通过 Ruby 新接口表达同样场景,并显式转换错误标记和可读会话索引;它不要求或证明旧 API 兼容,也不等于运行完整上游测试套件。
97
+ `test/compare_upstream.rb` 仅对共同的交互行为作可选差分验证,通过 Ruby 新接口表达同样场景,并显式转换错误标记和可读会话索引;它不要求或证明旧
98
+ API 兼容,也不等于运行完整上游测试套件。
@@ -4,62 +4,112 @@
4
4
 
5
5
  ## 模块职责
6
6
 
7
- | 模块 | 职责 |
8
- | --- | --- |
9
- | `expect.rb` | 会话入口、PTY 启动、缓冲、直接读写和关闭流程 |
10
- | `configuration.rb` | 配置校验与可复制的默认快照 |
11
- | `pattern.rb`、`pattern_list.rb`、`result.rb` | 字节定位、声明顺序及原生结果值 |
12
- | `matcher.rb` | 一次等待的匹配、事件派发和期限 |
13
- | `session_resources.rb` | IO、日志与直属子进程的所有权账本和 GC 兜底 |
14
- | `logging.rb` | 日志目标、监听器及同步输出;不持有第二份资源所有权 |
15
- | `terminal.rb` | `stty` 和窗口尺寸的会话终端接口 |
16
- | `interaction.rb`、`relay.rb`、`relay_writer.rb` | 人工接管、转义处理、共同调度和各目标发送游标 |
7
+ | 模块 | 职责 |
8
+ |-------------------------------------------------|----------------------------------------------------|
9
+ | `expect.rb` | 会话入口、PTY 启动、缓冲、直接读写和关闭流程 |
10
+ | `configuration.rb` | 配置校验与可复制的默认快照 |
11
+ | `pattern.rb`、`pattern_list.rb`、`result.rb` | 字节定位、声明顺序及原生结果值 |
12
+ | `matcher.rb` | 一次等待的匹配、事件派发和期限 |
13
+ | `session_resources.rb` | IO、日志与直属子进程的所有权账本和 GC 兜底 |
14
+ | `logging.rb` | 日志目标、监听器及同步输出;不持有第二份资源所有权 |
15
+ | `terminal.rb` | `stty` 和窗口尺寸的会话终端接口 |
16
+ | `interaction.rb`、`relay.rb`、`relay_writer.rb` | 人工接管、转义处理、共同调度和各目标发送游标 |
17
+
18
+ `redactor.rb` 为日志和各方向诊断提供内部字节流过滤器,不持有 IO,也不接管协议缓冲。
17
19
 
18
20
  这些能力文件延续 `interaction.rb` 的类重开方式,公共入口仍是 `expect/pty`。按职责组织方法,不引入新的继承层级,也不复制会话状态。
19
21
 
20
22
  跨对象调用使用 `__send__` 访问受保护或私有方法。变更以下入口时,应同时检查调用方与失败恢复路径:
21
23
 
22
- | 会话内部入口 | 使用方 | 不变量 |
23
- | --- | --- | --- |
24
- | `reset_result`、`record_match`、`record_eof`、`record_error` | Matcher | 先保存结果再执行回调;错误保留原始输入 |
25
- | `read_available` | Matcher、Relay、写入背压 | 转接缓冲交接时禁止直接传播和裁剪;普通匹配缓冲需要裁剪 |
26
- | `interaction_buffer`、`restore_relay_buffer` | Matcher、Relay | 嵌套匹配及转接退出时把未消费字节交还原所有者 |
27
- | `sequences`、`relay_history`、`relay_callback` | Relay、转义扫描 | 已识别转义只回调一次,已发送前缀不重放 |
28
- | `queue_output`、`relay_outputs`、`propagate` | Relay、转义扫描 | 每个目标独立保存短写进度;同步传播只用于普通匹配 |
24
+ | 会话内部入口 | 使用方 | 不变量 |
25
+ |--------------------------------------------------------------|--------------------------|--------------------------------------------------------|
26
+ | `reset_result`、`record_match`、`record_eof`、`record_error` | Matcher | 先保存结果再执行回调;错误保留原始输入 |
27
+ | `read_available` | Matcher、Relay、写入背压 | 转接缓冲交接时禁止直接传播和裁剪;普通匹配缓冲需要裁剪 |
28
+ | `interaction_buffer`、`restore_relay_buffer` | Matcher、Relay | 嵌套匹配及转接退出时把未消费字节交还原所有者 |
29
+ | `sequences`、`relay_history`、`relay_callback` | Relay、转义扫描 | 已识别转义只回调一次,已发送前缀不重放 |
30
+ | `queue_output`、`relay_outputs`、`propagate` | Relay、转义扫描 | 每个目标独立保存短写进度;同步传播只用于普通匹配 |
31
+
32
+ | 状态 | 所有者与修改入口 | 交接、超时与关闭 |
33
+ |-----------------------|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
34
+ | `@buffer` | 会话的未消费匹配输入;`read_available` 追加,`record_match` 消费,`clear_buffer` 移交 | Matcher 用公开 `buffer` 的副本扫描;一次扫描的重复来源复用副本,下轮重新获取。超时保留字节。关闭不清空它,便于检查尾部 |
35
+ | `@interaction_buffer` | Relay 当前持有的待处理输入;转接背压读取也追加到这里 | 转义回调内的 Matcher 临时移回 `@buffer`;Matcher 的 ensure 将余下字节交回 Relay。Relay 的 ensure 恢复上层交互状态,并把未处理字节还给普通缓冲 |
36
+ | `@relay_outputs` | 源会话持有的各目标发送游标;`queue_output` 创建,RelayWriter 推进 | 超时或写失败后保留原目标和已交付位置;重入续发,不重放成功前缀。关闭放弃剩余交付并清空队列,不关闭借用目标 |
37
+ | `@relay_history` | 源会话的正则转义历史窗口;`relay_buffer` 更新 | 同一规则跨次转接保留,规则改变或转义消费后清空。窗口遵守 buffer_limit 或内部默认上限,固定 UTF-8 正则不保留开头的孤立续字节。关闭清空 |
38
+ | `@relay_callback` | 已识别转义但尚未交付完前缀时,源会话暂存的回调 | 所有前缀目标交付完成后执行一次;超时保留,关闭释放。不能在等待写入后重新扫描这段已识别转义 |
39
+
40
+ 匹配优先级始终是“声明组 → 会话 → 模式”,不是文本位置;多个会话包装同一 IO
41
+ 时,就绪处理使用对象身份选择首个声明会话。回调前后用于判断缓冲是否变化的快照仍独立保留,不能换成可变内部字符串。零长度或
42
+ preserve_buffer 的继续匹配必须遵守 stalled 保护。
43
+
44
+ 文本与 EOF 回调遵守相同的继续期限:`continue(reset_timeout: false)` 返回后先检查原期限,再开始下一轮文本匹配。EOF
45
+ 继续已到期时仍依次派发已知 EOF,不读取新输入;超时仅通知仍活跃的来源,保留未消费缓冲。所有来源都已 EOF 时直接返回
46
+ EOF,不再制造一次超时事件。EOF 或超时回调重置期限后恢复普通匹配顺序。
29
47
 
30
- | 状态 | 所有者与修改入口 | 交接、超时与关闭 |
31
- | --- | --- | --- |
32
- | `@buffer` | 会话的未消费匹配输入;`read_available` 追加,`record_match` 消费,`clear_buffer` 移交 | Matcher 用公开 `buffer` 的副本扫描;一次扫描的重复来源复用副本,下轮重新获取。超时保留字节。关闭不清空它,便于检查尾部 |
33
- | `@interaction_buffer` | Relay 当前持有的待处理输入;转接背压读取也追加到这里 | 转义回调内的 Matcher 临时移回 `@buffer`;Matcher 的 ensure 将余下字节交回 Relay。Relay 的 ensure 恢复上层交互状态,并把未处理字节还给普通缓冲 |
34
- | `@relay_outputs` | 源会话持有的各目标发送游标;`queue_output` 创建,RelayWriter 推进 | 超时或写失败后保留原目标和已交付位置;重入续发,不重放成功前缀。关闭放弃剩余交付并清空队列,不关闭借用目标 |
35
- | `@relay_history` | 源会话的正则转义历史窗口;`relay_buffer` 更新 | 同一规则跨次转接保留,规则改变或转义消费后清空。窗口遵守 buffer_limit 或内部默认上限,固定 UTF-8 正则不保留开头的孤立续字节。关闭清空 |
36
- | `@relay_callback` | 已识别转义但尚未交付完前缀时,源会话暂存的回调 | 所有前缀目标交付完成后执行一次;超时保留,关闭释放。不能在等待写入后重新扫描这段已识别转义 |
48
+ 转义扫描只在一轮内复用 `history + buffer`,只含字面规则时不构造它。回调继续后重新读取规则、历史和缓冲;不能跨回调保存文本快照。字面转义的潜在前缀暂存,完整前缀交付后才执行转义回调。
37
49
 
38
- 匹配优先级始终是“声明组 → 会话 → 模式”,不是文本位置;多个会话包装同一 IO 时,就绪处理使用对象身份选择首个声明会话。回调前后用于判断缓冲是否变化的快照仍独立保留,不能换成可变内部字符串。零长度或 preserve_buffer 的继续匹配必须遵守 stalled 保护。
50
+ ## 关闭与所有权
39
51
 
40
- 文本与 EOF 回调遵守相同的继续期限:`continue(reset_timeout: false)` 返回后先检查原期限,再开始下一轮文本匹配。EOF 继续已到期时仍依次派发已知 EOF,不读取新输入;超时仅通知仍活跃的来源,保留未消费缓冲。所有来源都已 EOF 时直接返回 EOF,不再制造一次超时事件。EOF 或超时回调重置期限后恢复普通匹配顺序。
52
+ SessionResources 保存创建者 PID、所属句柄、直属子进程和所属日志。借用 IO 不关闭,fork 后的非创建者不发送信号或回收父进程的孩子。soft_close
53
+ 最多发送 TERM 并保留未退出 PID;hard_close 可在有限等待后发送 KILL。
41
54
 
42
- 转义扫描只在一轮内复用 `history + buffer`,只含字面规则时不构造它。回调继续后重新读取规则、历史和缓冲;不能跨回调保存文本快照。字面转义的潜在前缀暂存,完整前缀交付后才执行转义回调。
55
+ 工厂和构造器在校验之前登记资源归属,失败时沿用同一清理流程;启动成功必须显式记录,不能用 PID 非空推断整个工厂调用已经成功。即使
56
+ exec 成功后诊断输出失败,也要立即回收未交付给调用方的子进程。
43
57
 
44
- ## 关闭与所有权
58
+ 显式关闭遇到 IOError/SystemCallError 时,继续尝试其他句柄、交互包装器、日志和子进程清理,最后传播首个清理错误;已经在传播的其他异常保留。失败资源继续持有,后续关闭可重试。GC
59
+ 终结器独立尝试句柄、日志、非阻塞回收,常规清理错误不向外传播。终结器不能强引用会话本身。
60
+
61
+ 是否保留原始异常由当前构造、块或关闭作用域显式记录,不能直接读取调用者 rescue 中的 `$!`。`Interrupt` 和 `SystemExit`
62
+ 同样先清理再传播;`break` / `throw` 不是异常,此时清理失败仍应抛出。
63
+
64
+ ## 缓冲裁剪与字面扫描
65
+
66
+ `buffer_discarded_bytes` 随会话累计,只在匹配窗口执行 `trim_buffer` 时增加。读取、`buffer=`、降低上限和下一次匹配应用上限都可能触发裁剪;匹配消费、清空、EOF 和 Relay 交接不计入,关闭后仍保留计数。它是可观察的丢弃量,不是缓冲满事件,也不提供全文归档。
67
+
68
+ `@buffer_generation` 仅在替换、消费、清空、裁剪和 Relay 恢复时递增,同代次只追加。Matcher 按会话与模式对象身份记录字面未命中的代次、字节数及模式值;新增输入只回看模式长度减一的重叠区。无新字节可跳过重复扫描,代次或模式值改变则从头扫描。缓存只活在一次 Matcher 中,不保留文本副本。正则仍扫描完整窗口,不推断其长度、锚点或前瞻范围。
69
+
70
+ ## 相对期限与总期限
71
+
72
+ 公共 `deadline:` 使用 `Expect.monotonic` 的有限绝对秒数,`nil` 表示无总期限;它与 `timeout` 取较早者。`reset_timeout_on_read` 及所有继续回调只能重置相对期限,总期限固定。总期限过后不读取或消费新的文本匹配;正则计算结束后也要重查期限。已知 EOF 保留派发顺序,全部来源已 EOF 时直接返回 EOF。
73
+
74
+ 未设置总期限时,`timeout: 0` 保持现有缓冲匹配和首次非阻塞轮询语义。期限检查是协作式的,不强行打断单次正则、日志、同步监听器或用户回调;正则执行限时由调用方的 `Regexp` 实例控制。
75
+
76
+ ## 诊断与脱敏归属
77
+
78
+ `logging.rb` 分开处理接收日志、诊断和协议转发。`diagnostic_output` 借用 Logger、可写对象或回调,不进入资源账本;默认沿用 stderr。回调接收冻结的事件 Hash 和 message 字符串,不含会话对象。诊断失败与其他同步 IO 错误同样保留已读取的原始输入。
79
+
80
+ `redactor.rb` 是内部的字节流过滤器。会话复制并追加注册秘密,每个接收日志流以及发送、接收诊断方向各自持有过滤器。暂存最长秘密长度减一的尾部,重叠秘密合并为隐藏区间,过滤发生在 `inspect` 转义之前。缓冲快照可能只剩秘密中间字节,启用脱敏时整体隐藏,不重复展示其内容。
81
+
82
+ EOF 结束接收流,日志或诊断目标替换先冲刷旧流,显式关闭结束所有方向;尾部疑似秘密前缀保守隐藏。GC 只清理所属资源,不执行用户回调或过滤尾部输出。调用方需显式关闭以交付尾部;借用日志和诊断目标不关闭,库打开的日志文件仍由 SessionResources 管理。
83
+
84
+ 日志路径以覆盖模式重新打开前先结束旧流,冲刷失败不能提前截断目标文件。尾部回调重入并轮换日志时,外层重新读取当前目标及所有权;诊断冲刷对方向取快照并排空回调产生的尾部,交接前的发送和接收仍归旧目标。匹配诊断中的嵌套等待可消费缓冲,但不能替换外层已记录的 Result 或正式模式回调所见结果。
45
85
 
46
- SessionResources 保存创建者 PID、所属句柄、直属子进程和所属日志。借用 IO 不关闭,fork 后的非创建者不发送信号或回收父进程的孩子。soft_close 最多发送 TERM 并保留未退出 PID;hard_close 可在有限等待后发送 KILL。
86
+ 过滤不修改匹配缓冲、Result、stdout 或 listeners,不推断编码、转义等变换后的秘密,也不能追溯删除已交付日志。同步目标须及时返回;自定义回调抛出的非 IO 异常原样传播。
47
87
 
48
- 工厂和构造器在校验之前登记资源归属,失败时沿用同一清理流程;启动成功必须显式记录,不能用 PID 非空推断整个工厂调用已经成功。即使 exec 成功后诊断输出失败,也要立即回收未交付给调用方的子进程。
88
+ ## 生命周期与错误边界
49
89
 
50
- 显式关闭遇到 IOError/SystemCallError 时,继续尝试其他句柄、交互包装器、日志和子进程清理,最后传播首个清理错误;已经在传播的其他异常保留。失败资源继续持有,后续关闭可重试。GC 终结器独立尝试句柄、日志、非阻塞回收,常规清理错误不向外传播。终结器不能强引用会话本身。
90
+ 会话 IO、输入 EOF 与直属子进程状态是正交维度。软关闭后 `closed?` 与 `alive?` 同时为真是合法结果;借用 IO、外部关闭和外部 `waitpid` 也不能被单个线性枚举准确替代。外部已回收的 PID 必须清除,无法取得的退出状态保持未知。
51
91
 
52
- 是否保留原始异常由当前构造、块或关闭作用域显式记录,不能直接读取调用者 rescue 中的 `$!`。`Interrupt` 和 `SystemExit` 同样先清理再传播;`break` / `throw` 不是异常,此时清理失败仍应抛出。
92
+ 参数问题使用 `ArgumentError`;匹配等待的 EOF 和超时保留为 Result 事件,底层 IO 异常保留原对象;`SpawnError` 表示启动失败,`WriteTimeout` 继承 `IOError` 并携带写入进度。不得仅为统一命名而包装所有错误、丢失原异常或混淆等待事件与失败。
53
93
 
54
- ## 回归入口
94
+ ## 新增回归入口
55
95
 
56
96
  | 不变量 | 测试 |
57
97
  | --- | --- |
58
- | 部分关闭失败、错误保留、借用 IO、非 owner、软硬关闭和 GC | cleanup_test、process_test、edge_case_test |
59
- | 字节偏移、二进制和 UTF-8 分片、零长度、可选捕获 | pattern_offset_test、matching_test、ruby_api_test、edge_case_test |
60
- | 一轮快照、同 IO 首来源、回调换规则、嵌套匹配 | scan_reuse_test、multi_session_test、interconnect_test |
61
- | 短写、转义拆包、超时恢复、不重放、回调前缀顺序 | relay_recovery_test、interconnect_test |
62
- | 无限/零/有限超时、继续重置/保留、EINTR、接收重置 | timeout_test(控制输入到达时间)、edge_case_test |
63
- | 总期限和目标写期限、连续中断不延长写超时 | relay_recovery_test |
98
+ | 裁剪计数、正常消费、转接交接与日志错误 | buffer_accounting_test |
99
+ | 外部回收、借用 IO、关闭失败重试、原生错误分类 | lifecycle_contract_test |
100
+ | 绝对期限、接收和回调重置、过期文本、EOF、EINTR、零轮询 | deadline_test |
101
+ | Logger/IO/回调、分片与重叠秘密、二进制、流尾部、关闭失败、覆盖与重入 | diagnostics_test |
102
+ | 字面跨读取命中、声明优先级、缓存失效、操作序列与全量扫描对照 | literal_scan_test |
103
+
104
+ ## 既有回归入口
105
+
106
+ | 不变量 | 测试 |
107
+ |----------------------------------------------------------|-------------------------------------------------------------------|
108
+ | 部分关闭失败、错误保留、借用 IO、非 owner、软硬关闭和 GC | cleanup_test、process_test、edge_case_test |
109
+ | 字节偏移、二进制和 UTF-8 分片、零长度、可选捕获 | pattern_offset_test、matching_test、ruby_api_test、edge_case_test |
110
+ | 一轮快照、同 IO 首来源、回调换规则、嵌套匹配 | scan_reuse_test、multi_session_test、interconnect_test |
111
+ | 短写、转义拆包、超时恢复、不重放、回调前缀顺序 | relay_recovery_test、interconnect_test |
112
+ | 无限/零/有限超时、继续重置/保留、EINTR、接收重置 | timeout_test(控制输入到达时间)、edge_case_test |
113
+ | 总期限和目标写期限、连续中断不延长写超时 | relay_recovery_test |
64
114
 
65
115
  保留独立的 Matcher、Relay、write、wait 期限合同;仅为去重而统一事件循环会扩大上述状态交接的影响范围。
data/docs/PERFORMANCE.md CHANGED
@@ -6,21 +6,33 @@
6
6
  bundle exec ruby benchmark/matching.rb
7
7
  bundle exec ruby benchmark/relay.rb
8
8
  bundle exec ruby benchmark/send_slow.rb
9
+ bundle exec ruby benchmark/scaling.rb
9
10
  ```
10
11
 
11
- 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`,该目录不提交。每个场景先核对非空输入的预期结果,再计时;每轮计时后再次核对最后一次执行的结果。输出包括 Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输入规模、迭代次数、处理字节数、墙钟耗时、分配对象数和 GC 次数。
12
+ 默认预热一次、采样五次。结果分别写入 `tmp/benchmark/matching.json`、`relay.json`、`send_slow.json`
13
+ ,该目录不提交。每个场景先核对非空输入的预期结果,再计时;每轮计时后再次核对最后一次执行的结果。输出包括
14
+ Ruby、平台、源码提交、工作区是否修改、库源码 SHA-256、输入规模、迭代次数、处理字节数、墙钟耗时、分配对象数和 GC 次数。
12
15
 
13
16
  ## 工作负载与边界
14
17
 
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 延迟 |
18
+ | 脚本 | 范围 |
19
+ |-----------|----------------------------------------------------------------------------------------------------------------------------------------------|
20
+ | matching | 4 KiB、64 KiB、1 MiB;1、8、32 个正则;首个命中、末个命中、全未命中;UTF-8 前缀和可选捕获;1、8、32 个会话、多组重复来源和同时就绪的真实管道 |
21
+ | relay | 无转义、字面转义、16 个正则转义;正常目标与每次只接受 17 字节的目标共同接收相同数据 |
22
+ | send_slow | 真实本地 socket;无回显、持续回显;零延迟和每字符 1ms 延迟 |
20
23
 
21
- `matching` 的扫描用内部 `find_match` 单独度量缓冲扫描,排除 PTY 启动和回调开销;就绪场景包含管道写入、选择和读取。`relay` 的前三项单独度量转义扫描,混合目标项运行完整转接循环。短写模拟目标吞吐受限,不等同于真实慢网络。`send_slow` 包含 socket、接收线程和完整回显校验的成本;无回显时计时止于接收方收齐数据。
24
+ `matching` 的扫描用内部 `find_match` 单独度量缓冲扫描,排除 PTY 启动和回调开销;就绪场景包含管道写入、选择和读取。`relay`
25
+ 的前三项单独度量转义扫描,混合目标项运行完整转接循环。短写模拟目标吞吐受限,不等同于真实慢网络。`send_slow` 包含
26
+ socket、接收线程和完整回显校验的成本;无回显时计时止于接收方收齐数据。
22
27
 
23
- 处理字节数表示每轮提供给场景的输入字节数(混合目标为两份交付量),不是正则引擎实际访问内存的次数。分配量是整个 Ruby 进程的计数,socket 场景包含接收线程的分配。基准不提供 CPU 或网络隔离;耗时波动不能直接归因于代码变化。
28
+ 处理字节数表示每轮提供给场景的输入字节数(混合目标为两份交付量),不是正则引擎实际访问内存的次数。分配量是整个 Ruby
29
+ 进程的计数,socket 场景包含接收线程的分配。基准不提供 CPU 或网络隔离;耗时波动不能直接归因于代码变化。
30
+
31
+ `matching` 还比较同一次等待中持续追加 32 个 1 KiB 块后的字面与正则扫描;该场景每块都检查未命中,最后验证完整偏移,相关检查包含在计时内。初始窗口分别为 64 KiB 和 1 MiB,均使用 32 个模式。
32
+
33
+ `scaling` 默认使用 1、16、64 个真实管道来源,等待并消费所有同时就绪的标记;另一场景把目标管道填满且不消费,验证其他来源仍可触发退出转义。`--sessions N` 可单独选择容量,运行前检查进程描述符上限。`--smoke` 仅使用 1、8 个来源。
34
+
35
+ 每个样本记录计时区间前后的 RSS 和打开描述符数,以及进程的描述符上限;采样自身不进入计时或分配计数。RSS 是端点快照,受 Ruby 堆和分配器保留影响,不代表峰值或存活对象大小。平台不支持某项采样时记录 `null`。描述符快照也不是长期泄漏证明,应同时检查重复样本和关闭流程。
24
36
 
25
37
  ## 同环境对照
26
38
 
@@ -31,30 +43,64 @@ bundle exec ruby benchmark/matching.rb --library /path/to/baseline/lib --samples
31
43
  bundle exec ruby benchmark/matching.rb --samples 5 --output tmp/benchmark/after.json
32
44
  ```
33
45
 
34
- 另外两份脚本支持相同参数。`--iterations N` 可增加单个样本工作量;比较双方必须使用相同参数。源码 SHA-256 用于区分同一提交上的未提交修改。修改工作负载后,应对两个版本重新取样。
46
+ 其他脚本支持相同公共参数。`--iterations N` 可增加单个样本工作量;比较双方必须使用相同参数。源码 SHA-256
47
+ 用于区分同一提交上的未提交修改。修改工作负载后,应对两个版本重新取样。
35
48
 
36
49
  没有 Git 的源码归档或安装目录仍可运行;提交和工作区状态记为 `null`,保留源码 SHA-256。
37
50
 
38
51
  ## 正则输入复用验证(2026-09-27)
39
52
 
40
- `Pattern#locate` 只在正则要求的编码与输入编码不同时复制输入;其他正则复用只读缓冲。Ruby 字符串可能共享底层存储,因此不能把减少一次 `dup` 直接解释为少复制整个缓冲的字节数;这里报告实际对象分配和墙钟样本。
53
+ `Pattern#locate` 只在正则要求的编码与输入编码不同时复制输入;其他正则复用只读缓冲。Ruby 字符串可能共享底层存储,因此不能把减少一次
54
+ `dup` 直接解释为少复制整个缓冲的字节数;这里报告实际对象分配和墙钟样本。
41
55
 
42
- macOS arm64、Ruby 4.0.6、Bundler 4.0.17,使用同一套 `benchmark/matching.rb` 和依赖,对修改前后的 `lib` 分别运行 `--samples 5 --iterations 100`。下表为五次样本的中位数,每项均校验匹配结果。此前用 30 次迭代进行的首轮比较方向一致。
56
+ macOS arm64、Ruby 4.0.6、Bundler 4.0.17,使用同一套 `benchmark/matching.rb` 和依赖,对修改前后的 `lib` 分别运行
57
+ `--samples 5 --iterations 100`。下表为五次样本的中位数,每项均校验匹配结果。此前用 30 次迭代进行的首轮比较方向一致。
43
58
 
44
- | 场景 | 修改前耗时 | 修改后耗时 | 修改前分配 | 修改后分配 |
45
- | --- | --- | --- | --- | --- |
46
- | 4 KiB、32 个正则、全未命中 | 1.985 ms | 1.415 ms | 6,501 | 3,301 |
47
- | 64 KiB、32 个正则、全未命中 | 17.068 ms | 11.070 ms | 6,501 | 3,301 |
48
- | 1 MiB、32 个正则、全未命中 | 257.474 ms | 165.734 ms | 6,501 | 3,301 |
49
- | 1 MiB、32 个正则、最后命中 | 265.848 ms | 172.001 ms | 7,301 | 4,101 |
50
- | 1 MiB UTF-8 前缀、固定编码正则 | 78.348 ms | 74.721 ms | 1,201 | 1,201 |
59
+ | 场景 | 修改前耗时 | 修改后耗时 | 修改前分配 | 修改后分配 |
60
+ |--------------------------------|------------|------------|------------|------------|
61
+ | 4 KiB、32 个正则、全未命中 | 1.985 ms | 1.415 ms | 6,501 | 3,301 |
62
+ | 64 KiB、32 个正则、全未命中 | 17.068 ms | 11.070 ms | 6,501 | 3,301 |
63
+ | 1 MiB、32 个正则、全未命中 | 257.474 ms | 165.734 ms | 6,501 | 3,301 |
64
+ | 1 MiB、32 个正则、最后命中 | 265.848 ms | 172.001 ms | 7,301 | 4,101 |
65
+ | 1 MiB UTF-8 前缀、固定编码正则 | 78.348 ms | 74.721 ms | 1,201 | 1,201 |
51
66
 
52
- 固定 UTF-8 正则仍需要编码副本,分配量不变;约 4.6% 的耗时差不能视为稳定收益。单正则大缓冲场景也基本不变。上述结果适用于本次机器与工作负载,不设为 CI 性能门槛。
67
+ 固定 UTF-8 正则仍需要编码副本,分配量不变;约 4.6% 的耗时差不能视为稳定收益。单正则大缓冲场景也基本不变。上述结果适用于本次机器与工作负载,不设为
68
+ CI 性能门槛。
53
69
 
54
70
  ```sh
55
71
  bundle exec ruby benchmark/matching.rb --smoke
56
72
  bundle exec ruby benchmark/relay.rb --smoke
57
73
  bundle exec ruby benchmark/send_slow.rb --smoke
74
+ bundle exec ruby benchmark/scaling.rb --smoke
58
75
  ```
59
76
 
60
77
  `script/ci` 运行这些小规模正确性检查,不设置墙钟性能阈值。热点优化必须有实际收益证据;减少对象分配不代表所有输入都会变快,也不能替代完整测试和安装验证。
78
+
79
+ ## 持续字面扫描验证(2026-09-27)
80
+
81
+ 基线为 `b5e9159` 的源码归档,使用本轮同一套基准脚本对照工作树。macOS arm64、Ruby 4.0.6、Bundler 4.0.17,第二轮为 `--samples 5 --iterations 5`;下表为每个样本五次操作的中位数。首轮三次采样方向一致,正则仍保持完整窗口。
82
+
83
+ | 场景 | 基线耗时 | 工作树耗时 | 基线分配 | 工作树分配 |
84
+ | --- | --- | --- | --- | --- |
85
+ | 64 KiB 起始窗口、32 字面模式、32 次追加 | 80.422 ms | 6.579 ms | 2,051 | 2,221 |
86
+ | 1 MiB 起始窗口、32 字面模式、32 次追加 | 977.942 ms | 44.526 ms | 2,051 | 2,221 |
87
+ | 1 MiB 起始窗口、32 正则、32 次追加 | 301.139 ms | 301.379 ms | 7,346 | 7,346 |
88
+ | 1 MiB、32 正则、静态全未命中 | 8.314 ms | 8.289 ms | 166 | 166 |
89
+ | 32 来源、4 KiB、8 个重复来源组 | 0.724 ms | 0.884 ms | 1,446 | 1,446 |
90
+
91
+ 大窗口字面持续扫描耗时减少约 95.4%,代价是每次 Matcher 的小量未命中缓存分配;小窗口多分组场景仍有期限检查开销,不能声称所有场景变快。样本内描述符数保持不变。RSS 端点受全进程之前运行的场景影响,不据此声称内存峰值下降。
92
+
93
+ 原始记录位于本地忽略目录 `tmp/core-improvements/matching-before-final.json` 与 `matching-after-final.json`,源码摘要分别为 `ab9e8e8b8fd1f237986963b35ec3e823a16ab107fa9d6eaf8f01bc9487ce357b` 和 `985b6bf1d319f90db1fb5692a2736aa69cd81ddec78d88d552e7152e11ceaf5f`。归档无独立 Git 元数据时只记录源码摘要,不借用上层仓库提交。
94
+
95
+ 容量场景可单独复现:
96
+
97
+ ```sh
98
+ bundle exec ruby benchmark/scaling.rb --samples 3 --iterations 10
99
+ (ulimit -n 4096; bundle exec ruby benchmark/scaling.rb --sessions 1000 --samples 3 --iterations 3)
100
+ ```
101
+
102
+ 1,000 来源需要 2,000 个管道端点,超过本机默认 256 的软上限;只提高本次基准子进程上限。此场景不包含 PTY 子进程、网络、持续负载或尾延迟,不能作为 1,000 个真实设备并发承诺,也不足以据此引入新的 Reactor 后端。
103
+
104
+ 同一工作树的三次容量样本中,1、16、64 来源各执行十轮的耗时中位数分别为 0.179、1.372、8.285 ms;1,000 来源执行三轮为 401.878 ms。1,000 来源样本的描述符数均为 2,007 → 2,007,RSS 端点从约 35.8 MB 增至 37.9 MB,不能解释为稳定内存上限。阻塞目标场景十轮共 0.186 ms,其他来源仍可推进,描述符数均为 13 → 13。
105
+
106
+ 原始记录为 `tmp/core-improvements/scaling-final.json` 和 `scaling-1000-final.json`,库源码摘要与上述工作树匹配记录一致。当前证据支持保留既有 select 调度,先量化真实负载,再决定是否需要替换后端。