Speed up terminal shell selection with bash/sh/ash fast path
Release rolling / release (push) Successful in 7m48s
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:
+66
-17
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user