426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
/**
|
|
* PTY session store and console bridge for web-terminal sessions.
|
|
* It confines child process environment/cwd, buffers output for late subscribers, selects Bun or Node PTY backends, and emits the bridge events consumed by the agent server.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { EventEmitter } from "node:events";
|
|
import path from "node:path";
|
|
import { logger } from "@elizaos/core";
|
|
import { bunTruePtySpawn, isBunRuntime } from "./bun-pty-spawn";
|
|
import type {
|
|
ConsoleBridge,
|
|
SessionExitEvent,
|
|
SessionOutputEvent,
|
|
} from "./pty-contract";
|
|
import type {
|
|
PtyDisposable,
|
|
PtyHandle,
|
|
PtySessionInfo,
|
|
PtySpawn,
|
|
PtySpawnSpec,
|
|
} from "./pty-types";
|
|
|
|
/** Default terminal geometry when a caller doesn't specify one. */
|
|
const DEFAULT_COLS = 120;
|
|
const DEFAULT_ROWS = 30;
|
|
/** Hard cap on concurrent live sessions — a runaway backstop, not a real limit. */
|
|
const DEFAULT_MAX_SESSIONS = 24;
|
|
/** Per-session scrollback retained for clients that subscribe after spawn. */
|
|
const DEFAULT_OUTPUT_BUFFER_CHARS = 200_000;
|
|
/** How long an exited session's record lingers for a final drain before removal. */
|
|
const EXITED_SESSION_TTL_MS = 15_000;
|
|
/** Fallback reap for REPL sessions whose browser/socket disappeared. */
|
|
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
|
|
const SAFE_INHERITED_ENV_KEYS = new Set([
|
|
"PATH",
|
|
"Path",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"COLORTERM",
|
|
"TERM_PROGRAM",
|
|
"TERM_PROGRAM_VERSION",
|
|
"FORCE_COLOR",
|
|
"NO_COLOR",
|
|
"BUN_INSTALL",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_DATA_HOME",
|
|
]);
|
|
|
|
const ALLOWED_SPEC_ENV_KEYS = new Set([
|
|
"ELIZA_CODE_PROVIDER",
|
|
"ELIZA_CODE_CODING_ONLY",
|
|
"OPENAI_API_KEY",
|
|
"OPENAI_BASE_URL",
|
|
"OPENAI_SMALL_MODEL",
|
|
"OPENAI_MEDIUM_MODEL",
|
|
"OPENAI_LARGE_MODEL",
|
|
"CODING_TOOLS_WORKSPACE_ROOTS",
|
|
"SHELL_ALLOWED_DIRECTORY",
|
|
// Vendor-CLI tier (kind claude/codex, gated by PTY_VENDOR_CLI_ENABLED):
|
|
// opaque subscription credential handles the CLIs consume themselves.
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
"CODEX_HOME",
|
|
"TERM",
|
|
"COLORTERM",
|
|
"FORCE_COLOR",
|
|
"NO_COLOR",
|
|
]);
|
|
|
|
function buildPtyEnv(
|
|
specEnv: Record<string, string | undefined> | undefined,
|
|
cwd: string,
|
|
): Record<string, string | undefined> {
|
|
const env: Record<string, string | undefined> = {};
|
|
for (const key of SAFE_INHERITED_ENV_KEYS) {
|
|
const value = process.env[key];
|
|
if (value !== undefined) env[key] = value;
|
|
}
|
|
for (const [key, value] of Object.entries(specEnv ?? {})) {
|
|
if (ALLOWED_SPEC_ENV_KEYS.has(key)) env[key] = value;
|
|
}
|
|
env.PWD = cwd;
|
|
env.TERM = specEnv?.TERM ?? process.env.TERM ?? "xterm-256color";
|
|
return env;
|
|
}
|
|
|
|
/** Resolves the node-pty `spawn` implementation lazily (native, optional dep). */
|
|
export type PtySpawnResolver = () => Promise<PtySpawn>;
|
|
|
|
/** One live session: the PTY handle plus bookkeeping and its I/O subscriptions. */
|
|
interface LiveSession {
|
|
info: PtySessionInfo;
|
|
pty: PtyHandle;
|
|
disposers: PtyDisposable[];
|
|
outputBuffer: string;
|
|
reapTimer?: ReturnType<typeof setTimeout>;
|
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
lastActivityAt: number;
|
|
}
|
|
|
|
/**
|
|
* The default spawn resolver picks the PTY engine by runtime:
|
|
* • Bun → Bun's native truePty (`Bun.spawn({ terminal })`). node-pty's write
|
|
* path is broken under Bun, so we use Bun's own terminal — the same
|
|
* engine the Electrobun host uses.
|
|
* • Node → `@lydell/node-pty` (an optional native dep), which works end to end.
|
|
* Both are adapted to the same {@link PtyHandle}, so the store is engine-agnostic.
|
|
*/
|
|
export const defaultSpawnResolver: PtySpawnResolver = async () => {
|
|
if (isBunRuntime()) {
|
|
return bunTruePtySpawn;
|
|
}
|
|
const mod = (await import("@lydell/node-pty")) as {
|
|
spawn?: PtySpawn;
|
|
default?: { spawn?: PtySpawn };
|
|
};
|
|
const spawn = mod.spawn ?? mod.default?.spawn;
|
|
if (typeof spawn !== "function") {
|
|
throw new Error(
|
|
"PTY support unavailable: @lydell/node-pty did not expose a spawn() function.",
|
|
);
|
|
}
|
|
return spawn;
|
|
};
|
|
|
|
/**
|
|
* The {@link ConsoleBridge} the agent server drives over WebSocket. It emits
|
|
* `session_output` (per-session stdout) and `session_exit`, and forwards
|
|
* `writeRaw`/`resize` to the owning {@link PtySessionStore}. Composed around a
|
|
* plain `EventEmitter` so the on/off/writeRaw/resize signatures match the
|
|
* consumer contract exactly.
|
|
*/
|
|
export class PtyConsoleBridge implements ConsoleBridge {
|
|
private readonly emitter = new EventEmitter();
|
|
private store: PtySessionStore | null = null;
|
|
|
|
constructor() {
|
|
// Sessions can outnumber the default listener cap when many terminals are
|
|
// open; raise it so Node doesn't warn about a "leak" that is intentional.
|
|
this.emitter.setMaxListeners(0);
|
|
}
|
|
|
|
/** Wired once by the store that owns this bridge (avoids a constructor cycle). */
|
|
attachStore(store: PtySessionStore): void {
|
|
this.store = store;
|
|
}
|
|
|
|
on(event: string, listener: (...args: unknown[]) => void): void {
|
|
this.emitter.on(event, listener);
|
|
}
|
|
|
|
off(event: string, listener: (...args: unknown[]) => void): void {
|
|
this.emitter.off(event, listener);
|
|
}
|
|
|
|
writeRaw(sessionId: string, data: string): void {
|
|
this.store?.write(sessionId, data);
|
|
}
|
|
|
|
resize(sessionId: string, cols: number, rows: number): void {
|
|
this.store?.resize(sessionId, cols, rows);
|
|
}
|
|
|
|
/** Internal: publish a chunk of PTY output for `sessionId`. */
|
|
emitOutput(sessionId: string, data: string): void {
|
|
const payload: SessionOutputEvent = { sessionId, data };
|
|
this.emitter.emit("session_output", payload);
|
|
}
|
|
|
|
/** Internal: publish a session's process exit. */
|
|
emitExit(sessionId: string, exitCode: number | null): void {
|
|
const payload: SessionExitEvent = { sessionId, exitCode };
|
|
this.emitter.emit("session_exit", payload);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Owns the live PTY sessions: spawns them via an injectable resolver, streams
|
|
* their output through the bridge, and routes keystrokes/resizes/kills back to
|
|
* the right process. All cwd's are confined to {@link allowedRoot}.
|
|
*/
|
|
export class PtySessionStore {
|
|
private readonly sessions = new Map<string, LiveSession>();
|
|
private resolvedSpawn: PtySpawn | null = null;
|
|
|
|
constructor(
|
|
readonly bridge: PtyConsoleBridge,
|
|
private readonly resolveSpawn: PtySpawnResolver = defaultSpawnResolver,
|
|
private readonly opts: {
|
|
allowedRoot?: string;
|
|
maxSessions?: number;
|
|
idleTimeoutMs?: number;
|
|
} = {},
|
|
) {
|
|
bridge.attachStore(this);
|
|
}
|
|
|
|
private get maxSessions(): number {
|
|
return this.opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
}
|
|
|
|
private get idleTimeoutMs(): number {
|
|
return this.opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
}
|
|
|
|
/** Number of live (spawned, not-yet-reaped) sessions. */
|
|
get size(): number {
|
|
return this.sessions.size;
|
|
}
|
|
|
|
/**
|
|
* Reject a cwd that escapes the allowed root (defense in depth — the route
|
|
* and spec builder already resolve a safe cwd, but the store is the last gate
|
|
* before spawning a real process).
|
|
*/
|
|
private confineCwd(cwd: string): string {
|
|
const resolved = path.resolve(cwd);
|
|
const root = this.opts.allowedRoot
|
|
? path.resolve(this.opts.allowedRoot)
|
|
: null;
|
|
if (root) {
|
|
const rel = path.relative(root, resolved);
|
|
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
|
return resolved;
|
|
}
|
|
throw new Error(
|
|
`PTY cwd "${resolved}" is outside the allowed root "${root}".`,
|
|
);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/** Spawn a new interactive session and start streaming its output. */
|
|
async start(spec: PtySpawnSpec): Promise<PtySessionInfo> {
|
|
if (this.sessions.size >= this.maxSessions) {
|
|
throw new Error(
|
|
`PTY session limit reached (${this.maxSessions}); stop a session before starting another.`,
|
|
);
|
|
}
|
|
const cwd = this.confineCwd(spec.cwd);
|
|
if (!this.resolvedSpawn) {
|
|
this.resolvedSpawn = await this.resolveSpawn();
|
|
}
|
|
const spawn = this.resolvedSpawn;
|
|
|
|
const env = buildPtyEnv(spec.env, cwd);
|
|
|
|
const pty = spawn(spec.command, spec.args, {
|
|
cwd,
|
|
env,
|
|
name: env.TERM,
|
|
cols: spec.cols ?? DEFAULT_COLS,
|
|
rows: spec.rows ?? DEFAULT_ROWS,
|
|
});
|
|
|
|
const sessionId = randomUUID();
|
|
const info: PtySessionInfo = {
|
|
sessionId,
|
|
command: spec.command,
|
|
args: spec.args,
|
|
cwd,
|
|
label: spec.label,
|
|
kind: spec.kind,
|
|
ownerClientId: spec.ownerClientId,
|
|
pid: pty.pid,
|
|
createdAt: Date.now(),
|
|
exited: false,
|
|
exitCode: undefined,
|
|
};
|
|
|
|
const disposers: PtyDisposable[] = [];
|
|
this.sessions.set(sessionId, {
|
|
info,
|
|
pty,
|
|
disposers,
|
|
outputBuffer: "",
|
|
lastActivityAt: Date.now(),
|
|
});
|
|
this.scheduleIdleReap(sessionId);
|
|
|
|
const onData = pty.onData((data) => {
|
|
this.touch(sessionId);
|
|
this.appendBufferedOutput(sessionId, data);
|
|
this.bridge.emitOutput(sessionId, data);
|
|
});
|
|
if (onData) disposers.push(onData);
|
|
const onExit = pty.onExit(({ exitCode }) => {
|
|
this.handleExit(sessionId, exitCode ?? null);
|
|
});
|
|
if (onExit) disposers.push(onExit);
|
|
|
|
logger.info(
|
|
`[plugin-pty] started session ${sessionId} (${spec.label ?? spec.command}) pid=${pty.pid ?? "?"} cwd=${cwd}`,
|
|
);
|
|
return { ...info };
|
|
}
|
|
|
|
/** Forward a keystroke chunk to the session's PTY. */
|
|
write(sessionId: string, data: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.info.exited) return;
|
|
this.touch(sessionId);
|
|
try {
|
|
session.pty.write(data);
|
|
} catch (err) {
|
|
logger.warn(
|
|
`[plugin-pty] write to session ${sessionId} failed: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Resize the session's PTY. Ignores non-finite/degenerate geometry. */
|
|
resize(sessionId: string, cols: number, rows: number): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.info.exited) return;
|
|
if (
|
|
!Number.isFinite(cols) ||
|
|
!Number.isFinite(rows) ||
|
|
cols < 1 ||
|
|
rows < 1
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
this.touch(sessionId);
|
|
session.pty.resize(Math.floor(cols), Math.floor(rows));
|
|
} catch (err) {
|
|
logger.warn(
|
|
`[plugin-pty] resize of session ${sessionId} failed: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Kill a session's process now and remove its record. */
|
|
async stop(sessionId: string): Promise<void> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
if (session.reapTimer) clearTimeout(session.reapTimer);
|
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
try {
|
|
if (!session.info.exited) session.pty.kill();
|
|
} catch (err) {
|
|
logger.warn(
|
|
`[plugin-pty] kill of session ${sessionId} failed: ${String(err)}`,
|
|
);
|
|
}
|
|
for (const d of session.disposers) {
|
|
try {
|
|
d.dispose();
|
|
} catch {
|
|
// best-effort teardown
|
|
}
|
|
}
|
|
this.sessions.delete(sessionId);
|
|
logger.info(`[plugin-pty] stopped session ${sessionId}`);
|
|
}
|
|
|
|
/** Kill every live session (used on service shutdown). */
|
|
async stopAll(): Promise<void> {
|
|
const ids = [...this.sessions.keys()];
|
|
await Promise.all(ids.map((id) => this.stop(id)));
|
|
}
|
|
|
|
/** Serializable metadata for every live session. */
|
|
list(): PtySessionInfo[] {
|
|
return [...this.sessions.values()].map((s) => ({ ...s.info }));
|
|
}
|
|
|
|
/** Whether a session currently exists (live or draining). */
|
|
has(sessionId: string): boolean {
|
|
return this.sessions.has(sessionId);
|
|
}
|
|
|
|
/** Scrollback captured for late websocket subscribers, or undefined if absent. */
|
|
getBufferedOutput(sessionId: string): string | undefined {
|
|
return this.sessions.get(sessionId)?.outputBuffer;
|
|
}
|
|
|
|
private appendBufferedOutput(sessionId: string, data: string): void {
|
|
if (!data) return;
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
const next = session.outputBuffer + data;
|
|
session.outputBuffer =
|
|
next.length > DEFAULT_OUTPUT_BUFFER_CHARS
|
|
? next.slice(next.length - DEFAULT_OUTPUT_BUFFER_CHARS)
|
|
: next;
|
|
}
|
|
|
|
private handleExit(sessionId: string, exitCode: number | null): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
session.info.exited = true;
|
|
session.info.exitCode = exitCode;
|
|
this.bridge.emitExit(sessionId, exitCode);
|
|
logger.info(
|
|
`[plugin-pty] session ${sessionId} exited (code=${exitCode ?? "null"})`,
|
|
);
|
|
// Keep the record briefly so a just-connected client can drain the tail,
|
|
// then reap it.
|
|
session.reapTimer = setTimeout(() => {
|
|
const still = this.sessions.get(sessionId);
|
|
if (still) {
|
|
for (const d of still.disposers) {
|
|
try {
|
|
d.dispose();
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
if (still.idleTimer) clearTimeout(still.idleTimer);
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
}, EXITED_SESSION_TTL_MS);
|
|
// Don't hold the event loop open for the reap timer.
|
|
session.reapTimer.unref?.();
|
|
}
|
|
|
|
private touch(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.info.exited) return;
|
|
session.lastActivityAt = Date.now();
|
|
this.scheduleIdleReap(sessionId);
|
|
}
|
|
|
|
private scheduleIdleReap(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.info.exited) return;
|
|
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
const timeout = this.idleTimeoutMs;
|
|
if (!Number.isFinite(timeout) || timeout <= 0) return;
|
|
session.idleTimer = setTimeout(() => {
|
|
const current = this.sessions.get(sessionId);
|
|
if (!current || current.info.exited) return;
|
|
if (Date.now() - current.lastActivityAt < timeout) {
|
|
this.scheduleIdleReap(sessionId);
|
|
return;
|
|
}
|
|
logger.warn(
|
|
`[plugin-pty] idle timeout reached for session ${sessionId}; stopping orphaned PTY`,
|
|
);
|
|
void this.stop(sessionId);
|
|
}, timeout);
|
|
session.idleTimer.unref?.();
|
|
}
|
|
}
|