Skip to content

Commit 026abde

Browse files
NiveditJainclaude
andauthored
[luv-38] Restore the terminal when a connection dies mid-session (#38)
* [luv-38] Restore the terminal when a connection dies mid-session A dropped SSH connection left the local terminal in the remote program's modes: mouse tracking on, so every mouse move typed "35;22;1M" at the shell prompt, plus bracketed paste, a stuck alternate screen and raw mode. tmux and the agent TUI turn those on and only turn them off when they exit cleanly; a broken pipe skips that, and exec_ssh's execv meant luv had already become ssh, so nothing of ours was left alive to clean up. Add hand_over(), which keeps luv alive as the child's parent purely to restore the terminal afterwards — termios settings plus the DEC private modes only the remote program knew it had set. The child stays in the same foreground process group, so Ctrl-C, SIGWINCH and exit-code passthrough behave as they did under execv. The -nit pipe path keeps plain execv: no terminal there to leave dirty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012UYshGx4wKQ4covMGukc4r * [luv-38] Bump version to 0.2.1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012UYshGx4wKQ4covMGukc4r --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c29ff4a commit 026abde

4 files changed

Lines changed: 240 additions & 10 deletions

File tree

docs/remote-sessions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,19 @@ happen. Type the prompt into the running agent instead.
343343
It shows the last known state rather than pruning; nothing is lost. When a host
344344
is gone for good, `luv ls --prune` forgets its entries.
345345

346+
**Junk like `35;22;1M` appears at my prompt after a dropped connection**
347+
348+
That's mouse-tracking reports. The remote tmux/agent turned mouse tracking on
349+
in *your* terminal and, having been killed along with the connection, never
350+
turned it off — so every mouse move now types coordinates at your shell.
351+
Bracketed paste (`200~` around pastes), a missing cursor, and a stuck
352+
alternate screen come from the same cause.
353+
354+
luv now cleans this up itself: it stays alive as the parent of `ssh` and
355+
restores the terminal whatever happens to the connection. If you land in this
356+
state from something else, `reset` (or `stty sane` plus
357+
`printf '\033[?1003l\033[?1006l\033[?2004l'`) clears it.
358+
346359
**A session is wedged**
347360

348361
```bash

luv/__init__.py

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
import time
1313
from pathlib import Path
1414

15+
try:
16+
import termios
17+
except ImportError: # not POSIX — the ssh/tmux handoffs don't run there either
18+
termios = None
19+
1520
LUV_DIR = Path.home() / ".luv"
1621
CONFIG_FILE = LUV_DIR / "config.json"
1722
SESSIONS_FILE = LUV_DIR / "sessions.json"
@@ -1153,21 +1158,109 @@ def open_pr(org: str, repo: str, number: int, prompt: str | None, nav_mode: bool
11531158
launch(clone_dir, prompt, plan_mode=plan_mode, non_interactive=non_interactive, extra_env=extra_env, model=model, agent=agent)
11541159

11551160

1156-
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True) -> None:
1157-
"""Hand the terminal to ssh, replacing this process.
1161+
# Terminal modes a full-screen program switches on and is expected to switch
1162+
# off again on its way out. A connection that dies mid-session never gets to,
1163+
# and the leftovers are user-visible: mouse tracking turns every mouse move
1164+
# into "35;22;1M" junk at the shell prompt, bracketed paste wraps pastes in
1165+
# "200~", and the alternate screen swallows the scrollback.
1166+
TERM_RESET = (
1167+
"\x1b[?1000l\x1b[?1001l\x1b[?1002l\x1b[?1003l" # mouse tracking off
1168+
"\x1b[?1004l" # focus reporting off
1169+
"\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" # mouse report encodings off
1170+
"\x1b[?2004l" # bracketed paste off
1171+
"\x1b[?1049l" # back to the primary screen
1172+
"\x1b[?1l\x1b>" # normal cursor keys, keypad
1173+
"\x1b[r" # full-height scroll region
1174+
"\x1b[?7h\x1b[4l\x1b[?25h\x1b[0m" # wrap, replace, cursor, colours
1175+
)
1176+
1177+
1178+
def terminal_fd() -> int | None:
1179+
"""The fd our terminal is on, or None when there isn't one.
1180+
1181+
stdin first: it is the one a redirect is least likely to have taken away,
1182+
and termios wants the terminal device rather than whichever stream happens
1183+
to still point at it.
1184+
"""
1185+
for stream in (sys.stdin, sys.stdout, sys.stderr):
1186+
try:
1187+
fd = stream.fileno()
1188+
except (AttributeError, ValueError, OSError):
1189+
continue
1190+
if os.isatty(fd):
1191+
return fd
1192+
return None
1193+
1194+
1195+
@contextlib.contextmanager
1196+
def terminal_guard():
1197+
"""Restore the terminal on the way out, however we get there.
11581198
1159-
execv (rather than subprocess.run) is what gives correct TTY handling,
1160-
Ctrl-C, and exit-code passthrough for free — same as the local agent paths.
1199+
Two layers, because a killed program skips two different kinds of cleanup:
1200+
termios settings (raw mode, echo) that ssh itself normally puts back, and
1201+
the DEC private modes the *remote* program turned on, which nothing on this
1202+
side knows about. Both are cheap no-ops when nothing was broken.
1203+
"""
1204+
fd = terminal_fd()
1205+
saved = None
1206+
if fd is not None and termios is not None:
1207+
with contextlib.suppress(termios.error, OSError):
1208+
saved = termios.tcgetattr(fd)
1209+
try:
1210+
yield
1211+
finally:
1212+
if fd is not None:
1213+
for stream in (sys.stdout, sys.stderr):
1214+
with contextlib.suppress(ValueError, OSError):
1215+
stream.flush()
1216+
if saved is not None:
1217+
with contextlib.suppress(termios.error, OSError):
1218+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
1219+
with contextlib.suppress(OSError):
1220+
os.write(fd, TERM_RESET.encode())
1221+
1222+
1223+
def hand_over(argv: list[str], *, restore: bool = True) -> None:
1224+
"""Give the terminal to a child and exit with its status. Never returns.
1225+
1226+
Without `restore` this is a plain execv, which is the better deal when
1227+
there is no terminal to leave in a bad state: no process in the middle, and
1228+
TTY handling, Ctrl-C and the exit code all pass through for free.
1229+
1230+
With it, we stay alive as a parent whose only job is to clean up. That
1231+
costs one process and buys the case this exists for — ssh dying on a broken
1232+
pipe, taking a remote tmux and its agent TUI with it, with nobody left to
1233+
turn mouse tracking back off. The child is still in our foreground process
1234+
group, so it keeps receiving Ctrl-C and SIGWINCH from the terminal driver
1235+
exactly as it did under execv.
11611236
"""
1237+
if not restore:
1238+
os.execv(argv[0], argv)
1239+
else:
1240+
with terminal_guard():
1241+
proc = subprocess.Popen(argv)
1242+
while True:
1243+
try:
1244+
code = proc.wait()
1245+
break
1246+
except KeyboardInterrupt:
1247+
# The child got this same Ctrl-C from the tty and decides
1248+
# for itself what to do with it; outliving it is the point.
1249+
continue
1250+
sys.exit(code)
1251+
1252+
1253+
def exec_ssh(hc: dict, remote_cmd: str, *, tty: bool = True) -> None:
1254+
"""Hand the terminal to ssh. Never returns."""
11621255
ssh_bin = shutil.which("ssh")
11631256
if not ssh_bin:
11641257
die("'ssh' not found in PATH")
11651258
argv = ssh_base(hc, tty=tty) + [remote_shell(remote_cmd)]
1166-
os.execv(ssh_bin, [ssh_bin] + argv[1:])
1259+
hand_over([ssh_bin] + argv[1:], restore=tty)
11671260

11681261

11691262
def attach_session(hc: dict | None, name: str) -> None:
1170-
"""Attach to a tmux session, locally or over ssh. Replaces this process.
1263+
"""Attach to a tmux session, locally or over ssh. Never returns.
11711264
11721265
-d detaches other clients so the pane isn't size-clamped to a stale window
11731266
left open elsewhere; these are all the same user's sessions.
@@ -1176,7 +1269,8 @@ def attach_session(hc: dict | None, name: str) -> None:
11761269
tmux_bin = shutil.which("tmux")
11771270
if not tmux_bin:
11781271
die("'tmux' not found in PATH")
1179-
os.execv(tmux_bin, [tmux_bin, "attach", "-d", "-t", name])
1272+
hand_over([tmux_bin, "attach", "-d", "-t", name])
1273+
return
11801274
print(f"luv: attaching {name} on {hc['host']}")
11811275
exec_ssh(hc, shlex.join(["tmux", "attach", "-d", "-t", name]))
11821276

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "luv-cli"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
99
requires-python = ">=3.10"
1010
license = "MIT"

tests/test_agents.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,11 @@ def _dispatch(self, argv, env=None):
122122
with (patch.object(sys, "argv", ["luv"] + argv),
123123
patch.dict(luv.os.environ, env, clear=False),
124124
patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
125-
patch.object(luv.os, "execv") as execv,
125+
patch.object(luv, "hand_over") as hand_over,
126126
contextlib.redirect_stdout(io.StringIO()),
127127
contextlib.redirect_stderr(io.StringIO())):
128128
luv.main()
129-
return execv.call_args.args[1] if execv.called else None
129+
return hand_over.call_args.args[0] if hand_over.called else None
130130

131131
def test_new_workspace_gets_pending_session(self):
132132
argv = self._dispatch(["myrepo", "fix it"])
@@ -219,6 +219,129 @@ def test_local_flag_rejects_ssh_flags(self):
219219
self._dispatch(["--local", "-s", "gpu", "myrepo"])
220220

221221

222+
class _FakeProc:
223+
"""A child that can raise KeyboardInterrupt before it finally exits."""
224+
225+
def __init__(self, returncode=0, interrupts=0):
226+
self.returncode = returncode
227+
self.interrupts = interrupts
228+
229+
def wait(self):
230+
if self.interrupts:
231+
self.interrupts -= 1
232+
raise KeyboardInterrupt
233+
return self.returncode
234+
235+
236+
class TerminalRestoreTests(unittest.TestCase):
237+
"""A connection that dies must not leave the terminal in the remote
238+
program's modes — that's the "35;22;1M" junk at the shell prompt."""
239+
240+
FD = 42
241+
SAVED = ["saved", "termios", "attrs"]
242+
243+
def _hand_over(self, argv, returncode=0, interrupts=0, **kwargs):
244+
proc = _FakeProc(returncode, interrupts)
245+
self.writes = []
246+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
247+
patch.object(luv.subprocess, "Popen", return_value=proc) as popen,
248+
patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
249+
patch.object(luv.termios, "tcsetattr") as tcsetattr,
250+
patch.object(luv.os, "execv") as execv,
251+
patch.object(luv.os, "write",
252+
side_effect=lambda fd, data: self.writes.append((fd, data))),
253+
self.assertRaises(SystemExit) as exit_ctx):
254+
luv.hand_over(argv, **kwargs)
255+
self.popen, self.tcsetattr, self.execv = popen, tcsetattr, execv
256+
return exit_ctx.exception.code
257+
258+
def _reset_bytes(self):
259+
return b"".join(data for fd, data in self.writes if fd == self.FD)
260+
261+
def test_broken_connection_still_restores_the_terminal(self):
262+
# 255 is what ssh exits with when the connection drops under it.
263+
code = self._hand_over(["/bin/ssh", "box", "tmux attach"], returncode=255)
264+
265+
self.assertEqual(code, 255, "the child's exit code must still pass through")
266+
self.assertTrue(self.popen.called, "restore mode must not exec the child away")
267+
self.assertIn(b"\x1b[?1003l", self._reset_bytes(), "mouse tracking left on")
268+
self.assertIn(b"\x1b[?1006l", self._reset_bytes(), "SGR mouse reports left on")
269+
self.assertIn(b"\x1b[?2004l", self._reset_bytes(), "bracketed paste left on")
270+
self.assertIn(b"\x1b[?1049l", self._reset_bytes(), "alternate screen left on")
271+
self.assertEqual(self.tcsetattr.call_args.args[0], self.FD)
272+
self.assertEqual(self.tcsetattr.call_args.args[2], self.SAVED)
273+
274+
def test_clean_exit_restores_too(self):
275+
code = self._hand_over(["/bin/tmux", "attach"], returncode=0)
276+
277+
self.assertEqual(code, 0)
278+
self.assertIn(b"\x1b[?1003l", self._reset_bytes())
279+
280+
def test_ctrl_c_does_not_kill_the_parent_before_cleanup(self):
281+
# Ctrl-C reaches the child through the tty; the parent must outlive it
282+
# or there is nobody left to clean up after it.
283+
code = self._hand_over(["/bin/ssh", "box"], returncode=130, interrupts=2)
284+
285+
self.assertEqual(code, 130)
286+
self.assertIn(b"\x1b[?1003l", self._reset_bytes())
287+
288+
def test_no_tty_handoff_still_execs(self):
289+
# -nit streams stream-json into a pipe: no terminal to restore, so keep
290+
# the cheaper exec and don't leave a process in the middle.
291+
writes = []
292+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
293+
patch.object(luv.os, "execv") as execv,
294+
patch.object(luv.os, "write", side_effect=writes.append),
295+
patch.object(luv.subprocess, "Popen") as popen):
296+
luv.hand_over(["/bin/ssh", "box"], restore=False)
297+
298+
self.assertEqual(execv.call_args.args, ("/bin/ssh", ["/bin/ssh", "box"]))
299+
self.assertFalse(popen.called)
300+
self.assertEqual(writes, [])
301+
302+
def test_guard_is_a_noop_without_a_terminal(self):
303+
with (patch.object(luv, "terminal_fd", return_value=None),
304+
patch.object(luv.os, "write") as write,
305+
patch.object(luv.termios, "tcsetattr") as tcsetattr):
306+
with luv.terminal_guard():
307+
pass
308+
309+
self.assertFalse(write.called)
310+
self.assertFalse(tcsetattr.called)
311+
312+
def test_restore_survives_a_child_that_never_started(self):
313+
# An OSError out of Popen must not skip the cleanup either.
314+
writes = []
315+
with (patch.object(luv, "terminal_fd", return_value=self.FD),
316+
patch.object(luv.termios, "tcgetattr", return_value=self.SAVED),
317+
patch.object(luv.termios, "tcsetattr"),
318+
patch.object(luv.os, "write",
319+
side_effect=lambda fd, data: writes.append(data)),
320+
patch.object(luv.subprocess, "Popen", side_effect=OSError("boom"))):
321+
with self.assertRaises(OSError):
322+
luv.hand_over(["/bin/ssh", "box"])
323+
324+
self.assertIn(b"\x1b[?1003l", b"".join(writes))
325+
326+
def test_local_attach_goes_through_the_guard(self):
327+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
328+
patch.object(luv, "hand_over") as hand_over):
329+
luv.attach_session(None, "luv-myrepo-42")
330+
331+
self.assertEqual(hand_over.call_args.args[0],
332+
["/bin/tmux", "attach", "-d", "-t", "luv-myrepo-42"])
333+
334+
def test_remote_attach_asks_for_a_tty_and_restores(self):
335+
with (patch.object(luv.shutil, "which", side_effect=lambda n: f"/bin/{n}"),
336+
patch.object(luv, "hand_over") as hand_over,
337+
contextlib.redirect_stdout(io.StringIO())):
338+
luv.attach_session({"host": "box"}, "luv-myrepo-42")
339+
340+
argv = hand_over.call_args.args[0]
341+
self.assertIn("-t", argv)
342+
self.assertNotEqual(hand_over.call_args.kwargs.get("restore"), False)
343+
344+
222345
class SessionNameTests(unittest.TestCase):
223346
def test_illegal_tmux_characters_are_replaced(self):
224347
self.assertEqual(luv.tmux_session_name("foo.js-7"), "luv-foo_js-7")

0 commit comments

Comments
 (0)