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,885 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/endpoint"
|
|
4
|
+
require "libtmux/child"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
class GuardedBlock
|
|
8
|
+
attr_reader :guard, :body, :terminator, :bytesize
|
|
9
|
+
|
|
10
|
+
def initialize(guard:, body:, terminator:, bytesize:, opening:, closing:)
|
|
11
|
+
@guard, @body, @terminator, @bytesize = guard.freeze, body.b.freeze, terminator, bytesize
|
|
12
|
+
@opening, @closing = opening.b.freeze, closing.b.freeze
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def raw
|
|
17
|
+
(@opening + body + @closing).freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def guard_success?
|
|
21
|
+
terminator == :end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
"#<#{self.class} guard=#{guard.inspect} terminator=#{terminator} bytes=#{bytesize}>"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Blocks observed between private boundaries may include hooks. They do not
|
|
30
|
+
# establish command ownership, final status, or completion of delayed effects.
|
|
31
|
+
class GuardedReply
|
|
32
|
+
attr_reader :request_id, :blocks, :generation
|
|
33
|
+
|
|
34
|
+
def initialize(request_id:, blocks:, generation:)
|
|
35
|
+
@request_id, @blocks, @generation = request_id, blocks.freeze, generation
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delivery
|
|
40
|
+
:observed
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attribution
|
|
44
|
+
:boundary_window
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class} request_id=#{request_id} blocks=#{blocks.length} attribution=#{attribution}>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class ControlEvent
|
|
53
|
+
attr_reader :kind, :raw, :data, :pane_id, :sequence, :generation,
|
|
54
|
+
:lost_sequences, :dropped_bytes, :reason, :previous_generation
|
|
55
|
+
|
|
56
|
+
def initialize(kind:, raw:, data: nil, pane_id: nil, sequence: nil, generation: nil,
|
|
57
|
+
lost_sequences: nil, dropped_bytes: 0, reason: nil, previous_generation: nil)
|
|
58
|
+
@kind, @raw, @data = kind, raw.b.freeze, data&.b&.freeze
|
|
59
|
+
@pane_id, @sequence, @generation = pane_id&.dup&.freeze, sequence, generation&.dup&.freeze
|
|
60
|
+
@lost_sequences, @dropped_bytes = lost_sequences&.dup&.freeze, dropped_bytes
|
|
61
|
+
@reason, @previous_generation = reason, previous_generation&.dup&.freeze
|
|
62
|
+
freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bytesize
|
|
66
|
+
raw.bytesize + (data&.bytesize || 0)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def inspect
|
|
70
|
+
"#<#{self.class} kind=#{kind} sequence=#{sequence} bytes=#{bytesize}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class SubscriptionOverflow < CapacityError
|
|
75
|
+
attr_reader :sequence
|
|
76
|
+
|
|
77
|
+
def initialize(sequence:)
|
|
78
|
+
@sequence = sequence
|
|
79
|
+
super("control subscription exceeded its buffer limit", delivery: :observed, phase: :subscription)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class ControlSubscription
|
|
84
|
+
include Enumerable
|
|
85
|
+
attr_reader :generation
|
|
86
|
+
|
|
87
|
+
def initialize(max_bytes: 1 << 20, max_events: 1024, mode: :reliable, pane_id: nil, generation: nil)
|
|
88
|
+
unless [max_bytes, max_events].all? { |limit| limit.is_a?(Integer) && limit.positive? }
|
|
89
|
+
raise ArgumentError, "subscription limits must be positive integers"
|
|
90
|
+
end
|
|
91
|
+
raise ArgumentError, "subscription mode must be reliable or tail" unless [:reliable, :tail].include?(mode)
|
|
92
|
+
|
|
93
|
+
@max_bytes, @max_events, @mode, @pane_id = max_bytes, max_events, mode, pane_id
|
|
94
|
+
@owner_pid = Process.pid
|
|
95
|
+
@generation = generation&.dup&.freeze
|
|
96
|
+
@mutex, @changed = Mutex.new, ConditionVariable.new
|
|
97
|
+
@queue, @bytes, @closed = [], 0, false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def next(timeout: nil)
|
|
101
|
+
ensure_owner
|
|
102
|
+
unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
|
|
103
|
+
raise ArgumentError, "timeout must be finite and nonnegative"
|
|
104
|
+
end
|
|
105
|
+
deadline = timeout && clock + timeout
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
while true
|
|
108
|
+
if @gap
|
|
109
|
+
gap, @gap = @gap, nil
|
|
110
|
+
return gap
|
|
111
|
+
end
|
|
112
|
+
unless @queue.empty?
|
|
113
|
+
event = @queue.shift
|
|
114
|
+
@bytes -= event.bytesize
|
|
115
|
+
return event
|
|
116
|
+
end
|
|
117
|
+
raise @failure if @failure
|
|
118
|
+
raise StopIteration if @closed
|
|
119
|
+
|
|
120
|
+
remaining = deadline && deadline - clock
|
|
121
|
+
raise DeadlineExceeded.new("control event deadline elapsed", phase: :subscription) if remaining && remaining <= 0
|
|
122
|
+
|
|
123
|
+
@changed.wait(@mutex, remaining)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def each
|
|
129
|
+
return enum_for(__method__) unless block_given?
|
|
130
|
+
|
|
131
|
+
while true
|
|
132
|
+
event = begin
|
|
133
|
+
self.next
|
|
134
|
+
rescue StopIteration
|
|
135
|
+
break
|
|
136
|
+
end
|
|
137
|
+
yield event
|
|
138
|
+
end
|
|
139
|
+
self
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def close
|
|
143
|
+
ensure_owner
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@closed = true
|
|
146
|
+
@queue.clear
|
|
147
|
+
@bytes, @gap, @failure = 0, nil, nil
|
|
148
|
+
@changed.broadcast
|
|
149
|
+
end
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def closed?
|
|
154
|
+
ensure_owner
|
|
155
|
+
@mutex.synchronize { @closed }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns frozen local buffer state. A reliable overflow keeps its prefix
|
|
159
|
+
# readable; explicit close discards the prefix and pending loss report.
|
|
160
|
+
def diagnostics
|
|
161
|
+
ensure_owner
|
|
162
|
+
@mutex.synchronize do
|
|
163
|
+
{queued_events: @queue.length, retained_event_bytes: @bytes,
|
|
164
|
+
gap_pending: !@gap.nil?, overflowed: @failure.is_a?(SubscriptionOverflow),
|
|
165
|
+
closed: @closed, mode: @mode,
|
|
166
|
+
limits: {max_bytes: @max_bytes, max_events: @max_events}.freeze}.freeze
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def inspect
|
|
171
|
+
"#<#{self.class} mode=#{@mode} #{@closed ? 'closed' : 'open'}>"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def ensure_owner
|
|
177
|
+
raise ClosedError.new("control subscription belongs to another process", phase: :subscription) unless Process.pid == @owner_pid
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clock
|
|
181
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def publish(event)
|
|
185
|
+
if @pane_id
|
|
186
|
+
return if event.pane_id ? event.pane_id != @pane_id : event.kind != :gap
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@mutex.synchronize do
|
|
190
|
+
return if @closed
|
|
191
|
+
|
|
192
|
+
if @mode == :reliable && (@bytes + event.bytesize > @max_bytes || @queue.length >= @max_events)
|
|
193
|
+
@failure = SubscriptionOverflow.new(sequence: event.sequence)
|
|
194
|
+
@closed = true
|
|
195
|
+
else
|
|
196
|
+
while !@queue.empty? && (@bytes + event.bytesize > @max_bytes || @queue.length >= @max_events)
|
|
197
|
+
dropped = @queue.shift
|
|
198
|
+
@bytes -= dropped.bytesize
|
|
199
|
+
record_gap(dropped)
|
|
200
|
+
end
|
|
201
|
+
if event.bytesize > @max_bytes
|
|
202
|
+
record_gap(event)
|
|
203
|
+
else
|
|
204
|
+
@queue << event
|
|
205
|
+
@bytes += event.bytesize
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
@changed.broadcast
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def finish(failure = nil)
|
|
213
|
+
@mutex.synchronize do
|
|
214
|
+
@failure ||= failure
|
|
215
|
+
@closed = true
|
|
216
|
+
@changed.broadcast
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def record_gap(event)
|
|
221
|
+
first = @gap ? @gap.lost_sequences.first : event.sequence
|
|
222
|
+
unknown_loss = (@gap && @gap.dropped_bytes.nil?) || (event.kind == :gap && event.dropped_bytes.nil?)
|
|
223
|
+
bytes = unknown_loss ? nil : (@gap&.dropped_bytes || 0) + event.bytesize
|
|
224
|
+
@gap = ControlEvent.new(kind: :gap, raw: "".b, sequence: event.sequence,
|
|
225
|
+
generation: event.generation, lost_sequences: [first, event.sequence], dropped_bytes: bytes,
|
|
226
|
+
reason: :overflow, previous_generation: @gap&.previous_generation || event.previous_generation)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
module Internal
|
|
231
|
+
class ControlParser
|
|
232
|
+
GUARD = /\A%(begin|end|error) ([0-9]{1,20}) ([0-9]{1,10}) ([0-9]{1,10})\n\z/n
|
|
233
|
+
private_constant :GUARD
|
|
234
|
+
|
|
235
|
+
def initialize(max_line_bytes: 1 << 18, max_frame_bytes: 1 << 20)
|
|
236
|
+
unless [max_line_bytes, max_frame_bytes].all? { |n| n.is_a?(Integer) && n.positive? }
|
|
237
|
+
raise ArgumentError, "control parser limits must be positive integers"
|
|
238
|
+
end
|
|
239
|
+
@max_line, @max_frame = max_line_bytes, max_frame_bytes
|
|
240
|
+
@pending = +"".b
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def feed(bytes)
|
|
244
|
+
# Callers feed bounded read chunks; retained data is checked per line.
|
|
245
|
+
bytes.b.each_line("\n") do |part|
|
|
246
|
+
@pending << part
|
|
247
|
+
raise CapacityError.new("control line exceeds its byte limit", phase: :read) if @pending.bytesize > @max_line
|
|
248
|
+
next unless @pending.end_with?("\n")
|
|
249
|
+
|
|
250
|
+
line, @pending = @pending, +"".b
|
|
251
|
+
match = GUARD.match(line)
|
|
252
|
+
tuple = match && match.captures.drop(1).map(&:to_i)
|
|
253
|
+
if @guard
|
|
254
|
+
@frame_bytes += line.bytesize
|
|
255
|
+
raise CapacityError.new("control frame exceeds its byte limit", phase: :read) if @frame_bytes > @max_frame
|
|
256
|
+
|
|
257
|
+
if match && match[1] != "begin" && tuple == @guard
|
|
258
|
+
yield GuardedBlock.new(guard: @guard, body: @body,
|
|
259
|
+
terminator: match[1].to_sym, bytesize: @frame_bytes, opening: @opening, closing: line)
|
|
260
|
+
@guard = @body = nil
|
|
261
|
+
else
|
|
262
|
+
@body << line
|
|
263
|
+
end
|
|
264
|
+
elsif match
|
|
265
|
+
raise ProtocolError.new("control closing guard has no opening guard", phase: :read) unless match[1] == "begin"
|
|
266
|
+
|
|
267
|
+
@guard, @body, @frame_bytes = tuple, +"".b, line.bytesize
|
|
268
|
+
@opening = line
|
|
269
|
+
raise CapacityError.new("control frame exceeds its byte limit", phase: :read) if @frame_bytes > @max_frame
|
|
270
|
+
else
|
|
271
|
+
yield event(line)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def finish
|
|
277
|
+
return if @pending.empty? && !@guard
|
|
278
|
+
|
|
279
|
+
raise ProtocolError.new("control stream ended inside a line or guarded block", phase: :read)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def event(line)
|
|
285
|
+
if (match = /\A%output (%[0-9]+) (.*)\n\z/n.match(line))
|
|
286
|
+
ControlEvent.new(kind: :output, raw: line, pane_id: match[1], data: decode(match[2]))
|
|
287
|
+
elsif (match = /\A%extended-output (%[0-9]+) [0-9]+(?: [^\n]*?)? : (.*)\n\z/n.match(line))
|
|
288
|
+
ControlEvent.new(kind: :output, raw: line, pane_id: match[1], data: decode(match[2]))
|
|
289
|
+
elsif line.start_with?("%output ", "%extended-output ")
|
|
290
|
+
raise ProtocolError.new("malformed control output event", phase: :read)
|
|
291
|
+
elsif (match = /\A%(pause|continue) (%[0-9]+)\n\z/n.match(line))
|
|
292
|
+
ControlEvent.new(kind: :gap, raw: line, pane_id: match[2],
|
|
293
|
+
reason: match[1] == "pause" ? :pause : :resume, dropped_bytes: nil)
|
|
294
|
+
elsif line.start_with?("%pause ", "%continue ")
|
|
295
|
+
raise ProtocolError.new("malformed control flow event", phase: :read)
|
|
296
|
+
else
|
|
297
|
+
ControlEvent.new(kind: :notice, raw: line)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def decode(bytes)
|
|
302
|
+
decoded = +"".b
|
|
303
|
+
index = 0
|
|
304
|
+
while index < bytes.bytesize
|
|
305
|
+
if bytes.getbyte(index) == 92
|
|
306
|
+
digits = bytes.byteslice(index + 1, 3)
|
|
307
|
+
unless digits && /\A[0-3][0-7]{2}\z/n.match?(digits)
|
|
308
|
+
raise ProtocolError.new("malformed control output escape", phase: :read)
|
|
309
|
+
end
|
|
310
|
+
decoded << digits.to_i(8)
|
|
311
|
+
index += 4
|
|
312
|
+
else
|
|
313
|
+
decoded << bytes.getbyte(index)
|
|
314
|
+
index += 1
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
decoded
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
module LibTmux
|
|
324
|
+
class ControlConnection
|
|
325
|
+
module CleanupDetails
|
|
326
|
+
attr_reader :control_cleanup_errors
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
Request = Struct.new(:id, :wire, :offset, :start_marker, :end_marker, :started,
|
|
330
|
+
:blocks, :bytes, :reader, :writer, :result, :error, :flow, :flow_reported, keyword_init: true)
|
|
331
|
+
private_constant :Request, :CleanupDetails
|
|
332
|
+
|
|
333
|
+
attr_reader :pid, :generation, :previous_generation, :events, :cleanup_errors
|
|
334
|
+
|
|
335
|
+
def self.open(**options)
|
|
336
|
+
return new(**options) unless block_given?
|
|
337
|
+
|
|
338
|
+
connection = result = error = nil
|
|
339
|
+
begin
|
|
340
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
341
|
+
begin
|
|
342
|
+
connection = new(**options)
|
|
343
|
+
Thread.handle_interrupt(Exception => :immediate) { result = yield connection }
|
|
344
|
+
rescue Exception => failure
|
|
345
|
+
error = failure
|
|
346
|
+
ensure
|
|
347
|
+
begin
|
|
348
|
+
connection&.close
|
|
349
|
+
rescue Exception => cleanup
|
|
350
|
+
attach_cleanup_details(error, ["control cleanup failed (#{cleanup.class})"]) if error
|
|
351
|
+
error ||= cleanup
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
rescue Exception => deferred
|
|
356
|
+
error ||= deferred
|
|
357
|
+
end
|
|
358
|
+
raise error if error
|
|
359
|
+
|
|
360
|
+
result
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def self.attach_cleanup_details(error, details)
|
|
364
|
+
if error.is_a?(Error)
|
|
365
|
+
error.send(:attach_cleanup_errors, details)
|
|
366
|
+
else
|
|
367
|
+
error.extend(CleanupDetails)
|
|
368
|
+
previous = error.control_cleanup_errors || []
|
|
369
|
+
error.instance_variable_set(:@control_cleanup_errors, (previous + details).freeze)
|
|
370
|
+
end
|
|
371
|
+
rescue FrozenError, TypeError
|
|
372
|
+
nil
|
|
373
|
+
end
|
|
374
|
+
private_class_method :attach_cleanup_details
|
|
375
|
+
|
|
376
|
+
def initialize(binding:, session_id:, reconnect: nil, max_requests: 32, max_command_bytes: 1 << 18,
|
|
377
|
+
max_queue_bytes: 1 << 20, max_line_bytes: 1 << 18, max_reply_bytes: 1 << 20,
|
|
378
|
+
max_stderr_bytes: 1 << 18, max_subscriptions: 32)
|
|
379
|
+
initialize_state(binding_key: binding.key, session_id: session_id, reconnect: reconnect,
|
|
380
|
+
max_requests: max_requests, max_command_bytes: max_command_bytes,
|
|
381
|
+
max_queue_bytes: max_queue_bytes, max_line_bytes: max_line_bytes, max_reply_bytes: max_reply_bytes,
|
|
382
|
+
max_stderr_bytes: max_stderr_bytes, max_subscriptions: max_subscriptions)
|
|
383
|
+
error = nil
|
|
384
|
+
begin
|
|
385
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
386
|
+
begin
|
|
387
|
+
process_wait = Internal::ProcessWait.new
|
|
388
|
+
prefix = binding.command_prefix
|
|
389
|
+
@pin = Internal::SocketIdentity.new(Endpoint.new(socket_path: prefix.last, executable: prefix.first))
|
|
390
|
+
@wake_reader, @wake_writer = pipe
|
|
391
|
+
input_reader, @input = pipe
|
|
392
|
+
@output, output_writer = pipe
|
|
393
|
+
@error_output, error_writer = pipe
|
|
394
|
+
@child = Internal::OwnedChild.new(process_wait)
|
|
395
|
+
@exit_reader = @child.reader
|
|
396
|
+
@resources << @exit_reader
|
|
397
|
+
begin
|
|
398
|
+
@pid = Process.spawn({"TMUX" => nil, "TMUX_PANE" => nil},
|
|
399
|
+
*@pin.command_prefix, "-C", "attach-session", "-t", session_id,
|
|
400
|
+
in: input_reader, out: output_writer, err: error_writer, close_others: true)
|
|
401
|
+
ensure
|
|
402
|
+
@child.spawned(@pid)
|
|
403
|
+
end
|
|
404
|
+
[input_reader, output_writer, error_writer].each(&:close)
|
|
405
|
+
@worker = Thread.new { run }
|
|
406
|
+
Thread.handle_interrupt(Exception => :immediate) { nil }
|
|
407
|
+
rescue Exception => failure
|
|
408
|
+
error = failure
|
|
409
|
+
if @worker
|
|
410
|
+
@mutex.synchronize { @stopping = true; wake(@wake_writer) }
|
|
411
|
+
@worker.join(0.5)
|
|
412
|
+
else
|
|
413
|
+
cleanup
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
rescue Exception => deferred
|
|
418
|
+
error ||= deferred
|
|
419
|
+
end
|
|
420
|
+
self.class.send(:attach_cleanup_details, error, @cleanup_errors) if error && !@cleanup_errors.empty?
|
|
421
|
+
raise error if error
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def exchange(line, timeout: 5, cancel: nil)
|
|
425
|
+
exchange_request(line, timeout: timeout, cancel: cancel)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def exchange_request(line, timeout:, cancel:, flow: nil)
|
|
429
|
+
ensure_owner
|
|
430
|
+
unless line.is_a?(String) && !line.empty? && !line.b.match?(/[\x00\r\n]/n)
|
|
431
|
+
raise ArgumentError, "control input must be one nonempty raw command line without NUL or line endings"
|
|
432
|
+
end
|
|
433
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
434
|
+
raise ArgumentError, "control timeout must be positive and finite"
|
|
435
|
+
end
|
|
436
|
+
raise CapacityError.new("control command exceeds its byte limit", phase: :admission) if line.bytesize > @max_command
|
|
437
|
+
|
|
438
|
+
deadline, request, result, error = clock + timeout, nil, nil, nil
|
|
439
|
+
begin
|
|
440
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
441
|
+
begin
|
|
442
|
+
request = admit(line, cancel, flow: flow)
|
|
443
|
+
Thread.handle_interrupt(Exception => :immediate) do
|
|
444
|
+
loop do
|
|
445
|
+
result, error = @mutex.synchronize { [request.result, request.error] }
|
|
446
|
+
break if result || error
|
|
447
|
+
|
|
448
|
+
if cancel&.cancelled?
|
|
449
|
+
abort_request(request, Cancelled, "control request cancelled")
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
remaining = deadline - clock
|
|
453
|
+
if remaining <= 0
|
|
454
|
+
abort_request(request, DeadlineExceeded, "control request deadline elapsed")
|
|
455
|
+
next
|
|
456
|
+
end
|
|
457
|
+
IO.select([request.reader, cancel&.reader].compact, nil, nil, remaining)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
rescue Exception => failure
|
|
461
|
+
error ||= failure
|
|
462
|
+
ensure
|
|
463
|
+
if request
|
|
464
|
+
begin
|
|
465
|
+
abort_request(request, Cancelled, "control request interrupted")
|
|
466
|
+
rescue Exception => cleanup
|
|
467
|
+
self.class.send(:attach_cleanup_details, error, ["control request cleanup failed (#{cleanup.class})"]) if error
|
|
468
|
+
error ||= cleanup
|
|
469
|
+
ensure
|
|
470
|
+
@mutex.synchronize do
|
|
471
|
+
[request.reader, request.writer].each { |io| io.close unless io.closed? }
|
|
472
|
+
@request_pipes.delete(request.id)
|
|
473
|
+
@queued_bytes -= request.wire.bytesize
|
|
474
|
+
@retained_reply_bytes -= request.bytes
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
rescue Exception => deferred
|
|
481
|
+
error ||= deferred
|
|
482
|
+
end
|
|
483
|
+
return result if result
|
|
484
|
+
raise error if error
|
|
485
|
+
end
|
|
486
|
+
private :exchange_request
|
|
487
|
+
|
|
488
|
+
def pause_output(pane_id:, timeout: 5, cancel: nil)
|
|
489
|
+
change_output(pane_id, "pause", timeout, cancel)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def resume_output(pane_id:, timeout: 5, cancel: nil)
|
|
493
|
+
change_output(pane_id, "continue", timeout, cancel)
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def subscribe(pane_id: nil, mode: :reliable, max_bytes: 1 << 20, max_events: 1024)
|
|
497
|
+
ensure_owner
|
|
498
|
+
unless pane_id.nil? || (pane_id.is_a?(String) && /\A%[0-9]+\z/.match?(pane_id))
|
|
499
|
+
raise ArgumentError, "pane subscription must use an exact pane ID"
|
|
500
|
+
end
|
|
501
|
+
@mutex.synchronize do
|
|
502
|
+
raise ClosedError.new("control connection is closed", phase: :admission) if @stopping || @finished
|
|
503
|
+
|
|
504
|
+
@subscriptions.reject!(&:closed?)
|
|
505
|
+
raise CapacityError.new("control subscription limit reached", phase: :admission) if @subscriptions.length >= @max_subscriptions
|
|
506
|
+
|
|
507
|
+
subscription = build_subscription(pane_id: pane_id, mode: mode, max_bytes: max_bytes, max_events: max_events,
|
|
508
|
+
generation: @generation)
|
|
509
|
+
subscription.send(:publish, @reconnect_gap) if @reconnect_gap
|
|
510
|
+
@subscriptions << subscription
|
|
511
|
+
subscription
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def close(timeout: 0.5)
|
|
516
|
+
unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0 && timeout <= 0.5
|
|
517
|
+
raise ArgumentError, "control close timeout must be between zero and 0.5 seconds"
|
|
518
|
+
end
|
|
519
|
+
unless Process.pid == @owner_pid
|
|
520
|
+
@child&.detach
|
|
521
|
+
(@resources + @request_pipes.values.flatten).each { |io| io.close unless io.closed? }
|
|
522
|
+
@pin&.close
|
|
523
|
+
return nil
|
|
524
|
+
end
|
|
525
|
+
failure = nil
|
|
526
|
+
begin
|
|
527
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
528
|
+
request_close
|
|
529
|
+
unless !@worker || @worker.join(timeout)
|
|
530
|
+
failure = TransportError.new("control reader did not retire within its cleanup deadline", phase: :cleanup, pid: @pid)
|
|
531
|
+
end
|
|
532
|
+
unless @cleanup_errors.empty?
|
|
533
|
+
failure ||= TransportError.new("control cleanup failed", phase: :cleanup, pid: @pid, cleanup_errors: @cleanup_errors)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
rescue Exception => deferred
|
|
537
|
+
failure ||= deferred
|
|
538
|
+
end
|
|
539
|
+
raise failure if failure
|
|
540
|
+
|
|
541
|
+
nil
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def closed?
|
|
545
|
+
ensure_owner
|
|
546
|
+
@mutex.synchronize { !!(@finished && @cleanup_errors.empty?) }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Returns frozen local accounting, without payloads or native liveness I/O.
|
|
550
|
+
# Admission and byte reservations include completed replies until consumed.
|
|
551
|
+
# Subscription count includes closed streams retained by this connection.
|
|
552
|
+
def diagnostics
|
|
553
|
+
ensure_owner
|
|
554
|
+
@mutex.synchronize do
|
|
555
|
+
{admitted_requests: @request_pipes.length, incomplete_requests: @requests.length,
|
|
556
|
+
queued_requests: @queue.length, writing_requests: @writing ? 1 : 0,
|
|
557
|
+
awaiting_reply: @replies.length, reserved_wire_bytes: @queued_bytes,
|
|
558
|
+
retained_reply_bytes: @retained_reply_bytes, stderr_received_bytes: @stderr_bytes,
|
|
559
|
+
subscription_count: @subscriptions.length, stopping: !!@stopping,
|
|
560
|
+
finished: !!@finished, cleanup_error_count: @cleanup_errors.length,
|
|
561
|
+
limits: @diagnostic_limits}.freeze
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def inspect
|
|
566
|
+
"#<#{self.class} pid=#{@pid} generation=#{@generation} #{@stopping || @finished ? 'closed' : 'open'}>"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
private
|
|
570
|
+
|
|
571
|
+
def initialize_state(binding_key:, session_id:, reconnect: nil, max_requests: 32, max_command_bytes: 1 << 18,
|
|
572
|
+
max_queue_bytes: 1 << 20, max_line_bytes: 1 << 18, max_reply_bytes: 1 << 20,
|
|
573
|
+
max_stderr_bytes: 1 << 18, max_subscriptions: 32)
|
|
574
|
+
unless session_id.is_a?(String) && /\A\$[0-9]+\z/.match?(session_id)
|
|
575
|
+
raise ArgumentError, "control session must be an exact session ID"
|
|
576
|
+
end
|
|
577
|
+
limits = [max_requests, max_command_bytes, max_queue_bytes, max_line_bytes,
|
|
578
|
+
max_reply_bytes, max_stderr_bytes, max_subscriptions]
|
|
579
|
+
raise ArgumentError, "control limits must be positive integers" unless limits.all? { |n| n.is_a?(Integer) && n.positive? }
|
|
580
|
+
if reconnect
|
|
581
|
+
unless reconnect.is_a?(ControlConnection) && reconnect.closed? &&
|
|
582
|
+
reconnect.instance_variable_get(:@binding_key) == binding_key &&
|
|
583
|
+
reconnect.instance_variable_get(:@session_id) == session_id
|
|
584
|
+
raise ArgumentError, "reconnect requires a retired control connection for the same binding and session"
|
|
585
|
+
end
|
|
586
|
+
@previous_generation = reconnect.generation
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
@owner_pid, @generation = Process.pid, SecureRandom.hex(16).freeze
|
|
590
|
+
@binding_key, @session_id = binding_key, session_id.dup.freeze
|
|
591
|
+
@mutex = Mutex.new
|
|
592
|
+
@queue, @requests, @request_pipes, @subscriptions, @resources = [], {}, {}, [], []
|
|
593
|
+
@replies = []
|
|
594
|
+
@next_id, @queued_bytes, @sequence, @retained_reply_bytes = 0, 0, 0, 0
|
|
595
|
+
if @previous_generation
|
|
596
|
+
@sequence += 1
|
|
597
|
+
@reconnect_gap = ControlEvent.new(kind: :gap, raw: "".b, sequence: @sequence,
|
|
598
|
+
generation: @generation, previous_generation: @previous_generation, reason: :reconnect, dropped_bytes: nil)
|
|
599
|
+
end
|
|
600
|
+
@max_requests, @max_command, @max_queue = max_requests, max_command_bytes, max_queue_bytes
|
|
601
|
+
@max_reply, @max_stderr, @max_subscriptions = max_reply_bytes, max_stderr_bytes, max_subscriptions
|
|
602
|
+
@diagnostic_limits = {max_requests: max_requests, max_command_bytes: max_command_bytes,
|
|
603
|
+
max_queue_bytes: max_queue_bytes, max_line_bytes: max_line_bytes,
|
|
604
|
+
max_reply_bytes: max_reply_bytes, max_stderr_bytes: max_stderr_bytes,
|
|
605
|
+
max_subscriptions: max_subscriptions}.freeze
|
|
606
|
+
@parser = Internal::ControlParser.new(max_line_bytes: max_line_bytes, max_frame_bytes: max_reply_bytes)
|
|
607
|
+
@stderr_bytes, @cleanup_errors = 0, [].freeze
|
|
608
|
+
@events = subscribe
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def build_subscription(**options)
|
|
612
|
+
ControlSubscription.new(**options)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def change_output(pane_id, state, timeout, cancel)
|
|
616
|
+
unless pane_id.is_a?(String) && /\A%[0-9]+\z/.match?(pane_id)
|
|
617
|
+
raise ArgumentError, "control output target must be an exact pane ID"
|
|
618
|
+
end
|
|
619
|
+
flow = [pane_id.dup.freeze, state == "pause" ? :pause : :resume].freeze
|
|
620
|
+
exchange_request("refresh-client -A '#{pane_id}:#{state}'", timeout: timeout, cancel: cancel, flow: flow)
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def ensure_owner
|
|
624
|
+
raise ClosedError.new("control connection belongs to another process", phase: :admission) unless Process.pid == @owner_pid
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def clock
|
|
628
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def pipe
|
|
632
|
+
IO.pipe.tap { |pair| @resources.concat(pair); pair.each(&:binmode) }
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def request_close
|
|
636
|
+
ensure_owner
|
|
637
|
+
@mutex.synchronize { @stopping = true; wake(@wake_writer) }
|
|
638
|
+
nil
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def wake(writer)
|
|
642
|
+
writer&.write_nonblock("x", exception: false) unless writer&.closed?
|
|
643
|
+
rescue IOError, Errno::EPIPE
|
|
644
|
+
nil
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def abort_request(request, type, message)
|
|
648
|
+
@mutex.synchronize do
|
|
649
|
+
return if request.result || request.error
|
|
650
|
+
|
|
651
|
+
delivery = request.offset.zero? ? :not_sent : :possibly_sent
|
|
652
|
+
if request.offset.zero?
|
|
653
|
+
@queue.delete(request)
|
|
654
|
+
@replies.delete(request)
|
|
655
|
+
@writing = nil if @writing.equal?(request)
|
|
656
|
+
else
|
|
657
|
+
# Do not reuse an undrained boundary after cancellation.
|
|
658
|
+
@stopping = true
|
|
659
|
+
end
|
|
660
|
+
complete(request, error: type.new(message, delivery: delivery, phase: :control, pid: @pid))
|
|
661
|
+
wake(@wake_writer)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def admit(line, cancel, flow: nil)
|
|
666
|
+
@mutex.synchronize do
|
|
667
|
+
raise ClosedError.new("control connection is closed", phase: :admission) if @stopping || @finished
|
|
668
|
+
raise Cancelled.new("control request cancelled before admission", phase: :admission) if cancel&.cancelled?
|
|
669
|
+
|
|
670
|
+
start_marker = "libtmux_boundary_#{SecureRandom.hex(24)}"
|
|
671
|
+
end_marker = "libtmux_boundary_#{SecureRandom.hex(24)}"
|
|
672
|
+
wire = "#{start_marker}\n".b + line.b + "\n#{end_marker}\n".b
|
|
673
|
+
if @request_pipes.length >= @max_requests || @queued_bytes + wire.bytesize > @max_queue
|
|
674
|
+
raise CapacityError.new("control admission limit reached", phase: :admission)
|
|
675
|
+
end
|
|
676
|
+
reader, writer = IO.pipe
|
|
677
|
+
begin
|
|
678
|
+
request = Request.new(id: (@next_id += 1), wire: wire, offset: 0,
|
|
679
|
+
start_marker: start_marker, end_marker: end_marker, started: false,
|
|
680
|
+
blocks: [], bytes: 0, reader: reader, writer: writer, flow: flow)
|
|
681
|
+
rescue Exception
|
|
682
|
+
[reader, writer].each { |io| io.close unless io.closed? }
|
|
683
|
+
raise
|
|
684
|
+
end
|
|
685
|
+
@request_pipes[request.id] = [reader, writer]
|
|
686
|
+
@requests[request.id] = request
|
|
687
|
+
@queue << request
|
|
688
|
+
@queued_bytes += wire.bytesize
|
|
689
|
+
wake(@wake_writer)
|
|
690
|
+
request
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def complete(request, result: nil, error: nil)
|
|
695
|
+
return if request.result || request.error
|
|
696
|
+
|
|
697
|
+
if request.flow && !request.flow_reported && request.offset.positive?
|
|
698
|
+
publish_event(ControlEvent.new(kind: :gap, raw: "".b, pane_id: request.flow.first,
|
|
699
|
+
reason: request.flow.last == :pause ? :pause_requested : :resume_requested, dropped_bytes: nil))
|
|
700
|
+
request.flow_reported = true
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
request.result, request.error = result, error
|
|
704
|
+
@requests.delete(request.id)
|
|
705
|
+
wake(request.writer)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def run
|
|
709
|
+
Thread.current.report_on_exception = false
|
|
710
|
+
failure, exit_deadline = nil, nil
|
|
711
|
+
streams = [@output, @error_output]
|
|
712
|
+
loop do
|
|
713
|
+
writing = pending_write
|
|
714
|
+
break if @mutex.synchronize { @stopping }
|
|
715
|
+
raise TransportError.new("control client exited while a pipe remained open", phase: :read) if exit_deadline && clock >= exit_deadline
|
|
716
|
+
|
|
717
|
+
ready = IO.select(streams + [@wake_reader, @exit_reader], writing ? [@input] : nil,
|
|
718
|
+
nil, exit_deadline && [exit_deadline - clock, 0].max)
|
|
719
|
+
next unless ready
|
|
720
|
+
|
|
721
|
+
ready[0].each do |io|
|
|
722
|
+
data = io.read_nonblock(16_384, exception: false)
|
|
723
|
+
if io == @wake_reader
|
|
724
|
+
next
|
|
725
|
+
elsif io == @exit_reader
|
|
726
|
+
if @child.observation_error
|
|
727
|
+
raise TransportError.new("control exit observation failed (#{@child.observation_error.class})", phase: :wait)
|
|
728
|
+
end
|
|
729
|
+
exit_deadline ||= clock + 0.1
|
|
730
|
+
next
|
|
731
|
+
elsif data.nil?
|
|
732
|
+
streams.delete(io)
|
|
733
|
+
if io == @output
|
|
734
|
+
@parser.finish
|
|
735
|
+
raise TransportError.new("control client output closed", phase: :read)
|
|
736
|
+
end
|
|
737
|
+
elsif data.is_a?(String)
|
|
738
|
+
if io == @output
|
|
739
|
+
@parser.feed(data) { |record| receive(record) }
|
|
740
|
+
else
|
|
741
|
+
receive_stderr(data.bytesize)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
unless ready[1].empty?
|
|
746
|
+
@mutex.synchronize do
|
|
747
|
+
request = @writing
|
|
748
|
+
if request && !@stopping
|
|
749
|
+
sent = @input.write_nonblock(request.wire.byteslice(request.offset, 16_384), exception: false)
|
|
750
|
+
request.offset += sent if sent.is_a?(Integer)
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
rescue Exception => error
|
|
756
|
+
failure = error.is_a?(Error) ? error : TransportError.new("control transport failed (#{error.class})", phase: :read)
|
|
757
|
+
ensure
|
|
758
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
759
|
+
@mutex.synchronize do
|
|
760
|
+
@stopping = true
|
|
761
|
+
@requests.values.each do |request|
|
|
762
|
+
type = failure ? failure.class : ClosedError
|
|
763
|
+
error = type.new(failure ? failure.message : "control connection closed",
|
|
764
|
+
delivery: request.offset.zero? ? :not_sent : :possibly_sent, phase: :control, pid: @pid)
|
|
765
|
+
complete(request, error: error)
|
|
766
|
+
end
|
|
767
|
+
@queue.clear
|
|
768
|
+
@replies.clear
|
|
769
|
+
@writing = nil
|
|
770
|
+
@subscriptions.each { |subscription| subscription.send(:finish, failure) }
|
|
771
|
+
end
|
|
772
|
+
cleanup
|
|
773
|
+
@mutex.synchronize { @finished = true }
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def pending_write
|
|
778
|
+
@mutex.synchronize do
|
|
779
|
+
return nil if @stopping
|
|
780
|
+
|
|
781
|
+
@writing = nil if @writing && @writing.offset == @writing.wire.bytesize
|
|
782
|
+
unless @writing
|
|
783
|
+
@writing = @queue.shift
|
|
784
|
+
@replies << @writing if @writing
|
|
785
|
+
end
|
|
786
|
+
@writing
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def receive(record)
|
|
791
|
+
@mutex.synchronize do
|
|
792
|
+
request = @replies.first
|
|
793
|
+
if record.is_a?(GuardedBlock) && request
|
|
794
|
+
if marker?(record, request.start_marker)
|
|
795
|
+
raise ProtocolError.new("duplicate control start boundary", phase: :read) if request.started
|
|
796
|
+
|
|
797
|
+
request.started = true
|
|
798
|
+
return
|
|
799
|
+
elsif marker?(record, request.end_marker)
|
|
800
|
+
raise ProtocolError.new("control end boundary preceded its start", phase: :read) unless request.started
|
|
801
|
+
|
|
802
|
+
reply = GuardedReply.new(request_id: request.id, blocks: request.blocks, generation: @generation)
|
|
803
|
+
complete(request, result: reply)
|
|
804
|
+
@replies.shift
|
|
805
|
+
return
|
|
806
|
+
elsif request.started
|
|
807
|
+
if request.bytes + record.bytesize > @max_reply
|
|
808
|
+
raise CapacityError.new("control reply exceeds its byte limit", phase: :read)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
request.blocks << record
|
|
812
|
+
request.bytes += record.bytesize
|
|
813
|
+
# Cancellation can release admission while this read chunk drains.
|
|
814
|
+
@retained_reply_bytes += record.bytesize if @request_pipes.key?(request.id)
|
|
815
|
+
return
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
if request&.flow && record.is_a?(ControlEvent) && record.kind == :gap &&
|
|
819
|
+
record.pane_id == request.flow.first && record.reason == request.flow.last
|
|
820
|
+
request.flow_reported = true
|
|
821
|
+
end
|
|
822
|
+
publish_event(record)
|
|
823
|
+
@stopping = true if record.is_a?(ControlEvent) && (record.raw == "%exit\n".b || record.raw.start_with?("%exit "))
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def receive_stderr(bytes)
|
|
828
|
+
@mutex.synchronize do
|
|
829
|
+
@stderr_bytes += bytes
|
|
830
|
+
if @stderr_bytes > @max_stderr
|
|
831
|
+
raise CapacityError.new("control stderr exceeds its byte limit", phase: :read, pid: @pid)
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def publish_event(record)
|
|
837
|
+
@sequence += 1
|
|
838
|
+
event = if record.is_a?(GuardedBlock)
|
|
839
|
+
ControlEvent.new(kind: :unattributed_block, raw: record.raw,
|
|
840
|
+
sequence: @sequence, generation: @generation)
|
|
841
|
+
else
|
|
842
|
+
ControlEvent.new(kind: record.kind, raw: record.raw, data: record.data, pane_id: record.pane_id,
|
|
843
|
+
sequence: @sequence, generation: @generation, reason: record.reason,
|
|
844
|
+
previous_generation: record.previous_generation, lost_sequences: record.lost_sequences,
|
|
845
|
+
dropped_bytes: record.dropped_bytes)
|
|
846
|
+
end
|
|
847
|
+
@subscriptions.each { |subscription| subscription.send(:publish, event) }
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def marker?(block, marker)
|
|
851
|
+
block.terminator == :error && block.guard.last == 1 &&
|
|
852
|
+
block.body == "parse error: unknown command: #{marker}\n".b
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def cleanup
|
|
856
|
+
errors = []
|
|
857
|
+
deadline = clock + 0.4
|
|
858
|
+
attempt = lambda do |label, &operation|
|
|
859
|
+
operation.call
|
|
860
|
+
rescue Exception => error
|
|
861
|
+
errors << "#{label} failed (#{error.class})"
|
|
862
|
+
end
|
|
863
|
+
attempt.call("control input close") { @input.close if @input && !@input.closed? }
|
|
864
|
+
if @pid
|
|
865
|
+
attempt.call("control client termination") { signal("TERM") }
|
|
866
|
+
attempt.call("control client forced termination") { signal("KILL") } unless @child.observed?
|
|
867
|
+
@child.finish_signalling
|
|
868
|
+
attempt.call("control exit observer join") do
|
|
869
|
+
errors << "control client reap deferred after cleanup deadline" unless @child.join([deadline - clock, 0].max)
|
|
870
|
+
end
|
|
871
|
+
else
|
|
872
|
+
attempt.call("control exit observer join") { @child&.join([deadline - clock, 0].max) }
|
|
873
|
+
end
|
|
874
|
+
@resources.each { |io| attempt.call("control pipe close") { io.close unless io.closed? } }
|
|
875
|
+
attempt.call("control route close") { @pin&.close }
|
|
876
|
+
errors << "control exit observation failed (#{@child.observation_error.class})" if @child&.observation_error
|
|
877
|
+
errors << "control fallback reap failed (#{@child.retirement_error.class})" if @child&.retirement_error
|
|
878
|
+
@cleanup_errors = errors.freeze
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def signal(name)
|
|
882
|
+
@child&.signal(name)
|
|
883
|
+
end
|
|
884
|
+
end
|
|
885
|
+
end
|