fix: preserve tester session identity through cleanup (#1873)

This commit was merged in pull request #1931.
This commit is contained in:
jason.woltje committed 2026-11-11 08:22:31 -06:00
1 parent f382699d47
commit 4a122699a0
8 files changed
+177 -53

No files matched your search

@@ -55,8 +55,8 @@ for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills desc
assert.equal(result.stdout,''); assert.equal(result.stdout,'');
assert.equal(result.stderr,''); assert.equal(result.stderr,'');
const pairs=result.pairs; const pairs=result.pairs;
assert.equal(pairs.length,2,'the session leader and its live descendant both started'); assert.equal(pairs.length,2,'the command and its live descendant both started');
assert.equal(pairs[0][0],pairs[0][1],'the command leads its kernel session'); assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
assert.notEqual(pairs[1][0],pairs[0][0]); assert.notEqual(pairs[1][0],pairs[0][0]);
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session'); assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown'); assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
@@ -0,0 +1,34 @@
// No real process is signalled. These hostile kernel-boundary controls expose
// a new session reusing the old SID after the original leader disappears.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'the shipped cleanup program exists');
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
const control=`
import atexit, json
from types import SimpleNamespace
attempts = []
checks = 0
atexit.register(lambda: print(json.dumps(attempts)))
def observed(pid):
global checks
if pid == "self/fd/42":
checks += 1
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
return (b"R", session)
def send(fd, sig):
attempts.append([fd, sig])
raise PermissionError("never signal a real task")
fields = observed
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
`;
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
});
@@ -45,24 +45,27 @@ import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os'; import {tmpdir} from 'node:os';
import {join} from 'node:path'; import {join} from 'node:path';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable'])test(`${name}: namespace cleanup ${mode}`,t=>{ for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true})); const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'}); const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,''); assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null); const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
assert.equal(result.status,['unreadable','unkillable'].includes(mode)?86:81,JSON.stringify(result)); assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
assert.equal(result.stdout,'');assert.equal(result.stderr,''); assert.equal(result.stdout,'');
if(!['unreadable','unkillable'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive'); if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;} if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
assert.equal(result.pairs.length,2); assert.equal(result.pairs.length,2);
const [leader,descendant]=result.pairs; const [leader,descendant]=result.pairs;
assert.equal(leader[0],leader[1]); assert.notEqual(leader[0],leader[1]);
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
if(mode==='escape'){ if(mode==='escape'){
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]); assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it'); assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
}else{ }else{
assert.equal(descendant[1],leader[1]); assert.equal(descendant[1],leader[1]);
if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');} if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup'); else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
} }
}); });
@@ -1,5 +1,5 @@
// Namespace-local evidence and cleanup. The JSON observation precedes teardown. // Namespace-local evidence and cleanup. The JSON observation precedes teardown.
import {readFileSync,writeFileSync,readdirSync} from 'node:fs'; import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
import {spawnSync} from 'node:child_process'; import {spawnSync} from 'node:child_process';
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs'; const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
const source=readFileSync(script,'utf8'); const source=readFileSync(script,'utf8');
@@ -27,11 +27,16 @@ setTimeout(()=>{
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"}; const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]); const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]])); writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
writeFileSync(record+'.anchor',String(process.ppid));
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]); writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
},100); },100);
setInterval(()=>{},1000); setInterval(()=>{},1000);
`); `);
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',timeout:8000,killSignal:'SIGKILL'}); const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
closeSync(out);closeSync(err);
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
let pairs=[]; let pairs=[];
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{} try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
const live=[]; const live=[];
@@ -45,8 +50,9 @@ for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid)); if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
}catch{} }catch{}
} }
let parent; let parent,anchor;
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{} try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
process.stdout.write(JSON.stringify({sessionLive,parent,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live})); process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
// These PIDs exist only in the private namespace. Never signal them on the host. // These PIDs exist only in the private namespace. Never signal them on the host.
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{} for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
+1 -1
View File
@@ -938,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
// mos-1326: assert every argv element, not flattened/substr command detection. // mos-1326: assert every argv element, not flattened/substr command detection.
const source=read(SCRIPT); const source=read(SCRIPT);
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n'; const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'9158cc66c027a901e6751f01de56b92311f08ab804d64f14ec571c9c332642e1'); assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1]; const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1]; const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line)); const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
+9 -8
View File
@@ -215,7 +215,7 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
refuses(r,/^case2-unit: tester config query inner-timeout\n$/); refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
const seconds=readProbeSeconds(read(clock)); const seconds=readProbeSeconds(read(clock));
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number); const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`); assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`); assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`); assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`); t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
@@ -261,7 +261,7 @@ test('row253 C4 cleanup budget starts after inventory pass one',()=>{
const source=read(UNIT); const source=read(UNIT);
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1]; const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'fixed cleanup program exists'); assert.ok(program,'fixed cleanup program exists');
const anchor='try:\n while True:'; const anchor='\ntry:\n';
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique'); assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
const standins=` const standins=`
from types import SimpleNamespace from types import SimpleNamespace
@@ -272,22 +272,23 @@ clock = time.monotonic()
def inventory(path): def inventory(path):
global clock global clock
clock += 1000 clock += 1000
return ["424242"] return ["999"]
def fake_fields(pid): def fake_fields(pid):
return (b"R", session) if alive else None return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
def send(fd, sig): def send(fd, sig):
global alive, kills global alive, kills
alive = False if sig == 9:
kills += 1 alive = False
kills += 1
fields = fake_fields fields = fake_fields
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None) os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, pidfd_send_signal=send) signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None) time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
`; `;
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242'],{encoding:'utf8'}); const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1'); assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
}); });
+56 -16
View File
@@ -136,12 +136,15 @@ trap 'rm -rf -- "$WORK"' EXIT
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || : IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
set +m set +m
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3 seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
child="" timer="" verified=0 child="" timer="" session_ref="" verified=0
stat_fields() { stat_fields() {
local pid="$1" raw="" rest local pid="$1" raw="" rest file="/proc/$1/stat"
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
file="/proc/self/fd/$session_ref/stat"
fi
# EOF is expected: stat has no NUL. Decide on content, not read's status. # EOF is expected: stat has no NUL. Decide on content, not read's status.
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || : IFS= read -r -d '' raw 2>/dev/null < "$file" || :
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; } [ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
rest="${raw##*) }" rest="${raw##*) }"
[ "$rest" != "$raw" ] || return 1 [ "$rest" != "$raw" ] || return 1
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest" read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
@@ -154,6 +157,7 @@ clean_session() {
import os, signal, sys, time import os, signal, sys, time
session = int(sys.argv[1]) session = int(sys.argv[1])
until = None until = None
anchor_fd = None
def fields(pid): def fields(pid):
try: try:
@@ -177,11 +181,22 @@ def fields(pid):
raise ValueError("stat") raise ValueError("stat")
return words[0], int(words[3]) return words[0], int(words[3])
def original_leader():
# The inherited proc directory refers to the original task, even if its
# numeric PID is reused. A live trusted leader keeps this SID allocated.
info = fields("self/fd/" + sys.argv[2])
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
raise ValueError("lost session anchor")
return info
try: try:
anchor_fd = os.pidfd_open(session)
original_leader()
while True: while True:
original_leader()
live = False live = False
for pid in os.listdir("/proc"): for pid in os.listdir("/proc"):
if not pid.isdecimal(): if not pid.isdecimal() or int(pid) == session:
continue continue
info = fields(pid) info = fields(pid)
if info is None or info[1] != session or info[0] in (b"Z", b"X"): if info is None or info[1] != session or info[0] in (b"Z", b"X"):
@@ -194,6 +209,10 @@ try:
try: try:
current = fields(pid) current = fields(pid)
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"): if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
# Check AFTER membership: while the original anchor is
# alive, this SID cannot name a later session. The pidfd
# then prevents task reuse between this check and kill.
original_leader()
signal.pidfd_send_signal(fd, signal.SIGKILL) signal.pidfd_send_signal(fd, signal.SIGKILL)
finally: finally:
os.close(fd) os.close(fd)
@@ -203,7 +222,10 @@ try:
info = fields(pid) info = fields(pid)
if info is not None and info[0] not in (b"Z", b"X"): if info is not None and info[0] not in (b"Z", b"X"):
raise raise
if not live: if not live and original_leader()[0] == b"T":
# The trusted leader is stopped after waiting for the command.
# With all other members dead it may return the saved command code.
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
break break
now = time.monotonic() now = time.monotonic()
# Inventory pass 1 remains outside the 250 ms confirmation budget. # Inventory pass 1 remains outside the 250 ms confirmation budget.
@@ -214,7 +236,10 @@ try:
time.sleep(0.01) time.sleep(0.01)
except Exception: except Exception:
sys.exit(1) sys.exit(1)
' "$child" finally:
if anchor_fd is not None:
os.close(anchor_fd)
' "$child" "$session_ref"
} }
finish() { finish() {
local rc="$?" local rc="$?"
@@ -226,22 +251,35 @@ finish() {
fi fi
# A refused cleanup may leave an unkillable member. Do not hide rc 86 # A refused cleanup may leave an unkillable member. Do not hide rc 86
# behind an unbounded wait; the caller retains its existing outer guard. # behind an unbounded wait; the caller retains its existing outer guard.
[ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : if [ "$rc" != 86 ] && [ -n "$child" ]; then
local command_rc=0
wait "$child" 2>/dev/null || command_rc=$?
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
fi
[ -z "$session_ref" ] || exec {session_ref}<&-
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
exit "$rc" exit "$rc"
} }
trap finish EXIT trap finish EXIT
trap 'exit 88' HUP INT TERM trap 'exit 88' HUP INT TERM
"$sleep_bin" "$seconds" & timer=$! "$sleep_bin" "$seconds" & timer=$!
# Hold the trusted launcher until its session is observed. Even /bin/true # The trusted session leader stays alive through cleanup. Copy code runs as
# cannot exit (and be reaped by bash) before verification. No copy code runs # its child; only after cleanup continues the stopped leader does it return
# in this handshake; set +m ensures setsid need not fork away from child. # the saved command status. An open proc directory pins its task identity.
"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & child=$! "$setsid_bin" /bin/bash -c '
set +m
kill -STOP "$BASHPID"
"$@" & command=$!
rc=0; wait "$command" 2>/dev/null || rc=$?
kill -STOP "$BASHPID"
exit "${rc}"
' -- "$@" & child=$!
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
# Allow session creation the whole inner budget, rather than a 200 ms margin. # Allow session creation the whole inner budget, rather than a 200 ms margin.
while :; do while :; do
rc=0; stat_fields "$child" || rc=$? rc=0; stat_fields "$child" || rc=$?
[ "$rc" != 3 ] || exit 85 [ "$rc" != 3 ] || exit 85
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ]; then if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
verified=1 verified=1
kill -CONT "$child" 2>/dev/null || exit 85 kill -CONT "$child" 2>/dev/null || exit 85
break break
@@ -255,13 +293,15 @@ while :; do
"$sleep_bin" 0.002 "$sleep_bin" 0.002
done done
[ "$verified" = 1 ] || exit 85 [ "$verified" = 1 ] || exit 85
while kill -0 "$child" 2>/dev/null; do while :; do
rc=0; stat_fields "$child" || rc=$?
[ "$rc" = 0 ] || exit 86
case "$ST_STATE" in Z|X) exit 86 ;; esac
[ "$ST_STATE" != T ] || break
kill -0 "$timer" 2>/dev/null || exit 81 kill -0 "$timer" 2>/dev/null || exit 81
"$sleep_bin" 0.01 "$sleep_bin" 0.01
done done
kill -0 "$timer" 2>/dev/null || exit 81 kill -0 "$timer" 2>/dev/null || exit 81
rc=0; wait "$child" || rc=$?
[ "$rc" = 0 ] || exit 87
exit 0 exit 0
MOSAIC_TESTER_SUPERVISOR MOSAIC_TESTER_SUPERVISOR
# row253-supervisor-end # row253-supervisor-end
+56 -16
View File
@@ -228,12 +228,15 @@ fi
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || : IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
set +m set +m
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3 seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
child="" timer="" verified=0 child="" timer="" session_ref="" verified=0
stat_fields() { stat_fields() {
local pid="$1" raw="" rest local pid="$1" raw="" rest file="/proc/$1/stat"
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
file="/proc/self/fd/$session_ref/stat"
fi
# EOF is expected: stat has no NUL. Decide on content, not read's status. # EOF is expected: stat has no NUL. Decide on content, not read's status.
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || : IFS= read -r -d '' raw 2>/dev/null < "$file" || :
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; } [ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
rest="${raw##*) }" rest="${raw##*) }"
[ "$rest" != "$raw" ] || return 1 [ "$rest" != "$raw" ] || return 1
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest" read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
@@ -246,6 +249,7 @@ clean_session() {
import os, signal, sys, time import os, signal, sys, time
session = int(sys.argv[1]) session = int(sys.argv[1])
until = None until = None
anchor_fd = None
def fields(pid): def fields(pid):
try: try:
@@ -269,11 +273,22 @@ def fields(pid):
raise ValueError("stat") raise ValueError("stat")
return words[0], int(words[3]) return words[0], int(words[3])
def original_leader():
# The inherited proc directory refers to the original task, even if its
# numeric PID is reused. A live trusted leader keeps this SID allocated.
info = fields("self/fd/" + sys.argv[2])
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
raise ValueError("lost session anchor")
return info
try: try:
anchor_fd = os.pidfd_open(session)
original_leader()
while True: while True:
original_leader()
live = False live = False
for pid in os.listdir("/proc"): for pid in os.listdir("/proc"):
if not pid.isdecimal(): if not pid.isdecimal() or int(pid) == session:
continue continue
info = fields(pid) info = fields(pid)
if info is None or info[1] != session or info[0] in (b"Z", b"X"): if info is None or info[1] != session or info[0] in (b"Z", b"X"):
@@ -286,6 +301,10 @@ try:
try: try:
current = fields(pid) current = fields(pid)
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"): if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
# Check AFTER membership: while the original anchor is
# alive, this SID cannot name a later session. The pidfd
# then prevents task reuse between this check and kill.
original_leader()
signal.pidfd_send_signal(fd, signal.SIGKILL) signal.pidfd_send_signal(fd, signal.SIGKILL)
finally: finally:
os.close(fd) os.close(fd)
@@ -295,7 +314,10 @@ try:
info = fields(pid) info = fields(pid)
if info is not None and info[0] not in (b"Z", b"X"): if info is not None and info[0] not in (b"Z", b"X"):
raise raise
if not live: if not live and original_leader()[0] == b"T":
# The trusted leader is stopped after waiting for the command.
# With all other members dead it may return the saved command code.
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
break break
now = time.monotonic() now = time.monotonic()
# Inventory pass 1 remains outside the 250 ms confirmation budget. # Inventory pass 1 remains outside the 250 ms confirmation budget.
@@ -306,7 +328,10 @@ try:
time.sleep(0.01) time.sleep(0.01)
except Exception: except Exception:
sys.exit(1) sys.exit(1)
' "$child" finally:
if anchor_fd is not None:
os.close(anchor_fd)
' "$child" "$session_ref"
} }
finish() { finish() {
local rc="$?" local rc="$?"
@@ -318,22 +343,35 @@ finish() {
fi fi
# A refused cleanup may leave an unkillable member. Do not hide rc 86 # A refused cleanup may leave an unkillable member. Do not hide rc 86
# behind an unbounded wait; the caller retains its existing outer guard. # behind an unbounded wait; the caller retains its existing outer guard.
[ "$rc" = 86 ] || [ -z "$child" ] || wait "$child" 2>/dev/null || : if [ "$rc" != 86 ] && [ -n "$child" ]; then
local command_rc=0
wait "$child" 2>/dev/null || command_rc=$?
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
fi
[ -z "$session_ref" ] || exec {session_ref}<&-
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
exit "$rc" exit "$rc"
} }
trap finish EXIT trap finish EXIT
trap 'exit 88' HUP INT TERM trap 'exit 88' HUP INT TERM
"$sleep_bin" "$seconds" & timer=$! "$sleep_bin" "$seconds" & timer=$!
# Hold the trusted launcher until its session is observed. Even /bin/true # The trusted session leader stays alive through cleanup. Copy code runs as
# cannot exit (and be reaped by bash) before verification. No copy code runs # its child; only after cleanup continues the stopped leader does it return
# in this handshake; set +m ensures setsid need not fork away from child. # the saved command status. An open proc directory pins its task identity.
"$setsid_bin" /bin/bash -c 'kill -STOP "$BASHPID"; exec "$@"' -- "$@" & child=$! "$setsid_bin" /bin/bash -c '
set +m
kill -STOP "$BASHPID"
"$@" & command=$!
rc=0; wait "$command" 2>/dev/null || rc=$?
kill -STOP "$BASHPID"
exit "${rc}"
' -- "$@" & child=$!
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
# Allow session creation the whole inner budget, rather than a 200 ms margin. # Allow session creation the whole inner budget, rather than a 200 ms margin.
while :; do while :; do
rc=0; stat_fields "$child" || rc=$? rc=0; stat_fields "$child" || rc=$?
[ "$rc" != 3 ] || exit 85 [ "$rc" != 3 ] || exit 85
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ]; then if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
verified=1 verified=1
kill -CONT "$child" 2>/dev/null || exit 85 kill -CONT "$child" 2>/dev/null || exit 85
break break
@@ -347,13 +385,15 @@ while :; do
"$sleep_bin" 0.002 "$sleep_bin" 0.002
done done
[ "$verified" = 1 ] || exit 85 [ "$verified" = 1 ] || exit 85
while kill -0 "$child" 2>/dev/null; do while :; do
rc=0; stat_fields "$child" || rc=$?
[ "$rc" = 0 ] || exit 86
case "$ST_STATE" in Z|X) exit 86 ;; esac
[ "$ST_STATE" != T ] || break
kill -0 "$timer" 2>/dev/null || exit 81 kill -0 "$timer" 2>/dev/null || exit 81
"$sleep_bin" 0.01 "$sleep_bin" 0.01
done done
kill -0 "$timer" 2>/dev/null || exit 81 kill -0 "$timer" 2>/dev/null || exit 81
rc=0; wait "$child" || rc=$?
[ "$rc" = 0 ] || exit 87
exit 0 exit 0
MOSAIC_TESTER_SUPERVISOR MOSAIC_TESTER_SUPERVISOR
# row253-supervisor-end # row253-supervisor-end