VulpesOS/tundra/droidian/session.py
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

208 lines
11 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Run an isolated Vulpes window inside the existing Droidian Wayland session."""
import argparse
import fcntl
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
def drop_capabilities():
"""The compositor may inherit hardware capabilities; Gaia must not keep them."""
import ctypes
libc=ctypes.CDLL(None,use_errno=True)
if libc.prctl(47,4,0,0,0): # PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL
raise OSError(ctypes.get_errno(),'clear ambient capabilities')
class Header(ctypes.Structure):
_fields_=[('version',ctypes.c_uint32),('pid',ctypes.c_int)]
class Data(ctypes.Structure):
_fields_=[('effective',ctypes.c_uint32),('permitted',ctypes.c_uint32),('inheritable',ctypes.c_uint32)]
if libc.capset(ctypes.byref(Header(0x20080522,0)),(Data*2)()):
raise OSError(ctypes.get_errno(),'drop process capabilities')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--hybris-egl', action='store_true', help='Use the hash-pinned local libhybris EGL correction')
parser.add_argument('--software', action='store_true', help='Use software WebRender for EGL driver diagnostics')
parser.add_argument('--headless', action='store_true', help='Test Gecko/Gaia without the locked physical display')
parser.add_argument('--no-forkserver', action='store_true', help='Diagnose process creation on the old device kernel')
parser.add_argument('--trace-navigation', action='store_true', help='Write detailed Gecko navigation logs for this trial')
parser.add_argument('--own-compositor', action='store_true', help='Run inside the dedicated temporary Tundra compositor, without Phosh services')
parser.add_argument('--diagnostics', action='store_true', help='Open apps and scroll Gaia for the interactive qualification suite')
args = parser.parse_args()
if os.getuid() == 0:
raise RuntimeError('Run as the graphical session user, never root')
drop_capabilities()
trials = str(Path.home()/'vulpes-tundra-tests').encode()+b'/'
for proc in Path('/proc').glob('[0-9]*'):
try:
command = (proc/'cmdline').read_bytes().split(b'\0')
except OSError:
continue
if command and command[0].startswith(trials) and b'/product/engines/' in command[0]:
raise RuntimeError(f'A previous trial engine is still present: PID {proc.name}')
logs = ROOT/'logs'
logs.mkdir(exist_ok=True)
guard = (logs/'session.lock').open('w')
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
with socket.socket() as check:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
check.bind(('127.0.0.1', 8765))
env = {k:v for k,v in os.environ.items() if not k.startswith('MOZ_DISABLE_')}
for key in ('MOZ_HEADLESS', 'MOZ_MARIONETTE', 'B2G_HOMESCREEN'):
env.pop(key, None)
runtime_dir = Path(f'/run/user/{os.getuid()}')
display = 'tundra-0' if args.own_compositor else 'wayland-0'
if not (runtime_dir/display).is_socket():
raise RuntimeError('Existing Wayland session not found')
env.update(XDG_RUNTIME_DIR=str(runtime_dir), WAYLAND_DISPLAY=display,
DBUS_SESSION_BUS_ADDRESS=env.get('DBUS_SESSION_BUS_ADDRESS',f'unix:path={runtime_dir}/bus'),
MOZ_ENABLE_WAYLAND='1', EGL_PLATFORM='wayland', GDK_GL='gles',
VULPES_FULLSCREEN='1', VULPES_NATIVE_TOUCH='1',
VULPES_WORKSPACE_ROOT=str(ROOT))
if args.hybris_egl:
import hashlib
graphics = ROOT/'trial/graphics'
manifest = json.loads((graphics/'manifest.json').read_text())
vendor = Path(manifest['vendorLibrary'])
adapter = graphics/'libEGL_vulpes_hybris.so'
for path, expected in [(vendor,manifest['vendorSha256']), (adapter,manifest['adapterSha256'])]:
if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise RuntimeError('EGL adapter/library changed; requalify before use: '+str(path))
descriptor = graphics/'vulpes-egl.json'
descriptor.write_text(json.dumps({'file_format_version':'1.0.0','ICD':{'library_path':str(adapter)}}))
env['__EGL_VENDOR_LIBRARY_FILENAMES'] = str(descriptor)
env['TUNDRA_DIAGNOSTICS'] = '1' if args.diagnostics else '0'
env['TUNDRA_BOOT_ID'] = Path('/proc/sys/kernel/random/boot_id').read_text().strip()
env['TUNDRA_HEADLESS'] = '1' if args.headless else '0'
env['TUNDRA_OWN_COMPOSITOR'] = '1' if args.own_compositor else '0'
if args.trace_navigation:
env.update(MOZ_LOG='sync,timestamp,nsDocShell:5,DocumentChannel:5,ProcessIsolation:5,nsHttp:5',
MOZ_LOG_FILE=str(logs/'navigation.log'))
if args.headless:
env.update(MOZ_HEADLESS='1', VULPES_FULLSCREEN='0')
inhibitor = None
if not args.headless and not args.own_compositor:
from gi.repository import Gio, GLib
bus = Gio.DBusConnection.new_for_address_sync(env['DBUS_SESSION_BUS_ADDRESS'],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
None, None)
locked = bus.call_sync('org.gnome.ScreenSaver', '/org/gnome/ScreenSaver',
'org.gnome.ScreenSaver', 'GetActive', None, GLib.VariantType.new('(b)'),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
if locked:
raise RuntimeError('Unlock the phone before starting the visible trial')
cookie = bus.call_sync('org.gnome.SessionManager', '/org/gnome/SessionManager',
'org.gnome.SessionManager', 'Inhibit',
GLib.Variant('(susu)', ('org.vulpes.TundraTrial', 0, 'Essai graphique Vulpes', 8)),
GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
# The session manager releases this temporary idle inhibitor when our
# bus connection closes, including after a crash. Manual lock still works.
inhibitor = bus
sys.path.insert(0, str(ROOT/'tools'))
spec = importlib.util.spec_from_file_location('host', ROOT/'tools/run-host.py')
host = importlib.util.module_from_spec(spec)
spec.loader.exec_module(host)
lock = json.loads((ROOT/'engine-lock.json').read_text())
runtime = host.prepare(lock, test=True)
import shutil
shutil.copy2(ROOT/'trial/probe.sys.mjs', runtime/'vulpes/tests/DroidianProbe.sys.mjs')
with (runtime/'vulpes.cfg').open('a') as cfg:
cfg.write('''
Services.obs.addObserver({observe(win) {
win.addEventListener('load', () => {
if(win.document.documentElement.getAttribute('windowtype') === 'vulpes:host')
ChromeUtils.importESModule('resource://vulpes/tests/DroidianProbe.sys.mjs').run(win)
.catch(e => dump('TUNDRA_PROBE_ERROR '+e+'\\n'));
}, {once:true});
}}, 'domwindowopened');
''')
env['VULPES_HOST_ROOT'] = str(runtime/'vulpes/host')
profile = ROOT/'profiles/droidian-trial'
profile.mkdir(parents=True, exist_ok=True)
prefs = {'browser.shell.checkDefaultBrowser':False,
'font.default.x-western':'sans-serif',
'dom.w3c_touch_events.legacy_apis.enabled':True,
'font.name.sans-serif.x-western':'Fira Sans',
'browser.dom.window.dump.enabled':True,
'browser.startup.homepage_override.mstone':'ignore',
'browser.aboutwelcome.enabled':False,
'datareporting.policy.dataSubmissionEnabled':False,
'toolkit.telemetry.enabled':False, 'app.update.auto':False,
'marionette.enabled':env.get('TUNDRA_LOCAL_DEBUGGER')=='1',
'marionette.port':2858,'marionette.host':'127.0.0.1',
# Write both states: prefs.js retains values between trials.
'gfx.webrender.software':args.software,
'layers.acceleration.disabled':args.software,
'gfx.webrender.allow-partial-present-buffer-age':False,
'dom.ipc.forkserver.enable':not args.no_forkserver}
if args.software:
prefs.update({'gfx.webrender.software':True, 'layers.acceleration.disabled':True,
'media.ffmpeg.vaapi.enabled':False})
(profile/'user.js').write_text('\n'.join(
f'user_pref({json.dumps(k)}, {json.dumps(v)});' for k,v in prefs.items())+'\n')
(logs/'session.pid').write_text(str(os.getpid())+'\n')
def stop(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
processes = []
try:
with (logs/'server.log').open('w') as output:
server = subprocess.Popen([sys.executable, '-u', str(ROOT/'tools/serve-gaia.py')],
stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(server)
for _ in range(100):
if server.poll() is not None:
raise RuntimeError('Gaia server exited: see logs/server.log')
try:
with socket.create_connection(('127.0.0.1',8765), timeout=.2):
break
except OSError:
time.sleep(.1)
else:
raise RuntimeError('Gaia server startup timeout')
with (logs/'gecko.log').open('w') as output:
debug_args=['--remote-allow-system-access','-marionette','-new-window','about:blank'] if env.get('TUNDRA_LOCAL_DEBUGGER')=='1' else []
firefox = subprocess.Popen([str(runtime/'firefox'), *debug_args, '-purgecaches', '-no-remote',
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml'],
env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(firefox)
result = firefox.wait()
if result:
raise RuntimeError(f'Gecko exited with {result}: see logs/gecko.log')
finally:
remaining = []
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
remaining.append(process.pid)
(logs/'stop.json').write_text(json.dumps({'unreapedProcesses':remaining})+'\n')
(logs/'session.pid').unlink(missing_ok=True)
if inhibitor:
inhibitor.close_sync(None)
if remaining:
raise RuntimeError(f'Kernel has not released trial processes: {remaining}')
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
pass