libtmux 0.1.0.alpha.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/libtmux/capture.rb +146 -0
- data/lib/libtmux/catalog.rb +116 -0
- data/lib/libtmux/child.rb +156 -0
- data/lib/libtmux/control.rb +885 -0
- data/lib/libtmux/criteria.rb +410 -0
- data/lib/libtmux/endpoint.rb +213 -0
- data/lib/libtmux/entity.rb +262 -0
- data/lib/libtmux/errors.rb +54 -0
- data/lib/libtmux/group.rb +59 -0
- data/lib/libtmux/metadata.rb +85 -0
- data/lib/libtmux/operations.rb +412 -0
- data/lib/libtmux/options.rb +180 -0
- data/lib/libtmux/owned.rb +252 -0
- data/lib/libtmux/process.rb +327 -0
- data/lib/libtmux/process_wait.rb +66 -0
- data/lib/libtmux/selection.rb +95 -0
- data/lib/libtmux/server.rb +534 -0
- data/lib/libtmux/snapshot.rb +428 -0
- data/lib/libtmux/socket_readiness.rb +232 -0
- data/lib/libtmux/source_query.rb +57 -0
- data/lib/libtmux/terminal.rb +197 -0
- data/lib/libtmux/version.rb +5 -0
- data/lib/libtmux.rb +18 -0
- data/schema/where-v1.json +2291 -0
- data/sig/control.rbs +75 -0
- data/sig/criteria.rbs +36 -0
- data/sig/fields.rbs +72 -0
- data/sig/group.rbs +14 -0
- data/sig/libtmux.rbs +186 -0
- data/sig/operations.rbs +91 -0
- data/sig/owned.rbs +7 -0
- data/sig/snapshot.rbs +70 -0
- data/sig/terminal.rbs +15 -0
- metadata +145 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fcntl"
|
|
4
|
+
require "libtmux/server"
|
|
5
|
+
require "libtmux/child"
|
|
6
|
+
require "libtmux/socket_readiness"
|
|
7
|
+
|
|
8
|
+
module LibTmux
|
|
9
|
+
class Server
|
|
10
|
+
# Starts a foreground daemon on a new private endpoint. Close owns its exit.
|
|
11
|
+
# No session is created; the default config is empty. Startup blocks.
|
|
12
|
+
def self.start(**options, &block)
|
|
13
|
+
Internal.const_get(:OwnedServer, false).open(**options, &block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def owned?
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module Internal
|
|
22
|
+
class OwnedDaemon
|
|
23
|
+
attr_reader :endpoint
|
|
24
|
+
|
|
25
|
+
def initialize(executable:, config:, timeout:, cancel:)
|
|
26
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
27
|
+
raise ArgumentError, "startup timeout must be positive and finite"
|
|
28
|
+
end
|
|
29
|
+
if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
|
|
30
|
+
raise ArgumentError, "cancel must provide a cancellation reader and state"
|
|
31
|
+
end
|
|
32
|
+
if config && (!config.is_a?(String) || config.empty? || config.include?("\0"))
|
|
33
|
+
raise ArgumentError, "config must be a nonempty filename without NUL"
|
|
34
|
+
end
|
|
35
|
+
config = config ? File.expand_path(config) : File::NULL
|
|
36
|
+
raise ArgumentError, "config must be a readable regular file" unless File.file?(config) && File.readable?(config) || config == File::NULL
|
|
37
|
+
|
|
38
|
+
@owner_pid = Process.pid
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
@closed = false
|
|
41
|
+
failure = nil
|
|
42
|
+
deadline = clock + timeout
|
|
43
|
+
begin
|
|
44
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
45
|
+
begin
|
|
46
|
+
check_cancel(cancel)
|
|
47
|
+
@directory = Dir.mktmpdir("libtmux-ruby-server-")
|
|
48
|
+
@endpoint = Endpoint.new(socket_path: File.join(@directory, "socket"), executable: executable)
|
|
49
|
+
if @endpoint.socket_path.bytesize > 103
|
|
50
|
+
raise UnsupportedFeatureError.new("owned Unix socket path exceeds the platform limit", phase: :startup)
|
|
51
|
+
end
|
|
52
|
+
@readiness = SocketReadiness.new(@directory)
|
|
53
|
+
@child = OwnedChild.new
|
|
54
|
+
pid = nil
|
|
55
|
+
begin
|
|
56
|
+
check_cancel(cancel)
|
|
57
|
+
check_deadline(deadline)
|
|
58
|
+
pid = Process.spawn({"TMUX" => nil, "TMUX_PANE" => nil},
|
|
59
|
+
@endpoint.executable, "-u", "-D", *@readiness.arguments, "-S", @endpoint.socket_path, "-f", config,
|
|
60
|
+
in: File::NULL, out: File::NULL, err: File::NULL, close_others: true, **@readiness.spawn_options)
|
|
61
|
+
rescue SystemCallError, IOError => error
|
|
62
|
+
raise TransportError.new("owned daemon could not start (#{error.class})", phase: :spawn), cause: nil
|
|
63
|
+
ensure
|
|
64
|
+
@child.spawned(pid)
|
|
65
|
+
end
|
|
66
|
+
Thread.handle_interrupt(Exception => :immediate) { await_ready(deadline, cancel) }
|
|
67
|
+
@readiness.close
|
|
68
|
+
@readiness.remove_files(@child.pid)
|
|
69
|
+
rescue Exception => error
|
|
70
|
+
failure = error
|
|
71
|
+
begin
|
|
72
|
+
close
|
|
73
|
+
rescue Exception => cleanup
|
|
74
|
+
attach_cleanup(failure, cleanup)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
rescue Exception => deferred
|
|
79
|
+
failure ||= deferred
|
|
80
|
+
end
|
|
81
|
+
if failure
|
|
82
|
+
begin
|
|
83
|
+
Thread.handle_interrupt(Exception => :never) { close }
|
|
84
|
+
rescue Exception => cleanup
|
|
85
|
+
attach_cleanup(failure, cleanup)
|
|
86
|
+
end
|
|
87
|
+
raise failure
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close
|
|
92
|
+
if Process.pid != @owner_pid
|
|
93
|
+
@child&.detach
|
|
94
|
+
@readiness&.close
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
return nil if @closed
|
|
100
|
+
|
|
101
|
+
errors = []
|
|
102
|
+
deadline = clock + 0.5
|
|
103
|
+
attempt(errors, "readiness close") { @readiness&.close }
|
|
104
|
+
if @child
|
|
105
|
+
attempt(errors, "daemon retirement") do
|
|
106
|
+
begin
|
|
107
|
+
unless @child.observed? || @child.complete?
|
|
108
|
+
@child.signal("TERM")
|
|
109
|
+
@child.wait_observed([0.05, deadline - clock].min.clamp(0, 0.05))
|
|
110
|
+
@child.signal("KILL") unless @child.observed? || @child.complete?
|
|
111
|
+
end
|
|
112
|
+
ensure
|
|
113
|
+
@child.finish_signalling
|
|
114
|
+
end
|
|
115
|
+
unless @child.join((deadline - clock).clamp(0, 0.5))
|
|
116
|
+
raise DeadlineExceeded.new("owned daemon has not retired; retry close", phase: :retire, pid: @child.pid)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
attempt(errors, "observer close") { @child.close } if @child.complete?
|
|
120
|
+
end
|
|
121
|
+
if !@child || @child.complete?
|
|
122
|
+
attempt(errors, "startup log removal") { @readiness&.remove_files(@child&.pid) }
|
|
123
|
+
attempt(errors, "socket removal") do
|
|
124
|
+
File.unlink(@endpoint.socket_path) if @endpoint && File.exist?(@endpoint.socket_path)
|
|
125
|
+
end
|
|
126
|
+
attempt(errors, "owned directory removal") do
|
|
127
|
+
Dir.rmdir(@directory) if @directory && File.exist?(@directory)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
@closed = errors.empty?
|
|
131
|
+
if @child&.complete? && !@observer_fault_reported
|
|
132
|
+
[@child.retirement_error, @child.observation_error].compact.each do |error|
|
|
133
|
+
errors << "owned child observation or retirement failed (#{error.class})"
|
|
134
|
+
end
|
|
135
|
+
@observer_fault_reported = true
|
|
136
|
+
end
|
|
137
|
+
unless errors.empty?
|
|
138
|
+
raise TransportError.new("owned daemon cleanup failed", phase: :retire,
|
|
139
|
+
pid: @child&.pid, delivery: @child&.pid ? :possibly_sent : :not_sent, cleanup_errors: errors)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def await_ready(deadline, cancel)
|
|
149
|
+
loop do
|
|
150
|
+
if @child.observed? || @child.observation_error || @child.complete?
|
|
151
|
+
raise TransportError.new("owned tmux daemon exited before becoming ready", phase: :startup,
|
|
152
|
+
pid: @child.pid, delivery: :possibly_sent)
|
|
153
|
+
end
|
|
154
|
+
if @readiness.ready?(@child)
|
|
155
|
+
check_deadline(deadline)
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
check_cancel(cancel)
|
|
160
|
+
check_deadline(deadline)
|
|
161
|
+
readers = [@readiness.reader, @child.reader]
|
|
162
|
+
readers << cancel.reader if cancel
|
|
163
|
+
IO.select(readers, nil, nil, [deadline - clock, 0].max)
|
|
164
|
+
end
|
|
165
|
+
rescue IOError, SystemCallError
|
|
166
|
+
raise TransportError.new("owned daemon readiness failed", phase: :startup,
|
|
167
|
+
pid: @child.pid, delivery: :possibly_sent), cause: nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def check_cancel(cancel)
|
|
171
|
+
return unless cancel&.cancelled?
|
|
172
|
+
|
|
173
|
+
raise Cancelled.new("owned daemon startup cancelled", phase: :startup,
|
|
174
|
+
delivery: @child&.pid ? :possibly_sent : :not_sent, pid: @child&.pid)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def check_deadline(deadline)
|
|
178
|
+
return if clock < deadline
|
|
179
|
+
|
|
180
|
+
raise DeadlineExceeded.new("owned daemon startup exceeded deadline", phase: :startup,
|
|
181
|
+
delivery: @child&.pid ? :possibly_sent : :not_sent, pid: @child&.pid)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def clock
|
|
185
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def attempt(errors, operation)
|
|
189
|
+
yield
|
|
190
|
+
rescue Exception => error
|
|
191
|
+
errors << "#{operation} failed (#{error.class})"
|
|
192
|
+
errors.concat(error.cleanup_errors) if error.is_a?(Error)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def attach_cleanup(failure, cleanup)
|
|
196
|
+
if failure.is_a?(Error)
|
|
197
|
+
failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"])
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class OwnedServer < Server
|
|
203
|
+
def initialize(executable: "tmux", config: nil, timeout: 5.0, cancel: nil, **options)
|
|
204
|
+
begin
|
|
205
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
206
|
+
@daemon = OwnedDaemon.new(executable: executable, config: config, timeout: timeout, cancel: cancel)
|
|
207
|
+
super(endpoint: @daemon.endpoint, **options)
|
|
208
|
+
@binding_ready = true
|
|
209
|
+
Thread.handle_interrupt(Exception => :immediate) { nil }
|
|
210
|
+
end
|
|
211
|
+
rescue Exception => failure
|
|
212
|
+
begin
|
|
213
|
+
Thread.handle_interrupt(Exception => :never) { close }
|
|
214
|
+
rescue Exception => cleanup
|
|
215
|
+
failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"]) if failure.is_a?(Error)
|
|
216
|
+
end
|
|
217
|
+
raise failure
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def owned?
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def close
|
|
226
|
+
failure = nil
|
|
227
|
+
begin
|
|
228
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
229
|
+
begin
|
|
230
|
+
super if @binding_ready
|
|
231
|
+
rescue Exception => error
|
|
232
|
+
failure = error
|
|
233
|
+
ensure
|
|
234
|
+
begin
|
|
235
|
+
@daemon&.close
|
|
236
|
+
rescue Exception => cleanup
|
|
237
|
+
failure.__send__(:attach_cleanup_errors, ["owned daemon close failed (#{cleanup.class})"]) if failure.is_a?(Error)
|
|
238
|
+
failure ||= cleanup
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
rescue Exception => deferred
|
|
243
|
+
failure ||= deferred
|
|
244
|
+
end
|
|
245
|
+
raise failure if failure
|
|
246
|
+
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
private_constant :SocketReadiness, :OwnedDaemon, :OwnedServer
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/errors"
|
|
4
|
+
require "libtmux/child"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
class CommandResult
|
|
8
|
+
attr_reader :stdout, :stderr, :status, :elapsed_seconds, :pid, :argv
|
|
9
|
+
|
|
10
|
+
def initialize(stdout:, stderr:, status:, elapsed_seconds:, pid:, argv:)
|
|
11
|
+
@stdout = stdout.b.freeze
|
|
12
|
+
@stderr = stderr.b.freeze
|
|
13
|
+
@status = status
|
|
14
|
+
@elapsed_seconds = elapsed_seconds
|
|
15
|
+
@pid = pid
|
|
16
|
+
@argv = argv.map { |argument| argument.dup.freeze }.freeze
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def success?
|
|
21
|
+
status.success?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delivery
|
|
25
|
+
:observed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def text(invalid: :strict)
|
|
29
|
+
raise ArgumentError, "invalid text policy" unless [:strict, :replace].include?(invalid)
|
|
30
|
+
|
|
31
|
+
text = stdout.dup.force_encoding(Encoding::UTF_8)
|
|
32
|
+
return text.scrub if invalid == :replace
|
|
33
|
+
return text if text.valid_encoding?
|
|
34
|
+
|
|
35
|
+
raise FieldDecodeError.new("command stdout is not valid UTF-8", delivery: delivery, phase: :decode, pid: pid)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect
|
|
39
|
+
"#<#{self.class} pid=#{pid} exitstatus=#{status.exitstatus} stdout_bytes=#{stdout.bytesize} stderr_bytes=#{stderr.bytesize}>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Cancellation
|
|
44
|
+
attr_reader :reader
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@reader, @writer = IO.pipe
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@cancelled = false
|
|
50
|
+
@creator_pid = Process.pid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cancel
|
|
54
|
+
ensure_owner
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return if @cancelled
|
|
57
|
+
|
|
58
|
+
@cancelled = true
|
|
59
|
+
@writer.write_nonblock("x", exception: false)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def cancelled?
|
|
64
|
+
ensure_owner
|
|
65
|
+
@mutex.synchronize { @cancelled }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def close
|
|
69
|
+
return detach unless @creator_pid == Process.pid
|
|
70
|
+
|
|
71
|
+
cancel
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
[@reader, @writer].each { |io| io.close unless io.closed? }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def detach
|
|
80
|
+
raise ArgumentError, "only a forked child may detach this token" if @creator_pid == Process.pid
|
|
81
|
+
|
|
82
|
+
[@reader, @writer].each { |io| io.close unless io.closed? }
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ensure_owner
|
|
87
|
+
raise ClosedError.new("cancellation token belongs to another process", phase: :admission) unless @creator_pid == Process.pid
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
module Internal
|
|
92
|
+
Cancellation = LibTmux::Cancellation
|
|
93
|
+
|
|
94
|
+
class ProcessExecutor
|
|
95
|
+
def initialize(stdout_limit: 1 << 20, stderr_limit: 1 << 18, input_limit: 1 << 20, argv_limit: 1 << 18, cleanup_timeout: 0.5, drain_timeout: 0.5)
|
|
96
|
+
[stdout_limit, stderr_limit, input_limit, argv_limit].each do |limit|
|
|
97
|
+
raise ArgumentError, "byte limits must be nonnegative integers" unless limit.is_a?(Integer) && limit >= 0
|
|
98
|
+
end
|
|
99
|
+
[cleanup_timeout, drain_timeout].each do |timeout|
|
|
100
|
+
raise ArgumentError, "cleanup and drain deadlines must be positive and finite" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
101
|
+
end
|
|
102
|
+
@limits = {stdout: stdout_limit, stderr: stderr_limit, input: input_limit, argv: argv_limit}.freeze
|
|
103
|
+
@cleanup_timeout = cleanup_timeout
|
|
104
|
+
@drain_timeout = drain_timeout
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run(argv, input: "".b, env: {}, timeout: 5.0, cancel: nil)
|
|
108
|
+
Execution.new(argv, input, env, timeout, cancel, @limits, @cleanup_timeout, @drain_timeout).call
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class Execution
|
|
112
|
+
CHUNK_BYTES = 16_384
|
|
113
|
+
|
|
114
|
+
def initialize(argv, input, env, timeout, cancel, limits, cleanup_timeout, drain_timeout)
|
|
115
|
+
@started = monotonic
|
|
116
|
+
unless argv.is_a?(Array) && !argv.empty? && argv.all? { |argument| argument.is_a?(String) && !argument.include?("\0") }
|
|
117
|
+
raise ArgumentError, "argv must contain strings without NUL"
|
|
118
|
+
end
|
|
119
|
+
raise ArgumentError, "input must be a String" unless input.is_a?(String)
|
|
120
|
+
raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
|
|
121
|
+
if input.bytesize > limits.fetch(:input) || argv.sum { |argument| argument.bytesize + 1 } > limits.fetch(:argv)
|
|
122
|
+
raise CapacityError.new("command input exceeded its byte limit", delivery: :not_sent, phase: :admission)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@argv = argv.map { |argument| argument.dup.freeze }.freeze
|
|
126
|
+
@input = input.b.freeze
|
|
127
|
+
@env = env.merge("TMUX" => nil, "TMUX_PANE" => nil)
|
|
128
|
+
@deadline = @started + timeout
|
|
129
|
+
@cancel = cancel
|
|
130
|
+
@limits = limits
|
|
131
|
+
@cleanup_timeout = cleanup_timeout
|
|
132
|
+
@drain_timeout = drain_timeout
|
|
133
|
+
@process_wait = ProcessWait.new
|
|
134
|
+
@owned = []
|
|
135
|
+
@buffers = {stdout: +"".b, stderr: +"".b}
|
|
136
|
+
@offset = 0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def call
|
|
140
|
+
error = nil
|
|
141
|
+
result = nil
|
|
142
|
+
begin
|
|
143
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
144
|
+
begin
|
|
145
|
+
check_deadline(:admission)
|
|
146
|
+
check_cancel(:admission)
|
|
147
|
+
spawn
|
|
148
|
+
Thread.handle_interrupt(Exception => :immediate) { result = communicate }
|
|
149
|
+
rescue Exception => failure
|
|
150
|
+
error = failure
|
|
151
|
+
ensure
|
|
152
|
+
cleanup_errors = retire
|
|
153
|
+
if error.is_a?(Error)
|
|
154
|
+
error.send(:attach_cleanup_errors, cleanup_errors)
|
|
155
|
+
elsif error.nil? && !cleanup_errors.empty?
|
|
156
|
+
error = TransportError.new("command cleanup failed", **details(:retire), cleanup_errors: cleanup_errors)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
rescue Exception => deferred
|
|
161
|
+
error ||= deferred
|
|
162
|
+
end
|
|
163
|
+
raise error, cause: nil if error
|
|
164
|
+
|
|
165
|
+
result
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def monotonic
|
|
171
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def pipe
|
|
175
|
+
pair = IO.pipe
|
|
176
|
+
pair.each(&:binmode)
|
|
177
|
+
@owned.concat(pair)
|
|
178
|
+
pair
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def spawn
|
|
182
|
+
child_input, @input_writer = pipe
|
|
183
|
+
@output_reader, child_output = pipe
|
|
184
|
+
@error_reader, child_error = pipe
|
|
185
|
+
@child = OwnedChild.new(@process_wait)
|
|
186
|
+
@exit_reader = @child.reader
|
|
187
|
+
@owned << @exit_reader
|
|
188
|
+
begin
|
|
189
|
+
@pid = Process.spawn(@env, [@argv.first, @argv.first], *@argv.drop(1),
|
|
190
|
+
in: child_input, out: child_output, err: child_error, close_others: true)
|
|
191
|
+
ensure
|
|
192
|
+
@child.spawned(@pid)
|
|
193
|
+
end
|
|
194
|
+
[child_input, child_output, child_error].each(&:close)
|
|
195
|
+
@input_writer.close if @input.empty?
|
|
196
|
+
rescue SystemCallError, IOError => error
|
|
197
|
+
raise TransportError.new("could not start command (#{error.class})", **details(:spawn)), cause: nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def communicate
|
|
201
|
+
reading = {@output_reader => :stdout, @error_reader => :stderr}
|
|
202
|
+
loop do
|
|
203
|
+
return result if @status && reading.empty?
|
|
204
|
+
|
|
205
|
+
@drain_deadline ||= monotonic + @drain_timeout if @child.observed?
|
|
206
|
+
phase = @drain_deadline ? :drain : (@input_writer.closed? ? :read : :write)
|
|
207
|
+
check_cancel(phase) unless @status
|
|
208
|
+
check_deadline(phase)
|
|
209
|
+
readers = reading.keys
|
|
210
|
+
readers << @exit_reader unless @status
|
|
211
|
+
readers << @cancel.reader if @cancel && !@status
|
|
212
|
+
writers = @input_writer.closed? ? [] : [@input_writer]
|
|
213
|
+
remaining = [@deadline, @drain_deadline || @deadline].min - monotonic
|
|
214
|
+
ready = IO.select(readers, writers, nil, [remaining, 0].max)
|
|
215
|
+
next unless ready
|
|
216
|
+
|
|
217
|
+
if ready.first.include?(@exit_reader)
|
|
218
|
+
@exit_reader.read_nonblock(CHUNK_BYTES, exception: false)
|
|
219
|
+
raise @child.observation_error if @child.observation_error
|
|
220
|
+
|
|
221
|
+
@child.finish_signalling
|
|
222
|
+
if @child.complete?
|
|
223
|
+
raise @child.retirement_error if @child.retirement_error
|
|
224
|
+
|
|
225
|
+
@status = @child.status
|
|
226
|
+
@exit_reader.close
|
|
227
|
+
@input_writer.close unless @input_writer.closed?
|
|
228
|
+
@drain_deadline ||= monotonic + @drain_timeout
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
ready.first.each { |io| read_output(io, reading) if reading.key?(io) }
|
|
232
|
+
write_input if ready[1].include?(@input_writer) && !@input_writer.closed?
|
|
233
|
+
end
|
|
234
|
+
rescue SystemCallError, IOError => error
|
|
235
|
+
check_cancel(:read) unless @status
|
|
236
|
+
raise TransportError.new("command I/O failed (#{error.class})", **details(:read)), cause: nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def read_output(io, reading)
|
|
240
|
+
stream = reading.fetch(io)
|
|
241
|
+
buffer = @buffers.fetch(stream)
|
|
242
|
+
available = @limits.fetch(stream) - buffer.bytesize
|
|
243
|
+
bytes = io.read_nonblock([CHUNK_BYTES, available + 1].min, exception: false)
|
|
244
|
+
if bytes.nil?
|
|
245
|
+
reading.delete(io)
|
|
246
|
+
io.close
|
|
247
|
+
elsif bytes.is_a?(String)
|
|
248
|
+
raise CapacityError.new("command #{stream} exceeded its byte limit", **details(:read)) if bytes.bytesize > available
|
|
249
|
+
|
|
250
|
+
buffer << bytes
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def write_input
|
|
255
|
+
bytes = @input.byteslice(@offset, CHUNK_BYTES)
|
|
256
|
+
written = @input_writer.write_nonblock(bytes, exception: false)
|
|
257
|
+
@offset += written if written.is_a?(Integer)
|
|
258
|
+
@input_writer.close if @offset == @input.bytesize
|
|
259
|
+
rescue Errno::EPIPE
|
|
260
|
+
@input_writer.close
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def check_cancel(phase)
|
|
264
|
+
if @cancel&.cancelled? && !@child&.observed?
|
|
265
|
+
raise Cancelled.new("command was cancelled", **details(phase))
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def check_deadline(phase)
|
|
270
|
+
deadline = [@deadline, @drain_deadline || @deadline].min
|
|
271
|
+
raise DeadlineExceeded.new("command deadline exceeded", **details(phase)) if monotonic >= deadline
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def details(phase)
|
|
275
|
+
{delivery: @status ? :observed : (@pid ? :possibly_sent : :not_sent), phase: phase, pid: @pid}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def result
|
|
279
|
+
CommandResult.new(**@buffers, status: @status, elapsed_seconds: monotonic - @started, pid: @pid, argv: @argv)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def retire
|
|
283
|
+
errors = []
|
|
284
|
+
@owned.each do |io|
|
|
285
|
+
io.close unless io.closed?
|
|
286
|
+
rescue IOError, SystemCallError => error
|
|
287
|
+
errors << "descriptor cleanup failed (#{error.class})"
|
|
288
|
+
end
|
|
289
|
+
return errors unless @child
|
|
290
|
+
|
|
291
|
+
deadline = monotonic + @cleanup_timeout
|
|
292
|
+
unless @pid
|
|
293
|
+
errors << "command waiter did not finish" unless @child.join(@cleanup_timeout)
|
|
294
|
+
return errors
|
|
295
|
+
end
|
|
296
|
+
unless @child.observed? || @child.observation_error.is_a?(Errno::ECHILD)
|
|
297
|
+
signal("TERM", errors)
|
|
298
|
+
# Reserve the cleanup budget for reaping; timer grace can overrun it.
|
|
299
|
+
signal("KILL", errors) unless @child.observed?
|
|
300
|
+
end
|
|
301
|
+
@child.finish_signalling
|
|
302
|
+
if @child.join([deadline - monotonic, 0].max)
|
|
303
|
+
@status ||= @child.status
|
|
304
|
+
else
|
|
305
|
+
errors << "owned client cleanup remains pending after its deadline"
|
|
306
|
+
end
|
|
307
|
+
errors << "command exit observation failed (#{@child.observation_error.class})" if @child.observation_error
|
|
308
|
+
errors << "command fallback reap failed (#{@child.retirement_error.class})" if @child.retirement_error
|
|
309
|
+
errors
|
|
310
|
+
rescue StandardError => error
|
|
311
|
+
errors << "command waiter failed (#{error.class})"
|
|
312
|
+
errors
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def signal(name, errors)
|
|
316
|
+
@child.signal(name)
|
|
317
|
+
rescue Errno::ESRCH
|
|
318
|
+
nil
|
|
319
|
+
rescue SystemCallError => error
|
|
320
|
+
errors << "client termination failed (#{error.class})"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private_constant :Execution
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "libtmux/errors"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
module Internal
|
|
8
|
+
class ProcessWait
|
|
9
|
+
P_PID = 1
|
|
10
|
+
WNOHANG = 0x00000001
|
|
11
|
+
WEXITED = 0x00000004
|
|
12
|
+
private_constant :P_PID, :WNOHANG, :WEXITED
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
unless Fiddle::SIZEOF_INT == 4
|
|
16
|
+
raise UnsupportedFeatureError.new("native process waiting requires a 32-bit C int", phase: :admission)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Native wait.h values differ: Linux WNOWAIT=0x01000000, Darwin=0x20.
|
|
20
|
+
case RUBY_PLATFORM
|
|
21
|
+
when /linux/
|
|
22
|
+
@options = WEXITED | 0x01000000
|
|
23
|
+
@information_size = 128 # Linux siginfo_t has a fixed 128-byte ABI.
|
|
24
|
+
when /darwin/
|
|
25
|
+
@options = WEXITED | 0x00000020
|
|
26
|
+
@information_size = 6 * Fiddle::SIZEOF_INT + 2 * Fiddle::SIZEOF_VOIDP + 8 * Fiddle::SIZEOF_LONG
|
|
27
|
+
else
|
|
28
|
+
raise UnsupportedFeatureError.new("native process waiting is unavailable on this platform", phase: :admission)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@waitid = Fiddle::Function.new(Fiddle::Handle::DEFAULT["waitid"],
|
|
32
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
33
|
+
Fiddle::TYPE_INT, need_gvl: false)
|
|
34
|
+
verify_available
|
|
35
|
+
rescue Fiddle::DLError
|
|
36
|
+
raise UnsupportedFeatureError.new("native waitid is unavailable", phase: :admission), cause: nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def observe(pid)
|
|
40
|
+
raise ArgumentError, "child PID must be a positive integer" unless pid.is_a?(Integer) && pid.positive?
|
|
41
|
+
|
|
42
|
+
Fiddle::Pointer.malloc(@information_size, Fiddle::RUBY_FREE) do |information|
|
|
43
|
+
loop do
|
|
44
|
+
return if @waitid.call(P_PID, pid, information, @options).zero?
|
|
45
|
+
|
|
46
|
+
error = Fiddle.last_error
|
|
47
|
+
next if error == Errno::EINTR::Errno
|
|
48
|
+
|
|
49
|
+
raise SystemCallError.new("waitid", error)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def verify_available
|
|
57
|
+
Fiddle::Pointer.malloc(@information_size, Fiddle::RUBY_FREE) do |information|
|
|
58
|
+
# A process cannot be its own child. Probe flags before owning a client.
|
|
59
|
+
status = @waitid.call(P_PID, Process.pid, information, @options | WNOHANG)
|
|
60
|
+
return if status == -1 && Fiddle.last_error == Errno::ECHILD::Errno
|
|
61
|
+
end
|
|
62
|
+
raise UnsupportedFeatureError.new("native non-reaping process observation is unavailable", phase: :admission)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|