fix: follow up cmux timeout and interactive_bash runtime regressions

This commit is contained in:
Kenny
2026-03-29 16:55:31 +08:00
parent f7ac464194
commit 7088120045
5 changed files with 139 additions and 6 deletions

View File

@@ -1,4 +1,7 @@
import { describe, expect, test } from "bun:test"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
createCmuxNotificationAdapter,
type CmuxNotifyCommandResult,
@@ -139,4 +142,48 @@ describe("cmux notification adapter", () => {
expect(secondDelivered).toBe(false)
expect(callCount).toBe(1)
})
test("returns promptly on timeout when cmux process ignores TERM", async () => {
if (process.platform === "win32") {
return
}
const tempDirectory = mkdtempSync(join(tmpdir(), "cmux-notify-timeout-"))
const fakeCmuxPath = join(tempDirectory, "cmux")
const slowCmuxScript = `#!/bin/sh
if [ "$1" = "notify" ]; then
trap '' TERM
/bin/sleep 1
exit 0
fi
exit 1
`
writeFileSync(fakeCmuxPath, slowCmuxScript)
chmodSync(fakeCmuxPath, 0o755)
const runtime = createResolvedMultiplexer()
runtime.cmux.path = fakeCmuxPath
try {
const adapter = createCmuxNotificationAdapter({
runtime,
environment: {
PATH: tempDirectory,
},
timeoutMs: 40,
})
const startedAt = Date.now()
const delivered = await adapter.send("OpenCode", "Task complete")
const elapsedMs = Date.now() - startedAt
expect(delivered).toBe(false)
expect(adapter.hasDowngraded()).toBe(true)
expect(elapsedMs).toBeLessThan(500)
} finally {
rmSync(tempDirectory, { recursive: true, force: true })
}
})
})

View File

@@ -83,7 +83,20 @@ async function runCmuxNotifyCommand(input: {
clearTimeout(timeoutHandle)
}
const exitCode = timedOut ? null : await proc.exited.catch(() => null)
if (timedOut) {
// Do not await stdout/stderr after timeout: a process that ignores TERM
// may keep pipes open and block fallback completion.
void proc.exited.catch(() => {})
return {
exitCode: null,
stdout: "",
stderr: "",
timedOut: true,
}
}
const exitCode = await proc.exited.catch(() => null)
const stdout = await new Response(proc.stdout).text().catch(() => "")
const stderr = await new Response(proc.stderr).text().catch(() => "")

View File

@@ -83,6 +83,10 @@ describe("tmux-path-resolver probe environment", () => {
})
test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => {
if (process.platform === "win32") {
return
}
const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-"))
const fakeCmuxPath = join(tempDirectory, "cmux")
const slowCmuxScript = `#!/bin/sh

View File

@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "bun:test"
import {
resetResolvedMultiplexerRuntimeForTesting,
setResolvedMultiplexerRuntime,
type ResolvedMultiplexer,
} from "../../shared/tmux"
import { createInteractiveBashTool, interactive_bash } from "./tools"
const mockToolContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
abort: new AbortController().signal,
}
function createTmuxEnabledRuntime(): ResolvedMultiplexer {
return {
platform: process.platform,
mode: "tmux-only",
paneBackend: "tmux",
notificationBackend: "desktop",
tmux: {
path: "/usr/bin/tmux",
reachable: true,
insideEnvironment: true,
paneId: "%1",
explicitDisable: false,
},
cmux: {
path: null,
reachable: false,
notifyCapable: false,
socketPath: undefined,
endpointType: "missing",
workspaceId: undefined,
surfaceId: undefined,
hintStrength: "none",
explicitDisable: false,
},
}
}
describe("interactive_bash runtime resolution", () => {
afterEach(() => {
resetResolvedMultiplexerRuntimeForTesting()
})
test("createInteractiveBashTool without runtime resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
const tool = createInteractiveBashTool()
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await tool.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).not.toContain("pane control is unavailable")
})
test("interactive_bash singleton resolves current runtime on execute", async () => {
resetResolvedMultiplexerRuntimeForTesting()
setResolvedMultiplexerRuntime(createTmuxEnabledRuntime())
const result = await interactive_bash.execute({ tmux_command: "capture-pane -p" }, mockToolContext)
expect(result).not.toContain("pane control is unavailable")
})
})

View File

@@ -54,9 +54,7 @@ export function tokenizeCommand(cmd: string): string[] {
}
export function createInteractiveBashTool(
runtime: ResolvedMultiplexer =
getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime(),
runtime?: ResolvedMultiplexer,
): ToolDefinition {
return tool({
description: INTERACTIVE_BASH_DESCRIPTION,
@@ -65,8 +63,13 @@ export function createInteractiveBashTool(
},
execute: async (args) => {
try {
if (runtime.paneBackend !== "tmux") {
return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${runtime.mode}' runtime.`
const resolvedRuntime =
runtime
?? getResolvedMultiplexerRuntime()
?? createDisabledMultiplexerRuntime()
if (resolvedRuntime.paneBackend !== "tmux") {
return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${resolvedRuntime.mode}' runtime.`
}
const tmuxPath = await getTmuxPath()