Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
exit 0
- run: bun install --frozen-lockfile
if: needs.changes.outputs.draft != 'true'
- run: bun test ./server/claude-store.test.ts ./server/process-start.test.ts ./server/grok.test.ts
- run: bun test ./server/claude-store.test.ts ./server/process-start.test.ts ./server/darwin-process.test.ts ./server/grok.test.ts
if: needs.changes.outputs.draft != 'true'
rust-pty-host:
# The Rust PTY host (crates/pty-sidecar) is the default where it is installed or built, and its
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/macos-no-sync-subprocess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- On macOS the server no longer pauses every connection while `ps` tells when an agent's process
started or which terminal it runs in: it reads both from the kernel instead.
- Opening a file an agent named without its folder no longer pauses the server while git lists a
large repository.
4 changes: 2 additions & 2 deletions docs/muse-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ pane, so a session Muse opens after a failed scan can take that long to show. On
4096 entries, is not read, and Chat uses Terminal.

The data directory comes from the server's `XDG_DATA_HOME`; a Muse started with another one
uses Terminal. A process's start time comes from `ps` on macOS (whole seconds, so a three-second
margin) and `/proc` on Linux (to 10 ms, so a 20 ms margin). Windows uses Terminal.
uses Terminal. A process's start time comes from the kernel's process record on macOS (with a
three-second margin) and `/proc` on Linux (to 10 ms, so a 20 ms margin). Windows uses Terminal.

## What is read

Expand Down
3 changes: 2 additions & 1 deletion server/claude-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, sy
import * as fsPromises from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, win32 } from "node:path";
import { AmbiguousClaudeStore, claudeAttachArgs, claudeAttachedJob, claudeAttachSession, claudeForegroundSession, claudeJobSession, claudeProcessSession, claudeSessionTempDir, claudeStoreKey, claudeStoreOf, claudeProjectDir, claudeTranscriptFile, claudeRecordOnlyIn, configDirInPsLine, darwinProcess, forgetClaudeSessions, herdrLaunchSource, isClaudeProcess, paneClaudeSource, processClaudeConfigDir, processClaudeSource, procArgsEnvironment, procStartTime } from "./claude-store.ts";
import { AmbiguousClaudeStore, claudeAttachArgs, claudeAttachedJob, claudeAttachSession, claudeForegroundSession, claudeJobSession, claudeProcessSession, claudeSessionTempDir, claudeStoreKey, claudeStoreOf, claudeProjectDir, claudeTranscriptFile, claudeRecordOnlyIn, configDirInPsLine, darwinProcess, forgetClaudeSessions, herdrLaunchSource, isClaudeProcess, paneClaudeSource, processClaudeConfigDir, processClaudeSource, procArgsEnvironment } from "./claude-store.ts";
import { procStartTime } from "./darwin-process.ts";
import type { ProcessRow } from "./windows-processes.ts";

const NATIVE = process.platform === "linux" || process.platform === "darwin";
Expand Down
35 changes: 2 additions & 33 deletions server/claude-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
* id is a UUID herdr reports, so at most one file answers to it.
*/

import { dlopen, FFIType, ptr } from "bun:ffi";
import { createHash } from "node:crypto";
import { constants, type Dirent } from "node:fs";
import { open, readFile, readdir, readlink, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath, { basename, dirname, isAbsolute, join, type PlatformPath } from "node:path";
import { directoryKey, withoutVerbatimPrefix } from "./codex.ts";
import { darwinStartTime, sysctlCall } from "./darwin-process.ts";
import { recentProcessTable } from "./gjc-runtime.ts";
import { processAlive, processStartUpperBoundAsync } from "./process-start.ts";
import type { ProcessRow } from "./windows-processes.ts";
Expand Down Expand Up @@ -194,20 +194,6 @@ export async function claudeRecordOnlyIn(
return holders.length === 1 && holders[0] === wanted;
}

/** sysctl(3) from libSystem, opened once, on macOS only. */
let darwinSysctl: ((name: Int32Array, out: Uint8Array) => number | null) | undefined;

function sysctlCall(): (name: Int32Array, out: Uint8Array) => number | null {
if (darwinSysctl) return darwinSysctl;
// a system library's C function: nothing native of node's is loaded into Bun
const lib = dlopen("/usr/lib/libSystem.B.dylib", { sysctl: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.i32 } });
darwinSysctl = (name, out) => {
const size = new BigUint64Array([BigInt(out.byteLength)]);
return lib.symbols.sysctl(ptr(name), name.length, ptr(out), ptr(size), null, 0) === 0 ? Number(size[0]) : null;
};
return darwinSysctl;
}

/**
* The environment in a `KERN_PROCARGS2` answer, one entry each: an int argc, the executable's path,
* its NUL padding, argc arguments, then the environment, each NUL-terminated, up to an empty one or
Expand Down Expand Up @@ -249,23 +235,6 @@ function darwinEnvironment(pid: number): string | null {
return entries === null ? null : entries.join("\0");
}

/** kern.proc.pid begins with extern_proc.p_starttime, a 64-bit Darwin timeval. */
export function procStartTime(answer: Uint8Array): bigint | null {
if (answer.byteLength < 16) return null;
const view = new DataView(answer.buffer, answer.byteOffset, answer.byteLength);
const seconds = view.getBigInt64(0, true);
const micros = view.getInt32(8, true);
return seconds > 0n && micros >= 0 && micros < 1_000_000 ? seconds * 1_000_000n + BigInt(micros) : null;
}

/** The kernel's process birth time, including microseconds that `ps -o lstart` drops. */
function darwinStartTime(pid: number): bigint | null {
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
const answer = new Uint8Array(4096);
const length = sysctlCall()(new Int32Array([1, 14, 1, pid]), answer); // CTL_KERN, KERN_PROC, KERN_PROC_PID
return length === null ? null : procStartTime(answer.subarray(0, length));
}

export const darwinProcess = { environment: darwinEnvironment, startTime: darwinStartTime };

/** A named store: an absolute path to a directory, else null. */
Expand Down Expand Up @@ -921,7 +890,7 @@ async function findAttachedJob(configDir: string, { ref, byName }: ClaudeAttach,
*/
const attachedJobs = new Map<string, { pid: number; started: number; job: string | null; at: number }>();
const MAX_ATTACHED_JOBS = 256;
/** Two reads of one process's start differ by up to a second: macOS's ps counts whole seconds. */
/** Two reads of one process's start: Linux's differ by a tick or two; macOS's are the kernel's own, kept to the margin they had when `ps` counted whole seconds. */
const SAME_START_MS = 2000;
const ATTACH_MISS_MS = 10_000;

Expand Down
18 changes: 9 additions & 9 deletions server/conversation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import * as darwinProcess from "./darwin-process.ts";
import { Database } from "bun:sqlite";
import { appendFileSync, copyFileSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -2743,7 +2744,7 @@ describe("a Codex pane that reported no session yet", () => {
});

// any client snapshot starts the Claude subagent refresh that reads this, also the one a pending message is checked against (#1545)
it("reads a Claude pane's process start through a ps child on macOS, never a synchronous ps", async () => {
it("reads a Claude pane's process start from the kernel on macOS, with no ps for it", async () => {
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
const home = mkdtempSync(join(tmpdir(), "claude-darwin-start-"));
const previousHome = process.env["HOME"];
Expand All @@ -2755,23 +2756,22 @@ it("reads a Claude pane's process start through a ps child on macOS, never a syn
? { agent: { agent_session: { value: session } } }
: { process_info: { foreground_processes: [{ pid: 4242, name: "claude", argv: ["claude"] }] } }) as unknown as typeof herdr.herdrRpc);
const blocking = spyOn(Bun, "spawnSync");
const probes: string[][] = [];
const awaited = spyOn(Bun, "spawn").mockImplementation(((argv: string[]) => {
probes.push(argv);
return { stdout: new Response("01:00\n").body, exited: Promise.resolve(0), signalCode: null, kill: () => {} };
}) as unknown as typeof Bun.spawn);
// the kernel's process record answers the start; no ps asks it, awaited or not (#1404). Claude's own
// store may still ask an awaited ps for the process's environment on a real Mac.
const kernel = spyOn(darwinProcess, "darwinStartTime").mockReturnValue(1_000_000n);
const awaited = spyOn(Bun, "spawn");
process.env["HOME"] = home;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
const found = await claudePaneSession({ pane_id: "claude-darwin", cwd } as HerdrPane);
expect(found?.pid).toBe(4242);
expect(found?.startedAt).toBeNumber();
expect(probes).toContainEqual(["/bin/ps", "-o", "etime=", "-p", "4242"]);
expect(found?.startedAt).toBe(1_000);
expect((awaited.mock.calls as unknown as [string[]][]).filter(([argv]) => argv.includes("etime="))).toEqual([]);
expect(blocking).not.toHaveBeenCalled();
} finally {
Object.defineProperty(process, "platform", platform);
if (previousHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = previousHome;
awaited.mockRestore(); blocking.mockRestore(); rpc.mockRestore();
awaited.mockRestore(); blocking.mockRestore(); kernel.mockRestore(); rpc.mockRestore();
rmSync(home, { recursive: true, force: true });
}
});
2 changes: 1 addition & 1 deletion server/core/pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ export function createPendingDelivery({ options, clients, attachments, sight, se
&& front !== undefined && launchedName !== undefined && front !== launchedName;
if (identity.agent !== null && !shellBack) return false;
return identity.processPid !== launched
// macOS reads a birth to the whole second (samePendingIdentity)
// macOS allows a second, as samePendingIdentity does
|| (item.identity.processBirth != null && identity.processBirth != null
&& Math.abs(identity.processBirth - item.identity.processBirth) > (process.platform === "darwin" ? 1000 : 0));
}
Expand Down
56 changes: 56 additions & 0 deletions server/darwin-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test";
import { darwinStartTime, darwinTerminal, procTerminalDevice } from "./darwin-process.ts";

/** A 648-byte kinfo_proc that started at `seconds` (plus `micros`) on terminal device `device`. */
function kinfo(seconds: number, micros: number, device: number): Uint8Array {
const answer = new Uint8Array(648);
const view = new DataView(answer.buffer);
view.setBigInt64(0, BigInt(seconds), true);
view.setInt32(8, micros, true);
view.setInt32(572, device, true);
return answer;
}

describe("macOS kernel process record", () => {
it("reads the controlling terminal's device from kinfo_proc, and none for NODEV or a short answer", () => {
const answer = new Uint8Array(648).fill(0xff);
const view = new DataView(answer.buffer);
view.setInt32(572, 0x1000001, true);
expect(procTerminalDevice(answer)).toBe(0x1000001);
view.setInt32(572, -1, true);
expect(procTerminalDevice(answer)).toBeNull();
expect(procTerminalDevice(answer.subarray(0, 575))).toBeNull();
expect(procTerminalDevice(new Uint8Array(0))).toBeNull();
});

it("takes a terminal and its start from one record, so a PID reused between reads cannot pair two processes", () => {
// the first read sees process A on ttys001; any later read of the PID sees process B on ttys002
const records = [kinfo(1_793_000_000, 250_000, 1), kinfo(1_793_000_100, 0, 2)];
let reads = 0;
const read = () => records[Math.min(reads++, 1)]!;
const name = (device: number) => `ttys00${device}`;
expect(darwinTerminal(42, read, name)).toEqual({ tty: "ttys001", startedAt: 1_793_000_000_250 });
expect(reads).toBe(1);
// no terminal, no start or no process: nothing is paired
expect(darwinTerminal(42, () => kinfo(1_793_000_000, 0, -1), name)).toBeNull();
expect(darwinTerminal(42, () => kinfo(0, 0, 1), name)).toBeNull();
expect(darwinTerminal(42, () => new Uint8Array(0), name)).toBeNull();
expect(darwinTerminal(42, () => null, name)).toBeNull();
});

it.skipIf(process.platform !== "darwin")("tells what `ps` told, for a live process and none for a missing one", async () => {
const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(5000)"]);
try {
const ps = Bun.spawnSync(["/bin/ps", "-o", "tty=", "-o", "lstart=", "-p", String(child.pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" } });
const [tty, ...lstart] = ps.stdout.toString().trim().split(/\s+/);
const started = darwinStartTime(child.pid);
expect(started).not.toBeNull();
expect(Math.floor(Number(started! / 1000n) / 1000) * 1000).toBe(Date.parse(`${lstart.join(" ")} UTC`));
const record = darwinTerminal(child.pid);
expect(record?.tty ?? null).toBe(tty === "??" ? null : tty!);
if (record) expect(record.startedAt).toBe(Number(started! / 1000n));
} finally { child.kill(); }
expect(darwinStartTime(2147483647)).toBeNull();
expect(darwinTerminal(2147483647)).toBeNull();
});
});
87 changes: 87 additions & 0 deletions server/darwin-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* What macOS's kernel keeps about a process, read in the server's own thread through libSystem:
* `ps` asked the same kernel, but as a child the event loop had to wait for, and that wait stalled
* every connection (#1404). Each read takes microseconds and never starts a process.
*/
import { dlopen, FFIType, ptr } from "bun:ffi";

/** libSystem's sysctl(3) and devname(3), opened once, on macOS only. */
let libSystem: ReturnType<typeof openLibSystem> | undefined;

function openLibSystem() {
// a system library's C functions: nothing native of node's is loaded into Bun
return dlopen("/usr/lib/libSystem.B.dylib", {
sysctl: { args: [FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.i32 },
devname: { args: [FFIType.i32, FFIType.u16], returns: FFIType.cstring },
}).symbols;
}

/** sysctl(3) for `name`, into `out`: the answer's length, or null when the call failed. */
export function sysctlCall(): (name: Int32Array, out: Uint8Array) => number | null {
const symbols = libSystem ??= openLibSystem();
return (name, out) => {
const size = new BigUint64Array([BigInt(out.byteLength)]);
return symbols.sysctl(ptr(name), name.length, ptr(out), ptr(size), null, 0) === 0 ? Number(size[0]) : null;
};
}

/** kern.proc.pid begins with extern_proc.p_starttime, a 64-bit Darwin timeval. */
export function procStartTime(answer: Uint8Array): bigint | null {
if (answer.byteLength < 16) return null;
const view = new DataView(answer.buffer, answer.byteOffset, answer.byteLength);
const seconds = view.getBigInt64(0, true);
const micros = view.getInt32(8, true);
return seconds > 0n && micros >= 0 && micros < 1_000_000 ? seconds * 1_000_000n + BigInt(micros) : null;
}

/** Where kinfo_proc keeps kp_eproc.e_tdev, the controlling terminal's device (648-byte kinfo_proc, arm64 and x86_64). */
const E_TDEV_OFFSET = 572;
/** dev_t's NODEV: the process has no controlling terminal (`ps` prints `??`). */
const NODEV = -1;

/** The controlling terminal's device in a kern.proc.pid answer, or null when it has none. */
export function procTerminalDevice(answer: Uint8Array): number | null {
if (answer.byteLength < E_TDEV_OFFSET + 4) return null;
const device = new DataView(answer.buffer, answer.byteOffset, answer.byteLength).getInt32(E_TDEV_OFFSET, true);
return device === NODEV ? null : device;
}

/** CTL_KERN, KERN_PROC, KERN_PROC_PID: the process's kinfo_proc, empty when no such process runs. */
function kinfoProc(pid: number): Uint8Array | null {
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
const answer = new Uint8Array(4096);
const length = sysctlCall()(new Int32Array([1, 14, 1, pid]), answer);
return length === null ? null : answer.subarray(0, length);
}

/** The kernel's process birth time in microseconds, including those `ps -o lstart` drops. */
export function darwinStartTime(pid: number): bigint | null {
const answer = kinfoProc(pid);
return answer === null ? null : procStartTime(answer);
}

/** A character device's name under /dev (`ttys003`), or null when it has none. */
function deviceName(device: number): string | null {
const symbols = libSystem ??= openLibSystem();
const name = symbols.devname(device, 0o020000); // S_IFCHR
return name === null ? null : String(name) || null;
}

/**
* The process's controlling terminal under /dev (`ttys003`, as `ps -o tty=` prints it) and its start
* in ms, both from one kinfo_proc: two reads could describe two processes when the PID is reused
* between them. Null when no such process runs or it has no terminal. `read` and `name` stand in for
* the kernel in tests.
*/
export function darwinTerminal(
pid: number,
read: (pid: number) => Uint8Array | null = kinfoProc,
name: (device: number) => string | null = deviceName,
): { tty: string; startedAt: number } | null {
const answer = read(pid);
if (answer === null) return null;
const started = procStartTime(answer);
const device = procTerminalDevice(answer);
const tty = started === null || device === null ? null : name(device);
return tty === null ? null : { tty, startedAt: Number(started! / 1000n) };
}
Loading
Loading