Skip to content

Commit b55ff0f

Browse files
George-iamclaude
andcommitted
fix(sessions): match ownership against ancestor pid chain — Cursor session close was dead
QA finding (extension 0.1.6 full functional pass, 2026-06-11): axme_begin_close returned "No active AXME session found" on every Cursor extension install, which silently killed session close, the audit pipeline and worklog updates for the whole channel. Root cause: hooks record ownerPpid = getClaudeCodePid() (their grandparent above the sh wrapper). Under Claude Code that PID equals the MCP server's PARENT — one claude process spawns both, so the strict `ownerPpid === process.ppid` equality worked. Cursor adds a layer: hooks hang off the cursor-server main process while the MCP server is a child of the EXTENSION HOST: cursor-server(A) ─┬─ sh → hook ownerPpid = A └─ exthost(B) → server process.ppid = B ≠ A The stale-adoption fallback never fired either — A is alive. Fix: getOwnAncestorPids(maxDepth=4) walks the server's ancestor chain (Linux: /proc, microseconds; macOS: ps per level; Windows: whole chain in ONE powershell CIM call) and ownership checks now test membership in that set. chain[0] is process.ppid, so Claude Code behavior is bit-for- bit unchanged; Cursor matches at chain[1]. Applied to all three sites: getOwnedSessionIdForLogging, cleanupAndExit, auditOrphansInBackground. Stale-adoption fallback untouched. Verification: - 613/613 tests (5 new in test/session-ownership.test.ts), tsc, build. - E2E against the built dist/server.js with a real bash interposer reproducing the Cursor topology: mapping owned by the server's grandparent -> begin_close returns the checklist; control with an ALIVE unrelated owner pid -> still "No active AXME session found" (matching is selective); dead owner pid -> stale-adoption still fires (VS Code reload recovery preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fbe102c commit b55ff0f

3 files changed

Lines changed: 161 additions & 13 deletions

File tree

src/server.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
isPidAlive,
3838
loadSession,
3939
closeSession,
40+
getOwnAncestorPids,
4041
} from "./storage/sessions.js";
4142
import { logEvent } from "./storage/worklog.js";
4243
import { spawnDetachedAuditWorker } from "./audit-spawner.js";
@@ -78,10 +79,21 @@ const defaultWorkspacePath = isWorkspace ? serverCwd : null;
7879
// the Claude session_id, which is the key for multi-window isolation.
7980
//
8081
// Instead of a single "currentSession", the server owns all AXME sessions
81-
// created by hooks whose parent process id equals our own parent process
82-
// id (i.e., the same Claude Code instance that spawned us). At disconnect,
83-
// we close all of them.
82+
// created by hooks of the same IDE instance that spawned us. Hooks record
83+
// ownerPpid via getClaudeCodePid() (their grandparent above the sh wrapper).
84+
// Under Claude Code that equals our PARENT — claude spawns hooks and the
85+
// MCP server alike. Cursor adds a layer: hooks hang off the cursor-server
86+
// main process while we are a child of the extension host, so the recorded
87+
// owner is our GRANDparent and strict ppid equality never matched — every
88+
// Cursor extension install had session close / audit / worklog silently
89+
// dead ("No active AXME session found", QA 2026-06-11). Match against our
90+
// ancestor chain instead; chain[0] is process.ppid, so the Claude Code
91+
// behavior is unchanged.
8492
const OWN_PPID = process.ppid;
93+
const OWN_ANCESTOR_PIDS = new Set<number>(getOwnAncestorPids(4));
94+
function isOwnedMapping(ownerPpid: number | undefined): boolean {
95+
return ownerPpid != null && OWN_ANCESTOR_PIDS.has(ownerPpid);
96+
}
8597

8698
// Track which context paths have been delivered in this MCP session.
8799
// Used by axme_oracle/decisions/memories to avoid duplicating workspace
@@ -121,7 +133,7 @@ void sendStartupEvents();
121133
*/
122134
function getOwnedSessionIdForLogging(): string | undefined {
123135
const all = listClaudeSessionMappings(defaultProjectPath);
124-
const owned = all.filter(m => m.ownerPpid === OWN_PPID);
136+
const owned = all.filter(m => isOwnedMapping(m.ownerPpid));
125137
if (owned.length > 0) return owned[0].axmeSessionId;
126138

127139
// No mapping for our PID — check for stale mappings from dead Claude Code
@@ -173,7 +185,7 @@ async function cleanupAndExit(reason: string): Promise<void> {
173185
// detached audit worker for each, clear the mapping, and exit. No awaiting.
174186
try {
175187
const mappings = listClaudeSessionMappings(defaultProjectPath);
176-
const owned = mappings.filter(m => m.ownerPpid === OWN_PPID);
188+
const owned = mappings.filter(m => isOwnedMapping(m.ownerPpid));
177189

178190
// Deduplicate: group AXME sessions by Claude session ID.
179191
// Multiple AXME sessions can share the same Claude session (race condition
@@ -1197,7 +1209,7 @@ async function auditOrphansInBackground(): Promise<void> {
11971209
// currently owned by this MCP server via an active mapping file.
11981210
const ownedAxmeIds = new Set(
11991211
listClaudeSessionMappings(defaultProjectPath)
1200-
.filter(m => m.ownerPpid === OWN_PPID)
1212+
.filter(m => isOwnedMapping(m.ownerPpid))
12011213
.map(m => m.axmeSessionId),
12021214
);
12031215

src/storage/sessions.ts

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,20 +104,98 @@ export function isRetryableError(errMsg: string): boolean {
104104
* via ensureAxmeSessionForClaude).
105105
*/
106106
export function getClaudeCodePid(): number {
107+
const parent = readParentPidLinux(process.ppid);
108+
if (parent != null) return parent;
109+
// /proc missing (macOS, Windows), or parent died before we could read
110+
// its stat file.
111+
return process.ppid;
112+
}
113+
114+
/** Parse the parent PID of `pid` from /proc (Linux only). Null elsewhere/on failure. */
115+
function readParentPidLinux(pid: number): number | null {
107116
try {
108-
const stat = readFileSync(`/proc/${process.ppid}/stat`, "utf-8");
117+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
109118
const closeParen = stat.lastIndexOf(")");
110119
if (closeParen > 0) {
111120
// Fields after "(comm) " are space-separated: state ppid pgrp ...
112121
const parts = stat.slice(closeParen + 2).split(" ");
113-
const grandparent = parseInt(parts[1], 10);
114-
if (Number.isFinite(grandparent) && grandparent > 1) return grandparent;
122+
const parent = parseInt(parts[1], 10);
123+
if (Number.isFinite(parent) && parent > 1) return parent;
115124
}
116-
} catch {
117-
// /proc missing (macOS, Windows), or parent died before we could read
118-
// its stat file. Fall through to fallback.
125+
} catch { /* /proc missing or pid gone */ }
126+
return null;
127+
}
128+
129+
/** Parent PID of `pid` via `ps` (macOS and other POSIX without /proc). */
130+
function readParentPidPosix(pid: number): number | null {
131+
try {
132+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
133+
const out = execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
134+
const parent = parseInt(out, 10);
135+
if (Number.isFinite(parent) && parent > 1) return parent;
136+
} catch { /* ps unavailable or pid gone */ }
137+
return null;
138+
}
139+
140+
/**
141+
* Walk this process's ancestor chain — parent, grandparent, … — up to
142+
* `maxDepth` levels. The first element is always `process.ppid`.
143+
*
144+
* Why this exists: hooks record `ownerPpid` via getClaudeCodePid() (their
145+
* grandparent — one step above the sh wrapper). Under Claude Code that PID
146+
* equals the MCP server's PARENT, because the same claude process spawns
147+
* both — so a strict `ownerPpid === process.ppid` equality worked. Cursor
148+
* adds a layer: hooks are spawned by the cursor-server main process while
149+
* the MCP server is a child of the EXTENSION HOST, so the hook-recorded
150+
* owner is the server's GRANDparent and strict equality never matches.
151+
* (QA 2026-06-11: `axme_begin_close` returned "No active AXME session
152+
* found" on every Cursor extension install — session close, audit spawn
153+
* and worklog were all dead.) Ownership checks must match against the
154+
* ancestor chain, not a single ppid.
155+
*
156+
* Platform strategy: Linux walks /proc (microseconds); macOS walks `ps`
157+
* (one small exec per level); Windows resolves the whole chain in a single
158+
* PowerShell invocation (spawning powershell per level would cost seconds).
159+
* Any failure stops the walk — the chain always contains at least
160+
* `process.ppid`, so behavior degrades to the old strict equality.
161+
*/
162+
export function getOwnAncestorPids(maxDepth = 4): number[] {
163+
const chain: number[] = [];
164+
const seen = new Set<number>();
165+
let current = process.ppid;
166+
if (process.platform === "win32") {
167+
return getOwnAncestorPidsWindows(maxDepth);
119168
}
120-
return process.ppid;
169+
for (let depth = 0; depth < maxDepth; depth++) {
170+
if (!Number.isFinite(current) || current <= 1 || seen.has(current)) break;
171+
chain.push(current);
172+
seen.add(current);
173+
const parent = process.platform === "linux"
174+
? readParentPidLinux(current)
175+
: readParentPidPosix(current);
176+
if (parent == null) break;
177+
current = parent;
178+
}
179+
return chain.length > 0 ? chain : [process.ppid];
180+
}
181+
182+
/** Windows ancestor chain in ONE PowerShell call (CIM walk). */
183+
function getOwnAncestorPidsWindows(maxDepth: number): number[] {
184+
try {
185+
const { execSync } = require("node:child_process") as typeof import("node:child_process");
186+
const script =
187+
`$p=${process.ppid};$out=@();for($i=0;$i -lt ${maxDepth} -and $p -gt 1;$i++){` +
188+
`$out+=$p;$p=(Get-CimInstance Win32_Process -Filter \\"ProcessId=$p\\" -ErrorAction SilentlyContinue).ParentProcessId};` +
189+
`$out -join ','`;
190+
const out = execSync(`powershell -NoProfile -NonInteractive -Command "${script}"`, {
191+
encoding: "utf-8",
192+
timeout: 10_000,
193+
stdio: ["ignore", "pipe", "ignore"],
194+
}).trim();
195+
const chain = out.split(",").map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 1);
196+
if (chain.length > 0) return chain;
197+
} catch { /* powershell unavailable — fall back to parent only */ }
198+
return [process.ppid];
121199
}
122200

123201
function sessionsRoot(projectPath: string): string {

test/session-ownership.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { getOwnAncestorPids, getClaudeCodePid } from "../src/storage/sessions.js";
4+
5+
/**
6+
* Ancestor-chain ownership matching (Cursor extension fix, 2026-06-11).
7+
*
8+
* Hooks record ownerPpid = their grandparent (getClaudeCodePid). Under
9+
* Claude Code that equals the MCP server's parent; under Cursor it is the
10+
* server's GRANDparent (cursor-server vs extension host). Ownership checks
11+
* therefore match against getOwnAncestorPids(), whose first element must be
12+
* process.ppid so the historical strict-equality behavior is a subset.
13+
*/
14+
describe("getOwnAncestorPids", () => {
15+
it("starts with process.ppid", () => {
16+
const chain = getOwnAncestorPids();
17+
assert.ok(chain.length >= 1, "chain must never be empty");
18+
assert.equal(chain[0], process.ppid, "chain[0] must be the direct parent");
19+
});
20+
21+
it("walks beyond the direct parent where the platform allows", () => {
22+
const chain = getOwnAncestorPids(4);
23+
// Test runners are always at least two levels deep (init → shell/runner
24+
// → node). /proc (Linux) and ps (macOS) both support the walk; a
25+
// single-element chain there would mean the walk silently broke.
26+
if (process.platform === "linux" || process.platform === "darwin") {
27+
assert.ok(chain.length >= 2, `expected >=2 ancestors, got: ${chain.join(",")}`);
28+
}
29+
});
30+
31+
it("returns finite pids > 1 with no duplicates", () => {
32+
const chain = getOwnAncestorPids(4);
33+
for (const pid of chain) {
34+
assert.ok(Number.isFinite(pid) && pid > 1, `bad pid in chain: ${pid}`);
35+
}
36+
assert.equal(new Set(chain).size, chain.length, "chain must not contain duplicates");
37+
});
38+
39+
it("respects maxDepth", () => {
40+
assert.ok(getOwnAncestorPids(1).length <= 1);
41+
assert.ok(getOwnAncestorPids(2).length <= 2);
42+
});
43+
44+
it("contains the hook-side owner pid for same-parent layouts (Claude Code invariant)", () => {
45+
// In a Claude Code layout hooks and the server share one parent, and
46+
// getClaudeCodePid() from THIS process resolves our grandparent — which
47+
// must be inside our own ancestor chain. This is exactly the membership
48+
// check isOwnedMapping() performs in server.ts.
49+
const chain = new Set(getOwnAncestorPids(4));
50+
const hookStyleOwner = getClaudeCodePid();
51+
if (process.platform === "linux") {
52+
assert.ok(
53+
chain.has(hookStyleOwner),
54+
`grandparent ${hookStyleOwner} not in ancestor chain ${[...chain].join(",")}`,
55+
);
56+
}
57+
});
58+
});

0 commit comments

Comments
 (0)