fix: tighten cmux runtime probing and fallback semantics
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user