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.
@@ -10,22 +10,41 @@ module Muxr
10
10
  end
11
11
  end
12
12
 
13
- LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle].freeze
13
+ LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle auto].freeze
14
+
15
+ AUTO_SPIRAL_MIN_COLS = 180
16
+ AUTO_SPIRAL_MIN_ROWS = 30
17
+
18
+ class << self
19
+ attr_writer :auto_spiral_min_cols, :auto_spiral_min_rows
20
+
21
+ def auto_spiral_min_cols
22
+ @auto_spiral_min_cols || AUTO_SPIRAL_MIN_COLS
23
+ end
24
+
25
+ def auto_spiral_min_rows
26
+ @auto_spiral_min_rows || AUTO_SPIRAL_MIN_ROWS
27
+ end
28
+ end
29
+
30
+ DEFAULT_RATIO = 0.5
31
+ RATIO_BOUNDS = (0.1..0.9)
14
32
 
15
33
  module_function
16
34
 
17
- def compute(layout, count, area, focused_index: 0, master_index: 0)
35
+ def compute(layout, count, area, focused_index: 0, master_index: 0, ratio: DEFAULT_RATIO, nmaster: 1)
18
36
  return [] if count <= 0
19
37
  master_index = master_index.clamp(0, count - 1)
20
38
  focused_index = focused_index.clamp(0, count - 1)
21
- case layout
22
- when :tall then tall(count, area, master_index)
23
- when :wide then wide(count, area, master_index)
39
+ ratio = ratio.to_f.clamp(RATIO_BOUNDS)
40
+ case resolve(layout, area)
41
+ when :tall then tall(count, area, master_index, ratio, nmaster)
42
+ when :wide then wide(count, area, master_index, ratio, nmaster)
24
43
  when :columns then columns(count, area)
25
44
  when :rows then rows(count, area)
26
45
  when :grid then grid(count, area)
27
46
  when :spiral then spiral(count, area)
28
- when :centered then centered(count, area, master_index)
47
+ when :centered then centered(count, area, master_index, ratio, nmaster)
29
48
  when :stack then stack(count, area, focused_index)
30
49
  when :monocle then monocle(count, area, focused_index)
31
50
  else
@@ -33,54 +52,52 @@ module Muxr
33
52
  end
34
53
  end
35
54
 
36
- # Master pane on the left taking half the width; remaining panes stack
37
- # vertically on the right, dividing the remaining height evenly.
38
- def tall(count, area, master_index = 0)
39
- master_index = master_index.clamp(0, count - 1)
40
- return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
55
+ def resolve(layout, area)
56
+ return layout unless layout == :auto
57
+ spiral_fits?(area) ? :spiral : :stack
58
+ end
41
59
 
42
- master_w = [area.w / 2, 1].max
43
- stack_w = [area.w - master_w, 1].max
44
- others = (0...count).to_a - [master_index]
45
- slave_count = others.length
46
- base_h = area.h / slave_count
47
- remainder = area.h - base_h * slave_count
60
+ def spiral_fits?(area)
61
+ area.w >= LayoutManager.auto_spiral_min_cols && area.h >= LayoutManager.auto_spiral_min_rows
62
+ end
48
63
 
49
- rects = Array.new(count)
50
- rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
64
+ def split_masters(count, master_index, nmaster)
65
+ ordered = [master_index] + ((0...count).to_a - [master_index])
66
+ n = nmaster.to_i.clamp(1, count)
67
+ [ordered.first(n), ordered.drop(n)]
68
+ end
51
69
 
52
- y = area.y
53
- others.each_with_index do |idx, i|
54
- h = base_h + (i < remainder ? 1 : 0)
55
- rects[idx] = Rect.new(area.x + master_w, y, stack_w, h)
56
- y += h
57
- end
58
- rects
70
+ def master_extent(total, ratio)
71
+ (total * ratio).floor.clamp(1, [total - 1, 1].max)
59
72
  end
60
73
 
61
- # The transpose of `tall`: master pane spans the full width across the top
62
- # half; remaining panes sit side-by-side in the bottom half, dividing the
63
- # remaining width evenly.
64
- def wide(count, area, master_index = 0)
74
+ def tall(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
65
75
  master_index = master_index.clamp(0, count - 1)
66
- return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
76
+ masters, others = split_masters(count, master_index, nmaster)
77
+ rects = Array.new(count)
78
+ if others.empty?
79
+ stack_column(rects, masters, area.x, area.y, area.w, area.h)
80
+ return rects
81
+ end
67
82
 
68
- master_h = [area.h / 2, 1].max
69
- stack_h = [area.h - master_h, 1].max
70
- others = (0...count).to_a - [master_index]
71
- slave_count = others.length
72
- base_w = area.w / slave_count
73
- remainder = area.w - base_w * slave_count
83
+ master_w = master_extent(area.w, ratio)
84
+ stack_column(rects, masters, area.x, area.y, master_w, area.h)
85
+ stack_column(rects, others, area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
86
+ rects
87
+ end
74
88
 
89
+ def wide(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
90
+ master_index = master_index.clamp(0, count - 1)
91
+ masters, others = split_masters(count, master_index, nmaster)
75
92
  rects = Array.new(count)
76
- rects[master_index] = Rect.new(area.x, area.y, area.w, master_h)
77
-
78
- x = area.x
79
- others.each_with_index do |idx, i|
80
- w = base_w + (i < remainder ? 1 : 0)
81
- rects[idx] = Rect.new(x, area.y + master_h, w, stack_h)
82
- x += w
93
+ if others.empty?
94
+ spread_row(rects, masters, area.x, area.y, area.w, area.h)
95
+ return rects
83
96
  end
97
+
98
+ master_h = master_extent(area.h, ratio)
99
+ spread_row(rects, masters, area.x, area.y, area.w, master_h)
100
+ spread_row(rects, others, area.x, area.y + master_h, area.w, [area.h - master_h, 1].max)
84
101
  rects
85
102
  end
86
103
 
@@ -136,30 +153,27 @@ module Muxr
136
153
  rects
137
154
  end
138
155
 
139
- # Three-column master: master occupies the centre column full-height; the
140
- # remaining panes are dealt alternately to a left and a right column and
141
- # stacked within each. With a single slave there is no symmetry to keep, so
142
- # it falls back to a simple master/slave vertical split (like `tall`).
143
- def centered(count, area, master_index = 0)
156
+ def centered(count, area, master_index = 0, ratio = DEFAULT_RATIO, nmaster = 1)
144
157
  master_index = master_index.clamp(0, count - 1)
145
- return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
146
-
147
- others = (0...count).to_a - [master_index]
148
- rects = Array.new(count)
158
+ masters, others = split_masters(count, master_index, nmaster)
159
+ rects = Array.new(count)
160
+ if others.empty?
161
+ stack_column(rects, masters, area.x, area.y, area.w, area.h)
162
+ return rects
163
+ end
149
164
 
165
+ master_w = master_extent(area.w, ratio)
150
166
  if others.length == 1
151
- master_w = [area.w / 2, 1].max
152
- rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
167
+ stack_column(rects, masters, area.x, area.y, master_w, area.h)
153
168
  rects[others[0]] = Rect.new(area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
154
169
  return rects
155
170
  end
156
171
 
157
- master_w = [area.w / 2, 1].max
158
172
  side_w = area.w - master_w
159
173
  left_w = [side_w / 2, 1].max
160
174
  right_w = [side_w - left_w, 1].max
161
175
 
162
- rects[master_index] = Rect.new(area.x + left_w, area.y, master_w, area.h)
176
+ stack_column(rects, masters, area.x + left_w, area.y, master_w, area.h)
163
177
  left = others.select.with_index { |_, i| i.even? }
164
178
  right = others.select.with_index { |_, i| i.odd? }
165
179
  stack_column(rects, left, area.x, area.y, left_w, area.h)
@@ -205,6 +219,18 @@ module Muxr
205
219
  end
206
220
  end
207
221
 
222
+ def spread_row(rects, indices, x, y, total_w, h)
223
+ return if indices.empty?
224
+ base_w = total_w / indices.length
225
+ rem = total_w - base_w * indices.length
226
+ cx = x
227
+ indices.each_with_index do |idx, i|
228
+ w = base_w + (i < rem ? 1 : 0)
229
+ rects[idx] = Rect.new(cx, y, w, h)
230
+ cx += w
231
+ end
232
+ end
233
+
208
234
  # Roughly square grid. Each row stretches its panes to fill the full width
209
235
  # so an underfull bottom row doesn't leave gaps.
210
236
  def grid(count, area)
@@ -0,0 +1,24 @@
1
+ module Muxr
2
+ module MouseReport
3
+ WHEEL_UP = 64
4
+ WHEEL_DOWN = 65
5
+ X10_COORD_MAX = 223
6
+
7
+ module_function
8
+
9
+ def wheel(direction, row:, col:, encoding: :sgr)
10
+ button = direction == :up ? WHEEL_UP : WHEEL_DOWN
11
+ encoding == :sgr ? sgr(button, row, col) : x10(button, row, col)
12
+ end
13
+
14
+ def sgr(button, row, col)
15
+ "\e[<#{button};#{[col, 1].max};#{[row, 1].max}M".b
16
+ end
17
+
18
+ def x10(button, row, col)
19
+ c = col.clamp(1, X10_COORD_MAX)
20
+ r = row.clamp(1, X10_COORD_MAX)
21
+ "\e[M#{(32 + button).chr}#{(32 + c).chr}#{(32 + r).chr}".b
22
+ end
23
+ end
24
+ end
data/lib/muxr/pane.rb CHANGED
@@ -14,6 +14,12 @@ module Muxr
14
14
  class Pane
15
15
  attr_reader :id, :terminal, :process
16
16
  attr_accessor :rect
17
+ # "<session>:<pane id>" when this pane mirrors a pane owned by another muxr
18
+ # server; nil for a locally-owned PTY.
19
+ attr_accessor :origin
20
+ # Smallest viewport any remote mirror is showing this pane through, as
21
+ # [rows, cols], or nil when nobody is mirroring.
22
+ attr_accessor :mirror_size
17
23
  # Last value written by Application's foreground poller thread. nil when
18
24
  # the shell itself is foreground (the common empty-prompt case) or when
19
25
  # the lookup hasn't run / couldn't read. Renderer surfaces this in the
@@ -36,6 +42,17 @@ module Muxr
36
42
  @initial_cwd = cwd || @process.cwd
37
43
  @private_flag = false
38
44
  @foreground_command = nil
45
+ @origin = nil
46
+ @mirror_size = nil
47
+ @activity = false
48
+ @bell = false
49
+ @silent = false
50
+ @silence_after = nil
51
+ hush!
52
+ end
53
+
54
+ def mirror?
55
+ @process.respond_to?(:mirror?) && @process.mirror?
39
56
  end
40
57
 
41
58
  def pid
@@ -89,6 +106,77 @@ module Muxr
89
106
  # etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
90
107
  # frames and is the main source of in-pane flicker. Bounded by a byte cap
91
108
  # so a runaway producer can't starve other panes on a single tick.
109
+ ATTENTION_GRACE = 1.5
110
+ NAME_MAX = 24
111
+
112
+ attr_reader :name
113
+
114
+ def name=(value)
115
+ text = value.to_s.gsub(/[[:cntrl:]]/, "").strip[0, NAME_MAX]
116
+ @name = text.empty? ? nil : text
117
+ end
118
+
119
+ def label
120
+ @name || @id.to_s
121
+ end
122
+
123
+ def self.now
124
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
125
+ end
126
+
127
+ def activity?
128
+ @activity
129
+ end
130
+
131
+ def bell?
132
+ @bell
133
+ end
134
+
135
+ def silent?
136
+ @silent
137
+ end
138
+
139
+ attr_reader :silence_after
140
+
141
+ def note_output(now = Pane.now, attended: false)
142
+ return if now < @quiet_until
143
+ @last_output_at = now
144
+ @silent = false
145
+ @silence_reported = false
146
+ @activity = true unless attended
147
+ end
148
+
149
+ def watch_silence(seconds, now = Pane.now)
150
+ @silence_after = seconds
151
+ @last_output_at = now
152
+ @silent = false
153
+ @silence_reported = false
154
+ end
155
+
156
+ def silence_due?(now = Pane.now)
157
+ return false unless @silence_after
158
+ !@silence_reported && now - @last_output_at >= @silence_after
159
+ end
160
+
161
+ def note_silence!
162
+ @silent = true
163
+ @silence_reported = true
164
+ end
165
+
166
+ def note_bell
167
+ @bell = true
168
+ end
169
+
170
+ def clear_attention!
171
+ @activity = false
172
+ @bell = false
173
+ @silent = false
174
+ end
175
+
176
+ def hush!(now = Pane.now)
177
+ @quiet_until = now + ATTENTION_GRACE
178
+ end
179
+
92
180
  READ_BUDGET = 1 << 20 # 1 MiB
93
181
  def read_from_pty
94
182
  total = 0
@@ -96,13 +184,16 @@ module Muxr
96
184
  chunk = @process.read_nonblock
97
185
  break unless chunk
98
186
  @terminal.feed(chunk)
187
+ yield chunk if block_given?
99
188
  total += chunk.bytesize
100
189
  end
101
190
  # The emulator may owe the inner program a reply (DSR / CPR — see
102
191
  # Terminal#take_pending_replies!). Ship it back through the PTY's
103
192
  # input side as if it had been typed. Failure here is non-fatal: the
104
- # process can have exited between read and write.
105
- if (reply = @terminal.take_pending_replies!)
193
+ # process can have exited between read and write. A mirror stays quiet:
194
+ # the pane's owner already answered, and a second answer would reach the
195
+ # program as spurious keystrokes.
196
+ if (reply = @terminal.take_pending_replies!) && !mirror?
106
197
  begin
107
198
  @process.write(reply)
108
199
  rescue Errno::EIO, Errno::EPIPE
@@ -111,12 +202,43 @@ module Muxr
111
202
  total.positive? ? total : nil
112
203
  end
113
204
 
205
+ # A mirror's grid is sized by the pane's owner: the relayed stream carries
206
+ # absolute cursor addresses for *that* geometry. We only forward the
207
+ # viewport we can offer and wait for the size the owner settles on.
114
208
  def resize(rows, cols)
209
+ if mirror?
210
+ hush! unless rows == @terminal.rows && cols == @terminal.cols
211
+ return @process.resize(rows, cols)
212
+ end
213
+ rows, cols = fit_to_mirrors(rows, cols)
115
214
  return if rows == @terminal.rows && cols == @terminal.cols
215
+ hush!
116
216
  @terminal.resize(rows, cols)
117
217
  @process.resize(rows, cols)
118
218
  end
119
219
 
220
+ def fit_to_mirrors(rows, cols)
221
+ return [rows, cols] unless @mirror_size
222
+ [[rows, @mirror_size[0]].min.clamp(1, rows), [cols, @mirror_size[1]].min.clamp(1, cols)]
223
+ end
224
+
225
+ # Shrink to fit a mirror that just arrived, without waiting for a render —
226
+ # this session may be detached, in which case no render is coming. Growing
227
+ # back is the layout's call on the next frame it draws.
228
+ def clamp_to_mirrors!
229
+ rows, cols = fit_to_mirrors(@terminal.rows, @terminal.cols)
230
+ return if rows == @terminal.rows && cols == @terminal.cols
231
+ hush!
232
+ @terminal.resize(rows, cols)
233
+ @process.resize(rows, cols)
234
+ end
235
+
236
+ # Force the inner program to repaint itself (see PTYProcess#nudge_redraw).
237
+ # Used by the refresh keybinding to recover from emulation drift.
238
+ def request_redraw
239
+ @process.nudge_redraw
240
+ end
241
+
120
242
  def alive?
121
243
  @process.alive?
122
244
  end
@@ -125,6 +247,10 @@ module Muxr
125
247
  @process.cwd || @initial_cwd
126
248
  end
127
249
 
250
+ def relinquish!
251
+ @process.relinquish! if @process.respond_to?(:relinquish!)
252
+ end
253
+
128
254
  def close
129
255
  @process.close
130
256
  end
@@ -0,0 +1,49 @@
1
+ module Muxr
2
+ # Selection state for the attach overlay: a flat, navigable list of every
3
+ # pane offered by the other muxr servers on this machine, with session
4
+ # headings interleaved so the list reads as a grouped tree while the cursor
5
+ # only ever lands on something selectable.
6
+ class PanePicker
7
+ Row = Struct.new(:kind, :session, :entry) do
8
+ def selectable?
9
+ kind == :pane
10
+ end
11
+ end
12
+
13
+ attr_reader :rows, :index
14
+
15
+ def initialize(entries)
16
+ @rows = build_rows(entries)
17
+ @index = @rows.index(&:selectable?) || 0
18
+ end
19
+
20
+ def empty?
21
+ @rows.none?(&:selectable?)
22
+ end
23
+
24
+ def selected
25
+ row = @rows[@index]
26
+ row&.selectable? ? row.entry : nil
27
+ end
28
+
29
+ def move(delta)
30
+ return if empty?
31
+ i = @index
32
+ @rows.length.times do
33
+ i = (i + delta) % @rows.length
34
+ next unless @rows[i].selectable?
35
+ @index = i
36
+ return
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def build_rows(entries)
43
+ entries.group_by(&:session).flat_map do |session, panes|
44
+ [Row.new(:session, session, nil)] +
45
+ panes.map { |entry| Row.new(:pane, session, entry) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,172 @@
1
+ require "json"
2
+ require "socket"
3
+
4
+ module Muxr
5
+ # Takes a pane away from another muxr server for good. Where RemotePane
6
+ # borrows a pane by relaying its bytes, this moves the pane itself: the
7
+ # master pty file descriptor crosses the Unix socket via SCM_RIGHTS, so the
8
+ # shell that was running keeps running, with its jobs, its environment and
9
+ # whatever it had on screen, and simply belongs to us afterwards.
10
+ #
11
+ # Two things shape the wire order. The fd has to arrive *before* the bulky
12
+ # emulator state, because pulling it out of the socket needs recvmsg and any
13
+ # plain read that swallows the carrier byte first drops the fd on the floor.
14
+ # And a short preamble has to arrive before *that*, because a refusal (the
15
+ # pane is private, or the last one in its session) has no fd to send and we
16
+ # would otherwise block forever waiting for one. So: preamble, fd, state.
17
+ #
18
+ # The move is two-phase. Nothing is torn down on the far side until we have
19
+ # a working pane here and say so, and a failure anywhere leaves the pane
20
+ # exactly where it was rather than dropping a live shell between servers.
21
+ module PaneTransfer
22
+ class Error < StandardError; end
23
+
24
+ TIMEOUT = 5.0
25
+
26
+ Result = Struct.new(:pane, :session, keyword_init: true)
27
+
28
+ def self.claim(socket_path:, pane_id:)
29
+ socket = connect(socket_path)
30
+ begin
31
+ request(socket, 1, "pane.move", "pane" => pane_id)
32
+ await_preamble(socket)
33
+ io = receive_fd(socket)
34
+ begin
35
+ state = await_state(socket)
36
+ pane = build_pane(io, state)
37
+ rescue StandardError => e
38
+ io.close rescue nil
39
+ notify(socket, "pane.move_abort")
40
+ raise e.is_a?(Error) ? e : Error.new(e.message)
41
+ end
42
+ request(socket, 2, "pane.move_commit")
43
+ await_result(socket, 2)
44
+ Result.new(pane: pane, session: state["session"].to_s)
45
+ ensure
46
+ socket.close rescue nil
47
+ end
48
+ end
49
+
50
+ def self.build_pane(io, state)
51
+ rows = state["rows"].to_i
52
+ cols = state["cols"].to_i
53
+ raise Error, "owner sent a nonsense pane size (#{rows}x#{cols})" unless rows.positive? && cols.positive?
54
+ process = PTYProcess.new(
55
+ rows: rows, cols: cols,
56
+ adopt_io: io, adopt_pid: state["pid"]
57
+ )
58
+ pane = Pane.new(id: state["pane"].to_s, rows: rows, cols: cols, cwd: state["cwd"], process: process)
59
+ pane.terminal.restore_transfer!(state)
60
+ pane.name = state["name"] if state["name"]
61
+ pane
62
+ end
63
+
64
+ def self.connect(socket_path)
65
+ UNIXSocket.new(socket_path)
66
+ rescue SystemCallError => e
67
+ raise Error, "cannot reach session socket: #{e.message}"
68
+ end
69
+
70
+ def self.request(socket, id, method, params = {})
71
+ socket.write(JSON.generate("id" => id, "method" => method, "params" => params) + "\n")
72
+ rescue SystemCallError, IOError => e
73
+ raise Error, e.message
74
+ end
75
+
76
+ def self.notify(socket, method, params = {})
77
+ socket.write(JSON.generate("method" => method, "params" => params) + "\n")
78
+ rescue SystemCallError, IOError
79
+ # Owner will time the half-finished move out on its own.
80
+ end
81
+
82
+ # Read the preamble a byte at a time. Reading ahead here would be a bug,
83
+ # not an optimization: the very next thing on the socket is the fd, and a
84
+ # buffered read past the newline would consume its carrier byte.
85
+ def self.await_preamble(socket)
86
+ line = +""
87
+ deadline = now + TIMEOUT
88
+ until line.end_with?("\n")
89
+ raise Error, "timed out waiting for the owning session" unless wait_readable(socket, deadline)
90
+ byte = begin
91
+ socket.read_nonblock(1)
92
+ rescue IO::WaitReadable
93
+ next
94
+ rescue EOFError, SystemCallError, IOError
95
+ raise Error, "owning session closed the connection"
96
+ end
97
+ line << byte
98
+ raise Error, "owning session sent an oversized reply" if line.bytesize > 8192
99
+ end
100
+ check(parse(line))
101
+ end
102
+
103
+ def self.receive_fd(socket)
104
+ raise Error, "timed out waiting for the pane's terminal" unless wait_readable(socket, now + TIMEOUT)
105
+ socket.recv_io(IO, "r+")
106
+ rescue SystemCallError, IOError => e
107
+ raise Error, "could not take over the pane's terminal: #{e.message}"
108
+ end
109
+
110
+ # Safe to buffer freely now — the fd is out of the socket and the state
111
+ # line is the last thing the owner sends before we commit.
112
+ def self.await_state(socket)
113
+ buffer = +""
114
+ deadline = now + TIMEOUT
115
+ until (nl = buffer.index("\n"))
116
+ raise Error, "timed out waiting for the pane's contents" unless wait_readable(socket, deadline)
117
+ begin
118
+ buffer << socket.read_nonblock(READ_CHUNK)
119
+ rescue IO::WaitReadable
120
+ next
121
+ rescue EOFError, SystemCallError, IOError
122
+ raise Error, "owning session closed the connection"
123
+ end
124
+ end
125
+ check(parse(buffer.byteslice(0, nl + 1)))
126
+ end
127
+
128
+ def self.await_result(socket, id)
129
+ buffer = +""
130
+ deadline = now + TIMEOUT
131
+ loop do
132
+ while (nl = buffer.index("\n"))
133
+ line = buffer.slice!(0..nl)
134
+ msg = parse(line)
135
+ next unless msg["id"] == id
136
+ return check(msg)
137
+ end
138
+ raise Error, "timed out completing the move" unless wait_readable(socket, deadline)
139
+ begin
140
+ buffer << socket.read_nonblock(READ_CHUNK)
141
+ rescue IO::WaitReadable
142
+ next
143
+ rescue EOFError, SystemCallError, IOError
144
+ raise Error, "owning session closed the connection"
145
+ end
146
+ end
147
+ end
148
+
149
+ READ_CHUNK = 64 * 1024
150
+
151
+ def self.check(msg)
152
+ raise Error, msg["error"]["message"].to_s if msg["error"]
153
+ msg["result"] || {}
154
+ end
155
+
156
+ def self.parse(line)
157
+ JSON.parse(line)
158
+ rescue JSON::ParserError
159
+ raise Error, "owning session sent something that isn't JSON"
160
+ end
161
+
162
+ def self.wait_readable(socket, deadline)
163
+ remaining = deadline - now
164
+ return false if remaining <= 0
165
+ !!IO.select([socket], nil, nil, remaining)
166
+ end
167
+
168
+ def self.now
169
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ end
171
+ end
172
+ end