Exercise ordinary input in private display and retain inventory UI failure diagnosis
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','display-ownership.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','input-proof.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_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']:
|
||||
for name in ['test_headless_input_proof.py', 'test_headless_input_exercise_runner.py', '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()
|
||||
|
||||
@@ -630,6 +630,18 @@ def validate_input_observer(a):
|
||||
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
|
||||
|
||||
|
||||
def validate_input_exercise(a):
|
||||
if not getattr(a, 'exercise_player_input', False):
|
||||
return
|
||||
if (a.project not in {'core', 'chat'} or getattr(a, 'packed_core', None)
|
||||
or getattr(a, 'packed_addons', None) or getattr(a, 'packed_session', None)):
|
||||
refuse('Input exercise requires loose Core or Chat')
|
||||
if not getattr(a, 'observe_player_input', False) or not getattr(a, 'inspect_headless_display', False):
|
||||
refuse('Input exercise requires --observe-player-input and --inspect-headless-display')
|
||||
if a.settle < 50:
|
||||
refuse('Input exercise requires --settle >=50')
|
||||
|
||||
|
||||
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')
|
||||
@@ -743,11 +755,35 @@ def input_observer_evidence(content, nonce):
|
||||
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')
|
||||
# Native movement proof belongs to the observer, independently of whether
|
||||
# an external helper claims to have sent keys. Restrict it to READY→open.
|
||||
movement = {'action_observed': False, 'physical_motion_observed': False,
|
||||
'released_input_observed': False,
|
||||
'max_horizontal_displacement': 0.0}
|
||||
if len(ready) == 1:
|
||||
baseline = by_index.get(ready[0]['sample'])
|
||||
end_index = opens[0]['sample'] if len(opens) == 1 else 0
|
||||
if baseline and end_index:
|
||||
for sample in samples:
|
||||
if not ready[0]['sample'] < sample['sample'] <= end_index:
|
||||
continue
|
||||
distance = math.hypot(sample['origin'][0]-baseline['origin'][0], sample['origin'][2]-baseline['origin'][2])
|
||||
movement['max_horizontal_displacement'] = max(movement['max_horizontal_displacement'], distance)
|
||||
if sample['forward'] > 0.1 and math.hypot(sample['input'][0], sample['input'][2]) > 0.1:
|
||||
movement['action_observed'] = True
|
||||
if distance > 0.05 and math.hypot(sample['velocity'][0], sample['velocity'][2]) > 0.05:
|
||||
movement['physical_motion_observed'] = True
|
||||
if len(closes) == 1:
|
||||
movement['released_input_observed'] = any(
|
||||
sample['sample'] > closes[0]['sample'] and abs(sample['forward']) <= 0.01
|
||||
and math.sqrt(sum(value*value for value in sample['input'])) <= 0.01
|
||||
for sample in samples)
|
||||
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],
|
||||
'movement': movement,
|
||||
'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'}
|
||||
@@ -760,7 +796,132 @@ def input_observer_result(result, content, nonce):
|
||||
return result, evidence
|
||||
|
||||
|
||||
def input_exercise_command(command, root, compat, profile, executable, run_id, host_display, display_context):
|
||||
"""Put the input supervisor around the display inspector, inside Gamescope."""
|
||||
separator = command.index('--')
|
||||
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
|
||||
refuse('Input exercise requires the explicit headless Gamescope boundary')
|
||||
helper = root/'tools/headless_input_proof.py'
|
||||
ownership = root/'tools/headless_display_probe.py'
|
||||
child = command[separator+1:]
|
||||
if (not display_context or len(child) < 3 or child[:2] != [sys.executable, str(ownership)]
|
||||
or not re.fullmatch(r'[A-Za-z0-9]{1,32}', run_id)):
|
||||
refuse('Input exercise requires the existing scoped display wrapper and observer nonce')
|
||||
report = profile/('input-proof-'+run_id+'.json')
|
||||
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
|
||||
refuse('Input exercise requires a regular helper and a new report')
|
||||
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
|
||||
'ownership_helper_sha256': hashlib.sha256(ownership.read_bytes()).hexdigest(),
|
||||
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
|
||||
'command': child, 'nonce': run_id, 'display_report': display_context['report']}
|
||||
wrapper = [sys.executable, str(helper), '--enable-private-keyboard-proof', '--host-display', host_display,
|
||||
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
|
||||
'--report', str(report), '--nonce', run_id, '--']
|
||||
return command[:separator+1] + wrapper + child, context
|
||||
|
||||
|
||||
def input_exercise_poll_failure(context):
|
||||
"""Read only a complete, context-bound failure; an in-progress write is pending."""
|
||||
if not context:
|
||||
return False
|
||||
try:
|
||||
source = Path(context['report'])
|
||||
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
|
||||
return False
|
||||
with source.open('rb') as stream:
|
||||
raw = stream.read(1024*1024+1)
|
||||
if len(raw) > 1024*1024:
|
||||
return False
|
||||
report = json.loads(raw)
|
||||
if not isinstance(report, dict) or report.get('schema') != 1 or report.get('kind') != 'headless-private-keyboard-experiment':
|
||||
return False
|
||||
for key in ('helper_sha256', 'ownership_helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command', 'nonce'):
|
||||
if report.get(key) != context[key]:
|
||||
return False
|
||||
return bool(report.get('error') or report.get('supervision_failed') is True or (
|
||||
report.get('sequence_completed') is False
|
||||
and report.get('whole_process_group_cleanup_verified') is False
|
||||
and type(report.get('input_sent')) is bool))
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def input_exercise_poll_result(context, outcome, native_failure):
|
||||
if not input_exercise_poll_failure(context):
|
||||
return outcome
|
||||
# Native failures already latched by StartupMonitor remain authoritative,
|
||||
# even during its diagnostic grace period. Never wait for settle on failure.
|
||||
if native_failure:
|
||||
return 'game-failed'
|
||||
if outcome is not None and outcome != 'game-started':
|
||||
return outcome
|
||||
return 'input-exercise-failed'
|
||||
|
||||
|
||||
def input_exercise_result(result, context, session, observation, display_result):
|
||||
"""Bind immutable helper bytes and require independent native player evidence."""
|
||||
proof = {'requested': True, 'verified': False, 'input_sent': None, 'gameplay_qualified': False,
|
||||
'scope': 'bounded private keyboard delivery plus native movement/inventory observations; no full escape or multiplayer qualification'}
|
||||
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 input report')
|
||||
raw = source.read_bytes()
|
||||
proof['sha256'] = hashlib.sha256(raw).hexdigest()
|
||||
if not session:
|
||||
refuse('Input experiment lacks an archived native session')
|
||||
with (session/'input-proof.json').open('xb') as output:
|
||||
output.write(raw)
|
||||
proof['archive_file'] = 'input-proof.json'
|
||||
report = json.loads(raw)
|
||||
if not isinstance(report, dict):
|
||||
refuse('Input report is not an object')
|
||||
for key in ('helper_sha256', 'ownership_helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command', 'nonce'):
|
||||
if report.get(key) != context[key]:
|
||||
refuse('Input report binding mismatch: '+key)
|
||||
if type(report.get('input_sent')) is bool:
|
||||
proof['input_sent'] = report['input_sent']
|
||||
if (report.get('schema') != 1 or report.get('kind') != 'headless-private-keyboard-experiment'
|
||||
or report.get('sequence_completed') is not True or report.get('input_sent') is not True
|
||||
or report.get('hard_supervision') is not True or report.get('supervision_failed')
|
||||
or report.get('all_attempted_presses_released') is not True
|
||||
or report.get('error') or report.get('paired_release_errors') != [] or report.get('unconfirmed_held_key') is not None):
|
||||
refuse('Private keyboard sequence did not complete with confirmed paired release')
|
||||
import math
|
||||
events = report.get('events', [])
|
||||
expected = [('W', True), ('W', False), ('Tab', True), ('Tab', False), ('Tab', True), ('Tab', False)]
|
||||
if (not isinstance(events, list) or len(events) != len(expected)
|
||||
or any(not isinstance(e, dict) or type(e.get('down')) is not bool or (e.get('key'), e['down']) != pair or e.get('finally') for e, pair in zip(events, expected))):
|
||||
refuse('Input experiment does not contain the exact fixed key pairs')
|
||||
times = [e.get('monotonic') for e in events]
|
||||
if any(type(t) not in {int, float} or not math.isfinite(t) or t < 0 for t in times) or times != sorted(times) or times[-1]-times[0] > 25:
|
||||
refuse('Input event clock is invalid or outside its bound')
|
||||
if not display_result or display_result.get('verified') is not True:
|
||||
refuse('Input experiment lacks verified private display ownership')
|
||||
display = json.loads((session/'display-ownership.json').read_bytes())
|
||||
if report.get('binding') != display.get('binding') or report.get('owner') not in [
|
||||
{key: window[key] for key in ('window', 'pid', 'server_pid', 'process')} for window in display.get('owned_windows', [])]:
|
||||
refuse('Input owner does not match the independently observed private game window')
|
||||
native = report.get('observer', {})
|
||||
if (report.get('console') != str(session/'console.log')
|
||||
or native.get('events') != ['READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED']):
|
||||
refuse('Input experiment lacks the exact native session/transition handshake')
|
||||
if (not observation or observation.get('nonce') != context['nonce'] or not observation.get('sampler_complete')
|
||||
or not observation.get('inventory_open_observed') or not observation.get('inventory_close_observed')
|
||||
or not observation.get('movement', {}).get('action_observed')
|
||||
or not observation.get('movement', {}).get('physical_motion_observed')
|
||||
or not observation.get('movement', {}).get('released_input_observed')):
|
||||
refuse('Native movement and inventory open/close proof is incomplete')
|
||||
proof['verified'] = True
|
||||
except (OSError, ValueError, TypeError, AttributeError, KeyError) as error:
|
||||
proof['error'] = str(error)
|
||||
if result == 'game-started' and not proof['verified']:
|
||||
result = 'input-exercise-incomplete'
|
||||
return result, proof
|
||||
|
||||
|
||||
def run(a):
|
||||
validate_input_exercise(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):
|
||||
@@ -824,6 +985,9 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
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', ''))
|
||||
exercise_context = None
|
||||
if getattr(a, 'exercise_player_input', False):
|
||||
command, exercise_context = input_exercise_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''), display_context)
|
||||
silent_sink = 'erri_game_'+run_id
|
||||
application_id = 'org.escapistsrereintroduced.game.'+run_id
|
||||
env = os.environ.copy()
|
||||
@@ -878,6 +1042,7 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
content = console.read_text(errors='replace') if console.is_file() else ''
|
||||
evidence = packed_evidence(content, packed, steam, profile) if packed else game_evidence(content, a.project)
|
||||
outcome = monitor.observe(evidence, time.monotonic(), child.poll() is not None)
|
||||
outcome = input_exercise_poll_result(exercise_context, outcome, monitor.first_failure_at is not None)
|
||||
if outcome is not None:
|
||||
result = outcome; break
|
||||
# Grace never extends the original absolute run deadline.
|
||||
@@ -935,6 +1100,11 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
if observation_nonce:
|
||||
result, observation_result = input_observer_result(result, content, observation_nonce)
|
||||
final['input_observation'] = observation_result
|
||||
exercise_result = None
|
||||
if exercise_context:
|
||||
result, exercise_result = input_exercise_result(result, exercise_context, session, observation_result, display_result)
|
||||
final['input_exercise'] = exercise_result
|
||||
final['scope'] = 'ordinary game startup with an explicitly requested bounded private keyboard experiment; see input_exercise for sent-input and native-proof results; no full escape, multiplayer or FPS qualification'
|
||||
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,
|
||||
@@ -949,6 +1119,8 @@ def run_locked(a, steam, compat, profile, packed=None):
|
||||
'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 exercise_result is not None:
|
||||
summary['input_exercise'] = exercise_result
|
||||
if display_result is not None:
|
||||
summary['display_ownership'] = display_result
|
||||
summary['startup_result_before_display_check'] = startup_result
|
||||
@@ -979,6 +1151,7 @@ def main():
|
||||
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('--exercise-player-input', action='store_true', help='Explicit bounded private W/Tab keyboard experiment; requires observer, display inspection, loose Core/Chat and settle >=50')
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in bounded private XTest keyboard experiment. Not a launcher or gameplay proof."""
|
||||
import argparse
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import headless_display_probe as display
|
||||
|
||||
|
||||
NOTIFY = None
|
||||
|
||||
|
||||
def notify(value):
|
||||
if NOTIFY is not None:
|
||||
NOTIFY.send(value)
|
||||
|
||||
|
||||
class Refused(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class KeyStateBusy(Refused):
|
||||
pass
|
||||
|
||||
|
||||
def option(args, name):
|
||||
if args.count(name) != 1:
|
||||
raise Refused('Missing or repeated native option: ' + name)
|
||||
index = args.index(name)
|
||||
if index + 1 >= len(args):
|
||||
raise Refused('Missing native option value')
|
||||
return args[index + 1]
|
||||
|
||||
|
||||
def game_arguments(owner, args):
|
||||
native = display.process(owner['pid'])
|
||||
if display.identity(native) != owner['process']:
|
||||
raise Refused('Game process changed')
|
||||
if option(native['args'], '-errOrdinaryInputObserve') != args.nonce:
|
||||
raise Refused('Native observer nonce mismatch')
|
||||
if option(native['args'], '-errOrdinaryScenario') not in ('core', 'chat'):
|
||||
raise Refused('Not an ordinary successor scenario')
|
||||
if option(native['args'], '-profile') != 'Z:' + str(args.profile):
|
||||
raise Refused('Native profile mismatch')
|
||||
|
||||
|
||||
class Observer:
|
||||
"""Only records appended after this exact owned console was opened count."""
|
||||
pattern = re.compile(r'ERR INPUT OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=(\d+(?:\.\d+)?) (.+)$')
|
||||
|
||||
def __init__(self, path, nonce, clock=time.monotonic):
|
||||
self.path, self.nonce, self.clock = path, nonce, clock
|
||||
self.fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
info = os.fstat(self.fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
||||
os.close(self.fd)
|
||||
raise Refused('Console is not a regular same-user file')
|
||||
self.identity = info.st_dev, info.st_ino
|
||||
self.start_offset = os.lseek(self.fd, 0, os.SEEK_END)
|
||||
self.offset = self.start_offset
|
||||
self.pending = b''
|
||||
self.sample = self.real = self.world = -1
|
||||
self.last_sample_at = None
|
||||
self.events = []
|
||||
self.raw = []
|
||||
self.bytes = 0
|
||||
self.terminal = False
|
||||
|
||||
def close(self):
|
||||
os.close(self.fd)
|
||||
|
||||
def poll(self):
|
||||
info = self.path.lstat()
|
||||
if (info.st_dev, info.st_ino) != self.identity or not stat.S_ISREG(info.st_mode) or info.st_size < self.offset:
|
||||
raise Refused('Console replaced or truncated')
|
||||
data = os.read(self.fd, 65536)
|
||||
self.offset += len(data); self.bytes += len(data)
|
||||
if self.bytes > 8 * 1024 * 1024:
|
||||
raise Refused('Console observation byte budget exceeded')
|
||||
self.pending += data
|
||||
if len(self.pending) > 131072:
|
||||
raise Refused('Console line budget exceeded')
|
||||
lines = self.pending.split(b'\n'); self.pending = lines.pop()
|
||||
for raw in lines:
|
||||
line = raw.decode('utf-8', 'strict').rstrip('\r')
|
||||
if 'ERR INPUT OBSERVE: run=' + self.nonce + ' ' not in line:
|
||||
continue
|
||||
match = self.pattern.search(line)
|
||||
if not match or match[1] != self.nonce:
|
||||
raise Refused('Malformed matching observer record')
|
||||
sample, real, world = int(match[2]), int(match[3]), float(match[4])
|
||||
if not math.isfinite(world) or real > 2147483647:
|
||||
raise Refused('Invalid native observer clock')
|
||||
if sample < self.sample or real < self.real or world < self.world:
|
||||
raise Refused('Native observer clock/sample reversed')
|
||||
if sample > 450 or len(self.raw) >= 512:
|
||||
raise Refused('Native observer record budget exceeded')
|
||||
detail = match[5]
|
||||
if detail.startswith('SAMPLE '):
|
||||
if sample <= self.sample:
|
||||
raise Refused('Repeated native sample')
|
||||
self.last_sample_at = self.clock()
|
||||
self.sample, self.real, self.world = sample, real, world
|
||||
self.raw.append(line)
|
||||
if detail == 'READY':
|
||||
if self.events or self.last_sample_at is None:
|
||||
raise Refused('Repeated or unbound READY')
|
||||
self.events.append('READY')
|
||||
elif detail in ('INVENTORY_OPEN', 'INVENTORY_CLOSED'):
|
||||
expected = ['READY'] if detail == 'INVENTORY_OPEN' else ['READY', 'INVENTORY_OPEN']
|
||||
if self.events != expected:
|
||||
raise Refused('Unexpected native inventory transition')
|
||||
self.events.append(detail)
|
||||
elif detail.startswith(('CANCELLED ', 'FINISHED ')):
|
||||
self.terminal = True
|
||||
if self.terminal:
|
||||
raise Refused('Native observer ended')
|
||||
|
||||
def fresh(self):
|
||||
self.poll()
|
||||
if self.last_sample_at is None or self.clock() - self.last_sample_at > 1:
|
||||
raise Refused('Native observer samples are stale')
|
||||
|
||||
|
||||
class Keyboard:
|
||||
"""Only W/Tab key events on the pre-existing explicit owned connection."""
|
||||
def __init__(self, query):
|
||||
self.query = query
|
||||
self.xt = C.CDLL('libXtst.so.6')
|
||||
self.xt.XTestQueryExtension.restype = C.c_int
|
||||
self.xt.XTestQueryExtension.argtypes = [C.c_void_p] + [C.POINTER(C.c_int)] * 4
|
||||
self.xt.XTestFakeKeyEvent.restype = C.c_int
|
||||
self.xt.XTestFakeKeyEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong]
|
||||
for name, restype, argtypes in (
|
||||
('XKeysymToKeycode', C.c_ubyte, [C.c_void_p, C.c_ulong]),
|
||||
('XKeycodeToKeysym', C.c_ulong, [C.c_void_p, C.c_ubyte, C.c_int]),
|
||||
('XQueryKeymap', C.c_int, [C.c_void_p, C.POINTER(C.c_char)]),
|
||||
('XFlush', C.c_int, [C.c_void_p])):
|
||||
fn = getattr(query.x, name); fn.restype = restype; fn.argtypes = argtypes
|
||||
values = [C.c_int() for _ in range(4)]
|
||||
if not self.xt.XTestQueryExtension(query.d, *(C.byref(v) for v in values)):
|
||||
raise Refused('Private XTest extension unavailable')
|
||||
self.codes = {}
|
||||
for key, keysym in (('W', 0x77), ('Tab', 0xff09)):
|
||||
code = int(query.x.XKeysymToKeycode(query.d, keysym))
|
||||
if not 8 <= code <= 255 or query.x.XKeycodeToKeysym(query.d, code, 0) != keysym:
|
||||
raise Refused('Default unmodified key mapping unavailable')
|
||||
self.codes[key] = code
|
||||
if len(set(self.codes.values())) != 2:
|
||||
raise Refused('Ambiguous key mapping')
|
||||
|
||||
def require_idle(self):
|
||||
state = C.create_string_buffer(32)
|
||||
if not self.query.x.XQueryKeymap(self.query.d, state):
|
||||
raise Refused('Native private keyboard state query failed')
|
||||
if any(state.raw):
|
||||
raise KeyStateBusy('A private keyboard key is still held')
|
||||
|
||||
def send(self, key, down):
|
||||
if key not in self.codes or type(down) is not bool:
|
||||
raise Refused('Unsupported input event')
|
||||
if not self.xt.XTestFakeKeyEvent(self.query.d, self.codes[key], int(down), 0):
|
||||
raise Refused('Native XTest key request failed')
|
||||
self.query.x.XFlush(self.query.d)
|
||||
|
||||
|
||||
class Guard:
|
||||
def __init__(self, args, bound, query, owner):
|
||||
self.args, self.bound, self.query, self.owner = args, bound, query, owner
|
||||
|
||||
def check(self):
|
||||
if display.binding(self.args.host_display, self.args.prefix) != self.bound:
|
||||
raise Refused('Original display binding changed')
|
||||
# A new per-check read budget, still bounded by the experiment deadline.
|
||||
self.query.query_budget = 128
|
||||
current = {'window': self.owner['window'], 'pid': self.query.prop(self.owner['window'], '_NET_WM_PID'),
|
||||
'server_pid': self.query.server_pid(self.owner['window'])}
|
||||
verified = display.owned_window(current, self.bound, self.args.prefix, self.args.game_executable)
|
||||
if verified is None or verified['process'] != self.owner['process'] or verified['pid'] != self.owner['pid']:
|
||||
raise Refused('Original game window or process changed')
|
||||
game_arguments(verified, self.args)
|
||||
target = display.input_target(self.query, verified)
|
||||
if target.get('verified') is not True:
|
||||
raise Refused('Owned existing focus/mapping unavailable: ' + target.get('reason', 'unknown'))
|
||||
if display.binding(self.args.host_display, self.args.prefix) != self.bound:
|
||||
raise Refused('Display binding changed during target check')
|
||||
return target
|
||||
|
||||
|
||||
def confirm_release(guard, keyboard, report, key, clock=time.monotonic, sleep=time.sleep):
|
||||
"""Bounded read-only state polling; never sends/retries an input event."""
|
||||
start = clock(); deadline = start + .35
|
||||
observation = {'key': key, 'start': start, 'attempts': 0, 'confirmed': False}
|
||||
report.setdefault('release_observations', []).append(observation)
|
||||
try:
|
||||
while True:
|
||||
guard.check()
|
||||
if clock() >= deadline:
|
||||
raise Refused('Private key release state did not settle within 350ms')
|
||||
observation['attempts'] += 1
|
||||
try:
|
||||
keyboard.require_idle()
|
||||
if clock() >= deadline:
|
||||
raise Refused('Private key release state query exceeded 350ms bound')
|
||||
observation['confirmed'] = True
|
||||
return
|
||||
except KeyStateBusy as error:
|
||||
observation['last_busy_error'] = str(error)
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise Refused('Private key release state did not settle within 350ms') from error
|
||||
sleep(min(.025, remaining))
|
||||
finally:
|
||||
observation['end'] = clock()
|
||||
|
||||
|
||||
def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep):
|
||||
deadline = clock() + 70
|
||||
held = None
|
||||
report.update(events=[], paired_release_errors=[], sequence_completed=False)
|
||||
|
||||
def check(native=True):
|
||||
if clock() >= deadline:
|
||||
raise Refused('Input sequence deadline exceeded')
|
||||
if native:
|
||||
observer.fresh()
|
||||
return guard.check()
|
||||
|
||||
def wait_for(event, seconds):
|
||||
end = min(deadline, clock() + seconds)
|
||||
while clock() < end:
|
||||
observer.poll()
|
||||
guard.check()
|
||||
if event in observer.events:
|
||||
observer.fresh()
|
||||
return
|
||||
sleep(0.05)
|
||||
raise Refused('Native transition timeout: ' + event)
|
||||
|
||||
def key(key, down):
|
||||
nonlocal held
|
||||
check()
|
||||
if down:
|
||||
keyboard.require_idle()
|
||||
# Mark uncertain commit before the FFI call, so an exception also releases.
|
||||
held = key
|
||||
notify({'kind': 'press-pending', 'key': key})
|
||||
report['input_attempted'] = True
|
||||
keyboard.send(key, down)
|
||||
report['events'].append({'key': key, 'down': down, 'monotonic': clock()})
|
||||
report['input_sent'] = True
|
||||
if not down:
|
||||
confirm_release(guard, keyboard, report, key, clock, sleep)
|
||||
held = None
|
||||
notify({'kind': 'release-confirmed', 'key': key})
|
||||
|
||||
def pulse(key_name, duration):
|
||||
key(key_name, True)
|
||||
end = clock() + duration
|
||||
while clock() < end:
|
||||
sleep(min(0.05, end - clock()))
|
||||
# The key-up path performs a full fresh guard immediately before
|
||||
# sending. Avoid duplicating that guard once the hold has elapsed.
|
||||
if clock() < end:
|
||||
check()
|
||||
key(key_name, False)
|
||||
|
||||
try:
|
||||
wait_for('READY', 45)
|
||||
deadline = clock() + 12
|
||||
if observer.events != ['READY']:
|
||||
raise Refused('Inventory was already changed before input')
|
||||
pulse('W', 0.30)
|
||||
check()
|
||||
pulse('Tab', 0.05)
|
||||
wait_for('INVENTORY_OPEN', 4)
|
||||
open_until = clock() + .5
|
||||
while clock() < open_until:
|
||||
sleep(min(.05, open_until - clock()))
|
||||
check()
|
||||
if observer.events != ['READY', 'INVENTORY_OPEN']:
|
||||
raise Refused('Inventory closed before the observed open interval')
|
||||
pulse('Tab', 0.05)
|
||||
wait_for('INVENTORY_CLOSED', 4)
|
||||
check()
|
||||
keyboard.require_idle()
|
||||
report['sequence_completed'] = True
|
||||
finally:
|
||||
if held is not None:
|
||||
try:
|
||||
# Release needs live display/window/focus authority, but not a live
|
||||
# observer; cancellation must not suppress an otherwise safe key-up.
|
||||
guard.check()
|
||||
keyboard.send(held, False)
|
||||
confirm_release(guard, keyboard, report, held, clock, sleep)
|
||||
notify({'kind': 'release-confirmed', 'key': held})
|
||||
report['events'].append({'key': held, 'down': False, 'finally': True, 'monotonic': clock()})
|
||||
except (OSError, ValueError) as error:
|
||||
report['paired_release_errors'].append(str(error))
|
||||
report['unconfirmed_held_key'] = held if report['paired_release_errors'] else None
|
||||
report['all_attempted_presses_released'] = not report.get('unconfirmed_held_key')
|
||||
|
||||
|
||||
def arguments(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--enable-private-keyboard-proof', action='store_true', required=True)
|
||||
for name in ('prefix', 'game-executable', 'profile', 'report'):
|
||||
p.add_argument('--' + name, type=Path, required=True)
|
||||
p.add_argument('--console', type=Path, help='Existing exact session; omit only in wrapper mode')
|
||||
p.add_argument('--host-display', required=True)
|
||||
p.add_argument('--nonce', required=True)
|
||||
p.add_argument('command', nargs=argparse.REMAINDER)
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def wrapper_command(args):
|
||||
command = args.command[1:] if args.command[:1] == ['--'] else args.command
|
||||
if not command:
|
||||
if args.console is None:
|
||||
raise Refused('Sidecar requires an exact existing console')
|
||||
return []
|
||||
if args.console is not None:
|
||||
raise Refused('Wrapper discovers a new session; do not supply console')
|
||||
if (len(command) < 3 or Path(command[0]).resolve() != Path(sys.executable).resolve() or
|
||||
Path(command[1]) != Path(display.__file__).resolve()):
|
||||
raise Refused('Only the unchanged sibling read-only display wrapper is allowed')
|
||||
wrapped = display.parse_args(command[2:])
|
||||
if (wrapped.query_worker or wrapped.prefix != args.prefix or wrapped.profile != args.profile or
|
||||
wrapped.game_executable != args.game_executable or wrapped.host_display != args.host_display or
|
||||
wrapped.report == args.report):
|
||||
raise Refused('Read-only wrapper context differs')
|
||||
display.validate_paths(wrapped)
|
||||
native = wrapped.command[1:] if wrapped.command[:1] == ['--'] else wrapped.command
|
||||
proton = Path.home() / '.local/share/Steam/steamapps/common/Proton - Experimental/proton'
|
||||
if len(native) < 3 or native[:3] != [str(proton), 'run', str(args.game_executable)]:
|
||||
raise Refused('Expected exact installed Proton/game command')
|
||||
if (option(native, '-errOrdinaryInputObserve') != args.nonce or
|
||||
option(native, '-profile') != 'Z:' + str(args.profile) or
|
||||
option(native, '-errOrdinaryScenario') not in ('core', 'chat')):
|
||||
raise Refused('Wrapped native observer context differs')
|
||||
return command
|
||||
|
||||
|
||||
def sessions(profile):
|
||||
folder = profile / 'logs'
|
||||
if not folder.exists():
|
||||
return set()
|
||||
if folder.is_symlink() or not folder.is_dir():
|
||||
raise Refused('Invalid private native logs directory')
|
||||
return {path for path in folder.iterdir() if re.fullmatch(r'logs_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}', path.name)}
|
||||
|
||||
|
||||
def console_path(args):
|
||||
path = args.console
|
||||
if (path != path.resolve() or path.name != 'console.log' or
|
||||
path.parent.parent != args.profile / 'logs' or
|
||||
not re.fullmatch(r'logs_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}', path.parent.name)):
|
||||
raise Refused('Expected exact canonical native session console')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = arguments(argv)
|
||||
args.observe_seconds = 25; args.query_worker = False
|
||||
display.validate_paths(args)
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', args.nonce):
|
||||
raise Refused('Invalid observer nonce')
|
||||
command = wrapper_command(args)
|
||||
if args.console is not None:
|
||||
console_path(args)
|
||||
bound = display.binding(args.host_display, args.prefix)
|
||||
report = {'schema': 1, 'kind': 'headless-private-keyboard-experiment', 'input_sent': False,
|
||||
'sequence_completed': False, 'release_qualified': False, 'gameplay_qualified': False,
|
||||
'hard_supervision': NOTIFY is not None, 'nonce': args.nonce, 'binding': bound, 'command': command, 'report': str(args.report), 'input_attempted': False,
|
||||
'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'ownership_helper_sha256': hashlib.sha256(Path(display.__file__).read_bytes()).hexdigest(),
|
||||
'prefix': str(args.prefix), 'profile': str(args.profile), 'game_executable': str(args.game_executable)}
|
||||
fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
info = os.fstat(fd)
|
||||
notify({'kind': 'report', 'path': str(args.report), 'device': info.st_dev, 'inode': info.st_ino,
|
||||
'metadata': report})
|
||||
query = observer = child = None
|
||||
before_sessions = sessions(args.profile) if command else set()
|
||||
deadline = time.monotonic() + 180
|
||||
previous = {}
|
||||
def interrupted(signum, frame):
|
||||
if report.get('sequence_completed') and fd is None and child is not None:
|
||||
if child.poll() is None:
|
||||
child.send_signal(signum)
|
||||
return
|
||||
raise Refused('Input experiment interrupted by signal ' + str(signum))
|
||||
try:
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[signum] = signal.signal(signum, interrupted)
|
||||
if command:
|
||||
child = subprocess.Popen(command) # inherit the existing private process group
|
||||
notify({'kind': 'child', 'identity': display.identity(display.process(child.pid)), 'command': command})
|
||||
while time.monotonic() < deadline:
|
||||
if child.poll() is not None:
|
||||
raise Refused('Read-only wrapper exited before native session')
|
||||
if display.binding(args.host_display, args.prefix) != bound:
|
||||
raise Refused('Display binding changed during startup')
|
||||
new = sessions(args.profile) - before_sessions
|
||||
if len(new) > 1:
|
||||
raise Refused('Ambiguous new native sessions')
|
||||
if new:
|
||||
candidate = next(iter(new)) / 'console.log'
|
||||
if candidate.exists():
|
||||
args.console = candidate; console_path(args); break
|
||||
time.sleep(.1)
|
||||
if args.console is None:
|
||||
raise Refused('New native console deadline exceeded')
|
||||
report['console'] = str(args.console)
|
||||
observer = Observer(args.console, args.nonce)
|
||||
query = display.X11(bound['display'])
|
||||
while time.monotonic() < deadline:
|
||||
if child is not None and child.poll() is not None:
|
||||
raise Refused('Read-only wrapper exited during startup')
|
||||
observer.poll()
|
||||
if display.binding(args.host_display, args.prefix) != bound:
|
||||
raise Refused('Display binding changed during window startup')
|
||||
query.query_budget = display.MAX_WINDOWS
|
||||
owners = [owner for window in query.windows()
|
||||
if (owner := display.owned_window(window, bound, args.prefix, args.game_executable))]
|
||||
eligible = [owner for owner in owners[:16] if display.input_target(query, owner).get('verified') is True]
|
||||
exact = [owner for owner in eligible if query.focus()[0] == owner['window']]
|
||||
selected = exact or eligible
|
||||
if len(selected) == 1:
|
||||
break
|
||||
if len(selected) > 1:
|
||||
raise Refused('Ambiguous mapped focused owned game windows')
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise Refused('Mapped owned game window deadline exceeded')
|
||||
owner = selected[0]
|
||||
report['owner'] = {key: owner[key] for key in ('window', 'pid', 'server_pid', 'process')}
|
||||
guard = Guard(args, bound, query, owner)
|
||||
guard.check()
|
||||
keyboard = Keyboard(query)
|
||||
keyboard.require_idle()
|
||||
sequence(guard, keyboard, observer, report)
|
||||
except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error:
|
||||
report['error'] = str(error)
|
||||
finally:
|
||||
report['whole_process_group_cleanup_verified'] = False # outer runner owns it
|
||||
if observer:
|
||||
report['observer'] = {'inode': observer.identity, 'start_offset': observer.start_offset,
|
||||
'end_offset': observer.offset, 'records': observer.raw, 'events': observer.events}
|
||||
observer.close()
|
||||
if query:
|
||||
query.close()
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
fd = None
|
||||
json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
notify({'kind': 'proof-finished', 'successful': report['sequence_completed'] and not report.get('error')})
|
||||
child_code = 0
|
||||
try:
|
||||
if child is not None:
|
||||
if report.get('error'):
|
||||
child.terminate()
|
||||
child_code = child.wait(timeout=max(.1, deadline - time.monotonic()) if not report.get('error') else 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill(); child.wait(timeout=2)
|
||||
child_code = 1
|
||||
finally:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
return 0 if report['sequence_completed'] and not report.get('error') and child_code == 0 else 1
|
||||
|
||||
|
||||
def worker_entry(argv, pipe):
|
||||
global NOTIFY
|
||||
NOTIFY = pipe
|
||||
try:
|
||||
code = main(argv)
|
||||
finally:
|
||||
pipe.close()
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def stop_exact_child(record, read=display.process, kill=os.kill):
|
||||
if record is None:
|
||||
return False
|
||||
try:
|
||||
current = read(record['identity']['pid'])
|
||||
if (display.identity(current) != record['identity'] or current['args'] != record['command'] or
|
||||
current['uid'] != os.getuid()):
|
||||
return False
|
||||
# Only the exact display wrapper created by our worker; no group signalling.
|
||||
kill(current['pid'], signal.SIGTERM)
|
||||
return True
|
||||
except (OSError, ValueError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def supervision_failure(record, key, reason):
|
||||
"""Amend only the exact exclusive report created by our now-stopped worker."""
|
||||
if record is None:
|
||||
return
|
||||
fd = os.open(record['path'], os.O_RDWR | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if (info.st_dev, info.st_ino) != (record['device'], record['inode']) or info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode):
|
||||
raise Refused('Worker report changed before timeout recording')
|
||||
raw = os.read(fd, 2 * 1024 * 1024)
|
||||
try:
|
||||
report = json.loads(raw) if raw else dict(record['metadata'])
|
||||
except (ValueError, UnicodeError):
|
||||
report = dict(record['metadata'])
|
||||
# Keep the worker's actual refusal; supervisor lifecycle is separate evidence.
|
||||
key = key or report.get('unconfirmed_held_key')
|
||||
report.setdefault('error', reason)
|
||||
report.update(supervision_failed=True, supervision_error=reason, sequence_completed=False,
|
||||
all_attempted_presses_released=key is None and report.get('all_attempted_presses_released', True),
|
||||
unconfirmed_held_key=key)
|
||||
if key is not None:
|
||||
report['input_attempted'] = True
|
||||
report.setdefault('paired_release_errors', []).append('Supervisor could not confirm paired key release')
|
||||
payload = (json.dumps(report, indent=2) + '\n').encode()
|
||||
os.lseek(fd, 0, os.SEEK_SET); os.write(fd, payload); os.ftruncate(fd, len(payload)); os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def supervise(argv=None):
|
||||
# Fork before loading Xlib or opening any display. The worker owns the one
|
||||
# connection. Parent never opens a second connection or retries an event.
|
||||
context = multiprocessing.get_context('fork')
|
||||
parent, child_pipe = context.Pipe(duplex=False)
|
||||
child = context.Process(target=worker_entry, args=(argv, child_pipe))
|
||||
previous = {}; received = []
|
||||
def forward(signum, frame):
|
||||
received.append(signum)
|
||||
if child.pid and child.is_alive():
|
||||
os.kill(child.pid, signum)
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[signum] = signal.signal(signum, forward)
|
||||
held_at = None; held_key = None; owned_child = None; report_record = None; proof_finished = False; expired = False
|
||||
deadline = time.monotonic() + 190
|
||||
try:
|
||||
child.start(); child_pipe.close()
|
||||
while child.is_alive():
|
||||
if parent.poll(.05):
|
||||
try:
|
||||
event = parent.recv()
|
||||
except EOFError:
|
||||
event = None
|
||||
if event and event['kind'] == 'press-pending':
|
||||
held_at = time.monotonic(); held_key = event['key']
|
||||
elif event and event['kind'] == 'release-confirmed':
|
||||
held_at = None; held_key = None
|
||||
elif event and event['kind'] == 'child':
|
||||
owned_child = event
|
||||
elif event and event['kind'] == 'report':
|
||||
report_record = event
|
||||
elif event and event['kind'] == 'proof-finished':
|
||||
proof_finished = event['successful']
|
||||
now = time.monotonic()
|
||||
if received or now >= deadline or (held_at is not None and now - held_at > 1.5):
|
||||
expired = True
|
||||
child.terminate() # asks worker finally to release if still provable
|
||||
child.join(1)
|
||||
stop_exact_child(owned_child)
|
||||
if child.is_alive():
|
||||
child.kill(); child.join(2)
|
||||
# Drain release acknowledgements emitted during cooperative finally.
|
||||
while parent.poll():
|
||||
try:
|
||||
last = parent.recv()
|
||||
except EOFError:
|
||||
break
|
||||
if last['kind'] == 'release-confirmed':
|
||||
held_key = None
|
||||
elif last['kind'] == 'report':
|
||||
report_record = last
|
||||
elif last['kind'] == 'proof-finished':
|
||||
proof_finished = last['successful']
|
||||
if not (received and proof_finished and held_key is None):
|
||||
supervision_failure(report_record, held_key, 'Private input worker timed out or was interrupted')
|
||||
break
|
||||
child.join(2)
|
||||
if child.exitcode != 0 and not expired:
|
||||
stop_exact_child(owned_child)
|
||||
if not (received and proof_finished and held_key is None):
|
||||
supervision_failure(report_record, held_key, 'Private input worker exited unsuccessfully')
|
||||
return 1 if expired or child.exitcode != 0 else 0
|
||||
finally:
|
||||
if child.pid and child.is_alive():
|
||||
child.terminate(); child.join(1)
|
||||
stop_exact_child(owned_child)
|
||||
if child.is_alive():
|
||||
child.kill(); child.join(2)
|
||||
parent.close()
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(supervise())
|
||||
Reference in New Issue
Block a user