foruiman 0.1.2 → 0.3.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.
@@ -11,29 +11,39 @@ class Foruiman::Engine
11
11
  TERM_TIMEOUT = 5.0
12
12
  READ_CHUNK = 4096
13
13
  READ_BUDGET = 64 * 1024
14
+ INPUT_BUDGET = 64 * 1024
14
15
  State = Struct.new(:name, :process, :port, :pid, :pgid, :status, :exit_status,
15
16
  :generation, :restart_pending, :deadline, :reaped, :group_gone,
17
+ :input, :input_buffer,
16
18
  keyword_init: true)
17
19
  Event = Data.define(:type, :name, :pid, :status, :record, :message)
18
20
 
19
- attr_reader :logs, :env, :processes, :root, :procfile_path
21
+ attr_reader :logs, :env, :processes, :root, :procfile_path, :term_timeout, :exit_on
20
22
 
21
- def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
22
- term_timeout: TERM_TIMEOUT)
23
+ def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, input: $stdin, port: 5000, log_lines: 10_000,
24
+ term_timeout: TERM_TIMEOUT, exit_on: :all)
23
25
  raise Foruiman::Error, "port must be an integer in 1..65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
24
26
  raise Foruiman::Error, "log-lines must be a positive integer" unless log_lines.is_a?(Integer) && log_lines.positive?
27
+ unless term_timeout.is_a?(Numeric) && term_timeout.real? && term_timeout.finite? && term_timeout >= 0
28
+ raise Foruiman::Error, "timeout must be a finite nonnegative number"
29
+ end
30
+ raise Foruiman::Error, "exit-on must be all, any, or failure" unless %i[all any failure].include?(exit_on)
25
31
 
26
32
  @root = File.expand_path(root)
27
33
  raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
28
34
 
29
35
  @env = env.dup.freeze
36
+ @input = input
37
+ @managed_input = false
30
38
  @base_port = port
31
39
  @log_lines = log_lines
32
40
  @term_timeout = term_timeout
41
+ @exit_on = exit_on
33
42
  @processes = []
34
43
  @names = {}
35
44
  @running = {}
36
45
  @readers = {}
46
+ @inputs = {}
37
47
  @listeners = []
38
48
  @shutdown = false
39
49
  @explicit_shutdown = false
@@ -57,7 +67,8 @@ class Foruiman::Engine
57
67
  Foruiman::Procfile.new[name] = command
58
68
  process = Foruiman::Process.new(command, cwd: root, env: env)
59
69
  state = State.new(name: name.freeze, process: process, port: @base_port + (processes.size * 100),
60
- status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true)
70
+ status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true,
71
+ input_buffer: +"".b)
61
72
  @names[name] = state
62
73
  processes << state
63
74
  state
@@ -100,6 +111,16 @@ class Foruiman::Engine
100
111
  self
101
112
  end
102
113
 
114
+ # Give every child a dedicated pseudo-terminal for stdin. The TUI remains the
115
+ # sole reader of the real terminal and explicitly forwards input to one child.
116
+ def manage_input!
117
+ raise Foruiman::Error, "cannot change input mode after startup" if @started
118
+
119
+ require "pty"
120
+ @managed_input = true
121
+ self
122
+ end
123
+
103
124
  def start(name = nil)
104
125
  raise Foruiman::Error, "supervisor is closed" if @closed
105
126
  return if @shutdown
@@ -145,6 +166,33 @@ class Foruiman::Engine
145
166
  terminate(entry)
146
167
  end
147
168
 
169
+ def write_input(name, bytes)
170
+ entry = state(name)
171
+ return false unless entry.status == :running && entry.input && !entry.input.closed?
172
+ return false if entry.input_buffer.bytesize + bytes.bytesize > INPUT_BUDGET
173
+
174
+ entry.input_buffer << bytes.b
175
+ flush_input(entry)
176
+ rescue IOError, SystemCallError
177
+ false
178
+ end
179
+
180
+ def interrupt_process(name)
181
+ entry = state(name)
182
+ return unless entry.pgid
183
+
184
+ signal_group(entry, :INT)
185
+ self
186
+ end
187
+
188
+ def resize_inputs(rows, columns)
189
+ processes.each do |entry|
190
+ entry.input&.winsize = [rows, columns]
191
+ rescue IOError, SystemCallError
192
+ nil
193
+ end
194
+ end
195
+
148
196
  def shutdown(explicit: true)
149
197
  @explicit_shutdown ||= explicit
150
198
  return if @shutdown
@@ -165,7 +213,9 @@ class Foruiman::Engine
165
213
  end
166
214
 
167
215
  def exit_code
168
- @explicit_shutdown || !@failed ? 0 : 1
216
+ return 0 if @explicit_shutdown
217
+
218
+ @policy_exit_code || (@failed ? 1 : 0)
169
219
  end
170
220
 
171
221
  def run(keep_open: false)
@@ -189,9 +239,11 @@ class Foruiman::Engine
189
239
  shutdown if @signal_requested
190
240
  reap_children
191
241
  advance_groups
192
- ready = IO.select([@self_reader, *@readers.keys], nil, nil, timeout)&.first || []
242
+ writable = processes.filter_map { |entry| entry.input unless entry.input_buffer.empty? }
243
+ ready, writable = IO.select([@self_reader, *@readers.keys], writable, nil, timeout) || [[], []]
193
244
  drain_signal_pipe if ready.delete(@self_reader)
194
245
  shutdown if @signal_requested
246
+ writable.each { |input| flush_input(@inputs.fetch(input)) if @inputs.key?(input) }
195
247
  read_output(ready)
196
248
  reap_children
197
249
  advance_groups
@@ -224,16 +276,20 @@ class Foruiman::Engine
224
276
 
225
277
  stdout_reader, stdout_writer = create_pipe
226
278
  stderr_reader, stderr_writer = create_pipe
279
+ input_master, input_slave = create_managed_input if @managed_input
227
280
  begin
228
281
  pid = entry.process.run(output: stdout_writer, error: stderr_writer,
282
+ input: input_slave || @input,
229
283
  env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
230
284
  rescue SystemCallError => e
231
285
  stdout_reader.close
232
286
  stderr_reader.close
287
+ input_master&.close
233
288
  entry.status = :failed
234
289
  entry.restart_pending = false
235
290
  @failed = true
236
291
  lifecycle(entry, :failed, "failed to start: #{e.message}")
292
+ apply_exit_policy(1)
237
293
  return
238
294
  end
239
295
  entry.pid = entry.pgid = pid
@@ -243,12 +299,19 @@ class Foruiman::Engine
243
299
  entry.restart_pending = false
244
300
  entry.deadline = nil
245
301
  entry.reaped = entry.group_gone = false
302
+ entry.input = input_master
303
+ entry.input_buffer.clear
246
304
  @running[pid] = entry
247
305
  [[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
248
306
  @readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
249
307
  end
308
+ if input_master
309
+ @inputs[input_master] = entry
310
+ @readers[input_master] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: :stdin, pid: pid)]
311
+ end
250
312
  lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
251
313
  ensure
314
+ input_slave&.close
252
315
  stdout_writer&.close
253
316
  stderr_writer&.close
254
317
  [stdout_reader, stderr_reader].compact.each do |reader|
@@ -278,13 +341,22 @@ class Foruiman::Engine
278
341
  entry.reaped = true
279
342
  entry.exit_status = result.last
280
343
  success = entry.exit_status.success?
281
- @failed ||= !success && !%i[stopping restarting].include?(entry.status)
282
- entry.status = success ? :exited : :failed unless %i[stopping restarting].include?(entry.status)
344
+ transitioning = %i[stopping restarting].include?(entry.status)
345
+ @failed ||= !success && !transitioning
346
+ entry.status = success ? :exited : :failed unless transitioning
283
347
  lifecycle(entry, :exited, termination_message_for(entry.exit_status))
348
+ apply_exit_policy(entry.exit_status.exitstatus || (128 + entry.exit_status.termsig)) unless transitioning
284
349
  terminate(entry)
285
350
  end
286
351
  end
287
352
 
353
+ def apply_exit_policy(code)
354
+ return if @shutdown || exit_on == :all || (exit_on == :failure && code.zero?)
355
+
356
+ @policy_exit_code = code
357
+ shutdown(explicit: false)
358
+ end
359
+
288
360
  def advance_groups
289
361
  processes.each do |entry|
290
362
  next unless entry.pgid
@@ -299,6 +371,7 @@ class Foruiman::Engine
299
371
  next unless entry.reaped && entry.group_gone
300
372
  next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
301
373
 
374
+ close_input(entry)
302
375
  entry.pgid = nil
303
376
  entry.deadline = nil
304
377
  entry.status = :stopped if entry.status == :stopping
@@ -328,16 +401,19 @@ class Foruiman::Engine
328
401
  break if budget <= 0
329
402
 
330
403
  entry, output = @readers.fetch(reader)
331
- bytes = reader.read_nonblock(READ_CHUNK, exception: false)
332
- if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
333
- @readers.delete(reader)
334
- reader.close
335
- output.feed("", eof: true)
336
- elsif bytes != :wait_readable
337
- budget -= bytes.bytesize
338
- output.feed(bytes)
339
- # Rotate serviced readers to the back for the next select.
340
- @readers[reader] = @readers.delete(reader)
404
+ begin
405
+ bytes = reader.read_nonblock(READ_CHUNK, exception: false)
406
+ if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
407
+ finish_reader(reader, entry, output)
408
+ elsif bytes != :wait_readable
409
+ budget -= bytes.bytesize
410
+ output.feed(bytes)
411
+ # Rotate serviced readers to the back for the next select.
412
+ @readers[reader] = @readers.delete(reader)
413
+ end
414
+ rescue Errno::EIO
415
+ # PTY masters report EIO when the child closes the slave side.
416
+ finish_reader(reader, entry, output)
341
417
  end
342
418
  end
343
419
  # A daemon that escaped the group may still hold a pipe open. Drain available
@@ -346,10 +422,48 @@ class Foruiman::Engine
346
422
  entry, output = @readers.fetch(reader)
347
423
  next unless entry.group_gone && !ready.include?(reader)
348
424
 
349
- @readers.delete(reader)
350
- reader.close
351
- output.feed("", eof: true)
425
+ finish_reader(reader, entry, output)
426
+ end
427
+ end
428
+
429
+ def create_managed_input
430
+ master, slave = PTY.open
431
+ [master, slave].each do |io|
432
+ io.binmode
433
+ io.close_on_exec = true
352
434
  end
435
+ [master, slave]
436
+ end
437
+
438
+ def flush_input(entry)
439
+ return if entry.input_buffer.empty? || !entry.input
440
+
441
+ written = entry.input.write_nonblock(entry.input_buffer, exception: false)
442
+ entry.input_buffer.slice!(0, written) if written.is_a?(Integer)
443
+ true
444
+ rescue IOError, SystemCallError
445
+ reader = entry.input
446
+ finish_reader(reader, entry, @readers.fetch(reader).last) if reader && @readers.key?(reader)
447
+ false
448
+ end
449
+
450
+ def finish_reader(reader, entry, output)
451
+ @readers.delete(reader)
452
+ @inputs.delete(reader)
453
+ entry.input = nil if entry.input.equal?(reader)
454
+ reader.close unless reader.closed?
455
+ output.feed("", eof: true)
456
+ end
457
+
458
+ def close_input(entry)
459
+ input = entry.input
460
+ return unless input
461
+
462
+ @inputs.delete(input)
463
+ @readers.delete(input)
464
+ input.close unless input.closed?
465
+ entry.input = nil
466
+ entry.input_buffer.clear
353
467
  end
354
468
 
355
469
  def lifecycle(entry, type, message)
data/lib/foruiman/env.rb CHANGED
@@ -28,8 +28,11 @@ class Foruiman::Env
28
28
  def self.load(root: Dir.pwd, file: nil, dotenv: true, inherited: ENV.to_h)
29
29
  env = inherited.dup
30
30
  default = File.join(root, ".env")
31
- env.merge!(new(default).to_h) if dotenv && File.file?(default)
32
- env.merge!(new(file).to_h) if file
31
+ if file
32
+ Array(file).each { |filename| env.merge!(new(filename).to_h) }
33
+ elsif dotenv && File.file?(default)
34
+ env.merge!(new(default).to_h)
35
+ end
33
36
  env.transform_values { |value| value.dup.freeze }.freeze
34
37
  end
35
38
  end
@@ -8,7 +8,7 @@ class Foruiman::Output
8
8
  def initialize(logs, name:, stream:, pid:)
9
9
  @logs = logs
10
10
  @metadata = { name: name, stream: stream, pid: pid }
11
- @decoder = Foruiman::ANSI::Decoder.new
11
+ @decoder = Foruiman::ANSI::Decoder.new(line_controls: true)
12
12
  @styles = Foruiman::ANSI::Styles.new
13
13
  @text = +""
14
14
  @previous = nil
@@ -18,20 +18,25 @@ class Foruiman::Output
18
18
 
19
19
  def feed(bytes, eof: false)
20
20
  decoded = @decoder.feed(bytes, eof: eof)
21
- decoded.scan(/\e\[[0-9;:]*m|\n|[^\e\n]+/).each do |token|
21
+ decoded.scan(/\e\[[0-9;:]*[mGCDK]|[\n\r\b]|[^\e\n\r\b]+/).each do |token|
22
22
  if token == "\n"
23
23
  if @split_boundary && !@dirty && !@previous
24
24
  @split_boundary = false
25
25
  else
26
26
  publish(true)
27
27
  end
28
+ elsif token == "\r" || token == "\b" || token.match?(/\A\e\[\d*[GCDK]\z/)
29
+ require_relative "output_line"
30
+ @line ||= Foruiman::OutputLine.new(@text)
31
+ @line.control(token, limit: MAX_BYTES / 2)
32
+ @dirty = true
28
33
  elsif token.start_with?("\e[")
29
34
  split_record if @text.bytesize + token.bytesize > MAX_BYTES
30
- @text << token
35
+ @text << token unless @line
31
36
  @styles.apply(token)
32
37
  @dirty = true
33
38
  else
34
- append_text(token)
39
+ @line ? edit_text(token) : append_text(token)
35
40
  end
36
41
  end
37
42
  if eof
@@ -43,6 +48,17 @@ class Foruiman::Output
43
48
 
44
49
  private
45
50
 
51
+ def edit_text(text)
52
+ text.scan(/\X/).each do |character|
53
+ if @line.cost + character.bytesize + @styles.prefix.bytesize + 4 > MAX_BYTES / 2
54
+ split_record
55
+ @line = Foruiman::OutputLine.new(@text)
56
+ end
57
+ @line.write(character, @styles.prefix)
58
+ @dirty = true
59
+ end
60
+ end
61
+
46
62
  def append_text(text)
47
63
  until text.empty?
48
64
  available = MAX_BYTES - @text.bytesize
@@ -66,11 +82,13 @@ class Foruiman::Output
66
82
 
67
83
  def publish(complete)
68
84
  @split_boundary = false
85
+ @text = @line.to_s if @line
69
86
  @previous = @logs.write(**@metadata, text: @text, complete: complete, previous: @previous)
70
87
  @dirty = false
71
88
  return unless complete
72
89
 
73
90
  @text = +@styles.prefix
91
+ @line = nil
74
92
  @previous = nil
75
93
  end
76
94
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+ require_relative "ansi"
5
+
6
+ # A single editable terminal row. Used only after a child emits a horizontal
7
+ # cursor control; ordinary append-only logs keep their fast streaming path.
8
+ class Foruiman::OutputLine
9
+ attr_reader :cost
10
+
11
+ def initialize(text)
12
+ @cells = []
13
+ @cursor = 0
14
+ @cost = 0
15
+ styles = Foruiman::ANSI::Styles.new
16
+ text.scan(/\e\[[0-9;:]*m|\X/).each do |token|
17
+ if token.match?(Foruiman::ANSI::SGR)
18
+ styles.apply(token)
19
+ else
20
+ write(token, styles.prefix)
21
+ end
22
+ end
23
+ end
24
+
25
+ def write(character, style)
26
+ width = Unicode::DisplayWidth.of(character, emoji: :all)
27
+ if width.zero? && @cursor.positive?
28
+ index = @cursor - 1
29
+ index -= 1 while index.positive? && @cells[index] == :continuation
30
+ @cells[index][0] += character if @cells[index].is_a?(Array)
31
+ else
32
+ width.times { |offset| clear_cell(@cursor + offset) }
33
+ @cells[@cursor] = [character, style]
34
+ (1...width).each { |offset| @cells[@cursor + offset] = :continuation }
35
+ @cursor += width
36
+ end
37
+ # Conservative, constant-time bound including style transitions.
38
+ @cost += character.bytesize + style.bytesize + Foruiman::ANSI::RESET.bytesize
39
+ end
40
+
41
+ def control(token, limit:)
42
+ @cost = to_s.bytesize
43
+ number = token[/\d+/].to_i
44
+ amount = [number, 1].max
45
+ case token
46
+ when "\r" then @cursor = 0
47
+ when "\b" then @cursor = [@cursor - 1, 0].max
48
+ else
49
+ case token[-1]
50
+ when "G" then @cursor = (amount - 1).clamp(0, limit)
51
+ when "C" then @cursor = (@cursor + amount).clamp(0, limit)
52
+ when "D" then @cursor = [@cursor - amount, 0].max
53
+ when "K" then erase(number)
54
+ end
55
+ end
56
+ end
57
+
58
+ def to_s
59
+ style = ""
60
+ @cells.each_with_object(+"") do |cell, text|
61
+ next if cell == :continuation
62
+
63
+ character, next_style = cell || [" ", ""]
64
+ if next_style != style
65
+ text << Foruiman::ANSI::RESET unless style.empty?
66
+ text << next_style
67
+ style = next_style
68
+ end
69
+ text << character
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def clear_cell(index)
76
+ if @cells[index] == :continuation
77
+ start = index - 1
78
+ start -= 1 while start.positive? && @cells[start] == :continuation
79
+ @cells[start] = nil
80
+ end
81
+ following = index + 1
82
+ while @cells[following] == :continuation
83
+ @cells[following] = nil
84
+ following += 1
85
+ end
86
+ @cells[index] = nil
87
+ end
88
+
89
+ def erase(mode)
90
+ case mode
91
+ when 0
92
+ clear_cell(@cursor) if @cursor < @cells.size
93
+ @cells.slice!(@cursor..) if @cursor < @cells.size
94
+ when 1
95
+ [@cursor + 1, @cells.size].min.times { |index| clear_cell(index) }
96
+ when 2 then @cells.clear
97
+ end
98
+ @cost = to_s.bytesize
99
+ end
100
+ end
@@ -12,7 +12,7 @@ class Foruiman::Process
12
12
 
13
13
  def run(options = {})
14
14
  ::Process.spawn(env.merge(options.fetch(:env, {})), "/bin/sh", "-c", command,
15
- chdir: cwd, in: File::NULL, out: options.fetch(:output, $stdout),
15
+ chdir: cwd, in: options.fetch(:input, $stdin), out: options.fetch(:output, $stdout),
16
16
  err: options.fetch(:error, $stderr), pgroup: true, unsetenv_others: true,
17
17
  close_others: true)
18
18
  end
@@ -22,13 +22,16 @@ module Foruiman::TUI
22
22
  end
23
23
 
24
24
  def run
25
+ @engine.manage_input!
25
26
  @terminal.session do
26
27
  @engine.run(keep_open: true) do
27
28
  rows, columns = @terminal.size
29
+ @engine.resize_inputs(rows, columns)
30
+ detach_unavailable_input
28
31
  input = @terminal.read
29
32
  @engine.shutdown if input.nil?
30
- height = [Renderer.log_height(rows: rows, columns: columns), 1].max
31
- @keyboard.feed(input.is_a?(String) ? input : "").each { |key| handle(key, height) }
33
+ height = [Renderer.log_height(rows: rows, columns: columns, input: state.input?), 1].max
34
+ read_keys(input.is_a?(String) ? input : "", height)
32
35
  state.feedback = nil if monotonic >= @feedback_until
33
36
  next if monotonic < @next_frame_at && @was_shutting_down == @engine.shutting_down?
34
37
 
@@ -66,7 +69,9 @@ module Foruiman::TUI
66
69
  when :toggle_follow then state.viewport.toggle(buffer, height)
67
70
  when :help then state.help = !state.help
68
71
  when :escape then state.help = false
69
- when :restart, :stop then control(key)
72
+ when :restart then control(key)
73
+ when :stop then toggle_process
74
+ when :input then begin_input
70
75
  when :restart_all, :stop_all
71
76
  control_all(key == :restart_all ? :restart : :stop)
72
77
  when :quit then @engine.shutdown
@@ -75,6 +80,76 @@ module Foruiman::TUI
75
80
 
76
81
  private
77
82
 
83
+ def read_keys(bytes, height)
84
+ @keyboard.feed("").each { |key| handle(key, height) } unless state.input?
85
+ bytes.bytes.each_with_index do |byte, index|
86
+ if state.input?
87
+ forward_input(bytes.byteslice(index..))
88
+ break
89
+ end
90
+ @keyboard.feed(byte.chr).each { |key| handle(key, height) }
91
+ end
92
+ end
93
+
94
+ def begin_input
95
+ if state.name == "all"
96
+ feedback("Select a process before entering input mode")
97
+ elsif @engine.state(state.name).status != :running
98
+ feedback("#{state.name} is not running")
99
+ else
100
+ state.help = false
101
+ state.viewport.follow
102
+ state.input_target = state.name
103
+ state.feedback = nil
104
+ end
105
+ end
106
+
107
+ def forward_input(bytes)
108
+ name = state.input_target
109
+ state.input_line.feed(bytes).each do |event|
110
+ case event
111
+ when :detach
112
+ state.input_target = nil
113
+ feedback("Returned from #{name}")
114
+ when :interrupt then @engine.interrupt_process(name)
115
+ when :eof then send_input(name, "\x04")
116
+ when Array then send_input(name, "#{event.last}\n")
117
+ end
118
+ end
119
+ end
120
+
121
+ def send_input(name, bytes)
122
+ return if @engine.write_input(name, bytes)
123
+
124
+ state.input_target = nil
125
+ feedback("Input unavailable for #{name}")
126
+ end
127
+
128
+ def detach_unavailable_input
129
+ return unless state.input?
130
+ return if @engine.state(state.input_target).status == :running
131
+
132
+ name = state.input_target
133
+ state.input_target = nil
134
+ feedback("Input closed for #{name}")
135
+ end
136
+
137
+ def toggle_process
138
+ if state.name == "all"
139
+ control_all(:stop)
140
+ return
141
+ end
142
+
143
+ entry = @engine.state(state.name)
144
+ if entry.pgid
145
+ @engine.stop(state.name)
146
+ feedback("Stopping #{state.name}")
147
+ else
148
+ @engine.start(state.name)
149
+ feedback("Starting #{state.name}")
150
+ end
151
+ end
152
+
78
153
  def control(action)
79
154
  if state.name == "all"
80
155
  if action == :restart