Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 0a9bb2e0c3 Telemetry: keep the link-failure mark through re-raised errors
Review round 4 of #78: /api/android and /api/titles re-raise frame_android's
FrameError as Failure(str(e)), which dropped frame_link_failed, so their
connection failures were still reported after the status poll's first one.
Both now raise ... from e, and diagnostic() follows __cause__
(frame_telemetry.link_failed). server.ssh() judges a link failure on ssh's
stderr only, never the command's stdout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:46:54 +11:00
saphidandClaude Opus 5.5 9e70cdb48a Telemetry: decide "couldn't reach the Frame" where ssh runs, not from text
Review round 3 of #78: matching ssh's words in the message could still be
spoofed by a download's file name ("Connection closed by frame port 22.zip"
in a checksum failure lost its $exception after a poll timeout). Now
server.ssh and frame_android.ssh mark the exception (frame_host.link_failure)
when ssh itself failed to reach the headset: a time out, or exit 255 with a
line ssh starts (frame_host.ssh_link_failed). diagnostic() holds back only
marked errors in frame_unreachable / frame_not_set_up, once per session;
everything else keeps the 10-minute window. Web-link download and checksum
failures are classified download_failed before any other category.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:34:25 +11:00
saphid 76878403c3 Merge origin/main into fix/telemetry-connection-noise
# Conflicts:
#	tests/test_telemetry.py
2026-10-08 19:21:09 +11:00
saphidandClaude Opus 5.5 105b92a0dd Telemetry: count only lines that start as ssh's link failures
Review round 2 of #78: FRAME_LINK_FAILED matched its words anywhere, so a
web-link checksum mismatch for a file named kex_exchange_identification.zip,
after a status-poll timeout, lost its $exception. Each pattern is now
anchored to the start of a line as ssh prints it (re.M), and our own
"Timed out talking to HOST" must be the whole line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:19:21 +11:00
Alex Southwell d356d9a9d5 Merge pull request #75 from saphid/fix/apk-install-instant-failures
APK installs: ship the OpenXR layer so the Windows installer installs it; CI checks the install; categorize failures
2026-10-08 19:17:53 +11:00
saphidandClaude Opus 5.5 e47eefec7c Telemetry: hold back only ssh's own link failures for the session
Review of #78: the once-per-session hold applied to anything classified
frame_unreachable, so after one status-poll timeout a web-link download whose
connection reset ("download failed: ... Connection reset by peer") lost its
$exception. Now the hold needs ssh's own wording (connect to host, could not
resolve, timed out talking to, banner exchange, mux/client_loop, connection
closed/reset by HOST port N); everything else keeps the 10-minute window.
Web-link download failures are download_failed. A bare "ssh exited 255" is
no longer frame_unreachable: the command on the Frame may have exited 255.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:13:05 +11:00
saphid d92a9fc7ae Merge origin/main into fix/telemetry-connection-noise 2026-10-08 19:09:12 +11:00
saphid 875e92ea7b Merge remote-tracking branch 'origin/main' into fix/apk-install-instant-failures 2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 f9a87e76e8 Web-link APK installs report an unexpected install failure once
Review pr75-review-r2: _webinstall_run catches non-FrameError APK install
failures without a diagnostic, and apk_installed now leaves those to the
caller, so they went unreported. Report them there; test the full worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 95ccae5494 Show the missing-layer warning on every install path; report an unexpected install failure once
Review findings on PR #75 (pr75-review-r1):
- Catalogue, alternate-version and web-link installs now append the
  "installed without the OpenXR layer" warning to their completion message
  (frame_android.layer_note); dropped files already showed vr_issues.
- A non-FrameError that install() re-raises is reported by whoever catches
  it (job or request handler, with its traceback), so apk_installed sends
  install_finished without its own diagnostic for it (install_finished
  diagnose=False). Still exactly one install_finished.
- Tests run the real job path for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:58:01 +11:00
Alex Southwell d58a926257 Merge pull request #76 from saphid/fix/publish-release-draft
Releases: publish drafts through REST and link update.json to the tag page
2026-10-08 18:57:25 +11:00
Alex Southwell 9292ce0a4c Merge pull request #74 from saphid/fix/catalog-arm64-only
Android installs: say which APK to get on a wrong ABI; wrong-file failures don't mark an app broken
2026-10-08 18:54:04 +11:00
saphidandClaude Opus 5.5 9d062b7ba0 Ship the OpenXR layer gzipped so the Windows installer installs it; CI installs and checks
electron-builder's 7-Zip compresses a bare arm64 ELF with its ARM64 branch
filter (method 0A), which the NSIS installer's extractor can't decode and
silently skips. Every installed Windows copy (0.4.0, 0.4.1) lacked
libXrApiLayer_FRAME_compat.so, so VR APK installs failed instantly.

- Commit and package the library as libXrApiLayer_FRAME_compat.so.gz
  (byte-identical when decompressed; SHA-256 checked against the README);
  frame_android decompresses it, and a corrupt file counts as missing.
- build.sh writes the .gz.
- check-resources.js also runs from the command line against an installed
  copy, comparing every file with the unpacked build.
- release.yml: the Windows job installs the NSIS installer silently and runs
  that check.
- Tests: no bare ELF under prebuilt/, packaging filter ships only the .gz.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:47:00 +11:00
saphidandClaude Opus 5.5 a46c28d6c8 Drop compat reports in search from this change (moved to #77)
Review (T3 task pr74-review-r1, gpt-6.1-sol) found that search blocked on
frame_compat_db.load() after a cache expiry (up to ~40 s with an unreachable
database, or an untimed Keychain lookup without a key), and that the verdict
showed only in the detail view, not on cards. Revert ui/apk_sources/search.py,
ui/server.py, ui/index.html and their tests to main; keep the wrong-ABI error
text, the reports.py wrong-file filter and the ABI-selection tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:46:35 +11:00
saphidandClaude Opus 5.5 0b9a165d83 APK installs: install VR apps without a missing OpenXR layer, fail packaging without it, categorize failures
- frame_android: a missing, unreadable or empty OpenXR compat layer no longer
  aborts a VR install; it installs without the layer and says so (vr_issues),
  unless the layer was asked for explicitly. Patch failures are named, and any
  exception (not only FrameError) reaches the install hooks/telemetry.
- app/build/check-resources.js (electron-builder afterPack): the build fails if
  the layer's arm64-v8a library or other required resources are missing.
- frame_telemetry: new fixed error categories layer_missing, apk_repack_failed,
  tool_missing; ok=false without an error reports "other".
- server: install_finished carries xr_layer_missing when a VR APK installed
  without the layer. docs/privacy.md lists every category and the new field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:38:15 +11:00
saphidandClaude Opus 5.5 88a2fe6728 Android store: say which APK to get on a wrong ABI; show compat reports in search
PostHog (0.4.0): Grayjay installs failed twice with apk_wrong_abi, once with
an armeabi-v7a file and once with an x86_64 file. Grayjay's own site offers
one APK per ABI; the FUTO F-Droid repo's Grayjay 391 is universal and does
contain arm64-v8a, and the catalogue and repo search already drop builds
without arm64-v8a, so the wrong files did not come from those paths.

- The wrong-ABI error now tells the user to download the arm64-v8a (or
  arm64, or universal) APK instead, so they don't guess again.
- An install_failed report caused by a wrong-ABI (or too-new-Android) file
  no longer rates the whole app as broken in the catalogue.
- Search results and details carry the compatibility reports the catalogue
  uses: "Reported not working on the Frame" (a warning, not a block) or
  "Works on the Frame". A report never overrides a hard blocker.
- Tests: per-ABI split builds (offer and download the arm64 one), the new
  error text and its telemetry category, wrong-file reports, and search
  verdicts from reports, with the database hook off by default in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:37:08 +11:00
saphidandClaude Opus 5.5 ba9706b69e Telemetry: stop flooding error tracking with "Frame unreachable"
The status poll (POST /api/comfort status) meets an asleep or absent headset
every few seconds, and each ssh failure became a $exception: ~760 in two weeks
from 0.4.0, nearly all connection timeouts. Errors whose category only means
the Frame couldn't be reached (frame_unreachable, frame_not_set_up) are now
sent at most once per category per session; other errors keep the 10-minute
per-message window.

The classifier now also files "Host is down", Windows' "Unknown error" and
"banner exchange: Connection to UNKNOWN port -1", "ssh exited 255", and
dropped shared connections (mux_client_, client_loop) as frame_unreachable
instead of "other".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:32:03 +11:00
19 changed files with 729 additions and 39 deletions

No files matched your search

+14
View File
@@ -64,6 +64,20 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
- name: Check the Windows installer installs every file
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
+14
View File
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
+79
View File
@@ -0,0 +1,79 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+2 -1
View File
@@ -29,6 +29,7 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -81,7 +82,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so"
"prebuilt/**/*.so.gz"
]
},
{
+14 -5
View File
@@ -43,13 +43,20 @@ computer, exactly as they were sent.
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
`install_finished` never includes a file name, path or error message. An
error becomes one category from a fixed list (for example `apk_wrong_abi` or
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
is one. It names what was installed only when that's already public:
error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
Control's OpenXR compatibility layer, because this copy of the app is missing
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
names what was installed only when that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
@@ -93,7 +100,9 @@ scrubbed:
names, passwords, ports, paths and queries are dropped
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes.
The same error is sent at most once every 10 minutes. When ssh reports that
it couldn't reach the Frame (asleep, away, or not set up yet), that is sent
at most once per kind each time Frame Control runs.
## Report a problem
+4
View File
@@ -249,6 +249,10 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
and keeps the current refresh rate when SteamVR refuses a requested one.
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
If this copy of Frame Control is missing the layer's library (an incomplete
install, or a file removed after installing), VR apps install without it and
the install message says so; OpenXR 1.0 apps still run. Release builds fail
to package without it (`app/build/check-resources.js`).
Verified on the headset (2026-09-28):
+5 -2
View File
@@ -190,8 +190,11 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
Its SHA-256 is recorded below with the APK checks.
The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
extractor skips. Frame Control decompresses it when injecting the layer. The
SHA-256 of the uncompressed library is recorded below with the APK checks.
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
+5 -2
View File
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
cmake --build "$here/build-android"
mkdir -p "$here/prebuilt/arm64-v8a"
cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
# branch filter to a bare arm64 ELF, which the Windows installer's extractor
# can't decode, so the installed app silently lacked the library.
shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
+30
View File
@@ -256,6 +256,36 @@ class Repositories(unittest.TestCase):
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
def test_per_abi_builds_offer_and_download_the_arm64_one(self):
import hashlib
def build(code, name, abis):
self.files[name] = name.encode()
return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
'nativecode': abis},
'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
'added': 0}
# One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
# plus a universal and an arm64-only build sharing a code.
builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
build(3913, 'app-x86.apk', ['x86']),
build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
raw = self.root / 'splits.json'
raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
'metadata': {'name': {'en-US': 'Grayjay'}},
'versions': {str(i): b for i, b in enumerate(builds)}}}}))
source = {'id': 'test', 'url': URL}
app = fdroid._reduce(raw, source)['com.futo.platformplayer']
self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
with patch.object(fdroid, 'details', return_value=app):
for code in (None, 3912):
fdroid.download(source, 'com.futo.platformplayer', version_code=code)
self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
with self.assertRaises(SourceError): # the x86_64 build is never offered
fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
+29
View File
@@ -184,6 +184,35 @@ class VersionsTest(unittest.TestCase):
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_wrong_abi_error_says_which_file_to_get(self):
import frame_telemetry
for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried
info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis}
with self.assertRaises(frame_android.FrameError) as error:
frame_android.check_installable(info)
message = str(error.exception)
self.assertIn('no arm64-v8a build (%s)' % abis[0], message)
self.assertIn('download the APK marked arm64-v8a', message)
self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi')
frame_android.check_installable({'label': 'Universal', 'min_sdk': 28,
'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']})
def test_wrong_file_reports_do_not_rate_the_app(self):
reports = frame_catalog.reports
wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed',
'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only',
'date': '2026-10-01T10:00:00'}
self.assertIsNone(reports.verdict([wrong_file]))
app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file])
self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands
# A real installer failure, a crash or a person's rating still counts.
installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK')
self.assertEqual(reports.verdict([installer])[0], 'no')
crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02')
self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no')
rated = dict(wrong_file, rating='works', date='2026-10-03')
self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works')
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
+79 -1
View File
@@ -255,6 +255,10 @@ class VRTests(unittest.TestCase):
self.assertEqual(set(add), set(frame_android.XR_COMPAT_FILES))
self.assertIn(b'XR_APILAYER_FRAME_compat', add['assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json'])
self.assertTrue(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so'].startswith(b'\x7fELF'))
# The library verified on the headset (its SHA-256 is in the layer's README).
import hashlib
digest = hashlib.sha256(add['lib/arm64-v8a/libXrApiLayer_FRAME_compat.so']).hexdigest()
self.assertIn(digest, (Path(frame_android.XR_COMPAT) / 'README.md').read_text())
with zipfile.ZipFile(apk, 'a') as z: # already injected: nothing more to add
z.writestr('lib/arm64-v8a/libXrApiLayer_FRAME_compat.so', b'')
self.assertEqual(frame_android.xr_compat_files(str(apk)), {})
@@ -269,8 +273,82 @@ class VRTests(unittest.TestCase):
with zipfile.ZipFile(apk, 'w') as z:
z.writestr('lib/arm64-v8a/libopenxr_loader.so', b'')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaisesRegex(frame_android.FrameError, 'build.sh'):
with self.assertRaisesRegex(frame_android.LayerMissing, 'build.sh'):
frame_android.xr_compat_files(str(apk))
# Present but corrupt, or empty, counts as missing too.
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
with open(os.path.join(d, rel), 'wb') as f:
f.write(b'\x1f\x8b not really gzip')
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
for rel in frame_android.XR_COMPAT_FILES.values():
os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True)
open(os.path.join(d, rel), 'wb').close()
with patch.object(frame_android, 'XR_COMPAT', d):
with self.assertRaises(frame_android.LayerMissing):
frame_android.xr_compat_files(str(apk))
def test_layer_ships_in_packaged_builds(self):
"""The arm64 library is in the repo, copied by electron-builder, and checked after packing."""
import fnmatch, json
root = Path(__file__).resolve().parents[1]
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertGreater((root / 'frame/openxr-compat' / rel).stat().st_size, 0, rel)
# No bare arm64 ELF ships: electron-builder's 7-Zip gives those an ARM64 filter the
# Windows installer can't extract, so the installed app silently lacked the library.
for f in (root / 'frame/openxr-compat/prebuilt').rglob('*'):
if f.is_file():
self.assertNotEqual(f.read_bytes()[:4], b'\x7fELF', f)
build = json.loads((root / 'app/package.json').read_text())['build']
self.assertEqual(build.get('afterPack'), 'build/check-resources.js')
entry = next(e for e in build['extraResources'] if e['from'] == '../frame/openxr-compat')
self.assertEqual(entry['to'], 'frame/openxr-compat')
check = (root / 'app/build/check-resources.js').read_text()
for rel in frame_android.XR_COMPAT_FILES.values():
self.assertTrue(any(fnmatch.fnmatch(rel, f.replace('**/', '*/')) for f in entry['filter']), rel)
self.assertFalse(any(f.endswith('.so') for f in entry['filter']))
self.assertIn('frame/openxr-compat/' + rel, check)
def test_install_goes_ahead_without_a_missing_layer(self):
"""A copy of Frame Control without the layer installs VR apps anyway and says so."""
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None, 'vr_issues': [],
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
missing = frame_android.LayerMissing(frame_android.LAYER_MISSING)
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', side_effect=missing), \
patch.object(frame_android, 'patch') as repair, \
patch.object(frame_android, '_install', return_value={'package': 'org.test.vr'}) as install, \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
frame_android.install('game.apk')
repair.assert_not_called() # nothing to add and nothing to repair: the APK goes as is
info = install.call_args.args[1]
self.assertTrue(info['xr_layer_missing'])
self.assertEqual(info['vr_issues'], [frame_android.LAYER_MISSING_NOTE])
self.assertIsNone(seen[-1][2]) # reported as a working install
# Asked for explicitly, a missing layer is still an error.
with self.assertRaises(frame_android.LayerMissing):
frame_android.install('game.apk', xr_compat=True)
self.assertIsInstance(seen[-1][2], frame_android.LayerMissing)
def test_patch_failures_are_named_and_every_failure_is_reported(self):
base = {'package': 'org.test.vr', 'label': 'VR', 'abis': [], 'min_sdk': None,
'vr': True, 'vr_activity': True, 'launchable': True, 'repairable': False}
seen = []
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base)), \
patch.object(frame_android, 'xr_compat_files', return_value={'x': b'1'}), \
patch.object(frame_android, 'patch', side_effect=frame_android.FrameError('ZIP64 APKs are unsupported')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaisesRegex(frame_android.FrameError, 'could not prepare the APK for the Frame: ZIP64'):
frame_android.install('game.apk')
with patch.object(frame_android, 'apk_info', side_effect=lambda p: dict(base, vr=False)), \
patch.object(frame_android, '_install', side_effect=FileNotFoundError(2, 'No such file or directory', 'ssh')), \
patch.object(frame_android, 'install_hooks', [lambda *a: seen.append(a)]):
with self.assertRaises(FileNotFoundError):
frame_android.install('game.apk')
self.assertIsInstance(seen[-1][2], FileNotFoundError) # not only FrameErrors reach telemetry
def test_patch_rejects_corrupt_manifest_cleanly(self):
with patch.object(frame_android, 'apk_info', side_effect=struct.error('bad')):
+311
View File
@@ -6,6 +6,7 @@ Run: python3 -m unittest discover -s tests
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import sys
import tempfile
import threading
@@ -20,9 +21,15 @@ sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
import frame_host # noqa: E402
import frame_telemetry as tm # noqa: E402
def link_error(message, kind=RuntimeError):
"""An error as an ssh helper raises it when ssh couldn't reach the headset."""
return frame_host.link_failure(kind(message))
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
@@ -92,6 +99,47 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
def test_connection_failures_are_sent_once_per_session(self):
# The status poll meets an asleep or absent headset every few seconds (638 timeouts from
# six people in two weeks): one event per kind per session, whatever the address or route,
# for errors marked where ssh ran as ssh failing to reach the headset.
tm.update_settings({"diagnostics": True})
with mock.patch.object(tm.time, "time", return_value=1000.0):
for ip in ("192.168.1.20", "192.168.1.21", "10.0.0.5"):
for where in ("POST /api/comfort status", "job steam"):
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Connection timed out"))
tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Host is down"))
tm.diagnostic(where, link_error("Timed out talking to frame"))
tm.diagnostic("POST /api/comfort status",
link_error("ssh: Could not resolve hostname frame: No such host is known."))
with mock.patch.object(tm.time, "time", return_value=1000.0 + 10 * tm.REPEAT_WINDOW):
tm.diagnostic("POST /api/comfort status", link_error("client_loop: send disconnect: Connection reset"))
tm.diagnostic("POST /api/comfort status", link_error("ssh: Could not resolve hostname frame"))
sent = [e["properties"] for e in self.queued()]
self.assertEqual([p["error_category"] for p in sent], ["frame_unreachable", "frame_not_set_up"])
self.assertTrue(all(p["$exception_message"].startswith("ssh: ") for p in sent))
def test_only_marked_errors_are_held_for_the_session(self):
# The same words without the mark (from a download, a file name, anything not ssh) keep
# the usual 10-minute window.
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("job web", RuntimeError("download failed: [Errno 54] Connection reset by peer"))
tm.diagnostic("job web", RuntimeError("urlopen error [Errno 60] Operation timed out"))
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "frame_unreachable", "download_failed", "frame_unreachable"])
def test_real_errors_are_still_sent_beside_connection_failures(self):
tm.update_settings({"diagnostics": True})
tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
tm.diagnostic("POST /api/comfort status", KeyError("battery"))
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "other", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
@@ -180,6 +228,41 @@ class Scrub(unittest.TestCase):
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
def test_connection_failures_seen_from_released_versions(self):
# Wording from 0.4.0's error reports (addresses replaced), on Windows, macOS and Linux.
unreachable = [
"ssh: connect to host 192.168.1.20 port 22: Connection timed out",
"ssh: connect to host 192.168.1.20 port 22: Operation timed out",
"ssh: connect to host 192.168.1.20 port 22: No route to host",
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"ssh: connect to host 192.168.1.20 port 22: Unknown error",
"mux_client_request_session: read from master failed: Broken pipe\n"
"ssh: connect to host 192.168.1.20 port 22: Host is down",
"client_loop: send disconnect: Connection reset",
"banner exchange: Connection to UNKNOWN port -1: Connection refused",
"Timed out talking to frame",
]
for message in unreachable:
self.assertEqual(tm.categorize(message)[0], "frame_unreachable", message)
for message in ("ssh: Could not resolve hostname frame: No such host is known.",
"ssh: Could not resolve hostname fe80::1%wireless_32773: No such host is known."):
self.assertEqual(tm.categorize(message)[0], "frame_not_set_up", message)
self.assertEqual(tm.categorize("ssh exited 1")[0], "other")
self.assertEqual(tm.categorize("ssh exited 255")[0], "other") # may be the command's own exit code
self.assertEqual(tm.categorize("download failed: [Errno 54] Connection reset by peer")[0], "download_failed")
self.assertEqual(tm.categorize("frame@10.0.0.2: Permission denied (publickey).")[0], "frame_auth")
def test_install_failure_categories(self):
import frame_android
self.assertEqual(tm.categorize(frame_android.LayerMissing(frame_android.LAYER_MISSING))[0], "layer_missing")
# The message 0.4.0 sent, so old and new builds land in the same bucket.
self.assertEqual(tm.categorize("the OpenXR compatibility layer isn't built; run "
"frame/openxr-compat/build.sh")[0], "layer_missing")
self.assertEqual(tm.categorize("could not prepare the APK for the Frame: ZIP64 APKs are unsupported")[0],
"apk_repack_failed")
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -205,6 +288,228 @@ class Compat(Base):
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
class LinkFailureProvenance(Base):
"""Only ssh, where it runs, decides that it couldn't reach the headset; the error report
then holds that back for the session. Nothing in a message can claim it."""
def setUp(self):
super().setUp()
import frame_android
import frame_webinstall
import server
self.server, self.android, self.web = server, frame_android, frame_webinstall
for target, name, kw in ((server, "ensure_master", {}), (server, "LINK", {"new": None}),
(server, "repair_ssh_config", {"return_value": False})):
p = mock.patch.object(target, name, **kw)
p.start()
self.addCleanup(p.stop)
tm.update_settings({"diagnostics": True})
def ssh_fails(self, module, returncode, stderr, stdout=""):
"""Run module.ssh with ssh exiting `returncode`; -> the exception, as the route handler gets it."""
done = subprocess.CompletedProcess(["ssh"], returncode, stdout, stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=done):
try:
module.ssh("true")
except Exception as e: # noqa: BLE001
return e
self.fail("ssh did not raise")
def poll_fails(self):
e = self.ssh_fails(self.server, 255, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
tm.diagnostic("POST /api/comfort status", e)
def test_a_status_poll_that_cannot_reach_the_frame_is_sent_once(self):
for _ in range(5):
self.poll_fails()
for stderr in ("Warning: Permanently added '10.0.0.5' (ED25519) to the list of known hosts.\r\n"
"kex_exchange_identification: read: Connection reset by peer\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"\x1b[0mssh: connect to host 10.0.0.5 port 22: Unknown error\n"):
tm.diagnostic("POST /api/comfort status", self.ssh_fails(self.server, 255, stderr))
with mock.patch.object(frame_host, "run_ssh", side_effect=subprocess.TimeoutExpired("ssh", 30)):
with self.assertRaises(self.server.Failure) as caught:
self.server.ssh("true")
tm.diagnostic("POST /api/comfort status", caught.exception)
e = self.ssh_fails(self.android, 255, "ssh: connect to host 10.0.0.5 port 22: Host is down\n")
tm.diagnostic("job steam", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_unreachable"])
def test_a_command_that_fails_on_the_frame_is_not_marked(self):
self.poll_fails()
for module in (self.server, self.android):
for code, stderr in ((255, ""), (255, "x: ssh: connect to host frame port 22: Connection timed out\n"),
(1, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\n")):
e = self.ssh_fails(module, code, stderr)
self.assertFalse(getattr(e, "frame_link_failed", False), (module.__name__, code, stderr))
# Review round 4: the command's own output (stdout) isn't ssh speaking.
e = self.ssh_fails(module, 255, "", stdout="ssh: connect to host frame port 22: Connection timed out\n")
self.assertFalse(getattr(e, "frame_link_failed", False), module.__name__)
def request(self, path, body):
"""POST to the real server, as the page does."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port)
c.request("POST", path, json.dumps(body), {"X-Frame-UI": self.server.UI_KEY, "Content-Type": "application/json"})
r = c.getresponse()
result = r.status, json.loads(r.read())
c.close()
return result
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the status poll's first connection failure, theirs are held back too,
# also past the 10-minute window.
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
mock.patch.object(self.android, "_meta_or_fail", return_value=meta), \
mock.patch.object(self.server.frame_titles, "ensure_utils"):
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 503) # the page shows its offline message
for t in (1000.0, 1000.0 + 2 * tm.REPEAT_WINDOW):
with mock.patch.object(tm.time, "time", return_value=t):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 503)
self.assertEqual(self.request("/api/titles", {"action": "launch", "id": "mygame"})[0], 503)
# Not a link failure: still reported, through the same re-wrap.
with mock.patch.object(self.android, "_meta_or_fail", side_effect=self.android.FrameError("boom")):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
def test_download_failures_after_a_poll_failure_are_still_sent(self):
# Review round 3: these file names, in the real checksum message through the web-link worker,
# were held back with the poll's connection failures.
self.poll_fails()
names = ("Connection closed by frame port 22.zip", "Connection reset by frame port 22.apk",
"kex_exchange_identification: read.zip", "banner exchange: payload.zip",
"ssh: connect to host frame port 22: x.zip", "Timed out talking to frame")
for name in names:
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
"cancel": False}
plan = {"name": None, "exe": None, "url": "https://example.com/x.zip", "kind": "zip"}
error = self.web.WebInstallError(f"{name} doesn't match the manifest's sha256; not installing it")
with mock.patch.object(self.web, "download", side_effect=error):
self.server._webinstall_run(plan, job)
self.assertEqual(job["phase"], "error")
exceptions = [e["properties"] for e in self.queued() if e["event"] == "$exception"]
self.assertEqual([p["error_category"] for p in exceptions], ["frame_unreachable"] + ["download_failed"] * len(names))
finished = [e["properties"] for e in self.queued() if e["event"] == "install_finished"]
self.assertEqual({p["error_category"] for p in finished}, {"download_failed"})
class InstallFinished(unittest.TestCase):
def test_failure_category_only_no_text(self):
"""install_finished carries a fixed category for a failure, never the message or a file name."""
import frame_android
with mock.patch.object(tm, "capture") as capture, mock.patch.object(tm, "diagnostic"):
tm.install_finished("apk", False, 0.0, frame_android.LayerMissing(
"C:\\Users\\Bob\\My Game.apk: " + frame_android.LAYER_MISSING), catalog=False)
props = capture.call_args[0][1]
self.assertEqual(props["error_category"], "layer_missing")
self.assertNotIn("Bob", repr(props))
self.assertEqual(set(props), {"kind", "ok", "seconds", "error_category", "catalog"})
tm.install_finished("apk", False)
self.assertEqual(capture.call_args[0][1]["error_category"], "other")
class ApkInstallJobs(unittest.TestCase):
"""The whole job path: what the page is told, and what telemetry sends, once."""
def setUp(self):
import frame_android
import frame_webinstall
import server
self.server, self.android, self.web = server, frame_android, frame_webinstall
for target, name, kw in ((server, "ensure_master", {}), (server.frame_catalog, "app", {}),
(server.frame_catalog, "add_report", {})):
p = mock.patch.object(target, name, **kw)
p.start()
self.addCleanup(p.stop)
def run_job(self, body):
job = self.server.android(body)["job"]
for _ in range(500):
with self.server._jobs_lock:
state = dict(self.server._jobs[job])
if state["done"]:
return state
time.sleep(0.01)
self.fail("job did not finish")
def test_missing_layer_warning_reaches_every_completion_message(self):
meta = {"label": "VR", "package": "org.test.vr", "vr_issues": [self.android.LAYER_MISSING_NOTE]}
with mock.patch.object(self.server.frame_catalog, "install", return_value=meta):
state = self.run_job({"action": "install", "package": "org.test.vr"})
self.assertIsNone(state["error"])
self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
with mock.patch.object(self.server.frame_apk_versions, "install", return_value=meta):
state = self.run_job({"action": "install", "package": "org.test.vr", "url": "https://example.com/v.apk"})
self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
with mock.patch.object(self.android, "install", return_value=meta):
self.assertIn(self.android.LAYER_MISSING_NOTE, self.web.dispatch("/tmp/v.apk")["message"])
# A normal install says nothing about the layer.
with mock.patch.object(self.server.frame_catalog, "install", return_value=dict(meta, vr_issues=[])):
state = self.run_job({"action": "install", "package": "org.test.vr"})
self.assertNotIn("OpenXR", state["message"])
def test_unexpected_failure_is_one_event_and_one_diagnostic(self):
info = {"package": "org.test.flat", "label": "Flat", "abis": [], "min_sdk": None, "vr": False,
"vr_activity": False, "launchable": True, "repairable": False}
sent = []
tm._seen_errors.clear()
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install",
side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
mock.patch.object(self.server.frame_catalog, "install",
side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
state = self.run_job({"action": "install", "package": "org.test.flat"})
self.assertIn("FileNotFoundError", state["error"])
events = [e for e, _ in sent]
self.assertEqual(events.count("install_finished"), 1)
self.assertEqual(events.count("$exception"), 1, events)
finished = next(p for e, p in sent if e == "install_finished")
self.assertEqual((finished["ok"], finished["error_category"]), (False, "tool_missing"))
# The same failure through a web link: one event and one diagnostic there too.
sent.clear()
tm._seen_errors.clear()
job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
"cancel": False}
plan = {"name": None, "exe": None, "url": "https://example.com/x.apk", "kind": "apk"}
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install",
side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
mock.patch.object(self.server.frame_webinstall, "download",
side_effect=lambda plan, tmp, **kw: os.path.join(tmp, "x.apk")):
self.server._webinstall_run(plan, job)
self.assertEqual(job["phase"], "error")
self.assertIn("FileNotFoundError", job["error"])
events = [e for e, _ in sent]
self.assertEqual(events.count("install_finished"), 1, events)
self.assertEqual(events.count("$exception"), 1, events)
finished = next(p for e, p in sent if e == "install_finished")
self.assertEqual((finished["kind"], finished["error_category"]), ("apk", "tool_missing"))
# A FrameError still gets its install diagnostic (the job reports it as well, as before).
sent.clear()
tm._seen_errors.clear()
with mock.patch.object(tm, "enabled", return_value=True), \
mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
mock.patch.object(self.android, "_install", side_effect=self.android.FrameError("timed out talking to frame")), \
mock.patch.object(self.server.frame_catalog, "install",
side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
self.run_job({"action": "install", "package": "org.test.flat"})
self.assertEqual([e for e, _ in sent].count("install_finished"), 1)
class ApkInstallReports(unittest.TestCase):
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
@@ -224,6 +529,12 @@ class ApkInstallReports(unittest.TestCase):
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
self.assertIs(self.install_finished.call_args[0][1], False)
def test_install_without_layer_is_flagged(self):
self.server.apk_installed({"package": "com.private.vr", "xr_layer_missing": True}, {}, None, 4.0)
self.assertIs(self.install_finished.call_args[1]["xr_layer_missing"], True)
self.server.apk_installed({"package": "com.private.vr"}, {}, None, 4.0)
self.assertIsNone(self.install_finished.call_args[1]["xr_layer_missing"])
def test_connection_trouble_is_not_reported(self):
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
+52 -12
View File
@@ -11,7 +11,7 @@ Python stdlib only. CLI: python3 ui/frame_android.py
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import base64, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
@@ -33,7 +33,8 @@ SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
XR_COMPAT_FILES = {
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
# Gzipped in the repo and the app; see frame/openxr-compat/build.sh for why.
'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz',
}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -43,6 +44,18 @@ class FrameError(RuntimeError):
pass
class LayerMissing(FrameError):
"""The OpenXR compatibility layer's files aren't in this copy of Frame Control."""
LAYER_MISSING = ("Frame Control's OpenXR compatibility layer is missing from this copy "
"(frame/openxr-compat/prebuilt); reinstall Frame Control, or in a source "
"checkout run frame/openxr-compat/build.sh")
LAYER_MISSING_NOTE = ("Installed without the OpenXR compatibility layer, which is missing from this "
"copy of Frame Control; apps that need OpenXR 1.1 may not start. Reinstalling "
"Frame Control restores it.")
def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
@@ -50,9 +63,11 @@ def ssh(cmd, input=None, timeout=120):
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}')
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -94,17 +109,29 @@ def xr_compat_files(apk_path):
for entry, rel in XR_COMPAT_FILES.items():
try:
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
add[entry] = f.read()
except OSError:
raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
data = f.read()
add[entry] = gzip.decompress(data) if rel.endswith('.gz') else data
except (OSError, EOFError, zlib.error): # gzip.BadGzipFile is an OSError
add[entry] = b''
if not add[entry]: # missing, unreadable (antivirus, permissions) or truncated
raise LayerMissing(LAYER_MISSING)
return add
def layer_note(meta):
"""The missing-layer warning for an install's completion message, or ''."""
return LAYER_MISSING_NOTE if LAYER_MISSING_NOTE in ((meta or {}).get('vr_issues') or []) else ''
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
if info['abis'] and 'arm64-v8a' not in info['abis']:
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
# Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64,
# universal) leave the choice to the user; say which file to fetch instead.
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. "
"This file is for other devices: download the APK marked arm64-v8a "
"(or arm64, or universal) and install that instead")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
@@ -159,18 +186,31 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
if flatscreen is None:
flatscreen = not info['vr']
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
# changes calls SteamVR would otherwise reject.
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
# changes calls SteamVR would otherwise reject. Without it OpenXR 1.0 apps
# still run, so a copy of Frame Control that lacks it installs anyway and
# says so, unless the layer was asked for explicitly.
add = {}
if info['vr'] if xr_compat is None else xr_compat:
try:
add = xr_compat_files(apk_path)
except LayerMissing:
if xr_compat:
raise
info['vr_issues'] = list(info.get('vr_issues') or []) + [LAYER_MISSING_NOTE]
info['xr_layer_missing'] = True
with _install_lock:
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
patched = os.path.join(tmp, 'app.apk')
info['patched'] = patch(apk_path, patched, add)['patched']
try:
info['patched'] = patch(apk_path, patched, add)['patched']
except FrameError as e:
raise FrameError(f'could not prepare the APK for the Frame: {e}') from e
info['launchable'] = True
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
else:
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
except FrameError as e:
except Exception as e: # not only FrameError: every failed install is reported
_after_install(info, None, e, start)
raise
_after_install(info, meta, None, start)
+20
View File
@@ -8,6 +8,7 @@ CLI (used by the Electron app, so terminal handling lives in one place):
import hashlib
import io
import os
import re
import shlex
import shutil
import socket
@@ -36,6 +37,25 @@ class HostError(RuntimeError):
pass
# The start of a line in which ssh itself says the link to the headset failed (not the command it ran).
SSH_LINK_FAILED = re.compile(r"^(?:ssh: connect to host |ssh: Could not resolve hostname |banner exchange: |"
r"kex_exchange_identification: |mux_client_\w+: |client_loop: |"
r"Connection (?:closed|reset) by \S+ port \d+|Connection timed out during banner exchange)",
re.M)
def ssh_link_failed(returncode, stderr):
"""Whether an ssh run failed to reach the headset: ssh's own exit code (255) and its own words."""
return returncode == 255 and bool(SSH_LINK_FAILED.search(stderr or ""))
def link_failure(error):
"""Mark an exception as ssh failing to reach the headset, judged where ssh ran (so error
reports can tell it from a message that only looks like one: a file name, say)."""
error.frame_link_failed = True
return error
class Unreachable(HostError):
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
+48 -8
View File
@@ -53,6 +53,12 @@ SENT_KEEP = 200
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
FLUSH_EVERY = 60
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
# Not faults in Frame Control: the headset asleep, away or not set up yet. The status poll
# meets these every few seconds, so each is sent at most once per session, whatever the wording,
# but only for an error marked where ssh ran as ssh failing to reach the headset
# (frame_host.link_failure). The message alone isn't evidence: a download's "Connection reset by
# peer", or a file name with ssh's words in it, keeps the usual window.
EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up')
DEFAULT_HOST = 'https://us.i.posthog.com'
LEVELS = ('usage', 'compat', 'diagnostics')
@@ -240,16 +246,29 @@ def scrub(text, limit=2000):
# From the most to the least specific; the first match wins.
CATEGORIES = [
# A web-link download (frame_webinstall) that broke or didn't check out: not the headset,
# whatever the reason, and first so a file name in the message can't put it elsewhere.
('download_failed', re.compile(r"\A(?:download failed: |download cut off at |downloaded \d+ bytes; |"
r"the server says \d+ bytes; )|doesn't match the manifest's sha256; "
r"not installing it\Z")),
('android_installer', re.compile(r'INSTALL_(?:FAILED|PARSE_FAILED)_[A-Z_]+')),
('apk_needs_newer_android', re.compile(r'needs Android API')),
('apk_wrong_abi', re.compile(r'no arm64-v8a build')),
('layer_missing', re.compile(r'OpenXR compatibility layer')),
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed')),
# Anything ssh says about its own connection: "ssh: connect to host … port 22: <reason>" (Windows
# says "Unknown error"), a dropped shared connection (mux_client_…, client_loop), and Windows'
# "banner exchange: Connection to UNKNOWN port -1" (its ssh can't name a peer whose connect
# failed late). Not a bare "ssh exited 255": a command on the Frame can exit 255 too.
('frame_unreachable', re.compile(r'(?i)timed out|Connection (?:refused|reset|closed)|No route to host|'
r'Network is unreachable|Operation timed out|asleep|kex_exchange')),
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
('flatpak', re.compile(r'(?i)flatpak|flathub')),
@@ -349,8 +368,9 @@ def frame_seen(build, version):
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
def install_finished(kind, ok, seconds=None, error=None, **props):
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names)."""
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names).
diagnose=False when the error is reported as a diagnostic elsewhere."""
p = {'kind': kind, 'ok': bool(ok), **{k: v for k, v in props.items() if v is not None}}
if seconds is not None:
p['seconds'] = round(seconds, 1)
@@ -358,21 +378,41 @@ def install_finished(kind, ok, seconds=None, error=None, **props):
p['error_category'], code = categorize(error)
if code:
p['installer_code'] = code
elif not ok:
p['error_category'] = 'other'
capture('install_finished', p)
if error is not None and not ok:
if error is not None and not ok and diagnose:
diagnostic(f'{kind} install failed', error)
def link_failed(error):
"""Whether an ssh helper marked this error (frame_host.link_failure) as ssh failing to reach the
headset: the error itself, or one it was re-raised from (`raise Failure(...) from e`)."""
for _ in range(10): # a cause chain is short; never loop on a cycle
if error is None:
return False
if getattr(error, 'frame_link_failed', False):
return True
error = getattr(error, '__cause__', None)
return False
def diagnostic(where, error, tb=None):
"""An error for the opt-in diagnostics level: scrubbed text, and a traceback if there is one."""
if not enabled('diagnostics'):
return
message = scrub(error)
fingerprint = f'{where}|{message[:120]}'
category = categorize(error)[0]
now = time.time()
with _lock:
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
return
if category in EXPECTED_CATEGORIES and link_failed(error):
fingerprint = f'expected|{category}'
if fingerprint in _seen_errors:
return
else:
fingerprint = f'{where}|{message[:120]}'
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
return
_seen_errors[fingerprint] = now
exc_type = type(error).__name__ if isinstance(error, BaseException) else 'Error'
frames = []
@@ -385,7 +425,7 @@ def diagnostic(where, error, tb=None):
'mechanism': {'handled': True, 'type': 'generic'},
'stacktrace': {'type': 'raw', 'frames': frames[-30:]}}],
'$exception_type': exc_type, '$exception_message': message,
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
'where': scrub(where, 200), 'error_category': category},
level='diagnostics')
+3 -1
View File
@@ -456,7 +456,9 @@ def dispatch(path, name=None, exe=None, progress=None, source=None):
m = frame_android.install(path, source=source or os.path.basename(path))
except frame_android.FrameError as e:
raise WebInstallError(str(e))
return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
message = f"Installed {m['label']} as its own app in the Steam library"
note = frame_android.layer_note(m)
return {"message": f"{message}. {note}" if note else message, "kind": kind, "result": m}
try:
import frame_titles
except ImportError as e:
+20 -7
View File
@@ -337,7 +337,7 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
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}")
raise frame_host.link_failure(Failure(f"Timed out talking to {FRAME}"))
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
@@ -346,6 +346,11 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
# ssh never reached the Frame (see frame_telemetry.diagnostic): judged on ssh's stderr only,
# never on the command's output.
stderr = (r.stderr if text else (r.stderr or b"").decode(errors="replace")) or ""
if frame_host.ssh_link_failed(r.returncode, strip_ansi(stderr)):
frame_host.link_failure(failure)
raise failure
return r.stdout
@@ -1255,8 +1260,8 @@ def android(body):
def work():
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.",
"app": m}
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
return start_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
@@ -1287,7 +1292,7 @@ def android(body):
" and shared it" if frame_telemetry.enabled("compat") else " on this computer")
return {"message": f"Saved your report for {name}{where}", "report": r}
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
raise Failure("unknown action", 400)
@@ -1304,8 +1309,12 @@ def apk_installed(info, meta, error, seconds):
in_catalog = bool(pkg) and pkg in by_pkg
# Package names only for catalogue apps, which are public; a private APK's name stays here.
# No version: a local rebuild can share a catalogue app's package name but carry anything in its version.
# frame_android.install re-raises anything that isn't a FrameError, and whoever
# catches it (a job, a request) reports it with its traceback: once is enough.
frame_telemetry.install_finished("apk", error is None, seconds, error, catalog=in_catalog,
package=pkg if in_catalog else None)
diagnose=error is None or isinstance(error, frame_android.FrameError),
package=pkg if in_catalog else None,
xr_layer_missing=True if (info or {}).get("xr_layer_missing") else None)
if error is not None and pkg and frame_telemetry.categorize(error)[0] in APK_FAULTS:
frame_catalog.add_report(pkg, info.get("version"), result="install_failed", notes=str(error)[:300],
via="install", label=info.get("label"))
@@ -1424,7 +1433,7 @@ def titles(body):
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
raise Failure(str(e))
raise Failure(str(e)) from e # from e: keeps frame_host.link_failure's mark for diagnostics
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
@@ -1844,8 +1853,12 @@ def _webinstall_run(plan, job):
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
job["phase"] = "error"
# An APK that failed to install was counted by apk_installed.
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
apk_install = stage == "install" and plan.get("kind") == "apk"
if not isinstance(e, frame_webinstall.Cancelled) and not apk_install:
frame_telemetry.install_finished("web", False, error=e, stage=stage, kind_detail=plan.get("kind"))
elif apk_install and not isinstance(e, known):
# Not a FrameError, so apk_installed left its diagnostic to whoever caught it: here.
frame_telemetry.diagnostic("web install", e)
finally:
with _web_lock:
job.pop("_conn", None)