muxr 0.1.10 → 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.
data/lib/muxr/protocol.rb CHANGED
@@ -55,21 +55,67 @@ module Muxr
55
55
  buf
56
56
  end
57
57
 
58
- # Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
59
- def self.encode_size(rows, cols)
60
- "#{rows.to_i} #{cols.to_i}"
58
+ # Encodes a "ROWS COLS" string for HELLO / RESIZE payloads. Optional
59
+ # capabilities (from the width probe) ride along as trailing "key=val"
60
+ # tokens, e.g. "48 200 ambiguous=2". Older servers that split on the first
61
+ # two tokens still read the size correctly, and a server that doesn't know a
62
+ # given cap simply ignores it.
63
+ def self.encode_size(rows, cols, caps = nil)
64
+ base = "#{rows.to_i} #{cols.to_i}"
65
+ return base if caps.nil? || caps.empty?
66
+ base + " " + encode_caps(caps)
61
67
  end
62
68
 
63
- # Returns [rows, cols] or nil if malformed.
69
+ # Serializes a caps hash to whitespace-free "key=value" tokens. Integer
70
+ # values encode as "ambiguous=2"; a codepoint=>width map (the width probe's
71
+ # per-glyph overrides) encodes as "glyphs=23fa:2,273b:1" with hex codepoints.
72
+ def self.encode_caps(caps)
73
+ caps.filter_map do |k, v|
74
+ if v.is_a?(Hash)
75
+ next if v.empty?
76
+ "#{k}=" + v.map { |cp, w| "#{cp.to_s(16)}:#{w.to_i}" }.join(",")
77
+ else
78
+ "#{k}=#{v.to_i}"
79
+ end
80
+ end.join(" ")
81
+ end
82
+
83
+ # Returns [rows, cols] from a HELLO/RESIZE payload, or nil if malformed.
84
+ # Tolerates trailing caps tokens by reading only the first two integers.
64
85
  def self.decode_size(payload)
65
86
  parts = payload.to_s.strip.split(/\s+/)
66
- return nil unless parts.length == 2
87
+ return nil unless parts.length >= 2
67
88
  r = Integer(parts[0]) rescue nil
68
89
  c = Integer(parts[1]) rescue nil
69
90
  return nil unless r && c
70
91
  [r, c]
71
92
  end
72
93
 
94
+ # Pulls the trailing "key=val" capability tokens out of a HELLO payload into
95
+ # a symbol-keyed hash. Plain integer values decode to Integer; a value with
96
+ # "hex:width" pairs decodes to a {codepoint => width} hash (inverse of
97
+ # encode_caps). Unknown / malformed tokens are skipped, never raised on.
98
+ def self.decode_caps(payload)
99
+ caps = {}
100
+ payload.to_s.strip.split(/\s+/).each do |tok|
101
+ next unless tok.include?("=")
102
+ k, v = tok.split("=", 2)
103
+ next unless k =~ /\A[a-z_]+\z/
104
+ if v.include?(":")
105
+ map = {}
106
+ v.split(",").each do |pair|
107
+ cp, w = pair.split(":", 2)
108
+ next unless cp =~ /\A[0-9a-fA-F]+\z/ && w =~ /\A\d+\z/
109
+ map[cp.to_i(16)] = w.to_i
110
+ end
111
+ caps[k.to_sym] = map unless map.empty?
112
+ elsif v =~ /\A-?\d+\z/
113
+ caps[k.to_sym] = v.to_i
114
+ end
115
+ end
116
+ caps
117
+ end
118
+
73
119
  def self.read_exact(io, n)
74
120
  buf = +""
75
121
  while buf.bytesize < n
@@ -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
@@ -81,6 +92,26 @@ module Muxr
81
92
  end
82
93
  end
83
94
 
95
+ # Coax the foreground program into repainting from scratch by briefly
96
+ # toggling the PTY window size, which delivers SIGWINCH to the tty's
97
+ # foreground process group. Full-screen TUIs (vim, htop, less, fzf) redraw
98
+ # on WINCH, which rewrites muxr's Terminal grid and clears any emulation
99
+ # drift (e.g. a wide glyph that desynced the cursor). The size is restored
100
+ # immediately, so the program redraws at the real dimensions: it reads the
101
+ # current (restored) winsize in its handler and never observes the
102
+ # transient size. No-op when the pane is too narrow to wiggle.
103
+ def nudge_redraw
104
+ return if @exited
105
+ smaller = [@cols - 1, 1].max
106
+ return if smaller == @cols
107
+ begin
108
+ @reader.winsize = [@rows, smaller, 0, 0]
109
+ @reader.winsize = [@rows, @cols, 0, 0]
110
+ rescue StandardError
111
+ # Some platforms reject winsize pokes; reset_frame! still re-emits.
112
+ end
113
+ end
114
+
84
115
  def alive?
85
116
  return false if @exited
86
117
  Process.kill(0, @pid)
@@ -96,6 +127,22 @@ module Muxr
96
127
  nil
97
128
  end
98
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
+
99
146
  def close
100
147
  reap
101
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