mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Fix Windows hangs found against a real Frame
- Child processes never inherit the server's stdin. Under the app it's the pipe held open for --exit-on-eof, and Windows' ssh.exe waited on it forever, so captures, the screenshot list and Android apps timed out. - frame_connect's key check accepts a first-seen host key (as the copy step does), so an already-authorized key doesn't trigger a password prompt. Verified on Windows 11, Ubuntu and macOS against a Steam Frame: status, headset and desktop captures, live video, library, Android apps, screenshots and upload. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
37153f69ae
commit
03729ce950
6 files changed
+78
-16
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
# Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs
|
||||||
# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame.
|
# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR
|
||||||
|
# immersive-vr works on the Frame.
|
||||||
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
# Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host:
|
||||||
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
# scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh
|
||||||
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
# ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"'
|
||||||
@@ -43,6 +44,55 @@ gclient runhooks
|
|||||||
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64
|
||||||
guard
|
guard
|
||||||
cd src
|
cd src
|
||||||
|
# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client
|
||||||
|
# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR
|
||||||
|
# process (verified on the Frame 2026-09-26). Allow only that option.
|
||||||
|
IFS= read -r -d '' PEERCRED_PATCH <<'P' || true
|
||||||
|
diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
index 435e13d396..297453f582 100644
|
||||||
|
--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
+++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc
|
||||||
|
@@ -11,6 +11,7 @@
|
||||||
|
#include "sandbox/linux/system_headers/linux_syscalls.h"
|
||||||
|
#include "sandbox/policy/linux/sandbox_linux.h"
|
||||||
|
|
||||||
|
+using sandbox::bpf_dsl::AllOf;
|
||||||
|
using sandbox::bpf_dsl::Allow;
|
||||||
|
using sandbox::bpf_dsl::Arg;
|
||||||
|
using sandbox::bpf_dsl::Error;
|
||||||
|
@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default;
|
||||||
|
ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
switch (system_call_number) {
|
||||||
|
// The runtime reaches its compositor over an AF_UNIX socket and passes fds
|
||||||
|
- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt
|
||||||
|
- // stay disallowed; add a narrow level/optname restriction if ever needed.
|
||||||
|
+ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt
|
||||||
|
+ // stays disallowed; getsockopt is limited to SO_PEERCRED below.
|
||||||
|
#if defined(__NR_getpeername)
|
||||||
|
case __NR_getpeername:
|
||||||
|
#endif
|
||||||
|
@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const {
|
||||||
|
case __NR_get_robust_list:
|
||||||
|
#endif
|
||||||
|
return Allow();
|
||||||
|
+#if defined(__NR_getsockopt)
|
||||||
|
+ case __NR_getsockopt: {
|
||||||
|
+ // SteamVR's IPC client checks who is on the other end of its socket
|
||||||
|
+ // with SO_PEERCRED. Nothing else is readable.
|
||||||
|
+ const Arg<int> level(1);
|
||||||
|
+ const Arg<int> optname(2);
|
||||||
|
+ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow())
|
||||||
|
+ .Else(Error(EPERM));
|
||||||
|
+ }
|
||||||
|
+#endif
|
||||||
|
#if defined(__NR_kill)
|
||||||
|
case __NR_kill: {
|
||||||
|
// SteamVR probes its sibling processes for liveness with kill(pid, 0).
|
||||||
|
P
|
||||||
|
if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then
|
||||||
|
printf '%s\n' "$PEERCRED_PATCH" | git apply
|
||||||
|
stage "applied SO_PEERCRED patch"
|
||||||
|
fi
|
||||||
mkdir -p out/XR
|
mkdir -p out/XR
|
||||||
cat > out/XR/args.gn <<'A'
|
cat > out/XR/args.gn <<'A'
|
||||||
target_os = "linux"
|
target_os = "linux"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
# scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST
|
||||||
# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop
|
# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset
|
||||||
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
# scripts/chromium-xr.sh check # isSessionSupported via DevTools
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -35,10 +35,15 @@ case "${1:-}" in
|
|||||||
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr'
|
||||||
;;
|
;;
|
||||||
launch)
|
launch)
|
||||||
# run-on-frame starts in $HOME on the Frame, so the profile path is relative.
|
# Its own VR panel on gamescope's X display, so the Plasma desktop doesn't
|
||||||
exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \
|
# need to be open. The app starts in $HOME, so the profile path is relative.
|
||||||
|
# Without --no-first-run and --password-store=basic, startup can stop at a
|
||||||
|
# first-run or keyring prompt before DevTools comes up.
|
||||||
|
exec "$here/panel-on-frame.sh" --name chromium-xr -- '~/chromium-xr/chrome' \
|
||||||
--user-data-dir=.config/chromium-xr \
|
--user-data-dir=.config/chromium-xr \
|
||||||
--enable-features=OpenXR \
|
--enable-features=OpenXR \
|
||||||
|
--ozone-platform=x11 \
|
||||||
|
--no-first-run --no-default-browser-check --password-store=basic \
|
||||||
--remote-debugging-port="$DEVTOOLS_PORT" \
|
--remote-debugging-port="$DEVTOOLS_PORT" \
|
||||||
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
"${2:-https://immersive-web.github.io/webxr-samples/}"
|
||||||
;;
|
;;
|
||||||
|
|||||||
+5
-3
@@ -29,7 +29,9 @@ class FrameError(RuntimeError):
|
|||||||
|
|
||||||
def ssh(cmd, input=None, timeout=120):
|
def ssh(cmd, input=None, timeout=120):
|
||||||
try:
|
try:
|
||||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True,
|
# 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,
|
||||||
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}')
|
||||||
@@ -65,7 +67,7 @@ def apk_info(path):
|
|||||||
tool = aapt2()
|
tool = aapt2()
|
||||||
if not tool:
|
if not tool:
|
||||||
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
|
raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}")
|
||||||
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout
|
out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout
|
||||||
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out)
|
||||||
if not m:
|
if not m:
|
||||||
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
raise FrameError(f'not a readable APK: {os.path.basename(path)}')
|
||||||
@@ -115,7 +117,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:
|
||||||
subprocess.run(cmd, check=True, capture_output=True, 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:
|
||||||
|
|||||||
+3
-1
@@ -119,7 +119,9 @@ def write_config(host, port=22):
|
|||||||
|
|
||||||
|
|
||||||
def key_login_works():
|
def key_login_works():
|
||||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", FRAME_ALIAS, "true"],
|
# 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",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||||
capture_output=True).returncode == 0
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -185,7 +185,7 @@ def clipboard_text():
|
|||||||
for cmd in cmds:
|
for cmd in cmds:
|
||||||
if not shutil.which(cmd[0]):
|
if not shutil.which(cmd[0]):
|
||||||
continue
|
continue
|
||||||
r = subprocess.run(cmd, capture_output=True, timeout=10)
|
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
text = r.stdout.decode("utf-8", errors="replace")
|
text = r.stdout.decode("utf-8", errors="replace")
|
||||||
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
|
return text[:-2] if WINDOWS and text.endswith("\r\n") else text
|
||||||
@@ -197,7 +197,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 = subprocess.run(["ssh", "-G", alias], capture_output=True, 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():
|
||||||
|
|||||||
+9
-6
@@ -95,7 +95,7 @@ def ensure_master():
|
|||||||
|
|
||||||
def up():
|
def up():
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True,
|
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||||
timeout=5).returncode == 0
|
timeout=5).returncode == 0
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False
|
return False
|
||||||
@@ -118,7 +118,10 @@ def ensure_master():
|
|||||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||||
try:
|
try:
|
||||||
ensure_master()
|
ensure_master()
|
||||||
r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=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,
|
||||||
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}")
|
||||||
@@ -494,7 +497,7 @@ def adb_path():
|
|||||||
|
|
||||||
def adb(adb_bin, *args, timeout=20):
|
def adb(adb_bin, *args, timeout=20):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run([adb_bin, *args], capture_output=True, text=True,
|
r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=timeout)
|
errors="replace", timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
raise Failure(f"adb {' '.join(args[-2:])} timed out")
|
||||||
@@ -609,7 +612,7 @@ class AdbTunnel:
|
|||||||
self._stop_ssh()
|
self._stop_ssh()
|
||||||
for p in self.local:
|
for p in self.local:
|
||||||
try:
|
try:
|
||||||
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10)
|
subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -799,7 +802,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 = subprocess.run(cmd, capture_output=True, 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:
|
||||||
@@ -1046,7 +1049,7 @@ def main():
|
|||||||
finally:
|
finally:
|
||||||
# The master was started with -N, so it stays up until told to exit.
|
# The master was started with -N, so it stays up until told to exit.
|
||||||
if CONTROL:
|
if CONTROL:
|
||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||||
if _master and _master.poll() is None:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
|
|||||||
Reference in new issue
Block a user