Skip to content

Commit 22c615d

Browse files
pluskymimi1vx
authored andcommitted
fix(connection): bound SSH connect with connection_timeout; read it from [connection]
paramiko's client.connect() was called with no timeout, so a dead/firewalled refhost stalled on the OS TCP timeout — a bulk add_host fanning out across the testplatform appeared to hang for minutes even though the connect phase is already parallel. Pass connection_timeout as paramiko's timeout / banner_timeout / auth_timeout so the connect, banner and auth phases are bounded by the same knob that already bounds remote command execution. Also read connection_timeout from the [connection] section (falling back to the legacy [mtui] location) so the value takes effect where it is documented to live. Default unchanged (300s).
1 parent cdce00f commit 22c615d

5 files changed

Lines changed: 67 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
1616
--limit 1` returns the top unassigned update in a few calls; without the flags
1717
the listing stays a single SMELT call.
1818

19+
### Fixed
20+
21+
- The SSH connection setup now honours `connection_timeout` for the TCP
22+
connect, SSH banner, and authentication (previously only remote command
23+
execution was bounded, so a dead/firewalled refhost stalled on the OS TCP
24+
timeout — making a bulk `add_host` appear to hang for minutes).
25+
- `connection_timeout` is now read from the `[connection]` section (falling
26+
back to the legacy `[mtui]` section), matching where it is documented to live.
27+
1928
## [18.0.1] - 2026-06-18
2029

2130
### Added

mtui/hosts/connection/connection.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ def __init__(
7070
Args:
7171
hostname: The hostname or IP address of the remote host.
7272
port: The port number to connect to.
73-
timeout: The timeout for remote commands.
73+
timeout: Timeout in seconds for both establishing the connection
74+
(TCP connect, SSH banner, auth) and remote command execution.
7475
missing_host_key_policy: paramiko policy applied to unknown
7576
host keys. ``None`` (the default) preserves the legacy
7677
behaviour of ``AutoAddPolicy``.
@@ -156,6 +157,9 @@ def connect(self, quiet: bool = False) -> None:
156157
if "proxycommand" in opts
157158
else None
158159
),
160+
timeout=self.timeout,
161+
banner_timeout=self.timeout,
162+
auth_timeout=self.timeout,
159163
)
160164

161165
except (paramiko.AuthenticationException, paramiko.BadHostKeyException):
@@ -185,6 +189,9 @@ def connect(self, quiet: bool = False) -> None:
185189
if "proxycommand" in opts
186190
else None
187191
),
192+
timeout=self.timeout,
193+
banner_timeout=self.timeout,
194+
auth_timeout=self.timeout,
188195
)
189196
except paramiko.AuthenticationException:
190197
# if a wrong password was set, don't connect to the host and

mtui/support/config.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ def expanduser(p: Path | str) -> Path:
219219
getint = self.config.getint
220220
getboolean = self.config.getboolean
221221

222+
def get_connection_timeout(section: str, option: str) -> str:
223+
# Read from the [connection] section, falling back to the legacy
224+
# [mtui] location so existing configs keep working.
225+
try:
226+
return self.config.get(section, option)
227+
except (configparser.NoSectionError, configparser.NoOptionError):
228+
return self.config.get("mtui", option)
229+
222230
self.data: list[ConfigOption] = [
223231
ConfigOption(
224232
"template_dir",
@@ -247,15 +255,15 @@ def expanduser(p: Path | str) -> Path:
247255
Path,
248256
get,
249257
),
250-
# connection.timeout appears to be in units of seconds as
251-
# indicated by
252-
# http://www.lag.net/paramiko/docs/paramiko.Channel-class.html#gettimeout
258+
# Seconds. Bounds both establishing the SSH connection (TCP
259+
# connect / banner / auth) and remote command execution. Read
260+
# from [connection], falling back to the legacy [mtui] section.
253261
ConfigOption(
254262
"connection_timeout",
255-
("mtui", "connection_timeout"),
263+
("connection", "connection_timeout"),
256264
300,
257265
int,
258-
get,
266+
get_connection_timeout,
259267
),
260268
ConfigOption(
261269
"svn_path",

tests/test_config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@ def test_override_default_config(tmpdir):
5555
assert cfg.connection_timeout == 600
5656

5757

58+
def test_connection_timeout_from_connection_section(tmpdir):
59+
"""connection_timeout is read from the [connection] section."""
60+
config_file = Path(tmpdir.join("test.cfg"))
61+
config_file.write_text("[connection]\nconnection_timeout = 45\n")
62+
cfg = config.Config(config_file, refhosts=MockRefhosts)
63+
assert cfg.connection_timeout == 45
64+
65+
66+
def test_connection_timeout_connection_section_wins(tmpdir):
67+
"""[connection] takes precedence over the legacy [mtui] location."""
68+
config_file = Path(tmpdir.join("test.cfg"))
69+
config_file.write_text(
70+
"[mtui]\nconnection_timeout = 600\n[connection]\nconnection_timeout = 45\n"
71+
)
72+
cfg = config.Config(config_file, refhosts=MockRefhosts)
73+
assert cfg.connection_timeout == 45
74+
75+
5876
def test_smelt_url_defaults_empty_and_overrides(tmpdir):
5977
"""smelt_url has no default (off unless configured) and reads [smelt] url."""
6078
empty = Path(tmpdir.join("empty.cfg"))

tests/test_connection.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,14 @@ def test_connection_init_success(mock_ssh_client, mock_ssh_config, mock_path):
5555
mock_ssh_client.load_system_host_keys.assert_called_once()
5656
mock_ssh_client.set_missing_host_key_policy.assert_called_once()
5757
mock_ssh_client.connect.assert_called_once_with(
58-
hostname="test_host", port=22, username="root", key_filename=None, sock=None
58+
hostname="test_host",
59+
port=22,
60+
username="root",
61+
key_filename=None,
62+
sock=None,
63+
timeout=300,
64+
banner_timeout=300,
65+
auth_timeout=300,
5966
)
6067
assert conn.hostname == "test_host"
6168
assert conn.port == 22
@@ -74,14 +81,24 @@ def test_connection_init_auth_fallback(mock_ssh_client, mock_ssh_config, mock_pa
7481
assert mock_getpass.call_count == 1
7582
assert mock_ssh_client.connect.call_count == 2
7683
mock_ssh_client.connect.assert_any_call(
77-
hostname="test_host", port=22, username="root", key_filename=None, sock=None
84+
hostname="test_host",
85+
port=22,
86+
username="root",
87+
key_filename=None,
88+
sock=None,
89+
timeout=300,
90+
banner_timeout=300,
91+
auth_timeout=300,
7892
)
7993
mock_ssh_client.connect.assert_any_call(
8094
hostname="test_host",
8195
port=22,
8296
username="root",
8397
password="password",
8498
sock=None,
99+
timeout=300,
100+
banner_timeout=300,
101+
auth_timeout=300,
85102
)
86103

87104

0 commit comments

Comments
 (0)