Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions CLI/CMUXCLI+PiExtensionSource.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
extension CMUXCLI {
static let piExtensionSource = [
piExtensionSourcePart1,
piExtensionSourceDiagnostics,
piExtensionSourceDispatch,
piExtensionSourcePart2,
].joined(separator: "\n")
Expand Down
148 changes: 148 additions & 0 deletions CLI/CMUXCLI+PiExtensionSourceDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
extension CMUXCLI {
static let piExtensionSourceDiagnostics = #"""
type CommandFailureReason = "timeout" | "nonzero-exit" | "spawn-error" | "cancelled";
type CommandTerminationReason = "timeout" | "cancelled";

// Loaded repositories have produced successful 9s+ lifecycle hooks. Leave
// headroom above that observed tail without allowing a stuck child to block a
// session's serialized control queue indefinitely.
const defaultPiHookTimeoutMilliseconds = 15_000;
const maximumPiHookTimeoutMilliseconds = 60_000;

function piHookTimeoutMilliseconds(
rawValue: string | undefined = process.env.CMUX_PI_HOOK_TIMEOUT_MS,
): number {
const normalized = rawValue?.trim();
if (!normalized || !/^\d+$/.test(normalized)) return defaultPiHookTimeoutMilliseconds;
const parsed = Number(normalized);
if (parsed >= maximumPiHookTimeoutMilliseconds) return maximumPiHookTimeoutMilliseconds;
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultPiHookTimeoutMilliseconds;
}

function commandFailureReason(
status: number | null,
error: unknown,
terminationReason?: CommandTerminationReason,
): CommandFailureReason | undefined {
if (terminationReason) return terminationReason;
if (status === 0 && error === undefined) return undefined;
if (status !== null && status !== 0) return "nonzero-exit";
return "spawn-error";
}

function boundedPiHookName(value: string): string {
return utf8Prefix(value, 128) || "unknown";
}

function piHookName(args: string[]): string {
if (args[0] === "hooks" && args[1] === "pi") {
return boundedPiHookName(firstString(args[2]) || "unknown");
}
if (args[0] === "hooks" && args[1] === "feed") {
const eventIndex = args.indexOf("--event");
const eventName = eventIndex >= 0 ? firstString(args[eventIndex + 1]) : null;
return boundedPiHookName(eventName ? `feed:${eventName}` : "feed");
}
if (args[0] === "--json" && args[1] === "surface" && args[2] === "resume") {
return boundedPiHookName(`surface-resume-${firstString(args[3]) || "unknown"}`);
}
return "cmux-command";
}

function expandedPiHookLogPath(value: string, home: string | undefined = process.env.HOME): string {
if (value === "~") return home || value;
if (value.startsWith("~/") && home) {
return path.join(home, value.slice(2));
}
return value;
}

function piHookDiagnosticPath(
environment: Record<string, string | undefined> = process.env,
lastDebugLogPathFile = "/tmp/cmux-last-debug-log-path",
fallbackLogPath = "/tmp/cmux-debug.log",
): string {
const explicit = firstString(environment.CMUX_DEBUG_LOG);
if (explicit) return expandedPiHookLogPath(explicit, environment.HOME);

const socketPath = firstString(environment.CMUX_SOCKET_PATH, environment.CMUX_SOCKET);
if (socketPath) {
const socketName = path.basename(socketPath);
if (socketName.startsWith("cmux-debug-") && socketName.endsWith(".sock")) {
return path.join("/tmp", `${socketName.slice(0, -".sock".length)}.log`);
}
}

try {
const lastPath = firstString(fs.readFileSync(lastDebugLogPathFile, "utf8"));
if (lastPath) return expandedPiHookLogPath(lastPath, environment.HOME);
} catch (_) {}
return fallbackLogPath;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function appendPiHookDiagnostic(
payload: Record<string, unknown>,
environment: Record<string, string | undefined> = process.env,
lastDebugLogPathFile = "/tmp/cmux-last-debug-log-path",
fallbackLogPath = "/tmp/cmux-debug.log",
): Promise<void> {
let line: string;
try {
line = JSON.stringify({ timestamp: new Date().toISOString(), ...payload });
} catch (_) {
line = JSON.stringify({
timestamp: new Date().toISOString(),
source: "cmux-pi-extension",
level: "warning",
message: "failed to serialize Pi hook diagnostic",
hook_name: "extension",
reason: "serialization-error",
timeout_ms: piHookTimeoutMilliseconds(),
elapsed_ms: 0,
});
}
try {
// Read/write permits checking the existing JSONL boundary, while O_NONBLOCK
// keeps special files such as a FIFO from stalling Pi's lifecycle queue.
const flags = fs.constants.O_RDWR
| fs.constants.O_APPEND
| fs.constants.O_CREAT
| fs.constants.O_NONBLOCK;
const handle = await fs.promises.open(
piHookDiagnosticPath(environment, lastDebugLogPathFile, fallbackLogPath),
flags,
0o600,
);
try {
const metadata = await handle.stat();
// cmux diagnostics are files; drop device, socket, and pipe destinations.
if (!metadata.isFile()) return;
let prefix = "";
if (metadata.size > 0) {
const trailingByte = Buffer.alloc(1);
const { bytesRead } = await handle.read(trailingByte, 0, 1, metadata.size - 1);
if (bytesRead !== 1 || trailingByte[0] !== 0x0a) prefix = "\n";
}
await handle.writeFile(`${prefix}${line}\n`, "utf8");
} finally {
try { await handle.close(); } catch (_) {}
}
} catch (_) {}
}

function commandFailureDetails(
args: string[],
result: CommandResult,
): Record<string, unknown> {
return {
hook_name: piHookName(args),
reason: result.reason || commandFailureReason(result.status, result.error) || "spawn-error",
timeout_ms: result.timeoutMs,
elapsed_ms: result.elapsedMs,
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
};
}
"""#
}
79 changes: 58 additions & 21 deletions CLI/CMUXCLI+PiExtensionSourceDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ class PiCmuxCommandDispatcher {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
}
} else if (result.error instanceof Error && result.error.message.includes("timed out after")) {
} else if (result.reason === "timeout") {
const sessionId = command.context.sessionId;
if (sessionId) {
this.failTerminalFeedForSession(sessionId);
Expand Down Expand Up @@ -367,18 +367,20 @@ class PiCmuxCommandDispatcher {
}

const result = await this.spawnCmux(args, cwd, input, cancellation);
if (this.isSurfaceResolutionFailure(result)) {
const shouldWarn = !sessionId || !this.unavailableSessions.has(sessionId);
if (sessionId) this.unavailableSessions.add(sessionId);
if (shouldWarn) {
warn(context, "cmux hook command failed", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
surface_unavailable: true,
dispatch_disabled: true,
});
}
const surfaceUnavailable = this.isSurfaceResolutionFailure(result);
let shouldLogFailure = true;
if (surfaceUnavailable && sessionId) {
// Claim synchronously so overlapping Feed/control failures emit one diagnostic.
shouldLogFailure = !this.unavailableSessions.has(sessionId);
this.unavailableSessions.add(sessionId);
}
if (!result.ok && result.reason !== "cancelled" && shouldLogFailure) {
await warn(context, "cmux hook command failed", {
...commandFailureDetails(args, result),
...(surfaceUnavailable ? { surface_unavailable: true, dispatch_disabled: true } : {}),
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (surfaceUnavailable) {
return { ...result, surfaceUnavailable: true };
}
return result;
Expand All @@ -391,6 +393,8 @@ class PiCmuxCommandDispatcher {
cancellation?: PiCommandCancellation,
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve) => {
const startedAt = performance.now();
const timeoutMs = piHookTimeoutMilliseconds();
let settled = false;
let stdout = "";
let stderr = "";
Expand All @@ -399,6 +403,7 @@ class PiCmuxCommandDispatcher {
let terminateGrace: ReturnType<typeof setTimeout> | null = null;
let forceSettleTimeout: ReturnType<typeof setTimeout> | null = null;
let terminationError: Error | undefined;
let terminationReason: CommandTerminationReason | undefined;

const appendOutput = (current: string, chunk: unknown): string => {
const limit = 1024 * 1024;
Expand All @@ -414,12 +419,18 @@ class PiCmuxCommandDispatcher {
if (cancellation) cancellation.cancel = undefined;
resolve(result);
};
const elapsedMilliseconds = (): number => (
Math.max(0, Math.round(performance.now() - startedAt))
);
const terminatedResult = (): CommandResult => ({
ok: false,
status: null,
stdout,
stderr,
error: terminationError,
reason: commandFailureReason(null, terminationError, terminationReason),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});

try {
Expand All @@ -438,9 +449,10 @@ class PiCmuxCommandDispatcher {
child.stdin.on("error", (error) => {
inputError = error;
});
const beginTermination = (error: Error) => {
const beginTermination = (reason: CommandTerminationReason, error: Error) => {
if (terminationError) return;
terminationError = error;
terminationReason = reason;
child.stdin.destroy();
try {
child.kill("SIGTERM");
Expand All @@ -458,32 +470,55 @@ class PiCmuxCommandDispatcher {
}, 250);
};
child.on("error", (error) => {
settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
settle(terminationError ? terminatedResult() : {
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
child.on("close", (code) => {
if (terminationError) {
settle(terminatedResult());
return;
}
const status = typeof code === "number" ? code : null;
const error = inputError;
const reason = commandFailureReason(status, error);
settle({
ok: status === 0 && inputError === undefined,
ok: reason === undefined,
status,
stdout,
stderr,
error: inputError,
error,
reason,
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
if (cancellation) {
cancellation.cancel = () => beginTermination(new Error("cmux feed command cancelled"));
cancellation.cancel = () => beginTermination("cancelled", new Error("cmux feed command cancelled"));
if (cancellation.cancelled) cancellation.cancel();
}
timeout = setTimeout(() => {
beginTermination(new Error("cmux command timed out after 5000ms"));
}, 5000);
beginTermination("timeout", new Error(`cmux command timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.end(input);
} catch (error) {
settle({ ok: false, status: null, stdout, stderr, error });
settle({
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
}
});
}
Expand All @@ -498,6 +533,8 @@ class PiCmuxCommandDispatcher {
status: null,
stdout: "",
stderr: "",
timeoutMs: piHookTimeoutMilliseconds(),
elapsedMs: 0,
surfaceUnavailable: true,
};
}
Expand Down
Loading