Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 8051687e1e CI: stop recurring false failures on main and pull requests
- checks: the runner's packages.microsoft.com apt source now 403s, failing
  `apt-get update` and with it "Install zsh"; tolerate the refresh error
  (Ubuntu's lists still update and the install still fails if it can't).
- e2e: a failed quiet image build is rebuilt with the full log, but the
  script exited 2 even when that rebuild succeeded; only fail if it fails.
- ArtworkTests: a timed-out lookup gives its resolver slot back from its own
  thread, which a busy macOS runner may schedule after the next test starts.
  Wait for every slot before each test, and always return slots a test took,
  so one late thread can't cascade into three failures.
- release: tagged builds no longer re-upload the installers as workflow
  artifacts (they are attached to the release; the artifact finalize timed
  out on v0.3.0). Pull requests build installers only when the Electron app,
  the macOS helper it compiles, or the workflow changes.
- checks and release: a newer push to a pull request cancels the older run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:18:58 +10:00
14 changed files with 85 additions and 174 deletions

No files matched your search

+11 -2
View File
@@ -5,6 +5,11 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
checks: checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -17,7 +22,9 @@ jobs:
with: with:
node-version: "24" node-version: "24"
- name: Install zsh - name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh # The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: Script syntax - name: Script syntax
run: | run: |
sh -n ui/local-bin/ssh sh -n ui/local-bin/ssh
@@ -84,6 +91,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install zsh - name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh # The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests - name: End-to-end tests
run: scripts/e2e.sh run: scripts/e2e.sh
+12 -2
View File
@@ -2,14 +2,22 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches # Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing). # the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts. # Pull requests that change how the installers are built (the Electron app, the
# macOS helper it compiles, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on: on:
push: push:
tags: ["v*"] tags: ["v*"]
pull_request: pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"] paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch: workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions: permissions:
contents: write contents: write
@@ -49,7 +57,9 @@ jobs:
tag="${GITHUB_REF_NAME}" tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber gh release upload "$tag" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with: with:
name: frame-control-${{ matrix.os }} name: frame-control-${{ matrix.os }}
path: | path: |
+2 -1
View File
@@ -23,7 +23,8 @@ started=$SECONDS
print "==> Building fakeframe-frame (from $base) and fakeframe-host" print "==> Building fakeframe-frame (from $base) and fakeframe-host"
# Quiet when it works; if a build fails, build again with the full log so CI shows why. # Quiet when it works; if a build fails, build again with the full log so CI shows why.
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } } # That second build also rides out a flaky package mirror: only its failure is fatal.
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
+2 -5
View File
@@ -17,7 +17,6 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui")) sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402 import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1 CONFIG = """Host lxso1
HostName 192.168.1.109 HostName 192.168.1.109
@@ -275,8 +274,7 @@ class Pins(Base):
def test_hashed_and_non_default_port_entries(self): def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts" kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n") kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
@@ -285,8 +283,7 @@ class Pins(Base):
target = fd.known_hosts("d4") target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n") target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text()) self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4")) self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4")) self.assertTrue(fd.forget_pin("d4"))
+1 -1
View File
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'): with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path]) data.install_obb(PKG, [path])
stream.assert_not_called() stream.assert_not_called()
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'): with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command') data._stream('command')
+29 -8
View File
@@ -120,7 +120,27 @@ class LauncherTests(unittest.TestCase):
FIXTURES = ROOT / 'tests/fixtures/library' FIXTURES = ROOT / 'tests/fixtures/library'
def wait_for_idle_resolvers(timeout=10):
# Name lookups that outlast their deadline give their slot back from their own thread,
# which a busy runner may not schedule before the next test; wait until all are back.
from apk_sources import _images
held = []
try:
end = time.monotonic() + timeout
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
held.append(1)
if len(held) == 4:
return
raise AssertionError('artwork name lookups from an earlier test are still running')
finally:
for _ in held:
_images._resolvers.release()
class ArtworkTests(unittest.TestCase): class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
def test_source_inputs_and_url(self): def test_source_inputs_and_url(self):
from apk_sources import _images from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes() data = (FIXTURES / 'icon.png').read_bytes()
@@ -231,10 +251,7 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(sum('Too many' in e for e in errors), 2) self.assertEqual(sum('Too many' in e for e in errors), 2)
finally: finally:
gate.set() gate.set()
deadline = time.monotonic() + 5 wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
time.sleep(0.01)
_images._resolvers.release() # the stuck lookups finished and gave their slots back
def test_resolver_slot_released_when_thread_cannot_start(self): def test_resolver_slot_released_when_thread_cannot_start(self):
from apk_sources import _images from apk_sources import _images
@@ -242,10 +259,14 @@ class ArtworkTests(unittest.TestCase):
for _ in range(6): for _ in range(6):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1) _images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
for _ in range(4): # every slot came back held = 0
self.assertTrue(_images._resolvers.acquire(blocking=False)) try:
for _ in range(4): while held < 4 and _images._resolvers.acquire(blocking=False):
_images._resolvers.release() held += 1
self.assertEqual(held, 4) # every slot came back
finally:
for _ in range(held): # even on failure, so later tests don't inherit the leak
_images._resolvers.release()
def test_deadline_covers_name_resolution(self): def test_deadline_covers_name_resolution(self):
import threading import threading
-87
View File
@@ -1,87 +0,0 @@
"""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)
+3 -3
View File
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
try: try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. # 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} 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, p = subprocess.run(['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: except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}') raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0: if p.returncode != 0:
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
else: else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}'] cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try: try:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out') raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
+4 -5
View File
@@ -11,17 +11,16 @@ import tempfile
import uuid import uuid
import frame_android as android import frame_android as android
import frame_host
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py' REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None): def _stream(command, src=None, dst=None):
try: try:
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL, stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE, stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800) stderr=subprocess.PIPE, timeout=1800)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out') raise android.FrameError('app-data transfer timed out')
except OSError as error: except OSError as error:
+3 -5
View File
@@ -24,8 +24,6 @@ import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
import frame_host
FRAME_USER = os.environ.get("FRAME_USER", "steamos") FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame") FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
@@ -351,9 +349,9 @@ def _write_config(host, port, user):
def key_login_works(): def key_login_works():
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. # 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", return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0 capture_output=True).returncode == 0
def configured_user(): def configured_user():
+4 -4
View File
@@ -333,8 +333,8 @@ def remove_block(alias, path=None):
def effective_port(alias, config): def effective_port(alias, config):
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22.""" """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
try: try:
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True, out = subprocess.run(["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): except (OSError, subprocess.TimeoutExpired):
return 22 return 22
m = re.search(r"^port (\d+)$", out, re.M) m = re.search(r"^port (\d+)$", out, re.M)
@@ -346,8 +346,8 @@ def effective_port(alias, config):
def _keygen(*args): def _keygen(*args):
try: try:
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10) timeout=10)
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return None return None
+1 -38
View File
@@ -5,14 +5,12 @@ 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): 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 python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
""" """
import io
import os import os
import shlex import shlex
import shutil import shutil
import ssl import ssl
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
MAC = sys.platform == "darwin" MAC = sys.platform == "darwin"
@@ -34,41 +32,6 @@ class HostError(RuntimeError):
pass 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): def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME """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).""" (or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
@@ -268,7 +231,7 @@ def clipboard_text():
def ssh_hostname(alias): def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP.""" """The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try: try:
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
return alias return alias
for line in out.splitlines(): for line in out.splitlines():
+8 -8
View File
@@ -74,8 +74,8 @@ def ssh_g(alias):
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an """(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.""" ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
try: try:
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout timeout=10).stdout
except (OSError, subprocess.TimeoutExpired): except (OSError, subprocess.TimeoutExpired):
out = "" out = ""
got = {} got = {}
@@ -765,8 +765,8 @@ class Link:
if not self.control: if not self.control:
return False return False
try: try:
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True, return subprocess.run([*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): except (OSError, subprocess.TimeoutExpired):
return False return False
@@ -777,8 +777,8 @@ class Link:
pending.kill() pending.kill()
if self.control and self.alias: if self.control and self.alias:
try: try:
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True, subprocess.run([*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): except (OSError, subprocess.TimeoutExpired):
pass pass
if proc and proc.poll() is None: if proc and proc.poll() is None:
@@ -983,8 +983,8 @@ class Link:
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))), *self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"] "-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try: try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True, r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20) errors="replace", timeout=20)
err = r.stderr.strip() err = r.stderr.strip()
if r.returncode == 0: if r.returncode == 0:
rows[i].update(ssh="ok", detail=f"{lead} · SSH works") rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
+5 -5
View File
@@ -332,8 +332,8 @@ 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 # 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. # --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed, r = subprocess.run([*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: except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}") raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0: if r.returncode != 0:
@@ -503,8 +503,8 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR)) incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try: try:
try: try:
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)], r = subprocess.run(["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: except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out") raise Failure("Copying screenshots timed out")
if r.returncode != 0: if r.returncode != 0:
@@ -2246,7 +2246,7 @@ def push_file(path, dest="Downloads/"):
else: else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell. # Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"] cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out") raise Failure(f"Copying {name} timed out")
if r.returncode != 0: if r.returncode != 0: