feat: 添加看门狗与重连机制 #7
@@ -82,6 +82,7 @@ export class Xterm {
|
|||||||
private textDecoder = new TextDecoder();
|
private textDecoder = new TextDecoder();
|
||||||
private written = 0;
|
private written = 0;
|
||||||
private pending = 0;
|
private pending = 0;
|
||||||
|
private attachWatchdogTick = 0;
|
||||||
|
|
||||||
private terminal: Terminal;
|
private terminal: Terminal;
|
||||||
private fitAddon = new FitAddon();
|
private fitAddon = new FitAddon();
|
||||||
@@ -113,6 +114,8 @@ export class Xterm {
|
|||||||
private attachCommandSent = false;
|
private attachCommandSent = false;
|
||||||
private attachCommandSentAt?: number;
|
private attachCommandSentAt?: number;
|
||||||
private ptyOutputReceived = false;
|
private ptyOutputReceived = false;
|
||||||
|
private attachWatchdogId?: NodeJS.Timeout;
|
||||||
|
private commandLoadInFlight = false;
|
||||||
constructor(
|
constructor(
|
||||||
private options: XtermOptions,
|
private options: XtermOptions,
|
||||||
private sendCb: () => void
|
private sendCb: () => void
|
||||||
@@ -123,6 +126,10 @@ export class Xterm {
|
|||||||
d.dispose();
|
d.dispose();
|
||||||
}
|
}
|
||||||
this.disposables.length = 0;
|
this.disposables.length = 0;
|
||||||
|
if (this.attachWatchdogId) {
|
||||||
|
clearInterval(this.attachWatchdogId);
|
||||||
|
this.attachWatchdogId = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
@@ -278,6 +285,7 @@ export class Xterm {
|
|||||||
public connect() {
|
public connect() {
|
||||||
this.socket = new WebSocket(this.options.wsUrl, ['tty']);
|
this.socket = new WebSocket(this.options.wsUrl, ['tty']);
|
||||||
this.ptyOutputReceived = false;
|
this.ptyOutputReceived = false;
|
||||||
|
this.attachWatchdogTick = 0;
|
||||||
const { socket, register } = this;
|
const { socket, register } = this;
|
||||||
|
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
@@ -285,11 +293,31 @@ export class Xterm {
|
|||||||
register(addEventListener(socket, 'message', this.onSocketData as EventListener));
|
register(addEventListener(socket, 'message', this.onSocketData as EventListener));
|
||||||
register(addEventListener(socket, 'close', this.onSocketClose as EventListener));
|
register(addEventListener(socket, 'close', this.onSocketClose as EventListener));
|
||||||
register(addEventListener(socket, 'error', () => (this.doReconnect = false)));
|
register(addEventListener(socket, 'error', () => (this.doReconnect = false)));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private startAttachWatchdog() {
|
||||||
|
if (this.attachWatchdogId) return;
|
||||||
|
this.attachWatchdogId = setInterval(() => {
|
||||||
|
this.attachWatchdogTick++;
|
||||||
|
if (this.connectStatus) return;
|
||||||
|
this.tryExecuteAttachCommand();
|
||||||
|
if (!this.connectStatus) this.loadCommandOnce();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bind
|
||||||
|
private stopAttachWatchdog() {
|
||||||
|
if (!this.attachWatchdogId) return;
|
||||||
|
clearInterval(this.attachWatchdogId);
|
||||||
|
this.attachWatchdogId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
private onSocketOpen() {
|
private onSocketOpen() {
|
||||||
console.log('[webTerminal] WebSocket opened, containerStatus:', this.containerStatus, 'connectStatus:', this.connectStatus, 'attachCommandSent:', this.attachCommandSent);
|
console.log('[webTerminal] WebSocket opened, containerStatus:', this.containerStatus, 'connectStatus:', this.connectStatus, 'attachCommandSent:', this.attachCommandSent);
|
||||||
|
console.log('[webTerminal] onSocketOpen - postAttachCommand:', this.postAttachCommand?.length || 0, 'ptyOutputReceived:', this.ptyOutputReceived, 'commandLoadInFlight:', this.commandLoadInFlight);
|
||||||
|
|
||||||
const { textEncoder, terminal, overlayAddon } = this;
|
const { textEncoder, terminal, overlayAddon } = this;
|
||||||
const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows });
|
const msg = JSON.stringify({ AuthToken: this.token, columns: terminal.cols, rows: terminal.rows });
|
||||||
@@ -312,6 +340,9 @@ export class Xterm {
|
|||||||
this.doReconnect = this.reconnect;
|
this.doReconnect = this.reconnect;
|
||||||
this.initListeners();
|
this.initListeners();
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
|
|
||||||
|
// Check if can execute pending command
|
||||||
|
console.log('[webTerminal] onSocketOpen - calling tryExecuteAttachCommand()');
|
||||||
this.tryExecuteAttachCommand();
|
this.tryExecuteAttachCommand();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,31 +376,33 @@ export class Xterm {
|
|||||||
|
|
||||||
@bind
|
@bind
|
||||||
private tryExecuteAttachCommand() {
|
private tryExecuteAttachCommand() {
|
||||||
console.log('[Xterm] tryExecuteAttachCommand called:', {
|
if (this.connectStatus) {
|
||||||
attachCommandSent: this.attachCommandSent,
|
console.log('[Xterm] tryExecuteAttachCommand: already connected');
|
||||||
connectStatus: this.connectStatus,
|
|
||||||
hasCommand: !!(this.postAttachCommand && this.postAttachCommand.length > 0),
|
|
||||||
socketReady: this.socket?.readyState === WebSocket.OPEN,
|
|
||||||
ptyOutputReceived: this.ptyOutputReceived
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.attachCommandSent || this.connectStatus) {
|
|
||||||
console.log('[Xterm] Skipping: command already sent or connected');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.postAttachCommand || this.postAttachCommand.length === 0) {
|
if (!this.postAttachCommand || this.postAttachCommand.length === 0) {
|
||||||
console.log('[Xterm] Skipping: no command available');
|
console.log('[Xterm] tryExecuteAttachCommand: no command available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||||
console.log('[Xterm] Skipping: WebSocket not ready, state:', this.socket?.readyState);
|
console.log('[Xterm] tryExecuteAttachCommand: WebSocket not ready, state:', this.socket?.readyState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.ptyOutputReceived) {
|
if (!this.ptyOutputReceived) {
|
||||||
console.log('[Xterm] Skipping: ttyd not ready yet (waiting for first output)');
|
console.log('[Xterm] tryExecuteAttachCommand: ttyd not ready yet (waiting for first output)');
|
||||||
return; // Wait for TTY readiness confirm via output
|
return; // Wait for TTY readiness confirm via output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果已发送没连上,允许超时后重发
|
||||||
|
const shouldResend =
|
||||||
|
this.attachCommandSent &&
|
||||||
|
this.attachCommandSentAt !== undefined &&
|
||||||
|
Date.now() - this.attachCommandSentAt > 5000;
|
||||||
|
if (this.attachCommandSent && !shouldResend) {
|
||||||
|
console.log('[Xterm] tryExecuteAttachCommand: command already sent, not resending yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cmd = this.postAttachCommand[0];
|
const cmd = this.postAttachCommand[0];
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
console.log('[Xterm] ✅ All conditions met, executing attach command...');
|
console.log('[Xterm] ✅ All conditions met, executing attach command...');
|
||||||
@@ -403,6 +436,12 @@ export class Xterm {
|
|||||||
*/
|
*/
|
||||||
@bind
|
@bind
|
||||||
public loadCommandOnce() {
|
public loadCommandOnce() {
|
||||||
|
if (this.commandLoadInFlight) {
|
||||||
|
console.log('[Xterm] loadCommandOnce: command load already in flight, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[Xterm] loadCommandOnce: starting command load...');
|
||||||
|
this.commandLoadInFlight = true;
|
||||||
this.loadCommandWithRetry(0);
|
this.loadCommandWithRetry(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +454,7 @@ export class Xterm {
|
|||||||
const maxRetries = 5;
|
const maxRetries = 5;
|
||||||
const { params, baseUrl } = this.getUrlParams();
|
const { params, baseUrl } = this.getUrlParams();
|
||||||
|
|
||||||
|
console.log(`[Xterm] loadCommandWithRetry: attempt ${retryCount + 1}/${maxRetries}, fetching command from ${baseUrl}/devcontainer/command?${params}`);
|
||||||
fetch(`${baseUrl}/devcontainer/command?${params}`)
|
fetch(`${baseUrl}/devcontainer/command?${params}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -433,8 +473,10 @@ export class Xterm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 执行连接容器的命令(只执行一次)
|
// 执行连接容器的命令(只执行一次)
|
||||||
|
console.log('[Xterm] Command loaded successfully, attempting to execute...');
|
||||||
this.postAttachCommand = data.command.split('\n');
|
this.postAttachCommand = data.command.split('\n');
|
||||||
this.tryExecuteAttachCommand();
|
this.tryExecuteAttachCommand();
|
||||||
|
this.commandLoadInFlight = false;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(`[Xterm] Error loading command (attempt ${retryCount + 1}/${maxRetries}):`, error);
|
console.error(`[Xterm] Error loading command (attempt ${retryCount + 1}/${maxRetries}):`, error);
|
||||||
@@ -449,12 +491,26 @@ export class Xterm {
|
|||||||
} else {
|
} else {
|
||||||
console.error('[Xterm] Failed to load command after all retries');
|
console.error('[Xterm] Failed to load command after all retries');
|
||||||
// 可以在这里显示错误提示给用户
|
// 可以在这里显示错误提示给用户
|
||||||
|
this.commandLoadInFlight = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@bind
|
@bind
|
||||||
public changeContainerStatus(v: string){
|
public changeContainerStatus(v: string){
|
||||||
|
const oldStatus = this.containerStatus;
|
||||||
this.containerStatus = v;
|
this.containerStatus = v;
|
||||||
|
|
||||||
|
const { options } = this.getUrlParams();
|
||||||
|
if (options.get('type') !== 'docker') return;
|
||||||
|
|
||||||
|
const statusNum = parseInt(v);
|
||||||
|
const oldStatusNum = oldStatus ? parseInt(oldStatus) : -1;
|
||||||
|
|
||||||
|
// 检测到状态 9(已停止):启动 watchdog,等待容器启动
|
||||||
|
if (statusNum === 9 && !this.connectStatus) {
|
||||||
|
console.log('[Xterm] Container is stopped (status 9), starting attach watchdog to wait for startup');
|
||||||
|
this.startAttachWatchdog();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bind
|
@bind
|
||||||
@@ -501,31 +557,34 @@ export class Xterm {
|
|||||||
case Command.OUTPUT:
|
case Command.OUTPUT:
|
||||||
if (!this.ptyOutputReceived) {
|
if (!this.ptyOutputReceived) {
|
||||||
this.ptyOutputReceived = true;
|
this.ptyOutputReceived = true;
|
||||||
console.log('[Xterm] ✅ ttyd is now ready (received first output), attempting to execute attach command');
|
console.log('[Xterm] OUTPUT: ttyd is now ready (received first output), attempting to execute attach command');
|
||||||
this.tryExecuteAttachCommand();
|
this.tryExecuteAttachCommand();
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedData = textDecoder.decode(data);
|
const decodedData = textDecoder.decode(data);
|
||||||
console.log('[ttyd] output:', decodedData);
|
const pure = this.stripAnsi(decodedData);
|
||||||
const compactOutput = decodedData.replace(/\s/g, '');
|
const compactOutput = decodedData.replace(/\s/g, '');
|
||||||
const { options } = this.getUrlParams();
|
const { options } = this.getUrlParams();
|
||||||
if (options.get('type') === 'docker') {
|
if (options.get('type') === 'docker') {
|
||||||
// 保存host的标题
|
// 保存 host的标题
|
||||||
const pureContent = decodedData.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\u0007/g, '').trim();
|
const pureContent = this.stripAnsi(decodedData).trim();
|
||||||
if (this.hostTitle === '' && pureContent.length > 0){
|
if (!this.connectStatus && !this.attachCommandSent && this.hostTitle === '' && pureContent.length > 0) {
|
||||||
this.hostTitle = compactOutput;
|
this.hostTitle = pureContent;
|
||||||
console.log('[Xterm] Host title captured:', this.hostTitle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测是否退出devcontainer,标题等于host的标题
|
// 检测是否退出 devcontainer:
|
||||||
if (this.connectStatus && this.hostTitle && compactOutput.includes(this.hostTitle)){
|
if (this.connectStatus && this.hostTitle && pureContent === this.hostTitle) {
|
||||||
console.log('[Xterm] Detected exit to host shell');
|
|
||||||
this.connectStatus = false;
|
this.connectStatus = false;
|
||||||
this.connectionMessageBuffer = '';
|
this.connectionMessageBuffer = '';
|
||||||
this.attachCommandSent = false;
|
this.attachCommandSent = false;
|
||||||
this.attachCommandSentAt = undefined;
|
this.attachCommandSentAt = undefined;
|
||||||
this.postAttachCommandStatus = false;
|
this.postAttachCommandStatus = false;
|
||||||
this.ptyOutputReceived = false; // 重置 PTY 状态
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ptyOutputReceived = true;
|
||||||
|
} else {
|
||||||
|
this.ptyOutputReceived = false;
|
||||||
|
}
|
||||||
|
this.startAttachWatchdog();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.connectStatus) {
|
if (this.connectStatus) {
|
||||||
@@ -538,24 +597,40 @@ export class Xterm {
|
|||||||
// 未连接状态:缓冲所有输出
|
// 未连接状态:缓冲所有输出
|
||||||
this.connectionMessageBuffer += decodedData;
|
this.connectionMessageBuffer += decodedData;
|
||||||
|
|
||||||
|
// docker exec 失败时(容器不存在/未运行),只重置状态,让 watchdog 定时重试
|
||||||
|
const lower = pure.toLowerCase();
|
||||||
|
const isDockerExecError =
|
||||||
|
lower.includes('error response from daemon') &&
|
||||||
|
(lower.includes('is not running') || lower.includes('no such container') || lower.includes('cannot connect'));
|
||||||
|
if (isDockerExecError) {
|
||||||
|
this.attachCommandSent = false;
|
||||||
|
this.attachCommandSentAt = undefined;
|
||||||
|
this.connectionMessageBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
const successMarker = 'Successfully connected to the devcontainer';
|
const successMarker = 'Successfully connected to the devcontainer';
|
||||||
// 尝试在 buffer 中查找成功标记
|
// 尝试在 buffer 中查找成功标记
|
||||||
const markerIndex = this.connectionMessageBuffer.indexOf(successMarker);
|
const markerIndex = this.connectionMessageBuffer.indexOf(successMarker);
|
||||||
|
|
||||||
if (markerIndex !== -1) {
|
if (markerIndex !== -1) {
|
||||||
console.log('[Xterm] Connection established, flushing buffer.');
|
console.log('[Xterm] ✅ Connection established! Found success marker in buffer');
|
||||||
this.connectStatus = true;
|
this.connectStatus = true;
|
||||||
this.terminal.options.disableStdin = false;
|
this.terminal.options.disableStdin = false;
|
||||||
|
this.stopAttachWatchdog();
|
||||||
|
|
||||||
const validOutput = this.connectionMessageBuffer.substring(markerIndex);
|
const validOutput = this.connectionMessageBuffer.substring(markerIndex);
|
||||||
this.writeData(validOutput);
|
this.writeData(validOutput);
|
||||||
this.connectionMessageBuffer = '';
|
this.connectionMessageBuffer = '';
|
||||||
|
} else {
|
||||||
|
// 调试:如果命令已发送但还没连接成功,检查 buffer 内容
|
||||||
|
if (this.attachCommandSent && !this.connectStatus && this.connectionMessageBuffer.length > 0) {
|
||||||
|
const bufferPreview = this.connectionMessageBuffer.substring(0, 200).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||||
|
console.log('[Xterm] Waiting for connection success... buffer length:', this.connectionMessageBuffer.length, 'preview:', bufferPreview);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.connectionMessageBuffer.length > 20000) {
|
if (this.connectionMessageBuffer.length > 20000) {
|
||||||
console.warn('[Xterm] Buffer overflow protection. Flushing all.');
|
this.connectionMessageBuffer = this.connectionMessageBuffer.slice(-5000);
|
||||||
this.writeData(this.connectionMessageBuffer);
|
|
||||||
this.connectionMessageBuffer = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +652,12 @@ export class Xterm {
|
|||||||
document.title = this.title;
|
document.title = this.title;
|
||||||
break;
|
break;
|
||||||
case Command.SET_PREFERENCES:
|
case Command.SET_PREFERENCES:
|
||||||
|
console.log('[Xterm] Received SET_PREFERENCES, ptyOutputReceived:', this.ptyOutputReceived);
|
||||||
|
if (!this.ptyOutputReceived) {
|
||||||
|
this.ptyOutputReceived = true;
|
||||||
|
console.log('[Xterm] SET_PREFERENCES: ttyd is now ready, attempting to execute attach command');
|
||||||
|
this.tryExecuteAttachCommand();
|
||||||
|
}
|
||||||
this.applyPreferences({
|
this.applyPreferences({
|
||||||
...this.options.clientOptions,
|
...this.options.clientOptions,
|
||||||
...JSON.parse(textDecoder.decode(data)),
|
...JSON.parse(textDecoder.decode(data)),
|
||||||
@@ -756,6 +837,12 @@ export class Xterm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private stripAnsi(input: string): string {
|
private stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\u0007/g, '');
|
// CSI: ESC [ ... command
|
||||||
|
// OSC: ESC ] ... (BEL or ESC \)
|
||||||
|
// BEL: \u0007
|
||||||
|
return input
|
||||||
|
.replace(/\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g, '') // OSC ... BEL or ST
|
||||||
|
.replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '') // CSI
|
||||||
|
.replace(/\u0007/g, ''); // stray BEL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32648
src/html.h
generated
32648
src/html.h
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user