@vibelord/lanes 1.5.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.
package/package.json
CHANGED
package/payload/lanes/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.5.
|
|
1
|
+
1.5.1
|
package/payload/lanes/bin/lanes
CHANGED
|
@@ -1127,8 +1127,8 @@ def cmd_attach(args):
|
|
|
1127
1127
|
# Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
|
|
1128
1128
|
record_base(device, args)
|
|
1129
1129
|
record_look(device, args)
|
|
1130
|
-
# Views idle until a real client (with a real size) has attached once. We exec into the
|
|
1131
|
-
#
|
|
1130
|
+
# Views idle until a real client (with a real size) has attached once. We exec into the display
|
|
1131
|
+
# supervisor below, so a detached helper raises the flag shortly after its client has sized the session.
|
|
1132
1132
|
ready = L.STATE / 'clients' / f'{device}.ready'
|
|
1133
1133
|
if not ready.exists() and os.fork() == 0:
|
|
1134
1134
|
os.setsid()
|
|
@@ -1163,7 +1163,11 @@ def cmd_attach(args):
|
|
|
1163
1163
|
'COLORTERM': os.environ.get('COLORTERM', 'truecolor')})
|
|
1164
1164
|
if L.run_bounded(['infocmp', env['TERM']], 5)[0] != 0: # unknown here — or no answer: the safe default
|
|
1165
1165
|
env['TERM'] = 'xterm-256color'
|
|
1166
|
-
|
|
1166
|
+
# Own the terminal backlog as well as SSH's transport. A slow Zellij 0.45.1
|
|
1167
|
+
# client can stay alive after its server has dropped it (Exit is behind the
|
|
1168
|
+
# queued frames); keepalives alone cannot make lanes-launch reconnect it.
|
|
1169
|
+
os.execve(sys.executable, [sys.executable, str(L.REPO / 'libexec/attach.py'),
|
|
1170
|
+
*ZELLIJ_BASE, 'attach', device], env)
|
|
1167
1171
|
|
|
1168
1172
|
|
|
1169
1173
|
def cmd_open(args):
|
|
@@ -711,6 +711,23 @@ identity so folder permissions work. On Linux it is a systemd user service that
|
|
|
711
711
|
user manager holds, looked up before every login, so an agent a desktop session hands over after boot is used
|
|
712
712
|
without a restart. The installer's remembered `--no-uploads` / `--no-desktop` remove what an earlier install added.
|
|
713
713
|
|
|
714
|
+
Desktop `lanes attach` execs `libexec/attach.py`, a PTY supervisor for exactly one Zellij client. It relays raw
|
|
715
|
+
input and output with nonblocking writes, forwards the terminal size, and restores terminal modes on exit.
|
|
716
|
+
`OUTPUT_LIMIT` (1 MiB) and `OUTPUT_AGE` (5 s) bound output waiting for the SSH terminal; no output means no timeout.
|
|
717
|
+
Crossing either bound terminates only its child, with `STOP_GRACE` (1 s) before KILL, and exits 1 for the existing
|
|
718
|
+
`lanes-launch` reconnect loop. The view server, its panes and their tmux/zmx holders remain alive. The existing
|
|
719
|
+
`clients/<device>.<client>.pid` now names the supervisor; its argv still carries the exact Zellij attach command,
|
|
720
|
+
so stale-predecessor cleanup reaches the supervisor and its owned child. No new durable state is introduced.
|
|
721
|
+
|
|
722
|
+
This closes the desktop failure observed on Anarchy on 2026-10-16 (DEF-1965). In the pinned Zellij 0.45.1,
|
|
723
|
+
[`ClientSender`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-server/src/os_input_output.rs) removes a
|
|
724
|
+
client when its 5,000-message queue fills, but sends Exit only after draining queued messages. The
|
|
725
|
+
[`input handler`](https://github.com/zellij-org/zellij/blob/v0.45.1/zellij-client/src/input_handler.rs) can then wait
|
|
726
|
+
indefinitely for an action acknowledgement. The transport remains alive while the display cannot interact.
|
|
727
|
+
The supervisor bounds terminal backpressure before that can strand an attachment; it does not patch upstream
|
|
728
|
+
Zellij or cover its separate browser transport. `tests/attach_backpressure.py` proves the unread-terminal control,
|
|
729
|
+
bounded disconnect, holder survival and keyboard switching after reattachment with the installed pin.
|
|
730
|
+
|
|
714
731
|
The sidebar sends an upload request naming paths, workspace and device. The client checks its folder allowlist,
|
|
715
732
|
streams an archive over SSH, and reports original errors and progress. The host extracts data into that
|
|
716
733
|
workspace's `uploads/` folder without overwriting files; an optional paste places the resulting paths in the
|
|
@@ -378,7 +378,16 @@ lanes studio-host re-key --yes after the owner revoked the Host: a new mac
|
|
|
378
378
|
stderr. Writes are atomic and only on a change; the file stays 0600 and `~/.ssh` 0700. A non-default sshd
|
|
379
379
|
`AuthorizedKeysFile` does not read this block.
|
|
380
380
|
|
|
381
|
-
- **A
|
|
381
|
+
- **A desktop stops accepting keys while SSH stays connected:** reconnect that window's display first (close and
|
|
382
|
+
reopen the Lanes window, or terminate only its verified SSH display child so `lanes-launch` retries). Do not
|
|
383
|
+
restart holders or containers. `lanes attach` now supervises its Zellij client with `libexec/attach.py`: output
|
|
384
|
+
waiting to reach the terminal is limited to 1 MiB and an oldest pending chunk age of 5 s. Either bound ends only
|
|
385
|
+
that client (TERM, then KILL after 1 s if needed), exits 1, and lets `lanes-launch` reconnect to the existing view.
|
|
386
|
+
Idle terminals have no deadline; input, resizing and normal exit codes pass through. A client that cannot drain
|
|
387
|
+
its terminal cannot be rescued by SSH keepalives alone (DEF-1965). This guard covers desktop SSH attachments;
|
|
388
|
+
Zellij's browser transport does not use it. Already attached desktops pick it up on their next attachment.
|
|
389
|
+
- **A view's layout looks wrong:** `lanes rebuild mac`. Views are disposable; agents are untouched; the client
|
|
390
|
+
reconnects. Rebuilding also ends that view's Host tab shell, so prefer a display reconnect for a frozen client.
|
|
382
391
|
- **A lane was added/renamed/removed in a repo's `.vscode/terminals.json`:** nothing to do. `lanes-sync.timer`
|
|
383
392
|
re-reads registries every 2 minutes and the sidebar follows. The zellij layout is never edited live. A sync that
|
|
384
393
|
finds nothing new leaves `registry.json` (and its `generated_at`) untouched and only records the time in
|
|
@@ -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}')
|
package/payload/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibelord/lanes",
|
|
3
|
-
"version": "1.5.
|
|
4
|
-
"commit": "
|
|
5
|
-
"digest": "sha256:
|
|
3
|
+
"version": "1.5.1",
|
|
4
|
+
"commit": "3b7ac2e84a23ed47f2ab3f404232286c47d3f2e8",
|
|
5
|
+
"digest": "sha256:1647256dba4c8075b45ba74c973e4ccaf00ae74e2f572f5678c16e7ad1f682bc",
|
|
6
6
|
"executables": [
|
|
7
7
|
"bin/lanes",
|
|
8
8
|
"client/install.sh",
|