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