159 lines
8.2 KiB
JavaScript
159 lines
8.2 KiB
JavaScript
// Shared fake process model for the two Case host-script tests. No real sudo.
|
|
// Call case_sudo_run RECORD_DIR COMMAND... after the caller's argv checks.
|
|
// Missing session-mode preserves the original exec-style fake.
|
|
import { spawn } from 'node:child_process';
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
|
|
export const CASE_SUDO_SESSION = String.raw`
|
|
case_sudo_entry() {
|
|
local record="$1" here sid; shift
|
|
here="$BASHPID"
|
|
read -r sid < <(/bin/ps -o sid= -p "$here")
|
|
printf "%s %s\n" "$here" "$sid" >> "$record/session-pids"
|
|
if { [ -f "$record/stall-legacy-precheck" ] && [[ "$*" == *MOSAIC_LEGACY_STATUS* ]]; } ||
|
|
{ [ -f "$record/stall-config" ] && [[ "$*" == *MOSAIC_TESTER_SOCKET_V1* ]]; } ||
|
|
{ [ -f "$record/stall-probe" ] && [[ "$*" == *SO_PEERCRED* ]]; }; then
|
|
trap '' TERM HUP
|
|
[ ! -f "$record/stall-output" ] || /bin/cat "$record/stall-output"
|
|
exec /bin/sleep 60
|
|
fi
|
|
exec "$@"
|
|
}
|
|
case_sudo_run() {
|
|
local record="$1" child="" rc=0 mode=exec; shift
|
|
[ ! -f "$record/session-mode" ] || mode="$(<"$record/session-mode")"
|
|
if [ "$mode" != hop ]; then case_sudo_entry "$record" "$@"; return "$?"; fi
|
|
# The fake stays as parent, just as sudo does on its pty path. KILL cannot
|
|
# be caught or forwarded. The child creates the separate terminal session.
|
|
trap '[ -z "$child" ] || kill -TERM -- "-$child" 2>/dev/null || :' TERM
|
|
trap '[ -z "$child" ] || kill -HUP -- "-$child" 2>/dev/null || :' HUP
|
|
trap '[ -z "$child" ] || kill -INT -- "-$child" 2>/dev/null || :' INT
|
|
export -f case_sudo_entry
|
|
/usr/bin/setsid /bin/bash -c 'case_sudo_entry "$@"' -- "$record" "$@" &
|
|
child=$!
|
|
# An interrupted wait returns while the child still runs; keep waiting.
|
|
while :; do
|
|
wait "$child"; rc=$?
|
|
kill -0 "$child" 2>/dev/null || break
|
|
done
|
|
return "$rc"
|
|
}
|
|
`;
|
|
|
|
export function liveRecordedSessions(recordFile, readFile, procDirs) {
|
|
// Exec fake entries share the test runner's session. Only self-led sessions
|
|
// are isolated and safe to scan here; exec coverage also checks exact PIDs.
|
|
const sessions = new Set(readFile(recordFile).trim().split('\n').filter(Boolean).map(line=>line.trim().split(/\s+/).map(Number)).filter(([pid,sid])=>pid===sid).map(([,sid])=>sid));
|
|
const live=[];
|
|
for(const entry of procDirs()){
|
|
if(!/^\d+$/.test(entry))continue;
|
|
try{
|
|
const stat=readFile(`/proc/${entry}/stat`), fields=stat.slice(stat.lastIndexOf(') ')+2).split(' ');
|
|
if(sessions.has(Number(fields[3])) && fields[0]!=='Z') live.push(Number(entry));
|
|
}catch{}
|
|
}
|
|
return live;
|
|
}
|
|
|
|
export function recordedPids(file) {
|
|
try{return readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>line.trim().split(/\s+/).map(Number));}
|
|
catch(error){if(error.code==='ENOENT')return [];throw error;}
|
|
}
|
|
|
|
export function recordedLivePids(file) {
|
|
return sessionPairs(recordedPids(file)).filter(([pid,sid])=>{
|
|
try{
|
|
const raw=readFileSync(`/proc/${pid}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' ');
|
|
return Number(fields[3])===sid && fields[0]!=='Z' && fields[0]!=='X';
|
|
}catch(error){if(error.code==='ENOENT'||error.code==='ESRCH')return false;throw error;}
|
|
}).map(([pid])=>pid);
|
|
}
|
|
|
|
export function killRecordedPids(file) {
|
|
killPairs(recordedPids(file));
|
|
}
|
|
|
|
function killPairs(pairs) {
|
|
for(const [pid,sid] of sessionPairs(pairs)){
|
|
try{
|
|
const raw=readFileSync(`/proc/${pid}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' ');
|
|
if(Number(fields[3])===sid && fields[0]!=='Z' && fields[0]!=='X')process.kill(pid,'SIGKILL');
|
|
}catch(error){if(error.code!=='ENOENT'&&error.code!=='ESRCH')throw error;}
|
|
}
|
|
}
|
|
|
|
function sessionPairs(pairs) {
|
|
// Only self-led records authorize a session scan. Exec-mode records belong
|
|
// to the runner's session and authorize checking their exact PIDs only.
|
|
const sessions=new Set(pairs.filter(([pid,sid])=>pid===sid).map(([,sid])=>sid));
|
|
const found=new Map(pairs.map(pair=>[pair[0],pair]));
|
|
if(sessions.size)for(const entry of readdirSync('/proc')){
|
|
if(!/^\d+$/.test(entry))continue;
|
|
try{
|
|
const raw=readFileSync(`/proc/${entry}/stat`,'utf8'),fields=raw.slice(raw.lastIndexOf(') ')+2).split(' '),sid=Number(fields[3]);
|
|
if(sessions.has(sid))found.set(Number(entry),[Number(entry),sid]);
|
|
}catch(error){if(error.code!=='ENOENT'&&error.code!=='ESRCH')throw error;}
|
|
}
|
|
return [...found.values()];
|
|
}
|
|
|
|
// Safety net for a removed-outer-bound change. It is fixture cleanup, never
|
|
// product supervision. The guard reads only fixture records and checks sid
|
|
// immediately before every kill. Normal outer-bound cases return before it.
|
|
export function fixtureGuard(t,files,milliseconds=9500,startFile) {
|
|
// P10 already records the instant its observed probe starts. Arm its safety
|
|
// net there, so unrelated host-fixture setup cannot consume the probe budget.
|
|
// Callers without an observer retain the original immediate-start behavior.
|
|
const program=`const fs=require('node:fs');
|
|
function cleanup(){for(const file of JSON.parse(process.argv[1])){let rows=[];try{rows=fs.readFileSync(file,'utf8').trim().split(String.fromCharCode(10))}catch{}for(const row of rows){const [pid,sid]=row.trim().split(' ').map(Number);if(!(pid>0&&sid>0))continue;try{const raw=fs.readFileSync('/proc/'+pid+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(') ')+2).split(' ');if(Number(f[3])===sid&&f[0]!=='Z')process.kill(pid,'SIGKILL')}catch{}}}}
|
|
function arm(){setTimeout(cleanup,Number(process.argv[2]));}
|
|
if(!process.argv[3])arm();else{const poll=setInterval(()=>{let text='';try{text=fs.readFileSync(process.argv[3],'utf8')}catch{}if(/^begin [0-9]+[.][0-9]{2}\\n/.test(text)){clearInterval(poll);arm();}},10);}`;
|
|
const child=spawn(process.execPath,['-e',program,JSON.stringify(files),String(milliseconds),...(startFile?[startFile]:[])],{stdio:'ignore'});
|
|
const stop=async()=>{
|
|
if(child.exitCode!==null||child.signalCode!==null)return;
|
|
const exited=new Promise(resolve=>child.once('exit',resolve));
|
|
child.kill('SIGKILL');await exited;
|
|
};
|
|
t.after(stop);return stop;
|
|
}
|
|
|
|
export function cleanupFixture(t,files) {
|
|
let captured=[];
|
|
const capture=()=>{captured=files.flatMap(recordedPids);};
|
|
// tmp(t)'s directory-removal hook may run first. Retain PID/SID pairs in
|
|
// memory before assertions, then verify the kernel sid again before killing.
|
|
t.after(()=>killPairs([...captured,...files.flatMap(recordedPids)]));
|
|
return capture;
|
|
}
|
|
|
|
export async function pollFixtureGone(files) {
|
|
for(let n=0;n<50;n++){
|
|
if(files.every(file=>recordedLivePids(file).length===0))return [];
|
|
await new Promise(resolve=>setTimeout(resolve,10));
|
|
}
|
|
return files.flatMap(recordedLivePids);
|
|
}
|
|
|
|
export function blockingQuerySource(records,kind='signals',member,proof) {
|
|
const py=`import os,sys\nos.setpgid(0,0)\nwith open(sys.argv[2],"a") as f:f.write(str(os.getpid())+" "+str(os.getsid(0))+chr(10))\nos.execv(sys.argv[1],[sys.argv[1],"60"])`;
|
|
return `import fs from 'node:fs';import {spawn} from 'node:child_process';
|
|
const records=${JSON.stringify(records)};
|
|
function record(pid){const s=fs.readFileSync('/proc/'+pid+'/stat','utf8');const sid=Number(s.slice(s.lastIndexOf(') ')+2).split(' ')[3]);fs.appendFileSync(records,pid+' '+sid+String.fromCharCode(10));}
|
|
record(process.pid);
|
|
export function loadSystem(){
|
|
${kind==='sleep'?`const c=spawn('/usr/bin/sleep',['60'],{stdio:'inherit'});record(c.pid);`:''}
|
|
${kind==='signals'?`process.on('SIGTERM',()=>{});process.on('SIGHUP',()=>{});`:''}
|
|
${kind==='member'?`const c=spawn('/usr/bin/python3',['-I','-S','-c',${JSON.stringify(py)},${JSON.stringify(member)},records],{stdio:'inherit'});for(let n=0;n<200;n++){try{const s=fs.readFileSync('/proc/'+c.pid+'/stat','utf8');if(s.includes('('+${JSON.stringify(member?.split('/').at(-1))}+') ')){fs.writeFileSync(${JSON.stringify(proof)},s);break;}}catch{}Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,5);}`:''}
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0);
|
|
}`;
|
|
}
|
|
|
|
// Row276: production budgets include the existing conversation/precheck bounds.
|
|
export const LEGACY_CASES = Object.freeze([
|
|
{tag:'talk',inner:320,outer:330,check:'F3'},
|
|
{tag:'precheck',inner:200,outer:210,check:'F4'},
|
|
{tag:'claude-version',inner:5,outer:7,check:'F6'},
|
|
{tag:'pm-list',inner:10,outer:12,check:'F7'},
|
|
{tag:'node-version',inner:5,outer:7,check:'F10'},
|
|
]);
|