Files
TheTree_mock/tools/regression/owned_diagnostic_process.py
T
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
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.
2026-10-11 15:58:00 +09:00

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')