#!/usr/bin/env python3
"""Real AppImage shutdown capture. Run under headless-desktop; no app modifications.
Normal: wmctrl orderly close. Held: stop surviving zygotes at the main process kernel exit event,
exposing the post-unmount lifetime deterministically; resume survivors afterwards.
All process signals target PIDs discovered beneath the launched AppImage.
"""
import os,sys,time,json,ctypes,signal,subprocess,threading,queue,pathlib,tkinter as tk
from datetime import datetime
image,side,sha,mode,outroot=sys.argv[1:]
out=pathlib.Path(outroot)/(side+'-'+mode+'-'+datetime.now().strftime('%H%M%S'));out.mkdir()
ctypes.CDLL(None).prctl(36,1,0,0,0) # PR_SET_CHILD_SUBREAPER
q=queue.Queue(); events=[]; known={}; start=time.monotonic(); closing=None
root=tk.Tk();root.title('AppImage shutdown observer');root.geometry('730x1020+1180+15');root.configure(bg='#101820')
head=tk.Label(root,text=f'{side.upper()}  {sha[:10]}\nLinux x86_64 · Xvfb / openbox\nFresh app state · '+('exit-boundary hold' if mode=='held' else 'normal close'),bg='#101820',fg='white',font=('DejaVu Sans',17),justify='left');head.pack(anchor='w',padx=16,pady=15)
box=tk.Text(root,bg='#101820',fg='#dce9ef',font=('DejaVu Sans Mono',12),wrap='word',borderwidth=0,insertwidth=0);box.pack(fill='both',expand=True,padx=16,pady=8)
status=tk.Label(root,text='Launching real AppImage…',bg='#101820',fg='#ffc866',font=('DejaVu Sans',17),wraplength=690,justify='left');status.pack(padx=16,pady=18)
def emit(text,**extra):
    elapsed=round((time.monotonic()-start)*1000,3); ev=dict(ms=elapsed,event=text,**extra);events.append(ev);q.put(('line',f'{elapsed:10.3f} ms  {text}\n'))
    with (out/'events.json').open('w') as f:json.dump(events,f,indent=2)
def stat(pid):
    try:
        s=pathlib.Path(f'/proc/{pid}/stat').read_text(); a=s[s.rfind(')')+2:].split(); return a[0],int(a[1])
    except (OSError,ValueError):return None

def discover(pid):
    try:
        cmd=pathlib.Path(f'/proc/{pid}/cmdline').read_bytes().replace(b'\0',b' ').decode(errors='replace');known[pid]=cmd
        kids=set()
        for task in pathlib.Path(f'/proc/{pid}/task').iterdir():
            try:kids.update(map(int,(task/'children').read_text().split()))
            except OSError:pass
        for kid in kids:discover(kid)
    except OSError:pass

def stable_capture(label):
    a=out/(label+'-a.png');b=out/(label+'.png')
    for _ in range(10):
        subprocess.run(['import','-window','root',str(a)],check=True)
        time.sleep(.5)
        subprocess.run(['import','-window','root',str(b)],check=True)
        equal=subprocess.run(['compare','-metric','AE',str(a),str(b),'null:'],capture_output=True)
        if equal.returncode==0:return
    raise RuntimeError('Screenshot pixels did not settle: '+label)

def run():
 global closing
 wm=subprocess.Popen(['openbox'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
 recorder=None;app=None;tracked=[]
 try:
    time.sleep(1)
    recorder=subprocess.Popen(['ffmpeg','-y','-loglevel','error','-f','x11grab','-video_size','1920x1080','-framerate','12','-i',os.environ['DISPLAY'],'-c:v','libx264','-preset','ultrafast','-crf','24','-pix_fmt','yuv420p',str(out/'recording.mp4')],stdout=subprocess.DEVNULL,stderr=(out/'ffmpeg.log').open('w'))
    state=out/'state';state.mkdir()
    # Synthetic display label, so captures never expose the host's name.
    bindir=state/'bin';bindir.mkdir()
    hostlabel=bindir/'hostnamectl';hostlabel.write_text('#!/bin/sh\nprintf \'AppImage evidence\\n\'\n');hostlabel.chmod(0o755)
    env={k:os.environ[k] for k in ['PATH','DISPLAY','DBUS_SESSION_BUS_ADDRESS','XDG_RUNTIME_DIR','XAUTHORITY','LANG'] if k in os.environ}
    env['PATH']=str(bindir)+os.pathsep+env['PATH']
    env.update(HOME=str(state),XDG_CONFIG_HOME=str(state/'config'),XDG_DATA_HOME=str(state/'data'),XDG_CACHE_HOME=str(state/'cache'),T3CODE_HOME=str(state/'t3'),T3CODE_DEV_SEED='0')
    app=subprocess.Popen([image,'--no-sandbox','--disable-gpu'],env=env,cwd=state,stdout=(out/'app.log').open('w'),stderr=subprocess.STDOUT)
    runtime=app.pid;emit(f'AppImage launch PID {app.pid}')
    deadline=time.monotonic()+90;window=None;main=None
    while time.monotonic()<deadline:
        discover(runtime)
        lines=subprocess.check_output(['wmctrl','-lp'],text=True).splitlines()
        for line in lines:
            cols=line.split(None,4)
            if len(cols)>=5 and int(cols[2]) in known and 'T3' in cols[4]:window=cols[0];main=int(cols[2]);break
        if window:break
        if app.poll() is not None:raise RuntimeError('AppImage exited before its window opened')
        time.sleep(.1)
    if not window:raise RuntimeError('No app window within 90 seconds')
    subprocess.run(['wmctrl','-ir',window,'-b','remove,maximized_vert,maximized_horz'],check=True)
    subprocess.run(['wmctrl','-ir',window,'-e','0,15,35,1140,960'],check=True)
    time.sleep(7);discover(app.pid)
    # The static AppImage runtime daemon is reparented to this subreaper.
    runtimes=[]
    for proc in pathlib.Path('/proc').iterdir():
        if not proc.name.isdigit():continue
        pid=int(proc.name);st=stat(pid)
        if not st or st[1]!=os.getpid() or pid==app.pid:continue
        try:cmd=(proc/'cmdline').read_bytes().split(b'\0')[0].decode()
        except OSError:continue
        if cmd==image:runtimes.append(pid);known[pid]=cmd
    if len(runtimes)!=1:raise RuntimeError(f'Expected 1 AppImage mount runtime, found {runtimes}')
    runtime=runtimes[0];emit(f'AppImage mount runtime PID {runtime}')
    zygotes=[pid for pid,cmd in known.items() if '--type=zygote' in cmd]
    tracked=list(known);emit(f'Real app window ready; Electron main PID {main}')
    emit(f'Chromium zygotes: {zygotes}');q.put(('status','App running; close follows'))
    if len(zygotes)!=2:raise RuntimeError(f'Expected 2 zygotes, got {len(zygotes)}')
    exe=os.readlink(f'/proc/{main}/exe');mount=exe.split('/')[1:3];mount='/'+ '/'.join(mount)
    emit('AppImage executable is on a mounted squashfs')
    mounted=lambda: any(mount in s for s in pathlib.Path('/proc/self/mountinfo').read_text().splitlines())
    if not mounted():raise RuntimeError('AppImage mount not visible')
    time.sleep(3)
    stable_capture('app-open')
    lib=ctypes.CDLL(None,use_errno=True)
    if mode=='held':
        # PTRACE_O_TRACEEXIT observes the boundary after userspace exit hooks.
        if lib.ptrace(0x4206,main,0,0x40)!=0:raise OSError(ctypes.get_errno(),'PTRACE_SEIZE')
        emit('Observe main kernel exit; then hold surviving zygotes')
    q.put(('status','Closing last app window…'));emit('wmctrl WM_DELETE_WINDOW: close last app window')
    closing=time.monotonic();subprocess.run(['wmctrl','-ic',window],check=True)
    observed=set();runtime_done=False;deadline=time.monotonic()+25
    while time.monotonic()<deadline:
        for pid in [*zygotes,main,runtime]:
            s=stat(pid)
            if (s is None or s[0] in 'ZX') and pid not in observed:
                observed.add(pid);emit(f'{"zygote" if pid in zygotes else "Electron main" if pid==main else "AppImage runtime"} {pid}: mappings released',pid=pid,state=None if s is None else s[0])
        # Reap adopted descendants; only log application processes, no unrelated processes.
        while True:
            try:p,statuscode=os.waitpid(-1,os.WNOHANG)
            except ChildProcessError:break
            if not p:break
            if os.WIFSTOPPED(statuscode) and p==main and statuscode>>16==6:
                held=[]
                for z in zygotes:
                    st=stat(z)
                    if st and st[0] not in 'ZX':
                        os.kill(z,signal.SIGSTOP);held.append(z)
                lib.ptrace(7,main,0,0) # PTRACE_CONT: let main complete its exit.
                emit(f'Main kernel exit: held {len(held)} live zygotes',held=held)
                continue
            if os.WIFSTOPPED(statuscode) and p==main:
                lib.ptrace(7,main,0,os.WSTOPSIG(statuscode))
                continue
            if p in known:
                code=os.waitstatus_to_exitcode(statuscode);emit(f'reaped PID {p}: exit {code}'+(f' ({signal.Signals(-code).name})' if code<0 else ''),pid=p,exit=code,coreDumped=bool(os.WCOREDUMP(statuscode)))
                if p==runtime:runtime_done=True
                if p==app.pid:app.returncode=code
        if runtime_done:
            alive={p:stat(p) for p in zygotes};alive={p:s for p,s in alive.items() if s and s[0] not in 'ZX'}
            emit(f'Runtime exited; AppImage mount present: {mounted()}')
            emit(f'Zygotes still alive after runtime exit: {len(alive)}',survivors=alive)
            verdict='BUG: zygotes outlive the unmounted image' if alive else 'Zygotes released before runtime exit'
            q.put(('status',verdict))
            if mode=='held':
                time.sleep(5)
                if len(alive)==2:
                    victim,survivor=list(alive)
                    os.kill(victim,signal.SIGKILL)
                    emit(f'Pressure probe: kill companion zygote {victim}')
                    (out/'survivor-maps.txt').write_text(pathlib.Path(f'/proc/{survivor}/maps').read_text())
                    probe=subprocess.run(['sudo','-n','python3',str(pathlib.Path(__file__).with_name('pageout.py')),str(survivor)],capture_output=True,text=True)
                    (out/'pageout.json').write_text(probe.stdout)
                    detail=json.loads(probe.stdout) if probe.returncode==0 else []
                    emit(f'MADV_PAGEOUT: {sum(x["result"] for x in detail if x["result"]>0)} bytes; {sum(x["result"]<0 for x in detail)} errors',returncode=probe.returncode)
                    if probe.returncode:emit('Page-out probe unavailable: '+probe.stderr.strip())
                else:
                    emit('Pressure probe: no surviving zygote to page out')
                for p in alive:
                    try:os.kill(p,signal.SIGCONT)
                    except ProcessLookupError:pass
                emit('SIGCONT surviving zygote after page-out probe')
            end=time.monotonic()+3
            while time.monotonic()<end:
                try:p,s=os.waitpid(-1,os.WNOHANG)
                except ChildProcessError:break
                if p and p in known:
                    code=os.waitstatus_to_exitcode(s)
                    emit(f'reaped PID {p}: exit {code}'+(f' ({signal.Signals(-code).name})' if code<0 else ''),pid=p,exit=code,coreDumped=bool(os.WCOREDUMP(s)))
                    if code==-signal.SIGBUS:q.put(('status','BUG: surviving zygote crashed with SIGBUS'))
                else:time.sleep(.01)
            (out/'summary.json').write_text(json.dumps(dict(side=side,sha=sha,mode=mode,runtimeExit=True,mountPresent=mounted(),survivors=alive,zygotes=zygotes),indent=2))
            break
        time.sleep(.0001)
    if not runtime_done:raise RuntimeError('Runtime did not exit after WM close')
    time.sleep(5)
    # Stable final still: verify exact pixels twice, 500 ms apart.
    stable_capture('final')
    emit('Final screenshots captured')
 except Exception as e:
    emit('ERROR: '+str(e));q.put(('status','Capture could not finish: '+str(e)))
 finally:
    for p in tracked:
        s=stat(p)
        if s and s[0] not in 'ZX':
            try:os.kill(p,signal.SIGKILL)
            except ProcessLookupError:pass
    if app and app.poll() is None:
        app.kill();app.wait()
    if recorder:
        recorder.send_signal(signal.SIGINT);recorder.wait(timeout=15)
    wm.terminate();wm.wait(timeout=5)
    q.put(('done',str(out)))
def pump():
    try:
        while True:
            typ,s=q.get_nowait()
            if typ=='line':box.insert('end',s);box.see('end')
            elif typ=='status':status.config(text=s,fg='#ff6666' if s.startswith('BUG') else '#a8e6a1')
            elif typ=='done':print(s,flush=True);root.destroy();return
    except queue.Empty:pass
    root.after(20,pump)
threading.Thread(target=run,daemon=True).start();pump();root.mainloop()
