muxr 0.1.11 → 0.2.0

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.
@@ -6,20 +6,31 @@ module Muxr
6
6
  class PTYProcess
7
7
  attr_reader :pid, :io, :rows, :cols
8
8
 
9
- def initialize(command: nil, rows: 24, cols: 80, cwd: nil, env_overrides: {})
9
+ # +adopt_io+ / +adopt_pid+ take over a master pty handed across a Unix
10
+ # socket by another muxr server instead of spawning anything: the shell on
11
+ # the far end of that fd is already running and keeps running. The child is
12
+ # not ours, so it can be signalled and inspected but never waited on —
13
+ # #reap already tolerates ECHILD, which is exactly that case.
14
+ def initialize(command: nil, rows: 24, cols: 80, cwd: nil, env_overrides: {}, adopt_io: nil, adopt_pid: nil)
10
15
  @rows = rows
11
16
  @cols = cols
12
17
  @exited = false
13
18
  @write_buffer = +"".b
14
19
 
15
- shell = command || ENV["SHELL"] || "/bin/sh"
16
- env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
17
- env["LINES"] = rows.to_s
18
- env["COLUMNS"] = cols.to_s
20
+ if adopt_io
21
+ @reader = adopt_io
22
+ @writer = adopt_io.dup
23
+ @pid = adopt_pid
24
+ else
25
+ shell = command || ENV["SHELL"] || "/bin/sh"
26
+ env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
27
+ env["LINES"] = rows.to_s
28
+ env["COLUMNS"] = cols.to_s
19
29
 
20
- chdir = (cwd && File.directory?(cwd)) ? cwd : Dir.pwd
30
+ chdir = (cwd && File.directory?(cwd)) ? cwd : Dir.pwd
21
31
 
22
- @reader, @writer, @pid = PTY.spawn(env, shell, chdir: chdir)
32
+ @reader, @writer, @pid = PTY.spawn(env, shell, chdir: chdir)
33
+ end
23
34
  @io = @reader
24
35
  resize(rows, cols)
25
36
  end
@@ -116,6 +127,22 @@ module Muxr
116
127
  nil
117
128
  end
118
129
 
130
+ # Give the child up to another server without killing it: drop our fds and
131
+ # hand the corpse-reaping duty to a detach thread, since the process stays
132
+ # our child in the kernel's eyes no matter who holds the pty now. After
133
+ # this the process looks exited to us, so #close is a no-op and nothing
134
+ # signals a shell that has a new owner.
135
+ def relinquish!
136
+ return if @exited
137
+ @exited = true
138
+ @write_buffer.clear
139
+ Process.detach(@pid) if @pid
140
+ @reader.close unless @reader.closed?
141
+ @writer.close if @writer != @reader && !@writer.closed?
142
+ rescue Errno::EBADF, IOError
143
+ # already closed
144
+ end
145
+
119
146
  def close
120
147
  reap
121
148
  Process.kill("TERM", @pid) if alive?
@@ -0,0 +1,263 @@
1
+ require "base64"
2
+ require "json"
3
+ require "socket"
4
+
5
+ module Muxr
6
+ # Stands in for a PTYProcess when a pane is borrowed from another muxr
7
+ # server. It speaks the owner's control protocol (NDJSON over
8
+ # ~/.muxr/sockets/<name>.ctrl.sock) but presents the same surface a Pane
9
+ # expects from a local PTY, so the borrowed pane joins the event loop, the
10
+ # layout, and the Renderer with no special-casing anywhere else.
11
+ #
12
+ # The owner keeps the PTY and stays the only reader of it. What crosses the
13
+ # socket is the raw byte stream in one direction and keystrokes in the other,
14
+ # which makes the local Terminal a byte-exact replica rather than a
15
+ # screen-scrape: colors, cursor, alternate screen and bracketed paste all
16
+ # behave. The price is that geometry belongs to the owner — see Pane#resize.
17
+ #
18
+ # Lifecycle at both ends is failure-shaped rather than negotiated. If the
19
+ # owning server stops, the socket EOFs, #alive? goes false and the borrowing
20
+ # session prunes the pane. If the borrowing server stops, #close drops the
21
+ # mirror and the pane carries on at home at full size.
22
+ class RemotePane
23
+ class Error < StandardError; end
24
+
25
+ HANDSHAKE_TIMEOUT = 2.0
26
+ READ_CHUNK = 64 * 1024
27
+
28
+ attr_reader :session, :pane_id, :rows, :cols, :cwd
29
+
30
+ def self.connect(socket_path:, pane_id:, rows:, cols:)
31
+ new(socket_path: socket_path, pane_id: pane_id, rows: rows, cols: cols)
32
+ end
33
+
34
+ def initialize(socket_path:, pane_id:, rows:, cols:, socket: nil)
35
+ @socket_path = socket_path
36
+ @pane_id = pane_id.to_s
37
+ @requested = [rows, cols]
38
+ @in_buffer = +""
39
+ @write_buffer = +"".b
40
+ @queue = []
41
+ @closed = false
42
+ @terminal = nil
43
+ @socket = socket || connect_socket
44
+ handshake(rows, cols)
45
+ end
46
+
47
+ def mirror?
48
+ true
49
+ end
50
+
51
+ # The replica emulator this mirror drives. Bound after construction because
52
+ # the Pane builds its Terminal from the geometry we hand back.
53
+ def bind(terminal)
54
+ @terminal = terminal
55
+ end
56
+
57
+ def origin
58
+ "#{@session}:#{@pane_id}"
59
+ end
60
+
61
+ def io
62
+ @socket
63
+ end
64
+
65
+ def writer_io
66
+ @socket
67
+ end
68
+
69
+ def pid
70
+ nil
71
+ end
72
+
73
+ def pending_write?
74
+ !@write_buffer.empty?
75
+ end
76
+
77
+ def alive?
78
+ !@closed
79
+ end
80
+
81
+ def write(data)
82
+ return if @closed || data.nil? || data.empty?
83
+ request("pane.send_input", "pane" => @pane_id, "data" => Base64.strict_encode64(data), "base64" => true)
84
+ end
85
+
86
+ def resize(rows, cols)
87
+ return if @closed
88
+ return if [rows, cols] == @requested
89
+ @requested = [rows, cols]
90
+ request("pane.mirror_resize", "pane" => @pane_id, "rows" => rows, "cols" => cols)
91
+ end
92
+
93
+ def nudge_redraw
94
+ return if @closed
95
+ request("pane.redraw", "pane" => @pane_id)
96
+ end
97
+
98
+ def read_nonblock(_max = READ_CHUNK)
99
+ loop do
100
+ chunk = take_queued
101
+ return chunk if chunk
102
+ return nil unless fill
103
+ end
104
+ end
105
+
106
+ def drain
107
+ return if @closed || @write_buffer.empty?
108
+ loop do
109
+ n = @socket.write_nonblock(@write_buffer)
110
+ @write_buffer = @write_buffer.byteslice(n..-1) || +"".b
111
+ break if @write_buffer.empty?
112
+ end
113
+ rescue IO::WaitWritable
114
+ # Owner's receive buffer is full; the rest stays queued.
115
+ rescue SystemCallError, IOError
116
+ @closed = true
117
+ @write_buffer.clear
118
+ end
119
+
120
+ def close
121
+ return if @closed
122
+ @closed = true
123
+ begin
124
+ @socket.write(JSON.generate("method" => "pane.unmirror", "params" => { "pane" => @pane_id }) + "\n")
125
+ rescue SystemCallError, IOError
126
+ # Owner already gone; it drops the mirror when our socket closes.
127
+ end
128
+ @socket.close rescue nil
129
+ end
130
+
131
+ private
132
+
133
+ def connect_socket
134
+ UNIXSocket.new(@socket_path)
135
+ rescue SystemCallError => e
136
+ raise Error, "cannot reach session socket: #{e.message}"
137
+ end
138
+
139
+ def handshake(rows, cols)
140
+ send_line(JSON.generate(
141
+ "id" => 1,
142
+ "method" => "pane.mirror",
143
+ "params" => { "pane" => @pane_id, "rows" => rows, "cols" => cols }
144
+ ))
145
+ result = await_response(1)
146
+ @session = result["session"].to_s
147
+ @cwd = result["cwd"]
148
+ @rows = result["rows"].to_i
149
+ @cols = result["cols"].to_i
150
+ snapshot = decode(result["snapshot"])
151
+ @queue << [:bytes, snapshot] if snapshot && !snapshot.empty?
152
+ end
153
+
154
+ def await_response(id)
155
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
156
+ loop do
157
+ line = @in_buffer.slice!(/\A.*\n/)
158
+ if line
159
+ msg = parse(line)
160
+ next unless msg && msg["id"] == id
161
+ if (err = msg["error"])
162
+ raise Error, err["message"].to_s
163
+ end
164
+ return msg["result"] || {}
165
+ end
166
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
167
+ raise Error, "timed out waiting for the owning session" if remaining <= 0
168
+ raise Error, "timed out waiting for the owning session" unless IO.select([@socket], nil, nil, remaining)
169
+ chunk = begin
170
+ @socket.read_nonblock(READ_CHUNK)
171
+ rescue IO::WaitReadable
172
+ next
173
+ rescue EOFError, SystemCallError, IOError
174
+ raise Error, "owning session closed the connection"
175
+ end
176
+ @in_buffer << chunk
177
+ end
178
+ end
179
+
180
+ def request(method, params)
181
+ send_line(JSON.generate("method" => method, "params" => params))
182
+ end
183
+
184
+ def send_line(line)
185
+ @write_buffer << (line + "\n").b
186
+ drain
187
+ end
188
+
189
+ # Pull the next relayed output chunk off the queue, applying any geometry
190
+ # change that precedes it first: the owner's repaint snapshot is only
191
+ # meaningful against a replica that has already taken the new size.
192
+ def take_queued
193
+ while (entry = @queue.shift)
194
+ kind, *rest = entry
195
+ case kind
196
+ when :bytes
197
+ return rest[0]
198
+ when :geometry
199
+ rows, cols, snapshot = rest
200
+ @rows = rows
201
+ @cols = cols
202
+ @terminal&.resize(rows, cols)
203
+ return snapshot unless snapshot.nil? || snapshot.empty?
204
+ when :gone
205
+ @closed = true
206
+ return nil
207
+ end
208
+ end
209
+ nil
210
+ end
211
+
212
+ def fill
213
+ return false if @closed
214
+ chunk = begin
215
+ @socket.read_nonblock(READ_CHUNK)
216
+ rescue IO::WaitReadable
217
+ return false
218
+ rescue EOFError, SystemCallError, IOError
219
+ @closed = true
220
+ return false
221
+ end
222
+ @in_buffer << chunk
223
+ queued = false
224
+ while (line = @in_buffer.slice!(/\A.*\n/))
225
+ queued = true if enqueue(line)
226
+ end
227
+ queued
228
+ end
229
+
230
+ def enqueue(line)
231
+ msg = parse(line)
232
+ return false unless msg
233
+ params = msg["params"] || {}
234
+ return false unless params["pane"].to_s == @pane_id
235
+ case msg["method"]
236
+ when "event.pane.mirror"
237
+ data = decode(params["data"])
238
+ return false if data.nil? || data.empty?
239
+ @queue << [:bytes, data]
240
+ when "event.pane.geometry"
241
+ @queue << [:geometry, params["rows"].to_i, params["cols"].to_i, decode(params["snapshot"])]
242
+ when "event.pane.gone"
243
+ @queue << [:gone]
244
+ else
245
+ return false
246
+ end
247
+ true
248
+ end
249
+
250
+ def parse(line)
251
+ JSON.parse(line)
252
+ rescue JSON::ParserError
253
+ nil
254
+ end
255
+
256
+ def decode(value)
257
+ return nil unless value.is_a?(String)
258
+ Base64.strict_decode64(value)
259
+ rescue ArgumentError
260
+ nil
261
+ end
262
+ end
263
+ end