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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module TTY
6
+ class Command
7
+ module Window
8
+ # Handle to the child process spawned inside the PTY.
9
+ #
10
+ # Owns the process id and the PTY master file descriptor and
11
+ # exposes the operations the rest of the gem needs to perform
12
+ # against a live child: write user input, resize the terminal,
13
+ # forward a signal to its process group, reap the child, and
14
+ # close the master fd.
15
+ #
16
+ # **Orphan protection.** Every live session is tracked in a
17
+ # class-level set. A one-time +at_exit+ hook SIGKILLs the process
18
+ # group of any session still alive when the host process exits
19
+ # (follow-up review P1-8). This covers the case where the host
20
+ # dies before {Runner#cleanup} runs — SIGKILL to the host, an
21
+ # interpreter abort, or `exit!`.
22
+ class ChildSession
23
+ @live = {}.compare_by_identity
24
+ @live_mutex = Mutex.new
25
+ @at_exit_installed = false
26
+
27
+ class << self
28
+ # @api private
29
+ attr_reader :live, :live_mutex
30
+
31
+ # Register the session as live and ensure the +at_exit+ orphan
32
+ # sweeper is installed.
33
+ def track(session)
34
+ install_at_exit_once
35
+ @live_mutex.synchronize { @live[session] = true }
36
+ end
37
+
38
+ # Remove the session from the live-set (called on close).
39
+ def untrack(session)
40
+ @live_mutex.synchronize { @live.delete(session) }
41
+ end
42
+
43
+ # @api private for tests
44
+ def reap_orphans!
45
+ sessions = @live_mutex.synchronize { @live.keys.dup }
46
+ sessions.each(&:kill_group!)
47
+ end
48
+
49
+ private
50
+
51
+ def install_at_exit_once
52
+ return if @at_exit_installed
53
+
54
+ @at_exit_installed = true
55
+ at_exit { reap_orphans! }
56
+ end
57
+ end
58
+
59
+ # @param pid [Integer] child process id (session/pgroup leader)
60
+ # @param master [IO] PTY master fd
61
+ # @param rows [Integer] emulator/PTY row count (fixed for the lifetime of the session)
62
+ def initialize(pid:, master:, rows:)
63
+ @pid = pid
64
+ @master = master
65
+ @rows = rows
66
+ @closed = false
67
+ @reaped = false
68
+ self.class.track(self)
69
+ end
70
+
71
+ attr_reader :rows
72
+
73
+ # Write user keystrokes / bytes to the child.
74
+ def write(data)
75
+ @master.write(data) if @master && !@master.closed?
76
+ rescue Errno::EIO, IOError
77
+ nil
78
+ end
79
+
80
+ # Resize the PTY to the new column count. Rows are fixed.
81
+ def resize(cols)
82
+ @master&.winsize = [@rows, cols]
83
+ rescue Errno::EIO, Errno::EBADF, IOError
84
+ nil
85
+ end
86
+
87
+ # Forward a signal to the child's process group. No-op once the
88
+ # child has been reaped so a stale session cannot deliver to a
89
+ # recycled PID (belt-and-braces alongside the coordinator's
90
+ # unconditional @sessions.delete — see follow-up review P0-2).
91
+ def signal(name)
92
+ return if @reaped
93
+ return unless @pid
94
+
95
+ Process.kill(name, -@pid)
96
+ rescue Errno::ESRCH, Errno::EPERM
97
+ nil
98
+ end
99
+
100
+ # Block until the child has output or +timeout+ seconds elapse.
101
+ #
102
+ # @return [IO, nil] truthy when readable, nil on timeout
103
+ def wait_readable(timeout)
104
+ @master.wait_readable(timeout)
105
+ end
106
+
107
+ # Read up to +length+ bytes of child output without blocking.
108
+ # Raises like IO#read_nonblock (IO::WaitReadable, EOFError,
109
+ # Errno::EIO on PTY hangup) — callers own the EOF handling.
110
+ def read_nonblock(length)
111
+ @master.read_nonblock(length)
112
+ end
113
+
114
+ # Non-blocking waitpid poll. Marks the session as reaped on
115
+ # success so subsequent {#signal} calls are inert.
116
+ #
117
+ # @return [Process::Status, nil]
118
+ def try_wait
119
+ pid, status = Process.waitpid2(@pid, Process::WNOHANG)
120
+ return nil unless pid
121
+
122
+ @reaped = true
123
+ status
124
+ rescue Errno::ECHILD
125
+ @reaped = true
126
+ nil
127
+ end
128
+
129
+ # Blocking waitpid — used when the caller has already decided
130
+ # to give up polling (e.g. after SIGKILL). Marks reaped.
131
+ def wait_blocking
132
+ _, status = Process.waitpid2(@pid)
133
+ @reaped = true
134
+ status
135
+ rescue Errno::ECHILD
136
+ @reaped = true
137
+ nil
138
+ end
139
+
140
+ # Close the PTY master. Idempotent. Also removes the session
141
+ # from the orphan-tracking set.
142
+ def close
143
+ return if @closed
144
+
145
+ @closed = true
146
+ @master.close if @master && !@master.closed?
147
+ rescue IOError, Errno::EBADF
148
+ nil
149
+ ensure
150
+ self.class.untrack(self)
151
+ end
152
+
153
+ # Send SIGKILL to the process group. Used by the +at_exit+
154
+ # orphan sweeper; safe to call on a reaped session (no-op).
155
+ def kill_group!
156
+ signal("KILL")
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module TTY
6
+ class Command
7
+ module Window
8
+ # Owns a region at the bottom of the terminal and paints the stack of
9
+ # active {Block}s into it from a dedicated render thread.
10
+ #
11
+ # All painted lines end with explicit "\r\n" so rendering stays correct
12
+ # even when the input router has put the terminal into raw mode (which
13
+ # disables output newline translation for the whole tty).
14
+ class Coordinator
15
+ FRAME_INTERVAL = 0.08
16
+ HIDE_CURSOR = "\e[?25l"
17
+ SHOW_CURSOR = "\e[?25h"
18
+
19
+ @registry = {}
20
+ @registry_mutex = Mutex.new
21
+
22
+ class << self
23
+ # Coordinator bound to the given output IO (one per IO).
24
+ #
25
+ # @param output [IO]
26
+ # @param width [Integer, nil] fixed width override
27
+ # @return [Coordinator]
28
+ def for(output, width: nil)
29
+ @registry_mutex.synchronize do
30
+ @registry[output] ||= new(output: output, width_override: width)
31
+ end
32
+ end
33
+
34
+ # Emergency cleanup for at_exit: restore the cursor everywhere.
35
+ def restore_all
36
+ @registry_mutex.synchronize do
37
+ @registry.each_value(&:emergency_restore)
38
+ end
39
+ end
40
+
41
+ # Trap-safe variant of {.restore_all}: iterates a snapshot of the
42
+ # registry without @registry_mutex, which the main thread may hold
43
+ # when the signal arrives (see review P0-5).
44
+ def emergency_restore_all
45
+ # .values (not each_value): iterating the live hash could raise
46
+ # if the main thread mutates the registry mid-trap.
47
+ @registry.values.each(&:emergency_restore) # rubocop:disable Style/HashEachMethods
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ end
52
+
53
+ attr_reader :output, :pastel
54
+
55
+ def initialize(output:, width_override: nil)
56
+ @output = output
57
+ @width_override = width_override
58
+ @blocks = []
59
+ @sessions = {} # block => ChildSession, for signal + PTY resize
60
+ @painted_height = 0
61
+ @active = false
62
+ @winch = false
63
+ @dirty = false
64
+ @mutex = Mutex.new
65
+ @spinner = Spinner.new
66
+ @pastel = Pastel.new(enabled: color?)
67
+ end
68
+
69
+ # @return [Integer] current terminal width in columns
70
+ def width
71
+ return @width_override if @width_override
72
+
73
+ if @output.respond_to?(:winsize) && @output.respond_to?(:tty?) && @output.tty?
74
+ cols = @output.winsize[1]
75
+ return cols if cols.positive?
76
+ end
77
+ (ENV["COLUMNS"] || "80").to_i.clamp(20, 1000)
78
+ rescue Errno::ENOTTY, Errno::EBADF, IOError
79
+ 80
80
+ end
81
+
82
+ # Add a block to the stack and start rendering if needed.
83
+ #
84
+ # @param block [Block]
85
+ # @param child_session [ChildSession, nil] used for signal forwarding
86
+ # and PTY winsize updates; may be nil for test doubles.
87
+ def register(block, child_session: nil)
88
+ @mutex.synchronize do
89
+ activate unless @active
90
+ @blocks << block
91
+ @sessions[block] = child_session if child_session
92
+ paint
93
+ end
94
+ end
95
+
96
+ # Called by the runner when a block's command finished. Repaints and,
97
+ # for dump-on-failure blocks, replaces the block with its full
98
+ # history as permanent scrolled output. Drains the region when every
99
+ # block is done.
100
+ def finalize(block)
101
+ drained = false
102
+ @mutex.synchronize do
103
+ next unless @active
104
+
105
+ if block.dump_on_finalize?
106
+ wipe_region
107
+ @blocks.delete(block)
108
+ write_permanent(block.dump_text(@pastel))
109
+ end
110
+ # Drop the session unconditionally: the child has been reaped by
111
+ # Runner and its PID is no longer safe to signal. Leaving stale
112
+ # entries in @sessions risks INT/TERM delivery to a recycled PID
113
+ # (see follow-up review P0-2).
114
+ @sessions.delete(block)
115
+ paint
116
+ if @blocks.all?(&:done?)
117
+ drain
118
+ drained = true
119
+ end
120
+ end
121
+ join_render_thread if drained
122
+ end
123
+
124
+ # Request a repaint on the next frame (cheap, lock-free).
125
+ def mark_dirty
126
+ @dirty = true
127
+ end
128
+
129
+ # Trap-context WINCH notification; actual work happens on the render
130
+ # thread.
131
+ def winch!
132
+ @winch = true
133
+ end
134
+
135
+ # @return [Boolean] whether colored output is enabled
136
+ def color?
137
+ return false if ENV.key?("NO_COLOR")
138
+
139
+ Window.assume_tty || (@output.respond_to?(:tty?) && @output.tty?)
140
+ end
141
+
142
+ # at_exit safety net: show the cursor if we died mid-paint.
143
+ def emergency_restore
144
+ @output.write(SHOW_CURSOR) if @active && @output.respond_to?(:write) && !@output.closed?
145
+ rescue IOError, Errno::EBADF, Errno::EPIPE
146
+ nil
147
+ end
148
+
149
+ private
150
+
151
+ def activate
152
+ @active = true
153
+ @painted_height = 0
154
+ @output.write("\r#{HIDE_CURSOR}")
155
+ install_traps
156
+ Window.register_at_exit
157
+ @render_thread = Thread.new { render_loop }
158
+ @render_thread.name = "tty-command-window-render"
159
+ end
160
+
161
+ # Must be called while holding @mutex; the caller joins the render
162
+ # thread outside the lock afterwards.
163
+ def drain
164
+ @output.write("\r\n#{SHOW_CURSOR}")
165
+ @blocks.clear
166
+ @sessions.clear
167
+ @painted_height = 0
168
+ @active = false
169
+ restore_traps
170
+ end
171
+
172
+ def join_render_thread
173
+ thread = @render_thread
174
+ @render_thread = nil
175
+ thread&.join(1)
176
+ end
177
+
178
+ def render_loop
179
+ while @active
180
+ sleep(FRAME_INTERVAL)
181
+ @mutex.synchronize do
182
+ next unless @active
183
+
184
+ handle_winch if @winch
185
+ next unless @dirty || @blocks.any?(&:running?)
186
+
187
+ @dirty = false
188
+ @spinner.tick
189
+ paint
190
+ end
191
+ end
192
+ end
193
+
194
+ def handle_winch
195
+ @winch = false
196
+ wipe_region
197
+ new_width = width
198
+ @blocks.each do |block|
199
+ block.resize(new_width)
200
+ @sessions[block]&.resize(new_width)
201
+ end
202
+ end
203
+
204
+ # Paint the whole stack. Must be called while holding @mutex.
205
+ def paint
206
+ lines = render_all_lines
207
+ height = lines.length
208
+ return if height.zero? && @painted_height.zero?
209
+
210
+ ensure_capacity(height)
211
+ buffer = +"\r"
212
+ up = @painted_height - 1
213
+ buffer << "\e[#{up}A" if up.positive?
214
+ lines.each_with_index do |line, index|
215
+ buffer << line << "\e[0m\e[K"
216
+ buffer << "\r\n" if index < height - 1
217
+ end
218
+ buffer << "\e[0J" if @painted_height > height
219
+ @painted_height = height
220
+ @output.write(buffer)
221
+ end
222
+
223
+ def render_all_lines
224
+ current_width = width
225
+ frame = @spinner.frame
226
+ @blocks.flat_map do |block|
227
+ block.render(width: current_width, pastel: @pastel, frame: frame)
228
+ end
229
+ end
230
+
231
+ # Grow the painted region to +height+ lines by emitting newlines from
232
+ # the parked position (bottom line of the region, column 0).
233
+ def ensure_capacity(height)
234
+ return if height <= @painted_height
235
+
236
+ missing = height - [@painted_height, 1].max
237
+ @output.write("\r#{"\r\n" * missing}") if missing.positive?
238
+ @painted_height = height
239
+ end
240
+
241
+ # Erase the whole painted region, leaving the cursor at its former
242
+ # top line, column 0.
243
+ def wipe_region
244
+ return if @painted_height.zero?
245
+
246
+ up = @painted_height - 1
247
+ buffer = +"\r"
248
+ buffer << "\e[#{up}A" if up.positive?
249
+ buffer << "\e[0J"
250
+ @output.write(buffer)
251
+ @painted_height = 0
252
+ end
253
+
254
+ # Write permanent (scrolling) text at the current cursor position.
255
+ # Used for failure dumps. Cursor ends at column 0 of a fresh line.
256
+ def write_permanent(text)
257
+ body = text.split("\n", -1).join("\r\n")
258
+ body += "\r\n" unless body.end_with?("\r\n")
259
+ @output.write(body)
260
+ end
261
+
262
+ # --- signals -------------------------------------------------------
263
+ #
264
+ # Coordinators do not touch Signal.trap directly: the process-global
265
+ # TrapManager owns installation so concurrent coordinators (on
266
+ # distinct outputs) cannot clobber each other's handlers, and the
267
+ # host application's prior handler is captured exactly once and
268
+ # restored exactly once.
269
+
270
+ def install_traps
271
+ return unless @output.respond_to?(:tty?) && @output.tty?
272
+
273
+ @winch_sub = TrapManager.subscribe("WINCH", method(:on_winch_signal))
274
+ @int_sub = TrapManager.subscribe("INT", method(:on_process_signal))
275
+ @term_sub = TrapManager.subscribe("TERM", method(:on_process_signal))
276
+ end
277
+
278
+ def restore_traps
279
+ TrapManager.unsubscribe("WINCH", @winch_sub) if @winch_sub
280
+ TrapManager.unsubscribe("INT", @int_sub) if @int_sub
281
+ TrapManager.unsubscribe("TERM", @term_sub) if @term_sub
282
+ @winch_sub = @int_sub = @term_sub = nil
283
+ end
284
+
285
+ # Trap-context WINCH handler. Deferred to the render thread.
286
+ def on_winch_signal(_signal)
287
+ winch!
288
+ end
289
+
290
+ # Trap-context INT/TERM handler.
291
+ #
292
+ # Cannot acquire @mutex from trap context; instead we snapshot the
293
+ # sessions hash's values array. A concurrent register/finalize may
294
+ # miss the newly-added/removed session for this one signal — that
295
+ # is an accepted trade-off for INT/TERM forwarding.
296
+ def on_process_signal(signal)
297
+ sessions = begin
298
+ @sessions.values
299
+ rescue StandardError
300
+ []
301
+ end
302
+ # ChildSession#signal already rescues Errno::ESRCH / EPERM;
303
+ # no outer rescue needed here.
304
+ sessions.each { |session| session.signal(signal) }
305
+ # Restore the terminal before TrapManager delegates to the prior
306
+ # handler, which may terminate the process (review P0-5): cursor
307
+ # visibility for every coordinator, termios for the input router
308
+ # even when this session is not interactive.
309
+ self.class.emergency_restore_all
310
+ InputRouter.emergency_restore
311
+ end
312
+ end
313
+ end
314
+ end
315
+ end