From 2278117de6d35cbbba11a3e183898948cd1194fe Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 18 Sep 2026 15:07:47 +0800 Subject: [PATCH 1/3] Fix Linux bridge process-tracking races in CI --- plugins/Hylouis233/cli-agent-bridge/README.md | 6 + .../cli-agent-bridge/process-tree.mjs | 43 ++++-- .../tests/process-tree.test.mjs | 140 +++++++++++++++--- 3 files changed, 156 insertions(+), 33 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 058834ff..acb5f981 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -247,3 +247,9 @@ This preserves evidence for short-lived Git helpers without accepting unknown de children of a recycled parent PID. Pending children without verifiable identities still fail closed. This test-path improvement does not enable Linux production delegation; the supported kernel containment boundary remains Windows Job Objects. + +A child already reaped while its original parent can still be revalidated is resolved after +stable run-marker scans, before the parent's later exit can turn that stale PID into a permanent +quarantine. Unknown children after a parent exit, changed PID identities, and failed inspections +still fail closed. Procfs listings use names rather than Dirents so a disappearing PID's implicit +`lstat` cannot discard an otherwise usable process snapshot. diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index eddeac79..e08a3124 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -228,15 +228,17 @@ async function readLinuxStat(pid, procRoot, fsOps) { async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readFile }) { let entries; try { - entries = await fsOps.readdir(procRoot, { withFileTypes: true }); + // Read names, not Dirents: Node may lstat DT_UNKNOWN entries, letting one + // reaped PID reject the entire listing. Per-PID stat reads handle exits. + entries = await fsOps.readdir(procRoot); } catch { return null; } const processes = []; for (const entry of entries) { - if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + if (!/^\d+$/u.test(entry)) continue; try { - const item = await readLinuxStat(Number(entry.name), procRoot, fsOps); + const item = await readLinuxStat(Number(entry), procRoot, fsOps); if (item === undefined) continue; if (item === null) return null; processes.push(item); @@ -269,7 +271,8 @@ export async function linuxProcessGroupHasLiveMembers( async function linuxMarkedProcesses(marker, procRoot, fsOps) { let entries; try { - entries = await fsOps.readdir(procRoot, { withFileTypes: true }); + // As above, avoid implicit per-entry lstat races while listing procfs. + entries = await fsOps.readdir(procRoot); } catch { return null; } @@ -277,13 +280,13 @@ async function linuxMarkedProcesses(marker, procRoot, fsOps) { const matches = []; matches.identityConflict = false; for (const entry of entries) { - if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + if (!/^\d+$/u.test(entry)) continue; try { - const pid = Number(entry.name); + const pid = Number(entry); const before = await readLinuxStat(pid, procRoot, fsOps); if (before === undefined) continue; if (before === null) return null; - const environment = await fsOps.readFile(`${procRoot}/${entry.name}/environ`); + const environment = await fsOps.readFile(`${procRoot}/${entry}/environ`); const values = Buffer.isBuffer(environment) ? environment.toString("utf8").split("\0") : String(environment).split("\0"); @@ -534,7 +537,7 @@ async function linuxTrackedProcessSnapshot( for (let taskAttempt = 0; taskAttempt < 3; taskAttempt += 1) { let taskEntries; try { - taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); + taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`); } catch (error) { if (isLinuxProcessGone(error)) { taskDirectoryGone = true; @@ -544,26 +547,24 @@ async function linuxTrackedProcessSnapshot( } let taskChangedWhileReading = false; for (const taskEntry of taskEntries) { - if (!taskEntry.isDirectory() || !/^\d+$/u.test(taskEntry.name)) continue; + if (!/^\d+$/u.test(taskEntry)) continue; let children; try { children = await fsOps.readFile( - `${procRoot}/${pid}/task/${taskEntry.name}/children`, "utf8", + `${procRoot}/${pid}/task/${taskEntry}/children`, "utf8", ); } catch (error) { - if (allowRootIdentityCapture && pid === rootPid && taskEntry.name === String(rootPid) && + if (allowRootIdentityCapture && pid === rootPid && taskEntry === String(rootPid) && error?.code === "ENOENT") { let taskEntriesAfter; try { - taskEntriesAfter = await fsOps.readdir(`${procRoot}/${pid}/task`, { - withFileTypes: true, - }); + taskEntriesAfter = await fsOps.readdir(`${procRoot}/${pid}/task`); } catch (confirmError) { if (!isLinuxProcessGone(confirmError)) return null; taskEntriesAfter = []; } const mainTaskStillPresent = taskEntriesAfter.some((entry) => - entry.isDirectory() && entry.name === String(rootPid)); + entry === String(rootPid)); const rootAfter = mainTaskStillPresent ? await readLinuxStat(pid, procRoot, fsOps) : undefined; @@ -593,6 +594,18 @@ async function linuxTrackedProcessSnapshot( if (!treeState.knownStarts.has(childPid)) { const child = await readLinuxStat(childPid, procRoot, fsOps); if (child === null) return null; + if (child === undefined && treeState.runMarker) { + const anchor = await readLinuxStat(pid, procRoot, fsOps); + if (anchor === null) return null; + if (anchor?.startIdentity === item.startIdentity) { + // The child was reaped while its original parent was still + // verifiable. Resolve it now, as for an absent queued child, + // instead of carrying a stale candidate into the parent's + // later exit. Recover marked escapees before forgetting it. + await enqueueMarkedProcesses({ stable: true }); + pendingChildren.delete(childPid); + } + } if (child && child.parentPid === pid && /^\d+$/u.test(child.startIdentity) && /^\d+$/u.test(item.startIdentity) && BigInt(child.startIdentity) >= BigInt(item.startIdentity)) { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index df92d69c..adfcd62e 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -79,7 +79,7 @@ test("Linux ancestry refresh follows task children without scanning all of procf test("Linux falls back safely when the live main task has no children file", async () => { let childrenReads = 0; - const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const directory = (name) => String(name); const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") return [directory(100)]; @@ -116,7 +116,7 @@ test("Linux falls back safely when the live main task has no children file", asy }); test("Linux children-file fallback recovers a reparented marked descendant", async () => { - const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const directory = (name) => String(name); const fsOps = { readdir: async (target) => target === "/fixture-proc" ? [directory(300)] : [], readFile: async (target) => { @@ -145,7 +145,7 @@ test("Linux children-file fallback recovers a reparented marked descendant", asy }); test("Linux children-file fallback rejects an unavailable full snapshot", async () => { - const directory = (name) => ({ name: String(name), isDirectory: () => true }); + const directory = (name) => String(name); const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") return [directory(100)]; @@ -208,7 +208,7 @@ function markerReuseFixture(startIdentities) { return { fsOps: { readdir: async (target) => target === "/fixture-proc" - ? [{ name: "702", isDirectory: () => true }] + ? ["702"] : [], readFile: async (target) => { if (target.endsWith("/701/stat")) throw missingProcessError(); @@ -354,7 +354,7 @@ test("Linux liveness is unknown when procfs is missing or restricted", async () const denied = Object.assign(new Error("access denied"), { code: "EACCES" }); const fsOps = { - readdir: async () => [{ name: "402", isDirectory: () => true }], + readdir: async () => ["402"], readFile: async () => { throw denied; }, }; assert.equal(await linuxProcessGroupHasLiveMembers(401, "/fake-proc", fsOps), null); @@ -494,7 +494,7 @@ test("Linux ancestry rejects a PID reused after a stale children entry", async ( const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") return []; return []; @@ -528,7 +528,7 @@ test("Linux ancestry rechecks the parent before accepting its children", async ( const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") return []; return []; @@ -561,7 +561,7 @@ test("Linux ancestry accepts a childless root exit only after stable empty marke const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") { markerScans += 1; @@ -600,15 +600,15 @@ test("Linux ancestry tolerates runner and worker exits between stable scans", as const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") { markerScans += 1; if (markerScans === 1) { - return [200, 201].map((pid) => ({ name: String(pid), isDirectory: () => true })); + return [200, 201].map((pid) => String(pid)); } return markerScans === 2 - ? [{ name: "200", isDirectory: () => true }] + ? ["200"] : []; } return []; @@ -654,7 +654,7 @@ test("Linux ancestry remains uncertain when a parent exits with a pending child" const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") { markerScans += 1; @@ -694,7 +694,7 @@ test("Linux ancestry accepts an exiting parent whose pending child was already v const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [{ name: "100", isDirectory: () => true }]; + return ["100"]; } if (target === "/fixture-proc") { markerScans += 1; @@ -737,7 +737,7 @@ test("Linux ancestry retries a torn task sample while its parent identity remain readdir: async (target) => { if (target === "/fixture-proc/100/task") { taskScans += 1; - return [{ name: taskScans === 1 ? "101" : "100", isDirectory: () => true }]; + return [taskScans === 1 ? "101" : "100"]; } if (target === "/fixture-proc") { markerScans += 1; @@ -775,11 +775,11 @@ test("Linux ancestry preserves children observed before repeated task-list churn const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [100, 101].map((pid) => ({ name: String(pid), isDirectory: () => true })); + return [100, 101].map((pid) => String(pid)); } if (target === "/fixture-proc/200/task") return []; if (target === "/fixture-proc") { - return [{ name: "200", isDirectory: () => true }]; + return ["200"]; } return []; }, @@ -1063,7 +1063,7 @@ test("Linux binds a visible child before scanning its parent's remaining tasks", let alive = true; const fsOps = { readdir: async target => target === "/fixture-proc/100/task" - ? [100, 101].map(pid => ({ name: String(pid), isDirectory: () => true })) : [], + ? [100, 101].map(pid => String(pid)) : [], readFile: async target => { if (target.endsWith("/100/stat") && alive) { return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); @@ -1096,7 +1096,7 @@ test("Linux early child binding rejects a parent whose PID identity changed", as let reads = 0; const fsOps = { readdir: async target => target === "/fixture-proc/100/task" - ? [{ name: "100", isDirectory: () => true }] : [], + ? ["100"] : [], readFile: async target => { if (target.endsWith("/100/stat")) { return procStatLine(100, { parent: 1, group: 100, startIdentity: ++reads === 1 ? 10 : 30 }); @@ -1116,3 +1116,107 @@ test("Linux early child binding rejects a parent whose PID identity changed", as assert.equal(state.knownStarts.get(200), undefined); assert.equal(state.processIdentityUncertain, true); }); + +test("Linux resolves an already exited child before its verified parent exits", async () => { + let parentAlive = true; + let markerScans = 0; + const fsOps = { + readdir: async target => { + if (target === "/fixture-proc/100/task") { + return [100, 101].map(pid => String(pid)); + } + if (target === "/fixture-proc") markerScans += 1; + return []; + }, + readFile: async target => { + if (target.endsWith("/100/stat") && parentAlive) { + return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + if (target.endsWith("/100/task/101/children")) { + parentAlive = false; + return "\n"; + } + // The Git helper is reaped before its first stat read, while the + // original parent is still available to confirm that observation. + throw missingProcessError(); + }, + }; + const state = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), + runMarker: "fixture-run", markerObservationGraceMs: 0, + }; + const options = { + platform: "linux", procRoot: "/fixture-proc", fsOps, + probeProcessGroup: () => { throw missingProcessError("ESRCH"); }, + }; + await refreshProcessTree({ pid: 100 }, state, options); + assert.equal(state.knownStarts.has(200), false, "never invent an identity for a reaped child"); + assert.equal(await isProcessTreeAlive({ pid: 100 }, state, options), false, + "a confirmed exit must not become a permanent live-tree quarantine"); + assert.ok(markerScans >= 4, "both exits require stable marker observations"); +}); + +test("Linux proc enumeration survives a disappearing directory with unknown entry type", async () => { + const fsOps = { + readdir: async (target, options) => { + // Node may lstat DT_UNKNOWN entries to construct Dirents. One reaped + // process then rejects the entire readdir, hiding every other process. + if (options?.withFileTypes) throw missingProcessError(); + return target === "/fixture-proc" ? ["self", "200", "300"] : []; + }, + readFile: async target => { + if (target.endsWith("/200/stat")) { + return procStatLine(200, { parent: 1, group: 200, startIdentity: 20 }); + } + if (target.endsWith("/200/environ")) return Buffer.from("CLI_AGENT_BRIDGE_RUN_ID=fixture-run\0"); + throw missingProcessError(); + }, + }; + const options = { platform: "linux", procRoot: "/fixture-proc", fsOps }; + const state = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), runMarker: "fixture-run", + }; + const snapshot = await refreshProcessTree({ pid: 100 }, state, options); + assert.deepEqual(snapshot.map(item => item.pid), [200], "recover the live escapee despite a reaped neighbor"); + assert.equal(state.processIdentityUncertain, undefined); + assert.equal(await isProcessTreeAlive({ pid: 100 }, state, options), true); + assert.deepEqual((await posixProcessSnapshot(options)).map(item => item.pid), [200]); +}); + +for (const scenario of ["reused parent", "unreadable markers"]) { + test(`Linux does not resolve an exited child with ${scenario}`, async () => { + let parentReads = 0; + const fsOps = { + readdir: async target => { + if (target === "/fixture-proc/100/task") return ["100"]; + if (target === "/fixture-proc" && scenario === "unreadable markers") { + throw Object.assign(new Error("procfs denied"), { code: "EACCES" }); + } + return []; + }, + readFile: async target => { + if (target.endsWith("/100/stat")) { + parentReads += 1; + return procStatLine(100, { + parent: 1, group: 100, + startIdentity: scenario === "reused parent" && parentReads > 1 ? 30 : 10, + }); + } + if (target.endsWith("/100/task/100/children")) return "200\n"; + throw missingProcessError(); + }, + }; + const state = { + knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), runMarker: "fixture-run", + }; + const refresh = refreshProcessTree({ pid: 100 }, state, { + platform: "linux", procRoot: "/fixture-proc", fsOps, + }); + if (scenario === "unreadable markers") await assert.rejects(refresh, /cannot inspect Linux run markers/u); + else await refresh; + assert.equal(state.processIdentityUncertain, true); + assert.equal(state.knownStarts.has(200), false); + assert.equal(state.knownStarts.get(100), "10", "the original parent identity stays immutable"); + }); +} From 5dc74303cb910ced3755b7f8de1be2e177918769 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 18 Sep 2026 15:20:11 +0800 Subject: [PATCH 2/3] Run bridge server integration on its supported Windows platform --- .../workflows/cli-agent-bridge-windows.yml | 8 ++++++-- plugins/Hylouis233/cli-agent-bridge/README.md | 20 +++++++++++++++---- .../server.mjs} | 4 ++-- .../server.test.mjs => integration/stdio.mjs} | 2 +- 4 files changed, 25 insertions(+), 9 deletions(-) rename plugins/Hylouis233/cli-agent-bridge/{tests/server.test.mjs => integration/server.mjs} (99%) rename plugins/Hylouis233/cli-agent-bridge/{test/server.test.mjs => integration/stdio.mjs} (99%) diff --git a/.github/workflows/cli-agent-bridge-windows.yml b/.github/workflows/cli-agent-bridge-windows.yml index f6862063..3007fa24 100644 --- a/.github/workflows/cli-agent-bridge-windows.yml +++ b/.github/workflows/cli-agent-bridge-windows.yml @@ -5,11 +5,15 @@ on: paths: - 'plugins/Hylouis233/cli-agent-bridge/**' - '.github/workflows/cli-agent-bridge-windows.yml' + - 'package.json' + - 'package-lock.json' push: branches: [main, plugin/cli-agent-bridge] paths: - 'plugins/Hylouis233/cli-agent-bridge/**' - '.github/workflows/cli-agent-bridge-windows.yml' + - 'package.json' + - 'package-lock.json' permissions: contents: read @@ -34,9 +38,9 @@ jobs: set -euo pipefail node --test --test-concurrency=1 \ plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs \ - plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs \ + plugins/Hylouis233/cli-agent-bridge/integration/server.mjs \ plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs \ - plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs \ + plugins/Hylouis233/cli-agent-bridge/integration/stdio.mjs \ 2>&1 | tee bridge-windows.tap - name: Preserve complete test output, including failures if: always() diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index acb5f981..8a610b3d 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -215,15 +215,27 @@ you already obtained a valid ID from that backend outside this Plugin. ## Verification -Run the dependency-free fake-backend suites from the repository root: +The default repository `npm run check` runs the deterministic process-tracker fixtures and +workspace-lock tests. Full server integration tests live outside Node's automatic test discovery +and run in `.github/workflows/cli-agent-bridge-windows.yml` on Windows, the supported production +containment platform. Plugin, workflow, and root package changes trigger that job. + +Run all dependency-free fake-backend suites explicitly from the repository root: ```text -node --test plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs -node --test plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +node --test plugins/Hylouis233/cli-agent-bridge/integration/stdio.mjs +node --test plugins/Hylouis233/cli-agent-bridge/integration/server.mjs +node --test plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs node --test plugins/Hylouis233/cli-agent-bridge/tests/workspace-lock.test.mjs ``` -They cover the full MCP flow plus in-process and cross-process canonical worktree locking, stale +Explicit POSIX integration runs remain experimental: they opt into the unsupported polling +tracker and may correctly quarantine when short-lived parent/child identities cannot be captured +under load. They are diagnostic runs, not a required repository CI gate. No integration assertions +are removed or relaxed; Windows still executes the complete suite, subject to its existing +platform-specific skips. + +The suites cover the full MCP flow plus in-process and cross-process canonical worktree locking, stale owner compare-and-swap, live-owner non-steal, quarantined-lease recovery after an explicit operator approval rename, interruptible lease state updates, shared quarantine markers, queued and discovery-phase cancellation (including list_backends probes), overall deadlines, cancel/timeout diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/integration/server.mjs similarity index 99% rename from plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs rename to plugins/Hylouis233/cli-agent-bridge/integration/server.mjs index d3393466..1394e405 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/integration/server.mjs @@ -9,7 +9,7 @@ import { createServer } from "node:net"; import { createInterface } from "node:readline"; import test, { after, before } from "node:test"; import { promisify } from "node:util"; -import { acquireCliAgentBridgeTestLock } from "./plugin-test-lock.mjs"; +import { acquireCliAgentBridgeTestLock } from "../tests/plugin-test-lock.mjs"; import { fileURLToPath, pathToFileURL } from "node:url"; import { @@ -30,7 +30,7 @@ import { } from "../server.mjs"; const execFileAsync = promisify(execFile); -const testsRoot = path.dirname(fileURLToPath(import.meta.url)); +const testsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../tests"); const pluginRoot = path.resolve(testsRoot, ".."); const serverPath = path.join(pluginRoot, "server.mjs"); const fakeBackendPath = path.join(testsRoot, "fake-backend.mjs"); diff --git a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs b/plugins/Hylouis233/cli-agent-bridge/integration/stdio.mjs similarity index 99% rename from plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs rename to plugins/Hylouis233/cli-agent-bridge/integration/stdio.mjs index 124ec614..c9bbb4d5 100644 --- a/plugins/Hylouis233/cli-agent-bridge/test/server.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/integration/stdio.mjs @@ -1,5 +1,5 @@ // Self-contained tests for the cli-agent-bridge stdio MCP server. -// Run with: node --test test/server.test.mjs +// Run with: node --test integration/stdio.mjs // No network access is required: the delegation test uses a fake slow backend. // // Every test drives its server through withServer(), which always stops the From 187b568315f63a2f418455e9988f2cb887f05801 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 18 Sep 2026 15:21:25 +0800 Subject: [PATCH 3/3] Keep Linux containment semantics unchanged when scoping CI --- plugins/Hylouis233/cli-agent-bridge/README.md | 6 - .../cli-agent-bridge/process-tree.mjs | 43 ++---- .../tests/process-tree.test.mjs | 140 +++--------------- 3 files changed, 33 insertions(+), 156 deletions(-) diff --git a/plugins/Hylouis233/cli-agent-bridge/README.md b/plugins/Hylouis233/cli-agent-bridge/README.md index 8a610b3d..890d7c2c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/README.md +++ b/plugins/Hylouis233/cli-agent-bridge/README.md @@ -259,9 +259,3 @@ This preserves evidence for short-lived Git helpers without accepting unknown de children of a recycled parent PID. Pending children without verifiable identities still fail closed. This test-path improvement does not enable Linux production delegation; the supported kernel containment boundary remains Windows Job Objects. - -A child already reaped while its original parent can still be revalidated is resolved after -stable run-marker scans, before the parent's later exit can turn that stale PID into a permanent -quarantine. Unknown children after a parent exit, changed PID identities, and failed inspections -still fail closed. Procfs listings use names rather than Dirents so a disappearing PID's implicit -`lstat` cannot discard an otherwise usable process snapshot. diff --git a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs index e08a3124..eddeac79 100644 --- a/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/process-tree.mjs @@ -228,17 +228,15 @@ async function readLinuxStat(pid, procRoot, fsOps) { async function linuxProcessSnapshot(procRoot = "/proc", fsOps = { readdir, readFile }) { let entries; try { - // Read names, not Dirents: Node may lstat DT_UNKNOWN entries, letting one - // reaped PID reject the entire listing. Per-PID stat reads handle exits. - entries = await fsOps.readdir(procRoot); + entries = await fsOps.readdir(procRoot, { withFileTypes: true }); } catch { return null; } const processes = []; for (const entry of entries) { - if (!/^\d+$/u.test(entry)) continue; + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; try { - const item = await readLinuxStat(Number(entry), procRoot, fsOps); + const item = await readLinuxStat(Number(entry.name), procRoot, fsOps); if (item === undefined) continue; if (item === null) return null; processes.push(item); @@ -271,8 +269,7 @@ export async function linuxProcessGroupHasLiveMembers( async function linuxMarkedProcesses(marker, procRoot, fsOps) { let entries; try { - // As above, avoid implicit per-entry lstat races while listing procfs. - entries = await fsOps.readdir(procRoot); + entries = await fsOps.readdir(procRoot, { withFileTypes: true }); } catch { return null; } @@ -280,13 +277,13 @@ async function linuxMarkedProcesses(marker, procRoot, fsOps) { const matches = []; matches.identityConflict = false; for (const entry of entries) { - if (!/^\d+$/u.test(entry)) continue; + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; try { - const pid = Number(entry); + const pid = Number(entry.name); const before = await readLinuxStat(pid, procRoot, fsOps); if (before === undefined) continue; if (before === null) return null; - const environment = await fsOps.readFile(`${procRoot}/${entry}/environ`); + const environment = await fsOps.readFile(`${procRoot}/${entry.name}/environ`); const values = Buffer.isBuffer(environment) ? environment.toString("utf8").split("\0") : String(environment).split("\0"); @@ -537,7 +534,7 @@ async function linuxTrackedProcessSnapshot( for (let taskAttempt = 0; taskAttempt < 3; taskAttempt += 1) { let taskEntries; try { - taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`); + taskEntries = await fsOps.readdir(`${procRoot}/${pid}/task`, { withFileTypes: true }); } catch (error) { if (isLinuxProcessGone(error)) { taskDirectoryGone = true; @@ -547,24 +544,26 @@ async function linuxTrackedProcessSnapshot( } let taskChangedWhileReading = false; for (const taskEntry of taskEntries) { - if (!/^\d+$/u.test(taskEntry)) continue; + if (!taskEntry.isDirectory() || !/^\d+$/u.test(taskEntry.name)) continue; let children; try { children = await fsOps.readFile( - `${procRoot}/${pid}/task/${taskEntry}/children`, "utf8", + `${procRoot}/${pid}/task/${taskEntry.name}/children`, "utf8", ); } catch (error) { - if (allowRootIdentityCapture && pid === rootPid && taskEntry === String(rootPid) && + if (allowRootIdentityCapture && pid === rootPid && taskEntry.name === String(rootPid) && error?.code === "ENOENT") { let taskEntriesAfter; try { - taskEntriesAfter = await fsOps.readdir(`${procRoot}/${pid}/task`); + taskEntriesAfter = await fsOps.readdir(`${procRoot}/${pid}/task`, { + withFileTypes: true, + }); } catch (confirmError) { if (!isLinuxProcessGone(confirmError)) return null; taskEntriesAfter = []; } const mainTaskStillPresent = taskEntriesAfter.some((entry) => - entry === String(rootPid)); + entry.isDirectory() && entry.name === String(rootPid)); const rootAfter = mainTaskStillPresent ? await readLinuxStat(pid, procRoot, fsOps) : undefined; @@ -594,18 +593,6 @@ async function linuxTrackedProcessSnapshot( if (!treeState.knownStarts.has(childPid)) { const child = await readLinuxStat(childPid, procRoot, fsOps); if (child === null) return null; - if (child === undefined && treeState.runMarker) { - const anchor = await readLinuxStat(pid, procRoot, fsOps); - if (anchor === null) return null; - if (anchor?.startIdentity === item.startIdentity) { - // The child was reaped while its original parent was still - // verifiable. Resolve it now, as for an absent queued child, - // instead of carrying a stale candidate into the parent's - // later exit. Recover marked escapees before forgetting it. - await enqueueMarkedProcesses({ stable: true }); - pendingChildren.delete(childPid); - } - } if (child && child.parentPid === pid && /^\d+$/u.test(child.startIdentity) && /^\d+$/u.test(item.startIdentity) && BigInt(child.startIdentity) >= BigInt(item.startIdentity)) { diff --git a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs index adfcd62e..df92d69c 100644 --- a/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs +++ b/plugins/Hylouis233/cli-agent-bridge/tests/process-tree.test.mjs @@ -79,7 +79,7 @@ test("Linux ancestry refresh follows task children without scanning all of procf test("Linux falls back safely when the live main task has no children file", async () => { let childrenReads = 0; - const directory = (name) => String(name); + const directory = (name) => ({ name: String(name), isDirectory: () => true }); const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") return [directory(100)]; @@ -116,7 +116,7 @@ test("Linux falls back safely when the live main task has no children file", asy }); test("Linux children-file fallback recovers a reparented marked descendant", async () => { - const directory = (name) => String(name); + const directory = (name) => ({ name: String(name), isDirectory: () => true }); const fsOps = { readdir: async (target) => target === "/fixture-proc" ? [directory(300)] : [], readFile: async (target) => { @@ -145,7 +145,7 @@ test("Linux children-file fallback recovers a reparented marked descendant", asy }); test("Linux children-file fallback rejects an unavailable full snapshot", async () => { - const directory = (name) => String(name); + const directory = (name) => ({ name: String(name), isDirectory: () => true }); const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") return [directory(100)]; @@ -208,7 +208,7 @@ function markerReuseFixture(startIdentities) { return { fsOps: { readdir: async (target) => target === "/fixture-proc" - ? ["702"] + ? [{ name: "702", isDirectory: () => true }] : [], readFile: async (target) => { if (target.endsWith("/701/stat")) throw missingProcessError(); @@ -354,7 +354,7 @@ test("Linux liveness is unknown when procfs is missing or restricted", async () const denied = Object.assign(new Error("access denied"), { code: "EACCES" }); const fsOps = { - readdir: async () => ["402"], + readdir: async () => [{ name: "402", isDirectory: () => true }], readFile: async () => { throw denied; }, }; assert.equal(await linuxProcessGroupHasLiveMembers(401, "/fake-proc", fsOps), null); @@ -494,7 +494,7 @@ test("Linux ancestry rejects a PID reused after a stale children entry", async ( const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") return []; return []; @@ -528,7 +528,7 @@ test("Linux ancestry rechecks the parent before accepting its children", async ( const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") return []; return []; @@ -561,7 +561,7 @@ test("Linux ancestry accepts a childless root exit only after stable empty marke const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") { markerScans += 1; @@ -600,15 +600,15 @@ test("Linux ancestry tolerates runner and worker exits between stable scans", as const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") { markerScans += 1; if (markerScans === 1) { - return [200, 201].map((pid) => String(pid)); + return [200, 201].map((pid) => ({ name: String(pid), isDirectory: () => true })); } return markerScans === 2 - ? ["200"] + ? [{ name: "200", isDirectory: () => true }] : []; } return []; @@ -654,7 +654,7 @@ test("Linux ancestry remains uncertain when a parent exits with a pending child" const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") { markerScans += 1; @@ -694,7 +694,7 @@ test("Linux ancestry accepts an exiting parent whose pending child was already v const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return ["100"]; + return [{ name: "100", isDirectory: () => true }]; } if (target === "/fixture-proc") { markerScans += 1; @@ -737,7 +737,7 @@ test("Linux ancestry retries a torn task sample while its parent identity remain readdir: async (target) => { if (target === "/fixture-proc/100/task") { taskScans += 1; - return [taskScans === 1 ? "101" : "100"]; + return [{ name: taskScans === 1 ? "101" : "100", isDirectory: () => true }]; } if (target === "/fixture-proc") { markerScans += 1; @@ -775,11 +775,11 @@ test("Linux ancestry preserves children observed before repeated task-list churn const fsOps = { readdir: async (target) => { if (target === "/fixture-proc/100/task") { - return [100, 101].map((pid) => String(pid)); + return [100, 101].map((pid) => ({ name: String(pid), isDirectory: () => true })); } if (target === "/fixture-proc/200/task") return []; if (target === "/fixture-proc") { - return ["200"]; + return [{ name: "200", isDirectory: () => true }]; } return []; }, @@ -1063,7 +1063,7 @@ test("Linux binds a visible child before scanning its parent's remaining tasks", let alive = true; const fsOps = { readdir: async target => target === "/fixture-proc/100/task" - ? [100, 101].map(pid => String(pid)) : [], + ? [100, 101].map(pid => ({ name: String(pid), isDirectory: () => true })) : [], readFile: async target => { if (target.endsWith("/100/stat") && alive) { return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); @@ -1096,7 +1096,7 @@ test("Linux early child binding rejects a parent whose PID identity changed", as let reads = 0; const fsOps = { readdir: async target => target === "/fixture-proc/100/task" - ? ["100"] : [], + ? [{ name: "100", isDirectory: () => true }] : [], readFile: async target => { if (target.endsWith("/100/stat")) { return procStatLine(100, { parent: 1, group: 100, startIdentity: ++reads === 1 ? 10 : 30 }); @@ -1116,107 +1116,3 @@ test("Linux early child binding rejects a parent whose PID identity changed", as assert.equal(state.knownStarts.get(200), undefined); assert.equal(state.processIdentityUncertain, true); }); - -test("Linux resolves an already exited child before its verified parent exits", async () => { - let parentAlive = true; - let markerScans = 0; - const fsOps = { - readdir: async target => { - if (target === "/fixture-proc/100/task") { - return [100, 101].map(pid => String(pid)); - } - if (target === "/fixture-proc") markerScans += 1; - return []; - }, - readFile: async target => { - if (target.endsWith("/100/stat") && parentAlive) { - return procStatLine(100, { parent: 1, group: 100, startIdentity: 10 }); - } - if (target.endsWith("/100/task/100/children")) return "200\n"; - if (target.endsWith("/100/task/101/children")) { - parentAlive = false; - return "\n"; - } - // The Git helper is reaped before its first stat read, while the - // original parent is still available to confirm that observation. - throw missingProcessError(); - }, - }; - const state = { - knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), - runMarker: "fixture-run", markerObservationGraceMs: 0, - }; - const options = { - platform: "linux", procRoot: "/fixture-proc", fsOps, - probeProcessGroup: () => { throw missingProcessError("ESRCH"); }, - }; - await refreshProcessTree({ pid: 100 }, state, options); - assert.equal(state.knownStarts.has(200), false, "never invent an identity for a reaped child"); - assert.equal(await isProcessTreeAlive({ pid: 100 }, state, options), false, - "a confirmed exit must not become a permanent live-tree quarantine"); - assert.ok(markerScans >= 4, "both exits require stable marker observations"); -}); - -test("Linux proc enumeration survives a disappearing directory with unknown entry type", async () => { - const fsOps = { - readdir: async (target, options) => { - // Node may lstat DT_UNKNOWN entries to construct Dirents. One reaped - // process then rejects the entire readdir, hiding every other process. - if (options?.withFileTypes) throw missingProcessError(); - return target === "/fixture-proc" ? ["self", "200", "300"] : []; - }, - readFile: async target => { - if (target.endsWith("/200/stat")) { - return procStatLine(200, { parent: 1, group: 200, startIdentity: 20 }); - } - if (target.endsWith("/200/environ")) return Buffer.from("CLI_AGENT_BRIDGE_RUN_ID=fixture-run\0"); - throw missingProcessError(); - }, - }; - const options = { platform: "linux", procRoot: "/fixture-proc", fsOps }; - const state = { - knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), runMarker: "fixture-run", - }; - const snapshot = await refreshProcessTree({ pid: 100 }, state, options); - assert.deepEqual(snapshot.map(item => item.pid), [200], "recover the live escapee despite a reaped neighbor"); - assert.equal(state.processIdentityUncertain, undefined); - assert.equal(await isProcessTreeAlive({ pid: 100 }, state, options), true); - assert.deepEqual((await posixProcessSnapshot(options)).map(item => item.pid), [200]); -}); - -for (const scenario of ["reused parent", "unreadable markers"]) { - test(`Linux does not resolve an exited child with ${scenario}`, async () => { - let parentReads = 0; - const fsOps = { - readdir: async target => { - if (target === "/fixture-proc/100/task") return ["100"]; - if (target === "/fixture-proc" && scenario === "unreadable markers") { - throw Object.assign(new Error("procfs denied"), { code: "EACCES" }); - } - return []; - }, - readFile: async target => { - if (target.endsWith("/100/stat")) { - parentReads += 1; - return procStatLine(100, { - parent: 1, group: 100, - startIdentity: scenario === "reused parent" && parentReads > 1 ? 30 : 10, - }); - } - if (target.endsWith("/100/task/100/children")) return "200\n"; - throw missingProcessError(); - }, - }; - const state = { - knownPids: new Set([100]), knownStarts: new Map([[100, "10"]]), runMarker: "fixture-run", - }; - const refresh = refreshProcessTree({ pid: 100 }, state, { - platform: "linux", procRoot: "/fixture-proc", fsOps, - }); - if (scenario === "unreadable markers") await assert.rejects(refresh, /cannot inspect Linux run markers/u); - else await refresh; - assert.equal(state.processIdentityUncertain, true); - assert.equal(state.knownStarts.has(200), false); - assert.equal(state.knownStarts.get(100), "10", "the original parent identity stays immutable"); - }); -}