Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
226 lines
14 KiB
Python
226 lines
14 KiB
Python
"""Owned child lifecycle reused from exact c007e helper, strict raw diagnostics.
|
|
No external process/group signals. A tracked child Popen is finally killed/waited
|
|
by the vendored helper even if a START/END receipt cannot be written.
|
|
"""
|
|
from pathlib import Path
|
|
import hashlib, json, os, re, subprocess, time
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
HELPER_SHA = 'c007e712c281573254faa1233c2ac92e100ab87393256a6dc096b35283e43037'
|
|
_helper_file = HERE / 'p09_owned_engine_process.py'
|
|
if hashlib.sha256(_helper_file.read_bytes()).hexdigest() != HELPER_SHA:
|
|
raise RuntimeError('Exact previously reviewed owned-child helper changed')
|
|
helper = {'__name__': 'owned_diagnostic_vendored_helper', '__file__': str(_helper_file)}
|
|
exec(compile(_helper_file.read_bytes(), str(_helper_file), 'exec'), helper)
|
|
BANNER = 'Godot Engine v4.7.2.stable.official.ed1daf0bf - https://godotengine.org'
|
|
# Exact finite diagnostic families from the preserved strict collector r06.
|
|
KNOWN_EXIT_LEAK = re.compile(r'^\s*(?:(?:WARNING:|ERROR:)[^\r\n]*(?:ObjectDB instances leaked|(?:[0-9]+ )?ObjectDB instances were leaked at exit|resources? still in use at exit)|Leaked instance:)[^\r\n]*$')
|
|
DIGEST_SCRIPT = 'res://tests/performance/test_digest_performance.gd'
|
|
DIGEST_SOURCE_SHA = '363772fc43975f12c9e2056e2b01beb4cd8b2d7544db7528f80c6bd127710c1f'
|
|
DIGEST_UNICODE_LINE = 'Unicode parsing error, some characters were replaced with � (U+FFFD): Unexpected NUL character'
|
|
|
|
|
|
def process_probe(pid):
|
|
try:
|
|
raw = subprocess.check_output(['ps', '-p', str(pid), '-o', 'pid=,ppid=,pgid=,args='], text=True).strip()
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
parts = raw.split(None, 3)
|
|
if len(parts) != 4:
|
|
return None
|
|
return {'pid': int(parts[0]), 'ppid': int(parts[1]), 'pgid': int(parts[2]), 'args': parts[3]}
|
|
|
|
|
|
def native_diagnostics(stdout, stderr, argv):
|
|
lines = stdout.splitlines(keepends=True) + stderr.splitlines(keepends=True)
|
|
banners = [line for line in lines if line.startswith('Godot Engine v')]
|
|
errors = []
|
|
if len(banners) != 1 or banners[0] not in [BANNER + '\n', BANNER + '\r\n']:
|
|
errors.append('Missing, duplicate, wrong, or unterminated exact native engine banner')
|
|
for raw in lines:
|
|
line = raw.rstrip('\r\n')
|
|
if any(token in line for token in ['ERROR:', 'SCRIPT ERROR:', 'Parse Error:']) or re.match(r'^\s*(?:FAIL:|FAILED:|FAIL )', line):
|
|
errors.append(line)
|
|
if KNOWN_EXIT_LEAK.fullmatch(line):
|
|
errors.append(line)
|
|
unicode_lines = [line.rstrip('\r\n') for line in lines if 'Unicode parsing error' in line]
|
|
allowed_unicode = False
|
|
if unicode_lines and '--script' in argv and '--path' in argv:
|
|
script = argv[argv.index('--script') + 1]
|
|
stage = Path(argv[argv.index('--path') + 1])
|
|
source = stage / DIGEST_SCRIPT.removeprefix('res://')
|
|
allowed_unicode = script == DIGEST_SCRIPT and source.is_file() and hashlib.sha256(source.read_bytes()).hexdigest() == DIGEST_SOURCE_SHA and all(line == DIGEST_UNICODE_LINE for line in unicode_lines)
|
|
if unicode_lines and not allowed_unicode:
|
|
errors.append('Unrecognized Unicode diagnostic or source pin')
|
|
return {'errors': errors, 'banner_count': len(banners), 'known_unicode_diagnostic_count': len(unicode_lines) if allowed_unicode else 0,
|
|
'unrecognized_unicode_diagnostic_count': 0 if allowed_unicode else len(unicode_lines),
|
|
'exit_leak_diagnostic_lines': [line.rstrip('\r\n') for line in lines if KNOWN_EXIT_LEAK.fullmatch(line.rstrip('\r\n'))]}
|
|
|
|
|
|
def cleanup_actual_owned_child(process):
|
|
"""Extra bounded cleanup of exactly the factory's actual Popen object.
|
|
The old helper's bytes/three tries remain unchanged. These are at most
|
|
three additional poll/kill/wait attempts, each explicit wait at most 5s.
|
|
Receipt completeness is independent of actual child-ended observation.
|
|
"""
|
|
rows = []
|
|
code = None
|
|
if process is None:
|
|
return {'owned_child_created': False, 'engine_ended': False,
|
|
'actual_process_exit_code': None, 'attempts': [], 'errors': []}
|
|
errors = []
|
|
for attempt in range(3):
|
|
row = {'attempt': attempt + 1, 'kill_attempted': False, 'wait_timeout_seconds': 5,
|
|
'wait_completed': False, 'errors': []}
|
|
try:
|
|
code = process.poll()
|
|
row['poll_before'] = code
|
|
except BaseException as caught:
|
|
code = None
|
|
row['errors'].append('poll before ' + type(caught).__name__ + ': ' + str(caught))
|
|
if code is None:
|
|
row['kill_attempted'] = True
|
|
try:
|
|
process.kill()
|
|
except BaseException as caught:
|
|
row['errors'].append('kill ' + type(caught).__name__ + ': ' + str(caught))
|
|
try:
|
|
waited = process.wait(timeout=5)
|
|
row['wait_completed'] = True
|
|
row['wait_returned_code'] = waited
|
|
if type(waited) is int:
|
|
code = waited
|
|
except BaseException as caught:
|
|
row['errors'].append('wait ' + type(caught).__name__ + ': ' + str(caught))
|
|
try:
|
|
polled = process.poll()
|
|
row['poll_after'] = polled
|
|
if polled is not None:
|
|
code = polled
|
|
except BaseException as caught:
|
|
row['errors'].append('poll after ' + type(caught).__name__ + ': ' + str(caught))
|
|
rows.append(row)
|
|
errors.extend(row['errors'])
|
|
if code is not None:
|
|
break
|
|
return {'owned_child_created': True, 'engine_ended': code is not None,
|
|
'actual_process_exit_code': code, 'attempts': rows, 'errors': errors}
|
|
|
|
|
|
def run_owned_diagnostic(argv, folder, timeout, environment, *, now_fn, dump_fn, sha_fn, live_engines_fn,
|
|
popen_fn=None, probe_fn=None, monotonic_fn=None):
|
|
if live_engines_fn():
|
|
raise RuntimeError('Another Godot process owns CPU; diagnostic did not start')
|
|
folder = Path(folder)
|
|
folder.mkdir(exist_ok=False)
|
|
started = now_fn()
|
|
clock = monotonic_fn or time.monotonic
|
|
tick = clock()
|
|
owned = {'process': None, 'start': None, 'end': None, 'start_written': False, 'end_written': False}
|
|
probe = probe_fn or process_probe
|
|
actual_popen = popen_fn or subprocess.Popen
|
|
cwd = Path(argv[argv.index('--path') + 1]) if '--path' in argv else folder
|
|
with (folder / 'stdout.log').open('wb') as stdout, (folder / 'stderr.log').open('wb') as stderr:
|
|
def spawn(command, **options):
|
|
# No receipt, probe, or other failure-prone work before returning
|
|
# the actual Popen to the helper's owned try/finally.
|
|
process = actual_popen(command, cwd=options['cwd'], env=options['env'], stdout=stdout, stderr=stderr, start_new_session=True)
|
|
owned['process'] = process
|
|
class HelperPopenView:
|
|
# Preserve the old helper's actual object identity externally.
|
|
# Only its unbounded wait(None) is mapped to a 5s wait; normal
|
|
# explicit import/probe/BUILD timeout arguments pass unchanged.
|
|
@property
|
|
def pid(self):
|
|
return process.pid
|
|
def poll(self):
|
|
return process.poll()
|
|
def kill(self):
|
|
return process.kill()
|
|
def wait(self, timeout=None):
|
|
return process.wait(timeout=5 if timeout is None else timeout)
|
|
return HelperPopenView()
|
|
|
|
def write_start(receipt):
|
|
process = owned['process']
|
|
if process is None or receipt['actual_pid'] != process.pid or receipt['command'] != argv or receipt['owner_pid'] != os.getpid():
|
|
raise RuntimeError('START binding differs from actual owned Popen/argv/owner')
|
|
current = probe(process.pid)
|
|
exact_current = current is not None and current['pid'] == process.pid and current['ppid'] == os.getpid() and current['pgid'] == process.pid and current['args'] == ' '.join(argv)
|
|
ended_before_probe = current is None and process.poll() is not None
|
|
record = dict(receipt, pid=process.pid, argv=argv, started_utc=started,
|
|
current_process_identity=current, live_identity_exact=exact_current,
|
|
tracked_child_ended_before_initial_probe=ended_before_probe,
|
|
signal_scope='tracked Popen child only; no external group signal')
|
|
owned['start'] = record
|
|
if not exact_current and not ended_before_probe:
|
|
raise RuntimeError('Actual owned child live PID/PPID/PGID/argv identity mismatch')
|
|
dump_fn(folder / 'started.json', record)
|
|
owned['start_written'] = True
|
|
print('RUNTIME_SCALE_PROCESS_START ' + json.dumps({'name': folder.name, 'pid': process.pid}), flush=True)
|
|
|
|
def write_end(receipt):
|
|
process = owned['process']
|
|
if receipt['command'] != argv or receipt['owner_pid'] != os.getpid() or receipt['actual_pid'] != (process.pid if process is not None else None):
|
|
raise RuntimeError('END binding differs from actual tracked Popen/argv/owner')
|
|
record = dict(receipt, pid=process.pid if process is not None else None, argv=argv,
|
|
ended_utc=now_fn(), signal_scope='tracked Popen child only; no external group signal')
|
|
owned['end'] = record
|
|
dump_fn(folder / 'ended.json', record)
|
|
owned['end_written'] = True
|
|
|
|
class LifecycleTextSink:
|
|
def write(self, text):
|
|
return stdout.write(text.encode('utf-8'))
|
|
observed = None
|
|
adapter_error = None
|
|
cleanup = None
|
|
try:
|
|
observed = helper['run_owned'](argv, cwd, environment, LifecycleTextSink(), {'scope': 'isolated diagnostic owned child'}, timeout,
|
|
write_start, write_end, popen=spawn)
|
|
except BaseException as caught:
|
|
# Includes a second interrupt in the old helper's unguarded poll.
|
|
adapter_error = type(caught).__name__ + ': ' + str(caught)
|
|
finally:
|
|
# Keep the actual factory object, never discover or signal a PID
|
|
# or group from ps/receipts. All cleanup operations are bounded and
|
|
# catch BaseException individually, even after helper START failure.
|
|
cleanup = cleanup_actual_owned_child(owned['process'])
|
|
if observed is None:
|
|
observed = {'actual_pid': owned['process'].pid if owned['process'] is not None else None,
|
|
'exit_code': 130 if adapter_error and adapter_error.startswith('KeyboardInterrupt') else 127,
|
|
'actual_process_exit_code': cleanup['actual_process_exit_code'],
|
|
'engine_ended': cleanup['engine_ended'], 'timed_out': None,
|
|
'lifecycle_error': 'Helper escaped before returning: ' + str(adapter_error),
|
|
'start_receipt_written': owned['start_written'], 'end_receipt_written': owned['end_written']}
|
|
dump_fn(folder / 'adapter-owned-cleanup.json', cleanup)
|
|
raw = native_diagnostics((folder / 'stdout.log').read_text(errors='replace'), (folder / 'stderr.log').read_text(errors='replace'), argv)
|
|
errors = list(raw['errors'])
|
|
if adapter_error is not None or cleanup['errors'] or not cleanup['engine_ended']:
|
|
errors.append('Additional owned cleanup failed/interrupted: ' + str(adapter_error) + '; ' + str(cleanup['errors']))
|
|
if observed['lifecycle_error'] is not None or not observed['engine_ended'] or not observed['start_receipt_written'] or not observed['end_receipt_written']:
|
|
errors.append('Owned lifecycle/START/END receipt incomplete: ' + str(observed['lifecycle_error']))
|
|
lifecycle_errors = [str(value) for value in [observed['lifecycle_error'], adapter_error] if value is not None] + cleanup['errors']
|
|
result = {'argv': argv, 'pid': owned['process'].pid if owned['process'] is not None else None, 'started_utc': started, 'ended_utc': now_fn(),
|
|
'wall_seconds': clock() - tick, 'exit_code': cleanup['actual_process_exit_code'] if cleanup['actual_process_exit_code'] is not None else observed['exit_code'],
|
|
'helper_exit_code': observed['exit_code'], 'timed_out': observed['timed_out'],
|
|
'stdout_sha256': sha_fn(folder / 'stdout.log'), 'stderr_sha256': sha_fn(folder / 'stderr.log'), 'error_lines': errors,
|
|
'owned_popen_ended': cleanup['engine_ended'], 'old_helper_engine_ended': observed['engine_ended'], 'start_receipt_written': observed['start_receipt_written'],
|
|
'end_receipt_written': observed['end_receipt_written'], 'lifecycle_error': '; '.join(lifecycle_errors) if lifecycle_errors else None,
|
|
'old_helper_lifecycle_error': observed['lifecycle_error'],
|
|
'actual_start_binding': owned['start'], 'actual_end_binding': owned['end'], 'strict_native_diagnostics': raw,
|
|
'external_process_or_group_signals': 0, 'owned_child_process_helper_sha256': HELPER_SHA,
|
|
'adapter_owned_cleanup': cleanup, 'adapter_error': adapter_error,
|
|
'old_helper_bytes_preserved': True, 'helper_wait_None_maps_to_actual_timeout_seconds': 5,
|
|
'explicit_helper_wait_timeouts_preserved': True,
|
|
'additional_cleanup_wait_timeout_seconds': 5, 'additional_cleanup_max_attempts': 3}
|
|
dump_fn(folder / 'process.json', result)
|
|
return result
|
|
|
|
|
|
def process_gate(result, require_fn):
|
|
require_fn(result['exit_code'] == 0 and result['timed_out'] is False and not result['error_lines']
|
|
and result['owned_popen_ended'] is True and result['start_receipt_written'] is True
|
|
and result['end_receipt_written'] is True and result['lifecycle_error'] is None,
|
|
'Owned exit/timeout/START/END/exact banner/diagnostic gate failed')
|