mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 10:56:33 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3a9e88e2 | ||
|
|
65435c06f5 |
No files matched your search
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user