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,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "catalog"
5
+ require_relative "entity"
6
+ require_relative "selection"
7
+
8
+ module LibTmux
9
+ class CapturedRecord
10
+ attr_reader :capture_id
11
+
12
+ def initialize(graph, kind, raw_values, coverage)
13
+ @graph = graph
14
+ @entity_kind = kind
15
+ @capture_id = graph.capture_id
16
+ @raw_values = raw_values
17
+ @coverage = coverage
18
+ @values = raw_values.to_h do |name, raw|
19
+ [name, decode(Internal::Catalog.entity(kind).fields.fetch(name), raw)]
20
+ end.freeze
21
+ @ref = make_ref unless kind == :client
22
+ freeze
23
+ end
24
+ private_class_method :new
25
+
26
+ def ref
27
+ if entity_kind == :client
28
+ raise UnsupportedFeatureError, "client observations have no generation-safe command reference"
29
+ end
30
+
31
+ @ref
32
+ end
33
+
34
+ def raw(name)
35
+ Internal::Catalog.entity(entity_kind).fields.fetch(name)
36
+ ensure_coverage(field_coverage(name)) unless @raw_values.key?(name)
37
+ @raw_values.fetch(name)
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(CapturedRecord) && entity_kind == other.__send__(:entity_kind) &&
42
+ capture_id == other.capture_id && identity == other.__send__(:identity)
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ [capture_id, entity_kind, identity].hash
48
+ end
49
+
50
+ def inspect
51
+ "#<#{self.class} captured #{entity_kind}>"
52
+ end
53
+
54
+ private
55
+
56
+ INVALID_TEXT = Object.new.freeze
57
+ private_constant :INVALID_TEXT
58
+ attr_reader :entity_kind
59
+
60
+ def identity
61
+ case entity_kind
62
+ when :window_link then [read_field(:session_id), read_field(:index), read_field(:window_id)]
63
+ when :client then [read_field(:name), read_field(:pid), read_field(:created)]
64
+ else read_field(:id)
65
+ end
66
+ end
67
+
68
+ def field_coverage(name)
69
+ Internal::Catalog.entity(entity_kind).fields.fetch(name)
70
+ state = @coverage.fetch(:fields, {}).fetch(name) { @raw_values.key?(name) ? :complete : :unloaded }
71
+ state == :complete && !@raw_values.key?(name) ? :unloaded : state
72
+ end
73
+
74
+ def relation_coverage(name)
75
+ relation = Internal::Catalog.entity(entity_kind).relations.fetch(name)
76
+ state = @coverage.fetch(:relations, {}).fetch(name, :complete)
77
+ state == :complete ? @graph.__send__(:relation_coverage, self, relation) : state
78
+ end
79
+
80
+ def read_field(name)
81
+ ensure_coverage(field_coverage(name))
82
+ value = @values.fetch(name)
83
+ if value.equal?(INVALID_TEXT)
84
+ raise FieldDecodeError.new("invalid UTF-8 in #{entity_kind}.#{name}", delivery: :observed, phase: :decode)
85
+ end
86
+ value
87
+ end
88
+
89
+ def read_relation(name)
90
+ ensure_coverage(relation_coverage(name))
91
+ @graph.__send__(:read_relation, self, name)
92
+ end
93
+
94
+ def ensure_coverage(state)
95
+ case state
96
+ when :complete then nil
97
+ when :unsupported then raise UnsupportedFeatureError, "captured value is unsupported"
98
+ else raise IncompleteSnapshotError, "captured value is #{state}"
99
+ end
100
+ end
101
+
102
+ def make_ref
103
+ if entity_kind == :window_link
104
+ EntityRef.__send__(:new, binding_key: @graph.binding_key, kind: entity_kind,
105
+ id: read_field(:window_id), session_id: read_field(:session_id), index: read_field(:index))
106
+ else
107
+ EntityRef.__send__(:new, binding_key: @graph.binding_key, kind: entity_kind, id: read_field(:id))
108
+ end
109
+ end
110
+
111
+ def decode(field, raw)
112
+ if raw.nil? || (field.empty_is_null && raw.empty?)
113
+ return nil if field.nullable
114
+ decode_error(field)
115
+ end
116
+ case field.type
117
+ when :text
118
+ text = raw.dup.force_encoding(Encoding::UTF_8)
119
+ text.valid_encoding? ? text.freeze : INVALID_TEXT
120
+ when :integer
121
+ decode_error(field) unless raw.bytesize <= 20 && raw.match?(/\A-?(?:0|[1-9][0-9]*)\z/n)
122
+ value = Integer(raw, 10)
123
+ decode_error(field) unless value.between?(field.min, field.max)
124
+ value
125
+ when :boolean
126
+ return true if raw == "1"
127
+ return false if raw == "0"
128
+ decode_error(field)
129
+ end
130
+ end
131
+
132
+ def decode_error(field)
133
+ raise FieldDecodeError.new("invalid #{field.type} in #{field.id}", delivery: :observed, phase: :decode)
134
+ end
135
+ end
136
+
137
+ class SessionSnapshot < CapturedRecord; end
138
+ class WindowSnapshot < CapturedRecord; end
139
+ class PaneSnapshot < CapturedRecord; end
140
+ class WindowLinkSnapshot < CapturedRecord; end
141
+ class ClientSnapshot < CapturedRecord; end
142
+
143
+ {
144
+ session: SessionSnapshot, window: WindowSnapshot, pane: PaneSnapshot,
145
+ window_link: WindowLinkSnapshot, client: ClientSnapshot
146
+ }.each do |kind, type|
147
+ Internal::Catalog.entity(kind).fields.each_value do |field|
148
+ type.define_method(field.name) { read_field(field.name) }
149
+ type.alias_method(:"#{field.name}?", field.name) if field.type == :boolean
150
+ end
151
+ Internal::Catalog.entity(kind).relations.each_key do |name|
152
+ type.define_method(name) { read_relation(name) }
153
+ end
154
+ end
155
+
156
+ # A validated graph observed over an interval, with no transport reference.
157
+ class Snapshot
158
+ attr_reader :capture_id, :binding_key, :started_at, :finished_at, :reads, :server_info, :coverage
159
+
160
+ def initialize(rows:, binding_key:, started_at:, finished_at:, reads:, server_info:, coverage: {})
161
+ @capture_id = SecureRandom.hex(16).freeze
162
+ @binding_key = binding_key.dup.freeze
163
+ @started_at = started_at
164
+ @finished_at = finished_at
165
+ unless [started_at, finished_at].all? { |time| time.is_a?(Numeric) && time.finite? } && finished_at >= started_at
166
+ raise ArgumentError, "capture interval must be finite and ordered"
167
+ end
168
+ @reads = own(reads)
169
+ @server_info = own(server_info)
170
+ @coverage = own(coverage)
171
+ validate_coverage
172
+ @source_coverage = Internal::Catalog.kinds.to_h do |kind|
173
+ [kind, @coverage.fetch(kind, {}).fetch(:source) { rows.key?(kind) ? :complete : :unloaded }]
174
+ end.freeze
175
+ @indexes = {}
176
+ @raw_rows = {}
177
+ rows.each do |kind, source|
178
+ schema = Internal::Catalog.entity(kind)
179
+ index = {}
180
+ raw_index = {}
181
+ source.each do |row|
182
+ unknown = row.keys - schema.fields.keys
183
+ raise ArgumentError, "unknown captured field" unless unknown.empty?
184
+ raw = row.to_h do |name, value|
185
+ raise ArgumentError, "captured fields must be byte Strings or nil" unless value.nil? || value.is_a?(String)
186
+ [name, value&.b&.freeze]
187
+ end.freeze
188
+ record = record_type(kind).__send__(:new, self, kind, raw, record_coverage(kind, raw))
189
+ key = own(record.__send__(:identity))
190
+ inconsistent("conflicting repeated #{kind} rows") if raw_index.key?(key) && raw_index.fetch(key) != raw
191
+ raw_index[key] ||= raw
192
+ index[key] ||= record
193
+ end
194
+ @indexes[kind] = index.freeze
195
+ @raw_rows[kind] = raw_index.freeze
196
+ end
197
+ @indexes.freeze
198
+ @raw_rows.freeze
199
+ @panes_by_window = group_records(:pane, :window_id) { |record| record.index }
200
+ @links_by_window = group_records(:window_link, :window_id) { |record| [numeric_id(record.session_id), record.index] }
201
+ @links_by_session = group_records(:window_link, :session_id) { |record| record.index }
202
+ validate_graph
203
+ freeze
204
+ end
205
+ private_class_method :new
206
+
207
+ def sessions
208
+ collection(:session)
209
+ end
210
+
211
+ def windows
212
+ collection(:window)
213
+ end
214
+
215
+ def panes
216
+ collection(:pane)
217
+ end
218
+
219
+ def window_links
220
+ collection(:window_link)
221
+ end
222
+
223
+ def clients
224
+ collection(:client)
225
+ end
226
+
227
+ def resolve(ref)
228
+ unless ref.is_a?(EntityRef) && ref.binding_key == binding_key
229
+ raise TargetNotFoundError, "reference does not belong to this capture binding"
230
+ end
231
+ key = ref.kind == :window_link ? [ref.session_id, ref.index, ref.id] : ref.id
232
+ @indexes.fetch(ref.kind, {}).fetch(key) { raise TargetNotFoundError, "reference is absent from capture" }
233
+ end
234
+
235
+ def inspect
236
+ "#<#{self.class} captured graph>"
237
+ end
238
+
239
+ private
240
+
241
+ EMPTY = [].freeze
242
+ STATES = %i[complete unloaded incomplete unsupported].freeze
243
+ private_constant :EMPTY, :STATES
244
+
245
+ def own(value)
246
+ case value
247
+ when String then value.dup.freeze
248
+ when Hash then value.to_h { |key, item| [own(key), own(item)] }.freeze
249
+ when Array then value.map { |item| own(item) }.freeze
250
+ when Symbol, Integer, Float, true, false, nil then value
251
+ else raise ArgumentError, "capture metadata must contain plain immutable values"
252
+ end
253
+ end
254
+
255
+ def validate_coverage
256
+ @coverage.each do |kind, spec|
257
+ schema = Internal::Catalog.entity(kind)
258
+ raise ArgumentError, "unknown coverage attribute" unless (spec.keys - %i[source fields relations records]).empty?
259
+ validate_coverage_spec(schema, spec)
260
+ spec.fetch(:records, {}).each_value { |record| validate_coverage_spec(schema, record) }
261
+ end
262
+ end
263
+
264
+ def validate_coverage_spec(schema, spec)
265
+ raise ArgumentError, "invalid source coverage" if spec.key?(:source) && !STATES.include?(spec[:source])
266
+ {fields: schema.fields, relations: schema.relations}.each do |name, catalog|
267
+ spec.fetch(name, {}).each do |key, state|
268
+ raise ArgumentError, "invalid captured coverage" unless catalog.key?(key) && STATES.include?(state)
269
+ end
270
+ end
271
+ end
272
+
273
+ def record_coverage(kind, raw)
274
+ spec = @coverage.fetch(kind, {})
275
+ key = if kind == :window_link
276
+ [raw[:session_id], raw[:index]&.to_i, raw[:window_id]]
277
+ elsif kind == :client
278
+ [raw[:name], raw[:pid]&.to_i, raw[:created]&.to_i]
279
+ else raw[:id]
280
+ end
281
+ local = spec.fetch(:records, {}).fetch(key, {})
282
+ %i[fields relations].to_h { |name| [name, spec.fetch(name, {}).merge(local.fetch(name, {})).freeze] }.freeze
283
+ end
284
+
285
+ def record_type(kind)
286
+ {session: SessionSnapshot, window: WindowSnapshot, pane: PaneSnapshot,
287
+ window_link: WindowLinkSnapshot, client: ClientSnapshot}.fetch(kind)
288
+ end
289
+
290
+ def records(kind)
291
+ @indexes.fetch(kind, {}).values
292
+ end
293
+
294
+ def source_coverage(kind)
295
+ @source_coverage.fetch(kind)
296
+ end
297
+
298
+ def collection(kind)
299
+ ensure_complete(source_coverage(kind))
300
+ ordered = records(kind).sort_by do |record|
301
+ case kind
302
+ when :window_link then [numeric_id(record.session_id), record.index]
303
+ when :client then [record.pid, record.created, record.raw(:name)]
304
+ else numeric_id(record.id)
305
+ end
306
+ end
307
+ selection(ordered, kind)
308
+ end
309
+
310
+ def selection(values, kind)
311
+ Selection.new(values, entity: kind, graph: self)
312
+ end
313
+
314
+ def numeric_id(id)
315
+ Integer(id.byteslice(1..), 10)
316
+ end
317
+
318
+ def group_records(kind, field)
319
+ records(kind).group_by { |record| record.__send__(:read_field, field) }
320
+ .transform_values { |group| group.sort_by { |record| yield record }.freeze }.freeze
321
+ end
322
+
323
+ def relation_coverage(record, relation)
324
+ if record.__send__(:entity_kind) == :client && relation.name == :session
325
+ state = record.__send__(:field_coverage, :session_id)
326
+ return state unless state == :complete
327
+ return :complete if record.session_id.nil?
328
+ end
329
+ states = relation.capture_requirements.map { |kind| source_coverage(kind) }
330
+ return :unsupported if states.include?(:unsupported)
331
+ return :unloaded if states.include?(:unloaded)
332
+ return :incomplete if states.include?(:incomplete)
333
+
334
+ children = case [record.__send__(:entity_kind), relation.name]
335
+ when [:window, :active_pane] then @panes_by_window.fetch(record.id, EMPTY)
336
+ when [:session, :current_window] then @links_by_session.fetch(record.id, EMPTY)
337
+ end
338
+ if children
339
+ states = children.map { |child| child.__send__(:field_coverage, :active) }
340
+ return :unsupported if states.include?(:unsupported)
341
+ return :unloaded if states.include?(:unloaded)
342
+ return :incomplete if states.include?(:incomplete)
343
+ end
344
+ :complete
345
+ end
346
+
347
+ def read_relation(record, name)
348
+ kind = record.__send__(:entity_kind)
349
+ case [kind, name]
350
+ when [:pane, :window], [:window_link, :window]
351
+ @indexes.fetch(:window).fetch(record.window_id)
352
+ when [:window_link, :session]
353
+ @indexes.fetch(:session).fetch(record.session_id)
354
+ when [:client, :session]
355
+ record.session_id && @indexes.fetch(:session).fetch(record.session_id)
356
+ when [:window, :panes]
357
+ selection(@panes_by_window.fetch(record.id, EMPTY), :pane)
358
+ when [:window, :window_links]
359
+ selection(@links_by_window.fetch(record.id, EMPTY), :window_link)
360
+ when [:window, :active_pane]
361
+ @panes_by_window.fetch(record.id, EMPTY).find(&:active?)
362
+ when [:session, :window_links]
363
+ selection(@links_by_session.fetch(record.id, EMPTY), :window_link)
364
+ when [:session, :windows]
365
+ selection(@links_by_session.fetch(record.id, EMPTY).map { |link| @indexes.fetch(:window).fetch(link.window_id) }, :window)
366
+ when [:session, :panes]
367
+ selection(@links_by_session.fetch(record.id, EMPTY).flat_map { |link| @panes_by_window.fetch(link.window_id, EMPTY) }, :pane)
368
+ when [:session, :current_window]
369
+ link = @links_by_session.fetch(record.id, EMPTY).find(&:active?)
370
+ link && @indexes.fetch(:window).fetch(link.window_id)
371
+ end
372
+ end
373
+
374
+ def validate_graph
375
+ records(:pane).each { |pane| validate_edge(:window, pane.window_id) }
376
+ placements = {}
377
+ records(:window_link).each do |link|
378
+ validate_edge(:session, link.session_id)
379
+ validate_edge(:window, link.window_id)
380
+ key = [link.session_id, link.index]
381
+ inconsistent("conflicting window placement") if placements.key?(key)
382
+ placements[key] = true
383
+ end
384
+ records(:client).each do |client|
385
+ next unless client.__send__(:field_coverage, :session_id) == :complete
386
+ validate_edge(:session, client.session_id) if client.session_id
387
+ end
388
+ records(:window).each do |window|
389
+ next unless source_coverage(:pane) == :complete
390
+ panes = @panes_by_window.fetch(window.id, EMPTY)
391
+ inconsistent("duplicate pane index") unless panes.map(&:index).uniq.size == panes.size
392
+ validate_count(window, :pane_count, panes)
393
+ validate_active(panes)
394
+ end
395
+ records(:session).each do |session|
396
+ next unless source_coverage(:window_link) == :complete
397
+ links = @links_by_session.fetch(session.id, EMPTY)
398
+ validate_count(session, :window_count, links)
399
+ validate_active(links)
400
+ end
401
+ end
402
+
403
+ def validate_edge(kind, id)
404
+ return unless source_coverage(kind) == :complete
405
+ inconsistent("broken #{kind} reference") unless @indexes.fetch(kind, {}).key?(id)
406
+ end
407
+
408
+ def validate_count(record, field, children)
409
+ return unless record.__send__(:field_coverage, field) == :complete
410
+ inconsistent("captured child count changed") unless record.__send__(:read_field, field) == children.size
411
+ end
412
+
413
+ def validate_active(children)
414
+ return if children.empty? || children.any? { |child| child.__send__(:field_coverage, :active) != :complete }
415
+ inconsistent("captured active child changed") unless children.count(&:active?) == 1
416
+ end
417
+
418
+ def ensure_complete(state)
419
+ return if state == :complete
420
+ raise UnsupportedFeatureError, "capture source is unsupported" if state == :unsupported
421
+ raise IncompleteSnapshotError, "capture source is #{state}"
422
+ end
423
+
424
+ def inconsistent(message)
425
+ raise InconsistentSnapshotError.new(message, delivery: :observed, phase: :capture)
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fcntl"
4
+ require "fiddle"
5
+ require "libtmux/errors"
6
+
7
+ module LibTmux
8
+ module Internal
9
+ class InotifyReadinessEvents
10
+ attr_reader :reader
11
+
12
+ def initialize(directory, mask: 0x00000004)
13
+ libc = Fiddle::Handle::DEFAULT
14
+ init = Fiddle::Function.new(libc["inotify_init1"], [Fiddle::TYPE_INT], Fiddle::TYPE_INT)
15
+ add = Fiddle::Function.new(libc["inotify_add_watch"],
16
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
17
+ fd = init.call(Fcntl::O_NONBLOCK)
18
+ raise SystemCallError.new("inotify_init1", Fiddle.last_error) if fd.negative?
19
+
20
+ @reader = IO.for_fd(fd, autoclose: true)
21
+ @reader.close_on_exec = true
22
+ @watch = add.call(fd, directory, mask)
23
+ raise SystemCallError.new("inotify_add_watch", Fiddle.last_error) if @watch.negative?
24
+ rescue Exception
25
+ close
26
+ raise
27
+ end
28
+
29
+ def drain(pid)
30
+ bytes = @reader.read_nonblock(16_384, exception: false)
31
+ return [] unless bytes.is_a?(String)
32
+
33
+ offset = 0
34
+ events = []
35
+ while offset < bytes.bytesize
36
+ header = bytes.byteslice(offset, 16)
37
+ raise ProtocolError.new("truncated socket readiness event", phase: :startup, pid: pid, delivery: :possibly_sent) unless header.bytesize == 16
38
+
39
+ descriptor, mask, _, size = header.unpack("iIII")
40
+ if mask & 0x00004000 != 0 # IN_Q_OVERFLOW
41
+ raise CapacityError.new("socket readiness events overflowed", phase: :startup, pid: pid, delivery: :possibly_sent)
42
+ end
43
+ name = bytes.byteslice(offset + 16, size)
44
+ raise ProtocolError.new("truncated socket readiness name", phase: :startup, pid: pid, delivery: :possibly_sent) unless name && name.bytesize == size
45
+
46
+ events << [mask, name.delete("\0")] if descriptor == @watch
47
+ offset += 16 + size
48
+ end
49
+ events
50
+ end
51
+
52
+ def watch_file(_io); end
53
+
54
+ def close
55
+ @reader.close if @reader && !@reader.closed?
56
+ end
57
+ end
58
+
59
+ class KqueueReadinessEvents
60
+ attr_reader :reader
61
+
62
+ def initialize(directory)
63
+ unless Fiddle::SIZEOF_VOIDP == 8
64
+ raise UnsupportedFeatureError.new("owned daemon readiness requires 64-bit Darwin", phase: :startup)
65
+ end
66
+ libc = Fiddle::Handle::DEFAULT
67
+ create = Fiddle::Function.new(libc["kqueue"], [], Fiddle::TYPE_INT)
68
+ @kevent = Fiddle::Function.new(libc["kevent"], [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
69
+ Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT)
70
+ fd = create.call
71
+ raise SystemCallError.new("kqueue", Fiddle.last_error) if fd.negative?
72
+
73
+ @reader = IO.for_fd(fd, autoclose: true)
74
+ @reader.close_on_exec = true
75
+ @directory = File.open(directory, File::RDONLY)
76
+ @directory.close_on_exec = true
77
+ watch_file(@directory)
78
+ rescue Exception
79
+ close
80
+ raise
81
+ end
82
+
83
+ def watch_file(io)
84
+ # Darwin's 64-bit struct kevent: ident, filter, flags, fflags, data, udata.
85
+ # EVFILT_VNODE, EV_ADD|EV_CLEAR, NOTE_WRITE|DELETE|RENAME|REVOKE.
86
+ change = [io.fileno, -4, 0x21, 0x63, 0, 0].pack("QsS I qQ")
87
+ result = @kevent.call(@reader.fileno, change, 1, nil, 0, nil)
88
+ raise SystemCallError.new("kevent registration", Fiddle.last_error) if result.negative?
89
+ end
90
+
91
+ def drain(pid)
92
+ buffer = "\0".b * 256
93
+ zero_timeout = [0, 0].pack("q2")
94
+ count = @kevent.call(@reader.fileno, nil, 0, buffer, 8, zero_timeout)
95
+ if count.negative?
96
+ return if Fiddle.last_error == Errno::EINTR::Errno
97
+
98
+ raise SystemCallError.new("kevent readiness", Fiddle.last_error)
99
+ end
100
+ count.times do |index|
101
+ _, _, flags, notes, error, = buffer.byteslice(index * 32, 32).unpack("QsS I qQ")
102
+ raise SystemCallError.new("kevent event", error) if flags & 0x4000 != 0
103
+ if notes & 0x61 != 0
104
+ raise TransportError.new("owned startup log changed identity", phase: :startup, pid: pid, delivery: :possibly_sent)
105
+ end
106
+ end
107
+ end
108
+
109
+ def close
110
+ failure = nil
111
+ [@reader, @directory].compact.each do |io|
112
+ begin
113
+ io.close unless io.closed?
114
+ rescue Exception => error
115
+ failure ||= error
116
+ end
117
+ end
118
+ raise failure if failure
119
+ end
120
+ end
121
+
122
+ class SocketReadiness
123
+ def self.new(directory)
124
+ return LogSocketReadiness.new(directory) if RUBY_PLATFORM.include?("darwin")
125
+ unless RUBY_PLATFORM.include?("linux")
126
+ raise UnsupportedFeatureError.new("owned daemon readiness requires Linux or Darwin", phase: :startup)
127
+ end
128
+
129
+ super
130
+ end
131
+
132
+ def initialize(directory)
133
+ @events = InotifyReadinessEvents.new(directory)
134
+ end
135
+
136
+ def reader = @events.reader
137
+ def arguments = []
138
+ def spawn_options = {}
139
+ def remove_files(_pid); end
140
+
141
+ def ready?(child)
142
+ # tmux's initial chmod follows bind/listen; no client can cause it yet.
143
+ @events.drain(child.pid).any? { |mask, name| mask & 0x00000004 != 0 && name == "socket" }
144
+ end
145
+
146
+ def close = @events.close
147
+ end
148
+
149
+ class LogSocketReadiness
150
+ attr_reader :bytes_read
151
+
152
+ def initialize(directory)
153
+ @directory, @bytes_read, @tail = directory, 0, +"".b
154
+ @events = if RUBY_PLATFORM.include?("darwin")
155
+ KqueueReadinessEvents.new(directory)
156
+ elsif RUBY_PLATFORM.include?("linux")
157
+ InotifyReadinessEvents.new(directory, mask: 0x00000102) # IN_CREATE|IN_MODIFY
158
+ else
159
+ raise UnsupportedFeatureError.new("owned log readiness requires Linux or Darwin", phase: :startup)
160
+ end
161
+ end
162
+
163
+ def reader = @events.reader
164
+ def arguments = ["-v"]
165
+ def spawn_options = {chdir: @directory}
166
+ def stopped? = !!@stopped
167
+
168
+ def ready?(child)
169
+ return true if @stopped
170
+
171
+ @events.drain(child.pid)
172
+ unless @log
173
+ begin
174
+ @log = File.open(File.join(@directory, "tmux-server-#{child.pid}.log"), File::RDONLY | File::NOFOLLOW)
175
+ rescue Errno::ENOENT
176
+ return false
177
+ end
178
+ @log.close_on_exec = true
179
+ unless @log.stat.file?
180
+ raise ProtocolError.new("owned startup log is not a regular file", phase: :startup, pid: child.pid, delivery: :possibly_sent)
181
+ end
182
+ # Register before reading: an append between registration and EOF stays queued.
183
+ @events.watch_file(@log)
184
+ end
185
+ while (bytes = @log.read(16_384))
186
+ @bytes_read += bytes.bytesize
187
+ if @bytes_read > 1 << 20
188
+ raise CapacityError.new("owned startup log exceeded byte limit", phase: :startup, pid: child.pid, delivery: :possibly_sent)
189
+ end
190
+ text = @tail + bytes
191
+ if !@stopping && text.match?(/(?:\A|\n)\d+\.\d+ server loop enter\n/n)
192
+ child.signal("USR2")
193
+ @stopping = true
194
+ end
195
+ if @stopping && text.match?(/(?:\A|\n)\d+\.\d+ log closed\n/n)
196
+ @stopped = true
197
+ return true
198
+ end
199
+ @tail = text.byteslice(-128, 128) || text
200
+ end
201
+ false
202
+ end
203
+
204
+ def remove_files(pid)
205
+ return unless pid
206
+
207
+ %w[client server].each do |kind|
208
+ path = File.join(@directory, "tmux-#{kind}-#{pid}.log")
209
+ File.unlink(path) if File.exist?(path)
210
+ end
211
+ end
212
+
213
+ def close
214
+ failure = nil
215
+ begin
216
+ @log.close if @log && !@log.closed?
217
+ rescue Exception => error
218
+ failure = error
219
+ ensure
220
+ begin
221
+ @events.close
222
+ rescue Exception => error
223
+ failure ||= error
224
+ end
225
+ end
226
+ raise failure if failure
227
+ end
228
+ end
229
+
230
+ private_constant :InotifyReadinessEvents, :KqueueReadinessEvents, :SocketReadiness, :LogSocketReadiness
231
+ end
232
+ end