@vibelord/lanes 1.3.0 → 1.5.1

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.
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Bound the desktop client's terminal backlog; reconnect a slow display, never its session.
3
+
4
+ Zellij 0.45.1 removes a slow client when its 5,000-message queue fills, but
5
+ sends Exit only after draining that queue. Input can wait for an unblock that
6
+ the removed client will never receive. SSH keepalives cannot detect this.
7
+ This relay bounds bytes and age before the terminal can block Zellij's writer.
8
+ """
9
+ import collections
10
+ import errno
11
+ import fcntl
12
+ import os
13
+ import pty
14
+ import select
15
+ import signal
16
+ import sys
17
+ import termios
18
+ import time
19
+ import tty
20
+
21
+ OUTPUT_LIMIT = 1024 * 1024
22
+ OUTPUT_AGE = 5.0
23
+ CHUNK = 65536
24
+ STOP_GRACE = 1.0
25
+ # CAN cancels a partial escape sequence. Leave the alternate screen and restore
26
+ # terminal modes even when the child cannot run its own teardown.
27
+ RESET = (b'\x18\x1b[?2026l\x1b[?1049l\x1b[?1000l\x1b[?1002l\x1b[?1003l'
28
+ b'\x1b[?1006l\x1b[?2004l\x1b[<u\x1b[0m\x1b[?25h')
29
+
30
+
31
+ def relay(command, output_limit=OUTPUT_LIMIT, output_age=OUTPUT_AGE):
32
+ if not command:
33
+ raise ValueError('No display command supplied')
34
+ if not os.isatty(0) or not os.isatty(1):
35
+ raise ValueError('The Lanes display needs a terminal')
36
+ saved = termios.tcgetattr(0)
37
+ size = fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8)
38
+ flags = fcntl.fcntl(1, fcntl.F_GETFL)
39
+ pending = collections.deque() # (time read, remaining bytes); includes partial writes
40
+ pending_size = 0
41
+ input_pending = b''
42
+ child = master = None
43
+ status = None
44
+ stopped = False
45
+ reason = None
46
+
47
+ def stop(_sig, _frame):
48
+ nonlocal stopped
49
+ stopped = True
50
+
51
+ def resize(_sig=None, _frame=None):
52
+ # forkpty returns -1 in the child. The parent's first resize can send
53
+ # SIGWINCH before that child has restored its inherited handlers.
54
+ if master is not None and master >= 0:
55
+ try:
56
+ fcntl.ioctl(master, termios.TIOCSWINSZ,
57
+ fcntl.ioctl(0, termios.TIOCGWINSZ, b'\0' * 8))
58
+ except OSError:
59
+ pass # the terminal may have closed while SIGWINCH was pending
60
+
61
+ signals = (signal.SIGHUP, signal.SIGTERM, signal.SIGINT, signal.SIGWINCH)
62
+ handlers = {sig: signal.getsignal(sig) for sig in signals}
63
+ try:
64
+ # Install before fork: a close during startup must still reap our child.
65
+ for sig in signals:
66
+ signal.signal(sig, resize if sig == signal.SIGWINCH else stop)
67
+ tty.setraw(0)
68
+ child, master = pty.fork()
69
+ if child == 0:
70
+ for sig in signals:
71
+ signal.signal(sig, signal.SIG_DFL)
72
+ try:
73
+ # Give the first render the real dimensions, before exec can
74
+ # race the parent's initial SIGWINCH forwarding.
75
+ fcntl.ioctl(0, termios.TIOCSWINSZ, size)
76
+ os.execvpe(command[0], command, os.environ)
77
+ except OSError as error:
78
+ os.write(2, f'lanes: cannot start display: {error}\r\n'.encode())
79
+ os._exit(127)
80
+ os.set_blocking(master, False)
81
+ fcntl.fcntl(1, fcntl.F_SETFL, flags | os.O_NONBLOCK)
82
+ resize()
83
+ eof = False
84
+ while not stopped:
85
+ if status is None:
86
+ pid, value = os.waitpid(child, os.WNOHANG)
87
+ if pid:
88
+ status = value
89
+ if pending and time.monotonic() - pending[0][0] >= output_age:
90
+ reason = 'display output stalled'
91
+ break
92
+ if eof and not pending:
93
+ break
94
+ readers = ([] if eof else [master]) + ([] if input_pending or status is not None else [0])
95
+ writers = ([1] if pending else []) + ([master] if input_pending and not eof else [])
96
+ readable, writable, _ = select.select(readers, writers, [], 0.05)
97
+ # Drain first: a writable terminal should not trip the byte limit.
98
+ if 1 in writable:
99
+ at, data = pending[0]
100
+ try:
101
+ written = os.write(1, data)
102
+ except BlockingIOError:
103
+ written = 0
104
+ pending_size -= written
105
+ if written == len(data):
106
+ pending.popleft()
107
+ elif written:
108
+ pending[0] = (at, data[written:])
109
+ if master in writable:
110
+ try:
111
+ written = os.write(master, input_pending)
112
+ input_pending = input_pending[written:]
113
+ except BlockingIOError:
114
+ pass
115
+ if 0 in readable:
116
+ input_pending = os.read(0, CHUNK)
117
+ if not input_pending:
118
+ break
119
+ if master in readable:
120
+ try:
121
+ data = os.read(master, CHUNK)
122
+ except BlockingIOError:
123
+ continue
124
+ except OSError as error:
125
+ if error.errno != errno.EIO:
126
+ raise
127
+ data = b'' # Linux PTYs report EIO when the slave closes
128
+ if not data:
129
+ eof = True
130
+ elif pending_size + len(data) > output_limit:
131
+ reason = 'display output exceeded its backlog limit'
132
+ break
133
+ else:
134
+ pending.append((time.monotonic(), data))
135
+ pending_size += len(data)
136
+ finally:
137
+ if child and status is None:
138
+ # Signal only our unreaped child, never a session server or holder.
139
+ try:
140
+ os.kill(child, signal.SIGTERM)
141
+ except ProcessLookupError:
142
+ pass
143
+ deadline = time.monotonic() + STOP_GRACE
144
+ while time.monotonic() < deadline:
145
+ pid, value = os.waitpid(child, os.WNOHANG)
146
+ if pid:
147
+ status = value
148
+ break
149
+ time.sleep(0.02)
150
+ if status is None:
151
+ try:
152
+ os.kill(child, signal.SIGKILL)
153
+ except ProcessLookupError:
154
+ pass
155
+ _, status = os.waitpid(child, 0)
156
+ if master is not None:
157
+ os.close(master)
158
+ # Never block cleanup on the display that caused the timeout. The next
159
+ # attach repaints from current session state; queued stale output dies here.
160
+ ending = RESET
161
+ if reason:
162
+ ending += f'\r\nlanes: {reason}; reconnecting the display.\r\n'.encode()
163
+ try:
164
+ os.write(1, ending)
165
+ except OSError:
166
+ pass
167
+ try:
168
+ termios.tcsetattr(0, termios.TCSANOW, saved)
169
+ finally:
170
+ fcntl.fcntl(1, fcntl.F_SETFL, flags)
171
+ for sig, handler in handlers.items():
172
+ signal.signal(sig, handler)
173
+ # 1 is a reconnectable failure in lanes-launch; 64–78 impose a 60 s hold.
174
+ return 1 if reason or stopped else (os.waitstatus_to_exitcode(status) if status is not None else 0)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ try:
179
+ sys.exit(relay(sys.argv[1:]))
180
+ except (OSError, ValueError) as error:
181
+ sys.exit(f'lanes: display attach failed: {error}')
@@ -26,7 +26,9 @@ Trace keys: `cc-session/<id>` and `codex-session/<id>` (first two segments of th
26
26
 
27
27
  The poller (plan Task 11, opt-in `lanes-economy.service`; enabling it is an operator gate because it reads the
28
28
  production daemon's loopback control port): every `--interval` seconds it GETs `/status` and `/sessions` (3 s
29
- timeout, optional bearer from `LANES_THROTTLE_TOKEN_FILE`), folds today's log through `day_totals`, and writes
29
+ timeout, bearer from `LANES_THROTTLE_TOKEN_FILE`: both routes are guarded control reads, and the daemon's ControlAuth
30
+ answers 401 to a request without the bearer whatever the bind — there is no loopback exemption on the control port,
31
+ DEF-1930), folds today's log through `day_totals`, and writes
30
32
  `STATE/economy.json` (schema `v: 1`, additive) only when the payload changed apart from `at` — or when the file's
31
33
  `at` is HEARTBEAT seconds old, so `at` is a liveness heartbeat the sidebar ages with its own clock (DEF-1528); the
32
34
  cursor lives in `STATE/economy.cursor.json`. `LANES_THROTTLE_CONTROL=none` disables every daemon request (the log fold still runs);
@@ -442,7 +444,9 @@ def control_url(env):
442
444
 
443
445
 
444
446
  def read_token(env):
445
- """The optional bearer (loopback without `remote_key` needs none). Never logged, never written anywhere."""
447
+ """The bearer, or None when no file is named. `/status` and `/sessions` are guarded control routes: the daemon's
448
+ ControlAuth (its `remote_key` or the claude pool's managed local client token) answers 401 without one, loopback
449
+ or not (DEF-1930), so a poll without it degrades to `HTTP 401`. Never logged, never written anywhere."""
446
450
  path = env.get('LANES_THROTTLE_TOKEN_FILE')
447
451
  if not path:
448
452
  return None