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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module LibTmux
|
|
6
|
+
# Replayable captured membership. Snapshot construction owns record values.
|
|
7
|
+
class Selection
|
|
8
|
+
include Enumerable
|
|
9
|
+
OMITTED = Object.new.freeze
|
|
10
|
+
private_constant :OMITTED
|
|
11
|
+
|
|
12
|
+
def initialize(records, entity: nil, graph: nil)
|
|
13
|
+
@records = records.to_a.dup.freeze
|
|
14
|
+
@entity = entity
|
|
15
|
+
@graph = graph
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def each
|
|
20
|
+
return enum_for(:each) { @records.length } unless block_given?
|
|
21
|
+
|
|
22
|
+
@records.each { |record| yield record }
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def select
|
|
27
|
+
return enum_for(:select) { @records.length } unless block_given?
|
|
28
|
+
|
|
29
|
+
derive(@records.select { |record| yield record })
|
|
30
|
+
end
|
|
31
|
+
alias filter select
|
|
32
|
+
alias find_all select
|
|
33
|
+
|
|
34
|
+
def reject
|
|
35
|
+
return enum_for(:reject) { @records.length } unless block_given?
|
|
36
|
+
|
|
37
|
+
derive(@records.reject { |record| yield record })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_a
|
|
41
|
+
@records.dup
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def size
|
|
45
|
+
@records.length
|
|
46
|
+
end
|
|
47
|
+
alias length size
|
|
48
|
+
|
|
49
|
+
def empty?
|
|
50
|
+
@records.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def where(criteria = OMITTED, **keywords, &block)
|
|
54
|
+
require_relative "criteria"
|
|
55
|
+
raise ArgumentError, "where does not accept a block" if block
|
|
56
|
+
raise InvalidFilterError.new(expected: "selection with an entity schema") unless @entity
|
|
57
|
+
unless criteria.equal?(OMITTED) || keywords.empty?
|
|
58
|
+
raise ArgumentError, "use positional or keyword criteria, not both"
|
|
59
|
+
end
|
|
60
|
+
criteria = keywords if criteria.equal?(OMITTED)
|
|
61
|
+
expression = FilterExpr.build(@entity, criteria)
|
|
62
|
+
derive(expression.__send__(:select_records, @records))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def one(criteria = OMITTED, **keywords, &block)
|
|
66
|
+
selection = retrieval_selection(criteria, keywords, block)
|
|
67
|
+
raise NoMatchError, "selection has no matches" if selection.empty?
|
|
68
|
+
raise MultipleMatchesError, "selection has at least two matches" if selection.size > 1
|
|
69
|
+
|
|
70
|
+
selection.first
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def one_or_nil(criteria = OMITTED, **keywords, &block)
|
|
74
|
+
selection = retrieval_selection(criteria, keywords, block)
|
|
75
|
+
selection.empty? ? nil : selection.one
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def exists?(criteria = OMITTED, **keywords, &block)
|
|
79
|
+
!retrieval_selection(criteria, keywords, block).empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def derive(records)
|
|
85
|
+
self.class.new(records, entity: @entity, graph: @graph)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def retrieval_selection(criteria, keywords, block)
|
|
89
|
+
raise ArgumentError, "retrieval methods do not accept a block" if block
|
|
90
|
+
return keywords.empty? ? self : where(**keywords) if criteria.equal?(OMITTED)
|
|
91
|
+
|
|
92
|
+
where(criteria, **keywords)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "endpoint"
|
|
4
|
+
require_relative "process"
|
|
5
|
+
require_relative "metadata"
|
|
6
|
+
require_relative "entity"
|
|
7
|
+
|
|
8
|
+
module LibTmux
|
|
9
|
+
# Connects to an existing endpoint. Closing retires clients, never the daemon.
|
|
10
|
+
class Server
|
|
11
|
+
attr_reader :endpoint
|
|
12
|
+
|
|
13
|
+
def self.open(**options)
|
|
14
|
+
return new(**options) unless block_given?
|
|
15
|
+
|
|
16
|
+
failure = nil
|
|
17
|
+
result = nil
|
|
18
|
+
begin
|
|
19
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
20
|
+
server = nil
|
|
21
|
+
begin
|
|
22
|
+
server = new(**options)
|
|
23
|
+
Thread.handle_interrupt(Exception => :immediate) { result = yield server }
|
|
24
|
+
rescue Exception => error
|
|
25
|
+
failure = error
|
|
26
|
+
ensure
|
|
27
|
+
begin
|
|
28
|
+
server&.close
|
|
29
|
+
rescue Exception => cleanup
|
|
30
|
+
if failure.is_a?(Error)
|
|
31
|
+
failure.__send__(:attach_cleanup_errors, ["server close failed (#{cleanup.class})"])
|
|
32
|
+
end
|
|
33
|
+
failure ||= cleanup
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rescue Exception => deferred
|
|
38
|
+
failure ||= deferred
|
|
39
|
+
end
|
|
40
|
+
raise failure if failure
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(endpoint: nil, socket_path: nil, socket_name: nil, executable: "tmux", max_requests: 32, max_controls: 4, close_timeout: 0.5)
|
|
46
|
+
if endpoint && (socket_path || socket_name || executable != "tmux")
|
|
47
|
+
raise ArgumentError, "endpoint cannot be combined with socket or executable options"
|
|
48
|
+
end
|
|
49
|
+
raise ArgumentError, "endpoint must be an Endpoint" if endpoint && !endpoint.is_a?(Endpoint)
|
|
50
|
+
unless max_requests.is_a?(Integer) && max_requests.positive?
|
|
51
|
+
raise ArgumentError, "max_requests must be a positive Integer"
|
|
52
|
+
end
|
|
53
|
+
unless max_controls.is_a?(Integer) && max_controls.positive?
|
|
54
|
+
raise ArgumentError, "max_controls must be a positive Integer"
|
|
55
|
+
end
|
|
56
|
+
unless close_timeout.is_a?(Numeric) && close_timeout.finite? && close_timeout.positive?
|
|
57
|
+
raise ArgumentError, "close_timeout must be positive and finite"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@endpoint = endpoint || Endpoint.new(socket_path: socket_path, socket_name: socket_name, executable: executable)
|
|
61
|
+
@owner_pid = Process.pid
|
|
62
|
+
@max_requests = max_requests
|
|
63
|
+
@max_controls = max_controls
|
|
64
|
+
@close_timeout = close_timeout
|
|
65
|
+
@mutex = Mutex.new
|
|
66
|
+
@idle = ConditionVariable.new
|
|
67
|
+
@requests = {}
|
|
68
|
+
@controls = []
|
|
69
|
+
@closed = false
|
|
70
|
+
@executor = Internal::ProcessExecutor.new
|
|
71
|
+
@pin = Internal::SocketIdentity.new(@endpoint)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Raw tmux arguments retain tmux's separators and format semantics.
|
|
75
|
+
def run(argv, input: "".b, timeout: 5.0, cancel: nil)
|
|
76
|
+
ensure_owner
|
|
77
|
+
started = monotonic
|
|
78
|
+
validate_argv(argv)
|
|
79
|
+
raise ArgumentError, "raw commands cannot override endpoint flags" if argv.first.start_with?("-")
|
|
80
|
+
raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
|
|
81
|
+
perform_request(cancel: cancel) do |view|
|
|
82
|
+
@executor.run(@pin.command_prefix + argv, input: input,
|
|
83
|
+
timeout: timeout - (monotonic - started), cancel: view)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def close
|
|
88
|
+
if Process.pid != @owner_pid
|
|
89
|
+
@requests.each_key(&:close)
|
|
90
|
+
@controls.each(&:close)
|
|
91
|
+
@requests = {}
|
|
92
|
+
@controls = []
|
|
93
|
+
@closed = true
|
|
94
|
+
@pin.close
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
if @requests.value?(Thread.current)
|
|
101
|
+
raise ClosedError.new("cannot close a server from its active request", phase: :retire)
|
|
102
|
+
end
|
|
103
|
+
@closed = true
|
|
104
|
+
deadline = monotonic + @close_timeout
|
|
105
|
+
@controls.each { |control| control.__send__(:request_close) }
|
|
106
|
+
@requests.each_key(&:cancel)
|
|
107
|
+
until @requests.empty?
|
|
108
|
+
remaining = deadline - monotonic
|
|
109
|
+
unless remaining.positive?
|
|
110
|
+
raise DeadlineExceeded.new("server clients have not retired; retry close", phase: :retire, delivery: :possibly_sent)
|
|
111
|
+
end
|
|
112
|
+
@idle.wait(@mutex, remaining)
|
|
113
|
+
end
|
|
114
|
+
@controls.dup.each do |control|
|
|
115
|
+
control.close(timeout: (deadline - monotonic).clamp(0, 0.5))
|
|
116
|
+
@controls.delete(control)
|
|
117
|
+
end
|
|
118
|
+
@pin.close
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns a frozen local snapshot, including after close. Slots are admitted
|
|
125
|
+
# requests; control connections are retained registrations, not OS clients.
|
|
126
|
+
def diagnostics
|
|
127
|
+
ensure_owner
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
{admitted_requests: @requests.length, reserved_process_slots: @requests.length,
|
|
130
|
+
control_connections: @controls.length, closed: @closed,
|
|
131
|
+
limits: {max_requests: @max_requests, max_controls: @max_controls,
|
|
132
|
+
close_timeout: @close_timeout}.freeze}.freeze
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def kill(timeout: 5.0, cancel: nil)
|
|
137
|
+
execute_typed(["kill-server"], timeout: timeout, cancel: cancel)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def new_session(name:, command:, width: nil, height: nil, window_name: nil, cwd: nil, environment: {}, receipt: false, timeout: 5.0, cancel: nil)
|
|
141
|
+
budget = operation_budget(timeout, cancel)
|
|
142
|
+
arguments = ["new-session", "-d", "-P", "-F", creation_format(:session, receipt), "-s", literal_name(name)]
|
|
143
|
+
arguments.concat(["-n", literal_name(window_name)]) if window_name
|
|
144
|
+
{"-x" => width, "-y" => height}.each do |flag, value|
|
|
145
|
+
next if value.nil?
|
|
146
|
+
raise ArgumentError, "dimensions must be positive integers" unless value.is_a?(Integer) && value.positive?
|
|
147
|
+
|
|
148
|
+
arguments.concat([flag, value.to_s])
|
|
149
|
+
end
|
|
150
|
+
arguments.concat(creation_options(cwd: cwd, environment: environment))
|
|
151
|
+
create_entity(:session, arguments + ["--"] + pane_command(command), receipt: receipt, **budget.options)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def list_sessions(timeout: 5.0, cancel: nil)
|
|
155
|
+
list_entities(:session, [], global: true, timeout: timeout, cancel: cancel)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def list_windows(timeout: 5.0, cancel: nil)
|
|
159
|
+
list_entities(:window, ["-a"], global: true, timeout: timeout, cancel: cancel)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def list_panes(timeout: 5.0, cancel: nil)
|
|
163
|
+
list_entities(:pane, ["-a"], global: true, timeout: timeout, cancel: cancel)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def session(ref)
|
|
167
|
+
resolve(ref, :session)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def window(ref)
|
|
171
|
+
resolve(ref, :window)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def pane(ref)
|
|
175
|
+
resolve(ref, :pane)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def open_control(session:, **options)
|
|
179
|
+
session_id = target(session, :session)
|
|
180
|
+
failure = nil
|
|
181
|
+
result = nil
|
|
182
|
+
connection = nil
|
|
183
|
+
keep = false
|
|
184
|
+
begin
|
|
185
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
186
|
+
begin
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
ensure_open
|
|
189
|
+
@controls.reject!(&:closed?)
|
|
190
|
+
if @controls.length >= @max_controls
|
|
191
|
+
raise CapacityError.new("server control capacity is exhausted", phase: :admission)
|
|
192
|
+
end
|
|
193
|
+
connection = ControlConnection.new(binding: @pin, session_id: session_id, **options)
|
|
194
|
+
@controls << connection
|
|
195
|
+
end
|
|
196
|
+
if block_given?
|
|
197
|
+
Thread.handle_interrupt(Exception => :immediate) { result = yield connection }
|
|
198
|
+
else
|
|
199
|
+
Thread.handle_interrupt(Exception => :immediate) { nil }
|
|
200
|
+
result = connection
|
|
201
|
+
keep = true
|
|
202
|
+
end
|
|
203
|
+
rescue Exception => error
|
|
204
|
+
failure = error
|
|
205
|
+
ensure
|
|
206
|
+
if connection && !keep
|
|
207
|
+
begin
|
|
208
|
+
connection.close(timeout: [@close_timeout, 0.5].min)
|
|
209
|
+
@mutex.synchronize { @controls.delete(connection) }
|
|
210
|
+
rescue Exception => cleanup
|
|
211
|
+
failure.__send__(:attach_cleanup_errors, ["control close failed (#{cleanup.class})"]) if failure.is_a?(Error)
|
|
212
|
+
failure ||= cleanup
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
rescue Exception => deferred
|
|
218
|
+
failure ||= deferred
|
|
219
|
+
end
|
|
220
|
+
raise failure if failure
|
|
221
|
+
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def snapshot(**options)
|
|
226
|
+
ensure_owner
|
|
227
|
+
@mutex.synchronize { ensure_open }
|
|
228
|
+
Internal::Capture.new(self, binding_key: @pin.key).call(**options)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def search_panes(where: {}, pushdown: :auto, **options)
|
|
232
|
+
Internal::SourceQuery.new(:pane, where: where, pushdown: pushdown).execute(self, **options)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def explain_panes(where: {}, pushdown: :auto)
|
|
236
|
+
Internal::SourceQuery.new(:pane, where: where, pushdown: pushdown).explain
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
class OperationBudget
|
|
242
|
+
def initialize(timeout, cancel, clock)
|
|
243
|
+
raise ArgumentError, "timeout must be finite" unless timeout.is_a?(Numeric) && timeout.finite?
|
|
244
|
+
if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
|
|
245
|
+
raise ArgumentError, "cancel must provide a cancellation reader and state"
|
|
246
|
+
end
|
|
247
|
+
@clock, @cancel = clock, cancel
|
|
248
|
+
@deadline = clock.call + timeout
|
|
249
|
+
@dispatched = false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def options
|
|
253
|
+
delivery = @dispatched ? :possibly_sent : :not_sent
|
|
254
|
+
raise Cancelled.new("operation was cancelled", phase: :admission, delivery: delivery) if @cancel&.cancelled?
|
|
255
|
+
|
|
256
|
+
remaining = @deadline - @clock.call
|
|
257
|
+
unless remaining.positive?
|
|
258
|
+
raise DeadlineExceeded.new("operation deadline elapsed", phase: :admission, delivery: delivery)
|
|
259
|
+
end
|
|
260
|
+
@dispatched = true
|
|
261
|
+
{timeout: remaining, cancel: @cancel}
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
private_constant :OperationBudget
|
|
265
|
+
|
|
266
|
+
def operation_budget(timeout, cancel)
|
|
267
|
+
OperationBudget.new(timeout, cancel, method(:monotonic))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
class CancellationView
|
|
271
|
+
def initialize(owned, external)
|
|
272
|
+
@owned = owned
|
|
273
|
+
@external = external
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def reader
|
|
277
|
+
@owned.reader
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def cancelled?
|
|
281
|
+
@owned.cancelled? || !!@external&.cancelled?
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
private_constant :CancellationView
|
|
285
|
+
|
|
286
|
+
def monotonic
|
|
287
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def ensure_owner
|
|
291
|
+
unless Process.pid == @owner_pid
|
|
292
|
+
raise ClosedError.new("server belongs to another process", phase: :admission)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Borrowing adapters may copy these references, but must not suspend or do I/O.
|
|
297
|
+
def with_bound_endpoint
|
|
298
|
+
ensure_owner
|
|
299
|
+
@mutex.synchronize do
|
|
300
|
+
ensure_open
|
|
301
|
+
yield @endpoint, @pin
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def perform_request(cancel:)
|
|
306
|
+
ensure_owner
|
|
307
|
+
if cancel && (!cancel.respond_to?(:reader) || !cancel.respond_to?(:cancelled?))
|
|
308
|
+
raise ArgumentError, "cancel must provide a cancellation reader and state"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
failure = nil
|
|
312
|
+
result = nil
|
|
313
|
+
begin
|
|
314
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
315
|
+
owned = nil
|
|
316
|
+
watcher = nil
|
|
317
|
+
begin
|
|
318
|
+
@mutex.synchronize do
|
|
319
|
+
ensure_open
|
|
320
|
+
if @requests.length >= @max_requests
|
|
321
|
+
raise CapacityError.new("server request capacity is exhausted", phase: :admission)
|
|
322
|
+
end
|
|
323
|
+
owned = Internal::Cancellation.new
|
|
324
|
+
@requests[owned] = Thread.current
|
|
325
|
+
end
|
|
326
|
+
if cancel
|
|
327
|
+
watcher = Thread.new do
|
|
328
|
+
Thread.current.report_on_exception = false
|
|
329
|
+
IO.select([cancel.reader, owned.reader]) unless cancel.cancelled?
|
|
330
|
+
owned.cancel if cancel.cancelled?
|
|
331
|
+
rescue IOError, SystemCallError
|
|
332
|
+
owned.cancel
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
view = CancellationView.new(owned, cancel)
|
|
336
|
+
Thread.handle_interrupt(Exception => :immediate) do
|
|
337
|
+
result = yield view
|
|
338
|
+
end
|
|
339
|
+
rescue Exception => error
|
|
340
|
+
failure = error
|
|
341
|
+
ensure
|
|
342
|
+
cleanup_errors = retire_request(owned, watcher) if owned
|
|
343
|
+
if cleanup_errors && !cleanup_errors.empty?
|
|
344
|
+
if failure.is_a?(Error)
|
|
345
|
+
failure.__send__(:attach_cleanup_errors, cleanup_errors)
|
|
346
|
+
elsif failure.nil?
|
|
347
|
+
failure = TransportError.new("server request cleanup failed", phase: :retire,
|
|
348
|
+
delivery: result ? :observed : :possibly_sent, cleanup_errors: cleanup_errors)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
rescue Exception => deferred
|
|
354
|
+
failure ||= deferred
|
|
355
|
+
end
|
|
356
|
+
raise failure if failure
|
|
357
|
+
|
|
358
|
+
result
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def retire_request(owned, watcher)
|
|
362
|
+
errors = []
|
|
363
|
+
begin
|
|
364
|
+
owned.cancel
|
|
365
|
+
watcher&.join(@close_timeout) || errors << "cancellation watcher did not retire" if watcher
|
|
366
|
+
rescue Exception => error
|
|
367
|
+
errors << "cancellation watcher failed (#{error.class})"
|
|
368
|
+
ensure
|
|
369
|
+
begin
|
|
370
|
+
owned.close
|
|
371
|
+
rescue Exception => error
|
|
372
|
+
errors << "cancellation descriptors failed to close (#{error.class})"
|
|
373
|
+
ensure
|
|
374
|
+
@mutex.synchronize do
|
|
375
|
+
@requests.delete(owned)
|
|
376
|
+
@idle.broadcast
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
errors
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def ensure_open
|
|
384
|
+
raise ClosedError.new("server is closed", phase: :admission) if @closed
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def validate_argv(argv)
|
|
388
|
+
unless argv.is_a?(Array) && !argv.empty? && argv.all? { |value| value.is_a?(String) && !value.include?("\0") }
|
|
389
|
+
raise ArgumentError, "argv must be a nonempty Array of Strings without NUL"
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def execute_typed(arguments, **options)
|
|
394
|
+
result = run(encode_arguments(arguments), **options)
|
|
395
|
+
raise CommandError.new(result: result, phase: :command) unless result.success?
|
|
396
|
+
|
|
397
|
+
result
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def encode_arguments(arguments)
|
|
401
|
+
validate_argv(arguments)
|
|
402
|
+
# cmd_parse_from_arguments consumes one backslash before a final ';'.
|
|
403
|
+
arguments.map { |value| value.end_with?(";") ? value[0...-1] + "\\;" : value }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def literal_name(name)
|
|
407
|
+
unless name.is_a?(String) && !name.empty? && !name.include?("\0")
|
|
408
|
+
raise ArgumentError, "name must be a nonempty String without NUL"
|
|
409
|
+
end
|
|
410
|
+
name.gsub("#", "##")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def pane_command(command)
|
|
414
|
+
validate_argv(command)
|
|
415
|
+
raise ArgumentError, "command executable must not be empty" if command.first.empty?
|
|
416
|
+
|
|
417
|
+
# A single tmux argument would invoke $SHELL -c instead of execvp.
|
|
418
|
+
command.length == 1 ? ["/usr/bin/env", "--", *command] : command
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def target(ref, kind)
|
|
422
|
+
ensure_owner
|
|
423
|
+
@mutex.synchronize { ensure_open }
|
|
424
|
+
unless ref.is_a?(EntityRef) && ref.kind == kind && ref.binding_key == @pin.key
|
|
425
|
+
raise TargetNotFoundError.new("target does not belong to this server binding", phase: :admission)
|
|
426
|
+
end
|
|
427
|
+
ref.id
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def resolve(ref, kind)
|
|
431
|
+
target(ref, kind)
|
|
432
|
+
entity_class(kind).__send__(:new, self, ref)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def entity_class(kind)
|
|
436
|
+
{session: Session, window: Window, pane: Pane, window_link: WindowLink}.fetch(kind)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def build_entity(kind, id)
|
|
440
|
+
ref = EntityRef.__send__(:new, binding_key: @pin.key, kind: kind, id: id)
|
|
441
|
+
entity_class(kind).__send__(:new, self, ref)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def id_format(kind)
|
|
445
|
+
"\#{n:#{kind}_id}:\#{#{kind}_id}"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def creation_format(kind, receipt)
|
|
449
|
+
raise ArgumentError, "receipt must be Boolean" unless [true, false].include?(receipt)
|
|
450
|
+
return id_format(kind) unless receipt
|
|
451
|
+
|
|
452
|
+
kinds = kind == :session ? %i[session window pane] : %i[window pane]
|
|
453
|
+
kinds.map { |child| id_format(child) }.join
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def create_entity(kind, arguments, receipt: false, **options)
|
|
457
|
+
result = execute_typed(arguments, **options)
|
|
458
|
+
kinds = receipt ? (kind == :session ? %i[session window pane] : %i[window pane]) : [kind]
|
|
459
|
+
rows = Internal::Metadata.decode(result.stdout, fields: kinds.length, max_rows: 1)
|
|
460
|
+
raise ProtocolError.new("tmux did not return the created #{kind} ID", delivery: :observed, phase: :decode) unless rows.length == 1
|
|
461
|
+
|
|
462
|
+
entities = kinds.zip(rows.first).to_h { |child, id| [child, build_entity(child, id)] }
|
|
463
|
+
return entities.fetch(kind) unless receipt
|
|
464
|
+
|
|
465
|
+
CreationReceipt.__send__(:new, entity: entities.fetch(kind), window: entities.fetch(:window),
|
|
466
|
+
pane: entities.fetch(:pane), result: result)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def list_entities(kind, options, global: false, timeout: 5.0, cancel: nil)
|
|
470
|
+
result = execute_typed(["list-#{kind}s", *options, "-F", id_format(kind)], timeout: timeout, cancel: cancel)
|
|
471
|
+
entities = Internal::Metadata.decode(result.stdout, fields: 1).map { |row| build_entity(kind, row.first) }
|
|
472
|
+
entities = entities.uniq.sort_by { |entity| entity.id[1..].to_i } if global
|
|
473
|
+
entities.freeze
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def creation_options(cwd:, environment:)
|
|
477
|
+
arguments = []
|
|
478
|
+
if cwd
|
|
479
|
+
unless cwd.is_a?(String) && !cwd.empty? && !cwd.include?("\0")
|
|
480
|
+
raise ArgumentError, "cwd must be a nonempty String without NUL"
|
|
481
|
+
end
|
|
482
|
+
directory = File.expand_path(cwd)
|
|
483
|
+
raise ArgumentError, "cwd must name an existing accessible directory" unless File.directory?(directory) && File.executable?(directory)
|
|
484
|
+
|
|
485
|
+
# tmux expands -c as a format, while -e values are literal.
|
|
486
|
+
arguments.concat(["-c", directory.gsub("#", "##")])
|
|
487
|
+
end
|
|
488
|
+
raise ArgumentError, "environment must be a Hash" unless environment.is_a?(Hash)
|
|
489
|
+
|
|
490
|
+
environment.each do |name, value|
|
|
491
|
+
environment_name(name)
|
|
492
|
+
unless value.is_a?(String) && !value.include?("\0")
|
|
493
|
+
raise ArgumentError, "environment values must be Strings without NUL"
|
|
494
|
+
end
|
|
495
|
+
arguments.concat(["-e", "#{name}=#{value}"])
|
|
496
|
+
end
|
|
497
|
+
arguments
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def create_window(ref, name:, command:, index: nil, cwd: nil, environment: {}, focus: false, receipt: false, timeout: 5.0, cancel: nil)
|
|
501
|
+
budget = operation_budget(timeout, cancel)
|
|
502
|
+
unless index.nil? || (index.is_a?(Integer) && index.between?(0, (1 << 31) - 1))
|
|
503
|
+
raise ArgumentError, "index must be a nonnegative 32-bit Integer"
|
|
504
|
+
end
|
|
505
|
+
raise ArgumentError, "focus must be boolean" unless [true, false].include?(focus)
|
|
506
|
+
|
|
507
|
+
destination = target(ref, :session)
|
|
508
|
+
destination += ":#{index}" unless index.nil?
|
|
509
|
+
arguments = ["new-window", *(focus ? [] : ["-d"]), "-P", "-F", creation_format(:window, receipt), "-t", destination, "-n", literal_name(name)]
|
|
510
|
+
arguments.concat(creation_options(cwd: cwd, environment: environment))
|
|
511
|
+
create_entity(:window, arguments + ["--"] + pane_command(command), receipt: receipt, **budget.options)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def split_window(ref, direction:, command:, size: nil, cwd: nil, environment: {}, focus: false, timeout: 5.0, cancel: nil)
|
|
515
|
+
budget = operation_budget(timeout, cancel)
|
|
516
|
+
flag = {horizontal: "-h", vertical: "-v"}.fetch(direction) do
|
|
517
|
+
raise ArgumentError, "direction must be :horizontal or :vertical"
|
|
518
|
+
end
|
|
519
|
+
unless size.nil? || (size.is_a?(Integer) && size.between?(1, (1 << 31) - 1)) ||
|
|
520
|
+
(size.is_a?(String) && size.match?(/\A(?:[1-9][0-9]?|100)%\z/))
|
|
521
|
+
raise ArgumentError, "size must be positive cells or a percentage from 1% to 100%"
|
|
522
|
+
end
|
|
523
|
+
raise ArgumentError, "focus must be boolean" unless [true, false].include?(focus)
|
|
524
|
+
unless ref.is_a?(EntityRef) && [:window, :pane].include?(ref.kind)
|
|
525
|
+
raise ArgumentError, "split target must be a window or pane ref"
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
arguments = ["split-window", *(focus ? [] : ["-d"]), flag, "-P", "-F", id_format(:pane), "-t", target(ref, ref.kind)]
|
|
529
|
+
arguments.concat(["-l", size.to_s]) if size
|
|
530
|
+
arguments.concat(creation_options(cwd: cwd, environment: environment))
|
|
531
|
+
create_entity(:pane, arguments + ["--"] + pane_command(command), **budget.options)
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|