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