From 4fd7a023be77222ee8ca55d838ad97511e6d3fe6 Mon Sep 17 00:00:00 2001 From: Minh Date: Sat, 5 Sep 2026 04:25:35 -0300 Subject: [PATCH 1/3] fix(limits): resolve Antigravity process and ports on Linux without /bin/ps or lsof - Fall back to 'ps' from PATH if '/bin/ps' fails or does not exist (e.g. on NixOS) - Add native procfs (/proc//fd + /proc/net/tcp) and 'ss' port detection on Linux when 'lsof' is not installed - Add unit tests covering ps fallback, procfs port parsing, and ss output parsing --- src/lib/usage-limits.js | 121 ++++++++++++++++++++++++++++++++++---- test/usage-limits.test.js | 61 +++++++++++++++++++ 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 00d22b181..9ac6f26b6 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -2167,10 +2167,16 @@ async function detectAntigravityProcess({ ); processes = parseWindowsProcesses(result?.stdout); } else { - const result = await runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], { + let result = await runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], { timeout: timeoutMs, signal, }); + if (result?.error && !result?.stdout) { + result = await runCommand(commandRunner, "ps", ["-ax", "-o", "pid=,command="], { + timeout: timeoutMs, + signal, + }); + } processes = String(result?.stdout || "") .split("\n") .map(parseProcessLine) @@ -2633,10 +2639,70 @@ function parseWindowsListeningPorts(output, pid) { return Array.from(ports).sort((a, b) => a - b); } +function parseLinuxProcListeningPorts(pid, { procRoot = "/proc" } = {}) { + const numPid = Number(pid); + if (!Number.isFinite(numPid) || numPid <= 0) return []; + const inodes = new Set(); + try { + const fds = fs.readdirSync(path.join(procRoot, String(numPid), "fd")); + for (const fd of fds) { + try { + const link = fs.readlinkSync(path.join(procRoot, String(numPid), "fd", fd)); + const m = link.match(/^socket:\[(\d+)\]$/); + if (m) inodes.add(m[1]); + } catch {} + } + } catch { + return []; + } + if (inodes.size === 0) return []; + + const ports = new Set(); + for (const table of [path.join(procRoot, "net", "tcp"), path.join(procRoot, "net", "tcp6")]) { + try { + const content = fs.readFileSync(table, "utf8"); + for (const line of content.split("\n")) { + const parts = line.trim().split(/\s+/); + if (parts.length > 9 && parts[3] === "0A") { + const inode = parts[9]; + if (inodes.has(inode)) { + const portHex = parts[1]?.split(":")[1]; + if (portHex) { + const port = parseInt(portHex, 16); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + ports.add(port); + } + } + } + } + } + } catch {} + } + return Array.from(ports).sort((a, b) => a - b); +} + +function parseSsListeningPorts(output, pid) { + const ports = new Set(); + const pidPattern = pid ? new RegExp(`\\bpid=${pid}\\b`) : null; + for (const line of String(output || "").split("\n")) { + if (!line.includes("LISTEN")) continue; + if (pidPattern && !pidPattern.test(line)) continue; + const match = line.match(/:(\d+)\s+/); + if (match) { + const port = Number(match[1]); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + ports.add(port); + } + } + } + return Array.from(ports).sort((a, b) => a - b); +} + async function listAntigravityPorts(pid, { commandRunner, platform = process.platform, timeoutMs = ANTIGRAVITY_PROCESS_SCAN_TIMEOUT_MS, + procRoot = "/proc", signal, } = {}) { if (platform === "win32") { @@ -2652,21 +2718,48 @@ async function listAntigravityPorts(pid, { } return ports; } + + // On Linux, try procfs first when no mock command runner is provided (zero-spawn, no dependencies). + if (platform === "linux" && !commandRunner) { + const procPorts = parseLinuxProcListeningPorts(pid, { procRoot }); + if (procPorts.length > 0) return procPorts; + } + const lsof = await resolveLsofBinary({ commandRunner }); - if (!lsof) { - throw new Error("Antigravity port detection needs lsof. Install it, then retry."); + if (lsof) { + const result = await runCommand( + commandRunner, + lsof, + ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)], + { timeout: timeoutMs, signal }, + ); + const ports = parseListeningPorts(result?.stdout); + if (ports.length > 0) return ports; } - const result = await runCommand( - commandRunner, - lsof, - ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)], - { timeout: timeoutMs, signal }, - ); - const ports = parseListeningPorts(result?.stdout); - if (!ports.length) { - throw new Error("Antigravity is running but not exposing ports yet. Try again in a few seconds."); + + // On Linux, fall back to procfs (honoring procRoot) or ss if lsof is absent or yielded no ports. + if (platform === "linux") { + const procPorts = parseLinuxProcListeningPorts(pid, { procRoot }); + if (procPorts.length > 0) return procPorts; + + const ss = await whichBinary("ss", { commandRunner }); + if (ss) { + const result = await runCommand( + commandRunner, + ss, + ["-H", "-tlpn"], + { timeout: timeoutMs, signal }, + ); + const ssPorts = parseSsListeningPorts(result?.stdout, pid); + if (ssPorts.length > 0) return ssPorts; + } + } + + if (!lsof && platform !== "linux") { + throw new Error("Antigravity port detection needs lsof. Install it, then retry."); } - return ports; + + throw new Error("Antigravity is running but not exposing ports yet. Try again in a few seconds."); } function antigravityDefaultBody() { @@ -3969,6 +4062,8 @@ module.exports = { loadAntigravityCredentials, parseListeningPorts, parseWindowsListeningPorts, + parseLinuxProcListeningPorts, + parseSsListeningPorts, listAntigravityPorts, detectAntigravityProcess, fetchAntigravityLimits, diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index 54e42de7b..5cbb01d34 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -24,6 +24,8 @@ const { loadAntigravityCredentials, parseListeningPorts, parseWindowsListeningPorts, + parseLinuxProcListeningPorts, + parseSsListeningPorts, listAntigravityPorts, detectAntigravityProcess, fetchAntigravityLimits, @@ -3455,6 +3457,65 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS assert.equal(result.configured, false); }); + it("falls back to ps when /bin/ps is missing or returns error", async () => { + const calls = []; + const commandRunner = (command, args) => { + calls.push({ command, args }); + if (command === "/bin/ps") { + return { status: null, stdout: "", stderr: "", error: new Error("spawn /bin/ps ENOENT") }; + } + if (command === "ps") { + return { + status: 0, + stdout: "\n 456 agy\n", + }; + } + return { status: 1, stdout: "", stderr: "" }; + }; + + const result = await detectAntigravityProcess({ commandRunner }); + assert.equal(calls[0].command, "/bin/ps"); + assert.equal(calls[1].command, "ps"); + assert.equal(result.configured, true); + assert.equal(result.pid, 456); + }); + + it("discovers listening ports via Linux procfs", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-procfs-test-")); + try { + const pidDir = path.join(tmp, "456", "fd"); + const netDir = path.join(tmp, "net"); + fs.mkdirSync(pidDir, { recursive: true }); + fs.mkdirSync(netDir, { recursive: true }); + fs.symlinkSync("socket:[123456]", path.join(pidDir, "12")); + fs.writeFileSync( + path.join(netDir, "tcp"), + [ + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode", + " 0: 0100007F:8A11 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 123456 1 00000000 100 0 0 10 0", + ].join("\n"), + "utf8", + ); + + const ports = parseLinuxProcListeningPorts(456, { procRoot: tmp }); + assert.deepEqual(ports, [35345]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("parses listening ports from ss command output", () => { + const output = [ + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process", + 'LISTEN 0 4096 127.0.0.1:35345 0.0.0.0:* users:(("agy",pid=456,fd=14))', + 'LISTEN 0 4096 127.0.0.1:32919 0.0.0.0:* users:(("agy",pid=456,fd=12))', + 'LISTEN 0 4096 127.0.0.1:9999 0.0.0.0:* users:(("other",pid=789,fd=3))', + ].join("\n"); + + const ports = parseSsListeningPorts(output, 456); + assert.deepEqual(ports, [32919, 35345]); + }); + it("persists live Antigravity quota for use after the process exits", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-antigravity-cache-write-")); try { From 52bf6ae4b87aab6b8cbe5f2dfba90a5bfaf99b35 Mon Sep 17 00:00:00 2001 From: Minh Date: Sat, 5 Sep 2026 04:36:27 -0300 Subject: [PATCH 2/3] fix(limits): guard ps retry against timeout and cover Linux port fallback chain - Exclude ETIMEDOUT and AbortError from triggering the ps fallback spawn - Add integration test for listAntigravityPorts Linux fallback orchestration (procfs miss -> lsof miss -> ss hit) - Add unit test verifying that timeout/abort errors on /bin/ps do not trigger a ps retry --- src/lib/usage-limits.js | 5 +++- test/usage-limits.test.js | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 9ac6f26b6..437b56549 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -2171,7 +2171,10 @@ async function detectAntigravityProcess({ timeout: timeoutMs, signal, }); - if (result?.error && !result?.stdout) { + const isSpawnFailure = result?.error + && result.error.code !== "ETIMEDOUT" + && result.error.name !== "AbortError"; + if (isSpawnFailure && !result?.stdout) { result = await runCommand(commandRunner, "ps", ["-ax", "-o", "pid=,command="], { timeout: timeoutMs, signal, diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index 5cbb01d34..c02e88c35 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -3504,6 +3504,22 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS } }); + it("does not retry ps on timeout or abort errors", async () => { + const calls = []; + const timeoutError = new Error("spawn /bin/ps ETIMEDOUT"); + timeoutError.code = "ETIMEDOUT"; + + const commandRunner = (command, args) => { + calls.push({ command, args }); + return { status: null, stdout: "", stderr: "", error: timeoutError }; + }; + + const result = await detectAntigravityProcess({ commandRunner }); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "/bin/ps"); + assert.equal(result.configured, false); + }); + it("parses listening ports from ss command output", () => { const output = [ "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process", @@ -3516,6 +3532,45 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS assert.deepEqual(ports, [32919, 35345]); }); + it("exercises listAntigravityPorts Linux fallback chain (procfs miss -> lsof miss -> ss hit)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-listports-fallback-")); + try { + const calls = []; + const commandRunner = (command, args) => { + calls.push({ command, args }); + if (command === "which") { + if (args[0] === "lsof") return { status: 1, stdout: "", stderr: "" }; + if (args[0] === "ss") return { status: 0, stdout: "/usr/bin/ss\n", stderr: "" }; + } + if (command === "/usr/bin/ss" || command === "ss") { + return { + status: 0, + stdout: [ + "State Recv-Q Send-Q Local Address:Port Peer Address:Port Process", + 'LISTEN 0 4096 127.0.0.1:35345 0.0.0.0:* users:(("agy",pid=456,fd=14))', + 'LISTEN 0 4096 127.0.0.1:32919 0.0.0.0:* users:(("agy",pid=456,fd=12))', + ].join("\n"), + stderr: "", + }; + } + return { status: 1, stdout: "", stderr: "" }; + }; + + const ports = await listAntigravityPorts(456, { + commandRunner, + platform: "linux", + procRoot: tmp, // procfs miss + }); + + assert.deepEqual(ports, [32919, 35345]); + assert.ok(calls.some((c) => c.command === "which" && c.args[0] === "lsof")); + assert.ok(calls.some((c) => c.command === "which" && c.args[0] === "ss")); + assert.ok(calls.some((c) => c.command === "/usr/bin/ss" || c.command === "ss")); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("persists live Antigravity quota for use after the process exits", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-antigravity-cache-write-")); try { From c9a091096f42b7f2badb9d4af7c85da67b85af59 Mon Sep 17 00:00:00 2001 From: Minh Date: Sat, 5 Sep 2026 04:39:31 -0300 Subject: [PATCH 3/3] test(limits): accommodate lsof binary path check in integration test --- test/usage-limits.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index c02e88c35..ad3841d43 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -3563,8 +3563,8 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS }); assert.deepEqual(ports, [32919, 35345]); - assert.ok(calls.some((c) => c.command === "which" && c.args[0] === "lsof")); - assert.ok(calls.some((c) => c.command === "which" && c.args[0] === "ss")); + assert.ok(calls.some((c) => (c.command === "which" && c.args?.[0] === "lsof") || String(c.command).endsWith("lsof"))); + assert.ok(calls.some((c) => c.command === "which" && c.args?.[0] === "ss")); assert.ok(calls.some((c) => c.command === "/usr/bin/ss" || c.command === "ss")); } finally { fs.rmSync(tmp, { recursive: true, force: true });