Compare commits

..
Author SHA1 Message Date
陈大猫andCodex db3a9e88e2 fix(terminal): cap WebGL contexts for visible split panes (Fixes #4128) (#4239)
The #4122 budget of 12 only released hidden panes, so 17+ visible
splits still crossed Chromium’s 16-context limit and the 50ms
rebuild avalanched into white panes until the DOM breaker tripped.

Refuse new visible contexts when visible holders fill the cap while
preserving recently hidden holders during the tab-flip grace period.
Cover acquisition limits, missed visibility syncs, and recovery at cap.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 18:21:32 +08:00
陈大猫andCodex 65435c06f5 fix(x11): hide fd-bearing X11 extensions on byte-only forwards (Fixes #4200) (#4238)
Clear QueryExtension present for DRI3 and MIT-SHM on built-in X11 forwards so clients avoid SCM_RIGHTS replies that the SSH byte stream cannot transport. Preserve framing, byte order, sequence isolation and cookie rewriting.

Strip AppImage ARGV0 from local shell PTY environments so zsh preserves ssh argv[0] and Xorg recognizes system SSH forwarding. Add protocol and environment regression tests. Fixes #4200.

Co-authored-by: Codex <codex@openai.com>
2026-11-14 16:28:28 +08:00
26 changed files with 653 additions and 784 deletions

No files matched your search

-17
View File
@@ -1220,23 +1220,6 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
}
}
export function startRestoredSessionConnectionLogImpl(getCtx: AppContextGetter, session: TerminalSession) {
const { addConnectionLog, systemInfoRef } = getCtx();
const { username, hostname } = systemInfoRef.current;
addConnectionLog({
sessionId: session.id,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
protocol: session.protocol || 'ssh',
startTime: Date.now(),
localUsername: username,
localHostname: hostname,
saved: false,
});
}
export async function handleTerminalDataCaptureImpl(getCtx: AppContextGetter, sessionId: string, data: string) {
const { IS_DEV, connectionLogs, selectConnectionLogForTerminalDataCapture, sessions, updateConnectionLog } = getCtx();
{
+2 -12
View File
@@ -104,7 +104,7 @@ import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -1895,18 +1895,8 @@ export function AppSideEffects() {
setActiveTabId('vault');
}, [setActiveTabId]);
const restoredSessionLogsStartedRef = useRef(new Set<string>());
// A restored tab retains its session ID, but its previous ConnectionLog is closed.
// Start the new incarnation's log before updateSessionStatus clears restoreState.
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
const session = sessionByIdRef.current.get(sessionId);
if (session?.restoreState === 'restored-disconnected'
&& (status === 'connecting' || status === 'connected')
&& !restoredSessionLogsStartedRef.current.has(sessionId)) {
restoredSessionLogsStartedRef.current.add(sessionId);
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog, systemInfoRef }), session);
}
updateSessionStatus(sessionId, status);
if (status === 'connected') {
const session = sessionByIdRef.current.get(sessionId);
@@ -1914,7 +1904,7 @@ export function AppSideEffects() {
updateHostLastConnected(session.hostId);
}
}
}, [addConnectionLog, updateSessionStatus, updateHostLastConnected]);
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
// Functional update + identity-preserving updateHosts: only the patched
@@ -1,206 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import React, { useEffect, useRef } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { useSessionState } from '../state/useSessionState';
import { captureClosingTerminalSessions, useTerminalSessionCloseCapture } from '../state/terminalSessionCloseCapture';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { TerminalConnectionProgress } from '../../components/terminal/TerminalConnectionProgress';
import { I18nProvider } from '../i18n/I18nProvider';
import { handleTerminalDataCaptureImpl } from './AppHandlers';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('explicit close persists never-connected logs; runtime remounts leave them open', async (t) => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
try {
for (const scenario of [
{ name: 'tab X before connected, empty buffer', status: 'connecting', data: '' },
{ name: 'failed-dialog close, empty buffer', status: 'disconnected', data: '' },
{ name: 'failed-dialog close preserves buffered diagnostics', status: 'disconnected', data: 'SSH diagnostic\r\n' },
{ name: 'batch close before connected', status: 'connecting', data: '', closeMode: 'batch' },
{ name: 'workspace close before connected', status: 'connecting', data: '', closeMode: 'workspace' },
] as const) {
await t.test(scenario.name, async () => {
let vault!: ReturnType<typeof useVaultState>;
let state!: ReturnType<typeof useSessionState>;
let root: ReactTestRenderer | undefined;
let sessionId!: string;
let logId!: string;
const captures: Promise<void>[] = [];
const capturedData: string[] = [];
function TerminalProbe() {
const hasConnectedRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const buffer = useRef(createConnectionLogBuffer(10000));
useEffect(() => {
buffer.current.append(scenario.data);
return () => { buffer.current.reset(); };
}, []);
useTerminalSessionCloseCapture({
sessionId, enabled: true, hasConnectedRef, terminalDataCapturedRef,
finalizeTerminalLogData: () => buffer.current.toString(),
onTerminalDataCapture: (id, data, options) => {
assert.equal(options.finalized, true);
assert.ok(state.sessions.some((session) => session.id === id), 'capture starts before removal');
terminalDataCapturedRef.current = true;
capturedData.push(data);
const pending = handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: state.sessions,
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), id, data);
captures.push(pending);
return pending;
},
});
return scenario.status === 'disconnected' ? React.createElement(
I18nProvider, { locale: 'en' }, React.createElement(TerminalConnectionProgress, {
status: 'disconnected', error: 'Connection refused', timeLeft: 0,
isCancelling: false, showLogs: false, progressLogs: [],
onCancelConnect: () => {}, onCloseSession: () => state.closeSession(sessionId),
}),
) : null;
}
function Probe({ showTerminal = true }) {
vault = useVaultState();
state = useSessionState({ persistSessionRestore: false });
return showTerminal && state.sessions.some((session) => session.id === sessionId)
? React.createElement(TerminalProbe) : null;
}
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
try {
await mount();
await act(async () => { sessionId = state.createLocalTerminal(); });
await act(async () => {
state.updateSessionStatus(sessionId, scenario.status);
const session = state.sessions.find((candidate) => candidate.id === sessionId)!;
logId = vault.addConnectionLog({
sessionId, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username,
protocol: session.protocol, startTime: Date.now(),
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
await vault.updateConnectionLog(logId, {});
});
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
let otherId!: string;
await act(async () => { otherId = state.createLocalTerminal(); });
await act(async () => { state.createWorkspaceFromSessions(sessionId, otherId, 'right'); });
}
// Recycle the renderer while retaining the session, as with a pane
// remount. Neither disposal nor the replacement runtime may end its log.
await act(async () => { root!.update(React.createElement(Probe, { showTerminal: false })); });
await act(async () => { root!.update(React.createElement(Probe)); });
assert.equal(captures.length, 0);
assert.equal(vault.connectionLogs.find((log) => log.id === logId)?.endTime, undefined);
// Both UI actions converge here. This exercises the actual state
// removal, owning-terminal hook, capture handler and vault persistence.
await act(async () => {
if ('closeMode' in scenario && scenario.closeMode === 'workspace') {
state.closeWorkspace(state.workspaces[0].id);
} else if ('closeMode' in scenario && scenario.closeMode === 'batch') {
state.closeSessions([sessionId]);
} else if (scenario.status === 'disconnected') {
// Click the actual Close button rendered inside the failed dialog.
root!.root.findByType('button').props.onClick();
} else {
state.closeSession(sessionId);
}
await Promise.all(captures);
});
assert.equal(state.sessions.some((session) => session.id === sessionId), false);
assert.deepEqual(capturedData, [scenario.data]);
await act(async () => { state.closeSession(sessionId); });
assert.equal(captures.length, 1, 'removed runtimes must unregister their capture');
await act(async () => { root!.unmount(); });
root = undefined;
await mount();
const persisted = vault.connectionLogs.find((log) => log.id === logId)!;
assert.ok(persisted.endTime, 'a durable endTime must exist even without terminal output');
assert.equal(persisted.terminalData || '', scenario.data);
} finally {
await act(async () => { root?.unmount(); });
}
});
}
} finally {
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('close capture skips non-owning, already connected and already captured runtimes', async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
try {
for (const scenario of [
{ name: 'attached popup / inactive restored placeholder', enabled: false, connected: false, captured: false },
{ name: 'connected terminal uses existing drain path', enabled: true, connected: true, captured: false },
{ name: 'quit already captured the log', enabled: true, connected: false, captured: true },
]) {
await t.test(scenario.name, async () => {
let calls = 0;
let root!: ReactTestRenderer;
function Probe() {
useTerminalSessionCloseCapture({
sessionId: 'guarded', enabled: scenario.enabled,
hasConnectedRef: useRef(scenario.connected),
terminalDataCapturedRef: useRef(scenario.captured),
finalizeTerminalLogData: () => { calls++; return ''; },
onTerminalDataCapture: () => { calls++; },
});
return null;
}
await act(async () => { root = create(React.createElement(Probe)); });
try {
await act(async () => { captureClosingTerminalSessions(['guarded']); });
assert.equal(calls, 0);
} finally {
await act(async () => { root.unmount(); });
}
});
}
} finally {
if (previous) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previous);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
}
});
test('both UI close paths reach session removal and Terminal registers the owning capture', () => {
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
const app = read('./AppView.tsx');
assert.match(app, /onCloseSession=\{closeSession\}/);
assert.match(read('../../components/top-tabs/TopTabItems.tsx'), /onClick=\{\(e\) => onCloseSession\(session\.id, e\)\}/);
const terminal = read('../../components/Terminal.tsx');
assert.match(terminal, /useTerminalSessionCloseCapture\(\{\s*sessionId,\s*enabled: !attachExistingSession && restoreState !== "restored-disconnected",\s*hasConnectedRef,\s*terminalDataCapturedRef,\s*finalizeTerminalLogData,\s*onTerminalDataCapture: handleTerminalDataCaptureOnce,/);
const handler = terminal.slice(terminal.indexOf('const handleCloseDisconnectedSession ='), terminal.indexOf('const handleHostKeyClose ='));
assert.match(handler, /onCloseSession\?\.\(sessionId\)/);
assert.match(read('../../components/TerminalLayer.tsx'), /onCloseSession\(sessionId\)/);
assert.match(read('../../components/terminal/TerminalView.tsx'), /onCloseSession: handleCloseDisconnectedSession/);
assert.match(read('../../components/terminal/TerminalConnectionProgress.tsx'), /onClick=\{onCloseSession\}/);
});
@@ -1,132 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { EventEmitter } from 'node:events';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
import { installBrowserLockFixture } from '../testSupport/browserLockFixture';
import { withVaultImportLock } from '../state/vaultManagedImportLock';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import type { TerminalSession } from '../../domain/models';
import { createConnectionLogBuffer } from '../../components/terminal/connectionLogBuffer';
import { handleTerminalDataCaptureImpl, startRestoredSessionConnectionLogImpl } from './AppHandlers';
import { createPreloadApi } from '../../electron/preload/api.cjs';
import { finalizeSessionLogs } from '../../electron/bridges/sessionLogShutdown.cjs';
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
test('quit persists replay before acknowledgment; restored reconnect keeps the old log and starts a new one', async () => {
const dom = new JSDOM('', { url: 'http://localhost' });
const globals = ['window', 'localStorage', 'CustomEvent', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'];
const previous = new Map(globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import('../state/useVaultState');
let vault!: ReturnType<typeof useVaultState>;
let root: ReactTestRenderer | undefined;
const Probe = () => { vault = useVaultState(); return null; };
const mount = async () => {
await act(async () => { root = create(React.createElement(Probe)); });
for (let attempt = 0; !vault.isInitialized && attempt < 50; attempt++) {
await act(async () => { await tick(); });
}
assert.equal(vault.isInitialized, true);
};
const session: TerminalSession = {
id: 'restorable-tab', hostId: 'host', hostLabel: 'Host', hostname: 'host.test',
username: 'user', protocol: 'ssh', status: 'connected',
};
const systemInfoRef = { current: { username: 'local-user', hostname: 'local-host' } };
const capture = (data: string) => handleTerminalDataCaptureImpl(() => ({
IS_DEV: false, connectionLogs: vault.connectionLogs, sessions: [session],
selectConnectionLogForTerminalDataCapture, updateConnectionLog: vault.updateConnectionLog,
}), session.id, data);
try {
await mount();
let oldId!: string;
await act(async () => {
oldId = vault.addConnectionLog({
sessionId: session.id, hostId: session.hostId, hostLabel: session.hostLabel,
hostname: session.hostname, username: session.username, protocol: 'ssh', startTime: 1,
localUsername: 'local-user', localHostname: 'local-host', saved: false,
});
// Wait for the initial catalog write before blocking the capture write.
await vault.updateConnectionLog(oldId, {});
});
const buffer = createConnectionLogBuffer(10000);
buffer.append('output before quitting\r\n');
const ipcMain = new EventEmitter();
const ipcRenderer = new EventEmitter() as EventEmitter & { send: (channel: string, payload: unknown) => void };
const webContents = { send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
const off = api.onFinalizeSessionLogs(() => capture(buffer.toString()));
let release!: () => void;
const blockedWrite = withVaultImportLock('connection-logs', () => new Promise<void>((resolve) => { release = resolve; }));
await tick();
let acknowledged = false;
await act(async () => {
const quit = finalizeSessionLogs(webContents, { ipcMain }).then((success: boolean) => {
acknowledged = true;
assert.equal(success, true);
});
await tick();
assert.equal(acknowledged, false, 'main must wait for the persistence queue');
release();
await blockedWrite;
await quit;
});
off();
// Remounting reads durable storage. Node has no IndexedDB, so this also
// exercises the existing localStorage replay fallback.
await act(async () => { root?.unmount(); });
await mount();
const oldLog = vault.connectionLogs.find((log) => log.id === oldId)!;
assert.ok(oldLog.endTime);
assert.equal(oldLog.terminalData, 'output before quitting\r\n');
await act(async () => {
startRestoredSessionConnectionLogImpl(() => ({ addConnectionLog: vault.addConnectionLog, systemInfoRef }), {
...session, status: 'disconnected', restoreState: 'restored-disconnected',
});
});
const newLog = vault.connectionLogs.find((log) => log.id !== oldId)!;
assert.equal(newLog.sessionId, session.id);
assert.equal(newLog.endTime, undefined);
assert.equal(newLog.terminalData, undefined);
await act(async () => { await capture('output after reconnect\r\n'); });
await act(async () => { root?.unmount(); });
await mount();
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.terminalData, oldLog.terminalData);
assert.equal(vault.connectionLogs.find((log) => log.id === oldId)?.endTime, oldLog.endTime);
assert.equal(vault.connectionLogs.find((log) => log.id === newLog.id)?.terminalData, 'output after reconnect\r\n');
assert.ok(vault.connectionLogs.find((log) => log.id === newLog.id)?.endTime);
} finally {
await act(async () => { root?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('terminal quit listener drains output and awaits capture without tearing down tabs', () => {
const source = readFileSync(new URL('../../components/Terminal.tsx', import.meta.url), 'utf8');
const start = source.indexOf('onFinalizeSessionLogs?.(async () => {');
assert.ok(start > 0);
const listener = source.slice(start, source.indexOf('const attachHomeWebContentsIdRef', start));
assert.match(listener, /await flushPendingTerminalWritesBeforeHibernate\(term\)/);
assert.match(listener, /await handleTerminalDataCaptureOnce\(sessionId, finalizeTerminalLogData\(\), \{ finalized: true \}\)/);
assert.doesNotMatch(listener, /closeSession|teardown/);
const effects = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
const handler = effects.slice(effects.indexOf('const handleSessionStatusChange ='), effects.indexOf('const handleUpdateHostFromTerminal ='));
assert.ok(handler.indexOf('startRestoredSessionConnectionLogImpl(') < handler.indexOf('updateSessionStatus(sessionId, status)'));
assert.match(handler, /restoredSessionLogsStartedRef\.current\.has\(sessionId\)/);
});
@@ -1,57 +0,0 @@
import { useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
const closeCaptures = new Map<string, Set<() => void>>();
/** Notify owning terminals before explicit session removal, never on runtime cleanup. */
export function captureClosingTerminalSessions(sessionIds: readonly string[]): void {
for (const sessionId of new Set(sessionIds)) {
for (const capture of closeCaptures.get(sessionId) ?? []) capture();
}
}
export function useTerminalSessionCloseCapture({
sessionId,
enabled,
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture,
}: {
sessionId: string;
enabled: boolean;
hasConnectedRef: RefObject<boolean>;
terminalDataCapturedRef: RefObject<boolean>;
finalizeTerminalLogData: () => string;
onTerminalDataCapture: (
sessionId: string,
data: string,
options: { finalized: true },
) => void | Promise<void>;
}): void {
useEffect(() => {
if (!enabled) return;
const capture = () => {
// Connected terminals retain their existing drain/serialize close path.
if (hasConnectedRef.current || terminalDataCapturedRef.current) return;
try {
// A never-connected runtime is disposed synchronously on unmount.
// Finish its log now, even if empty, so persistence can set endTime.
void Promise.resolve(onTerminalDataCapture(
sessionId, finalizeTerminalLogData(), { finalized: true },
)).catch((err: unknown) => {
logger.warn('Failed to capture terminal log on session close', err);
});
} catch (err) {
logger.warn('Failed to capture terminal log on session close', err);
}
};
const captures = closeCaptures.get(sessionId) ?? new Set<() => void>();
closeCaptures.set(sessionId, captures);
captures.add(capture);
return () => {
captures.delete(capture);
if (!captures.size) closeCaptures.delete(sessionId);
};
}, [sessionId, enabled, hasConnectedRef, terminalDataCapturedRef, finalizeTerminalLogData, onTerminalDataCapture]);
}
-3
View File
@@ -75,7 +75,6 @@ import {
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { captureClosingTerminalSessions } from './terminalSessionCloseCapture';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
@@ -604,7 +603,6 @@ export const useSessionState = ({
const closedIds = sessionsRef.current
.filter(session => session.workspaceId === workspaceId)
.map(session => session.id);
captureClosingTerminalSessions(closedIds);
cleanupClosedTerminalSessions(closedIds);
for (const sessionId of closedIds) {
sessionPresentationStore.clearSession(sessionId);
@@ -628,7 +626,6 @@ export const useSessionState = ({
}, [setActiveTabId]);
const closeSessions = useCallback((sessionIds: string[]) => {
captureClosingTerminalSessions(sessionIds);
cleanupClosedTerminalSessions(sessionIds);
for (const sessionId of sessionIds) {
sessionPresentationStore.clearSession(sessionId);
-23
View File
@@ -82,7 +82,6 @@ import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { performSessionLogAction, type SessionLogAction } from "../application/state/sessionLogActions";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
import { useTerminalSessionCloseCapture } from "../application/state/terminalSessionCloseCapture";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
import { useAppearanceChromeStore } from "../application/state/appearanceChromeStore";
import {
@@ -1677,28 +1676,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return captureHandler(capturedSessionId, capturedData);
}, [finalizeTerminalLogData]);
useTerminalSessionCloseCapture({
sessionId,
enabled: !attachExistingSession && restoreState !== "restored-disconnected",
hasConnectedRef,
terminalDataCapturedRef,
finalizeTerminalLogData,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
});
useEffect(() => {
// Attached popups do not own the ConnectionLog; the home terminal captures it.
if (attachExistingSession) return;
return netcattyBridge.get()?.onFinalizeSessionLogs?.(async () => {
if (terminalDataCapturedRef.current || (restoreState === "restored-disconnected" && statusRef.current === "disconnected")) return;
const term = termRef.current;
if (term && !await flushPendingTerminalWritesBeforeHibernate(term)) {
throw new Error("Terminal output did not drain before quit capture");
}
await handleTerminalDataCaptureOnce(sessionId, finalizeTerminalLogData(), { finalized: true });
});
}, [attachExistingSession, restoreState, sessionId, finalizeTerminalLogData, handleTerminalDataCaptureOnce]);
const attachHomeWebContentsIdRef = useRef<number | null | undefined>(undefined);
useEffect(() => {
@@ -89,6 +89,15 @@ test("a visible pane past the cap releases the least recently seen hidden pane",
assert.equal(budget.getActiveCount(), 3);
});
test("new visible panes are refused when visible holders fill the cap", () => {
const { budget } = setup(2);
const members = ["a", "b", "c", "d"].map((name) => new FakeMember(name));
assert.deepEqual(members.map((member) => budget.tryAcquire(member)), [true, true, false, false]);
assert.equal(budget.tryAcquire(members[0]), true, "an existing holder keeps its slot");
assert.equal(budget.getActiveCount(), 2);
assert.deepEqual(members.map((member) => member.releases), [0, 0, 0, 0]);
});
test("recently hidden panes are released only once they stay hidden long enough", () => {
const { clock, budget } = setup(2);
const [a, b] = ["a", "b"].map((name) => new FakeMember(name));
@@ -101,6 +110,8 @@ test("recently hidden panes are released only once they stay hidden long enough"
budget.tryAcquire(new FakeMember("shown"));
assert.equal(a.releases, 0, "a quick tab flip must not lose the renderer");
assert.equal(budget.getActiveCount(), 3);
assert.equal(budget.tryAcquire(new FakeMember("overflow")), false);
assert.equal(budget.getActiveCount(), 3, "one hidden holder allows only one extra context");
clock.advance(2_999);
assert.equal(a.releases, 0);
@@ -110,26 +121,33 @@ test("recently hidden panes are released only once they stay hidden long enough"
assert.equal(clock.pendingTimers, 0);
});
test("a pane hidden after the budget went over is released once eligible", () => {
test("a pane hidden after a grace overshoot is released once eligible", () => {
const { clock, budget } = setup(2);
const [a, b, c] = ["a", "b", "c"].map((name) => new FakeMember(name));
budget.tryAcquire(a);
budget.tryAcquire(b);
budget.tryAcquire(c);
assert.equal(budget.getActiveCount(), 3, "three visible panes all keep WebGL");
assert.equal(budget.tryAcquire(c), false);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
a.hiddenSince = clock.now();
budget.noteHidden(a);
assert.equal(budget.tryAcquire(c), true);
assert.equal(budget.getActiveCount(), 3, "recently hidden holders allow a grace overshoot");
a.hiddenSince = null;
b.hiddenSince = clock.now();
budget.noteHidden(b);
clock.advance(3_000);
assert.equal(b.releases, 1);
assert.equal(a.releases, 0, "a holder shown again keeps its renderer");
assert.equal(budget.getActiveCount(), 2);
});
test("a hidden pane cannot take a context once the budget is full", () => {
const { budget } = setup(2);
budget.tryAcquire(new FakeMember("a"));
const { clock, budget } = setup(2);
const a = new FakeMember("a");
budget.tryAcquire(a);
budget.tryAcquire(new FakeMember("b"));
const hidden = new FakeMember("hidden");
@@ -138,7 +156,13 @@ test("a hidden pane cannot take a context once the budget is full", () => {
assert.equal(budget.getActiveCount(), 2);
hidden.hiddenSince = null;
assert.equal(budget.tryAcquire(hidden), true, "it gets one as soon as it is shown");
assert.equal(budget.tryAcquire(hidden), false, "visible holders still fill the cap");
a.hiddenSince = clock.now();
budget.noteHidden(a);
clock.advance(3_000);
assert.equal(budget.tryAcquire(hidden), true, "a shown pane can replace an eligible hidden holder");
assert.equal(a.releases, 1);
assert.equal(budget.getActiveCount(), 2);
});
test("released and forgotten panes free their slot", () => {
@@ -10,8 +10,9 @@
*
* The budget stays below the browser limit by releasing the renderers of panes
* that have been hidden for a while, least recently seen first. Visible panes
* are never released and always get a context. A released pane builds a new
* renderer when it is shown again. Below the cap nothing changes.
* are never released, but new ones stay on DOM when visible holders fill the
* cap. Recently hidden holders may temporarily exceed the cap during tab flips.
* A released pane can rebuild when shown if the budget permits.
*/
export const MAX_ACTIVE_WEBGL_CONTEXTS = 12;
@@ -31,8 +32,8 @@ export interface WebglContextBudgetMember {
export interface WebglContextBudget {
/**
* Reserve a context for a member about to create its renderer. Visible
* members always get one; hidden members only while the budget has room.
* Reserve a context if the budget has room. Visible members may replace
* hidden holders, allowing a temporary overshoot until their grace expires.
*/
tryAcquire(member: WebglContextBudgetMember): boolean;
/** Give the slot back once the member's renderer is gone. Idempotent. */
@@ -102,7 +103,11 @@ export function createWebglContextBudget(
return {
tryAcquire(member) {
if (active.has(member)) return true;
if (member.getHiddenSince() !== null && active.size >= maxActive) return false;
if (active.size >= maxActive) {
if (member.getHiddenSince() !== null) return false;
const visibleCount = [...active].filter((holder) => holder.getHiddenSince() === null).length;
if (visibleCount >= maxActive) return false;
}
active.add(member);
releaseOverflow();
return true;
@@ -186,20 +186,93 @@ test("panes past the budget give up WebGL after staying hidden, and get it back
assert.equal(budget.getActiveCount(), 2);
});
test("simultaneous visible ensures stay within the context budget", () => {
const { budget, createPane } = setup();
const panes = Array.from({ length: 13 }, () => createPane());
for (const pane of panes) pane.controller.ensure();
assert.deepEqual(panes.map((pane) => pane.loaded), [...Array(12).fill(true), false]);
assert.equal(panes[12].addons.length, 0);
assert.equal(budget.getActiveCount(), 12);
});
test("visible context loss recovers at the cap, including with a refused visible pane", () => {
for (const withOverflow of [false, true]) {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
const overflow = withOverflow ? createPane() : null;
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
panes[0].addons[0].loseContext();
assert.equal(budget.getActiveCount(), 1);
clock.advance(49);
assert.equal(panes[0].loaded, false);
clock.advance(1);
assert.equal(panes[0].loaded, true);
assert.equal(panes[0].addons.length, 2);
assert.equal(panes[1].addons.length, 1);
overflow?.controller.ensure();
assert.equal(overflow?.addons.length ?? 0, 0);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
}
});
test("visible loss stays on DOM if another visible pane takes the freed slot before recovery", () => {
const { clock, budget, createPane } = setup(2);
const panes = [createPane(), createPane(), createPane()];
for (const pane of panes) pane.controller.ensure();
assert.equal(panes[2].loaded, false);
panes[0].addons[0].loseContext();
panes[2].controller.ensure();
assert.equal(panes[2].loaded, true);
clock.advance(50);
assert.equal(panes[0].loaded, false);
assert.deepEqual(panes.map((pane) => pane.addons.length), [1, 1, 1]);
assert.equal(budget.getActiveCount(), 2);
assert.equal(clock.pendingTimers, 0);
assert.ok(panes.every((pane) => !pane.warnings.some((warning) => warning.includes("staying on DOM renderer"))));
});
test("a hidden pane mounted past the budget keeps the DOM renderer until shown", () => {
const { budget, createPane } = setup(1);
createPane().controller.ensure();
const { clock, budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
assert.equal(hidden.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
occupant.setVisible(false);
clock.advance(3_000);
hidden.reveal();
assert.equal(hidden.loaded, true);
assert.equal(occupant.loaded, false);
assert.equal(budget.getActiveCount(), 1);
});
test("a pane on screen is never refused or released, even if a sync was missed", () => {
test("revealing a hidden pane is refused while visible holders fill the budget", () => {
const { budget, createPane } = setup(1);
const occupant = createPane();
occupant.controller.ensure();
const hidden = createPane(false);
hidden.controller.ensure();
hidden.reveal();
assert.equal(hidden.loaded, false);
assert.equal(hidden.addons.length, 0);
assert.equal(occupant.loaded, true);
assert.equal(budget.getActiveCount(), 1);
});
test("a holder on screen is never released, even if a sync was missed", () => {
const { clock, budget, createPane } = setup(1);
const visible = createPane();
visible.controller.ensure();
@@ -210,12 +283,37 @@ test("a pane on screen is never refused or released, even if a sync was missed",
createPane().controller.ensure();
clock.advance(10_000);
assert.equal(visible.loaded, true);
assert.equal(budget.getActiveCount(), 2);
assert.equal(budget.getActiveCount(), 1);
const late = createPane(false);
late.setVisibleWithoutSync(true);
late.controller.ensure();
assert.equal(late.loaded, true);
assert.equal(late.loaded, false);
assert.equal(late.addons.length, 0);
assert.equal(budget.getActiveCount(), 1);
});
test("grace overflow never releases a holder shown again without a visibility sync", () => {
const { clock, budget, createPane } = setup(1);
const holder = createPane();
holder.controller.ensure();
holder.setVisible(false);
const front = createPane();
front.controller.ensure();
assert.equal(budget.getActiveCount(), 2);
holder.setVisibleWithoutSync(true);
clock.advance(3_000);
assert.equal(holder.loaded, true);
assert.equal(front.loaded, true);
assert.equal(holder.addons[0].disposeCalls, 0);
front.addons[0].loseContext();
clock.advance(50);
assert.equal(front.loaded, false, "recovery cannot exceed a cap already filled by visible holders");
assert.equal(front.addons.length, 1);
assert.equal(budget.getActiveCount(), 1);
assert.equal(clock.pendingTimers, 0);
});
test("suspend, dispose and failed loads return the budget slot", () => {
@@ -65,7 +65,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
// isVisible() is the source of truth; hiddenSince only remembers when the
// pane was last seen. Re-reading it at every decision keeps a pane that is
// on screen from being refused or released even if a sync was missed.
// on screen from being released even if a sync was missed.
const readVisibility = () => {
if (!options.isVisible) return;
if (options.isVisible()) hiddenSince = null;
@@ -94,7 +94,7 @@ export function createWebglRendererController<TAddon extends WebglRendererAddon>
const ensure = () => {
if (loaded || circuitBroken || disposed || !options.enabled) return;
// A hidden pane past the budget keeps the DOM renderer until it is shown.
// Panes past the budget keep the DOM renderer until a slot is available.
if (budget && !budget.tryAcquire(budgetMember)) return;
let nextAddon: TAddon | null = null;
try {
+3 -3
View File
@@ -938,9 +938,9 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
return;
}
// Never-connected logs are captured at explicit session close, including
// empty buffers. Runtime cleanup alone must not end an open log.
// Cancel the pending backend boot after giving an immediate
// A boot that never reached "connected" has nothing worth capturing, and
// the teardown below awaits terminal drain plus serialization before it
// closes anything. Cancel the pending backend boot after giving an immediate
// remount a chance to replace this generation, instead of letting it
// finish against a pane that is already gone. Attached popups never own
// the backend session (skip closeSession) but still sync-dispose xterm.
-38
View File
@@ -1,38 +0,0 @@
const { randomUUID } = require("node:crypto");
// Keep the renderer alive until its ConnectionLog replay writes have committed.
function finalizeSessionLogs(webContents, { ipcMain, timeoutMs = 5000 } = {}) {
if (!webContents || webContents.isDestroyed?.() || webContents.isCrashed?.()
|| typeof webContents.send !== "function"
|| typeof ipcMain?.on !== "function"
|| typeof ipcMain?.removeListener !== "function") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const requestId = randomUUID();
const finish = (success) => {
clearTimeout(timer);
try {
ipcMain.removeListener("app:session-logs-finalized", onResult);
} catch {
// IPC teardown must not prevent the window from closing.
}
if (!success) console.warn("[SessionLogs] Renderer did not finalize logs before close");
resolve(success);
};
const onResult = (event, payload) => {
if (event.sender === webContents && payload?.requestId === requestId) {
finish(payload.success === true);
}
};
const timer = setTimeout(() => finish(false), timeoutMs);
try {
ipcMain.on("app:session-logs-finalized", onResult);
webContents.send("app:finalize-session-logs", { requestId });
} catch {
finish(false);
}
});
}
module.exports = { finalizeSessionLogs };
@@ -1,193 +0,0 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { EventEmitter } = require("node:events");
const { finalizeSessionLogs } = require("./sessionLogShutdown.cjs");
const { createPreloadApi } = require("../preload/api.cjs");
const { handleBeforeQuit } = require("../main/appLockLifecycle.cjs");
const { readFileSync } = require("node:fs");
const vm = require("node:vm");
const tick = () => new Promise((resolve) => setImmediate(resolve));
function renderer(ipcMain = new EventEmitter()) {
const ipcRenderer = new EventEmitter();
const webContents = { send: (channel, payload) => ipcRenderer.emit(channel, {}, payload) };
ipcRenderer.send = (channel, payload) => ipcMain.emit(channel, { sender: webContents }, payload);
const api = createPreloadApi({ ipcRenderer });
return { api, ipcMain, ipcRenderer, webContents };
}
test("finalization waits for every terminal replay commit, including a hidden renderer", async () => {
const h = renderer();
const commits = [];
const off = h.api.onFinalizeSessionLogs(() => { throw new Error("unmounted terminal"); });
off();
for (let i = 0; i < 2; i++) {
h.api.onFinalizeSessionLogs(() => new Promise((resolve) => commits.push(resolve)));
}
let done = false;
const pending = finalizeSessionLogs(h.webContents, h).then((result) => { done = true; return result; });
await tick();
assert.equal(done, false);
commits[0](); await tick();
assert.equal(done, false);
commits[1]();
assert.equal(await pending, true);
assert.equal(h.ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("empty renderers acknowledge immediately; failed captures acknowledge failure", async () => {
const h = renderer();
assert.equal(await finalizeSessionLogs(h.webContents, h), true);
h.api.onFinalizeSessionLogs(() => { throw new Error("capture failed"); });
assert.equal(await finalizeSessionLogs(h.webContents, h), false);
});
test("overlapping window close and app quit share the pending replay commit", async () => {
const h = renderer();
let finish;
let captures = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finish = resolve; });
});
const first = finalizeSessionLogs(h.webContents, h);
let secondDone = false;
const second = finalizeSessionLogs(h.webContents, h).then((result) => { secondDone = true; return result; });
await tick();
assert.equal(captures, 1);
assert.equal(secondDone, false);
finish();
assert.equal(await first, true);
assert.equal(await second, true);
});
test("only the queried renderer and current request can acknowledge finalization", async () => {
const ipcMain = new EventEmitter();
let request;
const webContents = { send: (_channel, payload) => { request = payload; } };
let done = false;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((result) => { done = true; return result; });
ipcMain.emit("app:session-logs-finalized", { sender: {} }, { ...request, success: true });
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { requestId: "old", success: true });
await tick();
assert.equal(done, false);
ipcMain.emit("app:session-logs-finalized", { sender: webContents }, { ...request, success: true });
assert.equal(await pending, true);
});
test("unresponsive and destroyed renderers do not strand quit or leak listeners", async () => {
const ipcMain = new EventEmitter();
assert.equal(await finalizeSessionLogs({ send() {} }, { ipcMain, timeoutMs: 1 }), false);
assert.equal(await finalizeSessionLogs({ send() { throw new Error("destroyed"); } }, { ipcMain }), false);
assert.equal(await finalizeSessionLogs({ isDestroyed: () => true }, { ipcMain }), false);
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
});
test("unavailable finalize IPC returns immediately without scheduling timers or sending", async (t) => {
const timer = t.mock.method(globalThis, "setTimeout");
const ipcMain = new EventEmitter();
const send = t.mock.fn();
const on = t.mock.method(ipcMain, "on");
for (const unavailableIpc of [undefined, null, {}, { on() {} }, { removeListener() {} }, { on: true, removeListener() {} }, { on() {}, removeListener: true }]) {
assert.equal(await finalizeSessionLogs({ send }, { ipcMain: unavailableIpc }), false);
}
for (const webContents of [undefined, null, {}, { send: true }]) {
assert.equal(await finalizeSessionLogs(webContents, { ipcMain }), false);
}
assert.equal(timer.mock.callCount(), 0);
assert.equal(on.mock.callCount(), 0);
assert.equal(send.mock.callCount(), 0);
});
for (const failure of ["registration", "send", "cleanup"]) {
test(`finalize IPC ${failure} errors do not reject or leave a pending timeout`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const warn = t.mock.method(console, "warn", () => {});
const ipcMain = new EventEmitter();
const webContents = { send() { throw new Error("send failed"); } };
if (failure === "registration") {
ipcMain.on = (channel, listener) => {
EventEmitter.prototype.on.call(ipcMain, channel, listener);
throw new Error("registration failed");
};
} else if (failure === "cleanup") {
ipcMain.removeListener = (channel, listener) => {
EventEmitter.prototype.removeListener.call(ipcMain, channel, listener);
throw new Error("cleanup failed");
};
}
let result;
const pending = finalizeSessionLogs(webContents, { ipcMain }).then((success) => { result = success; });
await tick();
assert.equal(result, false, "failed setup must finish before the timeout");
await pending;
assert.equal(ipcMain.listenerCount("app:session-logs-finalized"), 0);
assert.equal(warn.mock.callCount(), 1);
t.mock.timers.tick(5000);
assert.equal(warn.mock.callCount(), 1, "the cleared timeout must not run after finalization");
});
}
test("tray quit finalizes only after the dirty guard accepts and waits before quitting", async () => {
const h = renderer();
let finishCapture;
let captures = 0;
let quits = 0;
h.api.onFinalizeSessionLogs(() => {
captures++;
return new Promise((resolve) => { finishCapture = resolve; });
});
let shutdown;
const options = {
event: { preventDefault() {} }, mainWindows: [{ webContents: h.webContents }],
queryDirtyEditors: async () => true,
commitQuit: () => { shutdown = finalizeSessionLogs(h.webContents, h).then(() => { quits++; }); },
dialog: { showMessageBoxSync() {} },
};
await handleBeforeQuit(options);
assert.equal(captures, 0);
await handleBeforeQuit({ ...options, queryDirtyEditors: async () => false });
await tick();
assert.equal(captures, 1);
assert.equal(quits, 0);
finishCapture(); await shutdown;
assert.equal(quits, 1);
});
test("production quit commit awaits renderer logs and file streams before plugins and process exit", async () => {
const source = readFileSync(require.resolve("../main.cjs"), "utf8");
const start = source.indexOf(" const commitQuit = () => {");
const end = source.indexOf('\n app.on("before-quit"', start);
assert.ok(start >= 0 && end > start);
const calls = [];
let finishCapture;
let finishStreams;
const hiddenWindow = { webContents: {} };
const context = {
console,
getWindowManager: () => ({ setIsQuitting: () => calls.push("quitting"), getAppContentWindows: () => [hiddenWindow] }),
electronModule: { ipcMain: {} },
finalizeSessionLogs: (wc) => {
assert.equal(wc, hiddenWindow.webContents);
calls.push("capture");
return new Promise((resolve) => { finishCapture = resolve; });
},
sessionLogStreamManager: { cleanupAll: () => {
calls.push("streams");
return new Promise((resolve) => { finishStreams = resolve; });
} },
appLockController: { setLocked: () => calls.push("lock") },
runPluginShutdown: async () => { calls.push("plugins"); return { timedOut: false }; },
app: { quit: () => calls.push("quit") },
quitGuardChannelBusy: false, quitConfirmed: false,
};
vm.runInNewContext(`${source.slice(start, end)}; commitQuit();`, context);
assert.deepEqual(calls, ["lock", "quitting", "capture"]);
assert.equal(context.quitGuardChannelBusy, true);
finishCapture(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams"]);
assert.equal(context.quitConfirmed, false);
finishStreams(); await tick();
assert.deepEqual(calls, ["lock", "quitting", "capture", "streams", "plugins", "quit"]);
assert.equal(context.quitConfirmed, true);
});
@@ -16,29 +16,10 @@ const {
startStream,
startStreamToFile,
stopStream,
cleanupAll,
} = require("./sessionLogStreamManager.cjs");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
test("quit cleanup finishes all pending file captures before resolving", async () => {
const directory = path.join(TEMP_ROOT, `quit-${Date.now()}`);
try {
for (const sessionId of ["quit-one", "quit-two"]) {
startStreamToFile(sessionId, { filePath: path.join(directory, `${sessionId}.log`), format: "raw" });
appendData(sessionId, `output before quit: ${sessionId}\r\n`);
}
await cleanupAll();
for (const sessionId of ["quit-one", "quit-two"]) {
assert.equal(hasStream(sessionId), false);
assert.equal(fs.readFileSync(path.join(directory, `${sessionId}.log`), "utf8"), `output before quit: ${sessionId}\r\n`);
}
} finally {
await cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+3
View File
@@ -959,6 +959,9 @@ async function buildLocalSessionEnv(
) {
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
const env = { ...buildTerminalProcessEnv(baseEnv), ...(payload?.env || {}) };
// AppImage's ARGV0 makes zsh rename external commands, including ssh. Xorg
// then mistakes ssh's X11 connection for a local client and sends unusable fds.
delete env.ARGV0;
if (platform !== "win32") return env;
const { getPathEnvKey, resolveWindowsLivePath } = require("./ai/shellUtils.cjs");
@@ -37,6 +37,15 @@ const STALE_PATH =
const LIVE_PATH =
"C:\\Users\\me\\.herdr\\packages\\standalone\\releases\\0.9.1-x86_64-pc-windows-msvc;C:\\Windows\\System32";
test("buildLocalSessionEnv strips AppImage ARGV0 without changing the base env", async () => {
const baseEnv = { PATH: "/usr/bin:/bin", ARGV0: "/opt/Netcatty.AppImage" };
const env = await bridge.buildLocalSessionEnv({}, { baseEnv, platform: "linux" });
assert.equal(Object.hasOwn(env, "ARGV0"), false);
assert.equal(env.PATH, baseEnv.PATH);
assert.equal(baseEnv.ARGV0, "/opt/Netcatty.AppImage");
});
test("buildLocalSessionEnv keeps the captured PATH untouched off Windows", async () => {
const env = await bridge.buildLocalSessionEnv(
{ env: { NETCATTY_MARKER: "1" } },
@@ -4,7 +4,6 @@ const {
} = require("./windowsWindowChrome.cjs");
const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const perfTrace = require("../../perfTrace.cjs");
const { finalizeSessionLogs } = require("../sessionLogShutdown.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
@@ -535,12 +534,11 @@ function createMainWindowApi(ctx) {
: false;
})();
Promise.resolve(dirtyEditorQuery)
.then(async (hasDirty) => {
.then((hasDirty) => {
if (hasDirty) {
thisWindowCloseRequested = false;
return;
}
await finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain });
dirtyEditorCloseConfirmed = true;
try {
win.close();
@@ -10,18 +10,10 @@ for (const peer of [true, false]) {
let ownsTabs = true;
let ownerChecks = 0;
let dirtyChecks = 0;
const ipcMain = new EventEmitter();
let finishLogCapture;
let logCaptures = 0;
let closeToTray = false;
class Window extends EventEmitter {
constructor() {
super(); this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send: (channel, payload) => {
if (channel !== "app:finalize-session-logs") return;
logCaptures++;
finishLogCapture = () => ipcMain.emit("app:session-logs-finalized", { sender: this.webContents }, { requestId: payload.requestId, success: true });
} });
this.webContents = Object.assign(new EventEmitter(), { id: 71, isDestroyed: () => this.destroyed, setWindowOpenHandler() {}, openDevTools() {}, send() {} });
}
close() { const event = { prevented: false, preventDefault() { this.prevented = true; } }; this.emit("close", event); if (!event.prevented) { this.destroyed = true; this.emit("closed"); } }
isDestroyed() { return this.destroyed; }
@@ -38,11 +30,7 @@ for (const peer of [true, false]) {
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: (event) => {
if (!closeToTray) return false;
event.preventDefault();
return true;
} }),
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; }, loadWindowState() { return null; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
@@ -55,18 +43,10 @@ for (const peer of [true, false]) {
closeEditorTabsForSource: () => { ownerChecks++; return new Promise((resolve) => { finish = resolve; }); },
queryDirtyEditors: async () => { dirtyChecks++; return false; },
});
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain }, {
const win = await api.createWindow({ BrowserWindow: Window, nativeTheme: {}, app: {}, screen: {}, shell: {}, ipcMain: {} }, {
preload: "/tmp/preload.cjs", devServerUrl: "http://localhost:5173", isDev: true, isMac: true,
electronDir: __dirname, route: peer ? "session-window" : undefined, registerAsMainWindow: !peer,
});
if (!peer) {
closeToTray = true;
win.close(); await tick();
assert.equal(win.destroyed, false);
assert.equal(logCaptures, 0, "hiding to tray must keep the session log live");
assert.equal(ownerChecks, 0);
closeToTray = false;
}
win.close(); win.close();
assert.equal(ownerChecks, 1, "repeat close must share the ownership check");
assert.equal(dirtyChecks, 0, "the short dirty query must not race a detached Save/Cancel prompt");
@@ -74,14 +54,10 @@ for (const peer of [true, false]) {
finish({ success: true, cancelled: true }); await tick();
assert.equal(win.destroyed, false, "Cancel keeps the source save/dock renderer alive");
assert.equal(dirtyChecks, 0);
assert.equal(logCaptures, 0, "Cancel must leave the live log ongoing");
win.close();
ownsTabs = false;
finish({ success: true, cancelled: false }); await tick();
assert.equal(dirtyChecks, 1);
assert.equal(logCaptures, 1);
assert.equal(win.destroyed, false, "renderer must survive until replay persistence acknowledges completion");
finishLogCapture(); await tick();
assert.equal(win.destroyed, true, "close proceeds only after all owned editors settled");
});
}
+190
View File
@@ -0,0 +1,190 @@
const { Transform } = require("node:stream");
const DEFAULT_HIDDEN_EXTENSIONS = Object.freeze(["DRI3", "MIT-SHM"]);
const EMPTY = Buffer.alloc(0);
function readUInt16(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
}
function readUInt32(buffer, offset, littleEndian) {
return littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
}
function pad4(length) {
return Math.ceil(length / 4) * 4;
}
// Retain only the header we need; large request/reply bodies stream through.
function collectHeader(state, length, chunk, offset) {
const count = Math.min(Math.max(0, length - state.header.length), chunk.length - offset);
if (count > 0) {
state.header = Buffer.concat([state.header, chunk.subarray(offset, offset + count)]);
}
return offset + count;
}
class RequestTracker extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
this.sequence = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length && shared.enabled) {
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
offset += count;
this.remaining -= count;
continue;
}
if (this.setup) {
offset = collectHeader(this, 1, chunk, offset);
if (this.header[0] !== 0x6c && this.header[0] !== 0x42) {
shared.enabled = false;
break;
}
shared.littleEndian = this.header[0] === 0x6c;
offset = collectHeader(this, 12, chunk, offset);
if (this.header.length < 12) break;
this.remaining = pad4(readUInt16(this.header, 6, shared.littleEndian))
+ pad4(readUInt16(this.header, 8, shared.littleEndian));
this.header = EMPTY;
this.setup = false;
continue;
}
offset = collectHeader(this, 4, chunk, offset);
if (this.header.length < 4) break;
let words = readUInt16(this.header, 2, shared.littleEndian);
const headerLength = words === 0 ? 8 : 4;
if (headerLength === 8) {
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
words = readUInt32(this.header, 4, shared.littleEndian);
}
const total = words * 4;
if (total < headerLength) {
shared.enabled = false;
break;
}
let name;
const nameOffset = headerLength + 4;
if (this.header[0] === 98 && total >= nameOffset) { // QueryExtension
offset = collectHeader(this, nameOffset, chunk, offset);
if (this.header.length < nameOffset) break;
const nameLength = readUInt16(this.header, headerLength, shared.littleEndian);
if (nameLength <= shared.maxNameLength && nameOffset + pad4(nameLength) <= total) {
offset = collectHeader(this, nameOffset + nameLength, chunk, offset);
if (this.header.length < nameOffset + nameLength) break;
name = this.header.toString("latin1", nameOffset, nameOffset + nameLength);
}
}
this.sequence = (this.sequence + 1) & 0xffff;
// Do not let an unanswered query affect an unrelated request after wrap.
shared.pending.delete(this.sequence);
if (shared.hidden.has(name)) shared.pending.set(this.sequence, name);
this.remaining = total - this.header.length;
this.header = EMPTY;
}
callback(null, chunk);
}
}
class ReplyFilter extends Transform {
constructor(shared) {
super();
this.shared = shared;
this.setup = true;
this.header = EMPTY;
this.remaining = 0;
}
_transform(chunk, _encoding, callback) {
const shared = this.shared;
let offset = 0;
while (offset < chunk.length) {
if (!shared.enabled) {
this.push(this.header);
this.header = EMPTY;
this.push(chunk.subarray(offset));
break;
}
if (this.remaining > 0) {
const count = Math.min(this.remaining, chunk.length - offset);
this.push(chunk.subarray(offset, offset + count));
offset += count;
this.remaining -= count;
continue;
}
offset = collectHeader(this, this.setup ? 8 : 4, chunk, offset);
if (this.header.length < (this.setup ? 8 : 4)) break;
if (this.setup) {
this.remaining = readUInt16(this.header, 6, shared.littleEndian) * 4;
this.setup = false;
} else {
const type = this.header[0];
let total = 32; // Errors and ordinary events are fixed size.
if (type === 1 || (type & 0x7f) === 35) { // Reply or GenericEvent
offset = collectHeader(this, 8, chunk, offset);
if (this.header.length < 8) break;
total += readUInt32(this.header, 4, shared.littleEndian) * 4;
}
if (type === 0 || type === 1) {
const sequence = readUInt16(this.header, 2, shared.littleEndian);
const name = shared.pending.get(sequence);
if (name !== undefined) {
if (type === 1) {
offset = collectHeader(this, 9, chunk, offset);
if (this.header.length < 9) break;
if (this.header[8] !== 0) {
this.header[8] = 0; // QueryExtension.present = False
if (!shared.warned) {
shared.warned = true;
shared.logger.warn?.(`[X11] hiding ${name}: forwarding cannot carry file descriptors`);
}
}
}
shared.pending.delete(sequence);
}
}
this.remaining = total - this.header.length;
}
this.push(this.header);
this.header = EMPTY;
}
callback();
}
_flush(callback) {
callback(null, this.header);
}
}
// SCM_RIGHTS descriptors cannot travel over an SSH byte stream. Hide extensions
// that require them so clients select a supported rendering path instead.
function createX11FdGuard(options = {}) {
const hidden = new Set(options.hiddenExtensions ?? DEFAULT_HIDDEN_EXTENSIONS);
const shared = {
enabled: hidden.size > 0,
hidden,
maxNameLength: Math.max(0, ...Array.from(hidden, (name) => name.length)),
littleEndian: true,
pending: new Map(),
warned: false,
logger: options.logger || console,
};
return { toServer: new RequestTracker(shared), toClient: new ReplyFilter(shared) };
}
module.exports = { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS };
+204
View File
@@ -0,0 +1,204 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { once } = require("node:events");
const { createX11FdGuard, DEFAULT_HIDDEN_EXTENSIONS } = require("./x11FdGuard.cjs");
function writeUInt16(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt16LE" : "writeUInt16BE"](value, offset);
}
function writeUInt32(buffer, value, offset, endian) {
buffer[endian === "l" ? "writeUInt32LE" : "writeUInt32BE"](value, offset);
}
function setupRequest(endian = "l") {
const packet = Buffer.alloc(24, 0xab);
packet[0] = endian.charCodeAt(0);
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
writeUInt16(packet, 5, 8, endian);
return packet;
}
function setupReply(endian = "l", status = 1) {
const packet = Buffer.alloc(20, 0xcd);
packet[0] = status;
writeUInt16(packet, 11, 2, endian);
writeUInt16(packet, 3, 6, endian);
return packet;
}
function request(name, { endian = "l", big = false, opcode = 98 } = {}) {
const nameBytes = Buffer.from(name, "latin1");
const prefix = big ? 12 : 8;
const packet = Buffer.alloc(prefix + Math.ceil(nameBytes.length / 4) * 4);
packet[0] = opcode;
writeUInt16(packet, big ? 0 : packet.length / 4, 2, endian);
if (big) writeUInt32(packet, packet.length / 4, 4, endian);
writeUInt16(packet, nameBytes.length, big ? 8 : 4, endian);
nameBytes.copy(packet, prefix);
return packet;
}
function reply(sequence, { endian = "l", type = 1, present = 1, extra = 0 } = {}) {
const packet = Buffer.alloc(32 + extra, 0xef);
packet[0] = type;
writeUInt16(packet, sequence, 2, endian);
writeUInt32(packet, extra / 4, 4, endian);
packet[8] = present;
return packet;
}
function withoutPresent(packet) {
const copy = Buffer.from(packet);
copy[8] = 0;
return copy;
}
function chunks(buffer, size) {
if (!size) return [buffer];
const result = [];
for (let offset = 0; offset < buffer.length; offset += size) {
result.push(buffer.subarray(offset, offset + size));
}
return result;
}
async function exchange(uplink, downlink, { chunkSize, ...options } = {}) {
const warnings = [];
const guard = createX11FdGuard({ logger: { warn: (message) => warnings.push(message) }, ...options });
const sent = [];
const received = [];
guard.toServer.on("data", (chunk) => sent.push(chunk));
guard.toClient.on("data", (chunk) => received.push(chunk));
const ended = Promise.all([once(guard.toServer, "end"), once(guard.toClient, "end")]);
for (const chunk of chunks(uplink, chunkSize)) guard.toServer.write(chunk);
for (const chunk of chunks(downlink, chunkSize)) guard.toClient.write(chunk);
guard.toServer.end();
guard.toClient.end();
await ended;
assert.deepEqual(Buffer.concat(sent), uplink, "uplink must stay byte-identical");
return { received: Buffer.concat(received), warnings };
}
for (const endian of ["l", "B"]) {
for (const chunkSize of [undefined, 1]) {
test(`default guard hides only DRI3 and MIT-SHM (${endian}, chunks ${chunkSize || "coalesced"})`, async () => {
assert.deepEqual(DEFAULT_HIDDEN_EXTENSIONS, ["DRI3", "MIT-SHM"]);
const names = ["DRI3", "GLX", "MIT-SHM", "RENDER", "dri3", "DRI3-extra", ""];
const replies = names.map((_, index) => reply(index + 1, { endian }));
const uplink = Buffer.concat([setupRequest(endian), ...names.map((name) => request(name, { endian }))]);
const downlink = Buffer.concat([setupReply(endian), ...replies]);
const expected = Buffer.concat([setupReply(endian), ...replies.map((packet, index) => (
index === 0 || index === 2 ? withoutPresent(packet) : packet
))]);
const result = await exchange(uplink, downlink, { chunkSize });
assert.deepEqual(result.received, expected);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /^\[X11\] hiding DRI3/);
assert.deepEqual(downlink, Buffer.concat([setupReply(endian), ...replies]), "input buffers must stay unchanged");
});
}
test(`BIG-REQUESTS use the extended header offsets (${endian})`, async () => {
const queries = [
request("DRI3", { endian, big: true }),
request("MIT-SHM", { endian, big: true }),
request("GLX", { endian, big: true }),
request("DRI3", { endian }),
];
const replies = queries.map((_, index) => reply(index + 1, { endian }));
const result = await exchange(
Buffer.concat([setupRequest(endian), ...queries]),
Buffer.concat([setupReply(endian), ...replies]),
{ chunkSize: 1 },
);
assert.deepEqual(result.received, Buffer.concat([
setupReply(endian), withoutPresent(replies[0]), withoutPresent(replies[1]), replies[2], withoutPresent(replies[3]),
]));
});
}
test("an empty hide list is byte-identical and a custom list replaces the defaults", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM"), request("GLX")]);
const replies = [reply(1), reply(2), reply(3)];
const downlink = Buffer.concat([setupReply(), ...replies]);
const disabled = await exchange(uplink, downlink, { hiddenExtensions: [], chunkSize: 1 });
assert.deepEqual(disabled.received, downlink);
assert.deepEqual(disabled.warnings, []);
const custom = await exchange(uplink, downlink, { hiddenExtensions: ["GLX"], chunkSize: 1 });
assert.deepEqual(custom.received, Buffer.concat([setupReply(), replies[0], replies[1], withoutPresent(replies[2])]));
assert.equal(custom.warnings.length, 1);
assert.match(custom.warnings[0], /hiding GLX/);
});
test("errors, unrelated replies, ordinary events and variable GenericEvents preserve sequence isolation", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("GLX"), request("MIT-SHM")]);
const error = reply(1, { type: 0 });
const afterError = reply(1);
const unrelated = reply(2, { extra: 64 });
const event = reply(3, { type: 12 });
const generic = reply(3, { type: 35, extra: 48 });
const sentGeneric = reply(3, { type: 0x80 | 35, extra: 12 });
const hidden = reply(3);
const repeated = reply(3);
const prefix = [setupReply(), error, afterError, unrelated, event, generic, sentGeneric];
const result = await exchange(uplink, Buffer.concat([...prefix, hidden, repeated]), { chunkSize: 1 });
assert.deepEqual(result.received, Buffer.concat([...prefix, withoutPresent(hidden), repeated]));
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /hiding MIT-SHM/);
});
test("absent extensions and failed queries do not log", async () => {
const uplink = Buffer.concat([setupRequest(), request("DRI3"), request("MIT-SHM")]);
const downlink = Buffer.concat([setupReply(), reply(1, { present: 0 }), reply(2, { type: 0 })]);
const result = await exchange(uplink, downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
assert.deepEqual(result.warnings, []);
});
test("request bodies are skipped without mistaking embedded queries for requests", async () => {
const body = Buffer.alloc(128 * 1024);
request("DRI3").copy(body, 0);
const large = Buffer.alloc(8 + body.length);
large[0] = 18; // ChangeProperty, using BIG-REQUESTS framing
large.writeUInt32LE(large.length / 4, 4);
body.copy(large, 8);
const uplink = Buffer.concat([setupRequest(), large, request("MIT-SHM")]);
const visible = reply(1, { extra: 128 * 1024 });
const hidden = reply(2);
const result = await exchange(uplink, Buffer.concat([setupReply(), visible, hidden]), { chunkSize: 17 });
assert.deepEqual(result.received, Buffer.concat([setupReply(), visible, withoutPresent(hidden)]));
});
test("sequence wrap hides new queries and clears stale entries on reused sequences", async () => {
const noOp = Buffer.from([127, 0, 1, 0]);
const queries = [request("DRI3"), Buffer.concat(Array(65534).fill(noOp)), request("MIT-SHM"), noOp, request("DRI3")];
const stale = reply(1);
const wrapped = reply(0);
const hidden = reply(2);
const result = await exchange(
Buffer.concat([setupRequest(), ...queries]),
Buffer.concat([setupReply(), wrapped, stale, hidden]),
{ chunkSize: 13 },
);
assert.deepEqual(result.received, Buffer.concat([setupReply(), withoutPresent(wrapped), stale, withoutPresent(hidden)]));
});
test("setup failures and truncated headers flush unchanged", async () => {
for (const status of [0, 2]) {
const downlink = setupReply("B", status);
const result = await exchange(setupRequest("B"), downlink, { chunkSize: 1 });
assert.deepEqual(result.received, downlink);
}
const shortSetup = await exchange(setupRequest(), setupReply().subarray(0, 5), { chunkSize: 1 });
assert.deepEqual(shortSetup.received, setupReply().subarray(0, 5));
const partial = Buffer.concat([setupReply(), reply(1).subarray(0, 7)]);
const result = await exchange(Buffer.concat([setupRequest(), request("DRI3")]), partial, { chunkSize: 1 });
assert.deepEqual(result.received, partial);
});
test("non-X11 payloads pass through unchanged", async () => {
const result = await exchange(Buffer.from("remote"), Buffer.from("local"), { chunkSize: 1 });
assert.equal(result.received.toString(), "local");
});
+13 -3
View File
@@ -2,6 +2,7 @@ const net = require("node:net");
const fs = require("node:fs");
const { Transform } = require("node:stream");
const { execFileSync } = require("node:child_process");
const { createX11FdGuard } = require("./x11FdGuard.cjs");
const X11_PORT_BASE = 6000;
const MIT_MAGIC_COOKIE_PROTOCOL = "MIT-MAGIC-COOKIE-1";
@@ -253,11 +254,16 @@ function attachX11Forwarding(conn, options = {}) {
const target = resolveX11DisplaySpec(display, { platform });
const localSocket = createSocket();
let acceptedChannel = null;
let guard = null;
let settled = false;
const cleanup = () => {
if (acceptedChannel) destroyStream(acceptedChannel);
destroyStream(localSocket);
if (guard) {
destroyStream(guard.toServer);
destroyStream(guard.toClient);
}
};
localSocket.once("connect", () => {
@@ -275,15 +281,19 @@ function attachX11Forwarding(conn, options = {}) {
localSocket.on("error", () => cleanup());
acceptedChannel.on("close", () => destroyStream(localSocket));
localSocket.on("close", () => destroyStream(acceptedChannel));
guard = createX11FdGuard({ hiddenExtensions: options.hiddenExtensions, logger });
guard.toServer.on("error", cleanup);
guard.toClient.on("error", cleanup);
const realCookie = resolveLocalAuthCookie();
if (realCookie && fakeCookie) {
acceptedChannel
.pipe(createX11AuthTransform({ fakeCookie, realCookie }))
.pipe(localSocket)
.pipe(acceptedChannel);
.pipe(guard.toServer)
.pipe(localSocket);
} else {
acceptedChannel.pipe(localSocket).pipe(acceptedChannel);
acceptedChannel.pipe(guard.toServer).pipe(localSocket);
}
localSocket.pipe(guard.toClient).pipe(acceptedChannel);
});
localSocket.once("error", (err) => {
+74
View File
@@ -181,6 +181,7 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
attachX11Forwarding(conn, {
display: ":0",
createSocket: () => localSocket,
readLocalAuthCookie: () => null,
sendMessage: (message) => messages.push(message),
platform: "linux",
});
@@ -203,6 +204,79 @@ test("attachX11Forwarding pipes accepted X11 channels to the local display socke
assert.deepEqual(messages, []);
});
for (const rewriteCookie of [false, true]) {
test(`attachX11Forwarding hides fd extensions with cookie rewriting ${rewriteCookie ? "enabled" : "disabled"}`, async (t) => {
const conn = new EventEmitter();
const capturedLocal = [];
const capturedRemote = [];
const warnings = [];
const makeDuplex = (captured) => new Duplex({
read() {},
write(chunk, _encoding, callback) {
captured.push(Buffer.from(chunk));
callback();
},
});
const localSocket = makeDuplex(capturedLocal);
const acceptedChannel = makeDuplex(capturedRemote);
t.after(() => {
localSocket.destroy();
acceptedChannel.destroy();
});
localSocket.connect = () => {
queueMicrotask(() => localSocket.emit("connect"));
return localSocket;
};
const fakeCookie = "11111111111111111111111111111111";
const realCookie = "22222222222222222222222222222222";
attachX11Forwarding(conn, {
display: ":0",
platform: "linux",
createSocket: () => localSocket,
fakeCookie: rewriteCookie ? fakeCookie : undefined,
readLocalAuthCookie: () => rewriteCookie ? Buffer.from(realCookie, "hex") : null,
logger: { warn: (message) => warnings.push(message) },
});
conn.emit("x11", {}, () => acceptedChannel, () => assert.fail("unexpected reject"));
await new Promise((resolve) => setImmediate(resolve));
const setup = buildX11SetupPacket({ cookie: fakeCookie });
const queries = ["DRI3", "MIT-SHM", "GLX"].map((name) => {
const packet = Buffer.alloc(8 + Math.ceil(name.length / 4) * 4);
packet[0] = 98;
packet.writeUInt16LE(packet.length / 4, 2);
packet.writeUInt16LE(name.length, 4);
packet.write(name, 8, "ascii");
return packet;
});
// Fragment the auth packet, coalescing its tail with the first requests.
acceptedChannel.push(setup.subarray(0, 7));
acceptedChannel.push(Buffer.concat([setup.subarray(7), ...queries]));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(Buffer.concat(capturedLocal), Buffer.concat([
buildX11SetupPacket({ cookie: rewriteCookie ? realCookie : fakeCookie }), ...queries,
]));
const setupReply = Buffer.from([1, 0, 11, 0, 0, 0, 0, 0]);
const replies = queries.map((_, index) => {
const packet = Buffer.alloc(32);
packet[0] = 1;
packet.writeUInt16LE(index + 1, 2);
packet[8] = 1;
return packet;
});
const response = Buffer.concat([setupReply, ...replies]);
for (const byte of response) localSocket.push(Buffer.from([byte]));
await new Promise((resolve) => setImmediate(resolve));
const expected = Buffer.from(response);
expected[8 + 8] = 0;
expected[8 + 32 + 8] = 0;
assert.deepEqual(Buffer.concat(capturedRemote), expected);
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^\[X11\] hiding DRI3/);
});
}
test("attachX11Forwarding rejects the remote channel and explains local display failures", async () => {
const conn = new EventEmitter();
const localSocket = new EventEmitter();
+6 -12
View File
@@ -237,7 +237,6 @@ const {
} = require("./main/appLockLifecycle.cjs");
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
const { queryDirtyEditors } = require("./bridges/dirtyEditorGuard.cjs");
const { finalizeSessionLogs } = require("./bridges/sessionLogShutdown.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -1666,17 +1665,7 @@ if (!gotLock) {
}
getWindowManager().setIsQuitting(true);
quitGuardChannelBusy = true;
void (async () => {
try {
await Promise.all(getWindowManager().getAppContentWindows().map((win) => (
finalizeSessionLogs(win.webContents, { ipcMain: electronModule.ipcMain })
)));
await sessionLogStreamManager.cleanupAll();
} catch (error) {
console.warn("[SessionLogs] Shutdown failed; continuing app quit:", error);
}
return runPluginShutdown();
})()
void runPluginShutdown()
.then(({ timedOut }) => {
if (timedOut) console.warn("[Plugins] Shutdown deadline elapsed; continuing app quit");
})
@@ -1738,6 +1727,11 @@ if (!gotLock) {
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
sessionLogStreamManager.cleanupAll();
} catch (err) {
console.warn("Error during session log stream cleanup:", err);
}
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
-17
View File
@@ -7,19 +7,6 @@ const fs = require("node:fs");
const { stageRendererFileToTemp } = require("./stageUploadFile.cjs");
function createPreloadApi(ctx) {
const sessionLogFinalizers = new Set();
let pendingSessionLogFinalization = null;
ctx.ipcRenderer?.on?.("app:finalize-session-logs", async (_event, { requestId }) => {
const pending = pendingSessionLogFinalization ??= Promise.allSettled(
[...sessionLogFinalizers].map((finalize) => Promise.resolve().then(finalize)),
);
const results = await pending;
if (pendingSessionLogFinalization === pending) pendingSessionLogFinalization = null;
ctx.ipcRenderer.send("app:session-logs-finalized", {
requestId,
success: results.every((result) => result.status === "fulfilled"),
});
});
const terminalDataBacklog = ctx.terminalDataBacklog || null;
const displayDataListeners = ctx.displayDataListeners || new Map();
const closedTerminalDataSessions = ctx.closedTerminalDataSessions || null;
@@ -1557,10 +1544,6 @@ function createPreloadApi(ctx) {
reportDirtyEditorsResult: (hasDirty, reason, options) => ipcRenderer.send("app:dirty-editors-result", {
hasDirty, reason, requestId: options?.requestId, pending: options?.pending === true,
}),
onFinalizeSessionLogs: (finalize) => {
sessionLogFinalizers.add(finalize);
return () => sessionLogFinalizers.delete(finalize);
},
// Port Forwarding API
startPortForward: async (options) => {
-1
View File
@@ -299,7 +299,6 @@ declare global {
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
// Each response must echo the request ID, including pending-transfer acknowledgements.
onCheckDirtyEditors?(listener: (request: { requestId: string; deadline: number }) => void): () => void;
onFinalizeSessionLogs?(listener: () => Promise<void>): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers", options?: { requestId?: string; pending?: boolean }): void;