fix: tighten cmux runtime probing and fallback semantics

This commit is contained in:
Kenny
2026-03-29 15:35:59 +08:00
parent 64a87a78d6
commit 73f5ae968f
9 changed files with 235 additions and 33 deletions

View File

@@ -576,9 +576,9 @@ The runtime evaluates tmux and cmux availability to determine operating mode:
| Mode | Conditions | Pane Control | Notifications |
| ------------------ | ---------------------------------------------- | ------------ | ------------- |
| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux |
| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux (if capable), else desktop |
| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop |
| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux |
| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux (if capable), else desktop |
| `none` | Neither tmux nor cmux available | none | desktop |
#### Backend Precedence Semantics
@@ -601,7 +601,7 @@ The runtime probes cmux availability using these signals:
**Endpoint types**: Unix domain sockets (`/tmp/cmux.sock`) and relay addresses (`host:port`) are both supported.
**Hint strength**: Strong hints (both `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` present) preserve cmux-shim mode even in nested tmux environments. Weak hints (e.g., `TERM_PROGRAM=ghostty`) are tolerated but do not override failed probes.
**Hint strength**: Strong hints (both `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID` present) and weak hints (e.g., `TERM_PROGRAM=ghostty`) are recorded in the runtime metadata but do not affect mode resolution or probe results.
#### Environment Variables
@@ -615,7 +615,7 @@ The runtime probes cmux availability using these signals:
#### Behavior Boundaries
- **Notifications**: Delivered via `cmux notify` when a live cmux endpoint is detected
- **Notifications**: Delivered via `cmux notify` when a live, notification-capable cmux endpoint is detected
- **Pane Control**: Tmux manages panes. Cmux does not create or control panes.
- **Guarantee**: Tmux-compatible pane control remains available when tmux is live

View File

@@ -3,6 +3,7 @@ import type { TmuxConfig } from '../../config/schema'
import type { WindowState, PaneAction } from './types'
import type { ActionResult, ExecuteContext } from './action-executor'
import type { TmuxUtilDeps } from './manager'
import type { ResolvedMultiplexer } from '../../shared/tmux'
import * as sharedModule from '../../shared'
type ExecuteActionsResult = {
@@ -40,6 +41,8 @@ const mockTmuxDeps: TmuxUtilDeps = {
getCurrentPaneId: mockGetCurrentPaneId,
}
let mockedResolvedMultiplexerRuntime: ResolvedMultiplexer | null = null
mock.module('./pane-state-querier', () => ({
queryWindowState: mockQueryWindowState,
paneExists: mockPaneExists,
@@ -61,8 +64,13 @@ mock.module('./action-executor', () => ({
mock.module('../../shared/tmux', () => {
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
const {
createDisabledMultiplexerRuntime,
} = require('../../shared/tmux/tmux-utils/multiplexer-runtime')
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
return {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime: () => mockedResolvedMultiplexerRuntime,
isInsideTmux,
getCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS,
@@ -135,6 +143,7 @@ describe('TmuxSessionManager', () => {
mockExecuteAction.mockClear()
mockIsInsideTmux.mockClear()
mockGetCurrentPaneId.mockClear()
mockedResolvedMultiplexerRuntime = null
trackedSessions.clear()
mockQueryWindowState.mockImplementation(async () => createWindowState())
@@ -227,6 +236,54 @@ describe('TmuxSessionManager', () => {
expect(manager).toBeDefined()
})
test('legacy deps constructor ignores global multiplexer cache', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)
mockedResolvedMultiplexerRuntime = {
platform: 'darwin',
mode: 'none',
paneBackend: 'none',
notificationBackend: 'desktop',
tmux: {
path: null,
reachable: false,
insideEnvironment: false,
paneId: undefined,
explicitDisable: false,
},
cmux: {
path: null,
reachable: false,
notifyCapable: false,
socketPath: undefined,
endpointType: 'missing',
workspaceId: undefined,
surfaceId: undefined,
hintStrength: 'none',
explicitDisable: false,
},
}
const { TmuxSessionManager } = await import('./manager')
const ctx = createMockContext()
const config: TmuxConfig = {
enabled: true,
layout: 'main-vertical',
main_pane_size: 60,
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
// when
await manager.onSessionCreated(
createSessionCreatedEvent('ses_cache_ignored', 'ses_parent', 'Cache Ignored')
)
// then
expect(mockExecuteActions).toHaveBeenCalledTimes(1)
})
test('falls back to default port when serverUrl has port 0', async () => {
// given
mockIsInsideTmux.mockReturnValue(true)

View File

@@ -4,7 +4,6 @@ import type { TrackedSession, CapacityConfig, WindowState } from "./types"
import { log, normalizeSDKResponse } from "../../shared"
import {
createDisabledMultiplexerRuntime,
getResolvedMultiplexerRuntime,
isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS,
@@ -115,7 +114,7 @@ export class TmuxSessionManager {
if (isTmuxUtilDeps(runtimeOrDeps)) {
this.deps = runtimeOrDeps
this.resolvedMultiplexer = getResolvedMultiplexerRuntime() ?? createRuntimeFromLegacyDeps(runtimeOrDeps)
this.resolvedMultiplexer = createRuntimeFromLegacyDeps(runtimeOrDeps)
} else {
this.deps = deps
this.resolvedMultiplexer = runtimeOrDeps

View File

@@ -104,11 +104,11 @@ describe("cmux notification adapter", () => {
expect(adapter.hasDowngraded()).toBe(true)
})
test("falls back to desktop on connection-refused failures", async () => {
test("falls back to desktop when output reports connection-refused", async () => {
const adapter = createCmuxNotificationAdapter({
runtime: createResolvedMultiplexer(),
executeCommand: async () => createResult({
exitCode: 1,
exitCode: 0,
stderr: "dial tcp 127.0.0.1:7777: connect: connection refused",
}),
})

View File

@@ -115,15 +115,15 @@ function shouldDowngrade(result: CmuxNotifyCommandResult): boolean {
return true
}
if (result.exitCode === 0) {
return false
}
const combinedOutput = `${result.stderr}\n${result.stdout}`
if (isConnectionRefusedText(combinedOutput)) {
return true
}
if (result.exitCode === 0) {
return false
}
return true
}

View File

@@ -1,10 +1,15 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import {
createDisabledMultiplexerRuntime,
resolveMultiplexerFromProbes,
resolveMultiplexerRuntime,
type ResolvedMultiplexer,
} from "./multiplexer-runtime"
import type { CmuxRuntimeProbe, TmuxRuntimeProbe } from "../../../tools/interactive-bash/tmux-path-resolver"
import {
resetMultiplexerPathCacheForTesting,
type CmuxRuntimeProbe,
type TmuxRuntimeProbe,
} from "../../../tools/interactive-bash/tmux-path-resolver"
function createTmuxProbe(overrides: Partial<TmuxRuntimeProbe> = {}): TmuxRuntimeProbe {
return {
@@ -234,4 +239,41 @@ describe("multiplexer runtime resolution", () => {
expect(runtime.tmux.reachable).toBe(false)
expect(runtime.tmux.insideEnvironment).toBe(true)
})
test("skips tmux and cmux path probing when both backends are disabled", async () => {
resetMultiplexerPathCacheForTesting()
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await resolveMultiplexerRuntime({
environment: {},
tmuxEnabled: false,
cmuxEnabled: false,
})
expect(whichSpy).toHaveBeenCalledTimes(0)
} finally {
whichSpy.mockRestore()
}
})
test("only probes cmux path when tmux backend is disabled", async () => {
resetMultiplexerPathCacheForTesting()
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await resolveMultiplexerRuntime({
environment: {
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
},
tmuxEnabled: false,
cmuxEnabled: true,
})
expect(whichSpy.mock.calls.length).toBeGreaterThan(0)
expect(whichSpy.mock.calls.every((call) => call[0] === "cmux")).toBe(true)
} finally {
whichSpy.mockRestore()
}
})
})

View File

@@ -78,6 +78,29 @@ function resolveMode(input: {
return "none"
}
function createDisabledTmuxProbe(): TmuxRuntimeProbe {
return {
path: null,
reachable: false,
paneControlReachable: false,
explicitDisable: false,
}
}
function createDisabledCmuxProbe(): CmuxRuntimeProbe {
return {
path: null,
socketPath: undefined,
endpointType: "missing",
workspaceId: undefined,
surfaceId: undefined,
hintStrength: "none",
reachable: false,
explicitDisable: false,
notifyCapable: false,
}
}
export function createDisabledMultiplexerRuntime(platform: NodeJS.Platform = process.platform): ResolvedMultiplexer {
return {
platform,
@@ -174,10 +197,19 @@ export async function resolveMultiplexerRuntime(
const tmuxEnabled = options.tmuxEnabled ?? true
const cmuxEnabled = options.cmuxEnabled ?? true
const [tmuxProbe, cmuxProbe] = await Promise.all([
options.tmuxProbe ? Promise.resolve(options.tmuxProbe) : probeTmuxRuntime({ environment }),
options.cmuxProbe ? Promise.resolve(options.cmuxProbe) : probeCmuxRuntime({ environment }),
])
const tmuxProbePromise = options.tmuxProbe
? Promise.resolve(options.tmuxProbe)
: tmuxEnabled
? probeTmuxRuntime({ environment })
: Promise.resolve(createDisabledTmuxProbe())
const cmuxProbePromise = options.cmuxProbe
? Promise.resolve(options.cmuxProbe)
: cmuxEnabled
? probeCmuxRuntime({ environment })
: Promise.resolve(createDisabledCmuxProbe())
const [tmuxProbe, cmuxProbe] = await Promise.all([tmuxProbePromise, cmuxProbePromise])
const resolved = resolveMultiplexerFromProbes({
platform,

View File

@@ -1,10 +1,62 @@
import { describe, expect, test } from "bun:test"
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
import {
classifyCmuxEndpoint,
isConnectionRefusedText,
probeCmuxReachability,
probeTmuxRuntime,
resetMultiplexerPathCacheForTesting,
supportsCmuxNotifyFlagModel,
} from "./tmux-path-resolver"
describe("tmux-path-resolver probe environment", () => {
beforeEach(() => {
resetMultiplexerPathCacheForTesting()
})
test("probeTmuxRuntime resolves tmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeTmuxRuntime({
environment: {
PATH: "/tmp/custom-tmux-bin",
TMUX: "/tmp/tmux-501/default,1,0",
TMUX_PANE: "%1",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"tmux",
{ PATH: "/tmp/custom-tmux-bin" },
])
} finally {
whichSpy.mockRestore()
}
})
test("probeCmuxReachability resolves cmux using provided PATH", async () => {
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
try {
await probeCmuxReachability({
environment: {
PATH: "/tmp/custom-cmux-bin",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
},
})
expect(whichSpy).toHaveBeenCalledTimes(1)
expect(whichSpy.mock.calls[0]).toEqual([
"cmux",
{ PATH: "/tmp/custom-cmux-bin" },
])
} finally {
whichSpy.mockRestore()
}
})
})
describe("tmux-path-resolver cmux endpoint helpers", () => {
test("classifies relay host:port endpoint as relay", () => {
expect(classifyCmuxEndpoint("127.0.0.1:7788")).toBe("relay")

View File

@@ -260,22 +260,37 @@ async function runProbeCommand(
}
}
function findCommandPath(commandName: string): string | null {
function findCommandPath(
commandName: string,
environment?: Record<string, string | undefined>,
): string | null {
try {
const discovered = Bun.which(commandName)
const probeEnvironment = toProbeEnvironment(environment)
const whichOptions =
probeEnvironment.PATH !== undefined
? { PATH: probeEnvironment.PATH }
: undefined
const discovered = Bun.which(commandName, whichOptions)
return discovered ?? null
} catch {
return null
}
}
async function resolveExecutablePath(commandName: string, verifyArgs: string[]): Promise<string | null> {
const discovered = findCommandPath(commandName)
async function resolveExecutablePath(
commandName: string,
verifyArgs: string[],
environment?: Record<string, string | undefined>,
): Promise<string | null> {
const discovered = findCommandPath(commandName, environment)
if (!discovered) {
return null
}
const verification = await runProbeCommand([discovered, ...verifyArgs])
const verification = await runProbeCommand([discovered, ...verifyArgs], {
environment,
})
if (verification.timedOut || verification.exitCode !== 0) {
return null
}
@@ -283,20 +298,20 @@ async function resolveExecutablePath(commandName: string, verifyArgs: string[]):
return discovered
}
async function findTmuxPath(): Promise<string | null> {
if (isTruthyFlag(process.env[TMUX_DISABLE_ENV_KEY])) {
async function findTmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
if (isTruthyFlag(environment[TMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("tmux", ["-V"])
return resolveExecutablePath("tmux", ["-V"], environment)
}
async function findCmuxPath(): Promise<string | null> {
if (isTruthyFlag(process.env[CMUX_DISABLE_ENV_KEY])) {
async function findCmuxPath(environment: Record<string, string | undefined> = process.env): Promise<string | null> {
if (isTruthyFlag(environment[CMUX_DISABLE_ENV_KEY])) {
return null
}
return resolveExecutablePath("cmux", ["--help"])
return resolveExecutablePath("cmux", ["--help"], environment)
}
export async function getTmuxPath(): Promise<string | null> {
@@ -356,7 +371,9 @@ export async function probeTmuxRuntime(options: ProbeOptions = {}): Promise<Tmux
}
}
const path = await getTmuxPath()
const path = options.environment
? await findTmuxPath(environment)
: await getTmuxPath()
const paneId = normalizeEnvValue(environment.TMUX_PANE)
const hasTmuxEnvironment = Boolean(normalizeEnvValue(environment.TMUX))
@@ -413,7 +430,9 @@ export async function probeCmuxReachability(options: ProbeOptions = {}): Promise
}
}
const path = await getCmuxPath()
const path = options.environment
? await findCmuxPath(environment)
: await getCmuxPath()
if (!path) {
return {
path: null,
@@ -521,7 +540,8 @@ export async function probeCmuxNotificationCapability(
}
}
const cmuxBinary = options.cmuxPath ?? await getCmuxPath()
const cmuxBinary = options.cmuxPath
?? (options.environment ? await findCmuxPath(environment) : await getCmuxPath())
if (!cmuxBinary) {
return {
capable: false,