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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1c19838a471ee26a9953d27f31bbaece230b6b8d2422d58dd5c57d0512a186f8
|
|
4
|
+
data.tar.gz: ce84cd707102bcd05b9a32d7dfdd7d59791fa8a802aa4eb81068bc36f81caae6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 99d71b78ddff682bbafbcb3cd24a60989cb8e5ea052bd34e83902276ccdf044b13ade3d8916fd7342e5627ebc752d8f4a7a30e85d22e703455ca423b5a5dcfbb
|
|
7
|
+
data.tar.gz: 835a4ff8a6185b3db991705836e229a5cf574b90ed8805d0110a21028878cbb9141368d4c52a88da2c9441342c7e1791bf300012f262b88dd73bc0c53103fcc0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026- libtmux contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# libtmux
|
|
2
|
+
|
|
3
|
+
Ruby tmux orchestration core. `Server.open` borrows an
|
|
4
|
+
existing explicit endpoint. Closing it retires owned clients and preserves
|
|
5
|
+
the daemon; `kill` explicitly terminates the daemon. `Server.start` creates a
|
|
6
|
+
private owned foreground daemon whose lifetime ends with its server handle.
|
|
7
|
+
Owned startup uses Linux and Darwin readiness backends. The
|
|
8
|
+
[compatibility workflow](https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml)
|
|
9
|
+
retains exact per-revision platform and version results.
|
|
10
|
+
|
|
11
|
+
Install the alpha with `gem install libtmux --pre`, then require `libtmux`.
|
|
12
|
+
Imports do not
|
|
13
|
+
start tmux, a scheduler or an MCP server. See the repository's contribution
|
|
14
|
+
guide for local build and verification commands.
|
|
15
|
+
|
|
16
|
+
Handles carry immutable refs bound to one open server binding. IDs and refs
|
|
17
|
+
are local readers; `list_*`, `snapshot` and command methods perform explicit
|
|
18
|
+
I/O. Global windows represent unique entities. `WindowLink` retains a session,
|
|
19
|
+
index and window ID so repeated links stay distinct.
|
|
20
|
+
|
|
21
|
+
Link selection, unlinking, movement, swapping and display check the complete
|
|
22
|
+
link identity in the same tmux queue turn as their operation. Configured
|
|
23
|
+
command aliases are avoided using unshadowed builtin spellings. Hook waits
|
|
24
|
+
before dispatch cannot turn a stale link into its replacement. Concurrent
|
|
25
|
+
rewriting of command aliases is outside this guarantee; applications must
|
|
26
|
+
coordinate configuration changes.
|
|
27
|
+
|
|
28
|
+
Typed arguments preserve literal semicolons and distinguish pane text from
|
|
29
|
+
key names. Pane commands take executable argument arrays. Hook commands,
|
|
30
|
+
display formats, pipe shell commands and `source_file` configuration are
|
|
31
|
+
explicit executable inputs. `Server.run` remains the raw tmux escape hatch,
|
|
32
|
+
including daemon aliases, separators and format semantics.
|
|
33
|
+
|
|
34
|
+
Creation accepts `cwd:` and a String-to-String `environment:` map. Directories
|
|
35
|
+
resolve from the Ruby caller and are checked before dispatch; concurrent
|
|
36
|
+
filesystem changes can still trigger tmux's directory fallback. Creation
|
|
37
|
+
returns tmux-assigned IDs after dispatch, without claiming program readiness
|
|
38
|
+
or a successful program exit. `new_session(window_name:)` names the initial
|
|
39
|
+
window; its index can be moved explicitly after creation. `new_window(index:)`
|
|
40
|
+
refuses an occupied slot. `Pane#split` targets that exact pane, with `size:` as
|
|
41
|
+
cells or a percentage string. Windows and splits retain focus unless
|
|
42
|
+
`focus: true` is requested.
|
|
43
|
+
|
|
44
|
+
Typed operations accept `timeout:` and `cancel:`. Composed link and copy operations
|
|
45
|
+
share one deadline across their preflights and final dispatch. Copy-mode
|
|
46
|
+
exit uses `cancel_mode: true`; `cancel:` accepts `LibTmux::Cancellation.new`.
|
|
47
|
+
Call the token's `cancel` from another thread to wake a blocked request, join
|
|
48
|
+
its caller, then `close` the token. See the [plain-Ruby cancellation recipe](../../examples/cancel.rb)
|
|
49
|
+
and [ownership contract](../../docs/ownership-errors.md).
|
|
50
|
+
|
|
51
|
+
`Options` retains raw bytes, inheritance and sparse array indexes;
|
|
52
|
+
`OptionValue#as` requests a strict conversion. Hook values remain tmux command
|
|
53
|
+
strings. The stable tmux option listing uses its escaped representation,
|
|
54
|
+
including octal bytes; it does not split raw values on line breaks. Option
|
|
55
|
+
names containing whitespace are currently rejected. Inherited hook listing
|
|
56
|
+
is not implemented and raises explicitly.
|
|
57
|
+
|
|
58
|
+
Indexed `get` acquires the array and selects locally, so a missing index raises
|
|
59
|
+
`NoMatchError` while a present empty value remains present. Reading an array
|
|
60
|
+
without an index raises `MultipleMatchesError` when it has several entries.
|
|
61
|
+
Append follows tmux's lowest-free-index rule; indexed hooks execute in index
|
|
62
|
+
order. Empty arrays remain distinct from empty String values.
|
|
63
|
+
|
|
64
|
+
Binary buffers and command results preserve trailing newlines. Capture can
|
|
65
|
+
join wrapped lines, include attribute escapes, escape nonprintable bytes,
|
|
66
|
+
preserve trailing spaces and trim unused trailing cells. `mode_screen: true`
|
|
67
|
+
reads the mode's backing screen (the copy-mode snapshot, without its UI),
|
|
68
|
+
`alternate: true` reads tmux's saved screen and raises when absent (while an
|
|
69
|
+
application occupies the alternate screen, this is the saved main screen),
|
|
70
|
+
and `pending: true` reads an incomplete escape sequence. These three selectors
|
|
71
|
+
are mutually exclusive. Mode-screen and trailing-cell flags are checked
|
|
72
|
+
against advertised command usage; unsupported requests raise explicitly.
|
|
73
|
+
Copy-mode flags are checked against the connected daemon's advertised command
|
|
74
|
+
usage. Client discovery returns observations. `Server#attach` uses an explicit
|
|
75
|
+
caller-owned TTY and terminal type, waits for its owned client to exit, and
|
|
76
|
+
restores the terminal mode. `Server#switch_client(client:, session:)` switches
|
|
77
|
+
an explicit current native client selector to an exact bound session, keeping
|
|
78
|
+
the session environment. A missing selector fails without fallback. This
|
|
79
|
+
operation does not turn a client observation into an incarnation-safe
|
|
80
|
+
reference; a reconnect matching the selector is eligible at dispatch.
|
|
81
|
+
|
|
82
|
+
Control connections expose bounded event subscriptions and raw guarded replies.
|
|
83
|
+
`pause_output(pane_id:)` and `resume_output(pane_id:)` return `GuardedReply`;
|
|
84
|
+
they do not establish that an action took effect. Their gap events identify
|
|
85
|
+
`:pause_requested` or `:resume_requested` when no outside-block native notice
|
|
86
|
+
was observed, including cancellation after possible dispatch. Native notices
|
|
87
|
+
use `:pause` and `:resume`. Loss counts are unknown (`dropped_bytes: nil`);
|
|
88
|
+
resume does not replay skipped output. Guarded notification-looking text stays
|
|
89
|
+
in its reply body.
|
|
90
|
+
|
|
91
|
+
Close the old control connection, then explicitly call
|
|
92
|
+
`server.open_control(session: ref, reconnect: old_connection)` to reconnect
|
|
93
|
+
within the same binding and session. The replacement has a new `generation`
|
|
94
|
+
and retains `previous_generation`. Every new subscription begins with a
|
|
95
|
+
`:reconnect` gap containing both generations and an unknown loss count. Old
|
|
96
|
+
subscriptions stay closed, and requests are never replayed. Event sequences
|
|
97
|
+
describe one connection's observations, not durable pane history.
|
|
98
|
+
|
|
99
|
+
Typed command coverage includes hierarchy creation/listing,
|
|
100
|
+
rename/split/resize/swap/join/break/respawn/layout operations, link operations,
|
|
101
|
+
options/hooks/environment, capture/send/paste/pipe/buffers, copy commands,
|
|
102
|
+
display/source-file/wait-for. It does not establish complete flag parity or
|
|
103
|
+
every compatibility cell; consult the workflow results. RBS validation checks declarations;
|
|
104
|
+
installed signature consumers check selected real arguments, blocks and
|
|
105
|
+
return values. [Executable recipes](../../docs/recipes.md) run
|
|
106
|
+
against installed artifacts; the documentation gate renders YARD and guides
|
|
107
|
+
and checks local destinations and fragments. The [public method inventory](../../docs/reference/api.md)
|
|
108
|
+
links exported methods to source and behavioral contracts. These consumer
|
|
109
|
+
checks do not establish whole-program static typing.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "snapshot"
|
|
4
|
+
require_relative "metadata"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
module Internal
|
|
8
|
+
class Capture
|
|
9
|
+
def initialize(server, binding_key:)
|
|
10
|
+
@server = server
|
|
11
|
+
@binding_key = binding_key.dup.freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(timeout: 5.0, cancel: nil, clients: false, max_bytes: 1 << 20,
|
|
15
|
+
max_rows: 10_000, max_field_bytes: 1 << 16)
|
|
16
|
+
unless timeout.is_a?(Numeric) && timeout.finite?
|
|
17
|
+
raise ArgumentError, "capture timeout must be finite"
|
|
18
|
+
end
|
|
19
|
+
unless [max_bytes, max_rows, max_field_bytes].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
20
|
+
raise ArgumentError, "capture limits must be positive integers"
|
|
21
|
+
end
|
|
22
|
+
unless clients == true || clients == false
|
|
23
|
+
raise ArgumentError, "clients must be a Boolean"
|
|
24
|
+
end
|
|
25
|
+
if cancel && !(cancel.respond_to?(:cancelled?) && cancel.respond_to?(:reader))
|
|
26
|
+
raise ArgumentError, "cancel must be a cancellation token"
|
|
27
|
+
end
|
|
28
|
+
started_at = monotonic
|
|
29
|
+
budget = {deadline: started_at + timeout, cancel: cancel, max_bytes: max_bytes,
|
|
30
|
+
max_rows: max_rows, max_field_bytes: max_field_bytes, bytes: 0, rows: 0, reads: []}
|
|
31
|
+
server_info = acquire_server_info(budget)
|
|
32
|
+
2.times do |attempt|
|
|
33
|
+
rows = acquire_rows(budget, attempt + 1, clients)
|
|
34
|
+
check_budget(budget)
|
|
35
|
+
begin
|
|
36
|
+
snapshot = Snapshot.__send__(:new, rows: rows, binding_key: @binding_key,
|
|
37
|
+
started_at: started_at, finished_at: monotonic, reads: budget.fetch(:reads), server_info: server_info)
|
|
38
|
+
check_budget(budget)
|
|
39
|
+
return snapshot
|
|
40
|
+
rescue InconsistentSnapshotError
|
|
41
|
+
raise if attempt == 1
|
|
42
|
+
check_budget(budget)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def acquire_server_info(budget)
|
|
50
|
+
rows = read(budget, :server, 1, ["display-message", "-p", framing(%w[version pid start_time])], 3)
|
|
51
|
+
unless rows.length == 1
|
|
52
|
+
raise ProtocolError.new("invalid capture server metadata", delivery: :observed, phase: :capture)
|
|
53
|
+
end
|
|
54
|
+
version, pid, start_time = rows.first
|
|
55
|
+
version = version.dup.force_encoding(Encoding::UTF_8)
|
|
56
|
+
match = /\A(?:next-)?([0-9]+)\.([0-9]+)([a-z]?)(?:-[A-Za-z0-9.-]+)?\z/.match(version) if version.valid_encoding?
|
|
57
|
+
unless match
|
|
58
|
+
raise UnsupportedFeatureError.new("unrecognized tmux version for capture", delivery: :observed, phase: :capture)
|
|
59
|
+
end
|
|
60
|
+
supported = ([match[1].to_i, match[2].to_i, match[3]] <=> [3, 2, "a"]) >= 0
|
|
61
|
+
unless supported
|
|
62
|
+
raise UnsupportedFeatureError.new("captured metadata requires tmux 3.2a or newer", delivery: :observed, phase: :capture)
|
|
63
|
+
end
|
|
64
|
+
unless pid.match?(/\A[1-9][0-9]{0,9}\z/n) && start_time.match?(/\A-?[0-9]{1,19}\z/n)
|
|
65
|
+
raise ProtocolError.new("invalid capture server identity metadata", delivery: :observed, phase: :capture)
|
|
66
|
+
end
|
|
67
|
+
{version: version, pid: Integer(pid, 10), start_time: Integer(start_time, 10),
|
|
68
|
+
capabilities: {metadata: :byte_counted, field_baseline: "3.2a"}}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def acquire_rows(budget, attempt, clients)
|
|
72
|
+
sessions = catalog_read(budget, :session, attempt, ["list-sessions"])
|
|
73
|
+
if sessions.empty?
|
|
74
|
+
# tmux has no windows or panes without a session. Their list commands
|
|
75
|
+
# nevertheless require a default target, so the complete empty root
|
|
76
|
+
# supplies this evidence without treating command failures as emptiness.
|
|
77
|
+
rows = {session: sessions, window: [], window_link: [], pane: []}
|
|
78
|
+
rows[:client] = [] if clients
|
|
79
|
+
return rows
|
|
80
|
+
end
|
|
81
|
+
window_fields = Catalog.entity(:window).fields.values
|
|
82
|
+
link_fields = Catalog.entity(:window_link).fields.values
|
|
83
|
+
combined = read(budget, :window_link, attempt,
|
|
84
|
+
["list-windows", "-a", "-F", framing((window_fields + link_fields).map(&:format))],
|
|
85
|
+
window_fields.length + link_fields.length)
|
|
86
|
+
windows = []
|
|
87
|
+
links = []
|
|
88
|
+
combined.each do |row|
|
|
89
|
+
windows << window_fields.map(&:name).zip(row.take(window_fields.length)).to_h
|
|
90
|
+
links << link_fields.map(&:name).zip(row.drop(window_fields.length)).to_h
|
|
91
|
+
end
|
|
92
|
+
rows = {session: sessions, window: windows, window_link: links,
|
|
93
|
+
pane: catalog_read(budget, :pane, attempt, ["list-panes", "-a"])}
|
|
94
|
+
rows[:client] = catalog_read(budget, :client, attempt, ["list-clients"]) if clients
|
|
95
|
+
rows
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def catalog_read(budget, kind, attempt, command)
|
|
99
|
+
fields = Catalog.entity(kind).fields.values
|
|
100
|
+
read(budget, kind, attempt, command + ["-F", framing(fields.map(&:format))], fields.length)
|
|
101
|
+
.map { |row| fields.map(&:name).zip(row).to_h }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def framing(formats)
|
|
105
|
+
Metadata.format(formats)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read(budget, source, attempt, argv, field_count)
|
|
109
|
+
started_at = monotonic
|
|
110
|
+
remaining = check_budget(budget)
|
|
111
|
+
result = @server.__send__(:execute_typed, argv, timeout: remaining, cancel: budget.fetch(:cancel))
|
|
112
|
+
finished_at = monotonic
|
|
113
|
+
budget[:bytes] += result.stdout.bytesize
|
|
114
|
+
capacity("capture exceeds its total byte limit") if budget.fetch(:bytes) > budget.fetch(:max_bytes)
|
|
115
|
+
remaining_rows = budget.fetch(:max_rows) - budget.fetch(:rows)
|
|
116
|
+
capacity("capture exceeds its total row limit") if remaining_rows <= 0 && !result.stdout.empty?
|
|
117
|
+
rows = Metadata.decode(result.stdout, fields: field_count, quoted: true, max_bytes: budget.fetch(:max_bytes),
|
|
118
|
+
max_rows: [remaining_rows, 1].max, max_field_bytes: budget.fetch(:max_field_bytes))
|
|
119
|
+
budget[:rows] += rows.length
|
|
120
|
+
budget.fetch(:reads) << {source: source, attempt: attempt, started_at: started_at,
|
|
121
|
+
finished_at: finished_at, bytes: result.stdout.bytesize, rows: rows.length}
|
|
122
|
+
rows
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def check_budget(budget)
|
|
126
|
+
delivery = budget.fetch(:reads).empty? ? :not_sent : :observed
|
|
127
|
+
if budget.fetch(:cancel)&.cancelled?
|
|
128
|
+
raise Cancelled.new("capture cancelled", delivery: delivery, phase: :capture)
|
|
129
|
+
end
|
|
130
|
+
remaining = budget.fetch(:deadline) - monotonic
|
|
131
|
+
unless remaining.positive?
|
|
132
|
+
raise DeadlineExceeded.new("capture exceeded its deadline", delivery: delivery, phase: :capture)
|
|
133
|
+
end
|
|
134
|
+
remaining
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def capacity(message)
|
|
138
|
+
raise CapacityError.new(message, delivery: :observed, phase: :capture)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def monotonic
|
|
142
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
module Internal
|
|
5
|
+
# Names and bounds are explicit: wire spelling is not Ruby case conversion.
|
|
6
|
+
module Catalog
|
|
7
|
+
Field = Data.define(:id, :name, :wire_name, :type, :nullable, :empty_is_null, :min, :max,
|
|
8
|
+
:operators, :format, :min_version, :scope, :capture_requirements)
|
|
9
|
+
Relation = Data.define(:name, :wire_name, :target, :cardinality, :nullable,
|
|
10
|
+
:capture_requirements)
|
|
11
|
+
Schema = Data.define(:kind, :wire_entity, :fields, :relations)
|
|
12
|
+
|
|
13
|
+
EQUALITY = %i[equals not in].freeze
|
|
14
|
+
INTEGER = (EQUALITY + %i[lt lte gt gte]).freeze
|
|
15
|
+
TEXT = (EQUALITY + %i[contains starts_with ends_with]).freeze
|
|
16
|
+
UINT_MAX = (1 << 32) - 1
|
|
17
|
+
INT_MAX = (1 << 31) - 1
|
|
18
|
+
TIME_MIN = -(1 << 63)
|
|
19
|
+
TIME_MAX = (1 << 63) - 1
|
|
20
|
+
private_constant :EQUALITY, :INTEGER, :TEXT, :UINT_MAX, :INT_MAX, :TIME_MIN, :TIME_MAX
|
|
21
|
+
|
|
22
|
+
def self.field(scope, id, name, wire_name, format, type, nullable: false, empty_is_null: false, min: nil, max: nil, operators: nil)
|
|
23
|
+
Field.new(id: id, name: name, wire_name: wire_name, format: format, type: type,
|
|
24
|
+
nullable: nullable, empty_is_null: empty_is_null, min: min, max: max,
|
|
25
|
+
operators: operators || {text: TEXT, integer: INTEGER, boolean: EQUALITY}.fetch(type),
|
|
26
|
+
min_version: "3.2a", scope: scope, capture_requirements: [scope].freeze)
|
|
27
|
+
end
|
|
28
|
+
private_class_method :field
|
|
29
|
+
|
|
30
|
+
def self.relation(name, wire_name, target, cardinality, *requirements, nullable: false)
|
|
31
|
+
Relation.new(name: name, wire_name: wire_name, target: target, cardinality: cardinality,
|
|
32
|
+
nullable: nullable, capture_requirements: requirements.freeze)
|
|
33
|
+
end
|
|
34
|
+
private_class_method :relation
|
|
35
|
+
|
|
36
|
+
def self.schema(kind, wire_entity, fields, relations)
|
|
37
|
+
Schema.new(kind: kind, wire_entity: wire_entity,
|
|
38
|
+
fields: fields.to_h { |field| [field.name, field] }.freeze,
|
|
39
|
+
relations: relations.to_h { |relation| [relation.name, relation] }.freeze)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :schema
|
|
42
|
+
|
|
43
|
+
# 3.2a is the supported capture baseline, not a claim about first introduction.
|
|
44
|
+
SCHEMAS = {
|
|
45
|
+
session: schema(:session, "session", [
|
|
46
|
+
field(:session, "session.id", :id, "id", "session_id", :text, operators: EQUALITY),
|
|
47
|
+
field(:session, "session.name", :name, "name", "session_name", :text),
|
|
48
|
+
field(:session, "session.created", :created, "created", "session_created", :integer, min: TIME_MIN, max: TIME_MAX),
|
|
49
|
+
field(:session, "session.attached", :attached, "attached", "session_attached", :integer, min: 0, max: UINT_MAX),
|
|
50
|
+
field(:session, "session.window_count", :window_count, "windowCount", "session_windows", :integer, min: 0, max: UINT_MAX)
|
|
51
|
+
], [
|
|
52
|
+
relation(:windows, "windows", :window, :many, :window_link, :window),
|
|
53
|
+
relation(:window_links, "windowLinks", :window_link, :many, :window_link),
|
|
54
|
+
relation(:panes, "panes", :pane, :many, :window_link, :window, :pane),
|
|
55
|
+
relation(:current_window, "currentWindow", :window, :one, :window_link, :window, nullable: true)
|
|
56
|
+
]),
|
|
57
|
+
window: schema(:window, "window", [
|
|
58
|
+
field(:window, "window.id", :id, "id", "window_id", :text, operators: EQUALITY),
|
|
59
|
+
field(:window, "window.name", :name, "name", "window_name", :text),
|
|
60
|
+
field(:window, "window.width", :width, "width", "window_width", :integer, min: 0, max: UINT_MAX),
|
|
61
|
+
field(:window, "window.height", :height, "height", "window_height", :integer, min: 0, max: UINT_MAX),
|
|
62
|
+
field(:window, "window.pane_count", :pane_count, "paneCount", "window_panes", :integer, min: 0, max: UINT_MAX),
|
|
63
|
+
field(:window, "window.layout", :layout, "layout", "window_layout", :text)
|
|
64
|
+
], [
|
|
65
|
+
relation(:panes, "panes", :pane, :many, :pane),
|
|
66
|
+
relation(:window_links, "windowLinks", :window_link, :many, :window_link),
|
|
67
|
+
relation(:active_pane, "activePane", :pane, :one, :pane, nullable: true)
|
|
68
|
+
]),
|
|
69
|
+
pane: schema(:pane, "pane", [
|
|
70
|
+
field(:pane, "pane.id", :id, "id", "pane_id", :text, operators: EQUALITY),
|
|
71
|
+
field(:pane, "pane.window_id", :window_id, "windowId", "window_id", :text, operators: EQUALITY),
|
|
72
|
+
field(:pane, "pane.index", :index, "index", "pane_index", :integer, min: 0, max: UINT_MAX),
|
|
73
|
+
field(:pane, "pane.pid", :pid, "pid", "pane_pid", :integer, min: 0, max: INT_MAX),
|
|
74
|
+
field(:pane, "pane.current_command", :current_command, "currentCommand", "pane_current_command", :text),
|
|
75
|
+
field(:pane, "pane.current_path", :current_path, "currentPath", "pane_current_path", :text, nullable: true, empty_is_null: true),
|
|
76
|
+
field(:pane, "pane.title", :title, "title", "pane_title", :text),
|
|
77
|
+
field(:pane, "pane.active", :active, "active", "pane_active", :boolean),
|
|
78
|
+
field(:pane, "pane.dead", :dead, "dead", "pane_dead", :boolean),
|
|
79
|
+
field(:pane, "pane.dead_status", :dead_status, "deadStatus", "pane_dead_status", :integer, nullable: true, empty_is_null: true, min: 0, max: 255),
|
|
80
|
+
field(:pane, "pane.width", :width, "width", "pane_width", :integer, min: 0, max: UINT_MAX),
|
|
81
|
+
field(:pane, "pane.height", :height, "height", "pane_height", :integer, min: 0, max: UINT_MAX)
|
|
82
|
+
], [relation(:window, "window", :window, :one, :window)]),
|
|
83
|
+
window_link: schema(:window_link, "window_link", [
|
|
84
|
+
field(:window_link, "window_link.session_id", :session_id, "sessionId", "session_id", :text, operators: EQUALITY),
|
|
85
|
+
field(:window_link, "window_link.window_id", :window_id, "windowId", "window_id", :text, operators: EQUALITY),
|
|
86
|
+
field(:window_link, "window_link.index", :index, "index", "window_index", :integer, min: 0, max: INT_MAX),
|
|
87
|
+
field(:window_link, "window_link.active", :active, "active", "window_active", :boolean)
|
|
88
|
+
], [
|
|
89
|
+
relation(:session, "session", :session, :one, :session),
|
|
90
|
+
relation(:window, "window", :window, :one, :window)
|
|
91
|
+
]),
|
|
92
|
+
client: schema(:client, "client", [
|
|
93
|
+
field(:client, "client.name", :name, "name", "client_name", :text),
|
|
94
|
+
field(:client, "client.pid", :pid, "pid", "client_pid", :integer, min: 0, max: INT_MAX),
|
|
95
|
+
field(:client, "client.created", :created, "created", "client_created", :integer, min: TIME_MIN, max: TIME_MAX),
|
|
96
|
+
field(:client, "client.tty", :tty, "tty", "client_tty", :text, nullable: true, empty_is_null: true),
|
|
97
|
+
field(:client, "client.session_id", :session_id, "sessionId", "session_id", :text, nullable: true, empty_is_null: true, operators: EQUALITY),
|
|
98
|
+
field(:client, "client.width", :width, "width", "client_width", :integer, min: 0, max: UINT_MAX),
|
|
99
|
+
field(:client, "client.height", :height, "height", "client_height", :integer, nullable: true, empty_is_null: true, min: 0, max: UINT_MAX),
|
|
100
|
+
field(:client, "client.read_only", :read_only, "readOnly", "client_readonly", :boolean),
|
|
101
|
+
field(:client, "client.utf8", :utf8, "utf8", "client_utf8", :boolean),
|
|
102
|
+
field(:client, "client.control_mode", :control_mode, "controlMode", "client_control_mode", :boolean)
|
|
103
|
+
], [relation(:session, "session", :session, :one, :session, nullable: true)])
|
|
104
|
+
}.freeze
|
|
105
|
+
private_constant :SCHEMAS
|
|
106
|
+
|
|
107
|
+
def self.entity(kind)
|
|
108
|
+
SCHEMAS.fetch(kind)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.kinds
|
|
112
|
+
SCHEMAS.keys.freeze
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/process_wait"
|
|
4
|
+
|
|
5
|
+
module LibTmux
|
|
6
|
+
module Internal
|
|
7
|
+
# The native observer never reaps until the I/O owner retires signalling.
|
|
8
|
+
# Its pipe reports latched state; transport readers and writers stay outside.
|
|
9
|
+
class OwnedChild
|
|
10
|
+
attr_reader :pid, :reader
|
|
11
|
+
|
|
12
|
+
def initialize(process_wait = ProcessWait.new)
|
|
13
|
+
@mutex, @changed = Mutex.new, ConditionVariable.new
|
|
14
|
+
@launched, @retirement = Queue.new, Queue.new
|
|
15
|
+
@reader, @writer = IO.pipe
|
|
16
|
+
@reader.binmode
|
|
17
|
+
@writer.binmode
|
|
18
|
+
@creator_pid = Process.pid
|
|
19
|
+
@observer = Thread.new { observe(process_wait) }
|
|
20
|
+
rescue Exception
|
|
21
|
+
[@reader, @writer].compact.each { |io| io.close unless io.closed? }
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def spawned(pid)
|
|
26
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
raise ArgumentError, "child ownership was already published" if @published
|
|
29
|
+
|
|
30
|
+
@published = true
|
|
31
|
+
@pid = pid
|
|
32
|
+
end
|
|
33
|
+
@launched << pid
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def observed?
|
|
38
|
+
@mutex.synchronize { !!@observed }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def observation_error
|
|
42
|
+
@mutex.synchronize { @observation_error }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def retirement_error
|
|
46
|
+
@mutex.synchronize { @retirement_error }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def status
|
|
50
|
+
@mutex.synchronize { @status }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def complete?
|
|
54
|
+
@mutex.synchronize { !!@complete }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wait_observed(timeout)
|
|
58
|
+
deadline = clock + timeout
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
until @observation_complete
|
|
61
|
+
remaining = deadline - clock
|
|
62
|
+
return nil unless remaining.positive?
|
|
63
|
+
|
|
64
|
+
@changed.wait(@mutex, remaining)
|
|
65
|
+
end
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def join(timeout)
|
|
71
|
+
@observer.join(timeout) && self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def signal(name)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
return nil unless @pid && !@signalling_finished && !@observation_error.is_a?(Errno::ECHILD)
|
|
77
|
+
|
|
78
|
+
Process.kill(name, @pid)
|
|
79
|
+
end
|
|
80
|
+
rescue Errno::ESRCH
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finish_signalling
|
|
85
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
return if @signalling_finished
|
|
88
|
+
|
|
89
|
+
@signalling_finished = true
|
|
90
|
+
# Always queue the handoff: observation may fail after the last signal.
|
|
91
|
+
@retirement << true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close
|
|
97
|
+
@reader.close unless @reader.closed?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def detach
|
|
101
|
+
raise ArgumentError, "only a forked child may detach its observer" if Process.pid == @creator_pid
|
|
102
|
+
|
|
103
|
+
[@reader, @writer].each { |io| io.close unless io.closed? }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def clock
|
|
109
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def observe(process_wait)
|
|
113
|
+
Thread.current.report_on_exception = false
|
|
114
|
+
child = @launched.pop
|
|
115
|
+
return unless child
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
process_wait.observe(child)
|
|
119
|
+
@mutex.synchronize { @observed = true }
|
|
120
|
+
rescue Exception => failure
|
|
121
|
+
@mutex.synchronize { @observation_error = failure }
|
|
122
|
+
ensure
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
@observation_complete = true
|
|
125
|
+
@changed.broadcast
|
|
126
|
+
end
|
|
127
|
+
notify
|
|
128
|
+
end
|
|
129
|
+
@retirement.pop
|
|
130
|
+
unless observation_error.is_a?(Errno::ECHILD)
|
|
131
|
+
begin
|
|
132
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
133
|
+
status = Process.wait2(child).last
|
|
134
|
+
@mutex.synchronize { @status = status }
|
|
135
|
+
end
|
|
136
|
+
rescue Exception => failure
|
|
137
|
+
@mutex.synchronize { @retirement_error = failure }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
ensure
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
@complete = @observation_complete = true
|
|
143
|
+
@changed.broadcast
|
|
144
|
+
end
|
|
145
|
+
notify
|
|
146
|
+
@writer.close unless @writer.closed?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def notify
|
|
150
|
+
@writer.write_nonblock("x", exception: false)
|
|
151
|
+
rescue IOError, SystemCallError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|