diff --git a/mycli/ssh_tunnel.py b/mycli/ssh_tunnel.py index 12a742cc..22932ad4 100644 --- a/mycli/ssh_tunnel.py +++ b/mycli/ssh_tunnel.py @@ -187,6 +187,8 @@ def _run(self) -> None: stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + # better insulate the thread from signals, but interactivity + # is disabled start_new_session=True, ) except OSError as exc: @@ -195,7 +197,23 @@ def _run(self) -> None: return return_code = self.process.wait() if return_code != 0 and not self._ready.is_set(): - self._failed.set() + # in case the user needed to enter an SSH password, try again with + # start_new_session=False + try: + self.process = subprocess.Popen( + self.command(), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=False, + ) + except OSError as exc: + self._startup_error = exc + self._failed.set() + return + return_code = self.process.wait() + if return_code != 0 and not self._ready.is_set(): + self._failed.set() def _is_listening(self) -> bool: try: diff --git a/test/pytests/test_ssh_tunnel.py b/test/pytests/test_ssh_tunnel.py index 128c086b..d7a75e3c 100644 --- a/test/pytests/test_ssh_tunnel.py +++ b/test/pytests/test_ssh_tunnel.py @@ -292,6 +292,38 @@ def fail_popen(*_args: Any, **_kwargs: Any) -> None: assert isinstance(excinfo.value.__cause__, FileNotFoundError) +def test_ssh_tunnel_start_reports_interactive_retry_start_error(monkeypatch: pytest.MonkeyPatch) -> None: + start_new_session_calls: list[bool] = [] + + class FakeProcess: + def wait(self, timeout: float | None = None) -> int: + return 255 + + def poll(self) -> int: + return 255 + + def fake_popen(*_args: Any, start_new_session: bool, **_kwargs: Any) -> FakeProcess: + start_new_session_calls.append(start_new_session) + if start_new_session: + return FakeProcess() + raise FileNotFoundError('missing interactive ssh') + + monkeypatch.setattr(ssh_tunnel.subprocess, 'Popen', fake_popen) + monkeypatch.setattr(ssh_tunnel, '_make_local_socket_path', lambda: '/tmp/mycli-ssh.sock') + tunnel = SshTunnel( + ssh_target='bastion', + remote_host='db.internal', + remote_port=3306, + ) + monkeypatch.setattr(tunnel, '_is_listening', lambda: False) + + with pytest.raises(SshTunnelError, match='Unable to start SSH tunnel process: missing interactive ssh') as excinfo: + tunnel.start() + + assert start_new_session_calls == [True, False] + assert isinstance(excinfo.value.__cause__, FileNotFoundError) + + def test_ssh_tunnel_start_reports_timeout(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ssh_tunnel, '_make_local_socket_path', lambda: '/tmp/mycli-ssh.sock') tunnel = SshTunnel(