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,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