forked from snxraven/peardock
Pin PTY sessions to the peer they were opened on so shells survive active-node switches until the window is closed. Electron host owns the connection; pop-out windows relay I/O via IPC (BroadcastChannel fallback). Explicit terminal sessionIds no longer wipe sibling PTYs on the same container.
679 lines
20 KiB
JavaScript
679 lines
20 KiB
JavaScript
/**
|
|
* Interactive container terminal over protomux-rpc.
|
|
* TTY sessions stream raw PTY bytes (no demux). Supports multi-session via sessionId.
|
|
*
|
|
* 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'
|
|
import { Pushes } from '../../shared/protocol.js'
|
|
import logger from '../utils/logger.js'
|
|
|
|
const SESSIONS_KEY = 'terminals'
|
|
|
|
/** 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
|
|
|
|
/**
|
|
* 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 FAST_SHELL_CANDIDATES = Object.freeze([
|
|
['/bin/bash'],
|
|
['/usr/bin/bash'],
|
|
['bash'],
|
|
['/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'],
|
|
['/bin/ksh'],
|
|
['/usr/bin/ksh'],
|
|
['ksh'],
|
|
['/bin/mksh'],
|
|
['/usr/bin/mksh'],
|
|
['mksh'],
|
|
['/bin/fish'],
|
|
['/usr/bin/fish'],
|
|
['fish'],
|
|
['/bin/busybox', 'sh'],
|
|
['/usr/bin/busybox', 'sh'],
|
|
['busybox', 'sh'],
|
|
['/busybox', 'sh'],
|
|
['/bin/busybox', 'ash'],
|
|
['/system/bin/sh'], // Android-ish images
|
|
])
|
|
|
|
/**
|
|
* One-shot script: from a working sh, pick the best available interactive shell.
|
|
* Used when /bin/sh (or equivalent) exists but we still want bash if present.
|
|
*
|
|
* Must be valid when passed to `sh -c`. Join with newlines (not spaces):
|
|
* space-joining turns `done\\nfor` into `done for`, which dash/sh reject
|
|
* as `Syntax error: "if" unexpected (expecting "done")`.
|
|
*/
|
|
const SHELL_PROBE_SCRIPT = [
|
|
'for s in /bin/bash /usr/bin/bash bash /bin/zsh /usr/bin/zsh zsh /bin/ash /usr/bin/ash ash /bin/dash /usr/bin/dash dash /bin/ksh /usr/bin/ksh ksh /bin/mksh /usr/bin/mksh mksh /bin/fish /usr/bin/fish fish; do',
|
|
' if [ -x "$s" ]; then exec "$s"; fi',
|
|
' if command -v "$s" >/dev/null 2>&1; then exec "$s"; fi',
|
|
'done',
|
|
'for b in /bin/busybox /usr/bin/busybox busybox /busybox; do',
|
|
' if [ -x "$b" ]; then exec "$b" sh; fi',
|
|
' if command -v "$b" >/dev/null 2>&1; then exec "$b" sh; fi',
|
|
'done',
|
|
'if [ -x /bin/sh ]; then exec /bin/sh; fi',
|
|
'if [ -x /usr/bin/sh ]; then exec /usr/bin/sh; fi',
|
|
'exit 127',
|
|
].join('\n')
|
|
|
|
/** Bootstrap wrappers that run SHELL_PROBE_SCRIPT. */
|
|
const PROBE_WRAPPERS = Object.freeze([
|
|
['/bin/sh', '-c', SHELL_PROBE_SCRIPT],
|
|
['/usr/bin/sh', '-c', SHELL_PROBE_SCRIPT],
|
|
['sh', '-c', SHELL_PROBE_SCRIPT],
|
|
['/bin/bash', '-c', SHELL_PROBE_SCRIPT],
|
|
['bash', '-c', SHELL_PROBE_SCRIPT],
|
|
['/bin/ash', '-c', SHELL_PROBE_SCRIPT],
|
|
['ash', '-c', SHELL_PROBE_SCRIPT],
|
|
['/bin/busybox', 'sh', '-c', SHELL_PROBE_SCRIPT],
|
|
['busybox', 'sh', '-c', SHELL_PROBE_SCRIPT],
|
|
])
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function getSessions(session) {
|
|
let map = session.state.get(SESSIONS_KEY)
|
|
if (!map) {
|
|
map = new Map()
|
|
session.state.set(SESSIONS_KEY, map)
|
|
}
|
|
return map
|
|
}
|
|
|
|
function resolveSessionId(args) {
|
|
return args.sessionId || args.terminalId || args.containerId || 'default'
|
|
}
|
|
|
|
/**
|
|
* Human-readable shell label for logs / client banner.
|
|
* Never include multi-line probe scripts (those dump into the PTY UI).
|
|
* @param {string[]} cmd
|
|
* @returns {string}
|
|
*/
|
|
export function describeShellCmd(cmd) {
|
|
if (!Array.isArray(cmd) || cmd.length === 0) return 'shell'
|
|
const cIdx = cmd.indexOf('-c')
|
|
if (cIdx >= 0) {
|
|
const script = String(cmd[cIdx + 1] || '')
|
|
const isProbe =
|
|
script.includes('for s in') ||
|
|
script.includes('\n') ||
|
|
script.length > 80
|
|
if (isProbe) {
|
|
const launcher = cmd.slice(0, cIdx).join(' ') || cmd[0]
|
|
return `${launcher} (auto)`
|
|
}
|
|
}
|
|
// busybox sh, etc.
|
|
if (cmd.length <= 3) return cmd.join(' ')
|
|
return cmd[0]
|
|
}
|
|
|
|
/**
|
|
* @param {Buffer|Uint8Array} chunk
|
|
*/
|
|
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[][]}
|
|
*/
|
|
export function buildShellCandidates(args = {}) {
|
|
/** @type {string[][]} */
|
|
const out = []
|
|
const seen = new Set()
|
|
|
|
const push = (cmd) => {
|
|
if (!Array.isArray(cmd) || !cmd.length) return
|
|
const key = cmd.join('\0')
|
|
if (seen.has(key)) return
|
|
seen.add(key)
|
|
out.push(cmd)
|
|
}
|
|
|
|
// Explicit user override first (still fall back if it fails)
|
|
if (Array.isArray(args.cmd) && args.cmd.length) {
|
|
push(args.cmd.map(String))
|
|
} else if (typeof args.cmd === 'string' && args.cmd.trim()) {
|
|
push(args.cmd.trim().split(/\s+/))
|
|
} else if (args.shell) {
|
|
push([String(args.shell)])
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
/**
|
|
* Safely tear down a failed probe stream / exec.
|
|
* @param {import('stream').Duplex|null|undefined} stream
|
|
*/
|
|
function destroyStream(stream) {
|
|
if (!stream) return
|
|
try {
|
|
stream.removeAllListeners?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
stream.end?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
stream.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After exec.start(), decide whether the process is a live shell.
|
|
* Docker often creates exec configs for missing binaries; they exit 126/127 immediately.
|
|
*
|
|
* @param {object} exec dockerode Exec
|
|
* @param {import('stream').Duplex} stream
|
|
* @param {number} [timeoutMs]
|
|
* @returns {Promise<{ ok: boolean, exitCode: number|null|undefined, buffered: Buffer[] }>}
|
|
*/
|
|
async function probeShellAlive(
|
|
exec,
|
|
stream,
|
|
timeoutMs = SHELL_PROBE_MS,
|
|
stepMs = SHELL_PROBE_STEP_MS
|
|
) {
|
|
/** @type {Buffer[]} */
|
|
const buffered = []
|
|
let ended = false
|
|
let streamError = null
|
|
|
|
const onData = (chunk) => {
|
|
buffered.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
|
}
|
|
const onEnd = () => {
|
|
ended = true
|
|
}
|
|
const onError = (err) => {
|
|
streamError = err
|
|
ended = true
|
|
}
|
|
|
|
stream.on('data', onData)
|
|
stream.on('end', onEnd)
|
|
stream.on('error', onError)
|
|
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
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const info = await exec.inspect()
|
|
running = Boolean(info.Running)
|
|
exitCode = info.ExitCode
|
|
if (running) {
|
|
// Live process — interactive shell waiting for input
|
|
detachProbeListeners(stream, onData, onEnd, onError)
|
|
return { ok: true, exitCode, buffered }
|
|
}
|
|
// Not running: if it already exited, treat as failure (try next shell)
|
|
if (info.Pid === 0 || exitCode != null || ended) {
|
|
detachProbeListeners(stream, onData, onEnd, onError)
|
|
return { ok: false, exitCode: exitCode ?? (streamError ? -1 : 127), buffered }
|
|
}
|
|
} catch {
|
|
// inspect can race; keep polling
|
|
}
|
|
if (ended && !running) {
|
|
detachProbeListeners(stream, onData, onEnd, onError)
|
|
return { ok: false, exitCode: exitCode ?? 127, buffered }
|
|
}
|
|
await sleep(pollStep)
|
|
}
|
|
|
|
// Timeout: accept if still running or we got output (prompt)
|
|
try {
|
|
const info = await exec.inspect()
|
|
running = Boolean(info.Running)
|
|
exitCode = info.ExitCode
|
|
} catch {
|
|
// ignore
|
|
}
|
|
detachProbeListeners(stream, onData, onEnd, onError)
|
|
if (running || buffered.length > 0) {
|
|
return { ok: true, exitCode, buffered }
|
|
}
|
|
return { ok: false, exitCode: exitCode ?? 127, buffered }
|
|
}
|
|
|
|
function detachProbeListeners(stream, onData, onEnd, onError) {
|
|
try {
|
|
stream.off?.('data', onData) || stream.removeListener?.('data', onData)
|
|
stream.off?.('end', onEnd) || stream.removeListener?.('end', onEnd)
|
|
stream.off?.('close', onEnd) || stream.removeListener?.('close', onEnd)
|
|
stream.off?.('error', onError) || stream.removeListener?.('error', onError)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try shell candidates until one stays running.
|
|
* @param {import('dockerode').Container} container
|
|
* @param {string[][]} candidates
|
|
* @param {boolean} useTty
|
|
* @returns {Promise<{ exec: object, stream: import('stream').Duplex, cmd: string[], buffered: Buffer[] }>}
|
|
*/
|
|
export async function openShellExec(container, candidates, useTty) {
|
|
/** @type {Error|null} */
|
|
let lastErr = null
|
|
const tried = []
|
|
|
|
for (const Cmd of candidates) {
|
|
const label = Cmd.join(' ')
|
|
tried.push(label)
|
|
let stream = null
|
|
try {
|
|
const exec = await container.exec({
|
|
Cmd,
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: useTty,
|
|
Env: ['TERM=xterm-256color', 'COLORTERM=truecolor'],
|
|
})
|
|
stream = await exec.start({
|
|
hijack: true,
|
|
stdin: true,
|
|
Tty: useTty,
|
|
})
|
|
|
|
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(
|
|
`Shell not available: ${label}` +
|
|
(probe.exitCode != null ? ` (exit ${probe.exitCode})` : '')
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Pause until live push handlers are attached so no bytes are dropped
|
|
try {
|
|
stream.pause?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
logger.info('terminal shell selected', { cmd: label })
|
|
return { exec, stream, cmd: Cmd, buffered: probe.buffered }
|
|
} catch (err) {
|
|
lastErr = err instanceof Error ? err : new Error(String(err))
|
|
destroyStream(stream)
|
|
logger.debug('terminal shell candidate error', {
|
|
cmd: label,
|
|
error: lastErr.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
const detail = tried.length ? ` Tried: ${tried.slice(0, 12).join(' · ')}${tried.length > 12 ? ' …' : ''}` : ''
|
|
throw (
|
|
lastErr ||
|
|
new Error(
|
|
`No working shell found in container.${detail} Install bash/sh or set startTerminal cmd.`
|
|
)
|
|
)
|
|
}
|
|
|
|
export function registerTerminalHandlers(session) {
|
|
session.respond('startTerminal', async (args) => {
|
|
const containerId = args.containerId
|
|
if (!containerId) throw new Error('containerId required')
|
|
|
|
const sessions = getSessions(session)
|
|
const sessionId = resolveSessionId(args)
|
|
|
|
// Replace any prior PTY for this exact session id (re-entry / restart).
|
|
// Explicit unique sessionIds (details-*, popout-*) may coexist on the same
|
|
// container so pop-out windows stay alive while the in-app tab reopens.
|
|
if (sessions.has(sessionId)) {
|
|
endOne(sessions, sessionId)
|
|
}
|
|
// Legacy clients key the session by containerId only — clear leftovers for
|
|
// that container so re-open is clean. Explicit multi-session ids skip this.
|
|
const explicitSession =
|
|
args.sessionId != null &&
|
|
String(args.sessionId) !== '' &&
|
|
String(args.sessionId) !== String(containerId) &&
|
|
String(args.sessionId) !== 'default'
|
|
if (!explicitSession) {
|
|
cleanupTerminalsForContainer(session, containerId)
|
|
}
|
|
|
|
const useTty = args.tty !== false
|
|
const shellCandidates = buildShellCandidates(args)
|
|
const container = docker.getContainer(containerId)
|
|
|
|
const { exec, stream, cmd, buffered } = await openShellExec(
|
|
container,
|
|
shellCandidates,
|
|
useTty
|
|
)
|
|
|
|
const entry = {
|
|
containerId,
|
|
exec,
|
|
stream,
|
|
sessionId,
|
|
tty: useTty,
|
|
cmd,
|
|
}
|
|
sessions.set(sessionId, entry)
|
|
session.state.set('terminal', entry)
|
|
|
|
const pushOut = (chunk, isErr = false) => {
|
|
const channel = isErr ? Pushes.terminalErrorOutput : Pushes.terminalOutput
|
|
const type = isErr ? 'terminalErrorOutput' : 'terminalOutput'
|
|
try {
|
|
session.push(channel, {
|
|
type,
|
|
containerId,
|
|
sessionId,
|
|
data: toBase64(chunk),
|
|
encoding: 'base64',
|
|
})
|
|
} catch (e) {
|
|
logger.debug('terminal push failed', { error: e.message })
|
|
}
|
|
}
|
|
|
|
// Flush any prompt bytes captured during the probe window, then live stream
|
|
for (const chunk of buffered) {
|
|
pushOut(chunk, false)
|
|
}
|
|
|
|
if (useTty) {
|
|
// Raw PTY — do NOT demux (would corrupt ANSI / binary)
|
|
stream.on('data', (chunk) => pushOut(chunk, false))
|
|
} else {
|
|
const stdout = new PassThrough()
|
|
const stderr = new PassThrough()
|
|
container.modem.demuxStream(stream, stdout, stderr)
|
|
stdout.on('data', (chunk) => pushOut(chunk, false))
|
|
stderr.on('data', (chunk) => pushOut(chunk, true))
|
|
}
|
|
try {
|
|
stream.resume?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
stream.on('end', () => {
|
|
sessions.delete(sessionId)
|
|
if (session.state.get('terminal') === entry) session.state.delete('terminal')
|
|
})
|
|
stream.on('error', (err) => {
|
|
logger.error('Terminal stream error', { containerId, error: err.message })
|
|
sessions.delete(sessionId)
|
|
})
|
|
|
|
const cols = Number(args.cols)
|
|
const rows = Number(args.rows)
|
|
if (cols > 0 && rows > 0) {
|
|
try {
|
|
await exec.resize({ h: rows, w: cols })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const shellLabel = describeShellCmd(cmd)
|
|
logger.info('Terminal session started', {
|
|
containerId,
|
|
sessionId,
|
|
tty: useTty,
|
|
shell: shellLabel,
|
|
peer: session.id.slice(0, 12),
|
|
})
|
|
return {
|
|
success: true,
|
|
message: `Terminal started for ${containerId}`,
|
|
sessionId,
|
|
containerId,
|
|
tty: useTty,
|
|
shell: shellLabel,
|
|
cmd: Array.isArray(cmd) ? cmd.map((p) => (String(p).length > 80 ? `${String(p).slice(0, 40)}…` : p)) : cmd,
|
|
}
|
|
})
|
|
|
|
// Hot path: events (id=0) — keep handler sync/cheap; no audit/metrics in session layer
|
|
session.respond(
|
|
'terminalInput',
|
|
(args) => {
|
|
const sessions = getSessions(session)
|
|
const sessionId = resolveSessionId(args)
|
|
const entry = sessions.get(sessionId) || session.state.get('terminal')
|
|
if (!entry) return null
|
|
if (args.containerId && args.containerId !== entry.containerId) return null
|
|
|
|
let inputData
|
|
if (args.encoding === 'base64') {
|
|
inputData = Buffer.from(args.data || '', 'base64')
|
|
} else {
|
|
// Default utf8 string in JSON (optimal for keystrokes)
|
|
inputData = Buffer.from(args.data || '', 'utf8')
|
|
}
|
|
if (inputData.length && entry.stream && !entry.stream.writableEnded) {
|
|
entry.stream.write(inputData)
|
|
}
|
|
return null
|
|
},
|
|
{ hot: true }
|
|
)
|
|
|
|
session.respond(
|
|
'terminalResize',
|
|
async (args) => {
|
|
const sessions = getSessions(session)
|
|
const sessionId = resolveSessionId(args)
|
|
const entry = sessions.get(sessionId) || session.state.get('terminal')
|
|
if (!entry) return null
|
|
if (args.containerId && args.containerId !== entry.containerId) return null
|
|
const cols = Number(args.cols)
|
|
const rows = Number(args.rows)
|
|
if (cols > 1 && rows > 0) {
|
|
try {
|
|
await entry.exec.resize({ h: rows, w: cols })
|
|
} catch {
|
|
// ignore transient resize errors
|
|
}
|
|
}
|
|
return null
|
|
},
|
|
{ hot: true }
|
|
)
|
|
|
|
session.respond('killTerminal', async (args) => {
|
|
const sessions = getSessions(session)
|
|
const sessionId = args.sessionId || args.terminalId
|
|
// Specific session requested: only kill that one. Never fall through to
|
|
// "current terminal" — a late kill after re-entry must not destroy the new PTY.
|
|
if (sessionId) {
|
|
if (sessions.has(sessionId)) {
|
|
const entry = sessions.get(sessionId)
|
|
endOne(sessions, sessionId)
|
|
return {
|
|
success: true,
|
|
message: `Terminal session ${sessionId} killed`,
|
|
sessionId,
|
|
containerId: entry.containerId,
|
|
}
|
|
}
|
|
return { success: false, message: 'No terminal session found' }
|
|
}
|
|
if (args.containerId) {
|
|
const killed = cleanupTerminalsForContainer(session, args.containerId)
|
|
if (killed) {
|
|
return { success: true, message: `Killed ${killed} terminal(s) for ${args.containerId}` }
|
|
}
|
|
}
|
|
const entry = session.state.get('terminal')
|
|
if (entry) {
|
|
endOne(sessions, entry.sessionId || 'default')
|
|
session.state.delete('terminal')
|
|
return { success: true, message: 'Terminal killed' }
|
|
}
|
|
return { success: false, message: 'No terminal session found' }
|
|
})
|
|
|
|
/** Used by removeContainer / recreate before Docker delete. */
|
|
session._cleanupTerminalsForContainer = (containerId) => {
|
|
cleanupTerminalsForContainer(session, containerId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('../rpc/session.js').PeerSession} session
|
|
* @param {string} containerId
|
|
* @returns {number} sessions ended
|
|
*/
|
|
function cleanupTerminalsForContainer(session, containerId) {
|
|
if (!containerId) return 0
|
|
const sessions = getSessions(session)
|
|
const needle = String(containerId)
|
|
let killed = 0
|
|
for (const [id, entry] of [...sessions.entries()]) {
|
|
const cid = String(entry?.containerId || '')
|
|
if (
|
|
cid === needle ||
|
|
(needle.length >= 12 && cid.startsWith(needle)) ||
|
|
(cid.length >= 12 && needle.startsWith(cid.slice(0, 12)))
|
|
) {
|
|
endOne(sessions, id)
|
|
killed += 1
|
|
}
|
|
}
|
|
const legacy = session.state.get('terminal')
|
|
if (legacy) {
|
|
const cid = String(legacy.containerId || '')
|
|
if (
|
|
cid === needle ||
|
|
(needle.length >= 12 && cid.startsWith(needle)) ||
|
|
(cid.length >= 12 && needle.startsWith(cid.slice(0, 12)))
|
|
) {
|
|
endOne(sessions, legacy.sessionId || 'default')
|
|
session.state.delete('terminal')
|
|
killed += 1
|
|
}
|
|
}
|
|
return killed
|
|
}
|
|
|
|
function endOne(sessions, sessionId) {
|
|
const entry = sessions.get(sessionId)
|
|
if (!entry) return
|
|
try {
|
|
entry.stream.end()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
entry.stream.destroy?.()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
sessions.delete(sessionId)
|
|
}
|
|
|
|
export function endTerminal(session) {
|
|
const sessions = session.state.get(SESSIONS_KEY)
|
|
if (sessions) {
|
|
for (const id of [...sessions.keys()]) endOne(sessions, id)
|
|
}
|
|
session.state.delete('terminal')
|
|
session.state.delete(SESSIONS_KEY)
|
|
}
|
|
|
|
export function cleanupTerminalOnClose(session) {
|
|
endTerminal(session)
|
|
}
|