#!/usr/bin/env python3
"""Actual application CLI; FAKE tailscale only; never invokes real tailscale."""
import asyncio, os, pathlib, json, signal, time, socket, subprocess, sys, platform
ROOT=pathlib.Path('/tmp/16238-ack-private-v2'); ROOT.mkdir(mode=0o700)
CO='/home/ubuntu/wt/issue-16238'
WRAPPER='''#!/usr/bin/python3
import os,sys,json,pathlib,signal,time
p=pathlib.Path(os.environ['FIXTURE_DIR']); a=sys.argv[1:]
def event(kind):
 r={'time':time.time(),'pid':os.getpid(),'args':a,'event':kind}
 with (p/'calls.jsonl').open('a') as f:f.write(json.dumps(r)+'\\n')
 with open(p/'events','w') as f:f.write(json.dumps(r)+'\\n')
event('entered')
if a[:2]==['serve','--bg']:
 if os.environ['FIXTURE_ROW']=='A2':
  def stop(sig,frame):
   event('terminated-before-mutation');sys.exit(128+sig)
  signal.signal(signal.SIGTERM,stop)
  event('hung-before-mutation')
  signal.pause()
 (p/'mapping.json').write_text(json.dumps({'httpsPort':int(a[2].split('=')[1]),'target':a[-1]}))
 event('mutation-ack-pending')
 with open(p/'ack','r') as f:f.read(1)
 event('acknowledged');sys.exit(0)
if a and a[0]=='serve' and a[-1]=='off':
 (p/'mapping.json').write_text('{}');event('disabled');sys.exit(0)
if a==['status','--json']:
 print(json.dumps({'Self':{'DNSName':'fixture.invalid.','TailscaleIPs':[]}}));sys.exit(0)
if a==['version']: print('FAKE tailscale fixture 1');sys.exit(0)
event('unsupported');sys.exit(1)
'''
def note(s): print(time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),s,flush=True)
async def row(name,port,serveport):
 d=ROOT/name;d.mkdir();(d/'bin').mkdir();(d/'bin'/'tailscale').write_text(WRAPPER);(d/'bin'/'tailscale').chmod(0o755)
 os.mkfifo(d/'events');os.mkfifo(d/'ack')
 # RDWR keeps the FIFO connected. Event-driven reads, no polling sleeps.
 fd=os.open(d/'events',os.O_RDWR|os.O_NONBLOCK)
 reader=asyncio.StreamReader(); protocol=asyncio.StreamReaderProtocol(reader)
 transport,_=await asyncio.get_running_loop().connect_read_pipe(lambda:protocol,os.fdopen(fd,'rb',buffering=0))
 initial=json.dumps({'httpsPort':serveport,'target':'http://127.0.0.1:19999','owner':'pre-existing-fixture'}) if name=='A2' else '{}'
 (d/'mapping.json').write_text(initial)
 for pt in (port,serveport):
  with socket.socket() as s:s.bind(('127.0.0.1',pt))
 env={k:v for k,v in os.environ.items() if not k.startswith(('T3CODE_','VITE_HTTP_URL','VITE_WS_URL'))}
 env.update(PATH=str(d/'bin')+':/home/ubuntu/.local/share/vite-plus/js_runtime/node/24.22.1/bin:'+env['PATH'],FIXTURE_DIR=str(d),FIXTURE_ROW=name,NO_COLOR='1')
 args=['/home/ubuntu/.local/share/vite-plus/js_runtime/node/24.22.1/bin/node','apps/server/src/bin.ts','serve','--tailscale-serve','--tailscale-serve-port',str(serveport),'--port',str(port),'--host','127.0.0.1','--base-dir',str(d/'base'),'--no-browser']
 note(f'{name} FAKE CLI; mapping-before={initial}');note('COMMAND cwd='+CO+' PATH='+str(d/'bin')+':<inherited> '+ ' '.join(args))
 proc=await asyncio.create_subprocess_exec(*args,cwd=CO,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,start_new_session=True)
 note(f'spawned captured PID/PGID={proc.pid}')
 ready=asyncio.Event(); configured=asyncio.Event()
 async def logs():
  if True:
   async for line in proc.stdout:
    s=line.decode(errors='replace')
    if 'T3 Code server is ready.' in s:ready.set();note(name+' application ready milestone')
    if 'Tailscale Serve configured' in s:configured.set();note(name+' application configured milestone')
    if 'Tailscale Serve disabled' in s:note(name+' application disabled milestone')
    if 'Failed to configure Tailscale Serve.' in s:note(name+' application retry warning milestone (raw body omitted)')
 task=asyncio.create_task(logs())
 async def event(kind):
  while True:
   r=json.loads(await asyncio.wait_for(reader.readline(),90));note(name+' CLI '+json.dumps(r))
   if r['event']==kind:return r
 try:
  await event('hung-before-mutation' if name=='A2' else 'mutation-ack-pending')
  await asyncio.wait_for(ready.wait(),90)
  r,w=await asyncio.open_connection('127.0.0.1',port);w.write(b'GET /.well-known/t3/environment HTTP/1.0\r\nHost: localhost\r\n\r\n');await w.drain();status=await r.readline();w.close();await w.wait_closed();note(name+' actual local HTTP GET /.well-known/t3/environment '+status.decode().strip());assert b'200' in status
  note(name+' mapping-at-shutdown='+(d/'mapping.json').read_text())
  before=len((d/'calls.jsonl').read_text().splitlines());t=time.monotonic();note(f'{name} SIGTERM captured server PID={proc.pid}; calls={before}');proc.send_signal(signal.SIGTERM)
  if name=='A1':
   assert proc.returncode is None
   records=[json.loads(x) for x in (d/'calls.jsonl').read_text().splitlines()]
   assert not any(x['args'][-1:] == ['off'] for x in records)
   note(name+' pending after SIGTERM: server alive; zero off before acknowledgement')
   with open(d/'ack','w') as f:f.write('1')
   note(f'{name} acknowledgement released seconds-after-SIGTERM={time.monotonic()-t:.6f}')
  rc=await asyncio.wait_for(proc.wait(),40);await task
  note(f'{name} server exit={rc}; shutdown_seconds={time.monotonic()-t:.3f}')
  records=[json.loads(x) for x in (d/'calls.jsonl').read_text().splitlines()];off=[x for x in records if x['args'] and x['args'][-1]=='off' and x['event']=='entered'];attempts=[x for x in records if x['args'][:2]==['serve','--bg'] and x['event']=='entered']
  final=(d/'mapping.json').read_text();note(name+' mapping-after='+final);note(f'{name} attempts={len(attempts)} serve-off-calls={len(off)} calls-after-SIGTERM={len(records)-before}')
  for pid in set(x['pid'] for x in records):
   alive=pathlib.Path(f'/proc/{pid}').exists();note(f'{name} fixture PID={pid} exists_after_exit={alive}');assert not alive
  # A short-lived orphan may still be exiting when the main CLI returns.
  # Observe only this captured process group, with bounded subprocess-driven checks.
  for check in range(1000):
   ps=await asyncio.create_subprocess_exec('ps','-eo','pid,ppid,pgid,stat',stdout=asyncio.subprocess.PIPE)
   out,_=await ps.communicate()
   remaining=[line for line in out.decode().splitlines()[1:] if line.split()[2]==str(proc.pid)]
   if not remaining:break
  note(name+' final group members='+repr(remaining))
  group_alive=bool(remaining)
  note(f'{name} captured process group exists_after_exit={group_alive}');assert not group_alive
  assert rc in (0,130) # Effect CLI signal termination is 130 on this runtime.
  assert len(off)==(0 if name=='A2' else 1)
  assert final==(initial if name=='A2' else '{}')
  assert len(attempts)==1
  note(name+' PASS')
 finally:
  if proc.returncode is None:
   os.killpg(proc.pid,signal.SIGTERM)
   try:await asyncio.wait_for(proc.wait(),10)
   except asyncio.TimeoutError:os.killpg(proc.pid,signal.SIGKILL);await proc.wait()
  transport.close()
async def main():
 note('SERVER_RUNTIME exact executable /home/ubuntu/.local/share/vite-plus/js_runtime/node/24.22.1/bin/node version='+subprocess.check_output(['/home/ubuntu/.local/share/vite-plus/js_runtime/node/24.22.1/bin/node','--version'],text=True).strip());note('SHA='+subprocess.check_output(['git','rev-parse','HEAD'],cwd=CO,text=True).strip());note('ENV python='+sys.version.split()[0]+' node_path='+subprocess.check_output(['which','node'],text=True).strip()+' '+platform.platform()+' '+subprocess.check_output(['node','--version'],text=True).strip());note('Raw logs never saved; transcript contains only selected fixed application milestones, no pairing output or QR blocks. All mapping state is fake, no real tailscale executable invoked.')
 await row('A1',29751,29451)
 await row('A2',29752,29452)
asyncio.run(main())
