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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "criteria"
4
+
5
+ module LibTmux
6
+ module Internal
7
+ # Capturing all relation evidence is the conservative source plan.
8
+ class SourceQuery
9
+ COLLECTIONS = {session: :sessions, window: :windows, pane: :panes,
10
+ window_link: :window_links, client: :clients}.freeze
11
+ private_constant :COLLECTIONS
12
+
13
+ def initialize(entity, where:, pushdown: :auto)
14
+ unless %i[auto never required].include?(pushdown)
15
+ raise ArgumentError, "pushdown must be :auto, :never or :required"
16
+ end
17
+ @expression = FilterExpr.build(entity, where)
18
+ @entity = entity
19
+ @collection = COLLECTIONS.fetch(entity)
20
+ @pushdown = pushdown
21
+ reasons = pushdown == :never ? [] : ["no tmux predicate compiler has passed differential verification"]
22
+ @explanation = freeze_tree({entity: entity, requested_pushdown: pushdown,
23
+ capture: :full_graph, capture_requirements: %i[session window window_link pane] + (entity == :client ? [:client] : []),
24
+ pushed: nil, residual: @expression.to_h, executable: pushdown != :required,
25
+ rejected_optimization_reasons: reasons})
26
+ freeze
27
+ end
28
+
29
+ def explain
30
+ @explanation
31
+ end
32
+
33
+ def inspect
34
+ "#<#{self.class} entity=#{@entity} capture=full_graph pushdown=#{@pushdown}>"
35
+ end
36
+
37
+ def execute(server, **options)
38
+ if @pushdown == :required
39
+ raise UnsupportedFeatureError.new("required pushdown has no verified exact compiler; use :auto or :never", phase: :plan)
40
+ end
41
+ options = options.merge(clients: true) if @entity == :client
42
+ server.snapshot(**options).public_send(@collection).where(@expression)
43
+ end
44
+
45
+ private
46
+
47
+ def freeze_tree(value)
48
+ case value
49
+ when Hash then value.to_h { |key, child| [key, freeze_tree(child)] }.freeze
50
+ when Array then value.map { |child| freeze_tree(child) }.freeze
51
+ when String then value.dup.freeze
52
+ else value
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "libtmux/server"
5
+ require "libtmux/child"
6
+ require "libtmux/operations"
7
+
8
+ module LibTmux
9
+ # Terminal bytes go directly to the caller's TTY and are not captured.
10
+ class TerminalResult
11
+ attr_reader :status, :pid, :elapsed_seconds
12
+
13
+ def initialize(status:, pid:, elapsed_seconds:)
14
+ @status, @pid, @elapsed_seconds = status, pid, elapsed_seconds
15
+ freeze
16
+ end
17
+ private_class_method :new
18
+
19
+ def success?
20
+ status.success?
21
+ end
22
+
23
+ def delivery
24
+ :observed
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class} pid=#{pid} exitstatus=#{status.exitstatus}>"
29
+ end
30
+ end
31
+
32
+ class Server
33
+ # Resolves the literal selector at dispatch; it is not a client reference.
34
+ def switch_client(client:, session:, timeout: 5.0, cancel: nil)
35
+ budget = operation_budget(timeout, cancel)
36
+ session_id = target(session, :session)
37
+ unless client.is_a?(String) && client.bytesize.between?(1, 1024) && !client.b.include?("\0")
38
+ raise ArgumentError, "client must be a nonempty String of at most 1024 bytes without NUL"
39
+ end
40
+ client = client.b.freeze
41
+ command = builtin_spellings("switch-client", budget: budget).fetch("switch-client")
42
+ execute_typed([command, "-E", "-c", client, "-t", session_id], **budget.options)
43
+ end
44
+
45
+ # Blocks until the owned interactive client exits. The caller supplies and
46
+ # retains its TTY. Cancellation detaches this client, not pane commands.
47
+ def attach(session:, terminal:, term:, read_only: false, timeout: nil, cancel: nil)
48
+ started = monotonic
49
+ session_id = target(session, :session)
50
+ unless terminal.is_a?(IO) && !terminal.closed? && terminal.tty?
51
+ raise ArgumentError, "terminal must be an open caller-owned TTY"
52
+ end
53
+ unless term.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.+-]{0,127}\z/.match?(term)
54
+ raise ArgumentError, "term must name a terminal type"
55
+ end
56
+ raise ArgumentError, "read_only must be Boolean" unless [true, false].include?(read_only)
57
+ unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?)
58
+ raise ArgumentError, "terminal timeout must be positive and finite, or nil"
59
+ end
60
+ argv = @pin.command_prefix + ["attach-session", "-E", *(read_only ? ["-r"] : []), "-t", session_id]
61
+ perform_request(cancel: cancel) do |view|
62
+ Internal.const_get(:TerminalExecution, false).new(argv, terminal, term,
63
+ timeout && timeout - (monotonic - started), view).call
64
+ end
65
+ end
66
+ end
67
+
68
+ module Internal
69
+ class TerminalExecution
70
+ def initialize(argv, terminal, term, timeout, cancel)
71
+ @argv, @terminal, @term, @cancel = argv, terminal, term.dup.freeze, cancel
72
+ @started = clock
73
+ @deadline = timeout && @started + timeout
74
+ end
75
+
76
+ def call
77
+ failure = result = nil
78
+ begin
79
+ Thread.handle_interrupt(Exception => :never) do
80
+ begin
81
+ check_cancel
82
+ check_deadline
83
+ @tty = @terminal.dup
84
+ @tty.close_on_exec = true
85
+ @mode = @tty.console_mode
86
+ @child = OwnedChild.new
87
+ begin
88
+ @pid = Process.spawn({"TMUX" => nil, "TMUX_PANE" => nil, "TERM" => @term},
89
+ *@argv, in: @tty, out: @tty, err: @tty, close_others: true)
90
+ rescue SystemCallError, IOError => error
91
+ raise TransportError.new("terminal client could not start (#{error.class})", **details(:spawn)), cause: nil
92
+ ensure
93
+ @child.spawned(@pid)
94
+ end
95
+ Thread.handle_interrupt(Exception => :immediate) { result = await_exit }
96
+ rescue Exception => error
97
+ failure = error
98
+ ensure
99
+ errors = retire
100
+ if failure.is_a?(Error)
101
+ failure.__send__(:attach_cleanup_errors, errors)
102
+ elsif !failure && !errors.empty?
103
+ failure = TransportError.new("terminal cleanup failed", **details(:retire), cleanup_errors: errors)
104
+ end
105
+ end
106
+ end
107
+ rescue Exception => deferred
108
+ failure ||= deferred
109
+ end
110
+ raise failure, cause: nil if failure
111
+
112
+ result
113
+ end
114
+
115
+ private
116
+
117
+ def await_exit
118
+ loop do
119
+ if @child.observed?
120
+ @retire_deadline ||= clock + 0.5
121
+ @child.finish_signalling
122
+ end
123
+ raise @child.observation_error if @child.observation_error
124
+ if @child.complete?
125
+ raise @child.retirement_error if @child.retirement_error
126
+
127
+ return TerminalResult.__send__(:new, status: @child.status, pid: @pid, elapsed_seconds: clock - @started)
128
+ end
129
+ check_cancel unless @retire_deadline
130
+ deadline = @retire_deadline || @deadline
131
+ if deadline && clock >= deadline
132
+ raise DeadlineExceeded.new("terminal client exceeded its deadline", **details(:terminal))
133
+ end
134
+ readers = [@child.reader]
135
+ readers << @cancel.reader unless @retire_deadline
136
+ if IO.select(readers, nil, nil, deadline && [deadline - clock, 0].max)
137
+ @child.reader.read_nonblock(16, exception: false)
138
+ end
139
+ end
140
+ rescue SystemCallError, IOError => error
141
+ raise TransportError.new("terminal process observation failed (#{error.class})", **details(:terminal)), cause: nil
142
+ end
143
+
144
+ def retire
145
+ errors = []
146
+ deadline = clock + 0.5
147
+ if @child
148
+ attempt(errors, "terminal client retirement") do
149
+ begin
150
+ unless @child.observed? || @child.complete?
151
+ @child.signal("TERM")
152
+ @child.wait_observed(0.05)
153
+ @child.signal("KILL") unless @child.observed? || @child.complete?
154
+ end
155
+ ensure
156
+ @child.finish_signalling
157
+ end
158
+ unless @child.join((deadline - clock).clamp(0, 0.5))
159
+ raise DeadlineExceeded.new("terminal client did not retire", **details(:retire))
160
+ end
161
+ raise @child.retirement_error if @child.retirement_error
162
+ raise @child.observation_error if @child.observation_error
163
+ end
164
+ attempt(errors, "observer close") { @child.close } if @child.complete?
165
+ end
166
+ attempt(errors, "terminal mode restoration") { @tty.console_mode = @mode } if @mode
167
+ attempt(errors, "terminal descriptor close") { @tty.close unless @tty.closed? } if @tty
168
+ errors
169
+ end
170
+
171
+ def attempt(errors, operation)
172
+ yield
173
+ rescue Exception => error
174
+ errors << "#{operation} failed (#{error.class})"
175
+ end
176
+
177
+ def check_cancel
178
+ raise Cancelled.new("terminal attachment cancelled", **details(:terminal)) if @cancel.cancelled?
179
+ end
180
+
181
+ def check_deadline
182
+ if @deadline && clock >= @deadline
183
+ raise DeadlineExceeded.new("terminal attachment deadline elapsed", **details(:admission))
184
+ end
185
+ end
186
+
187
+ def details(phase)
188
+ {phase: phase, pid: @pid, delivery: @pid ? :possibly_sent : :not_sent}
189
+ end
190
+
191
+ def clock
192
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
193
+ end
194
+ end
195
+ private_constant :TerminalExecution
196
+ end
197
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ VERSION = "0.1.0.alpha.1"
5
+ end
data/lib/libtmux.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/version"
4
+ require "libtmux/errors"
5
+ require "libtmux/process"
6
+ require "libtmux/endpoint"
7
+ require "libtmux/metadata"
8
+ require "libtmux/selection"
9
+ require "libtmux/entity"
10
+ require "libtmux/server"
11
+ require "libtmux/operations"
12
+ require "libtmux/criteria"
13
+ require "libtmux/capture"
14
+ require "libtmux/source_query"
15
+ require "libtmux/control"
16
+ require "libtmux/group"
17
+ require "libtmux/owned"
18
+ require "libtmux/terminal"