tty-command-window 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/lib/tty/command/window/ansi.rb +112 -0
- data/lib/tty/command/window/block.rb +184 -0
- data/lib/tty/command/window/child_session.rb +161 -0
- data/lib/tty/command/window/coordinator.rb +315 -0
- data/lib/tty/command/window/emulator.rb +613 -0
- data/lib/tty/command/window/input_router.rb +249 -0
- data/lib/tty/command/window/integration.rb +195 -0
- data/lib/tty/command/window/runner.rb +245 -0
- data/lib/tty/command/window/spinner.rb +30 -0
- data/lib/tty/command/window/trap_manager.rb +117 -0
- data/lib/tty/command/window/version.rb +9 -0
- data/lib/tty/command/window/window_options.rb +76 -0
- data/lib/tty/command/window.rb +110 -0
- data/lib/tty-command-window.rb +3 -0
- metadata +116 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# Routes keyboard input to interactive windows.
|
|
9
|
+
#
|
|
10
|
+
# While at least one interactive block is attached, the process's stdin
|
|
11
|
+
# is switched into raw mode and every keystroke is forwarded to the
|
|
12
|
+
# focused block's PTY. Ctrl-O cycles focus between interactive blocks.
|
|
13
|
+
#
|
|
14
|
+
# Note: in raw mode Ctrl-C is delivered to the focused child as a byte
|
|
15
|
+
# (0x03), not as SIGINT to the host process.
|
|
16
|
+
#
|
|
17
|
+
# Thread-ownership:
|
|
18
|
+
# * The class methods {.attach}/{.detach} are serialized by the class
|
|
19
|
+
# mutex and provide the process-global singleton contract.
|
|
20
|
+
# * The instance methods that mutate or read +@entries+ / +@focus_index+
|
|
21
|
+
# hold +@state_mutex+; the router thread takes the same mutex when it
|
|
22
|
+
# forwards or cycles focus, so a concurrent add/remove cannot race
|
|
23
|
+
# the reader (see review H7).
|
|
24
|
+
# * On emergency termination (SIGTERM/SIGHUP/at_exit) the terminal is
|
|
25
|
+
# restored from a saved +stty -g+ snapshot; see review H4/P0-5.
|
|
26
|
+
class InputRouter
|
|
27
|
+
FOCUS_KEY = "\x0f" # Ctrl-O
|
|
28
|
+
|
|
29
|
+
# One attached interactive window: the block that owns the focus
|
|
30
|
+
# flag, the session keystrokes are written to, and the coordinator
|
|
31
|
+
# to repaint on focus changes.
|
|
32
|
+
Entry = Struct.new(:block, :session, :coordinator)
|
|
33
|
+
|
|
34
|
+
@instance = nil
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@saved_termios = nil
|
|
37
|
+
@emergency_installed = false
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
attr_accessor :saved_termios
|
|
41
|
+
|
|
42
|
+
# Attach an interactive block; starts the router on first attach.
|
|
43
|
+
#
|
|
44
|
+
# @param block [Block] the view that owns the focus flag
|
|
45
|
+
# @param session [ChildSession] where keystrokes are written
|
|
46
|
+
# @param coordinator [Coordinator] repaint sink
|
|
47
|
+
def attach(block, session, coordinator)
|
|
48
|
+
return unless $stdin.tty?
|
|
49
|
+
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@instance ||= new
|
|
52
|
+
@instance.add(block, session, coordinator)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Detach a block; stops the router when none remain.
|
|
57
|
+
#
|
|
58
|
+
# Holds +@mutex+ across +shutdown+ so a concurrent {.attach}
|
|
59
|
+
# cannot construct a replacement router while the old one is
|
|
60
|
+
# still joining its thread and closing its wake pipes — which
|
|
61
|
+
# would race the class-level +@saved_termios+ slot and let
|
|
62
|
+
# +emergency_restore+ wipe the new router's snapshot
|
|
63
|
+
# (follow-up review P1-5).
|
|
64
|
+
def detach(block)
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
next unless @instance
|
|
67
|
+
|
|
68
|
+
@instance.remove(block)
|
|
69
|
+
if @instance.empty?
|
|
70
|
+
@instance.shutdown
|
|
71
|
+
@instance = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Restore the terminal to whatever mode it was in before we
|
|
77
|
+
# switched it into raw. Safe to call from at_exit or a signal
|
|
78
|
+
# handler. Idempotent.
|
|
79
|
+
def emergency_restore
|
|
80
|
+
saved = @saved_termios
|
|
81
|
+
return unless saved && !saved.empty?
|
|
82
|
+
|
|
83
|
+
@saved_termios = nil
|
|
84
|
+
system("stty", saved, in: "/dev/tty", err: File::NULL, out: File::NULL)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Install the emergency-restore hooks (at_exit + SIGTERM/SIGHUP)
|
|
90
|
+
# exactly once per process.
|
|
91
|
+
def install_emergency_hooks
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
return if @emergency_installed
|
|
94
|
+
|
|
95
|
+
@emergency_installed = true
|
|
96
|
+
at_exit { InputRouter.emergency_restore }
|
|
97
|
+
@termios_sub = ->(_sig) { InputRouter.emergency_restore }
|
|
98
|
+
TrapManager.subscribe("TERM", @termios_sub)
|
|
99
|
+
TrapManager.subscribe("HUP", @termios_sub)
|
|
100
|
+
TrapManager.subscribe("INT", @termios_sub)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @api private for tests
|
|
105
|
+
def capture_termios
|
|
106
|
+
out = `stty -g < /dev/tty 2>/dev/null`.chomp
|
|
107
|
+
out.empty? ? nil : out
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize
|
|
114
|
+
@entries = [] # [Entry, ...]
|
|
115
|
+
@focus_index = 0
|
|
116
|
+
@wake_read, @wake_write = IO.pipe
|
|
117
|
+
@running = false
|
|
118
|
+
@thread = nil
|
|
119
|
+
@state_mutex = Mutex.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def add(block, session, coordinator)
|
|
123
|
+
@state_mutex.synchronize do
|
|
124
|
+
@entries << Entry.new(block, session, coordinator)
|
|
125
|
+
refocus_locked(@entries.length - 1) if @entries.length == 1
|
|
126
|
+
end
|
|
127
|
+
start unless @running
|
|
128
|
+
repaint_all
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def remove(block)
|
|
132
|
+
removed = false
|
|
133
|
+
@state_mutex.synchronize do
|
|
134
|
+
index = @entries.index { |entry| entry.block.equal?(block) }
|
|
135
|
+
next unless index
|
|
136
|
+
|
|
137
|
+
@entries.delete_at(index)
|
|
138
|
+
block.focused = false
|
|
139
|
+
removed = true
|
|
140
|
+
refocus_locked(@focus_index % @entries.length) unless @entries.empty?
|
|
141
|
+
end
|
|
142
|
+
repaint_all if removed && !empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Boolean] true when no blocks are attached
|
|
146
|
+
def empty?
|
|
147
|
+
@state_mutex.synchronize { @entries.empty? }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Stop the router thread, join it, close pipes. Idempotent.
|
|
151
|
+
def shutdown
|
|
152
|
+
@running = false
|
|
153
|
+
begin
|
|
154
|
+
@wake_write.write("x")
|
|
155
|
+
rescue IOError, Errno::EPIPE
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
thread = @thread
|
|
159
|
+
thread&.join(1)
|
|
160
|
+
@thread = nil
|
|
161
|
+
[@wake_write, @wake_read].each do |io|
|
|
162
|
+
io.close
|
|
163
|
+
rescue IOError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
self.class.emergency_restore
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def start
|
|
172
|
+
self.class.install_emergency_hooks
|
|
173
|
+
self.class.saved_termios ||= self.class.capture_termios
|
|
174
|
+
@running = true
|
|
175
|
+
@thread = Thread.new { run }
|
|
176
|
+
@thread.name = "tty-command-window-input"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def run
|
|
180
|
+
$stdin.raw(intr: false) do
|
|
181
|
+
while @running
|
|
182
|
+
ready = IO.select([$stdin, @wake_read])
|
|
183
|
+
next unless ready
|
|
184
|
+
|
|
185
|
+
drain_wake_pipe if ready[0].include?(@wake_read)
|
|
186
|
+
break unless @running
|
|
187
|
+
|
|
188
|
+
forward($stdin.read_nonblock(4096)) if ready[0].include?($stdin)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
rescue IOError, Errno::EIO
|
|
192
|
+
nil
|
|
193
|
+
ensure
|
|
194
|
+
# Belt-and-braces restore: raw's block form already restores, but
|
|
195
|
+
# if the thread is killed asynchronously (Thread#kill or ensure
|
|
196
|
+
# skipped on interpreter exit) this line will not run — the
|
|
197
|
+
# emergency hooks installed via install_emergency_hooks cover
|
|
198
|
+
# that path.
|
|
199
|
+
@thread = nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def drain_wake_pipe
|
|
203
|
+
@wake_read.read_nonblock(64)
|
|
204
|
+
rescue IO::WaitReadable, EOFError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Split input on the focus key: keystrokes go to the focused block,
|
|
209
|
+
# each focus-key occurrence advances focus.
|
|
210
|
+
def forward(data)
|
|
211
|
+
segments = data.split(FOCUS_KEY, -1)
|
|
212
|
+
segments.each_with_index do |segment, index|
|
|
213
|
+
write_focused(segment) unless segment.empty?
|
|
214
|
+
cycle_focus if index < segments.length - 1
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def write_focused(segment)
|
|
219
|
+
entry = @state_mutex.synchronize { @entries[@focus_index] }
|
|
220
|
+
entry.session.write(segment) if entry&.block&.running?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def cycle_focus
|
|
224
|
+
moved = false
|
|
225
|
+
@state_mutex.synchronize do
|
|
226
|
+
next if @entries.length < 2
|
|
227
|
+
|
|
228
|
+
refocus_locked((@focus_index + 1) % @entries.length)
|
|
229
|
+
moved = true
|
|
230
|
+
end
|
|
231
|
+
repaint_all if moved
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Must be called with @state_mutex held.
|
|
235
|
+
def refocus_locked(index)
|
|
236
|
+
@entries.each { |entry| entry.block.focused = false }
|
|
237
|
+
@focus_index = index
|
|
238
|
+
block = @entries[@focus_index]&.block
|
|
239
|
+
block.focused = true if block
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def repaint_all
|
|
243
|
+
coordinators = @state_mutex.synchronize { @entries.map(&:coordinator) }
|
|
244
|
+
coordinators.each(&:mark_dirty)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TTY
|
|
4
|
+
class Command
|
|
5
|
+
module Window
|
|
6
|
+
# Adds {#run_windowed} and {#run_windowed!} to +TTY::Command+.
|
|
7
|
+
module Integration
|
|
8
|
+
# Run a command inside a live, fixed-height terminal window and raise
|
|
9
|
+
# +TTY::Command::ExitError+ on failure (mirrors +TTY::Command#run+).
|
|
10
|
+
#
|
|
11
|
+
# The child runs in a PTY reporting +lines:+ rows, so cursor-driven
|
|
12
|
+
# programs lay themselves out for the window height. Output is
|
|
13
|
+
# interpreted by a terminal emulator and painted as a static block.
|
|
14
|
+
#
|
|
15
|
+
# When stdout is not a TTY (or on Windows, or without PTY support)
|
|
16
|
+
# the call degrades to a plain +run+ with full streamed output —
|
|
17
|
+
# unless +on_unavailable: :raise+ is set, in which case
|
|
18
|
+
# {TTY::Command::Window::Unavailable} is raised instead.
|
|
19
|
+
#
|
|
20
|
+
# @note Under windowed rendering the child runs in a PTY, so its
|
|
21
|
+
# stderr is merged into +Result#out+ at the OS level;
|
|
22
|
+
# +Result#err+ is always +""+ (compare the plain +run+, where
|
|
23
|
+
# stderr is a separate stream). If you must inspect stderr
|
|
24
|
+
# independently, use +run+ / +run!+ or set +on_unavailable:
|
|
25
|
+
# :raise+ and handle both branches.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# cmd = TTY::Command.new(printer: :null)
|
|
29
|
+
# cmd.run_windowed("docker compose up -d", lines: 5)
|
|
30
|
+
#
|
|
31
|
+
# @param args [Array] command, arguments and options as for +#run+,
|
|
32
|
+
# plus the window options below
|
|
33
|
+
# @option args [Integer] :lines window height (default 5)
|
|
34
|
+
# @option args [String, false] :title title bar text; false hides it
|
|
35
|
+
# @option args [Symbol] :on_exit :freeze (default), :dump_on_failure
|
|
36
|
+
# or :collapse
|
|
37
|
+
# @option args [Integer] :scrollback plain-text history limit
|
|
38
|
+
# (default 10_000 lines)
|
|
39
|
+
# @option args [String] :output_log tee raw child output to this file
|
|
40
|
+
# @option args [Boolean] :interactive forward keystrokes to the child
|
|
41
|
+
# (Ctrl-O cycles focus between interactive windows)
|
|
42
|
+
# @option args [Symbol] :capture what Result#out contains — :raw
|
|
43
|
+
# (default), :stripped or :screen
|
|
44
|
+
# @option args [Integer, nil] :capture_max_bytes cap on the raw
|
|
45
|
+
# bytes retained for +Result#out+ (default 10 MiB); when the
|
|
46
|
+
# child produces more, the head is dropped and only the trailing
|
|
47
|
+
# +capture_max_bytes+ are kept. +nil+ disables the cap.
|
|
48
|
+
# Ignored for +capture: :screen+, which retains no raw stream.
|
|
49
|
+
# @option args [IO] :output render target (default: printer output)
|
|
50
|
+
# @option args [Boolean] :window force (+true+) or forbid (+false+)
|
|
51
|
+
# windowed rendering, overriding TTY / PTY / Windows detection
|
|
52
|
+
# @option args [Symbol] :on_unavailable :fallback (default) silently
|
|
53
|
+
# degrades to plain +run+ when the environment cannot render a
|
|
54
|
+
# window; :raise raises {TTY::Command::Window::Unavailable}
|
|
55
|
+
# @option args [Integer] :width fixed render width override
|
|
56
|
+
#
|
|
57
|
+
# @yield [chunk, nil] streamed raw output, like +#run+
|
|
58
|
+
# @return [TTY::Command::Result]
|
|
59
|
+
def run_windowed(*args, &)
|
|
60
|
+
execute_windowed(args, raise_on_error: true, &)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Same as {#run_windowed} but never raises on non-zero exit.
|
|
64
|
+
#
|
|
65
|
+
# @return [TTY::Command::Result]
|
|
66
|
+
def run_windowed!(*args, &)
|
|
67
|
+
execute_windowed(args, raise_on_error: false, &)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def execute_windowed(args, raise_on_error:, &block)
|
|
73
|
+
window_options, plain_args = Window.split_options(args)
|
|
74
|
+
Window.apply_tty_alias!(window_options)
|
|
75
|
+
Window.validate_on_unavailable!(window_options)
|
|
76
|
+
output = window_options[:output] || printer.output
|
|
77
|
+
|
|
78
|
+
unless windowed_renderable?(window_options, output)
|
|
79
|
+
if window_options.fetch(:on_unavailable, :fallback) == :raise
|
|
80
|
+
raise Window::Unavailable,
|
|
81
|
+
"windowed rendering unavailable " \
|
|
82
|
+
"(no TTY / PTY, on Windows, or dry-run)"
|
|
83
|
+
end
|
|
84
|
+
return raise_on_error ? run(*plain_args, &block) : run!(*plain_args, &block)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
cmd = build_cmd(plain_args)
|
|
88
|
+
options = Window.normalize_options(window_options, cmd)
|
|
89
|
+
coordinator = Coordinator.for(output, width: window_options[:width])
|
|
90
|
+
result = Runner.new(cmd, options, coordinator, &block).run!
|
|
91
|
+
|
|
92
|
+
raise ExitError.new(cmd.to_command, result) if raise_on_error && result.failure?
|
|
93
|
+
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# BOUNDARY: this call relies on +TTY::Command#command+, which is
|
|
98
|
+
# marked +@api private+ upstream. Reimplementing tty-command's
|
|
99
|
+
# argument parsing would couple us to more internals than we save,
|
|
100
|
+
# so we accept the dependency — but funnel it through this single
|
|
101
|
+
# named seam so an upstream shape change lands here, and nowhere
|
|
102
|
+
# else.
|
|
103
|
+
def build_cmd(plain_args)
|
|
104
|
+
command(*plain_args)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def windowed_renderable?(window_options, output)
|
|
108
|
+
return false if dry_run?
|
|
109
|
+
|
|
110
|
+
case window_options[:window]
|
|
111
|
+
when true then !Window.windows? && Window.pty_available?
|
|
112
|
+
when false then false
|
|
113
|
+
else Window.renderable?(output)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
# Extract window-specific keys from a trailing options hash.
|
|
120
|
+
#
|
|
121
|
+
# @param args [Array] raw run_windowed arguments
|
|
122
|
+
# @return [Array(Hash, Array)] window options and cleaned args
|
|
123
|
+
def split_options(args)
|
|
124
|
+
return [{}, args] unless args.last.respond_to?(:to_hash)
|
|
125
|
+
|
|
126
|
+
options = args.last.to_hash
|
|
127
|
+
window_options = options.slice(*OPTION_KEYS)
|
|
128
|
+
remaining = options.except(*OPTION_KEYS)
|
|
129
|
+
plain_args = args[0..-2]
|
|
130
|
+
plain_args << remaining unless remaining.empty?
|
|
131
|
+
[window_options, plain_args]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Translate the legacy +tty:+ key into +window:+ with a one-time
|
|
135
|
+
# deprecation warning. Preserves compatibility with pre-rename
|
|
136
|
+
# callers for one minor version.
|
|
137
|
+
#
|
|
138
|
+
# @api private
|
|
139
|
+
# @param window_options [Hash] mutated in place
|
|
140
|
+
def apply_tty_alias!(window_options)
|
|
141
|
+
return unless window_options.key?(:tty)
|
|
142
|
+
|
|
143
|
+
value = window_options.delete(:tty)
|
|
144
|
+
return if window_options.key?(:window)
|
|
145
|
+
|
|
146
|
+
warn_tty_deprecation
|
|
147
|
+
window_options[:window] = value
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fail fast on typos / unsupported values for +on_unavailable:+
|
|
151
|
+
# (see follow-up review P0-4).
|
|
152
|
+
#
|
|
153
|
+
# @api private
|
|
154
|
+
def validate_on_unavailable!(window_options)
|
|
155
|
+
return unless window_options.key?(:on_unavailable)
|
|
156
|
+
|
|
157
|
+
mode = window_options[:on_unavailable]
|
|
158
|
+
return if ON_UNAVAILABLE_MODES.include?(mode)
|
|
159
|
+
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"on_unavailable must be one of #{ON_UNAVAILABLE_MODES.join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validate and default the window options.
|
|
165
|
+
#
|
|
166
|
+
# @param window_options [Hash]
|
|
167
|
+
# @param cmd [TTY::Command::Cmd]
|
|
168
|
+
# @return [WindowOptions]
|
|
169
|
+
def normalize_options(window_options, cmd)
|
|
170
|
+
WindowOptions.build(window_options, cmd)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Install the at_exit cursor-restore hook once.
|
|
174
|
+
def register_at_exit
|
|
175
|
+
return if @at_exit_registered
|
|
176
|
+
|
|
177
|
+
@at_exit_registered = true
|
|
178
|
+
at_exit { Coordinator.restore_all }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def warn_tty_deprecation
|
|
184
|
+
@tty_deprecation_warned ||= false
|
|
185
|
+
return if @tty_deprecation_warned
|
|
186
|
+
|
|
187
|
+
@tty_deprecation_warned = true
|
|
188
|
+
warn "[tty-command-window] `tty:` is deprecated; use `window:` instead."
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
include Window::Integration
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/wait"
|
|
4
|
+
|
|
5
|
+
module TTY
|
|
6
|
+
class Command
|
|
7
|
+
module Window
|
|
8
|
+
# Spawns the child inside a PTY sized to the window, pumps its output
|
|
9
|
+
# into the block's emulator (and optional log/stream targets), enforces
|
|
10
|
+
# timeouts and builds the final +TTY::Command::Result+.
|
|
11
|
+
class Runner
|
|
12
|
+
READ_CHUNK = 64 * 1024
|
|
13
|
+
SELECT_INTERVAL = 0.25
|
|
14
|
+
|
|
15
|
+
# Post-pump reap budget. If the master closes but the child does
|
|
16
|
+
# not exit promptly (e.g. it double-forked or closed its stdio and
|
|
17
|
+
# kept running), we escalate to SIGKILL after this many seconds
|
|
18
|
+
# rather than blocking the caller forever.
|
|
19
|
+
REAP_DEADLINE = 2.0
|
|
20
|
+
|
|
21
|
+
# @param cmd [TTY::Command::Cmd]
|
|
22
|
+
# @param options [WindowOptions] validated window options
|
|
23
|
+
# @param coordinator [Coordinator]
|
|
24
|
+
# @param stream_block [Proc, nil] yields (chunk, nil) like tty-command
|
|
25
|
+
def initialize(cmd, options, coordinator, &stream_block)
|
|
26
|
+
@cmd = cmd
|
|
27
|
+
@options = options
|
|
28
|
+
@coordinator = coordinator
|
|
29
|
+
@stream_block = stream_block
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [TTY::Command::Result]
|
|
33
|
+
def run!
|
|
34
|
+
require "pty"
|
|
35
|
+
rows = @options.lines
|
|
36
|
+
width = @coordinator.width
|
|
37
|
+
|
|
38
|
+
emulator = Emulator.new(
|
|
39
|
+
rows: rows, cols: width,
|
|
40
|
+
scrollback_limit: @options.scrollback,
|
|
41
|
+
responder: ->(reply) { @session&.write(reply) }
|
|
42
|
+
)
|
|
43
|
+
@block = Block.new(
|
|
44
|
+
emulator: emulator,
|
|
45
|
+
title: @options.title,
|
|
46
|
+
lines: rows,
|
|
47
|
+
on_exit: @options.on_exit,
|
|
48
|
+
interactive: @options.interactive?
|
|
49
|
+
)
|
|
50
|
+
@emulator = emulator
|
|
51
|
+
|
|
52
|
+
@session = spawn_child(rows, width)
|
|
53
|
+
started = clock
|
|
54
|
+
# Only retain raw bytes when a capture mode actually consumes them.
|
|
55
|
+
# For capture: :screen the emulator grid holds the final state; the
|
|
56
|
+
# raw stream is dead weight and would grow unbounded on long runs
|
|
57
|
+
# (see review/performance.md H5 / P0-1).
|
|
58
|
+
@needs_raw = !@options.capture_screen?
|
|
59
|
+
@raw = @needs_raw ? (+"").force_encoding(Encoding::BINARY) : nil
|
|
60
|
+
@log = @options.output_log && File.open(@options.output_log, "wb")
|
|
61
|
+
|
|
62
|
+
@coordinator.register(@block, child_session: @session)
|
|
63
|
+
InputRouter.attach(@block, @session, @coordinator) if @options.interactive?
|
|
64
|
+
|
|
65
|
+
write_initial_input
|
|
66
|
+
timed_out = timed_out_pumping?
|
|
67
|
+
status = reap(timed_out)
|
|
68
|
+
runtime = clock - started
|
|
69
|
+
|
|
70
|
+
finish(status, runtime, timed_out)
|
|
71
|
+
raise TTY::Command::TimeoutExceeded if timed_out
|
|
72
|
+
|
|
73
|
+
build_result(status, runtime)
|
|
74
|
+
ensure
|
|
75
|
+
cleanup
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def clock
|
|
81
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [ChildSession]
|
|
85
|
+
def spawn_child(rows, cols)
|
|
86
|
+
master, slave = PTY.open
|
|
87
|
+
master.winsize = [rows, cols]
|
|
88
|
+
env = { "TERM" => ENV["TERM"] || "xterm-256color" }
|
|
89
|
+
pid = Process.spawn(env, @cmd.to_command,
|
|
90
|
+
in: slave, out: slave, err: slave, pgroup: true)
|
|
91
|
+
slave.close
|
|
92
|
+
ChildSession.new(pid: pid, master: master, rows: rows)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_initial_input
|
|
96
|
+
input = @cmd.options[:input]
|
|
97
|
+
@session.write(input) if input
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Pump child output until EOF or timeout.
|
|
101
|
+
#
|
|
102
|
+
# @return [Boolean] true when the timeout was exceeded
|
|
103
|
+
def timed_out_pumping?
|
|
104
|
+
timeout = @cmd.options[:timeout]
|
|
105
|
+
@pump_deadline = timeout && (clock + timeout)
|
|
106
|
+
deadline = @pump_deadline
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
return true if deadline && clock >= deadline
|
|
110
|
+
|
|
111
|
+
wait = deadline ? [deadline - clock, SELECT_INTERVAL].min : SELECT_INTERVAL
|
|
112
|
+
ready = @session.wait_readable([wait, 0].max)
|
|
113
|
+
|
|
114
|
+
if ready
|
|
115
|
+
break unless read_chunk
|
|
116
|
+
elsif child_exited?
|
|
117
|
+
drain_remaining
|
|
118
|
+
break
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] false on EOF
|
|
125
|
+
def read_chunk
|
|
126
|
+
data = @session.read_nonblock(READ_CHUNK)
|
|
127
|
+
handle_data(data)
|
|
128
|
+
true
|
|
129
|
+
rescue IO::WaitReadable
|
|
130
|
+
true
|
|
131
|
+
rescue Errno::EIO, EOFError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_data(data)
|
|
136
|
+
if @needs_raw
|
|
137
|
+
@raw << data
|
|
138
|
+
cap = @options.capture_max_bytes
|
|
139
|
+
# Truncate from the head — the tail of the log is what callers
|
|
140
|
+
# inspect after a failure (review P0-2).
|
|
141
|
+
@raw = @raw.byteslice(-cap, cap) if cap && @raw.bytesize > cap
|
|
142
|
+
end
|
|
143
|
+
@log&.write(data)
|
|
144
|
+
@block.feed(data)
|
|
145
|
+
@coordinator.mark_dirty
|
|
146
|
+
@stream_block&.call(data.dup, nil)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def child_exited?
|
|
150
|
+
status = @session.try_wait
|
|
151
|
+
if status
|
|
152
|
+
@reaped_status = status
|
|
153
|
+
true
|
|
154
|
+
else
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def drain_remaining
|
|
160
|
+
loop do
|
|
161
|
+
break unless @session.wait_readable(0.05)
|
|
162
|
+
break unless read_chunk
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Passes the pump deadline through when the caller set +timeout:+
|
|
167
|
+
# (review P0-3): after EOF the child keeps its remaining budget
|
|
168
|
+
# before SIGKILL escalation, and after a timeout the escalation is
|
|
169
|
+
# immediate instead of granting a bonus REAP_DEADLINE.
|
|
170
|
+
def reap(timed_out)
|
|
171
|
+
if timed_out
|
|
172
|
+
@session.signal(@cmd.options[:signal] || "SIGKILL")
|
|
173
|
+
wait_status(deadline: @pump_deadline) || @reaped_status
|
|
174
|
+
else
|
|
175
|
+
@reaped_status || wait_status(deadline: @pump_deadline)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Bounded, non-blocking reap. Polls waitpid2 with WNOHANG until
|
|
180
|
+
# either the child exits or the deadline elapses, at which point
|
|
181
|
+
# we SIGKILL and reap once more. Prevents indefinite hangs when
|
|
182
|
+
# a child closes its stdio but keeps running (see review H3).
|
|
183
|
+
def wait_status(deadline: nil)
|
|
184
|
+
deadline ||= clock + REAP_DEADLINE
|
|
185
|
+
loop do
|
|
186
|
+
status = @session.try_wait
|
|
187
|
+
return status if status
|
|
188
|
+
|
|
189
|
+
if clock >= deadline
|
|
190
|
+
@session.signal("SIGKILL")
|
|
191
|
+
return @session.wait_blocking
|
|
192
|
+
end
|
|
193
|
+
sleep 0.05
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def finish(status, runtime, timed_out)
|
|
198
|
+
success = !timed_out && !status.nil? && status.success?
|
|
199
|
+
@block.finish(success, runtime)
|
|
200
|
+
InputRouter.detach(@block) if @options.interactive?
|
|
201
|
+
@coordinator.finalize(@block)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def exit_code(status)
|
|
205
|
+
return 1 if status.nil?
|
|
206
|
+
|
|
207
|
+
status.exitstatus || (status.termsig ? 128 + status.termsig : 1)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def build_result(status, runtime)
|
|
211
|
+
TTY::Command::Result.new(exit_code(status), captured_output, "", runtime)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def captured_output
|
|
215
|
+
case @options.capture
|
|
216
|
+
when :stripped then ANSI.strip(utf8(@raw))
|
|
217
|
+
when :screen then @block.full_text
|
|
218
|
+
else utf8(@raw)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def utf8(data)
|
|
223
|
+
return "" if data.nil?
|
|
224
|
+
|
|
225
|
+
text = data.dup.force_encoding(Encoding::UTF_8)
|
|
226
|
+
text.valid_encoding? ? text : text.scrub("\u{FFFD}")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Ensure the child is dead and the block finalized even when the
|
|
230
|
+
# pump raised (Interrupt, timeout, IO errors).
|
|
231
|
+
def cleanup
|
|
232
|
+
if @block&.running?
|
|
233
|
+
@session&.signal("SIGKILL")
|
|
234
|
+
wait_status
|
|
235
|
+
@block.finish(false, 0.0)
|
|
236
|
+
InputRouter.detach(@block) if @options.interactive?
|
|
237
|
+
@coordinator.finalize(@block)
|
|
238
|
+
end
|
|
239
|
+
@session&.close
|
|
240
|
+
@log&.close
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|