expect-pty 0.5.3 → 0.7.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 +55 -1
- data/README.md +122 -130
- data/docs/API.md +113 -0
- data/docs/MIGRATION.md +68 -0
- data/lib/expect/cleanup.rb +34 -0
- data/lib/expect/interaction.rb +228 -210
- data/lib/expect/logging.rb +117 -162
- data/lib/expect/matcher.rb +42 -38
- data/lib/expect/pattern.rb +10 -3
- data/lib/expect/pattern_list.rb +39 -9
- data/lib/expect/redactor.rb +12 -5
- data/lib/expect/relay.rb +106 -81
- data/lib/expect/relay_writer.rb +7 -5
- data/lib/expect/result.rb +33 -9
- data/lib/expect/session.rb +554 -0
- data/lib/expect/session_resources.rb +19 -18
- data/lib/expect/version.rb +2 -2
- data/lib/expect.rb +68 -578
- data/sig/expect.rbs +279 -0
- metadata +14 -123
- 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/lib/expect/configuration.rb +0 -122
- data/lib/expect/terminal.rb +0 -102
- 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/examples/kibitz/kibitz.rb
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
require "tmpdir"
|
|
5
|
-
require "optparse"
|
|
6
|
-
require "shellwords"
|
|
7
|
-
require_relative "../../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
# Local counterpart of jacoby/expect.pm examples/kibitz: both keyboards feed
|
|
10
|
-
# one process, whose output is broadcast to both terminals using listeners.
|
|
11
|
-
module Kibitz
|
|
12
|
-
ESCAPE = "\x1d".b.freeze
|
|
13
|
-
|
|
14
|
-
def self.relay(input:, output:, peer:, shell: nil, escape: ESCAPE, timeout: nil, log: nil)
|
|
15
|
-
Expect.open(input, log_stdout: false, write_timeout: 5) do |keyboard|
|
|
16
|
-
Expect.open(peer, log_stdout: false, write_timeout: 5) do |partner|
|
|
17
|
-
keyboard.on_sequence(escape) if escape
|
|
18
|
-
if shell
|
|
19
|
-
keyboard.listeners = [shell]
|
|
20
|
-
partner.listeners = [shell]
|
|
21
|
-
shell.listeners = [output, partner]
|
|
22
|
-
shell.log_stdout = false
|
|
23
|
-
shell.log_output = log if log
|
|
24
|
-
# Keep canonical input, echo and ISIG on the process's own terminal.
|
|
25
|
-
shell.raw_terminal = false
|
|
26
|
-
else
|
|
27
|
-
keyboard.listeners = [partner]
|
|
28
|
-
partner.listeners = [output]
|
|
29
|
-
partner.log_output = log if log
|
|
30
|
-
end
|
|
31
|
-
stopped = Expect.interconnect(*[keyboard, partner, shell].compact, timeout: timeout)
|
|
32
|
-
reason = if stopped.nil?
|
|
33
|
-
:timeout
|
|
34
|
-
elsif stopped.equal?(keyboard)
|
|
35
|
-
:local
|
|
36
|
-
elsif stopped.equal?(partner)
|
|
37
|
-
:peer
|
|
38
|
-
else
|
|
39
|
-
:process
|
|
40
|
-
end
|
|
41
|
-
{ reason: reason, tail: keyboard.buffer }
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def self.escape(value)
|
|
47
|
-
return (value.getbyte(1) & 31).chr if value.match?(/\A\^[@A-Z\[\\\]\^_]\z/)
|
|
48
|
-
|
|
49
|
-
raise OptionParser::InvalidArgument, "escape must not be empty" if value.empty?
|
|
50
|
-
|
|
51
|
-
value
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- CLI 建连、终端接管和 ensure 清理共享一次生命周期。
|
|
55
|
-
def self.run(argv, input: $stdin, output: $stdout)
|
|
56
|
-
options = { escape: ESCAPE }
|
|
57
|
-
parser = OptionParser.new do |opts|
|
|
58
|
-
opts.banner = "Usage: ruby examples/kibitz/kibitz.rb [options] [-- program args...]"
|
|
59
|
-
opts.on("--join SOCKET", "Join the local session printed by its host") { |value| options[:join] = value }
|
|
60
|
-
opts.on("--noproc", "Exchange input directly, with no shared process or local echo") { options[:noproc] = true }
|
|
61
|
-
opts.on("--escape TEXT", "Local exit sequence (default ^]; caret notation accepted)") do |value|
|
|
62
|
-
options[:escape] = escape(value)
|
|
63
|
-
end
|
|
64
|
-
opts.on("--noescape", "Forward all keys; end by exiting the shared process") { options[:escape] = nil }
|
|
65
|
-
opts.on("--timeout SECONDS", Float, "Limit connection wait and then relay duration") do |value|
|
|
66
|
-
options[:timeout] = value
|
|
67
|
-
end
|
|
68
|
-
opts.on("--log FILE", "Create a new received-output log (0600)") { |value| options[:log] = value }
|
|
69
|
-
opts.on("-h", "--help") do
|
|
70
|
-
output.puts(opts)
|
|
71
|
-
return 0
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
parser.order!(argv)
|
|
75
|
-
raise OptionParser::InvalidArgument, "a terminal is required" unless input.tty?
|
|
76
|
-
if options[:timeout] && (!options[:timeout].finite? || options[:timeout] <= 0)
|
|
77
|
-
raise OptionParser::InvalidArgument,
|
|
78
|
-
"timeout must be positive and finite"
|
|
79
|
-
end
|
|
80
|
-
if options[:join] && (options[:noproc] || !argv.empty?)
|
|
81
|
-
raise OptionParser::InvalidArgument,
|
|
82
|
-
"--join cannot start a program or use --noproc"
|
|
83
|
-
end
|
|
84
|
-
raise OptionParser::InvalidArgument, "--noproc cannot start a program" if options[:noproc] && !argv.empty?
|
|
85
|
-
|
|
86
|
-
output.sync = true
|
|
87
|
-
# The ensure below closes the log after both terminal connections end.
|
|
88
|
-
log = File.open(options[:log], File::WRONLY | File::CREAT | File::EXCL, 0o600) if options[:log] # rubocop:disable Style/FileOpen
|
|
89
|
-
|
|
90
|
-
if options[:join]
|
|
91
|
-
peer = UNIXSocket.new(options[:join])
|
|
92
|
-
else
|
|
93
|
-
# A short, private socket path works with macOS's sockaddr_un limit.
|
|
94
|
-
directory = Dir.mktmpdir("expect-kibitz-", "/tmp")
|
|
95
|
-
socket_path = File.join(directory, "peer.sock")
|
|
96
|
-
server = UNIXServer.new(socket_path)
|
|
97
|
-
File.chmod(0o600, socket_path)
|
|
98
|
-
output.puts("Join from another terminal on this machine:")
|
|
99
|
-
output.puts(Shellwords.join([RbConfig.ruby, File.expand_path(__FILE__), "--join", socket_path]))
|
|
100
|
-
raise IOError, "timed out waiting for partner" unless server.wait_readable(options[:timeout])
|
|
101
|
-
|
|
102
|
-
peer = server.accept
|
|
103
|
-
server.close
|
|
104
|
-
File.unlink(socket_path)
|
|
105
|
-
unless options[:noproc]
|
|
106
|
-
command = argv.empty? ? [ENV.fetch("SHELL", "/bin/sh")] : argv
|
|
107
|
-
shell = Expect.spawn(*command, log_stdout: false, raw_terminal: false, write_timeout: 5)
|
|
108
|
-
shell.winsize = input.winsize
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
# Announce readiness only after local echo/canonical input are disabled.
|
|
112
|
-
# A fast typist (or PTY driver) may send bytes immediately after the banner.
|
|
113
|
-
result = input.raw do
|
|
114
|
-
hint = options[:escape] ? "Escape #{options[:escape].inspect} ends this session." : "Escape disabled."
|
|
115
|
-
output.write("Kibitz connected. #{hint}\r\n")
|
|
116
|
-
relay(input: input, output: output, peer: peer, shell: shell,
|
|
117
|
-
escape: options[:escape], timeout: options[:timeout], log: log)
|
|
118
|
-
end
|
|
119
|
-
if result[:reason] == :process
|
|
120
|
-
shell.soft_close(timeout: 2)
|
|
121
|
-
status = shell.exit_code || 1
|
|
122
|
-
else
|
|
123
|
-
status = result[:reason] == :timeout ? 1 : 0
|
|
124
|
-
end
|
|
125
|
-
output.puts("\nKibitz ended (#{result[:reason]}).")
|
|
126
|
-
status
|
|
127
|
-
rescue OptionParser::ParseError, IOError, SystemCallError, Expect::SpawnError => error
|
|
128
|
-
warn "kibitz: #{error.message}"
|
|
129
|
-
1
|
|
130
|
-
ensure
|
|
131
|
-
shell&.close
|
|
132
|
-
peer&.close
|
|
133
|
-
server&.close unless server&.closed?
|
|
134
|
-
log&.close
|
|
135
|
-
File.unlink(socket_path) if socket_path && File.socket?(socket_path)
|
|
136
|
-
Dir.rmdir(directory) if directory && Dir.exist?(directory)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
exit Kibitz.run(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "tmpdir"
|
|
6
|
-
require "time"
|
|
7
|
-
require_relative "../../test/support/kibitz_probe"
|
|
8
|
-
|
|
9
|
-
base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", "../../tmp/kibitz"), __dir__)
|
|
10
|
-
FileUtils.mkdir_p(base)
|
|
11
|
-
directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
|
|
12
|
-
report = { passed: false, cases: [], started_at: Time.now.utc.iso8601 }
|
|
13
|
-
begin
|
|
14
|
-
{
|
|
15
|
-
shared_shell: ->(dir) { KibitzProbe.shared_shell(dir) },
|
|
16
|
-
host_escape: ->(dir) { KibitzProbe.direct(dir, ending: :host) },
|
|
17
|
-
guest_escape: ->(dir) { KibitzProbe.direct(dir, ending: :guest) },
|
|
18
|
-
noescape: ->(dir) { KibitzProbe.noescape(dir) },
|
|
19
|
-
process_failure: ->(dir) { KibitzProbe.process_failure(dir) },
|
|
20
|
-
relay_timeout: ->(dir) { KibitzProbe.relay_timeout(dir) }
|
|
21
|
-
}.each do |name, probe|
|
|
22
|
-
path = File.join(directory, name.to_s)
|
|
23
|
-
Dir.mkdir(path, 0o700)
|
|
24
|
-
result = probe.call(path)
|
|
25
|
-
report[:cases] << result.merge(log_path: File.join(path, "session.log"))
|
|
26
|
-
puts "PASS #{name}: #{result[:checks].join(", ")}"
|
|
27
|
-
end
|
|
28
|
-
report[:passed] = true
|
|
29
|
-
rescue StandardError => error
|
|
30
|
-
report[:error] = "#{error.class}: #{error.message}"
|
|
31
|
-
ensure
|
|
32
|
-
report[:finished_at] = Time.now.utc.iso8601
|
|
33
|
-
path = File.join(directory, "report.json")
|
|
34
|
-
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
|
|
35
|
-
puts "Report: #{path}"
|
|
36
|
-
end
|
|
37
|
-
abort(report[:error]) unless report[:passed]
|
data/examples/ssh_auto.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "tempfile"
|
|
6
|
-
require "tmpdir"
|
|
7
|
-
require_relative "../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
# 在这里按顺序填写要执行的 macOS / POSIX shell 命令。
|
|
10
|
-
COMMANDS = ["id", "uname -srm", "sw_vers", "df -h /", "uptime"].freeze
|
|
11
|
-
PROMPT = "MACOS> "
|
|
12
|
-
|
|
13
|
-
if ARGV.delete("--help")
|
|
14
|
-
puts "用法:ruby examples/ssh_auto.rb [--no-interact]"
|
|
15
|
-
puts "自动 SSH 登录,执行 COMMANDS 后进入 interact;--no-interact 执行后退出。"
|
|
16
|
-
puts "环境变量:SSH_HOST、SSH_USER、SSH_PORT、SSH_KNOWN_HOSTS、EXPECT_PASSWORD、EXPECT_LOG_DIR。"
|
|
17
|
-
exit
|
|
18
|
-
end
|
|
19
|
-
interactive = !ARGV.delete("--no-interact")
|
|
20
|
-
abort "未知参数:#{ARGV.join(" ")}" unless ARGV.empty?
|
|
21
|
-
abort "interact 需要终端;批量执行请加 --no-interact" if interactive && !$stdin.tty?
|
|
22
|
-
|
|
23
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
24
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
25
|
-
port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
|
|
26
|
-
abort "SSH_PORT 应为 1..65535" unless (1..65_535).cover?(port)
|
|
27
|
-
abort "SSH_HOST / SSH_USER 格式无效" if [host, user].any? do |value|
|
|
28
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
29
|
-
end
|
|
30
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
31
|
-
abort "连接其他主机时请设置 SSH_KNOWN_HOSTS" unless known_hosts || %w[127.0.0.1 ::1 localhost].include?(host)
|
|
32
|
-
|
|
33
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
34
|
-
log = nil
|
|
35
|
-
begin
|
|
36
|
-
unless password
|
|
37
|
-
$stderr.print("SSH password: ")
|
|
38
|
-
password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
|
|
39
|
-
$stderr.puts
|
|
40
|
-
end
|
|
41
|
-
abort "请输入单行密码" if password.nil? || password.empty? || password.match?(/[\r\n\x00]/)
|
|
42
|
-
|
|
43
|
-
Dir.mktmpdir("expect-ssh-") do |dir|
|
|
44
|
-
ssh_args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
|
|
45
|
-
"-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
|
|
46
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
47
|
-
"-o", "StrictHostKeyChecking=#{known_hosts ? "yes" : "accept-new"}",
|
|
48
|
-
"-o", "UserKnownHostsFile=#{known_hosts || File.join(dir, "known_hosts")}",
|
|
49
|
-
"-l", user, host, "env ENV= PS1=#{Shellwords.escape(PROMPT)} /bin/sh -i"]
|
|
50
|
-
|
|
51
|
-
Expect.spawn(*ssh_args, raw_pty: true, log_stdout: false, write_timeout: 5) do |ssh|
|
|
52
|
-
# 登录成功后才开启日志,密码不写入文件。
|
|
53
|
-
ssh.expect(/password:\s*\z/i, timeout: 10) or abort "未收到密码提示:#{ssh.error}"
|
|
54
|
-
ssh.write(password, "\n")
|
|
55
|
-
ssh.expect(PROMPT, timeout: 10) or abort "SSH 登录失败:#{ssh.error}"
|
|
56
|
-
password.replace("\0" * password.bytesize)
|
|
57
|
-
ssh.write("stty sane -echo; set +o emacs; set +o vi\n")
|
|
58
|
-
ssh.expect(PROMPT, timeout: 5) or abort "终端初始化失败:#{ssh.error}"
|
|
59
|
-
|
|
60
|
-
log_dir = ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-auto", __dir__))
|
|
61
|
-
FileUtils.mkdir_p(log_dir)
|
|
62
|
-
log = Tempfile.create(["session-", ".log"], log_dir) # 0600,退出后保留
|
|
63
|
-
ssh.log_to(log)
|
|
64
|
-
puts "已登录 #{user}@#{host};日志:#{log.path}"
|
|
65
|
-
|
|
66
|
-
COMMANDS.each do |command|
|
|
67
|
-
puts "\n$ #{command}"
|
|
68
|
-
ssh.write_log("\n$ #{command}\n")
|
|
69
|
-
token = SecureRandom.hex(8)
|
|
70
|
-
# 远端拼接结束标记并带回退出码,避免把命令回显当成执行结果。
|
|
71
|
-
ssh.write("#{command}; printf '\\n__DONE_%s:%s\\n' '#{token}' \"$?\"\n")
|
|
72
|
-
ssh.expect(/\r?\n__DONE_#{token}:(\d+)\r?\n#{Regexp.escape(PROMPT)}/, timeout: 30) or abort "命令未完成:#{ssh.error}"
|
|
73
|
-
print ssh.before.gsub("\r\n", "\n")
|
|
74
|
-
abort "命令失败(退出码 #{ssh.captures.first}):#{command}" unless ssh.captures.first == "0"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
if interactive
|
|
78
|
-
ssh.write("stty echo\n")
|
|
79
|
-
ssh.expect(PROMPT, timeout: 5) or abort "无法进入交互:#{ssh.error}"
|
|
80
|
-
puts "\n检查完成,进入 interact。输入 exit 或按 Ctrl-] 结束。"
|
|
81
|
-
$stdout.print(PROMPT)
|
|
82
|
-
$stdout.flush
|
|
83
|
-
next unless ssh.interact(input: $stdin, escape: "\x1d", output: $stdout).equal?(ssh)
|
|
84
|
-
else
|
|
85
|
-
ssh.write("exit\n")
|
|
86
|
-
end
|
|
87
|
-
ssh.soft_close(timeout: 3)
|
|
88
|
-
abort "SSH 退出异常:#{ssh.exit_code.inspect}" unless ssh.exit_code&.zero?
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
ensure
|
|
92
|
-
log&.close
|
|
93
|
-
password&.replace("\0" * password.bytesize)
|
|
94
|
-
end
|
data/examples/ssh_interact.rb
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Run from the checkout or the unpacked gem. Test helpers use only standard
|
|
4
|
-
# libraries; Minitest is not needed for this live SSH example.
|
|
5
|
-
require "io/console"
|
|
6
|
-
require "json"
|
|
7
|
-
require "fileutils"
|
|
8
|
-
require "tmpdir"
|
|
9
|
-
require "time"
|
|
10
|
-
require_relative "../test/support/interact_probe"
|
|
11
|
-
|
|
12
|
-
if ARGV.delete("--help")
|
|
13
|
-
puts <<~HELP
|
|
14
|
-
Usage: ruby examples/ssh_interact.rb [--auto]
|
|
15
|
-
|
|
16
|
-
Default: SSH login with visible typing; Ctrl-] returns to expect, exit ends SSH.
|
|
17
|
-
--auto: Drive a real local PTY to verify commands, Ctrl-C, Ctrl-] and reentry.
|
|
18
|
-
|
|
19
|
-
Environment: SSH_HOST (127.0.0.1), SSH_USER (current user), SSH_PORT (22),
|
|
20
|
-
SSH_KNOWN_HOSTS, EXPECT_PASSWORD, EXPECT_LOG_DIR.
|
|
21
|
-
Password input is hidden. Non-loopback hosts require SSH_KNOWN_HOSTS.
|
|
22
|
-
Logs and JSON reports default to tmp/ssh-interact/ in this project.
|
|
23
|
-
HELP
|
|
24
|
-
exit
|
|
25
|
-
end
|
|
26
|
-
automatic = !ARGV.delete("--auto").nil?
|
|
27
|
-
abort "unknown arguments: #{ARGV.join(" ")} (use --help)" unless ARGV.empty?
|
|
28
|
-
abort "manual interact requires a terminal; use --auto for unattended testing" unless automatic || $stdin.tty?
|
|
29
|
-
|
|
30
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
31
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
32
|
-
port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
|
|
33
|
-
ScriptProbe.check((1..65_535).cover?(port), "SSH_PORT must be between 1 and 65535")
|
|
34
|
-
ScriptProbe.check([host, user].none? do |value|
|
|
35
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
36
|
-
end, "invalid SSH host or user")
|
|
37
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
38
|
-
ScriptProbe.check(known_hosts || %w[127.0.0.1 ::1 localhost].include?(host),
|
|
39
|
-
"SSH_KNOWN_HOSTS is required for remote hosts")
|
|
40
|
-
|
|
41
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
42
|
-
unless password
|
|
43
|
-
$stderr.print("SSH password: ")
|
|
44
|
-
password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
|
|
45
|
-
$stderr.puts
|
|
46
|
-
end
|
|
47
|
-
ScriptProbe.check(password && !password.empty? && !password.match?(/[\r\n\x00]/), "a single-line password is required")
|
|
48
|
-
base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-interact", __dir__)))
|
|
49
|
-
FileUtils.mkdir_p(base)
|
|
50
|
-
directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
|
|
51
|
-
log_path = File.join(directory, "session.log")
|
|
52
|
-
report_path = File.join(directory, "report.json")
|
|
53
|
-
report = { mode: automatic ? "automatic" : "manual", host: host, port: port, user: user,
|
|
54
|
-
started_at: Time.now.utc.iso8601, passed: false, cases: [], checks: [] }
|
|
55
|
-
session = nil
|
|
56
|
-
local_terminal = nil
|
|
57
|
-
|
|
58
|
-
begin
|
|
59
|
-
Dir.mktmpdir("expect-known-hosts-") do |temporary|
|
|
60
|
-
policy = known_hosts ? "yes" : "accept-new"
|
|
61
|
-
hosts_path = known_hosts || File.join(temporary, "known_hosts")
|
|
62
|
-
args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
|
|
63
|
-
"-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
|
|
64
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
65
|
-
"-o", "StrictHostKeyChecking=#{policy}", "-o", "UserKnownHostsFile=#{hosts_path}",
|
|
66
|
-
"-l", user, host, "env ENV= PS1=#{Shellwords.escape(ScriptProbe::PROMPT)} /bin/sh -i"]
|
|
67
|
-
session = Expect.spawn(*args, raw_pty: true, log_stdout: false, log_listeners: false,
|
|
68
|
-
debug_level: 0, write_timeout: 5)
|
|
69
|
-
login = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
|
|
70
|
-
ScriptProbe.check(login == 1, "SSH password prompt missing (#{session.error || "authentication rejected"})")
|
|
71
|
-
session.write(password, "\n")
|
|
72
|
-
runner = ScriptProbe::Runner.new(session, timeout: 10).ready!
|
|
73
|
-
InteractProbe.prepare(session, echo: !automatic)
|
|
74
|
-
File.open(log_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.truncate(0) }
|
|
75
|
-
session.log_to(log_path)
|
|
76
|
-
|
|
77
|
-
remote_exit = false
|
|
78
|
-
if automatic
|
|
79
|
-
report.merge!(InteractProbe.verify(session, user: user))
|
|
80
|
-
else
|
|
81
|
-
local_terminal = Expect.open($stdin)
|
|
82
|
-
initial_mode = InteractProbe.configuration(local_terminal)
|
|
83
|
-
puts "Logged in as #{user}@#{host}. Enter commands; press Ctrl-] to return to expect."
|
|
84
|
-
puts "Log: #{log_path}"
|
|
85
|
-
# prepare consumed the initial prompt while configuring remote echo.
|
|
86
|
-
$stdout.print(ScriptProbe::PROMPT)
|
|
87
|
-
$stdout.flush
|
|
88
|
-
# This is the actual public API handoff: stdin -> SSH, SSH -> stdout.
|
|
89
|
-
returned = session.interact(input: $stdin, escape: InteractProbe::ESCAPE, output: $stdout)
|
|
90
|
-
ScriptProbe.check(InteractProbe.configuration(local_terminal) == initial_mode,
|
|
91
|
-
"stdin terminal mode was not restored")
|
|
92
|
-
report[:local_terminal_restored] = true
|
|
93
|
-
report[:checks] += %w[real_stdin_stdout terminal_restored]
|
|
94
|
-
if returned.equal?(session)
|
|
95
|
-
remote_exit = true
|
|
96
|
-
report[:completion] = "remote_exit"
|
|
97
|
-
session.soft_close(timeout: 3)
|
|
98
|
-
ScriptProbe.check(session.closed? && session.exit_code&.zero?,
|
|
99
|
-
"SSH exited with status #{session.exit_code.inspect}")
|
|
100
|
-
report[:checks] << "remote_eof"
|
|
101
|
-
else
|
|
102
|
-
ScriptProbe.check(returned.is_a?(Expect) && returned.to_io.equal?($stdin), "unexpected interact termination")
|
|
103
|
-
ScriptProbe.check(session.alive?, "remote session ended during interaction")
|
|
104
|
-
report[:completion] = "local_escape"
|
|
105
|
-
report[:checks] += %w[ctrl_bracket_escape remote_alive]
|
|
106
|
-
puts "\nReturned from interact; verifying automated expect resumes..."
|
|
107
|
-
InteractProbe.prepare(session)
|
|
108
|
-
runner.run(
|
|
109
|
-
"expect_after_manual", "printf 'MANUAL_AUTOMATION_RESUMED\\n'",
|
|
110
|
-
expected_status: 0, expected_output: "MANUAL_AUTOMATION_RESUMED\n"
|
|
111
|
-
)
|
|
112
|
-
report[:cases] = runner.results
|
|
113
|
-
report[:checks] << "expect_resume"
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
runner.finish! unless remote_exit
|
|
118
|
-
report[:ssh_exit_code] = session.exit_code
|
|
119
|
-
text = ScriptProbe.normalize(File.binread(log_path))
|
|
120
|
-
report[:cases].each do |result|
|
|
121
|
-
output = result.fetch(:output).b
|
|
122
|
-
ScriptProbe.check(text.scan(Regexp.new(Regexp.escape(output), Regexp::NOENCODING)).length == 1,
|
|
123
|
-
"#{result[:name]}: log output missing or duplicated")
|
|
124
|
-
end
|
|
125
|
-
unless remote_exit
|
|
126
|
-
ScriptProbe.check(text.scan("SESSION_FINAL_TAIL\n").length == 1,
|
|
127
|
-
"shutdown tail missing or duplicated")
|
|
128
|
-
end
|
|
129
|
-
ScriptProbe.check(!text.include?(InteractProbe::TAIL), "local escape tail leaked to SSH") if automatic
|
|
130
|
-
ScriptProbe.check(!text.include?(password.b), "password detected in log")
|
|
131
|
-
report[:checks] += %w[unique_log_output eof_drain password_absent clean_ssh_exit]
|
|
132
|
-
report[:passed] = true
|
|
133
|
-
end
|
|
134
|
-
rescue StandardError => error
|
|
135
|
-
report[:error] = "#{error.class}: #{error.message}".gsub(password, "[REDACTED]")
|
|
136
|
-
ensure
|
|
137
|
-
session&.close
|
|
138
|
-
local_terminal&.close
|
|
139
|
-
if File.file?(log_path)
|
|
140
|
-
bytes = File.binread(log_path)
|
|
141
|
-
if bytes.include?(password.b)
|
|
142
|
-
File.binwrite(log_path, bytes.gsub(password.b, "[REDACTED]"))
|
|
143
|
-
report[:passed] = false
|
|
144
|
-
report[:error] = "password detected and removed from log"
|
|
145
|
-
end
|
|
146
|
-
report[:log_bytes] = File.size(log_path)
|
|
147
|
-
report[:log_sha256] = Digest::SHA256.file(log_path).hexdigest
|
|
148
|
-
end
|
|
149
|
-
password.replace("\0" * password.bytesize)
|
|
150
|
-
report[:finished_at] = Time.now.utc.iso8601
|
|
151
|
-
report[:log_path] = log_path
|
|
152
|
-
File.open(report_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
puts "Log: #{log_path}"
|
|
156
|
-
puts "Report: #{report_path}"
|
|
157
|
-
abort(report[:error]) unless report[:passed]
|
|
158
|
-
puts "PASS #{report[:mode]} interact: #{report[:cases].length} command checks, " \
|
|
159
|
-
"#{report[:checks].length} interaction/log checks; SSH exit=0"
|
data/examples/ssh_login.rb
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Optional real SSH integration check. Password input is hidden and never logged.
|
|
4
|
-
require "io/console"
|
|
5
|
-
require "tmpdir"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
require_relative "../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
10
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
11
|
-
abort "invalid SSH host or user" if [host, user].any? do |value|
|
|
12
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
13
|
-
end
|
|
14
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
15
|
-
unless password
|
|
16
|
-
$stderr.print("SSH password: ")
|
|
17
|
-
password = $stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets
|
|
18
|
-
$stderr.puts
|
|
19
|
-
password = password&.chomp
|
|
20
|
-
end
|
|
21
|
-
abort "password is required" if password.nil? || password.empty?
|
|
22
|
-
|
|
23
|
-
begin
|
|
24
|
-
Dir.mktmpdir("expect-ssh-") do |dir|
|
|
25
|
-
# Isolated known_hosts keeps this example independent of user SSH settings.
|
|
26
|
-
# For non-loopback hosts, use an existing trusted known_hosts file.
|
|
27
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
28
|
-
abort "SSH_KNOWN_HOSTS is required for remote hosts" unless known_hosts || %w[127.0.0.1 ::1
|
|
29
|
-
localhost].include?(host)
|
|
30
|
-
policy = known_hosts ? "yes" : "accept-new"
|
|
31
|
-
known_hosts ||= File.join(dir, "known_hosts")
|
|
32
|
-
arguments = ["ssh", "-F", "/dev/null", "-tt", "-o", "ConnectTimeout=5",
|
|
33
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
34
|
-
"-o", "NumberOfPasswordPrompts=1", "-o", "StrictHostKeyChecking=#{policy}",
|
|
35
|
-
"-o", "UserKnownHostsFile=#{known_hosts}", "-l", user, host,
|
|
36
|
-
"env PS1='EXPECT_SHELL> ' /bin/sh -i"]
|
|
37
|
-
Expect.spawn(*arguments, log_stdout: false, raw_pty: true) do |session|
|
|
38
|
-
prompt = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
|
|
39
|
-
abort "SSH password prompt not received (#{session.error || "authentication rejected"})" unless prompt == 1
|
|
40
|
-
session.write(password, "\n")
|
|
41
|
-
password.replace("\0" * password.bytesize)
|
|
42
|
-
|
|
43
|
-
# Start a command after authentication. The marker is assembled remotely
|
|
44
|
-
# from two quoted arguments, so terminal command echo cannot satisfy it.
|
|
45
|
-
login = session.expect("EXPECT_SHELL> ", /Permission denied/i, timeout: 10)
|
|
46
|
-
abort "SSH login failed (#{session.error || "authentication rejected"})" unless login == 1
|
|
47
|
-
token = SecureRandom.hex(12)
|
|
48
|
-
session.write("printf '\\n%s%s\\n' 'EXPECT_OK_' '#{token}'; id -un; tty\n")
|
|
49
|
-
marker = session.expect(/EXPECT_OK_#{Regexp.escape(token)}\r?\n/, timeout: 10)
|
|
50
|
-
abort "remote command did not run (#{session.error})" unless marker
|
|
51
|
-
identity = session.expect(%r{([^\r\n]+)\r?\n(/dev/[^\r\n]+)\r?\n}, timeout: 5)
|
|
52
|
-
abort "remote identity/TTY response missing" unless identity
|
|
53
|
-
abort "unexpected SSH user" unless session.captures.first == user.b
|
|
54
|
-
puts "SSH password login verified: #{user}@#{host}"
|
|
55
|
-
puts "Remote identity: #{session.captures.first}; TTY: #{session.captures.last}"
|
|
56
|
-
session.write("exit\n")
|
|
57
|
-
session.soft_close(timeout: 5)
|
|
58
|
-
abort "SSH exit status #{session.exit_code.inspect}" unless session.exit_code&.zero?
|
|
59
|
-
puts "SSH exited cleanly (0)"
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
ensure
|
|
63
|
-
password&.replace("\0" * password.bytesize)
|
|
64
|
-
end
|
data/expect-pty.gemspec
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/expect/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "expect-pty"
|
|
7
|
-
spec.version = Expect::VERSION
|
|
8
|
-
spec.authors = ["expect-pty contributors"]
|
|
9
|
-
spec.homepage = "https://github.com/gatework/expect-ruby"
|
|
10
|
-
spec.summary = "Ruby PTY automation with the Expect.pm interaction model"
|
|
11
|
-
spec.description = "Automate interactive programs with exact and regexp matching, " \
|
|
12
|
-
"Ruby blocks, multi-session waits, logging and terminal interaction."
|
|
13
|
-
spec.license = "MIT"
|
|
14
|
-
spec.required_ruby_version = ">= 3.2"
|
|
15
|
-
spec.files = Dir[
|
|
16
|
-
"lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
|
|
17
|
-
"test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
|
|
18
|
-
".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
|
|
19
|
-
"script/ci", "script/release.rb"
|
|
20
|
-
]
|
|
21
|
-
spec.require_paths = ["lib"]
|
|
22
|
-
# 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
|
|
23
|
-
# pty 是 Ruby 自带的 POSIX 扩展,不是独立 gem。
|
|
24
|
-
spec.add_dependency "forwardable", "~> 1.3"
|
|
25
|
-
spec.add_dependency "io-console", ">= 0.6", "< 1.0"
|
|
26
|
-
spec.add_dependency "io-wait", ">= 0.3", "< 1.0"
|
|
27
|
-
spec.add_dependency "shellwords", ">= 0.1", "< 1.0"
|
|
28
|
-
spec.add_dependency "stringio", "~> 3.0"
|
|
29
|
-
spec.metadata["rubygems_mfa_required"] = "true"
|
|
30
|
-
spec.metadata["source_code_uri"] = spec.homepage
|
|
31
|
-
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
32
|
-
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
33
|
-
end
|
data/lib/expect/configuration.rb
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class Expect
|
|
4
|
-
# 集中校验会话配置。类级默认值以冻结快照发布,每个会话再构造独立副本。
|
|
5
|
-
# 这里只保存策略值,不持有 IO、缓冲或日志对象;修改默认配置不会追溯影响已创建的会话。
|
|
6
|
-
class Configuration
|
|
7
|
-
# 会话通过 Forwardable 委托这些属性;to_h 使用同一清单生成配置副本。
|
|
8
|
-
ATTRIBUTES = %i[
|
|
9
|
-
timeout write_timeout buffer_limit debug_level raw_pty preserve_buffer
|
|
10
|
-
log_stdout log_listeners raw_terminal reset_timeout_on_read graceful_close
|
|
11
|
-
].freeze
|
|
12
|
-
# 布尔属性同时提供普通读方法和问号查询,写入统一采用 Ruby 真值规则。
|
|
13
|
-
PREDICATES = %i[
|
|
14
|
-
raw_pty? preserve_buffer? log_stdout? log_listeners? raw_terminal?
|
|
15
|
-
reset_timeout_on_read? graceful_close?
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
|
-
attr_reader :timeout, :write_timeout, :buffer_limit, :debug_level, :raw_pty, :preserve_buffer, :log_stdout,
|
|
19
|
-
:log_listeners, :raw_terminal, :reset_timeout_on_read, :graceful_close
|
|
20
|
-
|
|
21
|
-
# 通过 setter 校验构造参数,确保默认值、构造覆盖和后续赋值遵守同一规则。
|
|
22
|
-
def initialize(timeout: nil, write_timeout: nil, buffer_limit: nil, debug_level: 0,
|
|
23
|
-
raw_pty: false, preserve_buffer: false, log_stdout: false,
|
|
24
|
-
log_listeners: true, raw_terminal: true, reset_timeout_on_read: false,
|
|
25
|
-
graceful_close: false)
|
|
26
|
-
self.timeout = timeout
|
|
27
|
-
self.write_timeout = write_timeout
|
|
28
|
-
self.buffer_limit = buffer_limit
|
|
29
|
-
self.debug_level = debug_level
|
|
30
|
-
self.raw_pty = raw_pty
|
|
31
|
-
self.preserve_buffer = preserve_buffer
|
|
32
|
-
self.log_stdout = log_stdout
|
|
33
|
-
self.log_listeners = log_listeners
|
|
34
|
-
self.raw_terminal = raw_terminal
|
|
35
|
-
self.reset_timeout_on_read = reset_timeout_on_read
|
|
36
|
-
self.graceful_close = graceful_close
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# 设置匹配等待的默认秒数;nil 表示无限等待,0 表示只轮询现有数据。
|
|
40
|
-
def timeout=(value)
|
|
41
|
-
@timeout = Expect.duration(value)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# 设置写入背压的等待期限;先转换和校验,失败时保留原配置。
|
|
45
|
-
def write_timeout=(value)
|
|
46
|
-
@write_timeout = Expect.duration(value)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# 限制接收缓冲保留的尾部字节数;正整数为上限,nil 为无限。
|
|
50
|
-
def buffer_limit=(value)
|
|
51
|
-
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
52
|
-
raise ArgumentError, "buffer_limit must be a positive Integer or nil"
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
@buffer_limit = value
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# 设置诊断详细程度:0 关闭,1 生命周期与匹配,2 收发内容,3 缓冲内容。
|
|
59
|
-
def debug_level=(value)
|
|
60
|
-
unless value.is_a?(Integer) && (0..3).cover?(value)
|
|
61
|
-
raise ArgumentError, "debug_level must be an Integer between 0 and 3"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
@debug_level = value
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# 控制 spawn 前是否将子进程终端设为 raw,关闭回显和换行转换。
|
|
68
|
-
def raw_pty=(value)
|
|
69
|
-
@raw_pty = !!value
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
alias raw_pty? raw_pty
|
|
73
|
-
|
|
74
|
-
# 控制匹配成功后是否保留完整缓冲;启用时由继续回调自行消费匹配内容。
|
|
75
|
-
def preserve_buffer=(value)
|
|
76
|
-
@preserve_buffer = !!value
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
alias preserve_buffer? preserve_buffer
|
|
80
|
-
|
|
81
|
-
# 控制接收字节是否同步输出到当前 $stdout;默认关闭。
|
|
82
|
-
def log_stdout=(value)
|
|
83
|
-
@log_stdout = !!value
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
alias log_stdout? log_stdout
|
|
87
|
-
|
|
88
|
-
# 控制接收字节是否转发给监听器,与 stdout 和日志目标分别管理。
|
|
89
|
-
def log_listeners=(value)
|
|
90
|
-
@log_listeners = !!value
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
alias log_listeners? log_listeners
|
|
94
|
-
|
|
95
|
-
# 控制 interact 是否临时设置并恢复本地输入终端;通用 interconnect 不修改终端模式。
|
|
96
|
-
def raw_terminal=(value)
|
|
97
|
-
@raw_terminal = !!value
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
alias raw_terminal? raw_terminal
|
|
101
|
-
|
|
102
|
-
# 控制收到任何新数据时是否刷新匹配期限,适用于按静默时长判断超时。
|
|
103
|
-
# 只刷新相对 timeout;单次等待显式指定的绝对 deadline 仍是不可延长的上限。
|
|
104
|
-
def reset_timeout_on_read=(value)
|
|
105
|
-
@reset_timeout_on_read = !!value
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
alias reset_timeout_on_read? reset_timeout_on_read
|
|
109
|
-
|
|
110
|
-
# 控制通用 close 是否先软关闭;最终资源清理仍由硬关闭兜底。
|
|
111
|
-
def graceful_close=(value)
|
|
112
|
-
@graceful_close = !!value
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
alias graceful_close? graceful_close
|
|
116
|
-
|
|
117
|
-
# 导出新的属性 Hash,用于构造会话副本或发布下一份默认配置。
|
|
118
|
-
def to_h
|
|
119
|
-
ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|