Files
易良andQwen-Coder 5b7f9ec1c3 fix(ci): repair desktop RPM dependency and teardown checks (#16154)
* fix(ci): repair desktop rpm dependency and teardown checks

Co-authored-by: Qwen-Coder <[email protected]>

* test(desktop): use a counted space class in the EPEL checksum pin

`scripts/lint.js --eslint` runs on every file the PR touches, and the new
rpm dependency test spelled the two spaces in the pinned `echo` line as
literals: `no-regex-spaces` rejects them, so Lint & Static failed on the
incremental ESLint step.

`[0-9a-f]{64} {2}` matches the same two spaces the workflow emits; the
assertion still pins exactly what the release step writes.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvzj4h0j9a

* fix(desktop): keep the runtime group as a teardown target

Teardown collected daemon pids through the zombie-aware liveness predicate and
escalated SIGKILL only on leaders that were still alive, so a leader that had
already exited — reaped, replaced, or an unreaped zombie — silently dropped its
process group, which is teardown's only handle on it. The children the leader
spawned (MCP servers, tool subprocesses) then survived and kept writing under
the workspace about to be deleted, and the smoke reported a pass.

Collect every recorded pid, escalate on every collected group after the drain,
and keep the zombie-aware predicate on the "survived teardown" report so the
false positive this PR exists to fix stays fixed. The new fixture drives a
runtime whose leader exits while a writer in its group ignores SIGTERM.

Also pins the EPEL download URL and digest literals, drops the duplicate local
liveness predicate in favour of the shared helper, and covers the signal-probe
error arm (ESRCH gone; EPERM/EACCES alive, the rule the core helper documents).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvzpjyde9j

* fix(desktop): decide teardown on the runtime group, not its leader

The round-2 escalation signals the recorded pid's group (`kill(-pid)`), but
the verdict and the drain still asked `isProcessAlive(pid)`, which answers
"gone" as soon as the leader exits — the exact topology
`SMOKE_FIXTURE_EXIT_AFTER_SPAWN` reproduces. In that state `waitForExit`
returned on its first poll, so the 5 s drain was skipped and the
`survived teardown` branch was unreachable.

`isTeardownTargetAlive` probes the group first and falls back to the leader
where there is no group to signal (win32), so the drain and the leak verdict
cover the children the group can still hold.

Also drops the "mirrors the canonical … keep both in sync" claim from the
desktop probe's comment: it diverges from core's `isPidAlive` on the
pid <= 0 guard, the zombie exclusion on the EPERM/EACCES arm, and the
vanished-/proc answer. The comment now states those differences and why
teardown wants the dead-on-ENOENT direction.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvzrp44z9n

* fix(desktop): stop counting unreaped zombies as a live teardown group

`waitForExit` asks `isTeardownTargetAlive` whether a daemon's group still
exists. `kill(-pid, 0)` also answers for members that can no longer write, so
a daemon that exited and that the container's PID 1 has not reaped kept its
group "alive": the drain burned its five-second window, the second probe still
returned the pid, and teardown reported "the packaged runtime survived
teardown" for a runtime that was already gone.

On Linux the group probe now counts only members that can still write: scan
`/proc` for entries whose pgrp is the recorded pid, and report the group
stopped when every member is a zombie. A scan that observes no member keeps
the group probe's answer, so a hidden /proc cannot end a drain early, and the
group handle still wins over the leader handle.

Measured on a real zombie group (setsid + _exit, parent never reaps): /proc
state Z, `kill(-pgid, 0)` exit 0, `isProcessAlive` false, and
`isTeardownTargetAlive` true before this change, false after it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvzy4lf49w

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-03 10:18:16 +08:00

94 lines
3.1 KiB
JavaScript

import fs from 'node:fs';
// Teardown-only probe, deliberately narrower than the canonical
// packages/core/src/utils/process-liveness.ts: it takes no `pid <= 0` guard,
// skips the zombie exclusion on the EPERM/EACCES arm, and reads a vanished
// /proc entry as dead — the safe answer when the question is whether this
// script's own runtime leaked.
export function isProcessAlive(
pid,
{
platform = process.platform,
kill = process.kill,
readStat = (statPath) => fs.readFileSync(statPath, 'utf8'),
} = {},
) {
try {
kill(pid, 0);
} catch (error) {
// EPERM/EACCES: it exists under another owner.
return error?.code === 'EPERM' || error?.code === 'EACCES';
}
if (platform !== 'linux') return true;
try {
const stat = readStat(`/proc/${pid}/stat`);
const closingParen = stat.lastIndexOf(')');
if (closingParen < 0 || stat[closingParen + 1] !== ' ') return true;
return stat[closingParen + 2] !== 'Z';
} catch (error) {
return error?.code !== 'ENOENT';
}
}
// A daemon record's pid is also its process group, and the group outlives the
// leader that recorded it: after the leader exits a bare pid probe answers
// "gone" while the group's children keep writing under the workspace.
export function isTeardownTargetAlive(
pid,
{
platform = process.platform,
kill = process.kill,
readDir = (dir) => fs.readdirSync(dir),
readStat = (statPath) => fs.readFileSync(statPath, 'utf8'),
} = {},
) {
if (platform === 'win32') return isProcessAlive(pid, { platform, kill });
try {
kill(-pid, 0);
} catch (error) {
if (error?.code === 'ESRCH') return false;
if (error?.code === 'EPERM' || error?.code === 'EACCES') return true;
return isProcessAlive(pid, { platform, kill });
}
// The group probe also answers for members that can no longer write: an
// exited daemon the container's PID 1 has not reaped keeps its group
// "alive" as a zombie, which would fail teardown on a runtime that is gone.
if (platform !== 'linux') return true;
return groupHasLiveMember(pid, { readDir, readStat });
}
function groupHasLiveMember(pgid, { readDir, readStat }) {
let entries;
try {
entries = readDir('/proc');
} catch {
// Membership unknown: keep the group probe's answer rather than ending a
// drain on a failed scan.
return true;
}
let members = 0;
for (const entry of entries) {
if (!/^\d+$/.test(entry)) continue;
let stat;
try {
stat = readStat(`/proc/${entry}/stat`);
} catch {
// The member exited between readdir and read, which is the wanted state.
continue;
}
const closingParen = stat.lastIndexOf(')');
if (closingParen < 0) continue;
// `/proc/<pid>/stat` after `comm`: state, ppid, pgrp, ...
const fields = stat
.slice(closingParen + 2)
.trim()
.split(' ');
if (fields.length < 3 || Number(fields[2]) !== pgid) continue;
if (fields[0] !== 'Z') return true;
members += 1;
}
// Nothing observed is not the same as "only zombies": when /proc hides the
// group, the group probe that answered alive stays the better evidence.
return members === 0;
}