Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions ui/koko/composables/terminal/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,56 @@ export enum TerminalWebSocketProtocol {
Koko = "JMS-KOKO"
}

const MAX_STARTUP_OUTPUT_BYTES = 8 * 1024;

export function createKokoStartupOutputCapture(maxBytes = MAX_STARTUP_OUTPUT_BYTES) {
let chunks: Uint8Array[] = [];
let size = 0;
let ready = false;

const append = (data: Uint8Array) => {
if (ready || !data.byteLength) return;

const chunk = data.byteLength > maxBytes ? data.slice(-maxBytes) : Uint8Array.from(data);
chunks.push(chunk);
size += chunk.byteLength;
while (size > maxBytes) {
const excess = size - maxBytes;
const first = chunks.shift();
if (!first) return;
if (first.byteLength <= excess) {
size -= first.byteLength;
} else {
chunks.unshift(first.slice(excess));
size -= excess;
}
}
};

const markReady = () => {
ready = true;
chunks = [];
size = 0;
};

const take = () => {
if (ready || !size) return "";
const output = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(output);
};

return { append, markReady, take };
}

export function resolveKokoTerminalCloseMessage(startupOutput: string, fallback: string) {
return startupOutput.trim() ? startupOutput : fallback;
}

export interface TerminalIncomingMessage {
id: string;
type: string;
Expand Down
6 changes: 4 additions & 2 deletions ui/koko/composables/terminal/useTerminalMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { HostBridge } from "@jumpserver/connectors-core";
import type { useKokoHostAdapter } from "#koko/host";
import type { Terminal } from "@xterm/xterm";
import type { ComputedRef, Ref } from "vue";
import type { useKokoHostAdapter } from "#koko/host";
import type { useKokoConnectionStore } from "#koko/stores/connection";
import type { useKokoTerminalSettingsStore } from "#koko/stores/terminalSettings";
import type { ClipboardPermission, ClipboardPolicy } from "#koko/types/clipboard";
import type { OnlineUser, SettingConfig, ShareUserOptions, TerminalSessionInfo } from "#koko/types/session";
import type { TerminalCommandEnvelope } from "./envelope";
import type { TerminalIncomingMessage } from "./protocol";
import { markKokoTerminalAiSessionInfoReady } from "./useTerminalAiSessions";
import { HOST_MESSAGE_TYPE, MESSAGE_TYPE, ZMODEM_ACTION_TYPE } from "@jumpserver/connectors-core";
import { applyXtermTheme, terminalTheme } from "../../utils/terminalTheme";
import { formatMessage, updateIcon } from "../../utils/terminalUtils";
Expand All @@ -25,6 +24,7 @@ import {
parseTerminalPayload
} from "./envelope";
import { parseTerminalIncomingMessage } from "./protocol";
import { markKokoTerminalAiSessionInfoReady } from "./useTerminalAiSessions";

export type TerminalMessageHandlers = Partial<Record<string, (message: TerminalIncomingMessage) => void>>;

Expand Down Expand Up @@ -132,6 +132,7 @@ export function createKokoTerminalMessageHandlers(options: {
setClipboardAccess: (permission?: ClipboardPermission | null, policy?: ClipboardPolicy | null) => void;
showInfoOnce: (content: string) => void;
onConnected: (terminalId: string, socket: WebSocket, terminal: Terminal) => void;
onTerminalReady?: () => void;
onZmodemEnd: () => void;
onZmodemAbort: () => void;
onServerClose?: (reason: string) => void;
Expand Down Expand Up @@ -321,6 +322,7 @@ export function createKokoTerminalMessageHandlers(options: {
);
},
[MESSAGE_TYPE.TERMINAL_READY]: () => {
options.onTerminalReady?.();
const tabId = paneId();
if (tabId) markKokoTerminalAiSessionInfoReady(tabId);
},
Expand Down
16 changes: 11 additions & 5 deletions ui/koko/composables/terminal/useTerminalSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { useKokoTerminalSettingsStore } from "#koko/stores/terminalSettings";
import { getDefaultTerminalConfig } from "#koko/utils/guard";
import { applyXtermTheme, appTerminalTheme, syncXtermBackground, terminalTheme } from "#koko/utils/terminalTheme";
import { formatMessage, preprocessInput } from "#koko/utils/terminalUtils";
import { describeTerminalClose } from "./protocol";
import { createKokoStartupOutputCapture, describeTerminalClose, resolveKokoTerminalCloseMessage } from "./protocol";

const isSocketOpen = (socket: WebSocket) => socket.readyState === WebSocket.OPEN;

Expand Down Expand Up @@ -131,6 +131,7 @@ export const useKokoTerminalSocket = () => {
let fitAddon: FitAddon | null = null;
let sessionReady = false;
let serverCloseReason: string | undefined;
const startupOutput = createKokoStartupOutputCapture();
let disposeSocketEvents: (() => void) | undefined;
let hasPendingContainerFit = false;

Expand Down Expand Up @@ -289,6 +290,7 @@ export const useKokoTerminalSocket = () => {
onServerClose: (reason) => {
serverCloseReason = reason;
},
onTerminalReady: startupOutput.markReady,
onZmodemEnd: zmodem.finishDraining,
onZmodemAbort: () => {
zmodem.abortActiveSession();
Expand Down Expand Up @@ -331,6 +333,7 @@ export const useKokoTerminalSocket = () => {
const messageHandler = useKokoTerminalMessageHandler(terminalMessageHandlers, {
onTerminalOutput: (messageTerminalId, data) => {
if (messageTerminalId === Number(terminalId.value)) {
startupOutput.append(data);
binaryHandler.handleBinaryMessage(data);
}
}
Expand Down Expand Up @@ -378,10 +381,13 @@ export const useKokoTerminalSocket = () => {
lastSendTime: lastSendTime.value.toISOString(),
lastReceiveTime: lastReceiveTime.value.toISOString()
});
const message = t(detail.messageKey, {
code: event.code,
reason: detail.reasonKey ? t(detail.reasonKey) : ""
});
const message = resolveKokoTerminalCloseMessage(
startupOutput.take(),
t(detail.messageKey, {
code: event.code,
reason: detail.reasonKey ? t(detail.reasonKey) : ""
})
);
if (paneId) hostAdapter.markSessionDisconnected(paneId, message);
if (!terminalRef.value) return;
terminalRef.value.write("\r\n");
Expand Down
13 changes: 12 additions & 1 deletion ui/koko/tests/terminal/paneConnectionIsolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { MESSAGE_TYPE } from "@jumpserver/connectors-core";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computed, ref, shallowRef } from "vue";
import { createKokoTerminalMessageHandlers } from "#koko/composables/terminal/useTerminalMessageHandler";
import { parseEnvelope, parseJSONPayload } from "#koko/composables/terminal/envelope";
import { createKokoTerminalMessageHandlers } from "#koko/composables/terminal/useTerminalMessageHandler";
import { useKokoConnectionStore } from "#koko/stores/connection";
import {
clearWorkspaceSessionDetails,
Expand All @@ -21,6 +21,7 @@ const { getSessionDetails } = useWorkspaceSessionDetails();

function createPaneHandlers(paneId: string) {
const onServerClose = vi.fn();
const onTerminalReady = vi.fn();
const socket = { send: vi.fn(), close: vi.fn(), readyState: WebSocket.OPEN } as unknown as WebSocket;
const terminal = { cols: 80, rows: 24, write: vi.fn(), focus: vi.fn() } as unknown as Terminal;
const handlers = createKokoTerminalMessageHandlers({
Expand Down Expand Up @@ -52,6 +53,7 @@ function createPaneHandlers(paneId: string) {
setClipboardAccess: vi.fn(),
showInfoOnce: vi.fn(),
onConnected: vi.fn(),
onTerminalReady,
onZmodemEnd: vi.fn(),
onZmodemAbort: vi.fn(),
onServerClose
Expand All @@ -60,6 +62,7 @@ function createPaneHandlers(paneId: string) {
return {
handlers,
onServerClose,
onTerminalReady,
socket,
terminal,
created: (terminalId: number) => {
Expand Down Expand Up @@ -105,6 +108,14 @@ describe("koko pane connection isolation", () => {
);
});

it("notifies the terminal startup capture when Koko reports the session ready", () => {
const pane = createPaneHandlers(PANE_A);

pane.handlers[MESSAGE_TYPE.TERMINAL_READY]!();

expect(pane.onTerminalReady).toHaveBeenCalledOnce();
});

it("keeps each terminal's session, asset and share state on its own pane", () => {
const store = useKokoConnectionStore();
const paneA = createPaneHandlers(PANE_A);
Expand Down
34 changes: 33 additions & 1 deletion ui/koko/tests/terminal/terminalConnection.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { afterEach, expect, it, vi } from "vitest";
import { effectScope, ref } from "vue";
import { parseEnvelope, parseJSONPayload } from "#koko/composables/terminal/envelope";
import { describeTerminalClose } from "#koko/composables/terminal/protocol";
import {
createKokoStartupOutputCapture,
describeTerminalClose,
resolveKokoTerminalCloseMessage
} from "#koko/composables/terminal/protocol";
import { useKokoTerminalHeartbeat } from "#koko/composables/terminal/useTerminalHeartbeat";
import { useKokoTerminalTransport } from "#koko/composables/terminal/useTerminalTransport";

Expand All @@ -26,6 +30,34 @@ it("distinguishes Koko session end, Koko timeout, offline and unexplained closes
expect(describeTerminalClose(abnormal, "", true).source).toBe("koko");
});

it("uses raw startup output for an early close and clears it once ready", () => {
const capture = createKokoStartupOutputCapture();
const encoded = new TextEncoder().encode("sqlserver: 连接被拒绝");

capture.append(encoded.slice(0, 13));
capture.append(encoded.slice(13));
expect(resolveKokoTerminalCloseMessage(capture.take(), "Koko 已结束会话:资产连接已结束")).toBe(
"sqlserver: 连接被拒绝"
);

capture.markReady();
capture.append(new TextEncoder().encode("must not replace the close reason"));
expect(resolveKokoTerminalCloseMessage(capture.take(), "Koko 已结束会话:资产连接已结束")).toBe(
"Koko 已结束会话:资产连接已结束"
);
});

it("keeps only the last 8 KiB of startup output", () => {
const limit = 8 * 1024;
const capture = createKokoStartupOutputCapture(limit);
const encoder = new TextEncoder();

capture.append(encoder.encode("x".repeat(limit)));
capture.append(encoder.encode("tail"));

expect(capture.take()).toBe(`${"x".repeat(limit - 4)}tail`);
});

it("sends heartbeats only on open sockets, updates send time and stops after close", () => {
vi.useFakeTimers();
const socket = { readyState: WebSocket.CONNECTING as number, send: vi.fn() };
Expand Down
Loading