@@ -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+
222345class 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