Speed up terminal shell selection with bash/sh/ash fast path
Release rolling / release (push) Successful in 7m48s

Try bash, sh, and ash first with a short alive-probe so common images
attach quickly; fall back to other shells and adaptive probes only after.
This commit is contained in:
Raven Scott
2026-07-17 22:59:58 -04:00
parent 70b47bd2c5
commit a78ef40966
2 changed files with 99 additions and 22 deletions
+66 -17
View File
@@ -2,9 +2,10 @@
* Interactive container terminal over protomux-rpc.
* TTY sessions stream raw PTY bytes (no demux). Supports multi-session via sessionId.
*
* Shell selection: try an ordered list of shells (bash, sh, ash, …) until one
* actually stays running. Docker's exec create often succeeds even when the
* binary is missing — failure shows up only after start (exit 126/127).
* Shell selection: try bash / sh / ash first with a short alive-probe so common
* images enter the PTY quickly. Exotic shells and adaptive probe wrappers are
* only tried if the fast path fails. Docker's exec create often succeeds even
* when the binary is missing — failure shows up only after start (exit 126/127).
*/
import { PassThrough } from 'stream'
import { docker } from '../services/docker.js'
@@ -13,27 +14,49 @@ import logger from '../utils/logger.js'
const SESSIONS_KEY = 'terminals'
/** How long to wait for a candidate shell to prove it is alive. */
const SHELL_PROBE_MS = 450
const SHELL_PROBE_STEP_MS = 40
/** Alive-probe for bash/sh/ash fast path — fail missing binaries quickly. */
const SHELL_PROBE_FAST_MS = 90
const SHELL_PROBE_FAST_STEP_MS = 15
/** Alive-probe for fallback shells / probe wrappers. */
const SHELL_PROBE_MS = 350
const SHELL_PROBE_STEP_MS = 25
/**
* Ordered shell candidates. Prefer interactive shells, then POSIX sh variants,
* busybox, then PATH-relative names. First match that stays Running wins.
* Fast path: only bash, sh, ash (absolute + PATH). Tried first with a short
* timeout so typical Debian/Ubuntu/Alpine containers attach almost immediately.
*/
export const DEFAULT_SHELL_CANDIDATES = Object.freeze([
export const FAST_SHELL_CANDIDATES = Object.freeze([
['/bin/bash'],
['/usr/bin/bash'],
['bash'],
['/bin/zsh'],
['/usr/bin/zsh'],
['zsh'],
['/bin/sh'],
['/usr/bin/sh'],
['sh'],
['/bin/ash'],
['/usr/bin/ash'],
['ash'],
])
const FAST_SHELL_KEYS = new Set(FAST_SHELL_CANDIDATES.map((c) => c.join('\0')))
/**
* Fallback shell candidates after the fast path. Prefer interactive shells,
* then POSIX / busybox variants. First match that stays Running wins.
* (bash/sh/ash also listed here for completeness; buildShellCandidates dedupes.)
*/
export const DEFAULT_SHELL_CANDIDATES = Object.freeze([
['/bin/bash'],
['/usr/bin/bash'],
['bash'],
['/bin/sh'],
['/usr/bin/sh'],
['sh'],
['/bin/ash'],
['/usr/bin/ash'],
['ash'],
['/bin/zsh'],
['/usr/bin/zsh'],
['zsh'],
['/bin/dash'],
['/usr/bin/dash'],
['dash'],
@@ -138,8 +161,18 @@ function toBase64(chunk) {
return Buffer.isBuffer(chunk) ? chunk.toString('base64') : Buffer.from(chunk).toString('base64')
}
/**
* @param {string[]} cmd
* @returns {boolean}
*/
export function isFastShellCmd(cmd) {
if (!Array.isArray(cmd) || !cmd.length) return false
return FAST_SHELL_KEYS.has(cmd.join('\0'))
}
/**
* Build ordered unique shell command lists for this startTerminal call.
* Order: explicit override → bash/sh/ash (fast) → other shells → probe wrappers.
* @param {object} args
* @returns {string[][]}
*/
@@ -165,9 +198,12 @@ export function buildShellCandidates(args = {}) {
push([String(args.shell)])
}
// Prefer adaptive probe wrappers, then each concrete shell
for (const c of PROBE_WRAPPERS) push(c)
// Fast path: bash → sh → ash only (short probe in openShellExec)
for (const c of FAST_SHELL_CANDIDATES) push([...c])
// Fallbacks: zsh/fish/busybox/…
for (const c of DEFAULT_SHELL_CANDIDATES) push([...c])
// Heavy adaptive sh -c probe last (only if nothing concrete attached)
for (const c of PROBE_WRAPPERS) push(c)
return out
}
@@ -204,7 +240,12 @@ function destroyStream(stream) {
* @param {number} [timeoutMs]
* @returns {Promise<{ ok: boolean, exitCode: number|null|undefined, buffered: Buffer[] }>}
*/
async function probeShellAlive(exec, stream, timeoutMs = SHELL_PROBE_MS) {
async function probeShellAlive(
exec,
stream,
timeoutMs = SHELL_PROBE_MS,
stepMs = SHELL_PROBE_STEP_MS
) {
/** @type {Buffer[]} */
const buffered = []
let ended = false
@@ -227,6 +268,7 @@ async function probeShellAlive(exec, stream, timeoutMs = SHELL_PROBE_MS) {
stream.on('close', onEnd)
const deadline = Date.now() + timeoutMs
const pollStep = Math.max(10, Number(stepMs) || SHELL_PROBE_STEP_MS)
/** @type {number|null|undefined} */
let exitCode
let running = false
@@ -253,7 +295,7 @@ async function probeShellAlive(exec, stream, timeoutMs = SHELL_PROBE_MS) {
detachProbeListeners(stream, onData, onEnd, onError)
return { ok: false, exitCode: exitCode ?? 127, buffered }
}
await sleep(SHELL_PROBE_STEP_MS)
await sleep(pollStep)
}
// Timeout: accept if still running or we got output (prompt)
@@ -313,11 +355,18 @@ export async function openShellExec(container, candidates, useTty) {
Tty: useTty,
})
const probe = await probeShellAlive(exec, stream)
const fast = isFastShellCmd(Cmd)
const probe = await probeShellAlive(
exec,
stream,
fast ? SHELL_PROBE_FAST_MS : SHELL_PROBE_MS,
fast ? SHELL_PROBE_FAST_STEP_MS : SHELL_PROBE_STEP_MS
)
if (!probe.ok) {
logger.debug('terminal shell candidate rejected', {
cmd: label,
exitCode: probe.exitCode,
fast,
})
destroyStream(stream)
lastErr = new Error(