muxr 0.1.2 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/muxr/application.rb +67 -21
- data/lib/muxr/client.rb +40 -7
- data/lib/muxr/input_handler.rb +20 -7
- data/lib/muxr/pane.rb +12 -0
- data/lib/muxr/protocol.rb +12 -1
- data/lib/muxr/pty_process.rb +34 -2
- data/lib/muxr/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d19432b15ff535a2ce8f4bfb5b759ddd3599b4fb0af949d7871cf1059497c483
|
|
4
|
+
data.tar.gz: ce58f726a5a9dc186deb042c0d0e31fa8694676d0cdb4c496cfb57668d5fd4c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b96c66aa1b96e853e7d268c012f8bc5e62fdf77c1e3d4e89360b764d4cfc1d20bdb271be81acad05f26417d09e17fe27f437680cb00a33fcf6f2c934600fef2
|
|
7
|
+
data.tar.gz: '045758a0d3ec1e4f2d6bfe4bb887743cc2ab80310bd842190b362e9a9976fd023b8818c35d13f80478839bbbb12d91372b22ddabc91ee430afda8c3ce3b0853a'
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-05-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Large pastes into a pane no longer hang the server. PTY writes are now
|
|
13
|
+
non-blocking and buffered per pane, with the writer fd added to the
|
|
14
|
+
event loop's `IO.select` write set so back-pressure from a slow reader
|
|
15
|
+
(e.g. Claude Code processing a multi-KB paste) can't deadlock the
|
|
16
|
+
single-threaded server. Idle pass-through input is also batched into a
|
|
17
|
+
single `send_to_focused` chunk instead of one call per byte.
|
|
18
|
+
- Client↔server socket writes are now non-blocking too, with per-side
|
|
19
|
+
outgoing buffers and the socket added to `IO.select`'s write set when
|
|
20
|
+
there's queued data. The previous blocking `Protocol.write` could
|
|
21
|
+
deadlock both ends when a paste produced enough redraw traffic to fill
|
|
22
|
+
both directions of the unix-socket kernel buffer at once (vim and
|
|
23
|
+
Claude Code both reproduced this).
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Enable bracketed paste mode (`\e[?2004h`) on the outer terminal when
|
|
27
|
+
the client attaches. The terminal emulator now wraps pastes with
|
|
28
|
+
`\e[200~...\e[201~`, those markers flow through muxr to the focused
|
|
29
|
+
pane, and apps that opt in (Claude Code, vim, modern readline) again
|
|
30
|
+
recognise the input as a paste — Claude Code collapses it to
|
|
31
|
+
`[Pasted text +N lines]` instead of typing the whole thing out.
|
|
32
|
+
|
|
9
33
|
## [0.1.2] - 2026-05-11
|
|
10
34
|
|
|
11
35
|
### Added
|
data/lib/muxr/application.rb
CHANGED
|
@@ -57,6 +57,7 @@ module Muxr
|
|
|
57
57
|
@help_visible = false
|
|
58
58
|
@next_pane_id = 0
|
|
59
59
|
@current_client = nil
|
|
60
|
+
@client_write_buffer = +"".b
|
|
60
61
|
@listening_socket = nil
|
|
61
62
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
62
63
|
@paste_buffer = +""
|
|
@@ -401,12 +402,29 @@ module Muxr
|
|
|
401
402
|
end
|
|
402
403
|
end
|
|
403
404
|
|
|
404
|
-
# Called by the FramedOutput adapter;
|
|
405
|
-
# currently attached client
|
|
405
|
+
# Called by the FramedOutput adapter; queues one OUTPUT frame to the
|
|
406
|
+
# currently attached client and tries to push as much as the socket
|
|
407
|
+
# will take without blocking. Anything left over stays in
|
|
408
|
+
# @client_write_buffer and gets flushed by the event loop when the
|
|
409
|
+
# socket reports writable. This prevents a slow client (or slow
|
|
410
|
+
# terminal upstream of the client) from deadlocking the server when
|
|
411
|
+
# the server is also trying to read from that same client.
|
|
406
412
|
def deliver_output(bytes)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
413
|
+
return unless @current_client
|
|
414
|
+
@client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
|
|
415
|
+
drain_client_writes
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def drain_client_writes
|
|
419
|
+
return unless @current_client
|
|
420
|
+
return if @client_write_buffer.empty?
|
|
421
|
+
loop do
|
|
422
|
+
n = @current_client.write_nonblock(@client_write_buffer)
|
|
423
|
+
@client_write_buffer = @client_write_buffer.byteslice(n..-1) || +"".b
|
|
424
|
+
break if @client_write_buffer.empty?
|
|
425
|
+
end
|
|
426
|
+
rescue IO::WaitWritable
|
|
427
|
+
# Socket send buffer is full; the rest stays queued.
|
|
410
428
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
411
429
|
drop_client_silently
|
|
412
430
|
end
|
|
@@ -479,25 +497,40 @@ module Muxr
|
|
|
479
497
|
|
|
480
498
|
def loop_forever
|
|
481
499
|
while @running
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
@session.window.panes.each { |p|
|
|
485
|
-
|
|
486
|
-
|
|
500
|
+
read_ios = [@listening_socket]
|
|
501
|
+
read_ios << @current_client if @current_client
|
|
502
|
+
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
503
|
+
drawer_pane = @session.drawer&.pane
|
|
504
|
+
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
505
|
+
|
|
506
|
+
write_ios = []
|
|
507
|
+
@session.window.panes.each do |p|
|
|
508
|
+
write_ios << p.writer_io if p.alive? && p.pending_write?
|
|
509
|
+
end
|
|
510
|
+
if drawer_pane&.alive? && drawer_pane.pending_write?
|
|
511
|
+
write_ios << drawer_pane.writer_io
|
|
487
512
|
end
|
|
513
|
+
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
488
514
|
|
|
489
515
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
516
|
+
ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
|
|
517
|
+
|
|
518
|
+
ready_r&.each do |io|
|
|
519
|
+
if io == @listening_socket
|
|
520
|
+
accept_client
|
|
521
|
+
elsif io == @current_client
|
|
522
|
+
consume_client_frame
|
|
523
|
+
else
|
|
524
|
+
consume_pane_io(io)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
ready_w&.each do |io|
|
|
529
|
+
if io == @current_client
|
|
530
|
+
drain_client_writes
|
|
531
|
+
else
|
|
532
|
+
pane = pane_for_writer_io(io)
|
|
533
|
+
pane&.drain_writes
|
|
501
534
|
end
|
|
502
535
|
end
|
|
503
536
|
|
|
@@ -579,6 +612,13 @@ module Muxr
|
|
|
579
612
|
nil
|
|
580
613
|
end
|
|
581
614
|
|
|
615
|
+
def pane_for_writer_io(io)
|
|
616
|
+
pane = @session.window.panes.find { |p| p.writer_io == io }
|
|
617
|
+
return pane if pane
|
|
618
|
+
return @session.drawer.pane if @session.drawer&.pane && @session.drawer.pane.writer_io == io
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
582
622
|
def prune_dead_panes
|
|
583
623
|
dead = @session.window.panes.reject(&:alive?)
|
|
584
624
|
return if dead.empty?
|
|
@@ -612,6 +652,11 @@ module Muxr
|
|
|
612
652
|
|
|
613
653
|
def disconnect_client(reason: nil)
|
|
614
654
|
return unless @current_client
|
|
655
|
+
# Best-effort: drop any queued OUTPUT (the client is going away),
|
|
656
|
+
# send a final BYE, then close. BYE is small enough that one
|
|
657
|
+
# blocking write won't meaningfully wedge anything even if the
|
|
658
|
+
# client's recv is sluggish.
|
|
659
|
+
@client_write_buffer = +"".b
|
|
615
660
|
safe_protocol_write(@current_client, Protocol::BYE, reason || "")
|
|
616
661
|
@current_client.close rescue nil
|
|
617
662
|
@current_client = nil
|
|
@@ -621,6 +666,7 @@ module Muxr
|
|
|
621
666
|
return unless @current_client
|
|
622
667
|
@current_client.close rescue nil
|
|
623
668
|
@current_client = nil
|
|
669
|
+
@client_write_buffer = +"".b
|
|
624
670
|
end
|
|
625
671
|
|
|
626
672
|
def safe_protocol_write(io, type, payload = "")
|
data/lib/muxr/client.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Muxr
|
|
|
24
24
|
@resize_pending = false
|
|
25
25
|
@exit_code = 0
|
|
26
26
|
@bye_reason = nil
|
|
27
|
+
@write_buffer = +"".b
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Opens the socket. Returns true on success. Raises Errno::ENOENT /
|
|
@@ -62,28 +63,55 @@ module Muxr
|
|
|
62
63
|
send_resize
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
write_ios = @write_buffer.empty? ? nil : [@sock]
|
|
67
|
+
ready_r, ready_w, = IO.select([STDIN, @sock], write_ios, nil, SELECT_TIMEOUT)
|
|
68
|
+
next unless ready_r || ready_w
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
ready_r&.each do |io|
|
|
69
71
|
if io == STDIN
|
|
70
72
|
forward_stdin
|
|
71
73
|
else
|
|
72
74
|
consume_server_frame
|
|
73
75
|
end
|
|
74
76
|
end
|
|
77
|
+
|
|
78
|
+
drain_writes if ready_w&.include?(@sock)
|
|
75
79
|
end
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def forward_stdin
|
|
79
83
|
data = STDIN.read_nonblock(4096)
|
|
80
|
-
|
|
84
|
+
queue_frame(Protocol::INPUT, data)
|
|
81
85
|
rescue IO::WaitReadable
|
|
82
86
|
# spurious wake-up; nothing to do.
|
|
83
87
|
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
84
88
|
@running = false
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
def queue_frame(type, payload)
|
|
92
|
+
@write_buffer << Protocol.frame(type, payload)
|
|
93
|
+
drain_writes
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Push as much of @write_buffer to the server as the socket will
|
|
97
|
+
# accept without blocking. Anything left over stays queued and the
|
|
98
|
+
# event loop picks it back up when select reports the socket
|
|
99
|
+
# writable. Mirrors the server-side OUTPUT drain — without it, a
|
|
100
|
+
# busy server (rendering large vim/Claude-code redraws) could fill
|
|
101
|
+
# this socket's send buffer and wedge the client mid-paste.
|
|
102
|
+
def drain_writes
|
|
103
|
+
return if @write_buffer.empty?
|
|
104
|
+
loop do
|
|
105
|
+
n = @sock.write_nonblock(@write_buffer)
|
|
106
|
+
@write_buffer = @write_buffer.byteslice(n..-1) || +"".b
|
|
107
|
+
break if @write_buffer.empty?
|
|
108
|
+
end
|
|
109
|
+
rescue IO::WaitWritable
|
|
110
|
+
# Socket send buffer full; remainder stays queued.
|
|
111
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
112
|
+
@running = false
|
|
113
|
+
end
|
|
114
|
+
|
|
87
115
|
def consume_server_frame
|
|
88
116
|
type, payload = Protocol.read(@sock)
|
|
89
117
|
if type.nil?
|
|
@@ -106,7 +134,7 @@ module Muxr
|
|
|
106
134
|
|
|
107
135
|
def send_resize
|
|
108
136
|
rows, cols = terminal_size
|
|
109
|
-
|
|
137
|
+
queue_frame(Protocol::RESIZE, Protocol.encode_size(rows, cols))
|
|
110
138
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
111
139
|
@running = false
|
|
112
140
|
end
|
|
@@ -124,12 +152,17 @@ module Muxr
|
|
|
124
152
|
def enter_terminal_mode
|
|
125
153
|
STDIN.raw!
|
|
126
154
|
STDIN.echo = false
|
|
127
|
-
|
|
155
|
+
# Enable bracketed paste on the outer terminal so iTerm/Terminal/etc.
|
|
156
|
+
# wrap pastes with \e[200~...\e[201~. Those markers flow through
|
|
157
|
+
# untouched to the focused pane's PTY, which lets Claude Code, vim,
|
|
158
|
+
# modern bash, etc. recognise the input as a paste and collapse it
|
|
159
|
+
# instead of typing it out character-by-character.
|
|
160
|
+
STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e[?2004h")
|
|
128
161
|
STDOUT.flush
|
|
129
162
|
end
|
|
130
163
|
|
|
131
164
|
def leave_terminal_mode
|
|
132
|
-
STDOUT.write("\e[0m\e[?25h\e[?1049l")
|
|
165
|
+
STDOUT.write("\e[?2004l\e[0m\e[?25h\e[?1049l")
|
|
133
166
|
STDOUT.flush
|
|
134
167
|
begin
|
|
135
168
|
STDIN.cooked!
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -85,19 +85,32 @@ module Muxr
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def feed(data)
|
|
88
|
-
|
|
88
|
+
remaining = data
|
|
89
|
+
until remaining.empty?
|
|
90
|
+
if @state == :idle
|
|
91
|
+
# Fast path for pass-through: forward everything up to the next
|
|
92
|
+
# Ctrl-a as a single chunk so a large paste doesn't turn into one
|
|
93
|
+
# PTY write per byte. PREFIX is single-byte ASCII (\x01) and never
|
|
94
|
+
# appears mid-UTF-8, so byte/char index match.
|
|
95
|
+
idx = remaining.index(PREFIX)
|
|
96
|
+
if idx.nil?
|
|
97
|
+
@app.send_to_focused(remaining)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
@app.send_to_focused(remaining[0...idx]) if idx > 0
|
|
101
|
+
@state = :prefix
|
|
102
|
+
remaining = remaining[(idx + 1)..] || ""
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
ch = remaining[0]
|
|
107
|
+
remaining = remaining[1..] || ""
|
|
89
108
|
case @state
|
|
90
109
|
when :help
|
|
91
110
|
@app.dismiss_help
|
|
92
111
|
@state = :idle
|
|
93
112
|
when :confirm_quit
|
|
94
113
|
handle_confirm_quit(ch)
|
|
95
|
-
when :idle
|
|
96
|
-
if ch == PREFIX
|
|
97
|
-
@state = :prefix
|
|
98
|
-
else
|
|
99
|
-
@app.send_to_focused(ch)
|
|
100
|
-
end
|
|
101
114
|
when :prefix
|
|
102
115
|
handle_prefix(ch)
|
|
103
116
|
when :command
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -20,6 +20,18 @@ module Muxr
|
|
|
20
20
|
@process.io
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def writer_io
|
|
24
|
+
@process.writer_io
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pending_write?
|
|
28
|
+
@process.pending_write?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def drain_writes
|
|
32
|
+
@process.drain
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
def write(data)
|
|
24
36
|
@process.write(data)
|
|
25
37
|
end
|
data/lib/muxr/protocol.rb
CHANGED
|
@@ -39,9 +39,20 @@ module Muxr
|
|
|
39
39
|
|
|
40
40
|
# Writes one framed message. +payload+ is treated as raw bytes (binary).
|
|
41
41
|
def self.write(io, type, payload = "")
|
|
42
|
+
io.write(frame(type, payload))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds the on-the-wire bytes for a single frame without writing them.
|
|
46
|
+
# Lets callers append to an outgoing buffer (for non-blocking flushes
|
|
47
|
+
# later) instead of doing a synchronous io.write.
|
|
48
|
+
def self.frame(type, payload = "")
|
|
42
49
|
raise ArgumentError, "type must be a single byte" unless type.is_a?(String) && type.bytesize == 1
|
|
43
50
|
bytes = payload.to_s.b
|
|
44
|
-
|
|
51
|
+
buf = +"".b
|
|
52
|
+
buf << type.b
|
|
53
|
+
buf << [bytes.bytesize].pack("N")
|
|
54
|
+
buf << bytes
|
|
55
|
+
buf
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
|
data/lib/muxr/pty_process.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Muxr
|
|
|
10
10
|
@rows = rows
|
|
11
11
|
@cols = cols
|
|
12
12
|
@exited = false
|
|
13
|
+
@write_buffer = +"".b
|
|
13
14
|
|
|
14
15
|
shell = command || ENV["SHELL"] || "/bin/sh"
|
|
15
16
|
env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
|
|
@@ -23,11 +24,42 @@ module Muxr
|
|
|
23
24
|
resize(rows, cols)
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# Appends bytes to the per-process outgoing buffer and tries to flush as
|
|
28
|
+
# many as the PTY will accept right now. Any remainder stays buffered;
|
|
29
|
+
# the event loop drains it later when the writer fd is reported writable.
|
|
30
|
+
# This avoids deadlocking the single-threaded server when the inner
|
|
31
|
+
# program is slow to read (large pastes were the original motivating
|
|
32
|
+
# case — see CHANGELOG 0.1.3).
|
|
26
33
|
def write(data)
|
|
27
|
-
@
|
|
28
|
-
|
|
34
|
+
return if @exited
|
|
35
|
+
return if data.nil? || data.empty?
|
|
36
|
+
@write_buffer << data.b
|
|
37
|
+
drain
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Push as much of @write_buffer through the PTY as it'll take without
|
|
41
|
+
# blocking. Safe to call repeatedly — both from write() and from the
|
|
42
|
+
# event loop when select reports the writer fd writable.
|
|
43
|
+
def drain
|
|
44
|
+
return if @exited || @write_buffer.empty?
|
|
45
|
+
loop do
|
|
46
|
+
n = @writer.write_nonblock(@write_buffer)
|
|
47
|
+
@write_buffer = @write_buffer.byteslice(n..-1) || +"".b
|
|
48
|
+
break if @write_buffer.empty?
|
|
49
|
+
end
|
|
50
|
+
rescue IO::WaitWritable
|
|
51
|
+
# Kernel buffer full; the rest stays queued.
|
|
29
52
|
rescue Errno::EIO, IOError, Errno::EPIPE
|
|
30
53
|
@exited = true
|
|
54
|
+
@write_buffer.clear
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending_write?
|
|
58
|
+
!@write_buffer.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def writer_io
|
|
62
|
+
@writer
|
|
31
63
|
end
|
|
32
64
|
|
|
33
65
|
def read_nonblock(max = 8192)
|
data/lib/muxr/version.rb
CHANGED