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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab53a383e7781f83ff106e503047cf5952b12193d0d65e2b29631f2beda6bcf5
4
- data.tar.gz: 0fcef17549ae1745d76fe530a97f12740c9556704bbe724f1b4690932c7cabc5
3
+ metadata.gz: d19432b15ff535a2ce8f4bfb5b759ddd3599b4fb0af949d7871cf1059497c483
4
+ data.tar.gz: ce58f726a5a9dc186deb042c0d0e31fa8694676d0cdb4c496cfb57668d5fd4c1
5
5
  SHA512:
6
- metadata.gz: 4440cb89e4295407dce42ca26c474c64441ff556cb285769b085e0474448132085d9bb6ad9af91699e6349a915c90fe6ebd33825d7ee68f315cafc26ccac5244
7
- data.tar.gz: e17c2f092dcd76ca617bfbfd6acdf1e5475ea9fd7afe0a7458539af95eaa32b0282d499a094f19a6887b6fd228f03d470d5a8c6b9b167b9838e6989edd20ee74
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
@@ -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; ships one OUTPUT frame to the
405
- # currently attached client. No-op when nobody is attached.
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
- sock = @current_client
408
- return unless sock
409
- Protocol.write(sock, Protocol::OUTPUT, bytes)
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
- ready_ios = [@listening_socket]
483
- ready_ios << @current_client if @current_client
484
- @session.window.panes.each { |p| ready_ios << p.io if p.alive? }
485
- if @session.drawer&.pane && @session.drawer.pane.alive?
486
- ready_ios << @session.drawer.pane.io
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
- ready, = IO.select(ready_ios, nil, nil, timeout)
491
-
492
- if ready
493
- ready.each do |io|
494
- if io == @listening_socket
495
- accept_client
496
- elsif io == @current_client
497
- consume_client_frame
498
- else
499
- consume_pane_io(io)
500
- end
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
- ready, = IO.select([STDIN, @sock], nil, nil, SELECT_TIMEOUT)
66
- next unless ready
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
- ready.each do |io|
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
- Protocol.write(@sock, Protocol::INPUT, data)
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
- Protocol.write(@sock, Protocol::RESIZE, Protocol.encode_size(rows, cols))
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
- STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
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!
@@ -85,19 +85,32 @@ module Muxr
85
85
  end
86
86
 
87
87
  def feed(data)
88
- data.each_char do |ch|
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
- io.write(type + [bytes.bytesize].pack("N") + bytes)
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.
@@ -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
- @writer.write(data)
28
- @writer.flush
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
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.3"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc