expect-pty 0.5.3 → 0.6.1
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/CHANGELOG.md +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/Rakefile
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rake/testtask"
|
|
4
|
-
|
|
5
|
-
Rake::TestTask.new do |task|
|
|
6
|
-
task.libs << "test"
|
|
7
|
-
task.pattern = "test/**/*_test.rb"
|
|
8
|
-
task.warning = true
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
desc "Check Ruby style and common mistakes"
|
|
12
|
-
task :lint do
|
|
13
|
-
ruby "-S", "rubocop"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
task default: %i[lint test]
|
|
17
|
-
|
|
18
|
-
namespace :test do
|
|
19
|
-
desc "Log in over SSH and verify multiple scripts and persisted logs (opt-in)"
|
|
20
|
-
task :ssh do
|
|
21
|
-
ruby "test/integration/ssh_scripts.rb"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
desc "Log in over SSH and verify real PTY interact handoff (opt-in)"
|
|
25
|
-
task :ssh_interact do
|
|
26
|
-
ruby "examples/ssh_interact.rb", "--auto"
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
desc "Automatically log into local SSH and run read-only macOS checks"
|
|
30
|
-
task :ssh_auto do
|
|
31
|
-
ruby "examples/ssh_auto.rb", "--no-interact"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
desc "Run the local two-terminal kibitz scenarios and save logs (no SSH required)"
|
|
35
|
-
task :kibitz do
|
|
36
|
-
ruby "examples/kibitz/test_kibitz.rb"
|
|
37
|
-
end
|
|
38
|
-
end
|
data/benchmark/matching.rb
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
|
|
38
|
-
# 同一次等待不断追加新字节,分别对照字面与正则;跨块命中仍按声明优先级选择。
|
|
39
|
-
(runner.smoke ? [4096] : [65_536, 1_048_576]).product(%i[literal regexp]).each do |size, kind|
|
|
40
|
-
chunks = runner.smoke ? 4 : 32
|
|
41
|
-
patterns = Array.new(32) { |index| "missing#{index}" }
|
|
42
|
-
patterns[-1] = "END"
|
|
43
|
-
patterns.map! { |value| Regexp.new(Regexp.escape(value)) } if kind == :regexp
|
|
44
|
-
verify = lambda do |result|
|
|
45
|
-
ExpectBenchmark.check(result && result[1].number == 32 && result[2] == [size + (chunks * 1024), 3, []])
|
|
46
|
-
end
|
|
47
|
-
runner.measure("stream/#{size}/#{kind}", bytes: size + (chunks * 1024) + 3,
|
|
48
|
-
inputs: { initial_bytes: size, chunks: chunks, patterns: 32, kind: kind },
|
|
49
|
-
iterations: 5, verify: verify) do
|
|
50
|
-
session.buffer = "x" * size
|
|
51
|
-
matcher = Expect::Matcher.new(Expect::PatternList.new([session], patterns), nil)
|
|
52
|
-
ExpectBenchmark.check(matcher.__send__(:find_match).nil?)
|
|
53
|
-
chunks.times do
|
|
54
|
-
writer.write("x" * 1024)
|
|
55
|
-
session.__send__(:read_available)
|
|
56
|
-
ExpectBenchmark.check(matcher.__send__(:find_match).nil?)
|
|
57
|
-
end
|
|
58
|
-
writer.write("END")
|
|
59
|
-
session.__send__(:read_available)
|
|
60
|
-
matcher.__send__(:find_match)
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
ensure
|
|
64
|
-
session.close
|
|
65
|
-
reader.close
|
|
66
|
-
writer.close
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
[1, 8, 32].each do |count|
|
|
70
|
-
pipes = Array.new(count) { IO.pipe }
|
|
71
|
-
sessions = pipes.map { |input, _| Expect.open(input, log_stdout: false) }
|
|
72
|
-
begin
|
|
73
|
-
sessions.each { |source| source.buffer = "x" * 4096 }
|
|
74
|
-
list = Expect::PatternList.new
|
|
75
|
-
# 每轮交替反转来源组,确保同一会话真实出现在多个非相邻组。
|
|
76
|
-
8.times { |index| list.on(/missing/, from: index.even? ? sessions : sessions.reverse) }
|
|
77
|
-
# 单会话的相邻相同组会合并;保持普通单会话路径作为对照。
|
|
78
|
-
matcher = Expect::Matcher.new(list, 0)
|
|
79
|
-
runner.measure("groups/#{count}", bytes: 4096 * count, inputs: { sessions: count, groups: list.groups.size },
|
|
80
|
-
verify: lambda { |result|
|
|
81
|
-
ExpectBenchmark.check(result.nil?)
|
|
82
|
-
}) { matcher.__send__(:find_match) }
|
|
83
|
-
matcher.run
|
|
84
|
-
verify = lambda do |result|
|
|
85
|
-
ExpectBenchmark.check(result == :retry && sessions.all? { |source| source.buffer == "r" })
|
|
86
|
-
end
|
|
87
|
-
runner.measure("ready/#{count}", bytes: count, inputs: { sessions: count }, verify: verify) do
|
|
88
|
-
sessions.each(&:clear_buffer)
|
|
89
|
-
pipes.each { |pipe| pipe.last.write("r") }
|
|
90
|
-
ready = IO.select(pipes.map(&:first), nil, nil, 0).first
|
|
91
|
-
matcher.__send__(:read_ready, ready, sessions)
|
|
92
|
-
end
|
|
93
|
-
ensure
|
|
94
|
-
sessions.each(&:close)
|
|
95
|
-
pipes.flatten.each(&:close)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
runner.finish
|
data/benchmark/redactor.rb
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "support"
|
|
4
|
-
|
|
5
|
-
# 同一驱动可加载基线或候选库;每轮创建独立过滤器,不能跨样本继承流状态。
|
|
6
|
-
module RedactorBenchmark
|
|
7
|
-
def self.measure(runner, name, input, patterns, expected, chunk_size: nil, partial: false)
|
|
8
|
-
chunks = if chunk_size
|
|
9
|
-
(0...input.bytesize).step(chunk_size).map { |offset| input.byteslice(offset, chunk_size) }
|
|
10
|
-
else
|
|
11
|
-
[input]
|
|
12
|
-
end
|
|
13
|
-
runner.measure(name, bytes: input.bytesize,
|
|
14
|
-
inputs: { bytes: input.bytesize, secret_lengths: patterns.map(&:bytesize),
|
|
15
|
-
chunk_size: chunk_size, partial: partial, repeated_finish: true },
|
|
16
|
-
verify: ->(output) { ExpectBenchmark.check(output == expected, "incorrect #{name} output") },
|
|
17
|
-
iterations: 5) do
|
|
18
|
-
filter = Expect::Redactor.new(patterns)
|
|
19
|
-
output = +"".b
|
|
20
|
-
chunks.each { |chunk| output << filter.append(chunk) }
|
|
21
|
-
output << filter.finish(partial: partial) << filter.finish(partial: partial)
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
runner = ExpectBenchmark::Runner.new("redactor")
|
|
27
|
-
size = runner.smoke ? 512 : 65_536
|
|
28
|
-
plain = "x" * size
|
|
29
|
-
RedactorBenchmark.measure(runner, "empty_rules", plain, [], plain)
|
|
30
|
-
RedactorBenchmark.measure(runner, "no_match", plain, ["secret"], plain)
|
|
31
|
-
RedactorBenchmark.measure(runner, "no_match_many", plain, %w[secret password token credential], plain)
|
|
32
|
-
RedactorBenchmark.measure(runner, "sparse", "#{plain}secret!", ["secret"], "#{plain}[FILTERED]!")
|
|
33
|
-
RedactorBenchmark.measure(runner, "contiguous_short", "token" * (size / 5), ["token"], "[FILTERED]")
|
|
34
|
-
|
|
35
|
-
(runner.smoke ? [1, 32, 128] : [1, 32, 1024, 4096]).each do |length|
|
|
36
|
-
RedactorBenchmark.measure(runner, "overlap_#{length}", "a" * size, ["a" * length], "[FILTERED]")
|
|
37
|
-
end
|
|
38
|
-
count = runner.smoke ? 8 : 256
|
|
39
|
-
RedactorBenchmark.measure(runner, "contained_patterns", "abcde!" * count,
|
|
40
|
-
%w[abc bc ab bcd], "[FILTERED]e!" * count)
|
|
41
|
-
RedactorBenchmark.measure(runner, "binary_overlap", "\0\xffABC\x80!".b * count,
|
|
42
|
-
["\xffABC".b, "ABC\x80".b], "\0[FILTERED]!".b * count, chunk_size: 3)
|
|
43
|
-
RedactorBenchmark.measure(runner, "marker_in_input", "[FILTERED] secret!" * count,
|
|
44
|
-
%w[secret FILTERED], "[[FILTERED]] [FILTERED]!" * count)
|
|
45
|
-
|
|
46
|
-
[1, 7, 4096].each do |chunk_size|
|
|
47
|
-
RedactorBenchmark.measure(runner, "stream_#{chunk_size}", "prefix secret!\n" * count,
|
|
48
|
-
["secret"], "prefix [FILTERED]!\n" * count, chunk_size: chunk_size)
|
|
49
|
-
end
|
|
50
|
-
[true, false].each do |partial|
|
|
51
|
-
RedactorBenchmark.measure(runner, "partial_#{partial}", "prefix pass", ["password"],
|
|
52
|
-
partial ? "prefix [FILTERED]" : "prefix pass", chunk_size: 1, partial: partial)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
runner.measure("update_pending", bytes: 10, inputs: { update: "keep old mask and match new secret" },
|
|
56
|
-
verify: lambda { |output|
|
|
57
|
-
ExpectBenchmark.check(output == "[FILTERED]!")
|
|
58
|
-
}, iterations: 100) do
|
|
59
|
-
filter = Expect::Redactor.new(%w[abc long-pattern])
|
|
60
|
-
output = filter.append("abcsec")
|
|
61
|
-
filter.patterns = ["secret"]
|
|
62
|
-
output << filter.append("ret!") << filter.finish << filter.finish
|
|
63
|
-
end
|
|
64
|
-
runner.finish
|
data/benchmark/relay.rb
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
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
|
data/benchmark/scaling.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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/send_slow.rb
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
data/benchmark/support.rb
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
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
|
-
yield options if block_given?
|
|
33
|
-
end.parse!
|
|
34
|
-
unless @samples.positive? && (!@iterations || @iterations.positive?)
|
|
35
|
-
raise ArgumentError, "counts must be positive"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
require File.join(@library, "expect")
|
|
39
|
-
@results = []
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def measure(name, bytes:, inputs:, verify:, iterations: 100, &operation)
|
|
43
|
-
iterations = @iterations || (smoke ? 1 : iterations)
|
|
44
|
-
samples = smoke ? 1 : @samples
|
|
45
|
-
verify.call(operation.call) # 同时预热;错误输出永远不能成为更快的样本。
|
|
46
|
-
measurements = Array.new(samples) do
|
|
47
|
-
GC.start
|
|
48
|
-
resources_before = resources
|
|
49
|
-
before = GC.stat
|
|
50
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
-
result = nil
|
|
52
|
-
iterations.times { result = operation.call }
|
|
53
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
54
|
-
after = GC.stat
|
|
55
|
-
verify.call(result)
|
|
56
|
-
{
|
|
57
|
-
seconds: elapsed, allocated_objects: after[:total_allocated_objects] - before[:total_allocated_objects],
|
|
58
|
-
gc_count: after[:count] - before[:count], processed_bytes: bytes * iterations,
|
|
59
|
-
resources_before: resources_before, resources_after: resources
|
|
60
|
-
}
|
|
61
|
-
end
|
|
62
|
-
@results << { name: name, inputs: inputs, iterations: iterations, samples: measurements }
|
|
63
|
-
puts "#{name}: #{format("%.6f", measurements.map { |row| row[:seconds] }.sort[samples / 2])}s"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def finish
|
|
67
|
-
root = File.dirname(library)
|
|
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
|
|
73
|
-
digest = Digest::SHA256.new
|
|
74
|
-
Dir[File.join(library, "**/*.rb")].each do |path|
|
|
75
|
-
digest << path.delete_prefix(library) << File.binread(path)
|
|
76
|
-
end
|
|
77
|
-
report = {
|
|
78
|
-
ruby: RUBY_DESCRIPTION, platform: RUBY_PLATFORM, revision: sha&.strip,
|
|
79
|
-
fd_limit: Process.getrlimit(:NOFILE).first,
|
|
80
|
-
dirty: dirty.nil? ? nil : !dirty.empty?, library_sha256: digest.hexdigest, smoke: smoke, results: @results
|
|
81
|
-
}
|
|
82
|
-
FileUtils.mkdir_p(File.dirname(@output))
|
|
83
|
-
File.write(@output, "#{JSON.pretty_generate(report)}\n")
|
|
84
|
-
puts "Saved #{@output}"
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
private
|
|
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
|
-
|
|
102
|
-
# 安装包和源码归档可能没有 Git;未知状态记录为 nil,不误报为干净提交。
|
|
103
|
-
def git_output(root, *)
|
|
104
|
-
output, _, status = Open3.capture3("git", "-C", root, *)
|
|
105
|
-
output if status.success?
|
|
106
|
-
rescue Errno::ENOENT
|
|
107
|
-
nil
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def self.check(condition, message = "incorrect benchmark output")
|
|
112
|
-
raise message unless condition
|
|
113
|
-
end
|
|
114
|
-
end
|
data/docs/COMPATIBILITY.md
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# Ruby 接口与 Expect 行为对照
|
|
2
|
-
|
|
3
|
-
## Tcl Expect 语义边界
|
|
4
|
-
|
|
5
|
-
参考 Tcl Expect 的[官方手册](https://core.tcl-lang.org/expect/doc/trunk/expect.man)、
|
|
6
|
-
[`expect.c`](https://core.tcl-lang.org/expect/raw/expect.c?ci=trunk) 的 `eval_cases` / `expMatchProcess` / 计时循环,
|
|
7
|
-
以及 [`exp_inter.c`](https://core.tcl-lang.org/expect/raw/exp_inter.c?ci=trunk) 的 `intMatch` 与终端恢复流程。
|
|
8
|
-
本库借鉴交互模型,保留以下明确差异,不承诺 Tcl 脚本或完整功能兼容。
|
|
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` 只保留尾部字节,默认无限;`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` 仅过滤日志和诊断,协议转发与匹配仍使用原始字节。
|
|
24
|
-
|
|
25
|
-
## Expect.pm 接口迁移
|
|
26
|
-
|
|
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,并保留原期限 |
|
|
68
|
-
|
|
69
|
-
## 保留的交互能力
|
|
70
|
-
|
|
71
|
-
仍支持真实控制终端、精确/正则匹配、模式优先级、捕获组、二进制与分片
|
|
72
|
-
UTF-8、EOF/超时回调、绝对期限与接收重置、多会话、缓冲上限、慢速写入和背压、路径/IO/回调日志、监听组、人工接管、跨读取转义及终端恢复。
|
|
73
|
-
|
|
74
|
-
`expect` 返回模式序号或 `nil`;`expect_result` / `last_result` 返回原生七字段 `Struct`。`to_a`、`to_h`、模式解构使用 Ruby
|
|
75
|
-
自带行为,多重赋值需要显式 `result.to_a`。日志按实际读取时记录,转接与匹配切换不重复写日志;显式启用 `redact` 时过滤已注册秘密。
|
|
76
|
-
|
|
77
|
-
## 软关闭与硬关闭
|
|
78
|
-
|
|
79
|
-
参考原版的策略边界:`soft_close` 先收集剩余输出,关闭所属句柄,等待退出并最多发送 TERM; **不发送 KILL**。未退出返回 `nil`,保留
|
|
80
|
-
PID。`hard_close` 不等待输出,必要时 TERM、KILL 并回收子进程。关闭会话 IO 不代表子进程已经退出。
|
|
81
|
-
|
|
82
|
-
Ruby 使用关键字指定各阶段期限,关闭方法返回 `Process::Status` 或 `nil`。`close(graceful: true)` 和 `graceful_close = true`
|
|
83
|
-
先尝试软关闭,再在 `ensure` 中硬关闭;这对应原版销毁时可选软关闭、随后硬关闭的清理策略。`close` 返回 `nil`;GC
|
|
84
|
-
兜底直接强制清理,不阻塞等待。
|
|
85
|
-
|
|
86
|
-
## 有意采用的 Ruby 语义
|
|
87
|
-
|
|
88
|
-
- 默认关闭 stdout 输出,显式启用 `log_stdout`。配置按会话隔离,构造参数覆盖全局默认;配置异常不发布部分状态。
|
|
89
|
-
- 仅 `nil` 和 `false` 为假,`0` 为真。转接回调也采用此规则。
|
|
90
|
-
- 字符串始终是字面文本;只有 `:eof` / `:timeout` 是事件,`"EOF"` 是普通转义文本。
|
|
91
|
-
- 超时回调接收所有仍在监听的会话;重复注册超时回调抛出 `ArgumentError`。
|
|
92
|
-
- `write` 返回字节数,`puts` 返回 `nil`,`<<` 返回会话。
|
|
93
|
-
- 启动失败抛出 `Expect::SpawnError` 并回收;EOF 不主动终止仍存活的进程。借用 IO 不关闭,接管 IO 在初始化失败或会话关闭时释放。
|
|
94
|
-
- Perl 特有的正则、IO::Pty 继承 API、全局信号 handler 和 Solaris 字节删除补丁不移植。Ruby 使用 `to_io`、`slave`、`io/console`
|
|
95
|
-
和原生正则。
|
|
96
|
-
|
|
97
|
-
`test/compare_upstream.rb` 仅对共同的交互行为作可选差分验证,通过 Ruby 新接口表达同样场景,并显式转换错误标记和可读会话索引;它不要求或证明旧
|
|
98
|
-
API 兼容,也不等于运行完整上游测试套件。
|