mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 05:00:26 +01:00
Guard real Frame smoke with shared lock and standby preflight
This commit is contained in:
1 parent
aeac07cf54
commit
aeb6bb5294
5 files changed
+214
-13
No files matched your search
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"tag": "v0.4.38",
|
||||
"commit": "d87b863aa3c61c9833045b29ba79c7534a351ef4",
|
||||
"startedUtc": "2026-10-26T01:47:23Z",
|
||||
"frameOSVersion": "0.5.6",
|
||||
"frameOSBuild": "20261022.6153909",
|
||||
"preflight": {
|
||||
"steamVRActivity": 3,
|
||||
"proximityRaw": 6.3125,
|
||||
"batteryPercent": 100
|
||||
},
|
||||
"postflight": {
|
||||
"steamVRActivity": 3,
|
||||
"proximityRaw": 6.25,
|
||||
"batteryPercent": 100
|
||||
},
|
||||
"smoke": {
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"arm64": "running",
|
||||
"x86_64": "runtime missing",
|
||||
"windowsExe": "running",
|
||||
"finalCleanupPassed": true,
|
||||
"ownedProcessesAfterCleanup": 0
|
||||
},
|
||||
"sharedLock": "unique owner released",
|
||||
"limits": [
|
||||
"No worn headset",
|
||||
"No physical controller input",
|
||||
"No official Decky panel tested in this smoke"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# v0.4.38 real Frame release smoke
|
||||
|
||||
On 2026-10-26 at 01:47 UTC, the exact `v0.4.38` tag
|
||||
`d87b863aa3c61c9833045b29ba79c7534a351ef4` passed `scripts/frame-smoke.sh`
|
||||
on a real Frame running SteamOS 0.5.6 build `20261022.6153909`. The wrapper
|
||||
atomically acquired `/tmp/frame-test.lock` with a unique owner, then read
|
||||
SteamVR activity 3 (standby), proximity raw 6.3125 and battery 100%. It ran
|
||||
the smoke only after that preflight.
|
||||
|
||||
All 13 smoke steps passed. The ARM64 test title ran. The x86_64 title correctly
|
||||
reported its runtime missing. The Windows executable ran through the ARM64
|
||||
Proton path. The final cleanup found no test processes and removed the reserved
|
||||
smoke titles. The read-only postflight still found activity 3 and battery 100%;
|
||||
the wrapper released only its own lock. The public numeric summary is
|
||||
[v0438-release-smoke-2026-10-26.json](v0438-release-smoke-2026-10-26.json).
|
||||
Private raw logs and process paths remain in `/tmp` and were not published.
|
||||
|
||||
The new built-in shared-lock preflight on this PR's branch separately passed
|
||||
the same 13/13 smoke steps at 01:46 UTC, with activity 3, proximity raw 6.3125
|
||||
and battery 100% before the run. The lock was released after cleanup. The local
|
||||
`tests.test_smoke_cleanup` suite passed 20 tests, including refusal on a busy
|
||||
lock and release after a failed preflight. The built-in preflight was **not**
|
||||
part of the v0.4.38 tag; that release used the external lock wrapper described
|
||||
above.
|
||||
|
||||
These checks did not involve a worn headset or prove Framey panel visibility,
|
||||
physical controller interaction, or optical comfort.
|
||||
@@ -208,6 +208,13 @@ It resolves the checkout path independently of the current working directory.
|
||||
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
|
||||
does not contact the headset.
|
||||
|
||||
Before reading headset status or changing titles, the smoke script atomically
|
||||
acquires the shared `/tmp/frame-test.lock` with a unique owner token. It reads
|
||||
SteamVR activity, proximity and battery while locked. An awake headset, unknown
|
||||
activity, battery below 20%, or a busy lock stops the run without installing
|
||||
anything. It releases only its own lock after cleanup. The JSON result includes
|
||||
the read-only preflight for completed smoke runs.
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
|
||||
+96
-12
@@ -4,6 +4,11 @@ Runs on your computer through the `frame` SSH alias (or FRAME_ALIAS), with
|
||||
Frame Control's own modules, and records what happened with the headset's
|
||||
BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
||||
|
||||
Before any status read or title change, it atomically owns /tmp/frame-test.lock
|
||||
on the Frame and checks SteamVR standby, proximity and battery read-only. A
|
||||
busy lock, awake headset or battery below 20% stops the run. Only this run's
|
||||
lock is released, including after test failures.
|
||||
|
||||
- properties.json from Valve's devkit service names the login user;
|
||||
- status (ui/frame_status.py) reads the build, battery and storage;
|
||||
- three tiny titles (tests/smoke/tiny_programs.py: ARM64 and x86-64 static
|
||||
@@ -28,6 +33,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -50,6 +56,60 @@ import stop_processes # noqa: E402
|
||||
|
||||
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||
PREFIX = 'fc_smoke_'
|
||||
LOCK = '/tmp/frame-test.lock'
|
||||
|
||||
|
||||
def remote(command, *, input=None):
|
||||
"""Use a bounded SSH call for shared-lock operations."""
|
||||
return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ALIAS, command],
|
||||
input=input, capture_output=True, text=True, timeout=20)
|
||||
|
||||
|
||||
def acquire_lock(token):
|
||||
"""Atomically reserve the Frame before reading its state or changing titles."""
|
||||
command = (f'mkdir {LOCK} && {{ printf %s {shlex.quote(token)} > {LOCK}/owner || '
|
||||
f'{{ rmdir {LOCK}; exit 1; }}; }}')
|
||||
return remote(command).returncode == 0
|
||||
|
||||
|
||||
def release_lock(token):
|
||||
"""Never remove a lock if its owner changed."""
|
||||
command = (f'test "$(cat {LOCK}/owner)" = {shlex.quote(token)} && '
|
||||
f'rm {LOCK}/owner && rmdir {LOCK}')
|
||||
return remote(command).returncode == 0
|
||||
|
||||
|
||||
PREFLIGHT = r'''
|
||||
import json, pathlib, subprocess
|
||||
r = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd', '--stats'],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
try: activity = json.loads(r.stdout)[0]['activity_level'] if r.returncode == 0 else None
|
||||
except (ValueError, KeyError, IndexError, TypeError): activity = None
|
||||
proximity = []
|
||||
for path in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try: proximity.append(float(path.read_text().strip()))
|
||||
except (OSError, ValueError): pass
|
||||
battery = []
|
||||
for path in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (path.parent/'type').read_text().strip() == 'Battery':
|
||||
battery.append(int(path.read_text().strip()))
|
||||
except (OSError, ValueError): pass
|
||||
print(json.dumps({'activity': activity, 'proximity': proximity, 'battery': battery}))
|
||||
'''
|
||||
|
||||
|
||||
def preflight():
|
||||
result = remote('python3 -', input=PREFLIGHT)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Locked SteamVR and battery preflight failed: ' + result.stderr.strip())
|
||||
state = json.loads(result.stdout)
|
||||
if state.get('activity') != 3:
|
||||
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
|
||||
battery = state.get('battery')
|
||||
if not battery or min(battery) < 20:
|
||||
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
|
||||
return state
|
||||
|
||||
|
||||
def stop_titles(ids):
|
||||
@@ -308,17 +368,8 @@ def pair(folder):
|
||||
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
|
||||
args = ap.parse_args()
|
||||
|
||||
r = subprocess.run(['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', ALIAS, 'true'],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f'{ALIAS} is not reachable over SSH: {r.stderr.strip()}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
def run_smoke(args, before):
|
||||
"""Run only after the shared lock and read-only preflight succeeded."""
|
||||
smoke = Smoke()
|
||||
started = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
release = os_release()
|
||||
@@ -347,12 +398,45 @@ def main():
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
|
||||
'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
|
||||
args = ap.parse_args()
|
||||
token = 'frame-smoke-' + secrets.token_hex(10)
|
||||
try:
|
||||
locked = acquire_lock(token)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
locked = False
|
||||
if not locked:
|
||||
print('Shared Frame lock busy or unreachable; no headset status read or smoke run.', file=sys.stderr)
|
||||
return 2
|
||||
result = 1
|
||||
try:
|
||||
try:
|
||||
before = preflight()
|
||||
print('Locked read-only preflight: ' + json.dumps(before), flush=True)
|
||||
result = run_smoke(args, before)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as failure:
|
||||
print(str(failure), file=sys.stderr)
|
||||
result = 2
|
||||
finally:
|
||||
try:
|
||||
released = release_lock(token)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
released = False
|
||||
if not released:
|
||||
print('ERROR: could not release owned Frame lock.', file=sys.stderr)
|
||||
result = 1
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -139,11 +140,61 @@ class Safety(unittest.TestCase):
|
||||
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
|
||||
mock.patch('builtins.print'):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||
{'activity':3,'proximity':[],'battery':[100]}), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
|
||||
|
||||
class SharedSmokeLock(unittest.TestCase):
|
||||
def test_busy_lock_prevents_any_status_read_or_title_change(self):
|
||||
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke, 'acquire_lock', return_value=False), \
|
||||
mock.patch.object(frame_smoke, 'preflight') as preflight, \
|
||||
mock.patch.object(frame_smoke, 'run_smoke') as run, \
|
||||
mock.patch.object(frame_smoke, 'release_lock') as release:
|
||||
self.assertEqual(frame_smoke.main(), 2)
|
||||
preflight.assert_not_called()
|
||||
run.assert_not_called()
|
||||
release.assert_not_called()
|
||||
|
||||
def test_preflight_requires_standby_and_sufficient_battery(self):
|
||||
for state in ({'activity':1,'proximity':[1],'battery':[100]},
|
||||
{'activity':None,'proximity':[],'battery':[100]},
|
||||
{'activity':3,'proximity':[],'battery':[19]},
|
||||
{'activity':3,'proximity':[],'battery':[]}):
|
||||
with self.subTest(state=state), mock.patch.object(frame_smoke, 'remote',
|
||||
return_value=mock.Mock(returncode=0, stdout=json.dumps(state))):
|
||||
with self.assertRaises(RuntimeError):
|
||||
frame_smoke.preflight()
|
||||
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
|
||||
returncode=0, stdout=json.dumps({'activity':3,'proximity':[0],'battery':[99]}))):
|
||||
self.assertEqual(frame_smoke.preflight()['battery'], [99])
|
||||
|
||||
def test_owner_checked_on_release_and_preflight_failure_still_releases(self):
|
||||
commands = []
|
||||
def remote(command, **_):
|
||||
commands.append(command)
|
||||
return mock.Mock(returncode=0)
|
||||
with mock.patch.object(frame_smoke, 'remote', side_effect=remote):
|
||||
self.assertTrue(frame_smoke.acquire_lock('frame-smoke-test-token'))
|
||||
self.assertTrue(frame_smoke.release_lock('frame-smoke-test-token'))
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
|
||||
self.assertIn('> /tmp/frame-test.lock/owner', commands[0])
|
||||
self.assertIn('test "$(cat /tmp/frame-test.lock/owner)" = frame-smoke-test-token', commands[1])
|
||||
self.assertLess(commands[1].index('test '), commands[1].index('rm /tmp/frame-test.lock/owner'))
|
||||
|
||||
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke, 'acquire_lock', return_value=True), \
|
||||
mock.patch.object(frame_smoke, 'preflight', side_effect=RuntimeError('awake')), \
|
||||
mock.patch.object(frame_smoke, 'run_smoke') as run, \
|
||||
mock.patch.object(frame_smoke, 'release_lock', return_value=True) as release:
|
||||
self.assertEqual(frame_smoke.main(), 2)
|
||||
run.assert_not_called()
|
||||
release.assert_called_once()
|
||||
self.assertTrue(release.call_args.args[0].startswith('frame-smoke-'))
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
|
||||
class OrphanCleanup(unittest.TestCase):
|
||||
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
|
||||
|
||||
Reference in new issue
Block a user