Improve base guidance and qualify bounded aircraft route and hidden test lifecycle
This commit is contained in:
@@ -21,7 +21,7 @@ except (OSError, ValueError) as error:
|
||||
dest=ROOT/'docs/evidence'/a.name
|
||||
try:dest.mkdir(parents=True,exist_ok=False)
|
||||
except FileExistsError:p.error('Evidence already exists; refusing to overwrite')
|
||||
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json']:
|
||||
for name in ['console.log','script.log','error.log','crash.log','rereintroduced-hq.log','headless-launch.log','runner.json','game-startup-report.json','display-ownership.json']:
|
||||
if (source/name).is_file():shutil.copyfile(source/name,dest/(name+'.txt'))
|
||||
for capture in sorted([*source.glob('err-*.png'), *source.glob('err-*.bmp')]):
|
||||
if capture.is_file():shutil.copyfile(capture,dest/capture.name)
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ def check():
|
||||
assert set(mapped)==set(inventory['script_paths']), 'Original inventory has untracked or unknown entries'
|
||||
print(f'PASS: {len(mapped)} original script entries tracked across {len(feature_ids)} unfinished responsibilities')
|
||||
print(f'PASS: {len(ids)} isolated addons, acyclic dependencies, {len(resources)} unique resources, exact vanilla references')
|
||||
for name in ['test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
for name in ['test_private_prefix_cleanup.py', 'test_runner_private_cleanup.py', 'test_ordinary_input_runner.py', 'test_ordinary_input_observer.py', 'test_aircraft_route_envelope.py', 'test_aircraft_route_policy.py', 'test_aircraft_route_probe.py', 'test_rough_intel_feedback.py', 'test_headless_display_probe.py', 'test_headless_display_runner.py', 'test_preparation_status.py', 'test_hint_delivery.py', 'test_native_end_lifecycle.py', 'test_native_end_runner.py', 'test_escape_result_wording.py', 'test_native_stats.py', 'test_aircraft_translation.py', 'test_spawn_retry_budget.py', 'test_action_feedback.py', 'test_native_aircraft_control.py', 'test_aircraft_flight_policy.py', 'test_aircraft_flight_envelope.py', 'test_ordinary_screenshot.py', 'test_participant_selection.py', 'test_native_shared_map_ownership.py', 'test_shared_map_ownership.py', 'test_intel_registration.py', 'test_mortar_activity_completion.py', 'test_packed_chat_game.py', 'test_native_mortar_refresh.py', 'test_archive_packed.py', 'test_mortar_refresh.py', 'test_packed_core_game.py', 'test_pak_inventory.py', 'test_native_mortar_salvo.py', 'test_spectator_guidance.py', 'test_native_package.py', 'test_item_station_teardown.py', 'test_source_bundle.py', 'test_native_flight_footprint.py', 'test_native_flight_input_diagnostics.py', 'test_mortar_logistics.py', 'test_mortar_courier_storage.py','test_powered_flight_pad.py','test_prison_escape_policy.py','test_powered_flight_policy.py','test_flight_action_runner.py','test_native_local_admission_probe.py','test_mortar_report_ledger.py','test_dedicated_native.py','test_native_mortar_transfer.py','test_mortar_aim_renewal.py','test_dedicated_preflight.py','test_mortar_transactions.py','test_native_mortar_probe.py','test_diagnostic_no_splash.py','test_casualty_incapacitation.py','test_casualty_remove_probe.py','test_extraction_placement_policy.py','test_guard_cancellation_policy.py','test_casualty_ground_policy.py','test_casualty_reservation_death.py','test_casualty_loading_policy.py','test_initial_guard_policy.py','test_headless_game_runner.py','test_vanilla_game_control.py','test_archive_game_control.py','test_site_discovery_policy.py','test_faction_catalog_scope.py','test_mission_rules.py','test_admin_setting_policy.py', 'test_player_admission_policy.py','test_progression_identity.py','test_discovery_map_position.py','test_local_admission.py','test_prison_placement_policy.py', 'test_recovery_policy.py','test_time_policy.py','test_environment_policy.py','test_environment_restart_runner.py', 'test_inventory_enumeration_policy.py','test_radio_defence_policy.py','test_radio_feedback_policy.py','test_performance_comparison.py','test_release_check.py','test_settings_migration.py','hq_network_regression.py','helicopter_availability_regression.py','mortar_salvo_regression.py']:
|
||||
subprocess.run([sys.executable,str(ROOT/'tests'/name)],check=True)
|
||||
print('All repository checks passed. Native compilation and multiplayer tests are separate requirements.')
|
||||
if __name__=='__main__':check()
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only X11 ownership observer, executed INSIDE an owned headless Gamescope.
|
||||
No input/focus API, no engine launch of its own, no desktop DISPLAY fallback.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
PROFILE_ID = {'kind': 'erri-ordinary-game-disposable-profile', 'version': 1}
|
||||
MAX_WINDOWS = 1024
|
||||
|
||||
|
||||
def display_number(value):
|
||||
match = re.fullmatch(r'(?:unix)?:([0-9]{1,5})(?:\.([0-9]{1,3}))?', value or '')
|
||||
if not match:
|
||||
raise ValueError('DISPLAY must be explicit local Unix :N[.screen]')
|
||||
return int(match[1])
|
||||
|
||||
|
||||
def process(pid, proc_root=Path('/proc')):
|
||||
p = proc_root / str(pid)
|
||||
fields = (p/'stat').read_text().rsplit(')', 1)[1].split()
|
||||
return {'pid': int(pid), 'ppid': int(fields[1]), 'start': int(fields[19]),
|
||||
'uid': p.stat().st_uid, 'exe': os.readlink(p/'exe'),
|
||||
'args': [v.decode('utf-8', 'strict') for v in (p/'cmdline').read_bytes().split(b'\0') if v]}
|
||||
|
||||
|
||||
def environment(pid, proc_root=Path('/proc')):
|
||||
# Only allowlisted values leave this function; never serialize the environment.
|
||||
values = {}
|
||||
for value in (proc_root/str(pid)/'environ').read_bytes().split(b'\0'):
|
||||
key, _, content = value.partition(b'=')
|
||||
if key in (b'DISPLAY', b'WINEPREFIX', b'PULSE_PROP'):
|
||||
values[key.decode()] = content.decode('utf-8', 'strict')
|
||||
return values
|
||||
|
||||
|
||||
def prefix_matches(value, expected):
|
||||
"""Proton's pfx/ spelling only; do not normalize aliases or path components."""
|
||||
return isinstance(value, str) and value.rstrip('/') == str(expected)
|
||||
|
||||
|
||||
def identity(p):
|
||||
return {key: p[key] for key in ('pid', 'start', 'uid')}
|
||||
|
||||
|
||||
def headless_args(args):
|
||||
options = args[1:args.index('--')] if '--' in args else []
|
||||
return (options.count('--backend') == 1 and
|
||||
options[options.index('--backend') + 1:options.index('--backend') + 2] == ['headless'] and
|
||||
not any(value.startswith('--backend=') for value in options))
|
||||
|
||||
|
||||
def ancestor(pid, read=process):
|
||||
seen = set()
|
||||
for _ in range(64):
|
||||
if pid < 2 or pid in seen:
|
||||
break
|
||||
seen.add(pid)
|
||||
p = read(pid)
|
||||
if p['uid'] != os.getuid():
|
||||
raise ValueError('Foreign-user ancestor')
|
||||
if Path(p['exe']).name == 'gamescope':
|
||||
if not headless_args(p['args']):
|
||||
raise ValueError('Gamescope ancestor is not explicitly headless')
|
||||
return p
|
||||
pid = p['ppid']
|
||||
raise ValueError('No owned headless Gamescope ancestor')
|
||||
|
||||
|
||||
def descendant(pid, parent, read=process):
|
||||
for _ in range(64):
|
||||
if pid == parent:
|
||||
return True
|
||||
if pid < 2:
|
||||
break
|
||||
pid = read(pid)['ppid']
|
||||
return False
|
||||
|
||||
|
||||
def validate_display(display, host_display):
|
||||
number = display_number(display)
|
||||
# Host DISPLAY can be remote; never open it. Also reject localhost aliases.
|
||||
host_match = re.fullmatch(r'(?:unix|localhost)?:(\d+)(?:\.\d+)?', host_display or '')
|
||||
if display == host_display or (host_match and int(host_match[1]) == number):
|
||||
raise ValueError('Nested DISPLAY is the supplied host display')
|
||||
return number
|
||||
|
||||
|
||||
def owned_sockets(number, pid, proc_root=Path('/proc')):
|
||||
"""Prove every local Xlib connection address for this number is owned, before XOpenDisplay."""
|
||||
paths = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
|
||||
sockets = []
|
||||
for line in (proc_root/'net/unix').read_text().splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or fields[7] not in paths:
|
||||
continue
|
||||
if fields[4] != '0001' or fields[5] != '01' or not int(fields[3], 16) & 0x10000:
|
||||
continue
|
||||
sockets.append({'path':fields[7], 'inode':int(fields[6])})
|
||||
if not sockets or len({item['path'] for item in sockets}) != len(sockets):
|
||||
raise ValueError('No unique live local X11 listening socket')
|
||||
fds = set()
|
||||
for fd in (proc_root/str(pid)/'fd').iterdir():
|
||||
try:
|
||||
target = os.readlink(fd)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
match = re.fullmatch(r'socket:\[(\d+)\]', target)
|
||||
if match:
|
||||
fds.add(int(match[1]))
|
||||
if any(item['inode'] not in fds for item in sockets):
|
||||
raise ValueError('Local display socket is not held by owned Xwayland')
|
||||
return sorted(sockets, key=lambda item:item['path'])
|
||||
|
||||
|
||||
def candidate_relationship(p, scope, number, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
|
||||
"""Independent, sanitized process/socket checks for reparented Xwayland."""
|
||||
expected = os.environ.get('PULSE_PROP', '')
|
||||
valid_nonce = bool(re.fullmatch(r'application.id=org\.escapistsrereintroduced\.game\.[0-9a-f]{32}', expected))
|
||||
result = {'start_not_older_than_gamescope':p['start'] >= scope['start'],
|
||||
'wrapper_run_nonce_valid':valid_nonce,
|
||||
'xwayland_prefix_matches':None, 'gamescope_prefix_matches':None,
|
||||
'xwayland_run_nonce_matches':None, 'gamescope_run_nonce_matches':None,
|
||||
'xwayland_holds_all_display_listeners':False, 'gamescope_holds_all_display_listeners':False,
|
||||
'same_listener_inodes':False, 'listener_inodes':[], 'process_identities_stable':False}
|
||||
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
|
||||
try:
|
||||
env = env_read(pid)
|
||||
result[role+'_prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
|
||||
result[role+'_run_nonce_matches'] = valid_nonce and env.get('PULSE_PROP') == expected
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
pass
|
||||
listeners = {}
|
||||
for role, pid in (('xwayland',p['pid']),('gamescope',scope['pid'])):
|
||||
try:
|
||||
listeners[role] = owned_sockets(number,pid,proc_root)
|
||||
result[role+'_holds_all_display_listeners'] = True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if 'xwayland' in listeners:
|
||||
result['listener_inodes'] = [item['inode'] for item in listeners['xwayland']]
|
||||
result['same_listener_inodes'] = bool(listeners.get('xwayland')) and listeners.get('xwayland') == listeners.get('gamescope')
|
||||
try:
|
||||
result['process_identities_stable'] = identity(read(p['pid'])) == identity(p) and identity(read(scope['pid'])) == identity(scope)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
SHARED_CHECKS = (
|
||||
'start_not_older_than_gamescope', 'wrapper_run_nonce_valid',
|
||||
'xwayland_prefix_matches', 'gamescope_prefix_matches',
|
||||
'xwayland_run_nonce_matches', 'gamescope_run_nonce_matches',
|
||||
'xwayland_holds_all_display_listeners', 'gamescope_holds_all_display_listeners',
|
||||
'same_listener_inodes', 'process_identities_stable',
|
||||
)
|
||||
|
||||
|
||||
def shared_relationship_valid(details):
|
||||
if not isinstance(details, dict) or not all(details.get(key) is True for key in SHARED_CHECKS):
|
||||
return False
|
||||
inodes = details.get('listener_inodes')
|
||||
return (isinstance(inodes, list) and 1 <= len(inodes) <= 2 and
|
||||
all(type(inode) is int and inode > 0 for inode in inodes) and len(set(inodes)) == len(inodes))
|
||||
|
||||
|
||||
def binding(host_display, prefix, read=process, env_read=environment, proc_root=Path('/proc')):
|
||||
display = os.environ.get('DISPLAY', '')
|
||||
number = validate_display(display, host_display)
|
||||
if not prefix_matches(os.environ.get('WINEPREFIX'),prefix):
|
||||
raise ValueError('Wrapper prefix does not match supplied private prefix')
|
||||
scope = ancestor(os.getppid(), read)
|
||||
matches = []
|
||||
candidates = []
|
||||
candidate_count = 0
|
||||
unreadable = 0
|
||||
for entry in proc_root.iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
p = read(int(entry.name))
|
||||
if p['uid'] != os.getuid():
|
||||
continue
|
||||
basename = Path(p['exe']).name
|
||||
argv_name = Path(p['args'][0]).name if p['args'] else ''
|
||||
# Broader names are DIAGNOSTICS ONLY. The authorization check below
|
||||
# still requires the exact native executable name and an owned relationship.
|
||||
if 'xwayland' not in basename.lower() and 'xwayland' not in argv_name.lower():
|
||||
continue
|
||||
candidate_count += 1
|
||||
displays = [v for v in p['args'][1:] if re.fullmatch(r':\d+(?:\.\d+)?', v)]
|
||||
portal = any(v == '-enable-ei-portal' or v.startswith('-enable-ei-portal=') for v in p['args'])
|
||||
try:
|
||||
nested = descendant(p['pid'], scope['pid'], read)
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
nested = None
|
||||
relationship = None
|
||||
if len(displays) == 1 and display_number(displays[0]) == number:
|
||||
relationship = candidate_relationship(p,scope,number,prefix,read,env_read,proc_root)
|
||||
if len(candidates) < 16:
|
||||
candidate = {**identity(p), 'ppid':p['ppid'],
|
||||
'executable_basename':basename[:96],
|
||||
'display_args':displays[:4], 'display_arg_count':len(displays),
|
||||
'portal_enabled':portal, 'descendant':nested,
|
||||
'exact_executable_name':basename == 'Xwayland'}
|
||||
if relationship is not None:
|
||||
candidate['selected_display_relationship'] = relationship
|
||||
candidates.append(candidate)
|
||||
if basename != 'Xwayland':
|
||||
continue
|
||||
if len(displays) != 1 or display_number(displays[0]) != number:
|
||||
continue
|
||||
if portal:
|
||||
raise ValueError('Nested Xwayland has input-emulation portal enabled')
|
||||
if nested is True:
|
||||
matches.append((p, {'mode':'descendant', 'descendant':True}))
|
||||
elif shared_relationship_valid(relationship):
|
||||
matches.append((p, {'mode':'shared-listeners', 'checks':relationship}))
|
||||
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
||||
unreadable += 1
|
||||
continue
|
||||
if len(matches) != 1:
|
||||
diagnostic = {'display':display, 'gamescope':{**identity(scope),'ppid':scope['ppid']},
|
||||
'matching_candidates':len(matches), 'same_user_xwayland_candidates':candidates,
|
||||
'candidate_count':candidate_count, 'candidate_limit':16,
|
||||
'unreadable_process_count':unreadable,
|
||||
'read_only':True, 'display_opened':False, 'input_sent':False}
|
||||
raise ValueError('Cannot identify exactly one owned nested Xwayland; preflight=' +
|
||||
json.dumps(diagnostic,sort_keys=True,separators=(',',':')))
|
||||
selected, ownership = matches[0]
|
||||
sockets = owned_sockets(number, selected['pid'], proc_root)
|
||||
for pinned in (selected, scope):
|
||||
current = read(pinned['pid'])
|
||||
if identity(current) != identity(pinned) or current['exe'] != pinned['exe'] or current['args'] != pinned['args']:
|
||||
raise ValueError('Native process changed during socket ownership check')
|
||||
if ownership['mode'] == 'shared-listeners':
|
||||
final_checks = candidate_relationship(selected,scope,number,prefix,read,env_read,proc_root)
|
||||
if (not shared_relationship_valid(final_checks) or final_checks != ownership['checks'] or
|
||||
final_checks['listener_inodes'] != [item['inode'] for item in sockets]):
|
||||
raise ValueError('Shared listener ownership changed during binding')
|
||||
elif not descendant(selected['pid'],scope['pid'],read):
|
||||
raise ValueError('Native descendant ownership changed during binding')
|
||||
return {'display': display, 'gamescope': identity(scope), 'xwayland': identity(selected),
|
||||
'sockets': sockets, 'relationship': ownership}
|
||||
|
||||
|
||||
def executable_argument(args, executable):
|
||||
# Match an exact full PE path, not a basename/title/comm guess.
|
||||
accepted = {str(executable), 'Z:'+str(executable), 'Z:'+str(executable).replace('/', '\\')}
|
||||
return bool(args) and args[0] in accepted
|
||||
|
||||
|
||||
def owned_window(window, bound, prefix, executable, read=process, env_read=environment):
|
||||
pid = window.get('pid')
|
||||
if type(pid) is not int or pid <= 0 or pid != window.get('server_pid'):
|
||||
return None
|
||||
try:
|
||||
before = read(pid)
|
||||
if before['uid'] != os.getuid() or not executable_argument(before['args'], executable):
|
||||
return None
|
||||
env = env_read(pid)
|
||||
if not prefix_matches(env.get('WINEPREFIX'),prefix) or env.get('DISPLAY') != bound['display']:
|
||||
return None
|
||||
if identity(read(pid)) != identity(before):
|
||||
return None
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return None
|
||||
return {**window, 'process': identity(before)}
|
||||
|
||||
|
||||
def window_process_diagnostic(pid, bound, prefix, executable, read=process, env_read=environment):
|
||||
result = {'readable':False, 'same_user':None, 'game_executable_match':False,
|
||||
'argv0_class':'unreadable', 'argv0_case_insensitive_exact':False,
|
||||
'prefix_matches':None, 'display_matches':None, 'process_stable':False}
|
||||
if type(pid) is not int or pid <= 0:
|
||||
result['argv0_class'] = 'invalid-pid'
|
||||
return result
|
||||
try:
|
||||
before = read(pid)
|
||||
result['readable'] = True
|
||||
result['same_user'] = before['uid'] == os.getuid()
|
||||
argv0 = before['args'][0] if before['args'] else ''
|
||||
exact = executable_argument(before['args'],executable)
|
||||
result['game_executable_match'] = exact
|
||||
if exact:
|
||||
result['argv0_class'] = 'exact-unix-game' if argv0 == str(executable) else 'exact-wine-z-game'
|
||||
elif not argv0:
|
||||
result['argv0_class'] = 'empty'
|
||||
elif re.split(r'[\\/]',argv0)[-1].lower() == executable.name.lower():
|
||||
result['argv0_class'] = 'game-basename-noncanonical-path'
|
||||
elif argv0.lower().endswith('.exe'):
|
||||
result['argv0_class'] = 'other-windows-executable'
|
||||
else:
|
||||
result['argv0_class'] = 'other'
|
||||
normalized = argv0.replace('\\','/').lower()
|
||||
result['argv0_case_insensitive_exact'] = normalized in {str(executable).lower(),('Z:'+str(executable)).lower()}
|
||||
env = env_read(pid)
|
||||
result['prefix_matches'] = prefix_matches(env.get('WINEPREFIX'),prefix)
|
||||
result['display_matches'] = env.get('DISPLAY') == bound['display']
|
||||
result['process_stable'] = identity(read(pid)) == identity(before)
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def window_diagnostic(window, bound, prefix, executable, read=process, env_read=environment):
|
||||
pid, server = window.get('pid'), window.get('server_pid')
|
||||
property_process = window_process_diagnostic(pid,bound,prefix,executable,read,env_read)
|
||||
server_process = property_process if pid == server else window_process_diagnostic(server,bound,prefix,executable,read,env_read)
|
||||
if type(pid) is not int or pid <= 0:
|
||||
stage = 'invalid-property-pid'
|
||||
elif type(server) is not int or server <= 0:
|
||||
stage = 'server-pid-unavailable'
|
||||
elif pid != server:
|
||||
stage = 'property-server-pid-disagreement'
|
||||
elif not property_process['readable']:
|
||||
stage = 'native-process-unreadable'
|
||||
elif property_process['same_user'] is not True:
|
||||
stage = 'foreign-user'
|
||||
elif not property_process['game_executable_match']:
|
||||
stage = 'game-executable-mismatch'
|
||||
elif property_process['prefix_matches'] is not True:
|
||||
stage = 'private-prefix-unverified'
|
||||
elif property_process['display_matches'] is not True:
|
||||
stage = 'private-display-unverified'
|
||||
elif not property_process['process_stable']:
|
||||
stage = 'native-process-changed'
|
||||
else:
|
||||
stage = 'checks-passed'
|
||||
return {'window':window.get('window'), 'pid':pid, 'server_pid':server,
|
||||
'rejection_stage':stage, 'property_process':property_process, 'server_process':server_process,
|
||||
'xres_query':window.get('xres_query')}
|
||||
|
||||
|
||||
class WindowAttributes(C.Structure):
|
||||
# Exact installed Xlib.h layout; Bool is int and XIDs are unsigned long.
|
||||
_fields_ = [('x',C.c_int),('y',C.c_int),('width',C.c_int),('height',C.c_int),
|
||||
('border_width',C.c_int),('depth',C.c_int),('visual',C.c_void_p),
|
||||
('root',C.c_ulong),('window_class',C.c_int),('bit_gravity',C.c_int),
|
||||
('win_gravity',C.c_int),('backing_store',C.c_int),('backing_planes',C.c_ulong),
|
||||
('backing_pixel',C.c_ulong),('save_under',C.c_int),('colormap',C.c_ulong),
|
||||
('map_installed',C.c_int),('map_state',C.c_int),('all_event_masks',C.c_long),
|
||||
('your_event_mask',C.c_long),('do_not_propagate_mask',C.c_long),
|
||||
('override_redirect',C.c_int),('screen',C.c_void_p)]
|
||||
|
||||
|
||||
def mapped_geometry(query, window, root):
|
||||
attrs = query.attributes(window)
|
||||
root_attrs = query.attributes(root)
|
||||
for item in (attrs,root_attrs):
|
||||
if (not isinstance(item,dict) or any(type(item.get(key)) is not int for key in
|
||||
('root','window_class','map_state','width','height')) or
|
||||
item['root'] != root or item['window_class'] != 1 or item['map_state'] != 2 or
|
||||
not 0 < item['width'] <= 65535 or not 0 < item['height'] <= 65535):
|
||||
raise ValueError('unmapped-or-invalid-window-attributes')
|
||||
x,y = query.translate(window,root)
|
||||
if type(x) is not int or type(y) is not int:
|
||||
raise ValueError('invalid-window-root-coordinates')
|
||||
clipped = [max(0,x),max(0,y),min(root_attrs['width'],x+attrs['width']),min(root_attrs['height'],y+attrs['height'])]
|
||||
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
|
||||
raise ValueError('window-outside-private-root')
|
||||
return {'window':window,'root':root,'map_state':attrs['map_state'],'window_class':attrs['window_class'],
|
||||
'x':x,'y':y,'width':attrs['width'],'height':attrs['height'],
|
||||
'root_width':root_attrs['width'],'root_height':root_attrs['height'],'clipped_bounds':clipped}
|
||||
|
||||
|
||||
def input_target(query, owner):
|
||||
"""Point-in-time read-only mapped/focus evidence; never acquires focus."""
|
||||
result = {'window':owner.get('window'),'verified':False,'reason':'not-observed'}
|
||||
try:
|
||||
if any(type(owner.get(key)) is not int or owner[key] <= 1 for key in ('window','pid')):
|
||||
raise ValueError('invalid-owned-window-identity')
|
||||
root = query.root()
|
||||
if type(root) is not int or root <= 1:
|
||||
raise ValueError('invalid-private-root')
|
||||
geometry = mapped_geometry(query,owner['window'],root)
|
||||
result['geometry'] = geometry
|
||||
focused,revert = query.focus()
|
||||
if type(focused) is not int or focused <= 1 or type(revert) is not int or revert not in (0,1,2):
|
||||
raise ValueError('no-concrete-existing-focus')
|
||||
result['focus_window'] = focused
|
||||
result['focus_revert_to'] = revert
|
||||
chain = []; current = focused
|
||||
clipped = list(geometry['clipped_bounds'])
|
||||
for _ in range(32):
|
||||
if current in chain or current == root or current <= 1:
|
||||
raise ValueError('focus-outside-owned-window-tree')
|
||||
client_pid = query.server_pid(current)
|
||||
if type(client_pid) is not int or client_pid != owner['pid']:
|
||||
raise ValueError('focus-client-not-owned-game')
|
||||
child_geometry = mapped_geometry(query,current,root)
|
||||
bounds = child_geometry['clipped_bounds']
|
||||
clipped = [max(clipped[0],bounds[0]),max(clipped[1],bounds[1]),min(clipped[2],bounds[2]),min(clipped[3],bounds[3])]
|
||||
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
|
||||
raise ValueError('focus-has-no-visible-owned-intersection')
|
||||
chain.append(current)
|
||||
if current == owner['window']:
|
||||
break
|
||||
tree_root,parent,_ = query.tree(current)
|
||||
if tree_root != root or type(parent) is not int:
|
||||
raise ValueError('focus-tree-root-mismatch')
|
||||
current = parent
|
||||
else:
|
||||
raise ValueError('focus-ancestry-budget-exceeded')
|
||||
if query.focus() != (focused,revert) or mapped_geometry(query,owner['window'],root) != geometry:
|
||||
raise ValueError('focus-or-geometry-changed-during-observation')
|
||||
result.update(verified=True,reason='owned-mapped-existing-focus',focus_chain=chain,
|
||||
focused_clipped_bounds=clipped)
|
||||
except (OSError,ValueError,TypeError,KeyError) as error:
|
||||
result['reason'] = str(error)[:160]
|
||||
return result
|
||||
|
||||
|
||||
class X11:
|
||||
"""Explicit display connection; all exported methods are read-only queries."""
|
||||
class Spec(C.Structure):
|
||||
_fields_ = [('client', C.c_ulong), ('mask', C.c_uint)]
|
||||
|
||||
class Client(C.Structure):
|
||||
pass
|
||||
|
||||
def __init__(self, display):
|
||||
self.x = C.CDLL('libX11.so.6')
|
||||
self.r = C.CDLL('libXRes.so.1')
|
||||
self.d = None
|
||||
self.query_budget = MAX_WINDOWS
|
||||
signatures = {
|
||||
'XOpenDisplay': (C.c_void_p, [C.c_char_p]),
|
||||
'XCloseDisplay': (C.c_int, [C.c_void_p]),
|
||||
'XDefaultRootWindow': (C.c_ulong, [C.c_void_p]),
|
||||
'XGetWindowAttributes': (C.c_int,[C.c_void_p,C.c_ulong,C.POINTER(WindowAttributes)]),
|
||||
'XGetInputFocus': (C.c_int,[C.c_void_p,C.POINTER(C.c_ulong),C.POINTER(C.c_int)]),
|
||||
'XTranslateCoordinates': (C.c_int,[C.c_void_p,C.c_ulong,C.c_ulong,C.c_int,C.c_int,C.POINTER(C.c_int),C.POINTER(C.c_int),C.POINTER(C.c_ulong)]),
|
||||
'XInternAtom': (C.c_ulong, [C.c_void_p, C.c_char_p, C.c_int]),
|
||||
'XFree': (C.c_int, [C.c_void_p]),
|
||||
'XQueryTree': (C.c_int, [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.POINTER(C.c_ulong)), C.POINTER(C.c_uint)]),
|
||||
'XGetWindowProperty': (C.c_int, [C.c_void_p, C.c_ulong, C.c_ulong, C.c_long, C.c_long, C.c_int, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_int), C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.c_void_p)]),
|
||||
}
|
||||
for name, (ret, args) in signatures.items():
|
||||
method = getattr(self.x, name); method.restype = ret; method.argtypes = args
|
||||
self.r.XResQueryClientIds.argtypes = [C.c_void_p, C.c_long, C.POINTER(self.Spec), C.POINTER(C.c_long), C.POINTER(C.POINTER(self.Client))]
|
||||
self.r.XResQueryClientIds.restype = C.c_int
|
||||
self.r.XResGetClientPid.argtypes = [C.POINTER(self.Client)]
|
||||
self.r.XResGetClientPid.restype = C.c_int
|
||||
self.r.XResClientIdsDestroy.argtypes = [C.c_long, C.POINTER(self.Client)]
|
||||
self.r.XResClientIdsDestroy.restype = None
|
||||
self.d = self.x.XOpenDisplay(display.encode('ascii'))
|
||||
if not self.d:
|
||||
raise ValueError('Explicit nested XOpenDisplay failed')
|
||||
|
||||
def close(self):
|
||||
if self.d:
|
||||
self.x.XCloseDisplay(self.d); self.d = None
|
||||
|
||||
def prop(self, window, name):
|
||||
atom = self.x.XInternAtom(self.d, name.encode(), 1)
|
||||
if not atom:
|
||||
return None
|
||||
kind, fmt, count, after, data = C.c_ulong(), C.c_int(), C.c_ulong(), C.c_ulong(), C.c_void_p()
|
||||
status = self.x.XGetWindowProperty(self.d, window, atom, 0, 256, 0, 0,
|
||||
C.byref(kind), C.byref(fmt), C.byref(count), C.byref(after), C.byref(data))
|
||||
try:
|
||||
if status or after.value or not data.value:
|
||||
return None
|
||||
if name == '_NET_WM_PID':
|
||||
cardinal = self.x.XInternAtom(self.d, b'CARDINAL', 1)
|
||||
if fmt.value == 32 and count.value == 1 and kind.value == cardinal:
|
||||
return C.cast(data, C.POINTER(C.c_ulong))[0]
|
||||
return None
|
||||
if fmt.value == 8 and count.value <= 1024:
|
||||
return C.string_at(data, count.value).decode('utf-8', 'replace')[:256]
|
||||
return None
|
||||
finally:
|
||||
if data.value:
|
||||
self.x.XFree(data)
|
||||
|
||||
def server_pid(self, window):
|
||||
spec = self.Spec(window, 2) # XRES_CLIENT_ID_PID_MASK, installed XRes.h
|
||||
count, values = C.c_long(), C.POINTER(self.Client)()
|
||||
# This installed XRes API returns Success (0), unlike XQueryTree's Bool.
|
||||
status = self.r.XResQueryClientIds(self.d, 1, C.byref(spec), C.byref(count), C.byref(values))
|
||||
if not hasattr(self,'pid_diagnostics'):
|
||||
self.pid_diagnostics = {}
|
||||
diagnostic = {'status':int(status),'count':count.value,'mask':None}
|
||||
self.pid_diagnostics[window] = diagnostic
|
||||
if status != 0:
|
||||
return None
|
||||
try:
|
||||
if count.value == 1 and values:
|
||||
diagnostic['mask'] = values[0].spec.mask
|
||||
if count.value != 1 or not values or values[0].spec.mask != 2:
|
||||
return None
|
||||
pid = self.r.XResGetClientPid(values)
|
||||
return pid if pid > 0 else None
|
||||
finally:
|
||||
self.r.XResClientIdsDestroy(count, values)
|
||||
|
||||
def budget(self):
|
||||
self.query_budget -= 1
|
||||
if self.query_budget < 0:
|
||||
raise ValueError('native-read-query-budget-exceeded')
|
||||
|
||||
def root(self):
|
||||
return self.x.XDefaultRootWindow(self.d)
|
||||
|
||||
def attributes(self,window):
|
||||
self.budget()
|
||||
attrs = WindowAttributes()
|
||||
if not self.x.XGetWindowAttributes(self.d,window,C.byref(attrs)):
|
||||
raise ValueError('native-window-attributes-unavailable')
|
||||
return {key:getattr(attrs,key) for key in ('root','window_class','map_state','width','height')}
|
||||
|
||||
def focus(self):
|
||||
self.budget()
|
||||
window,revert = C.c_ulong(),C.c_int()
|
||||
if not self.x.XGetInputFocus(self.d,C.byref(window),C.byref(revert)):
|
||||
raise ValueError('native-input-focus-unavailable')
|
||||
return window.value,revert.value
|
||||
|
||||
def translate(self,window,root):
|
||||
self.budget()
|
||||
x,y,child = C.c_int(),C.c_int(),C.c_ulong()
|
||||
if not self.x.XTranslateCoordinates(self.d,window,root,0,0,C.byref(x),C.byref(y),C.byref(child)):
|
||||
raise ValueError('native-window-root-translation-unavailable')
|
||||
return x.value,y.value
|
||||
|
||||
def tree(self,window):
|
||||
self.budget()
|
||||
root,parent,children,count = C.c_ulong(),C.c_ulong(),C.POINTER(C.c_ulong)(),C.c_uint()
|
||||
if not self.x.XQueryTree(self.d,window,C.byref(root),C.byref(parent),C.byref(children),C.byref(count)):
|
||||
raise ValueError('Window tree changed during query')
|
||||
try:
|
||||
if count.value > MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
return root.value,parent.value,[children[i] for i in range(count.value)]
|
||||
finally:
|
||||
if children:
|
||||
self.x.XFree(children)
|
||||
|
||||
def windows(self):
|
||||
pending = [self.x.XDefaultRootWindow(self.d)]; seen = set(); found = []
|
||||
while pending:
|
||||
if len(seen) >= MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
window = pending.pop()
|
||||
if window in seen:
|
||||
raise ValueError('Unexpected repeated window tree entry')
|
||||
seen.add(window)
|
||||
pid = self.prop(window, '_NET_WM_PID')
|
||||
if pid is not None:
|
||||
server_pid = self.server_pid(window)
|
||||
found.append({'window': window, 'pid': pid, 'server_pid': server_pid,
|
||||
'xres_query':self.pid_diagnostics.get(window),
|
||||
'title': self.prop(window, '_NET_WM_NAME') or self.prop(window, 'WM_NAME') or ''})
|
||||
_,_,children = self.tree(window)
|
||||
if len(children) + len(pending) + len(seen) > MAX_WINDOWS:
|
||||
raise ValueError('Window tree exceeds bounded query budget')
|
||||
pending.extend(children)
|
||||
return found
|
||||
|
||||
|
||||
X11.Client._fields_ = [('spec', X11.Spec), ('length', C.c_long), ('value', C.c_void_p)]
|
||||
|
||||
|
||||
def observe(args, query_class=X11):
|
||||
before = binding(args.host_display, args.prefix)
|
||||
query = query_class(before['display'])
|
||||
try:
|
||||
windows = query.windows()
|
||||
owned = [match for window in windows if (match := owned_window(window,before,args.prefix,args.game_executable))]
|
||||
targets = [input_target(query,owner) for owner in owned[:16]]
|
||||
# Recheck the exact native process after geometry/focus reads. Never
|
||||
# promote a stale process identity into an input target.
|
||||
for target,owner in zip(targets,owned):
|
||||
current = owned_window(owner,before,args.prefix,args.game_executable)
|
||||
if current is None or current['process'] != owner['process']:
|
||||
target.update(verified=False,reason='game-process-changed-during-target-observation')
|
||||
finally:
|
||||
query.close()
|
||||
if binding(args.host_display, args.prefix) != before:
|
||||
raise ValueError('Display ownership changed during read-only query')
|
||||
diagnostics = [window_diagnostic(window,before,args.prefix,args.game_executable) for window in windows[:16]]
|
||||
valid_targets = [target for target in targets if target['verified']]
|
||||
# Nested owned windows can both contain focus. Prefer the exact focused
|
||||
# owned window; otherwise require exactly one unambiguous owned ancestor.
|
||||
exact_targets = [target for target in valid_targets if target.get('focus_window') == target['window']]
|
||||
chosen = exact_targets if exact_targets else valid_targets
|
||||
return {'binding':before,'owned_windows':owned,'owned_game_window_verified':bool(owned),
|
||||
'input_target_verified':len(chosen) == 1,'input_target':chosen[0] if len(chosen) == 1 else None,
|
||||
'input_target_observations':targets,'input_target_limit':16,
|
||||
'window_count':len(windows),'window_diagnostic_limit':16,'window_diagnostics':diagnostics,
|
||||
'unverified_window_count':len(windows)-len(owned)}
|
||||
|
||||
|
||||
def validate_paths(args):
|
||||
for path in (args.prefix, args.game_executable, args.profile):
|
||||
if not path.is_absolute() or path != path.resolve() or not path.exists():
|
||||
raise ValueError('Expected existing canonical runtime paths')
|
||||
if not args.prefix.is_dir() or not args.game_executable.is_file() or not args.profile.is_dir():
|
||||
raise ValueError('Invalid prefix, executable or profile type')
|
||||
if not args.profile.is_relative_to('/tmp') or args.profile == Path('/tmp'):
|
||||
raise ValueError('Report profile must be an owned private /tmp profile')
|
||||
marker = args.profile/'.erri-game-profile.json'
|
||||
if marker.is_symlink() or json.loads(marker.read_text()) != PROFILE_ID:
|
||||
raise ValueError('Profile is not marked as disposable ordinary-game profile')
|
||||
if (not args.report.is_absolute() or args.report.parent != args.profile or
|
||||
args.report.name in ('', '.', '..') or args.report.is_symlink() or (args.report.exists() and not args.query_worker)):
|
||||
raise ValueError('Report must be an exclusively new direct child of private profile')
|
||||
if args.profile.stat().st_uid != os.getuid() or not 1 <= args.observe_seconds <= 30:
|
||||
raise ValueError('Invalid report owner or observation bound')
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host-display', required=True)
|
||||
parser.add_argument('--prefix', type=Path, required=True)
|
||||
parser.add_argument('--game-executable', type=Path, required=True)
|
||||
parser.add_argument('--profile', type=Path, required=True)
|
||||
parser.add_argument('--report', type=Path, required=True)
|
||||
parser.add_argument('--observe-seconds', type=float, default=20)
|
||||
parser.add_argument('--query-worker', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('command', nargs=argparse.REMAINDER)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
validate_paths(args)
|
||||
bound = binding(args.host_display, args.prefix)
|
||||
if args.query_worker:
|
||||
print(json.dumps(observe(args)))
|
||||
return 0
|
||||
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
||||
if (len(command) < 3 or Path(command[0]).name != 'proton' or
|
||||
not Path(command[0]).is_absolute() or not Path(command[0]).is_file() or
|
||||
command[1] != 'run' or command[2] != str(args.game_executable)):
|
||||
raise ValueError('Wrapper command must contain the exact expected game executable')
|
||||
# A new report is a prerequisite for starting the requested child. No overwrite.
|
||||
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
report = {'schema': 1, 'kind': 'headless-display-read-only', 'binding': bound,
|
||||
'owned_game_window_verified': False, 'input_target_verified': False, 'read_only': True, 'input_sent': False, 'release_qualified': False,
|
||||
'scope': 'Read-only private X11 process/window ownership; no input or gameplay proof',
|
||||
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'wrapper': identity(process(os.getpid())), 'command': command,
|
||||
'prefix': str(args.prefix), 'profile': str(args.profile),
|
||||
'game_executable': str(args.game_executable), 'report': str(args.report)}
|
||||
report.update(query_attempts=0,successful_queries=0,first_window_count=None,last_window_count=None,
|
||||
first_query_error=None,last_query_error=None,last_query_status='not-started')
|
||||
child = None
|
||||
previous = {}
|
||||
received = []
|
||||
def forward(signum, frame):
|
||||
received.append(signum)
|
||||
if child is not None and child.poll() is None:
|
||||
child.send_signal(signum)
|
||||
try:
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[signum] = signal.signal(signum, forward)
|
||||
child = subprocess.Popen(command) # inherit existing owned process group and streams
|
||||
for signum in received:
|
||||
if child.poll() is None:
|
||||
child.send_signal(signum)
|
||||
deadline = time.monotonic() + args.observe_seconds
|
||||
worker = [sys.executable, str(Path(__file__).resolve()), '--query-worker', '--host-display', args.host_display,
|
||||
'--prefix', str(args.prefix), '--game-executable', str(args.game_executable),
|
||||
'--profile', str(args.profile), '--report', str(args.report), '--observe-seconds', str(args.observe_seconds)]
|
||||
while child.poll() is None and not received and time.monotonic() < deadline:
|
||||
report['query_attempts'] += 1
|
||||
try:
|
||||
result = subprocess.run(worker, capture_output=True, text=True, timeout=min(3, max(0.01, deadline-time.monotonic())))
|
||||
if result.returncode:
|
||||
report['last_query_status'] = 'worker-exit'
|
||||
report['last_query_error'] = 'Read-only worker failed; exit '+str(result.returncode)
|
||||
if report['first_query_error'] is None:
|
||||
report['first_query_error'] = report['last_query_error']
|
||||
else:
|
||||
observed = json.loads(result.stdout)
|
||||
if observed.get('binding') != bound:
|
||||
raise ValueError('Original display process identity changed')
|
||||
report.update(observed)
|
||||
report['successful_queries'] += 1
|
||||
report['last_query_status'] = 'ok'
|
||||
report['last_window_count'] = observed.get('window_count')
|
||||
if report['first_window_count'] is None:
|
||||
report['first_window_count'] = observed.get('window_count')
|
||||
if report['owned_game_window_verified']:
|
||||
break
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as error:
|
||||
report['last_query_status'] = 'query-exception'
|
||||
report['last_query_error'] = type(error).__name__
|
||||
if report['first_query_error'] is None:
|
||||
report['first_query_error'] = report['last_query_error']
|
||||
time.sleep(0.2)
|
||||
report['observation_finished'] = True
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
fd = None
|
||||
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
returncode = child.wait() # do not alter engine lifetime; outer runner owns timeout/cleanup
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
if returncode < 0:
|
||||
signum = -returncode
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
os.kill(os.getpid(), signum)
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError) as error:
|
||||
print('Display probe refused: '+str(error), file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
+301
-9
@@ -12,6 +12,7 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from native_provenance import snapshot, persist, bindings, seal_run
|
||||
@@ -309,7 +310,7 @@ def prefix_process_candidate(proc):
|
||||
|
||||
|
||||
def assert_prefix_idle(compat, proc_root=Path('/proc')):
|
||||
target = ('WINEPREFIX='+str(compat/'pfx')).encode()
|
||||
target = str(compat/'pfx').encode()
|
||||
failures = []
|
||||
for proc in proc_root.iterdir():
|
||||
if not proc.name.isdigit() or int(proc.name) == os.getpid():
|
||||
@@ -325,7 +326,9 @@ def assert_prefix_idle(compat, proc_root=Path('/proc')):
|
||||
except (OSError, ValueError) as error:
|
||||
failures.append(f'Cannot verify disposable prefix is idle (PID {proc.name}): {error}')
|
||||
continue
|
||||
if target in environment:
|
||||
# Proton assigns pfx/; only trailing separators are equivalent. Do not
|
||||
# resolve untrusted process paths or accept sibling/symlink spellings.
|
||||
if any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in environment):
|
||||
failures.append(f'Disposable prefix already has a process (PID {proc.name}); leave it untouched')
|
||||
if failures:
|
||||
refuse('\n'.join(failures))
|
||||
@@ -447,6 +450,42 @@ def cleanup(child, proton, env):
|
||||
child.wait(timeout=10)
|
||||
|
||||
|
||||
def cleanup_owned_run(child, proton, env, compat):
|
||||
"""After a launched owned session, while the caller still holds the prefix lock.
|
||||
|
||||
Noncanonical private prefixes retain normal shutdown plus strict idle proof;
|
||||
adopted-helper signalling is restricted to its one explicitly owned prefix.
|
||||
"""
|
||||
report = {'cleanup_confirmed':False, 'idle_verified':False, 'errors':[],
|
||||
'orphan_cleanup':{'attempted':False,'reason':'noncanonical-private-prefix'}}
|
||||
try:
|
||||
cleanup(child,proton,env)
|
||||
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
||||
report['errors'].append('normal shutdown: '+str(error))
|
||||
canonical = Path('/tmp/erri-headless-compat')
|
||||
if Path(compat) == canonical and Path(compat).resolve() == canonical:
|
||||
report['orphan_cleanup'] = {'attempted':True}
|
||||
try:
|
||||
from private_prefix_cleanup import cleanup_private_prefix
|
||||
details = cleanup_private_prefix(compat,proton)
|
||||
report['orphan_cleanup']['report'] = details
|
||||
if not isinstance(details,dict) or details.get('cleared') is not True:
|
||||
raise ValueError('Private helper cleanup did not prove completion')
|
||||
except (OSError,ValueError,ImportError,subprocess.SubprocessError) as error:
|
||||
report['orphan_cleanup']['error'] = str(error)
|
||||
details = getattr(error,'report',None)
|
||||
if details is not None:
|
||||
report['orphan_cleanup']['report'] = details
|
||||
report['errors'].append('private helper cleanup: '+str(error))
|
||||
try:
|
||||
assert_prefix_idle(compat)
|
||||
report['idle_verified'] = True
|
||||
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
||||
report['errors'].append('idle verification: '+str(error))
|
||||
report['cleanup_confirmed'] = report['idle_verified'] and not report['errors']
|
||||
return report
|
||||
|
||||
|
||||
class StartupMonitor:
|
||||
"""Latch failures while allowing a bounded diagnostic drain; no native I/O."""
|
||||
def __init__(self, started, timeout, settle, failure_grace=0):
|
||||
@@ -494,10 +533,240 @@ class StartupMonitor:
|
||||
return final
|
||||
|
||||
|
||||
def display_probe_command(command, root, compat, profile, executable, run_id, host_display):
|
||||
"""Wrap only the existing child of headless Gamescope; preserve Proton argv."""
|
||||
separator = command.index('--')
|
||||
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
|
||||
refuse('Display inspection requires the explicit headless Gamescope boundary')
|
||||
helper = root/'tools/headless_display_probe.py'
|
||||
report = profile/('display-ownership-'+run_id+'.json')
|
||||
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
|
||||
refuse('Display inspection requires a regular helper and a new report')
|
||||
child = command[separator+1:]
|
||||
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
|
||||
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
|
||||
'command': child, 'host_display': host_display}
|
||||
wrapper = [sys.executable, str(helper), '--host-display', host_display,
|
||||
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
|
||||
'--report', str(report), '--observe-seconds', '30', '--']
|
||||
return command[:separator+1] + wrapper + child, context
|
||||
|
||||
|
||||
def display_probe_result(context, session):
|
||||
"""Retain exact observation bytes after cleanup; an absent/unverified report fails closed."""
|
||||
result = {'requested': True, 'verified': False, 'read_only': True, 'input_sent': False}
|
||||
try:
|
||||
source = Path(context['report'])
|
||||
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
|
||||
refuse('Missing or invalid bounded display ownership report')
|
||||
raw = source.read_bytes()
|
||||
report = json.loads(raw)
|
||||
result['sha256'] = hashlib.sha256(raw).hexdigest()
|
||||
if session:
|
||||
with (session/'display-ownership.json').open('xb') as output:
|
||||
output.write(raw)
|
||||
result['archive_file'] = 'display-ownership.json'
|
||||
if not isinstance(report, dict):
|
||||
refuse('Display ownership report is not an object')
|
||||
for key in ('helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command'):
|
||||
if report.get(key) != context[key]:
|
||||
refuse('Display report binding mismatch: '+key)
|
||||
if (report.get('schema') != 1 or report.get('kind') != 'headless-display-read-only'
|
||||
or report.get('read_only') is not True or report.get('input_sent') is not False
|
||||
or report.get('observation_finished') is not True or report.get('owned_game_window_verified') is not True):
|
||||
refuse('Private display game-window ownership was not verified')
|
||||
from headless_display_probe import validate_display, shared_relationship_valid
|
||||
bound = report.get('binding', {})
|
||||
number = validate_display(bound.get('display'), context['host_display'])
|
||||
sockets = bound.get('sockets')
|
||||
allowed = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
|
||||
if not isinstance(sockets, list) or not sockets or len(sockets) > 2:
|
||||
refuse('Missing private X11 socket ownership')
|
||||
paths = set()
|
||||
for sock in sockets:
|
||||
if not isinstance(sock, dict) or sock.get('path') not in allowed or sock['path'] in paths or type(sock.get('inode')) is not int or sock['inode'] <= 0:
|
||||
refuse('Invalid private X11 socket ownership')
|
||||
paths.add(sock['path'])
|
||||
for identity in [report.get('wrapper'), bound.get('gamescope'), bound.get('xwayland')]:
|
||||
if not isinstance(identity, dict) or identity.get('uid') != os.getuid() or any(type(identity.get(k)) is not int or identity[k] <= 0 for k in ('pid', 'start')):
|
||||
refuse('Missing owned process binding')
|
||||
relationship = bound.get('relationship')
|
||||
if not isinstance(relationship, dict):
|
||||
refuse('Missing private display process relationship')
|
||||
if relationship.get('mode') == 'descendant':
|
||||
if relationship.get('descendant') is not True:
|
||||
refuse('Native descendant relationship was not verified')
|
||||
elif relationship.get('mode') == 'shared-listeners':
|
||||
checks = relationship.get('checks')
|
||||
if (not shared_relationship_valid(checks) or
|
||||
checks['listener_inodes'] != [sock['inode'] for sock in sockets] or
|
||||
bound['xwayland']['start'] < bound['gamescope']['start']):
|
||||
refuse('Shared-listener process relationship was not verified')
|
||||
else:
|
||||
refuse('Unknown private display process relationship')
|
||||
windows = report.get('owned_windows')
|
||||
if not isinstance(windows, list) or not windows:
|
||||
refuse('No verified game window records')
|
||||
for window in windows:
|
||||
identity = window.get('process', {})
|
||||
if (type(window.get('window')) is not int or window['window'] <= 0
|
||||
or type(window.get('server_pid')) is not int or type(identity.get('pid')) is not int
|
||||
or identity.get('uid') != os.getuid() or type(identity.get('start')) is not int or identity['start'] <= 0
|
||||
or type(window.get('pid')) is not int or window['pid'] <= 0
|
||||
or window['pid'] != window.get('server_pid') or window['pid'] != identity.get('pid')):
|
||||
refuse('Window lacks consistent native and server process ownership')
|
||||
result['verified'] = True
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
result['error'] = str(error)
|
||||
return result
|
||||
|
||||
|
||||
def validate_input_observer(a):
|
||||
if not getattr(a, 'observe_player_input', False):
|
||||
return
|
||||
if a.project == CONTROL or getattr(a, 'packed_core', None) or getattr(a, 'packed_addons', None):
|
||||
refuse('Input observation requires loose Core or Chat, not packed input or vanilla control')
|
||||
if a.settle < 50:
|
||||
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
|
||||
|
||||
|
||||
def input_observer_command(command, project, packed, nonce):
|
||||
if project not in {'core', 'chat'} or packed is not None:
|
||||
refuse('Input observation requires loose Core or Chat')
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
|
||||
refuse('Input observer nonce must contain 1..32 ASCII letters/digits')
|
||||
if any(value.startswith('-errOrdinaryInput') for value in command):
|
||||
refuse('Input observer command already has observer options')
|
||||
return command + ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
|
||||
|
||||
|
||||
def input_observer_evidence(content, nonce):
|
||||
"""Strictly summarize native sampler records; never infer input delivery."""
|
||||
import math
|
||||
errors, records, samples = [], [], []
|
||||
prefix = 'ERR INPUT OBSERVE: '
|
||||
number = r'-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
|
||||
header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=('+number+r') (.+)')
|
||||
sample_pattern = re.compile(r'SAMPLE origin=(.+?) forward=('+number+r') input=(.+?) velocity=(.+?) inventory=(0|1|true|false)')
|
||||
def vector_value(value):
|
||||
if value.startswith('<') and value.endswith('>'):
|
||||
value = value[1:-1]
|
||||
if not re.fullmatch(number+r'(?:\s*,\s*|\s+)'+number+r'(?:\s*,\s*|\s+)'+number, value.strip()):
|
||||
raise ValueError('invalid vector')
|
||||
result = [float(x) for x in re.findall(number, value)]
|
||||
if not all(math.isfinite(x) for x in result):
|
||||
raise ValueError('nonfinite vector')
|
||||
return result
|
||||
lines = [line.split(prefix, 1)[1].strip() for line in content.splitlines() if prefix in line]
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
|
||||
errors.append('invalid expected nonce')
|
||||
if len(lines) > 460:
|
||||
errors.append('observer record bound exceeded')
|
||||
for line in lines[:460]:
|
||||
match = header.fullmatch(line)
|
||||
if not match:
|
||||
errors.append('malformed observer record')
|
||||
continue
|
||||
actual, index, real, world, event = match.groups()
|
||||
index, real, world = int(index), int(real), float(world)
|
||||
if actual != nonce:
|
||||
errors.append('observer nonce mismatch')
|
||||
continue
|
||||
if not math.isfinite(world) or world < 0 or index > 450 or real > 2147483647:
|
||||
errors.append('observer sample/clock bound invalid')
|
||||
continue
|
||||
record = {'sample': index, 'real_ms': real, 'world_ms': world, 'event': event}
|
||||
if records and (real < records[-1]['real_ms'] or world < records[-1]['world_ms'] or index < records[-1]['sample']):
|
||||
errors.append('observer records reversed')
|
||||
if event.startswith('SAMPLE '):
|
||||
sample = sample_pattern.fullmatch(event)
|
||||
if not sample:
|
||||
errors.append('malformed observer sample')
|
||||
continue
|
||||
origin, forward, movement, velocity, opened = sample.groups()
|
||||
try:
|
||||
value = {**record, 'origin': vector_value(origin), 'forward': float(forward),
|
||||
'input': vector_value(movement), 'velocity': vector_value(velocity),
|
||||
'inventory': opened in {'1', 'true'}}
|
||||
if not math.isfinite(value['forward']):
|
||||
raise ValueError('nonfinite forward')
|
||||
except ValueError:
|
||||
errors.append('invalid observer sample values')
|
||||
continue
|
||||
if index != len(samples)+1:
|
||||
errors.append('observer sample sequence is not contiguous')
|
||||
samples.append(value)
|
||||
elif event not in {'QUEUED', 'READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED',
|
||||
'CAPTURE_REQUEST baseline', 'CAPTURE_REQUEST open', 'CAPTURE_REQUEST closed',
|
||||
'FINISHED observation-bound', 'FINISHED sample-bound'} and not re.fullmatch(r'CANCELLED [a-z-]+', event):
|
||||
errors.append('unknown observer event')
|
||||
elif index != len(samples):
|
||||
errors.append('observer transition sample does not match last sample')
|
||||
records.append(record)
|
||||
def matching(name):
|
||||
return [r for r in records if r['event'] == name or r['event'].startswith(name+' ')]
|
||||
queued, ready, finished, cancelled = (matching(name) for name in ('QUEUED', 'READY', 'FINISHED', 'CANCELLED'))
|
||||
if len(queued) != 1 or not records or records[0] not in queued or records[0]['sample'] != 0:
|
||||
errors.append('missing/duplicate/misordered QUEUED')
|
||||
if len(ready) != 1:
|
||||
errors.append('missing/duplicate READY')
|
||||
if len(finished) != 1 or not records or records[-1] not in finished:
|
||||
errors.append('missing/duplicate/misordered FINISHED')
|
||||
if cancelled:
|
||||
errors.append('observer cancelled')
|
||||
if not samples:
|
||||
errors.append('no native samples')
|
||||
if queued:
|
||||
start = queued[0]['real_ms']
|
||||
if any(s['real_ms'] < start or s['real_ms']-start >= 45000 for s in samples):
|
||||
errors.append('native sample outside real-time observation window')
|
||||
if ready and (not samples or ready[0]['real_ms'] < samples[0]['real_ms']+2000):
|
||||
errors.append('READY before stationary baseline interval')
|
||||
if finished:
|
||||
end = finished[0]
|
||||
if end['event'] == 'FINISHED observation-bound' and end['real_ms']-start < 45000:
|
||||
errors.append('FINISHED before real-time bound')
|
||||
if end['event'] == 'FINISHED sample-bound' and len(samples) != 450:
|
||||
errors.append('FINISHED before sample bound')
|
||||
opens, closes = matching('INVENTORY_OPEN'), matching('INVENTORY_CLOSED')
|
||||
if len(opens) > 1 or len(closes) > 1:
|
||||
errors.append('duplicate inventory transition')
|
||||
by_index = {s['sample']: s for s in samples}
|
||||
for transition, expected in [(r, True) for r in opens] + [(r, False) for r in closes]:
|
||||
sample = by_index.get(transition['sample'])
|
||||
if not ready or records.index(transition) < records.index(ready[0]) or not sample or sample['inventory'] is not expected:
|
||||
errors.append('inventory transition lacks matching post-READY sample')
|
||||
if closes and (not opens or records.index(closes[0]) <= records.index(opens[0])):
|
||||
errors.append('inventory close lacks prior observed open')
|
||||
captures = matching('CAPTURE_REQUEST')
|
||||
for suffix, transition in [('baseline', ready), ('open', opens), ('closed', closes)]:
|
||||
requested = [r for r in captures if r['event'] == 'CAPTURE_REQUEST '+suffix]
|
||||
if len(requested) > 1 or (requested and (not transition or records.index(requested[0]) <= records.index(transition[0]) or requested[0]['sample'] != transition[0]['sample'])):
|
||||
errors.append('capture request lacks its unique transition')
|
||||
return {'nonce': nonce, 'read_only': True, 'input_sent': False, 'sampler_complete': not errors,
|
||||
'queued': len(queued) == 1, 'ready': len(ready) == 1, 'finished': len(finished) == 1,
|
||||
'cancelled': bool(cancelled), 'sample_count': len(samples), 'record_count': len(lines),
|
||||
'inventory_open_observed': len(opens) == 1, 'inventory_close_observed': len(closes) == 1,
|
||||
'capture_requests': [r['event'].split()[-1] for r in captures],
|
||||
'transitions': [r for r in records if not r['event'].startswith('SAMPLE ')],
|
||||
'errors': list(dict.fromkeys(errors)),
|
||||
'scope': 'native read-only sampler lifecycle only; no generated input, movement success, inventory usability, screenshot file verification, manual playtest or multiplayer claim'}
|
||||
|
||||
|
||||
def input_observer_result(result, content, nonce):
|
||||
evidence = input_observer_evidence(content, nonce)
|
||||
if result == 'game-started' and not evidence['sampler_complete']:
|
||||
result = 'observation-incomplete'
|
||||
return result, evidence
|
||||
|
||||
|
||||
def run(a):
|
||||
validate_input_observer(a)
|
||||
packed = packed_request(a)
|
||||
if getattr(a, 'capture_prison_view', False) and (a.project == CONTROL or packed is not None):
|
||||
refuse('Prison view capture requires loose successor source with its opt-in observer')
|
||||
if getattr(a, 'inspect_headless_display', False) and (a.project == CONTROL or packed is not None):
|
||||
refuse('Display ownership inspection currently requires loose Core or Chat')
|
||||
steam, compat = a.steam.resolve(), a.compat.resolve()
|
||||
profile = packed['session']/'profile' if packed else a.profile.resolve()
|
||||
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
|
||||
@@ -549,6 +818,12 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
if link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
|
||||
refuse('Private profile addons must contain only the installed vanilla link')
|
||||
run_id = uuid.uuid4().hex
|
||||
observation_nonce = run_id if getattr(a, 'observe_player_input', False) else None
|
||||
if observation_nonce:
|
||||
command = input_observer_command(command, a.project, packed, observation_nonce)
|
||||
display_context = None
|
||||
if getattr(a, 'inspect_headless_display', False):
|
||||
command, display_context = display_probe_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''))
|
||||
silent_sink = 'erri_game_'+run_id
|
||||
application_id = 'org.escapistsrereintroduced.game.'+run_id
|
||||
env = os.environ.copy()
|
||||
@@ -565,6 +840,7 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
result = 'timeout'
|
||||
runner_error = None
|
||||
cleanup_ok = True
|
||||
cleanup_report = None
|
||||
log_root = profile/'logs'
|
||||
previous = set(log_root.glob('logs_*'))
|
||||
launch_log = profile/('game-launch-'+run_id+'.log')
|
||||
@@ -618,13 +894,12 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
runner_error = str(error)
|
||||
finally:
|
||||
if child is not None:
|
||||
try:
|
||||
cleanup(child, proton, env)
|
||||
assert_prefix_idle(compat)
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
cleanup_ok = False
|
||||
cleanup_report = cleanup_owned_run(child,proton,env,compat)
|
||||
cleanup_ok = cleanup_report['cleanup_confirmed']
|
||||
if not cleanup_ok:
|
||||
result = 'cleanup-failed'
|
||||
runner_error = str(error)
|
||||
cleanup_error = '; '.join(cleanup_report['errors'])
|
||||
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
|
||||
if sink_module is not None:
|
||||
try:
|
||||
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
|
||||
@@ -650,10 +925,20 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
result = 'vanilla-control-started'
|
||||
if packed and result == 'game-started':
|
||||
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
|
||||
display_result = None
|
||||
startup_result = result
|
||||
if display_context:
|
||||
display_result = display_probe_result(display_context, session)
|
||||
if not display_result['verified'] and result == 'game-started':
|
||||
result = 'display-probe-incomplete'
|
||||
observation_result = None
|
||||
if observation_nonce:
|
||||
result, observation_result = input_observer_result(result, content, observation_nonce)
|
||||
final['input_observation'] = observation_result
|
||||
source_bindings = {} if packed else bindings(captured)
|
||||
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
|
||||
'session': str(session) if session else None, 'project': a.project,
|
||||
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
|
||||
'cleanup_confirmed': cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error': runner_error,
|
||||
'failure_grace_seconds': a.failure_grace,
|
||||
'failure_observed_during_polling': monitor.first_failure_at is not None,
|
||||
'first_failure_elapsed_seconds': (monitor.first_failure_at - monitor.started) if monitor.first_failure_at is not None else None,
|
||||
@@ -662,6 +947,11 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
'control_only': a.project == CONTROL,
|
||||
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
|
||||
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
|
||||
if observation_result is not None:
|
||||
summary['input_observation'] = observation_result
|
||||
if display_result is not None:
|
||||
summary['display_ownership'] = display_result
|
||||
summary['startup_result_before_display_check'] = startup_result
|
||||
if packed:
|
||||
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
|
||||
final['packed_consumption_verified'] = summary['packed_consumption_verified']
|
||||
@@ -686,7 +976,9 @@ def main():
|
||||
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
|
||||
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
|
||||
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
|
||||
parser.add_argument('--inspect-headless-display', action='store_true', help='Read-only ownership inspection within the hidden display; no input sent, loose Core/Chat only')
|
||||
parser.add_argument('--capture-prison-view', action='store_true', help='Opt-in single genuine local prison screenshot; loose successor source only, no camera or input changes')
|
||||
parser.add_argument('--observe-player-input', action='store_true', help='Read-only native local-input sampler; loose Core/Chat only, requires --settle >=50; sends no input')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
parser.add_argument('--settle', type=int, default=15)
|
||||
parser.add_argument('--failure-grace', type=int, default=0, help='Keep a failed run alive for diagnostic logs only, at most 0..30 seconds within the original timeout')
|
||||
|
||||
+27
-20
@@ -6,12 +6,13 @@ No desktop rules or existing Wine processes are changed. This is not a dedicated
|
||||
import argparse, fcntl, json, os, shutil, signal, subprocess, time
|
||||
from pathlib import Path
|
||||
from native_provenance import snapshot, persist, bindings, seal_run
|
||||
from headless_game import assert_prefix_idle, cleanup_owned_run
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
|
||||
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
|
||||
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
|
||||
p.add_argument('--smoke', choices=['native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--smoke', choices=['aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
|
||||
p.add_argument('--station-teardown-diagnostics', action='store_true', help='Opt-in bounded native deletion diagnostics; preserves all validation errors')
|
||||
p.add_argument('--flight-autohover', action='store_true', help='Opt-in owned native helicopter autohover experiment; requires Core powered-flight and actor-local action inputs')
|
||||
p.add_argument('--flight-action-inputs', action='store_true', help='Experimental exact-AI ActionManager inputs; Core powered-flight fixture only')
|
||||
@@ -27,7 +28,7 @@ if a.smoke == 'mortar-courier' and (a.project != 'chat' or a.rounds != 1 or a.ti
|
||||
p.error('mortar-courier requires --project chat --rounds 1 --timeout at least 500')
|
||||
if a.smoke in {'mortar-refresh', 'mortar-salvo', 'mortar-expiry', 'mortar-transfer', 'mortar-fire'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 400):
|
||||
p.error('mortar-refresh/mortar-salvo/mortar-expiry/mortar-transfer/mortar-fire requires --project chat --rounds 1 --timeout at least 400')
|
||||
if a.smoke in {'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
|
||||
if a.smoke in {'aircraft-route', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate'} and (a.project != 'chat' or a.rounds != 1 or a.timeout < 420): p.error('aircraft-route/aircraft-control/aircraft-cancel/aircraft-translate requires Chat, one round, and at least 420 seconds')
|
||||
if a.smoke == 'map-ownership' and (a.project != 'chat' or a.rounds != 1 or a.timeout < 180): p.error('map-ownership requires Chat, one round, and at least 180 seconds')
|
||||
if a.smoke == 'powered-translate' and (a.project != 'core' or not a.flight_action_inputs or not a.flight_autohover): p.error('powered-translate requires Core with flight-action-inputs and flight-autohover')
|
||||
if a.flight_autohover and (a.project != 'core' or a.smoke not in {'powered-flight', 'powered-translate'} or not a.flight_action_inputs): p.error('flight-autohover requires Core powered-flight with flight-action-inputs')
|
||||
@@ -47,6 +48,9 @@ if compat.is_relative_to(steam) or not (compat/'pfx/system.reg').is_file():
|
||||
prefix_lock = (compat/'.erri-workbench.lock').open('a')
|
||||
try: fcntl.flock(prefix_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: p.error('Another test runner owns this disposable Wine prefix')
|
||||
# The lock must be held before the preflight; never repair a busy prefix here.
|
||||
try: assert_prefix_idle(compat)
|
||||
except (OSError,ValueError) as error: p.error(str(error))
|
||||
if profile.is_relative_to(ROOT) or profile.is_relative_to(steam):
|
||||
p.error('--profile must be outside source and Steam directories')
|
||||
if not shutil.which('gamescope') or not shutil.which('pactl'): p.error('gamescope and pactl are required')
|
||||
@@ -104,7 +108,9 @@ def silence_test_streams():
|
||||
pid = props.get('application.process.id', '')
|
||||
if not owned and pid.isdigit():
|
||||
try:
|
||||
owned = ('WINEPREFIX='+str(compat/'pfx')).encode() in (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
|
||||
target = str(compat/'pfx').encode()
|
||||
values = (Path('/proc')/pid/'environ').read_bytes().split(b'\0')
|
||||
owned = any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in values)
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): pass
|
||||
if not owned: continue
|
||||
index = str(stream['index'])
|
||||
@@ -122,9 +128,13 @@ started = time.time()
|
||||
log_root = profile/'logs'
|
||||
previous = set(log_root.glob('logs_*')) if log_root.exists() else set()
|
||||
result, session, complete_at = 'timeout', None, None
|
||||
runner_error = None
|
||||
cleanup_ok = True
|
||||
cleanup_report = None
|
||||
child = None
|
||||
with (profile/'headless-launch.log').open('w') as output:
|
||||
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
child = subprocess.Popen(command, cwd=wb, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
while time.time()-started < a.timeout:
|
||||
silence_test_streams()
|
||||
candidates = [x for x in log_root.glob('logs_*') if x not in previous]
|
||||
@@ -144,23 +154,20 @@ with (profile/'headless-launch.log').open('w') as output:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
result = 'interrupted'
|
||||
except (OSError,ValueError,subprocess.SubprocessError) as error:
|
||||
result = 'runner-failed'; runner_error = str(error)
|
||||
finally:
|
||||
# Stop Wine in this disposable prefix only. Proton's reaper may create a
|
||||
# separate process group, so killing Gamescope alone can leave Wine alive.
|
||||
wine_server = proton.parent/'files/bin/wineserver'
|
||||
subprocess.run([str(wine_server), '-k'], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
# Only this fresh process group belongs to the runner and disposable prefix.
|
||||
try: os.killpg(child.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try: child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL); child.wait()
|
||||
# Gamescope's reaper can outlive its parent and ignores SIGTERM.
|
||||
try: os.killpg(child.pid, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
stopped_evidence = seal_run(ROOT, session, captured_sources) if session else {}
|
||||
if session and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
|
||||
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
||||
result_before_cleanup = result
|
||||
if child is not None:
|
||||
cleanup_report = cleanup_owned_run(child,proton,env,compat)
|
||||
cleanup_ok = cleanup_report['cleanup_confirmed']
|
||||
if not cleanup_ok:
|
||||
result = 'cleanup-failed'
|
||||
cleanup_error = '; '.join(cleanup_report['errors'])
|
||||
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
|
||||
stopped_evidence = seal_run(ROOT, session, captured_sources) if cleanup_ok and session else {}
|
||||
if stopped_evidence and not stopped_evidence['source_unchanged_after_run']: result = 'source-changed'
|
||||
summary = {**stopped_evidence, **bindings(captured_sources), 'started_unix':started, 'result':result, 'result_before_cleanup':result_before_cleanup, 'cleanup_confirmed':cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error':runner_error, 'session':str(session) if session else None, 'rounds_required':a.rounds, 'project':a.project, 'display':'headless Gamescope 1280x720/30Hz', 'dedicated_server':False, 'audio':'silent sink and per-test-stream mute'}
|
||||
if session:
|
||||
(session/'runner.json').write_text(json.dumps(summary, indent=2)+'\n')
|
||||
shutil.copyfile(profile/'headless-launch.log', session/'headless-launch.log')
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Narrow post-run cleanup of adopted device helpers in the one disposable prefix.
|
||||
|
||||
Library only: importing or executing this file sends no signals. Call only while
|
||||
holding .erri-workbench.lock, after normal wineserver/Gamescope shutdown. This is
|
||||
not a general Wine cleanup tool. Environment values are never returned/logged.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import os
|
||||
import posixpath
|
||||
import select
|
||||
import signal
|
||||
import stat
|
||||
import time
|
||||
|
||||
COMPAT = Path('/tmp/erri-headless-compat')
|
||||
INSTALLED_PROTON = Path.home()/'.local/share/Steam/steamapps/common/Proton - Experimental/proton'
|
||||
PREFIX = b'/tmp/erri-headless-compat/pfx'
|
||||
DEVICE_ARGV = (b'C:\\windows\\system32\\winedevice.exe',)
|
||||
SYSTEMD_ARGV = (b'/usr/lib/systemd/systemd', b'--user')
|
||||
|
||||
|
||||
class CleanupRefused(ValueError):
|
||||
def __init__(self, message, report=None):
|
||||
super().__init__(message)
|
||||
self.report = report or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Process:
|
||||
pid: int
|
||||
uid: int
|
||||
ppid: int
|
||||
start: int
|
||||
state: str
|
||||
comm: str
|
||||
argv: tuple
|
||||
exe: str | None
|
||||
executable_id: tuple | None
|
||||
prefix: bytes | None = None
|
||||
|
||||
def identity(self):
|
||||
# State may legitimately change between running and sleeping.
|
||||
return (self.pid, self.uid, self.ppid, self.start, self.comm, self.argv,
|
||||
self.exe, self.executable_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Device:
|
||||
process: Process
|
||||
parent: Process
|
||||
|
||||
def identity(self):
|
||||
return self.process.identity(), self.parent.identity()
|
||||
|
||||
def public(self):
|
||||
p = self.process
|
||||
return {'pid': p.pid, 'ppid': p.ppid, 'state': p.state,
|
||||
'exebasename': 'wine64-preloader', 'start_ticks': p.start,
|
||||
'role': 'adopted-winedevice'}
|
||||
|
||||
|
||||
def prefix_kind(value):
|
||||
if value is None:
|
||||
return 'other'
|
||||
if value.rstrip(b'/') == PREFIX:
|
||||
return 'exact'
|
||||
# Never resolve untrusted environment paths or follow their symlinks.
|
||||
# A lexical spelling of this prefix is ambiguous and cannot be killed.
|
||||
if posixpath.normpath(value) == PREFIX:
|
||||
return 'alias'
|
||||
return 'other'
|
||||
|
||||
|
||||
def native_metadata_proves_unrelated(p):
|
||||
names = [p.comm.lower(), *(os.fsdecode(a).replace('\\', '/').rsplit('/', 1)[-1].lower() for a in p.argv)]
|
||||
if p.exe:
|
||||
names.append(Path(p.exe).name.lower())
|
||||
if any(n.endswith('.exe') or n.startswith(('wine', 'proton', 'gamescope', 'armareforger')) or n in {'reaper', 'pressure-vessel-wrap', 'pv-bwrap'} for n in names):
|
||||
return False
|
||||
if p.exe and p.exe.startswith('/'):
|
||||
return True
|
||||
if p.comm == 'systemd' and p.argv == SYSTEMD_ARGV and p.ppid == 1:
|
||||
return True
|
||||
if p.comm == '(sd-pam)' and p.argv == (b'(sd-pam)',):
|
||||
return True
|
||||
# Same narrow native foreground signature as headless_game's preflight.
|
||||
if p.comm == 'apache2' and p.argv == (b'apache2', b'-DFOREGROUND'):
|
||||
return True
|
||||
# Nondumpable desktop services still expose argv and kernel comm. Match
|
||||
# both (Linux comm truncates at 15 bytes), after refusing runtime markers
|
||||
# in every argument above. This does not authorize any signal: it only
|
||||
# skips an unreadable environment for positively native metadata.
|
||||
if p.argv and p.argv[0].startswith(b'/'):
|
||||
basename = os.fsdecode(p.argv[0]).rsplit('/', 1)[-1]
|
||||
if p.comm and p.comm in {basename, basename[:15]}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def prove_device(p, parent, uid, expected_exe, executable_id):
|
||||
if (p.uid != uid or p.state in {'Z', 'X'} or prefix_kind(p.prefix) != 'exact'
|
||||
or p.comm != 'winedevice.exe' or p.argv != DEVICE_ARGV
|
||||
or p.exe != expected_exe or p.executable_id != executable_id):
|
||||
raise CleanupRefused(f'PID {p.pid} is not the exact adopted device helper; left untouched')
|
||||
if (parent.pid != p.ppid or parent.uid != uid or parent.ppid != 1
|
||||
or parent.state in {'Z', 'X'} or parent.comm != 'systemd'
|
||||
or parent.argv != SYSTEMD_ARGV
|
||||
or parent.exe not in {None, '/usr/lib/systemd/systemd'}):
|
||||
raise CleanupRefused(f'PID {p.pid} does not have the proven user-systemd parent; left untouched')
|
||||
# The observed user-systemd is nondumpable: an unreadable exe is accepted
|
||||
# only with all exact parent metadata above. A readable wrong exe is refused.
|
||||
return Device(p, parent)
|
||||
|
||||
|
||||
class ProcFS:
|
||||
def __init__(self, expected_exe, executable_id, root=Path('/proc'), uid=None, self_pid=None):
|
||||
self.root = root
|
||||
self.uid = os.getuid() if uid is None else uid
|
||||
self.self_pid = os.getpid() if self_pid is None else self_pid
|
||||
self.expected_exe = str(expected_exe)
|
||||
self.executable_id = executable_id
|
||||
|
||||
def metadata(self, pid):
|
||||
p = self.root / str(pid)
|
||||
uid = p.stat().st_uid
|
||||
text = (p/'stat').read_text()
|
||||
fields = text[text.rfind(')') + 2:].split()
|
||||
state, ppid, start = fields[0], int(fields[1]), int(fields[19])
|
||||
comm = (p/'comm').read_text().strip()
|
||||
argv = tuple(a for a in (p/'cmdline').read_bytes().split(b'\0') if a)
|
||||
try:
|
||||
exe = os.readlink(p/'exe')
|
||||
info = (p/'exe').stat()
|
||||
executable_id = (info.st_dev, info.st_ino)
|
||||
except PermissionError:
|
||||
exe, executable_id = None, None
|
||||
# Detect PID reuse while reading the separate proc records.
|
||||
final = (p/'stat').read_text()
|
||||
after = final[final.rfind(')') + 2:].split()
|
||||
if int(after[19]) != start or int(after[1]) != ppid:
|
||||
raise CleanupRefused(f'PID {pid} changed while reading metadata; left untouched')
|
||||
return Process(pid, uid, ppid, start, state, comm, argv, exe, executable_id)
|
||||
|
||||
def devices(self):
|
||||
found = []
|
||||
for entry in self.root.iterdir():
|
||||
if not entry.name.isdigit() or int(entry.name) == self.self_pid:
|
||||
continue # library caller is this process, never another PID
|
||||
pid = int(entry.name)
|
||||
try:
|
||||
if entry.stat().st_uid != self.uid:
|
||||
continue
|
||||
p = self.metadata(pid)
|
||||
if p.state in {'Z', 'X'}:
|
||||
continue # already exited, not a signal target
|
||||
try:
|
||||
values = [v[11:] for v in (entry/'environ').read_bytes().split(b'\0') if v.startswith(b'WINEPREFIX=')]
|
||||
except PermissionError:
|
||||
if native_metadata_proves_unrelated(p):
|
||||
continue
|
||||
raise CleanupRefused(f'Cannot establish prefix ownership for PID {pid}; left untouched')
|
||||
kinds = [prefix_kind(value) for value in values]
|
||||
if not any(k in {'exact', 'alias'} for k in kinds):
|
||||
continue
|
||||
if len(values) != 1 or kinds[0] != 'exact':
|
||||
raise CleanupRefused(f'PID {pid} has ambiguous private-prefix spelling; left untouched')
|
||||
again = self.metadata(pid)
|
||||
if again.identity() != p.identity():
|
||||
raise CleanupRefused(f'PID {pid} changed during prefix inspection; left untouched')
|
||||
p = Process(**{**p.__dict__, 'prefix': values[0]})
|
||||
parent = self.metadata(p.ppid)
|
||||
found.append(prove_device(p, parent, self.uid, self.expected_exe, self.executable_id))
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
continue
|
||||
except (PermissionError, OSError) as error:
|
||||
raise CleanupRefused(f'Cannot verify private-prefix PID {pid}: {type(error).__name__}; left untouched') from None
|
||||
return sorted(found, key=lambda d: d.process.pid)
|
||||
|
||||
|
||||
class Pidfds:
|
||||
def open(self, pid):
|
||||
return os.pidfd_open(pid)
|
||||
|
||||
def close(self, fd):
|
||||
os.close(fd)
|
||||
|
||||
def send(self, fd, sig):
|
||||
signal.pidfd_send_signal(fd, sig)
|
||||
|
||||
def wait(self, handles, timeout):
|
||||
poller = select.poll()
|
||||
by_fd = {fd: pid for pid, fd in handles.items()}
|
||||
for fd in by_fd:
|
||||
poller.register(fd, select.POLLIN)
|
||||
finished = set()
|
||||
until = time.monotonic() + timeout
|
||||
while len(finished) < len(handles):
|
||||
remaining = max(0, until-time.monotonic())
|
||||
events = poller.poll(int(remaining*1000))
|
||||
for fd, flags in events:
|
||||
if flags & (select.POLLIN | select.POLLHUP):
|
||||
finished.add(by_fd[fd])
|
||||
poller.unregister(fd)
|
||||
elif flags & (select.POLLERR | select.POLLNVAL):
|
||||
raise CleanupRefused('A pinned process handle became unverifiable')
|
||||
if remaining <= 0 or time.monotonic() >= until:
|
||||
break
|
||||
return finished
|
||||
|
||||
|
||||
def _cleanup(inspector, signals):
|
||||
"""Testable algorithm; production entry below always supplies real guards."""
|
||||
initial = {d.process.pid: d for d in inspector.devices()}
|
||||
report = {'scope': str(COMPAT/'pfx'), 'candidates': [d.public() for d in initial.values()],
|
||||
'term_sent': [], 'kill_sent': [], 'cleared': False}
|
||||
handles = {}
|
||||
|
||||
def census():
|
||||
current = {d.process.pid: d for d in inspector.devices()}
|
||||
for pid, candidate in current.items():
|
||||
if pid not in initial or candidate.identity() != initial[pid].identity():
|
||||
raise CleanupRefused(f'Private-prefix process identity changed (PID {pid}); remaining processes left untouched')
|
||||
return current
|
||||
|
||||
try:
|
||||
# Open every handle only after the complete prefix census proves there
|
||||
# are no active/unknown processes. Before signalling, verify it again.
|
||||
for pid in initial:
|
||||
try:
|
||||
handles[pid] = signals.open(pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for pid, fd in handles.items():
|
||||
current = census()
|
||||
if pid in signals.wait({pid: fd}, 0):
|
||||
continue
|
||||
if pid not in current:
|
||||
raise CleanupRefused(f'Pinned PID {pid} lost its proven prefix identity; left untouched')
|
||||
try:
|
||||
signals.send(fd, signal.SIGTERM)
|
||||
report['term_sent'].append(pid)
|
||||
except ProcessLookupError:
|
||||
pass # pinned original exited, never retry via its numeric PID
|
||||
exited = signals.wait(handles, 3.0)
|
||||
for pid, fd in handles.items():
|
||||
if pid in exited:
|
||||
continue
|
||||
current = census()
|
||||
if pid in signals.wait({pid: fd}, 0):
|
||||
continue
|
||||
if pid not in current:
|
||||
raise CleanupRefused(f'Pinned PID {pid} lost its proven prefix identity; left untouched')
|
||||
try:
|
||||
signals.send(fd, signal.SIGKILL)
|
||||
report['kill_sent'].append(pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
signals.wait(handles, 1.0)
|
||||
if census():
|
||||
raise CleanupRefused('Some pinned private-prefix helpers remain alive; no broader cleanup attempted')
|
||||
report['cleared'] = True
|
||||
return report
|
||||
except (CleanupRefused, OSError) as error:
|
||||
raise CleanupRefused(str(error), report) from None
|
||||
finally:
|
||||
for fd in handles.values():
|
||||
signals.close(fd)
|
||||
|
||||
|
||||
def _inspector(compat, proton):
|
||||
compat, proton = Path(compat), Path(proton)
|
||||
if compat != COMPAT or compat.resolve() != COMPAT or (compat/'pfx').resolve() != COMPAT/'pfx':
|
||||
raise CleanupRefused('Cleanup is restricted to the canonical disposable /tmp/erri-headless-compat/pfx')
|
||||
for path in (compat, compat/'pfx'):
|
||||
if not path.is_dir() or path.stat().st_uid != os.getuid():
|
||||
raise CleanupRefused('Private prefix must be an existing directory owned by this user')
|
||||
expected = proton.parent/'files/lib/wine/i386-unix/wine64-preloader'
|
||||
if proton != INSTALLED_PROTON or not proton.is_file() or not expected.is_file() or expected.resolve() != expected:
|
||||
raise CleanupRefused('Verified installed Proton device-helper executable required')
|
||||
info = expected.stat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise CleanupRefused('Proton device-helper executable is not a regular file')
|
||||
if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
|
||||
raise CleanupRefused('PID-pinned signalling is unavailable; no PID-only fallback')
|
||||
return ProcFS(expected, (info.st_dev, info.st_ino))
|
||||
|
||||
|
||||
def inspect_private_prefix(compat, proton):
|
||||
"""Read-only proof; refuses if anything except exact adopted helpers remains."""
|
||||
return [d.public() for d in _inspector(compat, proton).devices()]
|
||||
|
||||
|
||||
def cleanup_private_prefix(compat, proton):
|
||||
"""After ordinary shutdown, with the shared prefix lock still held.
|
||||
|
||||
Never call as startup recovery for a busy prefix. The caller owns the
|
||||
disposed test session; any other live process makes this fail closed.
|
||||
"""
|
||||
return _cleanup(_inspector(compat, proton), Pidfds())
|
||||
Reference in New Issue
Block a user