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.
@@ -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