@@ -68,32 +68,80 @@ export async function readSystemdUnit(pid: number | "self"): Promise<string | un
6868 }
6969}
7070
71+ /**
72+ * MainPID of a systemd unit as reported by the system manager, or undefined when it cannot be
73+ * determined (systemctl missing, unit unknown, MainPID=0). `systemctl --user` units are invisible
74+ * to the system manager and resolve to undefined — the daemon is then treated as unsupervised,
75+ * matching the restart path, which also only drives the system manager.
76+ */
77+ export async function readUnitMainPid ( unit : string ) : Promise < number | undefined > {
78+ try {
79+ const { stdout } = await execFileAsync ( "systemctl" , [ "show" , "--property=MainPID" , "--value" , unit ] ) ;
80+ const mainPid = Number ( String ( stdout ) . trim ( ) ) ;
81+ return Number . isInteger ( mainPid ) && mainPid > 0 ? mainPid : undefined ;
82+ } catch {
83+ return undefined ;
84+ }
85+ }
86+
87+ /** Parent PID of `pid` from /proc/<pid>/stat, or undefined. */
88+ export async function readParentPid ( pid : number ) : Promise < number | undefined > {
89+ try {
90+ const stat = await fs . readFile ( `/proc/${ pid } /stat` , "utf-8" ) ;
91+ // Format: `pid (comm) state ppid …` — comm may itself contain spaces or parens, so the
92+ // fields are only parseable after the LAST closing paren.
93+ const afterComm = stat . slice ( stat . lastIndexOf ( ")" ) + 2 ) ;
94+ const ppid = Number ( afterComm . split ( " " ) [ 1 ] ) ;
95+ return Number . isInteger ( ppid ) && ppid > 0 ? ppid : undefined ;
96+ } catch {
97+ return undefined ;
98+ }
99+ }
100+
71101/**
72102 * Detect whether THIS process was started by systemd, and under which unit. systemd sets
73- * $INVOCATION_ID for every service it spawns; the unit name comes from this process's own cgroup.
74- * `env`/`readUnit` are injectable for testing. Returns undefined when not systemd-supervised.
103+ * $INVOCATION_ID for every service it spawns and the unit name comes from this process's own
104+ * cgroup — but BOTH are inherited by every descendant of any service (e.g. all processes inside a
105+ * CI runner's agent service, issue #92), so unit membership alone is not ownership. The daemon is
106+ * only systemd-supervised when it is the unit's main process (or its direct child, covering
107+ * `ExecStart=/bin/sh -c …` wrappers); otherwise restarting the unit would bounce an unrelated
108+ * service. `env`/`readUnit`/`getMainPid`/`self` are injectable for testing. Returns undefined when
109+ * not systemd-supervised.
75110 */
76111export async function detectSelfSupervisor (
77112 env : NodeJS . ProcessEnv = process . env ,
78- readUnit : ( pid : number | "self" ) => Promise < string | undefined > = readSystemdUnit
113+ readUnit : ( pid : number | "self" ) => Promise < string | undefined > = readSystemdUnit ,
114+ getMainPid : ( unit : string ) => Promise < number | undefined > = readUnitMainPid ,
115+ self : { pid : number ; ppid : number } = { pid : process . pid , ppid : process . ppid }
79116) : Promise < DaemonSupervisor | undefined > {
80117 if ( ! env . INVOCATION_ID ) return undefined ;
81118 const unit = await readUnit ( "self" ) ;
82- return unit ? { type : "systemd" , unit } : undefined ;
119+ if ( ! unit ) return undefined ;
120+ const mainPid = await getMainPid ( unit ) ;
121+ if ( mainPid === undefined || ( mainPid !== self . pid && mainPid !== self . ppid ) ) return undefined ;
122+ return { type : "systemd" , unit } ;
83123}
84124
85125/**
86126 * Resolve the supervisor for a daemon described by `state`. Prefers the `supervisor` it recorded
87- * at startup; for legacy daemons that predate that field, falls back to inferring the unit from the
88- * live process's cgroup. `readUnit` is injectable for testing.
127+ * at startup (already ownership-verified by detectSelfSupervisor); for legacy daemons that predate
128+ * that field, falls back to inferring the unit from the live process's cgroup, with the same
129+ * MainPID ownership check as detectSelfSupervisor (issue #92).
130+ * `readUnit`/`getMainPid`/`getParentPid` are injectable for testing.
89131 */
90132export async function resolveDaemonSupervisor (
91133 state : DaemonState ,
92- readUnit : ( pid : number | "self" ) => Promise < string | undefined > = readSystemdUnit
134+ readUnit : ( pid : number | "self" ) => Promise < string | undefined > = readSystemdUnit ,
135+ getMainPid : ( unit : string ) => Promise < number | undefined > = readUnitMainPid ,
136+ getParentPid : ( pid : number ) => Promise < number | undefined > = readParentPid
93137) : Promise < DaemonSupervisor | undefined > {
94138 if ( state . supervisor ) return state . supervisor ;
95139 const unit = await readUnit ( state . pid ) ;
96- return unit ? { type : "systemd" , unit } : undefined ;
140+ if ( ! unit ) return undefined ;
141+ const mainPid = await getMainPid ( unit ) ;
142+ if ( mainPid === undefined ) return undefined ;
143+ if ( mainPid !== state . pid && mainPid !== ( await getParentPid ( state . pid ) ) ) return undefined ;
144+ return { type : "systemd" , unit } ;
97145}
98146
99147function stateFilePath ( pid : number ) : string {
0 commit comments