From 34a334fa27da42d148fbfda502bd51ce0d068c95 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:52:24 +1000 Subject: [PATCH 1/2] Fix Windows OpenSSH stderr capture in app and tests Windows OpenSSH 9.5 blocks while writing captured stderr to a pipe, even with stdin disconnected and a connection timeout. Capture stderr in a temporary file for one-shot OpenSSH calls on Windows, preserving subprocess output, text, check, and timeout behavior. Leave POSIX capture unchanged. Use the shared runner for SSH, scp, key lookup, and streamed app-data transfers. Bound the real ssh-keygen hashing tests and keep their assertions; move the transfer-error mock to the runner seam. Add ten regression tests. Verified the full suite on Windows 11 with bundled Python 3.12.14: 628 tests, OK (110 existing skips), 42.685s. Verified macOS Python 3.9.6: 628 tests, OK, 67.934s. Independent Codex gpt-6-sol high-reasoning review found no actionable issues. Protected RDP code is unchanged. Co-Authored-By: GPT-6.1 Sol (Codex) --- tests/test_devices.py | 7 ++- tests/test_frame_android_data.py | 2 +- tests/test_frame_host.py | 87 ++++++++++++++++++++++++++++++++ ui/frame_android.py | 4 +- ui/frame_android_data.py | 3 +- ui/frame_connect.py | 4 +- ui/frame_devices.py | 4 +- ui/frame_host.py | 39 +++++++++++++- ui/frame_link.py | 8 +-- ui/server.py | 6 +-- 10 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 tests/test_frame_host.py diff --git a/tests/test_devices.py b/tests/test_devices.py index 31f3c61..522e879 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "ui")) import frame_devices as fd # noqa: E402 +import frame_host # noqa: E402 CONFIG = """Host lxso1 HostName 192.168.1.109 @@ -274,7 +275,8 @@ class Pins(Base): def test_hashed_and_non_default_port_entries(self): kh = self.ssh / "known_hosts" kh.write_text(f"[frame.local]:2222 {KEY}\n") - subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) + frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, + stdin=subprocess.DEVNULL, check=True, timeout=10) self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) @@ -283,7 +285,8 @@ class Pins(Base): target = fd.known_hosts("d4") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(f"frame-control-d4 {KEY}\n") - subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True) + frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, + stdin=subprocess.DEVNULL, check=True, timeout=10) self.assertNotIn("frame-control-d4", target.read_text()) self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.forget_pin("d4")) diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py index 175762c..6b9d3e8 100644 --- a/tests/test_frame_android_data.py +++ b/tests/test_frame_android_data.py @@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase): with self.assertRaisesRegex(android.FrameError, 'start this app'): data.install_obb(PKG, [path]) stream.assert_not_called() - with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): + with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): with self.assertRaisesRegex(android.FrameError, 'bad hash'): data._stream('command') diff --git a/tests/test_frame_host.py b/tests/test_frame_host.py new file mode 100644 index 0000000..39c8aa9 --- /dev/null +++ b/tests/test_frame_host.py @@ -0,0 +1,87 @@ +"""Captured OpenSSH output keeps working on Windows and POSIX hosts.""" +import sandbox # noqa: F401 +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui")) +import frame_host + + +class CapturedSSH(unittest.TestCase): + def run_command(self, source, **kwargs): + with mock.patch.object(frame_host, "WINDOWS", True): + return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs) + + def test_binary_output_and_input(self): + result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); " + "sys.stderr.buffer.write(b'error\\r\\n')", + capture_output=True, input=b"data\x00\xff") + self.assertEqual(result.stdout, b"data\x00\xff") + self.assertEqual(result.stderr, b"error\r\n") + + def test_text_output_normalizes_newlines(self): + result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); " + "sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')", + capture_output=True, input="hello\n", text=True) + self.assertEqual(result.stdout, "hello\n") + self.assertEqual(result.stderr, "first\nsecond\nthird\n") + + def test_explicit_encoding_and_errors(self): + result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')", + capture_output=True, encoding="ascii", errors="replace") + self.assertEqual(result.stderr, "\ufffd\ufffd") + + def test_check_preserves_error_output(self): + with self.assertRaises(subprocess.CalledProcessError) as caught: + self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)", + capture_output=True, text=True, check=True) + self.assertEqual(caught.exception.returncode, 7) + self.assertEqual(caught.exception.stdout, "out\n") + self.assertEqual(caught.exception.stderr, "err\n") + + def test_timeout_preserves_partial_stderr(self): + with self.assertRaises(subprocess.TimeoutExpired) as caught: + with mock.patch.object(frame_host, "WINDOWS", True): + frame_host.run_ssh([sys.executable, "-c", "import sys, time; " + "sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"], + capture_output=True, text=True, timeout=1) + self.assertEqual(caught.exception.stderr, b"waiting") + + def test_streamed_stdout_is_kept_separate(self): + with tempfile.TemporaryFile() as output: + result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); " + "sys.stderr.buffer.write(b'error')", + stdout=output, stderr=subprocess.PIPE) + output.seek(0) + self.assertEqual(output.read(), b"file") + self.assertIsNone(result.stdout) + self.assertEqual(result.stderr, b"error") + + def test_uncaptured_windows_call_is_unchanged(self): + with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run: + frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5) + run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5) + + def test_posix_call_is_unchanged(self): + with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run: + frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5) + run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5) + + def test_capture_rejects_explicit_streams(self): + for stream in ("stdout", "stderr"): + with self.subTest(stream=stream), self.assertRaises(ValueError): + self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL}) + + @unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH") + def test_real_ssh_failure_returns_stderr_without_hanging(self): + result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes", + "-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"], + capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5) + self.assertEqual(result.returncode, 255) + self.assertIn("Could not resolve hostname", result.stderr) diff --git a/ui/frame_android.py b/ui/frame_android.py index 4345971..ceaa02f 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -47,7 +47,7 @@ def ssh(cmd, input=None, timeout=120): try: # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL} - p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, + p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, timeout=timeout, text=isinstance(input, str) or input is None) except subprocess.TimeoutExpired: raise FrameError(f'timed out talking to {FRAME}') @@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600): else: cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}'] try: - subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) + frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) except subprocess.TimeoutExpired: raise FrameError(f'copying {name} to the Frame timed out') except subprocess.CalledProcessError as e: diff --git a/ui/frame_android_data.py b/ui/frame_android_data.py index 5a2e31a..dc6e7a5 100644 --- a/ui/frame_android_data.py +++ b/ui/frame_android_data.py @@ -11,13 +11,14 @@ import tempfile import uuid import frame_android as android +import frame_host REMOTE = Path(android.ROOT) / 'frame/android/app-data.py' def _stream(command, src=None, dst=None): try: - result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command], + result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], stdin=src if src else subprocess.DEVNULL, stdout=dst if dst else subprocess.PIPE, stderr=subprocess.PIPE, timeout=1800) diff --git a/ui/frame_connect.py b/ui/frame_connect.py index a459a32..c43a7cf 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -24,6 +24,8 @@ import urllib.error import urllib.request from pathlib import Path +import frame_host + FRAME_USER = os.environ.get("FRAME_USER", "steamos") USER_FROM_ENV = "FRAME_USER" in os.environ FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") @@ -349,7 +351,7 @@ def _write_config(host, port, user): def key_login_works(): # accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. - return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], capture_output=True).returncode == 0 diff --git a/ui/frame_devices.py b/ui/frame_devices.py index 8345302..e575a59 100644 --- a/ui/frame_devices.py +++ b/ui/frame_devices.py @@ -333,7 +333,7 @@ def remove_block(alias, path=None): def effective_port(alias, config): """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" try: - out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, + out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return 22 @@ -346,7 +346,7 @@ def effective_port(alias, config): def _keygen(*args): try: - return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, + return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10) except (OSError, subprocess.TimeoutExpired): return None diff --git a/ui/frame_host.py b/ui/frame_host.py index e5c1914..59505a8 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -5,12 +5,14 @@ Everything here runs on your computer, not the Frame. Python stdlib only. CLI (used by the Electron app, so terminal handling lives in one place): python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window """ +import io import os import shlex import shutil import ssl import subprocess import sys +import tempfile from pathlib import Path MAC = sys.platform == "darwin" @@ -32,6 +34,41 @@ class HostError(RuntimeError): pass +def run_ssh(argv, **kwargs): + """Run an OpenSSH tool without Windows' redirected-stderr pipe hang. + + A real temporary file avoids OpenSSH's blocked asynchronous stderr writes, + while keeping subprocess.run's captured output, text, check and timeout API. + """ + if not WINDOWS: + return subprocess.run(argv, **kwargs) + if kwargs.pop("capture_output", False): + if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None: + raise ValueError("stdout and stderr arguments may not be used with capture_output") + kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if kwargs.get("stderr") != subprocess.PIPE: + return subprocess.run(argv, **kwargs) + check = kwargs.pop("check", False) + text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors")) + with tempfile.TemporaryFile() as stderr: + kwargs["stderr"] = stderr + try: + result = subprocess.run(argv, **kwargs) + except subprocess.TimeoutExpired as error: + stderr.seek(0) + error.stderr = stderr.read() + raise + stderr.seek(0) + if text: + with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader: + result.stderr = reader.read() + else: + result.stderr = stderr.read() + if check: + result.check_returncode() + return result + + def data_dir(*parts): """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME (or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory).""" @@ -231,7 +268,7 @@ def clipboard_text(): def ssh_hostname(alias): """The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP.""" try: - out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout + out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return alias for line in out.splitlines(): diff --git a/ui/frame_link.py b/ui/frame_link.py index 51979c6..b2e8e39 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -74,7 +74,7 @@ def ssh_g(alias): """(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it.""" try: - out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, + out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): out = "" @@ -765,7 +765,7 @@ class Link: if not self.control: return False try: - return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, + return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, stdin=subprocess.DEVNULL, timeout=5).returncode == 0 except (OSError, subprocess.TimeoutExpired): return False @@ -777,7 +777,7 @@ class Link: pending.kill() if self.control and self.alias: try: - subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, + frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, stdin=subprocess.DEVNULL, timeout=5) except (OSError, subprocess.TimeoutExpired): pass @@ -983,7 +983,7 @@ class Link: *self.host_opts(device, ssh_target(a["host"], res.get("ip"))), "-o", "StrictHostKeyChecking=yes", device["alias"], "true"] try: - r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, + r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=20) err = r.stderr.strip() if r.returncode == 0: diff --git a/ui/server.py b/ui/server.py index 7771b72..14ec154 100755 --- a/ui/server.py +++ b/ui/server.py @@ -332,7 +332,7 @@ def ssh(remote, *, stdin=None, timeout=30, text=True): # Never let ssh inherit our stdin: under the app it's the pipe held open for # --exit-on-eof, and Windows' ssh.exe waits on it forever. feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} - r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed, + r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed, text=text, errors="replace" if text else None, timeout=timeout) except subprocess.TimeoutExpired: raise Failure(f"Timed out talking to {FRAME}") @@ -503,7 +503,7 @@ def save_shots(body): incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR)) try: try: - r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], + r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) except subprocess.TimeoutExpired: raise Failure("Copying screenshots timed out") @@ -2246,7 +2246,7 @@ def push_file(path, dest="Downloads/"): else: # Modern scp uses SFTP, so the remote path isn't parsed by a shell. cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"] - r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) + r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) except subprocess.TimeoutExpired: raise Failure(f"Copying {name} timed out") if r.returncode != 0: From 865e8dc17f1815452593676d6c9492271bad04e8 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:58:28 +1000 Subject: [PATCH 2/2] Align continuation lines after the run_ssh rename Co-Authored-By: Claude Opus 5.5 (1M context) --- ui/frame_android.py | 2 +- ui/frame_android_data.py | 6 +++--- ui/frame_connect.py | 4 ++-- ui/frame_devices.py | 4 ++-- ui/frame_link.py | 8 ++++---- ui/server.py | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ui/frame_android.py b/ui/frame_android.py index ceaa02f..eff7c19 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -48,7 +48,7 @@ def ssh(cmd, input=None, timeout=120): # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL} p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, - timeout=timeout, text=isinstance(input, str) or input is None) + timeout=timeout, text=isinstance(input, str) or input is None) except subprocess.TimeoutExpired: raise FrameError(f'timed out talking to {FRAME}') if p.returncode != 0: diff --git a/ui/frame_android_data.py b/ui/frame_android_data.py index dc6e7a5..c1a5dfb 100644 --- a/ui/frame_android_data.py +++ b/ui/frame_android_data.py @@ -19,9 +19,9 @@ REMOTE = Path(android.ROOT) / 'frame/android/app-data.py' def _stream(command, src=None, dst=None): try: result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], - stdin=src if src else subprocess.DEVNULL, - stdout=dst if dst else subprocess.PIPE, - stderr=subprocess.PIPE, timeout=1800) + stdin=src if src else subprocess.DEVNULL, + stdout=dst if dst else subprocess.PIPE, + stderr=subprocess.PIPE, timeout=1800) except subprocess.TimeoutExpired: raise android.FrameError('app-data transfer timed out') except OSError as error: diff --git a/ui/frame_connect.py b/ui/frame_connect.py index c43a7cf..c0cefa4 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -352,8 +352,8 @@ def _write_config(host, port, user): def key_login_works(): # accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", - "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], - capture_output=True).returncode == 0 + "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], + capture_output=True).returncode == 0 def configured_user(): diff --git a/ui/frame_devices.py b/ui/frame_devices.py index e575a59..fb2f29a 100644 --- a/ui/frame_devices.py +++ b/ui/frame_devices.py @@ -334,7 +334,7 @@ def effective_port(alias, config): """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" try: out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, - stdin=subprocess.DEVNULL, timeout=10).stdout + stdin=subprocess.DEVNULL, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return 22 m = re.search(r"^port (\d+)$", out, re.M) @@ -347,7 +347,7 @@ def effective_port(alias, config): def _keygen(*args): try: return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, - timeout=10) + timeout=10) except (OSError, subprocess.TimeoutExpired): return None diff --git a/ui/frame_link.py b/ui/frame_link.py index b2e8e39..d9fd551 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -75,7 +75,7 @@ def ssh_g(alias): ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it.""" try: out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, - timeout=10).stdout + timeout=10).stdout except (OSError, subprocess.TimeoutExpired): out = "" got = {} @@ -766,7 +766,7 @@ class Link: return False try: return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, - stdin=subprocess.DEVNULL, timeout=5).returncode == 0 + stdin=subprocess.DEVNULL, timeout=5).returncode == 0 except (OSError, subprocess.TimeoutExpired): return False @@ -778,7 +778,7 @@ class Link: if self.control and self.alias: try: frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, - stdin=subprocess.DEVNULL, timeout=5) + stdin=subprocess.DEVNULL, timeout=5) except (OSError, subprocess.TimeoutExpired): pass if proc and proc.poll() is None: @@ -984,7 +984,7 @@ class Link: "-o", "StrictHostKeyChecking=yes", device["alias"], "true"] try: r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, - errors="replace", timeout=20) + errors="replace", timeout=20) err = r.stderr.strip() if r.returncode == 0: rows[i].update(ssh="ok", detail=f"{lead} ยท SSH works") diff --git a/ui/server.py b/ui/server.py index 14ec154..aab1325 100755 --- a/ui/server.py +++ b/ui/server.py @@ -333,7 +333,7 @@ def ssh(remote, *, stdin=None, timeout=30, text=True): # --exit-on-eof, and Windows' ssh.exe waits on it forever. feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed, - text=text, errors="replace" if text else None, timeout=timeout) + text=text, errors="replace" if text else None, timeout=timeout) except subprocess.TimeoutExpired: raise Failure(f"Timed out talking to {FRAME}") if r.returncode != 0: @@ -504,7 +504,7 @@ def save_shots(body): try: try: r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], - capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) + capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300) except subprocess.TimeoutExpired: raise Failure("Copying screenshots timed out") if r.returncode != 0: