mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5040355156 | ||
|
|
cabdcdf31c | ||
|
|
e2a9e54a90 | ||
|
|
c9d22b3b4d | ||
|
|
2dea078c6d | ||
|
|
446e82180b | ||
|
|
2eaddf0baf | ||
|
|
7b964ead21 | ||
|
|
0ea8fa4943 | ||
|
|
7228af73bf | ||
|
|
3626601eef | ||
|
|
81ec019892 | ||
|
|
1151b31a42 | ||
|
|
20b12a861c | ||
|
|
f70f6e4a5f | ||
|
|
2d9890a92e | ||
|
|
1ef7b026a6 | ||
|
|
f9eded887f | ||
|
|
701e35dac0 |
No files matched your search
@@ -877,6 +877,7 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
@@ -1695,6 +1696,7 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
|
||||
@@ -695,6 +695,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
|
||||
@@ -315,6 +315,30 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
|
||||
let globalToggles = 0;
|
||||
let workspaceToggles = 0;
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
activeTabStore: { getActiveTabId: () => 'orphan-1' },
|
||||
editorTabs: [],
|
||||
orderedTabs: ['orphan-1', 'orphan-2'],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
toEditorTabId: (id: string) => id,
|
||||
sessions: [
|
||||
{ id: 'orphan-1' },
|
||||
{ id: 'orphan-2' },
|
||||
],
|
||||
workspaces: [],
|
||||
canUseGlobalBroadcast: true,
|
||||
toggleBroadcast: () => { workspaceToggles += 1; },
|
||||
toggleGlobalBroadcast: () => { globalToggles += 1; },
|
||||
}), 'broadcast', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(globalToggles, 1);
|
||||
assert.equal(workspaceToggles, 0);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
|
||||
@@ -722,7 +722,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -916,11 +916,16 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -737,6 +737,9 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
|
||||
@@ -207,6 +207,8 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
@@ -238,6 +240,8 @@ export function TerminalHost() {
|
||||
terminalSettings: terminalSettings.terminalSettings,
|
||||
terminalThemeId: terminalSettings.terminalThemeId,
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
|
||||
@@ -70,6 +70,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -147,6 +150,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -175,8 +181,10 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
@@ -193,6 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -68,6 +68,9 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
| 'createSessionFromCloneSource'
|
||||
|
||||
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
protocol: "ssh",
|
||||
} as Host);
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
const batchExistingIdentity = retainedStatus !== undefined;
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const originalGet = netcattyBridge.get;
|
||||
let abort = false;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let childId: string | undefined;
|
||||
const parent: TransferTask = {
|
||||
id: "superseded-compacted-folder", fileName: "folder",
|
||||
sourcePath: "/local/folder", targetPath: "/remote/folder",
|
||||
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
|
||||
targetHostId: "h1", direction: "upload", status: "transferring",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
|
||||
isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await running;
|
||||
netcattyBridge.get = originalGet;
|
||||
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(parent.id);
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
const persisted: TransferTask = {
|
||||
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
|
||||
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
|
||||
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
};
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
// Exercise the real large-history batching branch with one relevant retained row.
|
||||
const childBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => id === persisted.id,
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => [{
|
||||
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
|
||||
}],
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => {
|
||||
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
|
||||
return { size: 10, lastModified: 1 };
|
||||
},
|
||||
startStreamTransfer: async (options: {
|
||||
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
|
||||
}) => {
|
||||
childId = options.transferId;
|
||||
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "started", transferId: childId, lifecycleEpoch: 0,
|
||||
parentTaskId: options.parentTaskId,
|
||||
directoryEntryIndex: options.directoryEntryIndex,
|
||||
directoryEntryIdentity: options.directoryEntryIdentity,
|
||||
});
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
});
|
||||
running = resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
|
||||
onChildUpdate: (child) => batchExistingIdentity
|
||||
? childBatcher.push(child)
|
||||
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
if (newerPause) {
|
||||
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
|
||||
assert.equal(childId, undefined, "newer paused child must not start");
|
||||
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
|
||||
return;
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
|
||||
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.equal((result as { success: boolean }).success, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
|
||||
assert.equal(
|
||||
resolveDirectoryResumeTargetRoot({
|
||||
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
|
||||
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
|
||||
});
|
||||
|
||||
test("resolveHostForTransferEndpoint prefers id then label", () => {
|
||||
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
|
||||
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
|
||||
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const opened: string[] = [];
|
||||
let uploads = 0;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
|
||||
closeSftp: async () => {},
|
||||
statLocal: async () => ({ size: 100, lastModified: 123 }),
|
||||
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
|
||||
});
|
||||
const hosts = scenario === "deleted-id"
|
||||
? [host("different-id", "production", "different.example")]
|
||||
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
|
||||
const result = await resumeTransferWithDedicatedSession({
|
||||
id: "wrong-host-resume", fileName: "deployment.zip",
|
||||
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
|
||||
sourceConnectionId: "local", targetConnectionId: "right-expired",
|
||||
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
|
||||
targetHostLabel: "production", direction: "upload", status: "interrupted",
|
||||
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
|
||||
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
|
||||
}, {hosts, keys: [], identities: []});
|
||||
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
|
||||
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
|
||||
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
|
||||
* label/hostname when the id is stale after vault edits or older task records.
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
return hosts.find((host) => {
|
||||
const matches = hosts.filter((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
}) ?? null;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
onProgress?: (progress: DedicatedResumeProgress) => void,
|
||||
options?: DedicatedResumeOptions,
|
||||
): Promise<DedicatedResumeResult> {
|
||||
// Fresh recovery authorizes only pauses already present at its entry. Keep
|
||||
// row references so a newer cross-window pause invalidates that permission.
|
||||
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
|
||||
.map((child) => [child.id, child]));
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { success: false, error: "Transfer bridge unavailable" };
|
||||
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
|
||||
const streamResult = await bridge.startStreamTransfer!({
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,46 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
|
||||
};
|
||||
}
|
||||
|
||||
test("an older pause response must not resume a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
let calls = 0;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
|
||||
pauseTransfer: async () => {
|
||||
backendPaused = true;
|
||||
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
|
||||
return { success: true, lifecycleEpoch: 2 };
|
||||
},
|
||||
resumeTransfer: async () => {
|
||||
backendPaused = false;
|
||||
return { success: true, lifecycleEpoch: 3 };
|
||||
},
|
||||
}));
|
||||
const first = softPauseTransfer(host, "overlapping-pause");
|
||||
await softPauseTransfer(host, "overlapping-pause");
|
||||
finishFirstPause({ success: true, lifecycleEpoch: 1 });
|
||||
await first;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
|
||||
});
|
||||
|
||||
test("a delayed resume response must not repaint a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
|
||||
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
|
||||
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
|
||||
}));
|
||||
const resume = softResumeTransfer(host, "resume-then-pause");
|
||||
await softPauseTransfer(host, "resume-then-pause");
|
||||
finishResume({ success: true, lifecycleEpoch: 2 });
|
||||
await resume;
|
||||
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
|
||||
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
@@ -245,3 +285,218 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
});
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
for (const newerLocalPause of [false, true]) {
|
||||
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
|
||||
let pauses = 0;
|
||||
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
|
||||
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
|
||||
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(initial, () => ({
|
||||
pauseTransfer: () => ++pauses === 1
|
||||
? new Promise((resolve) => { finish = resolve; })
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
|
||||
}));
|
||||
const pending = softPauseTransfer(host, id);
|
||||
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
|
||||
if (newerLocalPause) await softPauseTransfer(host, id);
|
||||
finish({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await pending;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"] as const) {
|
||||
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: boolean }) => void;
|
||||
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
|
||||
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
|
||||
const id = getTasks()[0].id;
|
||||
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
|
||||
// A global event from another window changes lifecycle, not this window's local control epoch.
|
||||
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
|
||||
finish({ success: false, superseded: true });
|
||||
const result = await operation;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 8);
|
||||
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
|
||||
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
|
||||
});
|
||||
}
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `remote-pause-${isDirectory}`;
|
||||
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
|
||||
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(tasks, () => ({
|
||||
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
|
||||
}));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(isTransferPauseLatched(id), true);
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
test("a child-only remote resume does not release the folder pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const { host } = createHost([
|
||||
{ ...makeTask("mixed-root"), isDirectory: true },
|
||||
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
|
||||
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
|
||||
], () => ({ pauseTransfer: async id => id === "mixed-one"
|
||||
? { success: false, superseded: true, supersededBy: "resume" }
|
||||
: { success: true } }));
|
||||
await softPauseTransfer(host, "mixed-root");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched("mixed-root"), true);
|
||||
assert.equal(isTransferPauseLatched("mixed-two"), true);
|
||||
});
|
||||
|
||||
test("directory resume joins successful and remotely resumed children", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "mixed-success-resume";
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
|
||||
? { success: true, lifecycleEpoch: 3 }
|
||||
: { success: false, superseded: true, supersededBy: "resume" } }));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
});
|
||||
|
||||
for (const newerPause of [false, true]) {
|
||||
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `rejected-folder-resume-${newerPause}`;
|
||||
registerTransferWalk(id);
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
|
||||
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerPause) await softPauseTransfer(host, id);
|
||||
rejectResume(new Error("resume transport disconnected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerPause);
|
||||
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
|
||||
assert.equal(backendPaused, true);
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
|
||||
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `partial-reject-${newerResume}`;
|
||||
const successfulId = `${id}-one`;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let successfulBackendPaused = true;
|
||||
let round = 0;
|
||||
let rollbackCalls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: async childId => {
|
||||
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
|
||||
if (round > 0) return { success: true };
|
||||
return new Promise((_, reject) => { rejectResume = reject; });
|
||||
},
|
||||
pauseTransfer: async childId => {
|
||||
rollbackCalls++;
|
||||
if (childId === successfulId) successfulBackendPaused = true;
|
||||
return { success: true };
|
||||
},
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) { round++; await softResumeTransfer(host, id); }
|
||||
rejectResume(new Error("second child IPC rejected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
|
||||
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
|
||||
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
const rejected = getTasks().find(task => task.id === `${id}-two`);
|
||||
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
|
||||
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const resolvedFailure of [false, true]) {
|
||||
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "remote-partial-reject";
|
||||
const runningId = `${id}-one`;
|
||||
const rejectedId = `${id}-two`;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(runningId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => {
|
||||
if (childId === rejectedId) {
|
||||
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
|
||||
throw new Error("sibling resume rejected");
|
||||
}
|
||||
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
|
||||
return { success: false, superseded: true, supersededBy: "resume" };
|
||||
} }));
|
||||
assert.equal((await softResumeTransfer(host, id)).handled, true);
|
||||
assert.equal(getTasks()[0].status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
|
||||
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
|
||||
assert.equal(isTransferPauseLatched(rejectedId), true);
|
||||
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `resolved-verification-${newerResume}`;
|
||||
registerTransferWalk(id);
|
||||
let finish!: (value: { success: boolean; reason: string }) => void;
|
||||
let calls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) await softResumeTransfer(host, id);
|
||||
finish({ success: false, reason: "Could not verify the source file for resume" });
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerResume);
|
||||
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
|
||||
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(id), !newerResume);
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
} from "./transferControlEpoch";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -28,6 +29,9 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
export type TransferControlBridge = {
|
||||
pauseTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: TransferTask["resumeStage"];
|
||||
@@ -39,12 +43,67 @@ export type TransferControlBridge = {
|
||||
}>;
|
||||
resumeTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
lifecycleEpoch?: number;
|
||||
}>;
|
||||
cancelTransfer?: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
|
||||
return result?.superseded === true;
|
||||
}
|
||||
|
||||
type SupersededControlResult = {
|
||||
success?: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
};
|
||||
|
||||
export function reconcileSupersededControls(
|
||||
host: TransferControlHost,
|
||||
taskId: string,
|
||||
childIds: string[],
|
||||
backendIds: string[],
|
||||
results: ReadonlyArray<SupersededControlResult | undefined>,
|
||||
epoch: number,
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
const task = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
|
||||
if (action === "resume") releaseTransferPauseTree(id, descendants);
|
||||
else latchTransferPauseTree(id, descendants);
|
||||
for (const affectedId of [id, ...descendants]) {
|
||||
try {
|
||||
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
|
||||
else globalSftpTransferScheduler.pause(affectedId);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
const decisions = results.map((result) => result?.superseded
|
||||
? result.supersededBy
|
||||
: result?.success ? requestedAction : undefined);
|
||||
// A child-only resume does not establish a folder-wide decision. Require all
|
||||
// relevant children to agree, or an already-authoritative resumed root row.
|
||||
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
|
||||
apply(taskId, childIds, "resume");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
|
||||
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
|
||||
apply(taskId, childIds, "pause");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
|
||||
} else if (task.status === "transferring" && decisions.includes("resume")) {
|
||||
apply(taskId, [], "resume");
|
||||
}
|
||||
decisions.forEach((action, index) => {
|
||||
if (action) apply(backendIds[index], [], action);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
|
||||
function maxBridgeLifecycleEpoch(
|
||||
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
|
||||
@@ -170,14 +229,23 @@ export async function softPauseTransfer(
|
||||
}
|
||||
|
||||
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
// An obsolete pause can be superseded by another pause or cancellation,
|
||||
// not only by resume. Compensate only while the tree still wants to run.
|
||||
const undoObsoletePause = async (id: string) => {
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
let result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
const maxAttempts = task.isDirectory ? 4 : 16;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (wasSuperseded(result)) return result;
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
if (result.success) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
return { success: false, reason: "Pause superseded by resume" };
|
||||
}
|
||||
@@ -186,6 +254,9 @@ export async function softPauseTransfer(
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
return { success: false, reason: "Pause superseded by newer control" };
|
||||
}
|
||||
result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
}
|
||||
@@ -200,17 +271,22 @@ export async function softPauseTransfer(
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
const result = await pauseOne(id);
|
||||
if (wasSuperseded(result)) return { id, result };
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
const userResumed = !pauseStillCurrent()
|
||||
|| !live
|
||||
|| (live.status !== "paused" && live.status !== "pausing");
|
||||
if (userResumed) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
return { id, result };
|
||||
}));
|
||||
if (!pauseStillCurrent()) return;
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return;
|
||||
}
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!after || after.status === "cancelled") return;
|
||||
if (after.status !== "paused" && after.status !== "pausing") return;
|
||||
@@ -259,6 +335,10 @@ export async function softPauseTransfer(
|
||||
id,
|
||||
result: await pauseOne(id),
|
||||
})));
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return "noop";
|
||||
}
|
||||
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (afterLivePause?.status === "cancelled") {
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
|
||||
if (userAlreadyResumed) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
|
||||
bridgeResults: pauseResults.map((row) => row.result),
|
||||
});
|
||||
for (const id of rollback.bridgeIdsToResume) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
|
||||
const hard = pauseResults.find(({ result }) =>
|
||||
result && !result.success && !isBenignPauseMiss(result.reason),
|
||||
)?.result;
|
||||
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
|
||||
: [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
|
||||
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
|
||||
bumpTransferControlEpoch(taskId);
|
||||
const resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, releaseIds);
|
||||
for (const id of [taskId, ...releaseIds]) {
|
||||
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
|
||||
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
|
||||
try { bridge = host.getBridge(); } catch { bridge = undefined; }
|
||||
|
||||
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
|
||||
));
|
||||
const failedIds = new Set<string>();
|
||||
const results = await Promise.all(resumeIds.map(async (id) => {
|
||||
try {
|
||||
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
|
||||
} catch (error) {
|
||||
// Rejections must pass the same stale-control check as ordinary failures.
|
||||
failedIds.add(id);
|
||||
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
|
||||
}
|
||||
}));
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return { handled: true };
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
|
||||
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
|
||||
|
||||
results.forEach((result, index) => {
|
||||
// A missing stream can belong to queued directory work. Verification and
|
||||
// other explicit failures still own a paused stream and must remain held.
|
||||
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
|
||||
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
|
||||
});
|
||||
if (results.some(wasSuperseded)) {
|
||||
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
|
||||
}
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
|
||||
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
|
||||
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
|
||||
if (failedIds.size > 0) {
|
||||
// A partial resume stays visibly active; hold only failed children. Do not
|
||||
// hide successfully running streams behind a paused root barrier.
|
||||
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
|
||||
for (const id of heldIds) {
|
||||
latchTransferPauseTree(id, []);
|
||||
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!effectiveRunning) return { handled: false, reason: failureReason };
|
||||
}
|
||||
if (results.some(wasSuperseded) && failedIds.size === 0) {
|
||||
return { handled: true };
|
||||
}
|
||||
const walkAlive = isTransferWalkInFlight(taskId);
|
||||
// Directory walk can continue after unlatch without bridge resume on every child.
|
||||
// Single-file must not claim success when every bridge resume fails (stuck bar).
|
||||
if (successIds.length === 0) {
|
||||
if (!effectiveRunning) {
|
||||
if (task.isDirectory && walkAlive) {
|
||||
host.setTasks(paintTreeStatus(
|
||||
host.getTasks(),
|
||||
@@ -491,6 +605,12 @@ export async function softResumeTransfer(
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (failedIds.has(candidate.id)) {
|
||||
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
|
||||
}
|
||||
const candidateResult = results[resumeIds.indexOf(candidate.id)];
|
||||
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
|
||||
|
||||
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
|
||||
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
|
||||
if (candidate.id === taskId) {
|
||||
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
error: failureReason,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
|
||||
@@ -41,6 +41,106 @@ const rootTask = (): TransferTask => ({
|
||||
progressMode: "files",
|
||||
});
|
||||
|
||||
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
|
||||
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
|
||||
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
|
||||
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
const pausedTasksRef = { current: new Set<string>() };
|
||||
let pauseCalls = 0;
|
||||
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
|
||||
let finishTransfer!: (value: { error?: string }) => void;
|
||||
let pauseStarted!: () => void;
|
||||
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
|
||||
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
|
||||
let resumeCalls = 0;
|
||||
let childId = "";
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
// Reproduce a stream arming during the parent's initial pause round.
|
||||
bumpTransferControlEpoch(root.id);
|
||||
latchTransferPauseTree(root.id, [childId]);
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
|
||||
return transferGate;
|
||||
},
|
||||
pauseTransfer: () => {
|
||||
pauseCalls++;
|
||||
pauseStarted();
|
||||
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
|
||||
},
|
||||
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<unknown> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
|
||||
pausedTasksRef,
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
await pauseGate;
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
|
||||
// Another window's resumed event updates rows without bumping this local epoch.
|
||||
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
|
||||
finishPause({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
|
||||
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(isTransferPauseLatched(childId), false);
|
||||
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(pausedTasksRef.current.has(childId), false);
|
||||
return;
|
||||
}
|
||||
bumpTransferControlEpoch(root.id);
|
||||
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
|
||||
else {
|
||||
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
|
||||
resetTransferPauseLatchesForTests();
|
||||
}
|
||||
finishPause({ success: true });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
|
||||
} finally {
|
||||
pausedTasksRef.current.clear();
|
||||
resetTransferPauseLatchesForTests();
|
||||
finishPause?.({ success: true });
|
||||
finishTransfer({});
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
resetTransferControlEpochsForTests();
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test("live folder settles a superseded child whose completed row was compacted", async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
let childId: string | undefined;
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
|
||||
// The winning invocation has already completed; history compaction drops its row.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
} };
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<number> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "live-compacted-owner", cancelledTasksRef,
|
||||
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
|
||||
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
|
||||
assert.equal(result, 0);
|
||||
} finally {
|
||||
cancelledTasksRef.current.add(root.id);
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(root.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
// Await the invoke result — cancel resolves with { error } and may
|
||||
// not fire onComplete/onError after preload clears listeners.
|
||||
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
|
||||
const transferPromise = runTransferAndWaitForOwner(
|
||||
task,
|
||||
() => netcattyBridge.require().startStreamTransfer!(options),
|
||||
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
|
||||
);
|
||||
// Streams that arm after the parent pause round never receive the
|
||||
// initial pauseTransfer. Keep pausing while the folder is latched.
|
||||
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
|
||||
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
|
||||
)
|
||||
) {
|
||||
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
|
||||
const childEpochAtAttempt = getTransferControlEpoch(task.id);
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
|
||||
// Resume won while we were awaiting pause — undo.
|
||||
if (
|
||||
result?.superseded && result.supersededBy === "resume"
|
||||
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
|
||||
reconcileSupersededControls({
|
||||
getTasks: () => transfersRef.current,
|
||||
setTasks: (next) => setTransfers(next),
|
||||
getBridge: () => netcattyBridge.get(),
|
||||
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
|
||||
epochAtAttempt, "pause", rootTaskId);
|
||||
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
|
||||
pausedTasksRef.current.delete(task.id);
|
||||
break;
|
||||
}
|
||||
// Compensate only if the newest decision still wants this file running.
|
||||
// A new pause or cancellation also changes the epoch.
|
||||
if (
|
||||
result?.success
|
||||
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& !isPauseLatched(rootTaskId, task.id)
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.resumeTransfer?.(task.id);
|
||||
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
function child(): TransferTask {
|
||||
return {
|
||||
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
|
||||
};
|
||||
}
|
||||
|
||||
for (const status of ["completed", "failed", "cancelled"] as const) {
|
||||
test(`settlement observation retains exact ${status} before history pruning`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
|
||||
assert.equal(observation.read()?.status, status);
|
||||
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
|
||||
observation.dispose();
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("another file reusing an id and index is not completion evidence", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
|
||||
assert.equal(observation.read(), undefined);
|
||||
observation.dispose();
|
||||
});
|
||||
|
||||
test("a disposed observer receives no later completion", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
observation.dispose();
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
|
||||
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
|
||||
store.upsertTasks([task]);
|
||||
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
|
||||
assert.equal(store.admitTaskRun(next), "ready");
|
||||
assert.equal(store.getTask(task.id)?.status, "transferring");
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
|
||||
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
|
||||
});
|
||||
|
||||
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
|
||||
test(`dispatch cannot revive a retained ${status} row`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([
|
||||
{ ...task, id: "root", parentTaskId: undefined },
|
||||
{ ...task, status },
|
||||
]);
|
||||
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
});
|
||||
}
|
||||
|
||||
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), lifecycleEpoch: 9 };
|
||||
store.upsertTasks([task]);
|
||||
const before = store.getTask(task.id);
|
||||
assert.equal(store.admitTaskRun(task), "ready");
|
||||
assert.equal(store.getTask(task.id), before);
|
||||
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
|
||||
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
|
||||
});
|
||||
|
||||
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
|
||||
latchTransferPause("root");
|
||||
assert.equal(store.admitTaskRun(task), "paused");
|
||||
resetTransferPauseLatchesForTests();
|
||||
markTransferCancelledTree("root", [task.id]);
|
||||
assert.equal(store.admitTaskRun(task), "cancelled");
|
||||
assert.equal(store.getTask(task.id)?.status, "failed");
|
||||
});
|
||||
|
||||
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
|
||||
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "paused" }]);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||||
// Attach rejection handling before the cancellation event is delivered.
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(store.getTask(task.id)?.status, "paused");
|
||||
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||||
if (nextStatus === "cancelled") {
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
markTransferCancelledTree(task.id, []);
|
||||
t.after(() => settleTransferCancelTree(task.id, []));
|
||||
} else store.patchTask(task.id, { status: nextStatus });
|
||||
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
|
||||
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
|
||||
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "completed" }]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
const start = async () => { assert.fail("completed transfer must not restart"); };
|
||||
await runTransferAndWaitForOwner(task, start, () => false);
|
||||
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
|
||||
latchTransferPause(task.id);
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => {
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
return { superseded: true };
|
||||
}, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
|
||||
// An unrelated lifecycle publication captures the previous failed row while waiting.
|
||||
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
|
||||
t.after(() => store.dismiss("unrelated-admission"));
|
||||
resetTransferPauseLatchesForTests();
|
||||
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
|
||||
});
|
||||
|
||||
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
|
||||
const paused = store.getTask(task.id)!;
|
||||
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
|
||||
assert.equal(store.admitTaskRun(task, paused), "ready");
|
||||
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
|
||||
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
|
||||
const currentPause = store.getTask(task.id)!;
|
||||
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
|
||||
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
task: TransferTask,
|
||||
start: () => Promise<StreamResult>,
|
||||
shouldAbort: () => boolean,
|
||||
pausedAtResume?: TransferTask,
|
||||
): Promise<StreamResult> {
|
||||
// Register before admission/start: an owner may finish while dispatch waits for resume.
|
||||
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
try {
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
|
||||
if (admission === "cancelled") throw new Error("Transfer cancelled");
|
||||
if (admission === "completed" || observation.read()?.status === "completed") return {};
|
||||
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
|
||||
if (admission === "ready") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
// Admission begins a new attempt; discard any previous failed settlement.
|
||||
observation.dispose();
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
const result = await start();
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const latest = observation.read();
|
||||
if (latest?.status === "completed") return { ...result, superseded: false };
|
||||
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
|
||||
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} finally {
|
||||
observation.dispose();
|
||||
}
|
||||
}
|
||||
@@ -3762,3 +3762,108 @@ test("hard directory resume failure falls back to bounded fresh retry history",
|
||||
assert.equal(retainedParent?.checkpointBytes, 0);
|
||||
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
|
||||
});
|
||||
|
||||
|
||||
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => resumeGate,
|
||||
} } });
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask("held-file", "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: true };
|
||||
});
|
||||
const first = store.resume("held-file");
|
||||
await startGate;
|
||||
await store.pause("held-file");
|
||||
const second = store.resume("held-file");
|
||||
await store.pause("held-file");
|
||||
releaseResume({ success: true, lifecycleEpoch: 2 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
|
||||
assert.equal(row?.lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
for (const outcome of ["rejected", "stream-gone"] as const) {
|
||||
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let resolveResume!: (result: { success: boolean; reason: string }) => void;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let resumeCalls = 0;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
|
||||
resolveResume = resolve;
|
||||
rejectResume = reject;
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => ++resumeCalls === 1
|
||||
? resumeGate
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
|
||||
} } });
|
||||
const id = `held-file-${outcome}`;
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask(id, "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask(id, { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
});
|
||||
const first = store.resume(id);
|
||||
await startGate;
|
||||
await store.pause(id);
|
||||
const second = store.resume(id);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (outcome === "stream-gone") {
|
||||
resolveResume({ success: false, reason: "Transfer is no longer active" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await store.pause(id);
|
||||
if (outcome === "rejected") {
|
||||
rejectResume(new Error("worker channel unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
|
||||
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import {
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./sftp/transferCancelLatch";
|
||||
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
|
||||
getBadgeSnapshot(): { count: number; hasAttention: boolean };
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
getTask(taskId: string): TransferTask | undefined;
|
||||
/** Observe exact task settlement before completed child rows are compacted. */
|
||||
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
|
||||
/** Publish an explicit dispatch identity without flushing large child-history batches. */
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
getOwnerTasks(ownerId: string): TransferTask[];
|
||||
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
|
||||
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
|
||||
@@ -398,6 +403,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
const progressListeners = new Set<Listener>();
|
||||
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
|
||||
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
|
||||
expected.id === candidate.id
|
||||
&& expected.sourcePath === candidate.sourcePath
|
||||
&& expected.targetPath === candidate.targetPath
|
||||
&& expected.parentTaskId === candidate.parentTaskId
|
||||
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
|
||||
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
|
||||
);
|
||||
const refreshBadgeSnapshot = () => {
|
||||
const next = buildBadgeSnapshot(tasks);
|
||||
if (
|
||||
@@ -609,6 +623,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// live renderer walk keeps its controls until TransferRuntime.runWalk
|
||||
// settles; everything else can release its whole tree here.
|
||||
settleFinishedTransferControlState(tasks, tasks);
|
||||
if (settlementObservers.size > 0) {
|
||||
for (const task of tasks) {
|
||||
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
const beforePrune = tasks;
|
||||
tasks = pruneSftpTransferHistory(tasks);
|
||||
const retainedIds = new Set(tasks.map((task) => task.id));
|
||||
@@ -1307,6 +1329,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
|
||||
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
|
||||
if (parent?.status === "cancelled") return "cancelled";
|
||||
const index = tasks.findIndex((task) => task.id === incoming.id);
|
||||
const existing = index < 0 ? undefined : tasks[index];
|
||||
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|
||||
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
|
||||
if (existing?.status === "cancelled") return "cancelled";
|
||||
if (existing?.status === "completed") {
|
||||
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
|
||||
}
|
||||
const resumesUnchangedPause = existing === pausedAtResume
|
||||
&& existing?.status === "paused"
|
||||
&& (parent?.status === "pending" || parent?.status === "transferring");
|
||||
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|
||||
|| parent?.status === "paused" || parent?.status === "pausing"
|
||||
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
|
||||
if (parent?.status === "completed") return "conflict";
|
||||
if (!existing) return "ready";
|
||||
if (existing.status === "transferring"
|
||||
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
|
||||
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
|
||||
// Explicit retry may leave failed/interrupted and replace its manifest identity.
|
||||
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
|
||||
const next = tasks.slice();
|
||||
next[index] = {
|
||||
...existing,
|
||||
directoryEntryIndex: incoming.directoryEntryIndex,
|
||||
directoryEntryIdentity: incoming.directoryEntryIdentity,
|
||||
...(existing.status === "transferring" ? {} : {
|
||||
status: "transferring" as const,
|
||||
lifecycleEpoch: undefined,
|
||||
error: undefined,
|
||||
endTime: undefined,
|
||||
reconnectRequired: false,
|
||||
}),
|
||||
};
|
||||
tasks = next;
|
||||
emitProgress(false);
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected) {
|
||||
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
|
||||
const observers = settlementObservers.get(expected.id) ?? new Set();
|
||||
observers.add(observer);
|
||||
settlementObservers.set(expected.id, observers);
|
||||
let disposed = false;
|
||||
return {
|
||||
read() {
|
||||
if (disposed) return undefined;
|
||||
if (observer.settled) return observer.settled;
|
||||
const current = tasks.find((task) => task.id === expected.id);
|
||||
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
observer.settled = undefined;
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) settlementObservers.delete(expected.id);
|
||||
},
|
||||
};
|
||||
},
|
||||
getTask(taskId) {
|
||||
return tasks.find((task) => task.id === taskId);
|
||||
},
|
||||
@@ -1671,7 +1757,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
bumpTransferControlEpoch(taskId);
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
@@ -1693,64 +1779,53 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
|
||||
));
|
||||
const after = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return existing;
|
||||
// Only rejoin when at least one backend stream actually resumed.
|
||||
// Empty/all-fail must not paint transferring over a dead held run.
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
if (successIds.length > 0) {
|
||||
const resumed = new Set(successIds);
|
||||
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
|
||||
// if omitted so main-process progress is not stale-dropped.
|
||||
let bridgeEpoch: number | undefined;
|
||||
for (let index = 0; index < results.length; index += 1) {
|
||||
if (!results[index]?.success) continue;
|
||||
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
|
||||
if (!Number.isFinite(epoch)) continue;
|
||||
bridgeEpoch = bridgeEpoch === undefined
|
||||
? (epoch as number)
|
||||
: Math.max(bridgeEpoch, epoch as number);
|
||||
}
|
||||
tasks = tasks.map((candidate) => {
|
||||
if (candidate.id === taskId || resumed.has(candidate.id)) {
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
lifecycleEpoch: bridgeEpoch,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
|
||||
@@ -385,7 +385,10 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("global broadcast availability follows visible orphan session count", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
assert.equal(current().orphanSessions.length, 0);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "01" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 1);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 2);
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(second.id);
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -233,6 +233,8 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -1121,6 +1123,12 @@ export const useSessionState = ({
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
const tabId = getLogViewTabId(log);
|
||||
@@ -1272,6 +1280,14 @@ export const useSessionState = ({
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
@@ -1395,9 +1411,12 @@ export const useSessionState = ({
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
canUseGlobalBroadcast,
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useStoredBoolean } from "./useStoredBoolean.ts";
|
||||
|
||||
const STORAGE_KEY = "netcatty:test:stored-boolean";
|
||||
|
||||
test("functional updates stay pure when another consumer shares the storage key", async () => {
|
||||
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget();
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
window?: typeof fakeWindow;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
|
||||
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let toggle: (() => void) | null = null;
|
||||
let parentValue = false;
|
||||
let childValue = false;
|
||||
const errors: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, "dispatchEvent", {
|
||||
value: fakeWindow.dispatchEvent.bind(fakeWindow),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "CustomEvent", {
|
||||
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
|
||||
readonly detail: T;
|
||||
|
||||
constructor(type: string, init?: CustomEventInit<T>) {
|
||||
super(type);
|
||||
this.detail = init?.detail as T;
|
||||
}
|
||||
|
||||
initCustomEvent(): void {
|
||||
// Deprecated browser API required by the CustomEvent interface.
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
const Parent = () => {
|
||||
[parentValue] = useStoredBoolean(STORAGE_KEY);
|
||||
return React.createElement(Child);
|
||||
};
|
||||
const Child = () => {
|
||||
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
|
||||
childValue = value;
|
||||
toggle = () => setValue((previous) => !previous);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
|
||||
});
|
||||
await act(async () => {
|
||||
toggle?.();
|
||||
});
|
||||
|
||||
assert.equal(parentValue, true);
|
||||
assert.equal(childValue, true);
|
||||
assert.equal(values.get(STORAGE_KEY), "true");
|
||||
assert.equal(
|
||||
errors.some((message) => message.includes("Cannot update a component")
|
||||
&& message.includes("while rendering a different component")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else delete globals.localStorage;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else delete globals.window;
|
||||
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
|
||||
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
|
||||
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
|
||||
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
@@ -17,30 +17,37 @@ export const useStoredBoolean = (
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
setValue((prev) => {
|
||||
const resolved = typeof next === "function" ? next(prev) : next;
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
// Resolve functional updates before scheduling React state. The updater
|
||||
// must stay pure because React may evaluate it while rendering.
|
||||
const resolved = typeof next === "function" ? next(valueRef.current) : next;
|
||||
valueRef.current = resolved;
|
||||
setValue(resolved);
|
||||
localStorageAdapter.writeBoolean(storageKey, resolved);
|
||||
// Notify other same-window consumers outside the state updater.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
|
||||
);
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync from other components in the same window
|
||||
const handleCustom = (e: Event) => {
|
||||
const { key, value: newValue } = (e as CustomEvent).detail;
|
||||
if (key === storageKey) setValue(newValue);
|
||||
if (key === storageKey) {
|
||||
valueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
};
|
||||
// Sync from other windows
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
setValue(stored ?? fallback);
|
||||
const nextValue = stored ?? fallback;
|
||||
valueRef.current = nextValue;
|
||||
setValue(nextValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener("stored-boolean-change", handleCustom);
|
||||
|
||||
@@ -46,6 +46,9 @@ const baseProps = {
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
isBroadcastEnabled: () => false,
|
||||
onToggleBroadcast: () => {},
|
||||
isGlobalBroadcastEnabled: false,
|
||||
canUseGlobalBroadcast: false,
|
||||
onToggleGlobalBroadcast: () => {},
|
||||
updateSnippets: () => {},
|
||||
updateSnippetPackages: () => {},
|
||||
onSplitSession: () => {},
|
||||
@@ -121,6 +124,23 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when global broadcast state changes", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, isGlobalBroadcastEnabled: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, canUseGlobalBroadcast: true } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
|
||||
@@ -67,6 +67,7 @@ import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
|
||||
import { resolveTerminalBroadcastTargetIds } from '../domain/terminalBroadcast';
|
||||
import {
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
shouldDelayAutoRunSnippetInput,
|
||||
@@ -246,6 +247,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCreateLocalTerminal,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
onToggleGlobalBroadcast,
|
||||
canUseGlobalBroadcast,
|
||||
updateHosts,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
@@ -1049,27 +1053,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const sessionHostsMapRef = useRef(sessionHostsMap);
|
||||
sessionHostsMapRef.current = sessionHostsMap;
|
||||
|
||||
// Handle broadcast input - write to all other sessions in the source workspace.
|
||||
// Resolve the active broadcast mode, then write to its target sessions.
|
||||
const handleBroadcastInput = useCallback((
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
options?: TerminalBroadcastInputOptions,
|
||||
) => {
|
||||
const directTargetIds = options?.kittyKeyboardTargetSessionIds;
|
||||
const sourceSession = directTargetIds
|
||||
? undefined
|
||||
: sessionsRef.current.find((session) => session.id === sourceSessionId);
|
||||
const workspaceId = sourceSession?.workspaceId;
|
||||
if (!directTargetIds && !workspaceId) return [];
|
||||
const directTargetSet = directTargetIds ? new Set(directTargetIds) : null;
|
||||
const targetSessionIds = resolveTerminalBroadcastTargetIds({
|
||||
sessions: sessionsRef.current,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled: isGlobalBroadcastEnabled,
|
||||
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
|
||||
});
|
||||
if (targetSessionIds.length === 0) return [];
|
||||
|
||||
const targetSessionIdSet = new Set(targetSessionIds);
|
||||
const deliveredSessionIds: string[] = [];
|
||||
|
||||
for (const session of sessionsRef.current) {
|
||||
if (directTargetSet) {
|
||||
if (!directTargetSet.has(session.id)) continue;
|
||||
} else if (session.workspaceId !== workspaceId || session.id === sourceSessionId) {
|
||||
continue;
|
||||
}
|
||||
if (!targetSessionIdSet.has(session.id)) continue;
|
||||
if (!canUseDirectSessionWriteFallback(session)) continue;
|
||||
|
||||
if (options?.kittyKeyboardInput) {
|
||||
@@ -1107,7 +1109,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
deliveredSessionIds.push(session.id);
|
||||
}
|
||||
return deliveredSessionIds;
|
||||
}, [terminalBackend]);
|
||||
}, [terminalBackend, isGlobalBroadcastEnabled]);
|
||||
|
||||
const handleCommandSubmitted = useCallback((command: string, _hostId: string, _hostLabel: string, sessionId: string) => {
|
||||
codingCliSignalController.handleCommandSubmitted(sessionId, command);
|
||||
@@ -1297,6 +1299,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleBroadcastRef.current = onToggleBroadcast;
|
||||
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const onToggleGlobalBroadcastRef = useRef(onToggleGlobalBroadcast);
|
||||
onToggleGlobalBroadcastRef.current = onToggleGlobalBroadcast;
|
||||
|
||||
const mountedSftpTabIds = useMemo(
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
@@ -2298,6 +2303,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
restoreTerminalCwd,
|
||||
identities,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
isComposeBarOpen,
|
||||
keyBindings,
|
||||
keys,
|
||||
@@ -2334,6 +2341,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSplitSession,
|
||||
onSplitSessionRef,
|
||||
onToggleBroadcastRef,
|
||||
onToggleGlobalBroadcastRef,
|
||||
onToggleWorkspaceViewMode,
|
||||
onToggleWorkspaceViewModeRef,
|
||||
onUpdateHost,
|
||||
|
||||
@@ -367,8 +367,15 @@ test("note editor registers a code block editor for pasted fenced code", () => {
|
||||
source,
|
||||
/codeBlockPlugin\([^)]*\),\s*codeMirrorPlugin\(\{\s*codeBlockLanguages:/s,
|
||||
);
|
||||
assert.match(source, /codeMirrorExtensions:\s*NOTE_CODE_MIRROR_EXTENSIONS/);
|
||||
assert.match(source, /codeMirrorExtensions:\s*\[\s*\.\.\.NOTE_CODE_MIRROR_EXTENSIONS,/);
|
||||
assert.match(source, /syntaxHighlighting\(noteCodeHighlightStyle\)/);
|
||||
// Tooltips mount on body to escape clipped code blocks, so their placement
|
||||
// must be bounded to this editor instead of the whole window.
|
||||
assert.match(
|
||||
source,
|
||||
/createNoteCodeTooltipExtensions\(\s*typeof document === "undefined" \? undefined : document\.body,/,
|
||||
);
|
||||
assert.match(source, /\(\) => containerRef\.current,/);
|
||||
});
|
||||
|
||||
test("note editor enables image plugin for remote markdown images", () => {
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from "./noteClipboardPaste";
|
||||
import { annotateNoteImageSizes } from "./noteImageLayout";
|
||||
import { renderNoteMathFormula } from "./noteMathRenderer";
|
||||
import { createNoteCodeTooltipExtensions } from "./noteCodeTooltips";
|
||||
import {
|
||||
EMPTY_ACTIVE_FORMATS,
|
||||
type ActiveTextFormats,
|
||||
@@ -1203,7 +1204,16 @@ export const InlineMarkdownEditor = React.memo(
|
||||
codeBlockPlugin({ defaultCodeBlockLanguage: "" }),
|
||||
codeMirrorPlugin({
|
||||
codeBlockLanguages: NOTE_CODE_BLOCK_LANGUAGES,
|
||||
codeMirrorExtensions: NOTE_CODE_MIRROR_EXTENSIONS,
|
||||
codeMirrorExtensions: [
|
||||
...NOTE_CODE_MIRROR_EXTENSIONS,
|
||||
...createNoteCodeTooltipExtensions(
|
||||
typeof document === "undefined" ? undefined : document.body,
|
||||
// Tooltips escape clipped code blocks via the body parent; bound
|
||||
// their placement to this notes editor so a completion near a pane
|
||||
// edge flips or shrinks instead of rendering over an adjacent pane.
|
||||
() => containerRef.current,
|
||||
),
|
||||
],
|
||||
}),
|
||||
markdownShortcutPlugin(),
|
||||
noteEditorDialogBridgePlugin({
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, showTooltip } from "@codemirror/view";
|
||||
import { syncNoteCodeTooltipStyle, createNoteCodeTooltipExtensions, getNoteTooltipSpace } from "./noteCodeTooltips";
|
||||
|
||||
const stubRect = (element: Element, top: number, left: number, right: number, bottom: number) => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
};
|
||||
|
||||
test("note tooltips escape clipped code blocks and disappear with their editor", () => {
|
||||
const dom = new JSDOM('<div id="note" style="overflow:hidden;height:20px"></div>', {
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const keys = ["window", "document", "MutationObserver", "requestAnimationFrame", "cancelAnimationFrame"] as const;
|
||||
const previous = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
for (const key of keys) {
|
||||
const value = dom.window[key];
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value: typeof value === "function" && key.includes("AnimationFrame") ? value.bind(dom.window) : value,
|
||||
});
|
||||
}
|
||||
let view: EditorView | undefined;
|
||||
try {
|
||||
const parent = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: "con",
|
||||
extensions: [
|
||||
...createNoteCodeTooltipExtensions(dom.window.document.body),
|
||||
showTooltip.of({
|
||||
pos: 0,
|
||||
create() {
|
||||
const tooltip = dom.window.document.createElement("div");
|
||||
tooltip.textContent = "const";
|
||||
return { dom: tooltip };
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tooltip = dom.window.document.querySelector(".cm-tooltip")!;
|
||||
assert.ok(tooltip);
|
||||
assert.equal(parent.contains(tooltip), false);
|
||||
assert.equal(tooltip.parentElement?.parentElement, dom.window.document.body);
|
||||
for (const themeClass of view.themeClasses.split(" ")) {
|
||||
assert.ok(tooltip.parentElement?.classList.contains(themeClass));
|
||||
}
|
||||
view.dom.style.setProperty("--popover", "120 50% 20%");
|
||||
view.dom.style.setProperty("--accent", "120 70% 40%");
|
||||
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 300, bottom: 400 });
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "120 50% 20%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "120 70% 40%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "200px");
|
||||
view.dom.style.setProperty("--popover", "240 50% 20%");
|
||||
view.dom.style.removeProperty("--accent");
|
||||
syncNoteCodeTooltipStyle(view, { top: 0, left: 100, right: 250, bottom: 400 });
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--popover"), "240 50% 20%");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--accent"), "");
|
||||
assert.equal((tooltip as HTMLElement).style.getPropertyValue("--note-tooltip-width"), "150px");
|
||||
view.destroy();
|
||||
view = undefined;
|
||||
assert.equal(dom.window.document.querySelector(".cm-tooltip"), null);
|
||||
} finally {
|
||||
view?.destroy();
|
||||
keys.forEach((key, index) => {
|
||||
const descriptor = previous[index];
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space is bounded by clipping pane ancestors", () => {
|
||||
const dom = new JSDOM('<div id="pane" style="overflow:hidden"><div id="note"></div></div>');
|
||||
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
|
||||
const note = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
stubRect(pane, 100, 200, 600, 500);
|
||||
// The notes editor extends below its pane; a tooltip must not follow it out.
|
||||
stubRect(note, 150, 250, 550, 700);
|
||||
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
dom.window.getComputedStyle = ((element: Element) => ({
|
||||
...realGetComputedStyle(element),
|
||||
overflowX: element === pane ? "hidden" : "visible",
|
||||
overflowY: element === pane ? "hidden" : "visible",
|
||||
})) as typeof dom.window.getComputedStyle;
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
|
||||
top: 150,
|
||||
left: 250,
|
||||
right: 550,
|
||||
bottom: 500,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space honors paint containment without overflow clipping", () => {
|
||||
const dom = new JSDOM(
|
||||
'<style>#pane{contain:strict}</style><div id="pane"><div id="note"></div></div>',
|
||||
);
|
||||
const pane = dom.window.document.querySelector("#pane") as HTMLElement;
|
||||
const note = dom.window.document.querySelector("#note") as HTMLElement;
|
||||
stubRect(pane, 0, 0, 800, 600);
|
||||
stubRect(note, 50, -50, 850, 900);
|
||||
const realGetComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
dom.window.getComputedStyle = ((element: Element) => ({
|
||||
...realGetComputedStyle(element),
|
||||
overflowX: "visible",
|
||||
overflowY: "visible",
|
||||
contain: element === pane ? "strict" : "none",
|
||||
})) as typeof dom.window.getComputedStyle;
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(note, dom.window.document), {
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("note tooltip space falls back to window bounds when the editor is unmounted", () => {
|
||||
const dom = new JSDOM("<div></div>");
|
||||
const detached = dom.window.document.createElement("div");
|
||||
try {
|
||||
assert.deepEqual(getNoteTooltipSpace(detached, dom.window.document), {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: dom.window.document.documentElement.clientWidth,
|
||||
bottom: dom.window.document.documentElement.clientHeight,
|
||||
});
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { EditorView, getTooltip, showTooltip, tooltips } from "@codemirror/view";
|
||||
|
||||
export interface NoteTooltipSpace {
|
||||
top: number;
|
||||
left: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/** True when the element clips horizontally overflowing descendants. */
|
||||
const clipsHorizontally = (style: CSSStyleDeclaration): boolean =>
|
||||
style.overflowX !== "visible" ||
|
||||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
|
||||
|
||||
/** True when the element clips vertically overflowing descendants. Paint
|
||||
* containment (strict/content/paint) also clips and captures fixed
|
||||
* descendants even when overflow is visible. */
|
||||
const clipsVertically = (style: CSSStyleDeclaration): boolean =>
|
||||
style.overflowY !== "visible" ||
|
||||
/(^|\s)(strict|content|paint)(\s|$)/.test(style.contain || "");
|
||||
|
||||
/**
|
||||
* Viewport-space bounds CodeMirror may place note tooltips in: the owning
|
||||
* notes region (`boundsRoot`) shrunk by every clipping ancestor (terminal
|
||||
* side-panel pane hosts, ScrollArea viewports). Tooltips are mounted on
|
||||
* `document.body` so they escape clipped code blocks; bounding their space
|
||||
* to the notes pane keeps a completion from rendering over an adjacent
|
||||
* split pane. Without a mounted `boundsRoot`, falls back to the window
|
||||
* bounds CodeMirror would use by default.
|
||||
*/
|
||||
export const getNoteTooltipSpace = (
|
||||
boundsRoot: HTMLElement | null | undefined,
|
||||
doc: Document,
|
||||
): NoteTooltipSpace => {
|
||||
if (!boundsRoot || !boundsRoot.isConnected) {
|
||||
const docElt = doc.documentElement;
|
||||
return { top: 0, left: 0, right: docElt.clientWidth, bottom: docElt.clientHeight };
|
||||
}
|
||||
const rootRect = boundsRoot.getBoundingClientRect();
|
||||
let space: NoteTooltipSpace = {
|
||||
top: rootRect.top,
|
||||
left: rootRect.left,
|
||||
right: rootRect.right,
|
||||
bottom: rootRect.bottom,
|
||||
};
|
||||
const defaultView = boundsRoot.ownerDocument.defaultView;
|
||||
if (!defaultView) return space;
|
||||
for (let node: Element | null = boundsRoot; node; node = node.parentElement) {
|
||||
const style = defaultView.getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (clipsHorizontally(style)) {
|
||||
space = {
|
||||
...space,
|
||||
left: Math.max(space.left, rect.left),
|
||||
right: Math.min(space.right, rect.right),
|
||||
};
|
||||
}
|
||||
if (clipsVertically(style)) {
|
||||
space = {
|
||||
...space,
|
||||
top: Math.max(space.top, rect.top),
|
||||
bottom: Math.min(space.bottom, rect.bottom),
|
||||
};
|
||||
}
|
||||
}
|
||||
return space;
|
||||
};
|
||||
|
||||
export const syncNoteCodeTooltipStyle = (view: EditorView, space: NoteTooltipSpace): void => {
|
||||
const width = `${Math.max(0, space.right - space.left)}px`;
|
||||
const paneStyle = view.dom.ownerDocument.defaultView?.getComputedStyle(view.dom);
|
||||
const tokens = ["--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground"];
|
||||
for (const tooltip of view.state.facet(showTooltip)) {
|
||||
if (!tooltip) continue;
|
||||
const dom = getTooltip(view, tooltip)?.dom;
|
||||
if (!dom) continue;
|
||||
dom.style.setProperty("--note-tooltip-width", width);
|
||||
// Detached tooltips must retain terminal-side-panel theme overrides.
|
||||
for (const token of tokens) {
|
||||
const value = paneStyle?.getPropertyValue(token).trim();
|
||||
if (value) dom.style.setProperty(token, value);
|
||||
else dom.style.removeProperty(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createNoteCodeTooltipExtensions = (
|
||||
parent: HTMLElement | undefined,
|
||||
getBoundsRoot?: () => HTMLElement | null,
|
||||
) => [
|
||||
// Code blocks and their enclosing note panes can clip editor descendants.
|
||||
Prec.highest(
|
||||
tooltips({
|
||||
parent,
|
||||
// Tooltips escape those clips via the global parent, so constrain their
|
||||
// placement to the owning notes pane instead of the whole window.
|
||||
tooltipSpace: (view: EditorView) => {
|
||||
const space = getNoteTooltipSpace(getBoundsRoot?.(), view.dom.ownerDocument);
|
||||
// CodeMirror constrains coordinates and height, but not width. Set
|
||||
// the available width; its ResizeObserver remeasures after resizing.
|
||||
syncNoteCodeTooltipStyle(view, space);
|
||||
return space;
|
||||
},
|
||||
}),
|
||||
),
|
||||
// CodeMirror carries this theme's scope onto its detached tooltip container.
|
||||
// Keep these rules local to Notes and use the application's existing colors.
|
||||
Prec.highest(EditorView.theme({
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
maxWidth: "var(--note-tooltip-width, 95vw)",
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
minWidth: "min(250px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
|
||||
maxWidth: "min(700px, max(0px, calc(var(--note-tooltip-width, 95vw) - 2px)))",
|
||||
},
|
||||
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
backgroundColor: "hsl(var(--accent))",
|
||||
color: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
})),
|
||||
];
|
||||
@@ -832,7 +832,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
)}
|
||||
{showHostInfoBar && <div className="flex-1 min-w-0" />}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
{onToggleBroadcast && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -50,7 +50,7 @@ test("app resume handlers flush backlog and recover the terminal renderer before
|
||||
assertRecoverTerminalOnAppResumeOrder(source);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) return;\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serializ
|
||||
import type { Terminal as XTermType } from "@xterm/xterm";
|
||||
|
||||
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
|
||||
import { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure.ts";
|
||||
import {
|
||||
noteTerminalOutputPressureData,
|
||||
setTerminalOutputPressureVisibility,
|
||||
} from "./runtime/terminalOutputPressure.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
import { KeywordHighlighter } from "./keywordHighlight.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -243,6 +247,27 @@ test("bulk output skipped on the hot path is highlighted after output becomes qu
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("streaming bulk output colors the viewport in the same write", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 50 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
// More than BULK_WRITE_LINE_BREAKS newlines per chunk, but far below the
|
||||
// large-output rate, like a coalesced `tail -f` tick (#3271).
|
||||
const chunk = Array.from({ length: 12 }, (_, index) => `line-${index} ERROR`).join("\r\n");
|
||||
await write(term, chunk);
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.equal(cellRgb(term, viewportY + 4, "ERROR"), RED, "visible rows must color in-frame");
|
||||
assert.notEqual(cellRgb(term, 0, "ERROR"), RED, "history above the viewport stays with catch-up");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
assert.equal(highlighter.rebuildCount, 1);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("ordinary Enter does not rebuild a saturated scrollback", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 40, rows: 3, scrollback: 10 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
@@ -1039,7 +1064,9 @@ test("quiet catch-up refreshes a tall viewport once", async () => {
|
||||
await write(term, flood);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.equal(refreshCount, 1);
|
||||
// One refresh paints the viewport in-frame inside the bulk write callback;
|
||||
// the second is the single catch-up viewport pass after output quiets.
|
||||
assert.equal(refreshCount, 2);
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
@@ -1064,7 +1091,7 @@ test("identical flood lines are all colored after quiet catch-up", async () => {
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("saturated flood does not rematch the pinned viewport on each write", async () => {
|
||||
test("saturated streaming keeps the pinned viewport colored in-frame", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
@@ -1076,8 +1103,56 @@ test("saturated flood does not rematch the pinned viewport on each write", async
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
// Bulk bypass still colors the visible rows inside the write callback, so
|
||||
// streaming logs never show an uncolored frame (#3271).
|
||||
assert.equal(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.equal(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("hidden panes keep bulk streaming on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 40 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
const chunk = Array.from({ length: 16 }, () => line).join("\r\n");
|
||||
// Below the 16KB/100ms flood gate, so without the background check the bulk
|
||||
// bypass would recolor and refresh the viewport for every hidden batch.
|
||||
setTerminalOutputPressureVisibility(term, false);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
assert.notEqual(cellRgb(term, viewportY + 2, "ERROR"), RED);
|
||||
// Reveal the pane and let catch-up paint the deferred range.
|
||||
setTerminalOutputPressureVisibility(term, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("high-rate flood keeps the viewport on the deferred catch-up path", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 8 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const line = "2026-08-13 INFO worker=1 WARN ERROR failed from 10.2.0.1";
|
||||
// ~20KB inside the 100ms rate window trips the large-output degradation, so
|
||||
// the in-frame viewport repaint stays off and only catch-up colors.
|
||||
const chunk = Array.from({ length: 300 }, () => line).join("\r\n");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
noteTerminalOutputPressureData(term, chunk);
|
||||
await write(term, chunk);
|
||||
}
|
||||
assert.equal(highlighter.rebuildCount, 0);
|
||||
const viewportY = term.buffer.active.viewportY;
|
||||
assert.notEqual(cellRgb(term, viewportY, "ERROR"), RED);
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
@@ -1098,3 +1173,52 @@ test("recycled identical flood rows are recolored on catch-up", async () => {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
|
||||
test("hibernate replay defers bulk viewport coloring but subsequent live output colors immediately", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
const highlighter = new KeywordHighlighter(term);
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "history ERROR").join("\r\n");
|
||||
try {
|
||||
await writeTerminalPayloadChunked(term, chunk);
|
||||
assert.notEqual(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
await highlighter.whenSettled();
|
||||
assert.deepEqual(uncoloredKeywordLines(term, "ERROR", RED), []);
|
||||
await write(term, "\r\n" + chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("bulk streaming does not recolor a scrolled-back history viewport", async () => {
|
||||
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 6, scrollback: 100 });
|
||||
let bypass = false;
|
||||
const highlighter = new KeywordHighlighter(term, { shouldBypassHighlight: () => bypass });
|
||||
highlighter.setRules(rule(), true);
|
||||
const chunk = Array.from({ length: 20 }, () => "log ERROR").join("\r\n") + "\r\n";
|
||||
try {
|
||||
await write(term, chunk);
|
||||
await highlighter.whenSettled();
|
||||
term.scrollToTop();
|
||||
let refreshes = 0;
|
||||
const refresh = term.refresh.bind(term);
|
||||
term.refresh = (start, end) => { refreshes += 1; refresh(start, end); };
|
||||
bypass = true;
|
||||
await write(term, chunk);
|
||||
const scrollRefreshes = refreshes;
|
||||
refreshes = 0;
|
||||
bypass = false;
|
||||
await write(term, chunk);
|
||||
assert.equal(refreshes, scrollRefreshes, "bulk callback must not add another history repaint");
|
||||
term.scrollToBottom();
|
||||
await write(term, chunk);
|
||||
assert.equal(cellRgb(term, term.buffer.active.viewportY, "ERROR"), RED);
|
||||
} finally {
|
||||
highlighter.dispose();
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
@@ -5,9 +5,13 @@ import { isSafePluginDecorationPattern } from "../../domain/pluginTerminalProvid
|
||||
import { checkRegexSafetyPattern } from "../../lib/regexSafety";
|
||||
import type { KeywordHighlightRule } from "../../types";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
|
||||
import { isTerminalReplayWrite } from "./terminalReplay";
|
||||
import { readPluginTerminalBufferText } from "./pluginTerminalBufferText";
|
||||
import { compileRe2RangeMatcher, forEachNonEmptyRegexMatch } from "./keywordHighlightRegex";
|
||||
import { shouldDegradeTerminalKeywordHighlight } from "./runtime/terminalOutputPressure";
|
||||
import {
|
||||
isTerminalOutputInBackground,
|
||||
shouldDegradeTerminalKeywordHighlight,
|
||||
} from "./runtime/terminalOutputPressure";
|
||||
|
||||
type RuntimeKeywordHighlightRule = KeywordHighlightRule & { readonly providerId?: string };
|
||||
|
||||
@@ -396,12 +400,43 @@ export class KeywordHighlighter implements IDisposable {
|
||||
? this.resolveAbsoluteRepaintRange(absoluteControls, originModeNeedsSafety)
|
||||
: null;
|
||||
const bypass = !startedOnNormal || this.shouldBypassWrite(data);
|
||||
// Freeze in-frame eligibility at entry: the pressure window is a rolling
|
||||
// sample, and by the time this write's callback runs (xterm parses in a
|
||||
// macrotask while backlogged) later chunks may have aged it below the
|
||||
// threshold. Re-checking inside the callback would let a write that took
|
||||
// the flood path repaint the viewport anyway, defeating the deferred path.
|
||||
// Pane visibility is the exception: it can flip while this write is still
|
||||
// in flight (pane/page shown between entry and callback), so it is
|
||||
// re-evaluated live in the callback below instead of frozen here.
|
||||
const colorDuringBypass = bypass && this.mayColorViewportDuringBypass(data);
|
||||
if (bypass) {
|
||||
if (startedOnNormal && (this.enabled || this.compiledPatterns.length > 0 || this.hasPendingCatchUp())) {
|
||||
this.markCatchUp(absoluteRepaintRange === null ? startY : Math.min(startY, startBaseY));
|
||||
this.scheduleCatchUp();
|
||||
}
|
||||
return this.originalWrite(data, () => {
|
||||
// Bulk writes skip per-write coloring so xterm can keep painting the
|
||||
// flood, but the viewport itself must not sit uncolored until the
|
||||
// catch-up timer fires (#3271). Recolor the visible rows here, inside
|
||||
// the write callback: xterm parses in a macrotask and renders in the
|
||||
// next rAF, so these colors land in the same frame as the text. The
|
||||
// cost is O(viewport rows × rules), independent of chunk size, and
|
||||
// true rate/long-line floods keep the fully deferred path. Rows that
|
||||
// scrolled past the viewport stay with the deferred catch-up.
|
||||
if (
|
||||
this.term.buffer.active.type === "normal"
|
||||
&& this.compiledPatterns.length > 0
|
||||
&& colorDuringBypass
|
||||
// Visibility may have changed since this write started (hidden pane
|
||||
// shown before the async xterm parse finished): the entry snapshot
|
||||
// only froze the flood/rate decision, so check the live state here.
|
||||
&& !isTerminalOutputInBackground(this.term)
|
||||
// Appended logs should not add another repaint while reading history.
|
||||
// Scroll/reveal handlers retain their existing recoloring behavior.
|
||||
&& this.term.buffer.active.viewportY === this.term.buffer.active.baseY
|
||||
) {
|
||||
this.recolorVisible();
|
||||
}
|
||||
if (this.term.buffer.active.type === "normal" && !startedOnNormal) {
|
||||
if (this.enabled || this.compiledPatterns.length > 0) {
|
||||
this.markCatchUp(0);
|
||||
@@ -692,6 +727,23 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return this.countNewlines(data) >= BULK_WRITE_LINE_BREAKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming logs usually trip only the bulk line-break heuristic (coalesced
|
||||
* PTY ticks), which is cheap to color in-frame: rematching the visible
|
||||
* viewport costs O(viewport rows × rules) per write, independent of chunk
|
||||
* size. Rate/long-line floods and explicit full-bypass requests keep the
|
||||
* deferred catch-up so xterm keeps painting the dump smoothly.
|
||||
*
|
||||
* This is the frozen flood/rate decision only: pane visibility is evaluated
|
||||
* live in the write callback (`isTerminalOutputInBackground`) because it can
|
||||
* flip between write entry and the asynchronous callback.
|
||||
*/
|
||||
private mayColorViewportDuringBypass(data: string | Uint8Array): boolean {
|
||||
if (this.options.shouldBypassHighlight?.()) return false;
|
||||
if (typeof data !== "string" || isTerminalReplayWrite(this.term)) return false;
|
||||
return !shouldDegradeTerminalKeywordHighlight(this.term, data);
|
||||
}
|
||||
|
||||
private countNewlines(data: string): number {
|
||||
let count = 0;
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
|
||||
@@ -246,6 +246,11 @@ export const setTerminalOutputPressureVisibility = (
|
||||
getOrCreateState(term).background = !visible;
|
||||
};
|
||||
|
||||
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
|
||||
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
|
||||
pressureStates.get(term)?.background ?? false
|
||||
);
|
||||
|
||||
export const setTerminalOutputPressureLargeOutput = (
|
||||
term: XTerm,
|
||||
largeOutput: boolean,
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
writeSessionData,
|
||||
} from "./terminalSessionAttachment.ts";
|
||||
import { getVisibleTerminalLineTimestampRows } from "./terminalLineTimestamps.ts";
|
||||
import { noteTerminalOutputPressureData } from "./terminalOutputPressure.ts";
|
||||
import {
|
||||
getTerminalOutputPressure,
|
||||
noteTerminalOutputPressureData,
|
||||
} from "./terminalOutputPressure.ts";
|
||||
|
||||
import {
|
||||
clearTerminalSessionFlowAck,
|
||||
@@ -1539,6 +1542,50 @@ test("writeSessionData keeps the hidden flush gate after coalescer reset and flu
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("writeSessionData refreshes pressure visibility when hidden output flushes after reveal", () => {
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
const payload = "x".repeat(XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES + 1);
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
buffer: { active: { type: "normal" } },
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom() {},
|
||||
} as unknown as XTerm;
|
||||
const ctx = {
|
||||
...createContext(false),
|
||||
isVisibleRef: { current: false },
|
||||
sessionRef: { current: "session-1" },
|
||||
terminalBackend: {
|
||||
ackSessionFlow: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
getFlowController(ctx as never, term);
|
||||
resetTerminalWriteCoalescer(term);
|
||||
withAnimationFrameQueue(() => {
|
||||
withDocumentVisibility("visible", () => {
|
||||
writeSessionData(ctx as never, term, payload);
|
||||
});
|
||||
// The ingress snapshot records the pane as hidden.
|
||||
assert.equal(getTerminalOutputPressure(term).background, true);
|
||||
assert.deepEqual(writes, []);
|
||||
|
||||
ctx.isVisibleRef.current = true;
|
||||
withDocumentVisibility("visible", () => {
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
});
|
||||
// The batch is actually parsed after reveal, so the flush must clear the
|
||||
// stale hidden flag: bulk write callbacks (e.g. keyword highlight) gate
|
||||
// their in-frame viewport recolor on this state (#3272).
|
||||
assert.deepEqual(writes, [payload]);
|
||||
assert.equal(getTerminalOutputPressure(term).background, false);
|
||||
});
|
||||
clearTerminalSessionFlowAck("session-1");
|
||||
});
|
||||
|
||||
test("hidden tab output marks pending scroll without scrolling immediately", async () => {
|
||||
const writes: string[] = [];
|
||||
let scrollCalls = 0;
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from "./terminalOutputPipeline";
|
||||
import {
|
||||
hasPendingTerminalWrites,
|
||||
isTerminalPageHidden,
|
||||
maybeFlushTerminalWriteCoalescerWhenUnfocused,
|
||||
scheduleTerminalRepaintWhenUnfocused,
|
||||
shouldFlushTerminalWritesForBackgroundOutput,
|
||||
@@ -114,6 +115,16 @@ const isTerminalPaneVisible = (ctx: TerminalSessionStartersContext): boolean =>
|
||||
(ctx.isPaneVisibleRef?.current ?? ctx.isVisibleRef?.current) !== false
|
||||
);
|
||||
|
||||
/**
|
||||
* Visibility recorded for the output-pressure gates. A pane whose ref says
|
||||
* "visible" still cannot paint while the document is page-hidden, so record it
|
||||
* as background there too; otherwise bulk batches take the background flush
|
||||
* path but keyword-highlight still repainted the viewport per batch.
|
||||
*/
|
||||
const isTerminalOutputVisibleForPressure = (ctx: TerminalSessionStartersContext): boolean => (
|
||||
isTerminalPaneVisible(ctx) && !isTerminalPageHidden()
|
||||
);
|
||||
|
||||
const handleTerminalOutputAutoScroll = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -471,7 +482,7 @@ export const writeSessionData = (
|
||||
visible: isPaneVisible,
|
||||
});
|
||||
flow.received(ingressBytes);
|
||||
setTerminalOutputPressureVisibility(term, isPaneVisible);
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
noteTerminalOutputPressureData(term, data);
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const preservePromptSourceChunks = Boolean(
|
||||
@@ -485,6 +496,12 @@ export const writeSessionData = (
|
||||
batchIngress: number,
|
||||
writeOptions?: CoalescedTerminalWriteOptions,
|
||||
): void => {
|
||||
// Output queued while hidden can be flushed after the pane is revealed.
|
||||
// Refresh the pressure visibility at flush time so the write callback
|
||||
// sees the live pane state instead of the ingress snapshot; otherwise a
|
||||
// bulk batch written after reveal stays on the fully deferred path and
|
||||
// the viewport sits uncolored until the quiet catch-up (#3272).
|
||||
setTerminalOutputPressureVisibility(term, isTerminalOutputVisibleForPressure(ctx));
|
||||
writeSessionDataImmediate(ctx, term, batch, batchIngress, {
|
||||
...writeOptions,
|
||||
deferStart: writeOptions?.deferStart ?? !isPaneCurrentlyVisible(),
|
||||
|
||||
@@ -24,7 +24,7 @@ test("renderer activity follows the hibernate setting instead of active-tab visi
|
||||
);
|
||||
});
|
||||
|
||||
test("inactive terminal surfaces remain painted and non-interactive without hibernate", () => {
|
||||
test("inactive terminal surfaces remain mounted and non-interactive without hibernate", () => {
|
||||
assert.match(supportSource, /resolveTerminalHibernateEnabledForProtocol\(terminalSettings, host\.protocol\)/);
|
||||
assert.match(
|
||||
supportSource,
|
||||
|
||||
@@ -12,10 +12,20 @@ const scheduleFrame = (): Promise<void> => new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
// The highlighter snapshots this flag synchronously when term.write enters.
|
||||
// Replay has no PTY pressure samples, so it must not look like live log output.
|
||||
const replayWrites = new WeakSet<XTerm>();
|
||||
export const isTerminalReplayWrite = (term: XTerm): boolean => replayWrites.has(term);
|
||||
|
||||
const writeChunk = (term: XTerm, data: string): Promise<void> => {
|
||||
if (!data) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
term.write(data, () => resolve());
|
||||
replayWrites.add(term);
|
||||
try {
|
||||
term.write(data, () => resolve());
|
||||
} finally {
|
||||
replayWrites.delete(term);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ test('tab-switch suppression does not consume the visible recovery pass', () =>
|
||||
assert.match(source, /const becameVisible = isVisible && !wasVisibleRef\.current/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
/if \(!isVisible\) \{\s*wasVisibleRef\.current = false;\s*syncOutputPressureVisibility\(\);\s*return;\s*\}[\s\S]*if \(splitResizeActive\) return;[\s\S]*wasVisibleRef\.current = true;[\s\S]*recoverTerminalAfterBecomeVisible\(\)/,
|
||||
);
|
||||
assert.doesNotMatch(source, /wasVisibleRef\.current = isVisible;\s*if \(!isVisible \|\| isResizing\) return/);
|
||||
});
|
||||
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
flushPendingTerminalWritesBeforeHibernate,
|
||||
flushPendingTerminalWritesOnResume,
|
||||
forceTerminalRepaintBypassingAnimationFrame,
|
||||
isTerminalPageHidden,
|
||||
repaintTerminalAfterReveal,
|
||||
} from './runtime/terminalUnfocusedRepaint';
|
||||
import { setTerminalOutputPressureVisibility } from './runtime/terminalOutputPressure';
|
||||
import {
|
||||
forceXTermFontRemeasure,
|
||||
type XTermFontRemeasureTarget,
|
||||
@@ -1274,10 +1276,27 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
pendingOutputScrollRef.current = false;
|
||||
};
|
||||
|
||||
// The output-pressure visibility flag is read live by the keyword
|
||||
// highlighter's write callback, but the write path only refreshes it at
|
||||
// ingress and coalescer flush. A batch dispatched while hidden can still be
|
||||
// parsing when the pane/page is revealed, and a stale `background: true`
|
||||
// would then skip the in-frame viewport recolor (#3272). Re-sync the flag
|
||||
// on every visibility transition so in-flight writes always see the
|
||||
// current pane/document state.
|
||||
const syncOutputPressureVisibility = () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
setTerminalOutputPressureVisibility(
|
||||
term,
|
||||
isVisibleRef.current && !isTerminalPageHidden(),
|
||||
);
|
||||
};
|
||||
|
||||
const flushTerminalWritesAfterBecomeVisible = () => {
|
||||
lastCommittedVisibleLayoutKeyRef.current = null;
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
repaintTerminalAfterReveal(term, () => isVisibleRef.current);
|
||||
@@ -1327,6 +1346,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
|
||||
if (!isVisible) {
|
||||
wasVisibleRef.current = false;
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1844,6 +1864,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const recoverTerminalOnAppResume = () => {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
syncOutputPressureVisibility();
|
||||
cancelScheduledUnfocusedRepaint(term);
|
||||
flushPendingTerminalWritesOnResume(term);
|
||||
forceTerminalRepaintBypassingAnimationFrame(term);
|
||||
@@ -1854,7 +1875,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (document.visibilityState !== 'visible') {
|
||||
syncOutputPressureVisibility();
|
||||
return;
|
||||
}
|
||||
recoverTerminalOnAppResume();
|
||||
};
|
||||
|
||||
|
||||
@@ -710,7 +710,10 @@ export interface TerminalLayerProps {
|
||||
onCreateLocalTerminal?: () => void;
|
||||
// Broadcast mode
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleBroadcast?: (workspaceId: string) => void;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
// SFTP side panel
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
updateSnippets?: (snippets: Snippet[]) => void;
|
||||
@@ -810,6 +813,9 @@ interface TerminalPaneProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -935,6 +941,9 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.onBroadcastInput === next.onBroadcastInput &&
|
||||
prev.onBroadcastInterruptPriorityChange === next.onBroadcastInterruptPriorityChange &&
|
||||
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
|
||||
@@ -1233,6 +1242,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
onBroadcastInput,
|
||||
onBroadcastInterruptPriorityChange,
|
||||
onToggleWorkspaceComposeBar,
|
||||
@@ -1459,7 +1471,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
: undefined;
|
||||
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
|
||||
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
|
||||
const broadcastEnabled = activeWorkspaceId ? !!isBroadcastEnabled?.(activeWorkspaceId) : false;
|
||||
const broadcastEnabled = activeWorkspaceId
|
||||
? !!isBroadcastEnabled?.(activeWorkspaceId)
|
||||
: (isGlobalBroadcastEnabled ?? false);
|
||||
const isHostEphemeral = !isSavedVaultHost(hostMap.get(host.id));
|
||||
const sessionAppearance = useMemo(
|
||||
() => resolveSessionAppearance({ host, isEphemeral: isHostEphemeral }),
|
||||
@@ -1605,7 +1619,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
|
||||
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
|
||||
isBroadcastEnabled={broadcastEnabled}
|
||||
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
|
||||
onToggleBroadcast={
|
||||
inActiveWorkspace
|
||||
? workspaceBroadcastHandler
|
||||
: (canUseGlobalBroadcast ? onToggleGlobalBroadcast : undefined)
|
||||
}
|
||||
onToggleComposeBar={inActiveWorkspace ? onToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={broadcastEnabled ? onBroadcastInput : undefined}
|
||||
@@ -1697,6 +1715,9 @@ interface TerminalPanesHostProps {
|
||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
|
||||
isBroadcastEnabled?: (workspaceId: string) => boolean;
|
||||
isGlobalBroadcastEnabled?: boolean;
|
||||
canUseGlobalBroadcast?: boolean;
|
||||
onToggleGlobalBroadcast?: () => void;
|
||||
onBroadcastInput: (
|
||||
data: string,
|
||||
sourceSessionId: string,
|
||||
@@ -1784,6 +1805,9 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
|
||||
if (prev.onSplitSession !== next.onSplitSession) return false;
|
||||
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
|
||||
if (prev.isGlobalBroadcastEnabled !== next.isGlobalBroadcastEnabled) return false;
|
||||
if (prev.canUseGlobalBroadcast !== next.canUseGlobalBroadcast) return false;
|
||||
if (prev.onToggleGlobalBroadcast !== next.onToggleGlobalBroadcast) return false;
|
||||
if (prev.onBroadcastInput !== next.onBroadcastInput) return false;
|
||||
if (prev.onBroadcastInterruptPriorityChange !== next.onBroadcastInterruptPriorityChange) return false;
|
||||
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
|
||||
|
||||
@@ -635,6 +635,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
validAIScopeTargetIds: s.validAIScopeTargetIds,
|
||||
workspaceBroadcastHandlersRef: s.workspaceBroadcastHandlersRef,
|
||||
workspaceById,
|
||||
isGlobalBroadcastEnabled: s.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: s.canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: s.onToggleGlobalBroadcastRef.current,
|
||||
// AI scope maintenance (merge/dissolve handoff) needs the full list; do not
|
||||
// rely on workspaceById alone — SidePanelStateRoot reads ctx.workspaces.
|
||||
workspaces: s.workspaces,
|
||||
@@ -691,6 +694,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
s.terminalTheme,
|
||||
s.resolveSessionAppearance,
|
||||
s.hostMap,
|
||||
s.isGlobalBroadcastEnabled,
|
||||
s.canUseGlobalBroadcast,
|
||||
s.onToggleGlobalBroadcastRef,
|
||||
]);
|
||||
|
||||
return <TerminalLayerView ctx={ctx} />;
|
||||
|
||||
@@ -123,6 +123,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
onToggleGlobalBroadcast,
|
||||
t,
|
||||
} = ctx;
|
||||
|
||||
@@ -255,6 +258,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs={onReorderTabs}
|
||||
onStartSessionDrag={onStartSessionDrag}
|
||||
onEndSessionDrag={onEndSessionDrag}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
/>
|
||||
{!isFocusMode && activeResizers.map((handle: any) => {
|
||||
const isVertical = handle.direction === 'vertical';
|
||||
|
||||
@@ -430,6 +430,9 @@ const WORKSPACE_CTX_KEYS = [
|
||||
'onReorderTabs',
|
||||
'onStartSessionDrag',
|
||||
'onEndSessionDrag',
|
||||
'isGlobalBroadcastEnabled',
|
||||
'canUseGlobalBroadcast',
|
||||
'onToggleGlobalBroadcast',
|
||||
] as const;
|
||||
|
||||
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
|
||||
|
||||
@@ -73,6 +73,9 @@ export const terminalLayerAreEqual = (
|
||||
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&
|
||||
prev.isBroadcastEnabled === next.isBroadcastEnabled &&
|
||||
prev.onToggleBroadcast === next.onToggleBroadcast &&
|
||||
prev.isGlobalBroadcastEnabled === next.isGlobalBroadcastEnabled &&
|
||||
prev.canUseGlobalBroadcast === next.canUseGlobalBroadcast &&
|
||||
prev.onToggleGlobalBroadcast === next.onToggleGlobalBroadcast &&
|
||||
prev.updateSnippets === next.updateSnippets &&
|
||||
prev.updateSnippetPackages === next.updateSnippetPackages &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
|
||||
@@ -227,14 +227,15 @@ test("hidden terminal layers measure once when their layout must stay active", (
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("inactive terminal pane keeps rendering when hibernate is disabled", () => {
|
||||
test("inactive terminal pane parks its screen without changing live dimensions", () => {
|
||||
const inactiveStyle = resolveInactiveTerminalPaneStyle(
|
||||
{ left: "40px", top: 0, width: "640px", height: "480px" },
|
||||
{ width: 1180, height: 720 },
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(inactiveStyle.left, "40px");
|
||||
assert.equal(inactiveStyle.left, 0);
|
||||
assert.equal(inactiveStyle.transform, "translateX(calc(-100vw - 100%))");
|
||||
assert.equal(inactiveStyle.top, 0);
|
||||
assert.equal(inactiveStyle.visibility, "visible");
|
||||
assert.equal(inactiveStyle.pointerEvents, "none");
|
||||
|
||||
@@ -22,6 +22,7 @@ export type TerminalPaneStyle = {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
visibility?: string;
|
||||
transform?: string;
|
||||
pointerEvents?: string;
|
||||
zIndex?: number;
|
||||
};
|
||||
@@ -65,6 +66,14 @@ export function resolveInactiveTerminalPaneStyle<T extends TerminalPaneStyle>(
|
||||
): T {
|
||||
return {
|
||||
...layoutStyle,
|
||||
// Keep the measured box and live parser, but move the screen outside the
|
||||
// viewport so xterm's IntersectionObserver pauses rendering. Occlusion and
|
||||
// visibility:hidden alone do not stop xterm's WebGL refreshes.
|
||||
// Reset cached split offsets before translating: they can exceed the current
|
||||
// viewport after a window shrinks. Translate by both pane and viewport width,
|
||||
// including pinned panes wider than a window resized in the background.
|
||||
left: 0,
|
||||
transform: "translateX(calc(-100vw - 100%))",
|
||||
visibility: hibernateHiddenTabs ? "hidden" : "visible",
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Background terminal rendering (#3278)
|
||||
|
||||
Inactive panes retain their live terminal and measured dimensions, but move
|
||||
outside the viewport. This lets xterm's IntersectionObserver stop painting
|
||||
without stopping output parsing or rebuilding the terminal on every tab switch.
|
||||
The existing reveal, resize and WebGL recovery paths remain responsible for
|
||||
restoring the current screen. No new output queue or lossy truncation is added.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Run `npm run test:terminal-background-rendering` with a graphical Electron
|
||||
session (Linux CI uses xvfb). It bundles the production runtime and inactive
|
||||
pane style helper, creates five real terminals, and verifies:
|
||||
|
||||
- The visible terminal paints while all four background terminals do not.
|
||||
- Output is already parsed in each hidden terminal and dimensions stay valid.
|
||||
- Twelve hide/reveal cycles repaint newly received output.
|
||||
- A window-area resize does not collapse the hidden terminal's measured width.
|
||||
- Cursor-addressed alternate-screen output survives hiding and a reveal resize.
|
||||
- Two visible split panes paint while the remaining hidden panes do not.
|
||||
|
||||
Before this change, the first visibility assertion fails: each background pane
|
||||
paints 12 frames instead of zero. The test does not claim to reproduce the
|
||||
reporter's delayed high-CPU condition, and direct writes in this harness do not
|
||||
exercise the complete SSH/output transport path.
|
||||
|
||||
## Local application validation
|
||||
|
||||
A separate Netcatty development instance with an isolated profile was tested on
|
||||
macOS (M2 Max, 32GB), using five real local shell sessions. Four sessions emitted
|
||||
continuous logs. The UI was exercised through tab switching, split creation,
|
||||
focus mode, detaching back to a tab, closing the extra split, and window resizing.
|
||||
Actual screen captures were checked for restored content. CPU comparisons use
|
||||
that instance's renderer and GPU process metrics, not other applications.
|
||||
|
||||
This is a CPU optimization for invisible panes. It does not promise lower
|
||||
history/GPU memory usage, nor establish the root cause of every #3278 report.
|
||||
Remote SSH, Windows/Linux interactive recovery and the reporter's M4 environment
|
||||
still require separate validation; this PR should reference rather than close
|
||||
that issue automatically.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Folder completion after same-ID ownership handoff
|
||||
|
||||
Both normal directory transfer and dedicated directory recovery can receive a
|
||||
superseded stream result: another invocation now owns the same child transfer.
|
||||
The old caller must wait for that owner rather than report premature completion.
|
||||
Previously it polled the visible child row. Completed rows are compacted into
|
||||
parent checkpoints, so a completion arriving before the superseded reply removes
|
||||
the row first. Polling then waits forever; a stale panel row can mask this further.
|
||||
|
||||
Two regressions use the actual React directory hook / dedicated recovery entrypoint
|
||||
and actual store compaction. Each records one completed file in the parent while
|
||||
the transfer operation remains pending on the baseline.
|
||||
|
||||
The fix registers a bounded settlement observation before starting the stream.
|
||||
The store captures terminal state for exact observed file identities before
|
||||
history compaction. One shared helper waits for the actual owner in both paths,
|
||||
then releases its observation on success, failure or cancellation. There is no
|
||||
persistent per-file tombstone list and no inference that a missing row means
|
||||
success. Reused IDs with different indexed file identities are not evidence.
|
||||
|
||||
This addresses a separately reproduced folder-never-settles condition relevant
|
||||
to #2568 and #3155. It does not establish the original reporters' precise cause.
|
||||
|
||||
Large-history recovery follow-up: stream lifecycle events carry the current child hierarchy identity. Before dispatch, the store admits the explicit retry into the current row without a full history compaction, so a batched old failed row cannot reject the new completion. Admission distinguishes pause waiting, cancellation, identity conflict and exact prior completion; active lifecycle epochs and newer pause/cancel intent remain protected.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Local transfer publication safety audit
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
The completed download moves an existing destination to a backup, checks that
|
||||
its pathname is unoccupied, then renames the prepared download into place.
|
||||
Another program can save to that pathname between the check and rename. The
|
||||
rename overwrites those bytes and successful cleanup deletes the original
|
||||
backup. Backup restoration and post-publication rollback have equivalent races.
|
||||
|
||||
Real filesystem regressions inject concurrent creation at the actual final
|
||||
publication and restoration boundary. Both lose concurrent contents on the
|
||||
baseline. An explicitly absent destination can also appear after validation and
|
||||
be incorrectly moved aside. These tests fail before the change.
|
||||
|
||||
## Contract and design
|
||||
|
||||
- Prepared data and an original backup remain private sibling files.
|
||||
- Publication and backup restoration use the same no-overwrite primitive.
|
||||
- On hardlink-capable filesystems, linking publishes complete bytes atomically.
|
||||
- On filesystems without that operation, exclusive open plus writes through the
|
||||
owned handle preserves compatibility without overwriting another destination.
|
||||
This fallback exposes partial contents during copying; it does not promise
|
||||
atomic visibility. It never deletes the destination pathname on failure.
|
||||
- Failed fallback copies retain complete prepared data and original backup, with
|
||||
their locations in the error. A concurrent replacement is never removed.
|
||||
- Successful publication is the commit boundary. Cancellation checked before it
|
||||
restores the original where possible. Cancellation arriving after publication
|
||||
does not attempt unsafe pathname-based rollback.
|
||||
- A validated absent target is distinct from an omitted validation callback.
|
||||
- This change does not claim to prevent another program writing through an
|
||||
already-open handle or provide durable power-loss transactions.
|
||||
|
||||
## Validation
|
||||
|
||||
The focused tests cover publication and restoration boundary races, absent
|
||||
validation, normal mode-preserving replacement, early/late cancellation,
|
||||
unsupported-hardlink fallback and write failure with concurrent replacement.
|
||||
Real unsupported-filesystem hardware has not been exercised; the fallback uses
|
||||
real files with hardlink capability failure injected.
|
||||
|
||||
Related audit themes: #3186 replacement attributes and #3213 interrupted recovery.
|
||||
These reports do not establish the cause of this separately reproduced defect.
|
||||
@@ -0,0 +1,41 @@
|
||||
# SFTP restart recovery must not guess another server
|
||||
|
||||
Audit baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
`resolveHostForTransferEndpoint` used a display-name/hostname search when a
|
||||
recorded host id no longer existed, and returned the first match when multiple
|
||||
hosts shared a name. Dedicated resume used those credentials to open the target
|
||||
and start uploading. A saved task for a deleted server could therefore send
|
||||
its source file to an unrelated same-named server. Both endpoint directions and
|
||||
folder recovery use this resolver.
|
||||
|
||||
Two regressions invoke `resumeTransferWithDedicatedSession`, with the real
|
||||
resolver, scheduler, credential construction, and resume path but instrumented
|
||||
bridge I/O. Before the fix, both selected an unrelated endpoint and called
|
||||
`startStreamTransfer`: a deleted recorded id with one same-named replacement,
|
||||
and a legacy task without an id with two same-named hosts. Both now stop before
|
||||
opening or uploading.
|
||||
|
||||
## Fix contract
|
||||
|
||||
- A recorded host id is authoritative; absence must not fall back to a name.
|
||||
- Legacy tasks without an id may resolve a name only if exactly one host matches.
|
||||
- Preserve exact-id recovery, unique-name legacy recovery, and a still-live
|
||||
original session when vault credentials are absent.
|
||||
- Never select an arbitrary member of an ambiguous name match.
|
||||
|
||||
This does not freeze future edits to the connection options of an existing host
|
||||
id; a persisted immutable endpoint snapshot would be a separate schema and
|
||||
migration change. It also does not claim to explain all historical restart
|
||||
failures from issue #3213 or #2638.
|
||||
|
||||
## Verification
|
||||
|
||||
`node --test --import tsx application/state/sftp/dedicatedTransferResume.test.ts`:
|
||||
39 passed, including both regressions and existing single-file/folder restart,
|
||||
remote-to-remote, source-validation, and live-session fallback tests.
|
||||
|
||||
Broader SFTP audit remains in progress in the sibling `codex/sftp-transfer-audit`
|
||||
worktree; its first control-ordering fix is PR #3284.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Shared SSH connection cancellation audit
|
||||
|
||||
Cancelling a transfer while its isolated SFTP channel is opening calls the
|
||||
bounded-open abort path. Previously that path ended/destroyed the physical SSH
|
||||
transport. Production pooled SFTP sessions normally share a terminal SSH
|
||||
connection, so cancelling one transfer can disconnect terminals, browsing and
|
||||
other transfers. The same teardown happened on the channel-open deadline.
|
||||
|
||||
The real startTransfer/cancelTransfer path reproduces physical end/destroy calls;
|
||||
its old test double lacked those methods and could not detect the problem.
|
||||
|
||||
The repair rejects the abandoned caller promptly, retains the shared transport,
|
||||
closes a channel arriving after abandonment, and blocks further channel-open
|
||||
allocation on that transport while any abandoned request remains unresolved.
|
||||
This bounds retry accumulation without queuing controls behind a transfer.
|
||||
Healthy parallel opens remain allowed; callback settlement or transport closure
|
||||
releases the abandoned-open bookkeeping. Existing active channels remain usable.
|
||||
|
||||
This separately reproduced mechanism is relevant to the connection-loss themes
|
||||
in #2973 and #2832, but does not establish the cause of their VPN/jump-host reports.
|
||||
@@ -0,0 +1,248 @@
|
||||
# SFTP transfer audit (September 2026)
|
||||
|
||||
Baseline: `7b964ead21c1e176999557147914f4975a63f5c8`.
|
||||
Status: scoped audit completed, with five independently reproduced defects
|
||||
fixed and submitted. This ledger distinguishes verified paths from reporter
|
||||
environments unavailable to this audit.
|
||||
|
||||
## Completion requirements
|
||||
|
||||
Review the global transfer center, live and restored execution, admission and
|
||||
connection ownership, pause/resume/cancel ordering, durable checkpoints, source
|
||||
and destination integrity, folder replacement/traversal, history retention,
|
||||
transfer responsiveness, and architectural duplication. Cross-reference actual
|
||||
issue reports and mature open-source clients. Reproduce material findings before
|
||||
fixing them. Submit verified fixes as PRs and retain evidence for unresolved
|
||||
reporter-specific conditions.
|
||||
|
||||
## Confirmed finding: stale transfer control can defeat a newer pause
|
||||
|
||||
Priority: high. The user can pause a transfer and have an older request silently
|
||||
restart it, or see the state return to transferring while the pause latch remains
|
||||
set. File transfers and folder children share the affected control path.
|
||||
|
||||
Reproductions (each failed on the baseline before the implementation change):
|
||||
|
||||
- Two overlapping pauses: the first reply incorrectly compensates by calling
|
||||
resume, even though the second pause is still intended.
|
||||
- Resume reply delayed until after a newer pause: old success overwrites the row
|
||||
and its lifecycle epoch.
|
||||
- Dedicated single-file recovery held for the stream lifetime: its duplicated
|
||||
soft-resume implementation independently has the same stale reply problem.
|
||||
- Pipelined upload: resume waits for outstanding writes, receives a newer pause,
|
||||
then resumes anyway when draining finishes.
|
||||
- Worker reply fan-out: old successful resume, failed pause, or rejected pause
|
||||
emits a new resumed event after a later pause succeeded.
|
||||
|
||||
Fix contract: the newest pause/cancel decision wins over older replies; no late
|
||||
result can revive a terminal task. Keep checkpoints and source verification.
|
||||
Do not serialize controls behind a stream lifetime. Reuse the shared soft-resume
|
||||
path for held single-file recovery instead of maintaining another state writer.
|
||||
|
||||
Verification so far:
|
||||
|
||||
- Original selected SFTP suite: 747 passed.
|
||||
- Initial fixes with the three new regressions: 750 passed.
|
||||
- Store and control suite after dedicated-path consolidation: 107 passed.
|
||||
- Worker fan-out suite including three out-of-order cases: 9 passed.
|
||||
- Loopback SSH/SFTP: 12 files of 128 MiB, two file jobs concurrently, each starts
|
||||
from a 32 MiB saved checkpoint and pauses/resumes during the remaining download.
|
||||
All final SHA-256 digests match. Pause acknowledgements 6-15 ms, resume checks
|
||||
1391-1493 ms. These are fixture measurements, not WAN throughput claims.
|
||||
- Independent review found another held-run failure path: rejection or a dead
|
||||
stream response followed by a newer pause during wind-down could start a fresh
|
||||
resume. Two regressions reproduced it; shared rejection handling and epoch
|
||||
checks after wind-down now pass (109 store/control tests).
|
||||
- First full suite: 11377 passed, 7 plugin archive failures traced to missing
|
||||
worktree-local nested dependency (yauzl 3.x). Reinstalled with npm ci; all 29
|
||||
plugin CLI tests then pass. Full rerun passed: 11386 passed, 0 failed,
|
||||
18 skipped.
|
||||
- GitHub review found a cross-window gap: the obsolete worker result was a hard
|
||||
failure to a renderer whose local control epoch had not changed. Explicit
|
||||
superseded outcomes now bypass rollback, compensation and dedicated recovery,
|
||||
including rejected worker requests. Updated focused suite: 122 passed.
|
||||
- Production build and lint pass after that follow-up; two independent reviewers
|
||||
found no actionable follow-up issues.
|
||||
- Browser fixture exercised the actual transfer-center component/store with
|
||||
simulated transport: pause all, resume all, individual pause, paused filter,
|
||||
and cancel. State/buttons matched; no browser console errors. This is not a
|
||||
full Electron connection-path test.
|
||||
- Separate real loopback SSH/SFTP audit experiments killed the transferring child
|
||||
process with SIGKILL and resumed the saved checkpoint in a fresh child process.
|
||||
Both 32 MiB download and upload completed with matching SHA-256. Persisted
|
||||
checkpoints were 30670848 and 14024704 bytes respectively. This validates the
|
||||
transfer engine and disk staging across process death, not the renderer's
|
||||
automatic history restoration or a real VPN/jump-host environment.
|
||||
|
||||
## Submitted fixes
|
||||
|
||||
| PR | Confirmed failure | Architectural change |
|
||||
| --- | --- | --- |
|
||||
| [3284](https://github.com/binaricat/Netcatty/pull/3284) | Late pause/resume replies revive a newer paused/cancelled task or overwrite its visible state, including cross-window and folder watcher paths | Shared held-file resume handling; obsolete controls carry the winning action to reconcile local barriers; compensation checks current intent rather than assuming every epoch change means resume. |
|
||||
| [3285](https://github.com/binaricat/Netcatty/pull/3285) | Deleted recorded host or duplicate legacy display names can resume an upload against another saved server | Exact host-ID recovery; unique-match-only legacy resolution; preserve live-session recovery. |
|
||||
| [3286](https://github.com/binaricat/Netcatty/pull/3286) | Publication/restoration can overwrite a concurrently saved local file; rollback can delete a replacement | One exclusive publication helper, a clear commit boundary, preserved recovery artifacts on conflicts or incomplete fallback copying. |
|
||||
| [3287](https://github.com/binaricat/Netcatty/pull/3287) | Folder stays active after child completion was compacted out of visible history | One bounded settlement observer/helper for both live transfer and recovery; no persistent tombstone history. |
|
||||
| [3288](https://github.com/binaricat/Netcatty/pull/3288) | Cancelling or timing out channel initialization disconnects shared SSH users | Channel cancellation is separated from shared-transport ownership; abandoned initialization is bounded until settlement. |
|
||||
|
||||
Additional engine experiments used actual loopback SSH/SFTP, killed the child
|
||||
process, and resumed in a fresh process for remote-to-remote transfers. Both the
|
||||
download phase and upload phase passed final 32 MiB SHA-256 comparison. The
|
||||
upload-phase checkpoint was 16777216 bytes. These complement direct upload and
|
||||
download recovery, not full-app history restoration or reporter confirmation.
|
||||
|
||||
## Historical issue evidence fetched in this audit
|
||||
|
||||
| Issue | Reported condition | Audit treatment |
|
||||
| --- | --- | --- |
|
||||
| [3213](https://github.com/binaricat/Netcatty/issues/3213) | macOS, 10+ files of 100-200 MB; pause/resume and force-quit recovery unreliable | Control ordering reproduced separately; source direction and server still absent from report. Do not claim reporter confirmation. |
|
||||
| [3155](https://github.com/binaricat/Netcatty/issues/3155) | Windows, many-file transfer freezes; no count or logs | Recheck bounded discovery, scheduling, publication and history work. |
|
||||
| [2973](https://github.com/binaricat/Netcatty/issues/2973) | VPN uploads disconnect SSH and SFTP; transfer spinner continues; later inode VPN report | Check transport loss and settlement. Network/security cause not established by the available logs. |
|
||||
| [3186](https://github.com/binaricat/Netcatty/issues/3186) | Replacement changes permissions on 1.1.82 | Check mode/owner behavior on each replacement path; existing bot explanations are not proof. |
|
||||
| [3149](https://github.com/binaricat/Netcatty/issues/3149) | Windows proxy + terminal drag-upload reports No such file | Check target pinning, path encoding, session and retry behavior. |
|
||||
| [2832](https://github.com/binaricat/Netcatty/issues/2832) | Browsing works through VPN/jump host, transfers wait indefinitely, cancel works | Check dedicated connection admission/authentication/timeout. |
|
||||
| [2568](https://github.com/binaricat/Netcatty/issues/2568) | Folder copy reaches 100% but remains active; pause ineffective | Check parent settlement and directory checkpoints. |
|
||||
| [2458](https://github.com/binaricat/Netcatty/issues/2458) | Windows 1 GiB upload continues after Pause/Pause all from both terminal sidebar and SFTP tab | Motivates transport-plus-visible-state regressions; current delayed-control defects independently reproduced. |
|
||||
| [3031](https://github.com/binaricat/Netcatty/issues/3031) | macOS jump-host/proxy drag upload: no such file | Missing full error, protocol, target path and direct-connect comparison prevent attribution. |
|
||||
| [2556](https://github.com/binaricat/Netcatty/issues/2556) | Windows download of 1.9 GiB from local Linux VM; separate many-small-files progress complaint | Preserve verification correctness; distinguish network payload from verification and incremental discovery. No comparative reporter throughput available. |
|
||||
| [2886](https://github.com/binaricat/Netcatty/issues/2886) | sudo terminal drop denied while SFTP upload succeeds | Existing terminal fallback fix is separate; contradictory bot explanations are not evidence of identity or permission correctness. |
|
||||
| [2638](https://github.com/binaricat/Netcatty/issues/2638) | Recovery after network failure | Verify restore end to end; UI availability alone is insufficient. |
|
||||
|
||||
Issue state (open/closed) and automated comments do not substitute for runtime
|
||||
proof. Initial title search hit its 100-result cap. Expanded SFTP search returned
|
||||
308 issue matches, including reports without SFTP in the title. The reports
|
||||
listed above were read as representative symptom clusters; this was not a claim
|
||||
to have investigated all 308 matching issues individually.
|
||||
|
||||
## External reference points
|
||||
|
||||
- [Tabby SFTP implementation](https://github.com/Eugeny/tabby/blob/master/tabby-ssh/src/session/sftp.ts):
|
||||
stream transfer and temporary upload destination before rename. Useful as a
|
||||
separation-of-concerns comparison; this file does not establish durable
|
||||
restart recovery and must not be treated as a complete replacement design.
|
||||
- [WinSCP resume documentation](https://winscp.net/eng/docs/resume): partial-file
|
||||
discovery and temporary filenames support interruption recovery; temporary
|
||||
creation can be unavailable under some permission layouts.
|
||||
- [Electerm transfer implementation](https://github.com/electerm/electerm/blob/master/src/app/server/transfer.js):
|
||||
separates a per-transfer object from queue/UI state, opens a separate SFTP
|
||||
channel on the existing SSH connection when available, and uses 32 KiB chunks
|
||||
with 64 requests. Its live pause flag stops scheduling additional reads. Its
|
||||
ordinary transfer opens the destination with `w`; it is not evidence for
|
||||
durable checkpoint recovery. Retain Netcatty's staging and contiguous-offset
|
||||
protections when simplifying ownership.
|
||||
- [Electerm action store](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transports-action-store.jsx)
|
||||
counts pending initializations toward admission; its
|
||||
[mutation queue](https://github.com/electerm/electerm/blob/master/src/client/components/file-transfer/transfer-queue.jsx)
|
||||
distinguishes completion of a state update from completion of transfer I/O.
|
||||
This supports keeping control requests independent of long-lived transfer runs.
|
||||
|
||||
## Coverage and practical limits
|
||||
|
||||
| Area | Evidence and result |
|
||||
| --- | --- |
|
||||
| Transfer controls and global center | Actual component/store browser interactions; delayed replies, direct/worker, folder watcher and cross-window action regressions. PR 3284 fixes confirmed ordering failures, including root-state reconciliation and failed resume settlement. |
|
||||
| Upload/download and remote-to-remote durability | Four real SSH/SFTP fresh-process recovery experiments, including both remote-to-remote phases, all compare final bytes by SHA-256. Existing transfer tests cover changed source, sparse ranges, cancellation and publication. |
|
||||
| Local/SCP publication | Real filesystem conflict regressions and SCP abort tests; one shared no-overwrite publication helper. Copy fallback retains mode/timestamps and recovery files on failure. Actual FAT/exFAT hardware was unavailable. |
|
||||
| Folder traversal and final state | Discovery concurrency, replacement/rollback, manifest, pause latch, skip/conflict and history tests; actual live/recovery entrypoints reproduce compacted-child hang and pass after PR 3287. |
|
||||
| History, restart and ownership | Store history/large-manifest and dedicated recovery tests; PR 3285 rejects missing or ambiguous saved host identity. Full Electron quit/relaunch with restored user credentials was not exercised; engine-process recovery was. Editing endpoint details under an unchanged saved host ID remains a documented identity-model limitation. |
|
||||
| Connection sharing and cleanup | Connection pool, lease and initialization tests; real delayed SFTP OPEN followed by cancellation leaves browsing alive and closes the late channel after PR 3288. VPN/MFA/security-product and original jump-host environments were unavailable. |
|
||||
| Responsiveness | Actual browser controls, list virtualization, bounded directory discovery and 50k history cases; scheduler smoke figures below are diagnostic, not end-user throughput or Windows runtime proof. |
|
||||
|
||||
No additional data-loss defect was established by these checks. User reports of
|
||||
VPN-specific hangs, proxy drag-upload path errors and Windows throughput/freezes
|
||||
still require their original environments and discriminating logs. They are not
|
||||
marked resolved solely because a related mechanism was repaired here.
|
||||
|
||||
## Validation summary
|
||||
|
||||
Each fix received two independent local reviews. Review-discovered gaps were
|
||||
corrected and checked again. PR 3284 latest focused control/worker tests: 35 pass;
|
||||
two actual direct-resume tests include the full undrained timeout and pass.
|
||||
PR 3285 full suite: 11,379 pass, 18 skip. PR 3286 full suite before its metadata
|
||||
follow-up: 11,386 pass, 18 skip; latest publication/abort tests: 34 pass.
|
||||
PR 3287 serial full suite: 11,383 pass, 18 skip; production build passes.
|
||||
PR 3288 real SSH cancellation experiment and nine channel tests pass; its full
|
||||
suite has 11,380 passes and one unrelated terminal write-queue timing failure,
|
||||
independently reproduced on the unchanged baseline. That entire test file passes
|
||||
alone (81 tests). A separate integration worktree combines all five branches;
|
||||
one test-only insertion conflict was resolved by retaining both regressions.
|
||||
The folder test was subsequently relocated in its own PR; a fresh merge-tree
|
||||
check confirms it combines with the other control tests without a conflict.
|
||||
|
||||
## Architectural assessment
|
||||
|
||||
The staged-file and contiguous-checkpoint design is worth retaining. A simpler
|
||||
client that writes directly into the destination is not an equivalent safety
|
||||
reference. The most important simplification is ownership: a long-lived stream
|
||||
must not block its own control requests, and one authoritative lifecycle must
|
||||
inform every window. PR 3284 removes a duplicate state-writing recovery path
|
||||
and makes obsolete controls explicit.
|
||||
|
||||
Publication is a second useful module boundary. PR 3286 replaces repeated
|
||||
check/rename/check/rollback branches with one no-overwrite operation shared by
|
||||
publication and restoration. On hardlink-capable filesystems its commit is
|
||||
atomic; the documented exclusive-copy fallback trades atomic visibility for
|
||||
compatibility while preserving recoverable data and cancellation.
|
||||
|
||||
The current transfer list virtualizes beyond 20 visible tasks, directory listing
|
||||
has a tree-wide concurrency gate, and history migration yields cooperatively.
|
||||
A scheduler-only smoke workload of 1000 and 10000 immediately completing jobs
|
||||
finished in 87 ms and 4362 ms, with maximum timer gaps of 15 ms and 111 ms during
|
||||
other validation activity. The queue still scans for eligibility/priority/fairness;
|
||||
this is a follow-up performance lead, not proof of the Windows freeze reports or
|
||||
a standalone throughput benchmark. Folder fan-out bounds ordinary queue growth.
|
||||
|
||||
Do not weaken full saved-prefix verification merely to improve resume timings.
|
||||
The loopback experiments include extra verification reads; those bytes are not
|
||||
payload throughput. A future optimization needs proof that changed sources and
|
||||
out-of-order durable ranges still cannot produce mixed file contents.
|
||||
|
||||
PR 3287 makes final transfer ownership independent of visible history retention:
|
||||
observers capture terminal settlement before compaction and are disposed after
|
||||
the waiting invocation exits. PR 3288 keeps per-channel cancellation from
|
||||
claiming ownership of a shared SSH connection. These targeted boundaries reduce
|
||||
duplicated policy without replacing the working durable-transfer engine.
|
||||
|
||||
Combined-engine live checks also passed: shared-connection cancellation retains
|
||||
working browsing and closes the late channel; a 32 MiB download killed at a
|
||||
16809984-byte checkpoint resumes in a new process to the expected SHA-256. An
|
||||
initial 8 MiB fixture completed before an intermediate checkpoint was sampled;
|
||||
the larger paced fixture supplies the intended process-death evidence.
|
||||
|
||||
## Final combined validation
|
||||
|
||||
The combined full suite passed: 11,439 passed, 0 failed, 18 skipped. Lint and
|
||||
production build passed. This run includes all five fixes and the failed-child
|
||||
admission follow-up. A final control-aggregation follow-up then corrected mixed
|
||||
success/superseded resume outcomes and current IPC rejection handling; both
|
||||
independent reviewers approved it. After bringing that follow-up and the
|
||||
test-only relocation into the integration checkout, all 202 affected control,
|
||||
store, folder, observation, recovery and worker tests passed. Final lint/build
|
||||
are recorded in the PR descriptions. No production changes were made in the
|
||||
integration checkout; the only merge edits preserve independent regression tests.
|
||||
|
||||
The initial combined run stopped after an older exact-result assertion rejected
|
||||
the newly structured superseded response and left its fixture alive. The
|
||||
assertion was updated, its fixture completed, and the successful full run above
|
||||
supersedes that aborted run. Tests were not removed or weakened.
|
||||
|
||||
## Delivery follow-ups
|
||||
|
||||
The remote publication branch received an automated patch during final review.
|
||||
It was fetched and checked rather than assuming earlier validation covered it.
|
||||
Two actual filesystem regressions showed lost normal mtime preservation and a
|
||||
remaining final-path stamping race. The repair prepares staged timestamps before
|
||||
publication and restrictive chmod, skips post-commit stamping of that pathname,
|
||||
and uses a verified file handle on non-promoted local paths. Fallback close
|
||||
errors also retain recovery artifacts. Publication/bridge/SCP checks: 31 pass;
|
||||
independent targeted review: 10 pass.
|
||||
|
||||
Fresh dedicated recovery now recognizes only the exact unchanged paused child
|
||||
rows captured at its entry as old pauses eligible to resume. A newer row,
|
||||
pausing state, root/child latch or cancellation still blocks admission. This
|
||||
avoids a 4096-row batching deadlock without removing later pause protection.
|
||||
Actual batched paused-history and later-pause regressions pass; 163 relevant
|
||||
tests and an independent 60-test review pass.
|
||||
|
||||
The delivery rerun includes all follow-ups above: 11,452 tests passed, 0 failed,
|
||||
18 skipped (182.9 seconds, four test processes). This is the final combined
|
||||
full-suite result and supersedes the earlier intermediate counts.
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveTerminalBroadcastTargetIds } from './terminalBroadcast.ts';
|
||||
import type { TerminalSession } from './models.ts';
|
||||
|
||||
const session = (
|
||||
id: string,
|
||||
overrides: Pick<TerminalSession, 'workspaceId' | 'hiddenFromTabs'> = {},
|
||||
): Pick<TerminalSession, 'id' | 'workspaceId' | 'hiddenFromTabs'> => ({
|
||||
id,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('workspace broadcast targets only other sessions in the source workspace', () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions: [
|
||||
session('source', { workspaceId: 'workspace-1' }),
|
||||
session('peer', { workspaceId: 'workspace-1' }),
|
||||
session('other-workspace', { workspaceId: 'workspace-2' }),
|
||||
session('orphan'),
|
||||
],
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: true,
|
||||
}),
|
||||
['peer'],
|
||||
);
|
||||
});
|
||||
|
||||
test('global broadcast targets only other visible orphan sessions', () => {
|
||||
const sessions = [
|
||||
session('source'),
|
||||
session('peer'),
|
||||
session('workspace-peer', { workspaceId: 'workspace-1' }),
|
||||
session('hidden-orphan', { hiddenFromTabs: true }),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: true,
|
||||
}),
|
||||
['peer'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('direct target IDs bypass workspace and global mode selection', () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions: [
|
||||
session('source', { workspaceId: 'workspace-1' }),
|
||||
session('peer', { workspaceId: 'workspace-1' }),
|
||||
session('direct-target'),
|
||||
],
|
||||
sourceSessionId: 'source',
|
||||
globalBroadcastEnabled: false,
|
||||
directTargetSessionIds: ['direct-target'],
|
||||
}),
|
||||
['direct-target'],
|
||||
);
|
||||
});
|
||||
|
||||
test('missing or hidden global sources do not fan out', () => {
|
||||
const sessions = [session('hidden-source', { hiddenFromTabs: true }), session('peer')];
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'missing',
|
||||
globalBroadcastEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId: 'hidden-source',
|
||||
globalBroadcastEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TerminalSession } from './models';
|
||||
|
||||
type BroadcastSession = Pick<TerminalSession, 'id' | 'workspaceId' | 'hiddenFromTabs'>;
|
||||
|
||||
export function resolveTerminalBroadcastTargetIds({
|
||||
sessions,
|
||||
sourceSessionId,
|
||||
globalBroadcastEnabled,
|
||||
directTargetSessionIds,
|
||||
}: {
|
||||
sessions: readonly BroadcastSession[];
|
||||
sourceSessionId: string;
|
||||
globalBroadcastEnabled: boolean;
|
||||
directTargetSessionIds?: readonly string[];
|
||||
}): string[] {
|
||||
if (directTargetSessionIds) {
|
||||
const directTargetIds = new Set(directTargetSessionIds);
|
||||
return sessions
|
||||
.filter((session) => directTargetIds.has(session.id))
|
||||
.map((session) => session.id);
|
||||
}
|
||||
|
||||
const sourceSession = sessions.find((session) => session.id === sourceSessionId);
|
||||
if (!sourceSession) return [];
|
||||
|
||||
if (sourceSession.workspaceId) {
|
||||
return sessions
|
||||
.filter((session) => (
|
||||
session.workspaceId === sourceSession.workspaceId
|
||||
&& session.id !== sourceSessionId
|
||||
))
|
||||
.map((session) => session.id);
|
||||
}
|
||||
|
||||
if (!globalBroadcastEnabled || sourceSession.hiddenFromTabs) return [];
|
||||
|
||||
return sessions
|
||||
.filter((session) => (
|
||||
!session.workspaceId
|
||||
&& !session.hiddenFromTabs
|
||||
&& session.id !== sourceSessionId
|
||||
))
|
||||
.map((session) => session.id);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
// Both fish and POSIX shells accept this command. Inspect the parent of a
|
||||
// short-lived sh in the interactive PTY, rather than the SSH login shell.
|
||||
function buildLiveShellProbe(marker) {
|
||||
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
|
||||
+ `printf "${marker}_P:%s\\n" "$name"`;
|
||||
// Put the job marker near the start of the echo, as the existing wrappers
|
||||
// do, so preload can suppress it before a long command is split into chunks.
|
||||
// The builtin completion marker still runs if sh cannot launch. Leave it
|
||||
// unterminated so preload hides the intermediate prompt and wrapper echo
|
||||
// on the same marker-bearing line, including across output chunks.
|
||||
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
|
||||
}
|
||||
|
||||
function parseLiveShellProbe(output, marker) {
|
||||
const lines = String(output).replace(/\r/g, "\n").split("\n");
|
||||
if (!lines.some((line) => line.startsWith(`${marker}_Q`))) return null;
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith(`${marker}_P:`)) continue;
|
||||
const name = line.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
|
||||
return {
|
||||
kind: name === "fish" ? "fish"
|
||||
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
|
||||
};
|
||||
}
|
||||
return { kind: null };
|
||||
}
|
||||
|
||||
module.exports = { buildLiveShellProbe, parseLiveShellProbe };
|
||||
@@ -0,0 +1,164 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { startPtyJob } = require('./ptyExec.cjs');
|
||||
|
||||
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
|
||||
const marker = '__NCMCP_probe__';
|
||||
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
|
||||
assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null);
|
||||
assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null);
|
||||
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null });
|
||||
});
|
||||
|
||||
test('probe waits for complete reply before choosing the first wrapper', async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
|
||||
assert.equal(writes.length, 1);
|
||||
assert.ok(!writes[0].includes('printf success'));
|
||||
pty.emit('data', `${job.marker}_P:fi`);
|
||||
assert.equal(writes.length, 1);
|
||||
pty.emit('data', `sh\r\n${job.marker}_Q`);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.ok(writes[1].includes('function __ncmcp_int'));
|
||||
pty.emit('data', `${job.marker}_S\r\nsuccess\r\n${job.marker}_E:0\r\n`);
|
||||
assert.equal((await job.resultPromise).exitCode, 0);
|
||||
});
|
||||
|
||||
test('probe wrapper keeps the start marker separate when terminal echo is disabled', async () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
const script = String(data).replace(/^\x15\x0b/, '');
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
|
||||
};
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
});
|
||||
|
||||
test('cancelled probe never injects user command after a late reply', async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, 'touch should-not-run', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
job.cancel();
|
||||
pty.emit('data', `${job.marker}_P:fish\n${job.marker}_Q`);
|
||||
pty.emit('close');
|
||||
await job.resultPromise;
|
||||
assert.ok(writes.every((data) => !data.includes('touch should-not-run')));
|
||||
});
|
||||
|
||||
test('cancelling a probe completes when the idle prompt returns', async () => {
|
||||
const pty = new EventEmitter();
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, 'should-not-run', {
|
||||
probeLiveShell: true, expectedPrompt: 'user@host:~$ ', timeoutMs: 1000,
|
||||
});
|
||||
job.cancel();
|
||||
pty.emit('data', 'user@host:~$ ');
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, 'Cancelled');
|
||||
});
|
||||
|
||||
test('real PTY: first execution in startup fish and return to parent shells', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 30000,
|
||||
}, async () => {
|
||||
const nodePty = require('node-pty');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
for (const [parent, startup] of [['/bin/bash', false], ['/bin/zsh', false], ['/bin/bash', true]]) {
|
||||
const fishCommand = "fish --no-config -C 'function fish_prompt; printf FISH_READY\\>\\ ; end'";
|
||||
const terminal = nodePty.spawn(parent, startup ? ['-c', `exec ${fishCommand}`] : parent.endsWith('bash') ? ['--noprofile', '--norc'] : ['-f'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'PARENT_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
terminal.onData((data) => { output += data; });
|
||||
const waitFor = async (text) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!output.includes(text)) {
|
||||
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
output = '';
|
||||
};
|
||||
try {
|
||||
if (!startup) {
|
||||
await waitFor('PARENT_READY>');
|
||||
terminal.write('echo earlier-command\r');
|
||||
await waitFor('PARENT_READY>');
|
||||
terminal.write(`${fishCommand}\r`);
|
||||
}
|
||||
await waitFor('FISH_READY>');
|
||||
const result = await execViaPty(terminal, 'printf first-command-success', {
|
||||
loginShellHint: 'posix', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000, enforceWallTimeout: true,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /first-command-success/);
|
||||
if (startup) {
|
||||
await waitFor('FISH_READY>');
|
||||
terminal.write('set -gx PATH /nonexistent\r');
|
||||
await waitFor('FISH_READY>');
|
||||
const fallback = await execViaPty(terminal, 'printf fallback-success', {
|
||||
shellKind: 'fish', probeLiveShell: true, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(fallback.exitCode, 0, JSON.stringify(fallback));
|
||||
assert.match(fallback.stdout, /fallback-success/);
|
||||
continue;
|
||||
}
|
||||
await waitFor('FISH_READY>');
|
||||
terminal.write('exit\r');
|
||||
await waitFor('PARENT_READY>');
|
||||
const returned = await execViaPty(terminal, 'printf parent-command-success', {
|
||||
loginShellHint: 'fish', probeLiveShell: true, stripMarkers: true, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(returned.exitCode, 0, JSON.stringify(returned));
|
||||
assert.match(returned.stdout, /parent-command-success/);
|
||||
} finally {
|
||||
terminal.kill();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real PTY: echo-disabled bash completes output without a trailing newline', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'NOECHO_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
pty.onData((data) => { output += data; });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!output.includes('NOECHO_READY>')) {
|
||||
if (Date.now() > deadline) throw new Error('No echo-disabled shell prompt');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
output = '';
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
pty.write('stty -echo\n');
|
||||
await ready();
|
||||
const result = await require('./ptyExec.cjs').execViaPty(pty, 'printf noecho-success', {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /noecho-success/);
|
||||
assert.ok(!output.includes('command sh -c'), 'the terminal must actually suppress input echo');
|
||||
} finally {
|
||||
pty.kill();
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,8 @@ const {
|
||||
} = require("./ptyExecHelpers.cjs");
|
||||
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
|
||||
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
|
||||
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
|
||||
const END_MARKER_PROMPT_WAIT_MS = 30000;
|
||||
const promptRecoveryPendingPtys = new WeakSet();
|
||||
@@ -54,6 +56,8 @@ function startPtyJob(ptyStream, command, options) {
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
loginShellHint,
|
||||
probeLiveShell = false,
|
||||
onProbeAborted,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
@@ -65,7 +69,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
|
||||
let resolvedShellKind = resolveEffectiveShellKind(shellKind, expectedPrompt, {
|
||||
loginShellHint,
|
||||
});
|
||||
const waitForReturnedPrompt = loginShellHint === "cmd"
|
||||
@@ -88,6 +92,10 @@ function startPtyJob(ptyStream, command, options) {
|
||||
const CANCEL_RETRY_MS = 5000;
|
||||
const CANCEL_WALL_TIMEOUT_MS = 30000;
|
||||
|
||||
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
|
||||
let probingShell = usesLiveShellProbe;
|
||||
let probeOutput = "";
|
||||
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let preStartOutput = "";
|
||||
@@ -405,6 +413,13 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
// Display cleanup must never prevent command cancellation or completion.
|
||||
}
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(wallTimeoutId);
|
||||
clearStartupTimeout();
|
||||
@@ -513,6 +528,22 @@ function startPtyJob(ptyStream, command, options) {
|
||||
if (!text) return;
|
||||
if (!pendingEnd) armOutputTimeout();
|
||||
|
||||
if (probingShell) {
|
||||
probeOutput = (probeOutput + text).slice(-16384);
|
||||
if (cancelRequested && hasExpectedPromptSuffix(probeOutput, expectedPrompt)) {
|
||||
finish("", -1, "Cancelled");
|
||||
return;
|
||||
}
|
||||
const probe = parseLiveShellProbe(stripAnsi(probeOutput), marker);
|
||||
if (!probe) return;
|
||||
probingShell = false;
|
||||
probeOutput = "";
|
||||
if (probe.kind) resolvedShellKind = probe.kind;
|
||||
if (finished || cancelRequested) return;
|
||||
writeWrappedCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!foundStart) {
|
||||
preStartOutput += text;
|
||||
const combined = pendingStart + text;
|
||||
@@ -698,8 +729,16 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
}
|
||||
|
||||
if (probingShell) {
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
|
||||
} else {
|
||||
writeWrappedCommand();
|
||||
}
|
||||
|
||||
return {
|
||||
marker,
|
||||
|
||||
@@ -957,6 +957,60 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
|
||||
}
|
||||
});
|
||||
|
||||
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
|
||||
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
|
||||
// filter suppresses the redraw fragments containing it, and readline's row
|
||||
// accounting desyncs from the rendered screen — leaving AI command residue
|
||||
// when the user navigates history. The wrapper must delete its own entry.
|
||||
test("posix wrapper removes its own entry from bash history", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["--norc", "-c", 'set -o history; HISTCONTROL=; HISTFILE=/dev/null; history -s "echo user_old"; ' + wrapped + "history"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
assert.match(result.stdout, /HISTORY_PROBE/);
|
||||
// The user's own entry survives...
|
||||
assert.match(result.stdout, /echo user_old/);
|
||||
// ...and the wrapper entry is gone (deletion happens in the same line).
|
||||
const historyListing = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
|
||||
assert.ok(historyListing.length > 0, "expected a history listing");
|
||||
assert.ok(
|
||||
historyListing.every((line) => !line.includes(marker)),
|
||||
`wrapper entry still in history: ${historyListing.join(" | ")}`,
|
||||
);
|
||||
// The cleanup is silent: no stderr leakage into the terminal.
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("posix wrapper history cleanup never deletes the user's previous entry", () => {
|
||||
// HISTCONTROL=ignorespace suppresses the leading-space wrapper entry, so
|
||||
// $HISTCMD points at the user's previous command. The marker match must
|
||||
// fail and leave that entry alone.
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["--norc", "-c", 'set -o history; HISTCONTROL=ignorespace; HISTFILE=/dev/null; history -s "echo user_old"; ' + wrapped + "history"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
assert.match(result.stdout, /echo user_old/);
|
||||
const historyListing = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
|
||||
assert.ok(historyListing.length > 0, "expected a history listing");
|
||||
assert.ok(
|
||||
historyListing.every((line) => !line.includes(marker)),
|
||||
`wrapper entry still in history: ${historyListing.join(" | ")}`,
|
||||
);
|
||||
assert.equal(result.stderr, "");
|
||||
});
|
||||
|
||||
test("execViaChannel registers a pending-cancel marker before the SSH channel opens", () => {
|
||||
// Regression for the IPC-transit race surfaced by codex on #1101
|
||||
// problem 3: if `cancelPtyExecsForSession` runs while we're still
|
||||
@@ -1240,3 +1294,74 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
|
||||
assert.doesNotMatch(result.stdout, /�/u);
|
||||
assert.equal(result.error, "Cancelled");
|
||||
});
|
||||
|
||||
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", "history() { :; }"]) {
|
||||
test(`posix wrapper bypasses history customization: ${customization}`, () => {
|
||||
const marker = "__NCMCP_CUSTOM_HISTORY__";
|
||||
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
|
||||
const result = spawnSync("bash", ["--noprofile", "--norc", "-i"], {
|
||||
input: `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=
|
||||
${customization}
|
||||
builtin history -c
|
||||
echo user_old
|
||||
${wrapped}builtin history
|
||||
exit
|
||||
`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERM: "dumb" },
|
||||
});
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
const entries = result.stdout.split("\n").filter((line) => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some((line) => line.includes("echo user_old")));
|
||||
assert.ok(entries.every((line) => !line.includes(marker)), entries.join("\n"));
|
||||
});
|
||||
}
|
||||
|
||||
test("cancelled live probe resets display once without injecting a command", async () => {
|
||||
for (const callbackThrows of [false, true]) {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
const resets = [];
|
||||
pty.write = (data) => writes.push(data);
|
||||
const job = startPtyJob(pty, "touch should-not-run", {
|
||||
shellKind: "posix",
|
||||
probeLiveShell: true,
|
||||
expectedPrompt: "user@host:~$ ",
|
||||
timeoutMs: 1000,
|
||||
onProbeAborted: (marker) => {
|
||||
resets.push(marker);
|
||||
if (callbackThrows) throw new Error("Renderer closed");
|
||||
},
|
||||
});
|
||||
job.cancel();
|
||||
pty.emit("data", "\r\nuser@host:~$ ");
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
pty.emit("data", `${job.marker}_P:fish\n${job.marker}_Q`);
|
||||
job.cancel();
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
assert.ok(writes.every((data) => !data.includes("touch should-not-run")));
|
||||
assert.ok(writes.every((data) => !data.includes(`${job.marker}_R`)));
|
||||
}
|
||||
});
|
||||
|
||||
test("posix wrapper avoids history expansion in interactive zsh", (t) => {
|
||||
const marker = "__NCMCP_ZSH_HISTORY__";
|
||||
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
|
||||
const result = spawnSync("zsh", ["-f", "-i"], {
|
||||
input: `${wrapped}exit\n`,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TERM: "dumb" },
|
||||
});
|
||||
if (result.error?.code === "ENOENT") {
|
||||
t.skip("zsh is not installed");
|
||||
return;
|
||||
}
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
assert.match(result.stdout, /HISTORY_PROBE/);
|
||||
assert.doesNotMatch(result.stderr, /event not found/);
|
||||
});
|
||||
@@ -198,18 +198,36 @@ function buildPendingInputClearPrefix(shellKind) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker) {
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
const cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
// Issue #3265: without HISTCONTROL=ignorespace the wrapper line is recorded
|
||||
// in bash history. Pressing arrow-up then makes readline redraw that huge
|
||||
// marker-containing line, and the preload __NCMCP_ filter suppresses the
|
||||
// redraw fragments that contain the marker — readline's row accounting
|
||||
// diverges from what the terminal actually rendered, so every subsequent
|
||||
// history-navigation redraw leaves fragments of the AI command on screen.
|
||||
// Match the latest entry before deleting it, preserving user history when
|
||||
// HISTCONTROL=ignorespace skips the wrapper. Read the entry's actual number:
|
||||
// older Bash versions can expose HISTCMD as the next history number.
|
||||
// builtin history bypasses user aliases and functions. The unset-safe guard
|
||||
// leaves non-Bash shells alone, including shells with nounset enabled.
|
||||
// Use ^ for the Bash bracket negation: ! triggers interactive zsh history
|
||||
// expansion before the Bash-only guard can run.
|
||||
const historyCleanup =
|
||||
`[ -n "\${BASH_VERSION-}" ] && { ${marker}_h=$(builtin history 1 2>/dev/null); case "$${marker}_h" in *${marker}*) ${marker}_h=\${${marker}_h#"\${${marker}_h%%[^[:space:]]*}"}; builtin history -d "\${${marker}_h%%[[:space:]]*}" 2>/dev/null ;; esac; }`;
|
||||
return (
|
||||
`${marker}=0; ${cmdAssign}; { printf '%s\\n' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }`
|
||||
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; ${historyCleanup}; (exit $__NCMCP_rc); }`
|
||||
);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
@@ -235,7 +253,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
` set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`printf '${startFormat}' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
@@ -284,7 +302,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return ` ${buildPosixWrapperBody(command, marker)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,11 @@ function registerCattyExecHandlers(ctx) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
"use strict";
|
||||
|
||||
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
|
||||
// An abandoned OPEN cannot be cancelled in SSH, but its physical connection
|
||||
// may own unrelated terminals. Block only additional opens until its callback
|
||||
// or transport closure settles; retain no unbounded retry queue.
|
||||
const abandonedOpens = new WeakMap();
|
||||
|
||||
function abandonOpen(client, token) {
|
||||
let state = abandonedOpens.get(client);
|
||||
if (!state) {
|
||||
state = { tokens: new Set(), onClose: null };
|
||||
state.onClose = () => {
|
||||
if (abandonedOpens.get(client) === state) abandonedOpens.delete(client);
|
||||
state.tokens.clear();
|
||||
};
|
||||
abandonedOpens.set(client, state);
|
||||
client.once?.("close", state.onClose);
|
||||
}
|
||||
state.tokens.add(token);
|
||||
}
|
||||
|
||||
function settleAbandonedOpen(client, token) {
|
||||
const state = abandonedOpens.get(client);
|
||||
if (!state || !state.tokens.delete(token) || state.tokens.size > 0) return;
|
||||
abandonedOpens.delete(client);
|
||||
client.removeListener?.("close", state.onClose);
|
||||
}
|
||||
|
||||
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -24,6 +48,12 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const signal = options.signal || null;
|
||||
if (signal?.aborted) return Promise.reject(createSftpOpenAbortError(signal));
|
||||
if (abandonedOpens.has(sshClient)) {
|
||||
const error = new Error("A previous SFTP channel open is still pending on this connection");
|
||||
error.code = "SFTP_CHANNEL_OPEN_PENDING";
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const setTimeoutFn = options.setTimeoutFn || setTimeout;
|
||||
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
|
||||
const timeoutMs = Math.max(
|
||||
@@ -33,13 +63,15 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let requestPending = false;
|
||||
const token = Symbol("sftp-open");
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
signal?.removeEventListener?.("abort", onAbort);
|
||||
};
|
||||
const finish = (error, channel = null, { invalidateTransport = false } = {}) => {
|
||||
const finish = (error, channel = null, { abandon = false } = {}) => {
|
||||
if (settled) {
|
||||
closeSftpChannel(channel);
|
||||
return false;
|
||||
@@ -47,13 +79,13 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error && channel) closeSftpChannel(channel);
|
||||
if (invalidateTransport) invalidateSshTransport(sshClient);
|
||||
if (abandon && requestPending) abandonOpen(sshClient, token);
|
||||
if (error) reject(error);
|
||||
else resolve(channel);
|
||||
return true;
|
||||
};
|
||||
const onAbort = () => finish(createSftpOpenAbortError(signal), null, {
|
||||
invalidateTransport: true,
|
||||
abandon: true,
|
||||
});
|
||||
|
||||
if (signal?.aborted) {
|
||||
@@ -66,15 +98,20 @@ function openBoundedSftpChannel(sshClient, options = {}) {
|
||||
timer = setTimeoutFn(() => {
|
||||
const error = new Error(`SFTP channel open timed out after ${timeoutMs}ms`);
|
||||
error.code = "SFTP_CHANNEL_OPEN_TIMEOUT";
|
||||
finish(error, null, { invalidateTransport: true });
|
||||
finish(error, null, { abandon: true });
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
requestPending = true;
|
||||
sshClient.sftp((error, channel) => {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
if (error) finish(error, channel || null);
|
||||
else finish(null, channel || null);
|
||||
});
|
||||
} catch (error) {
|
||||
requestPending = false;
|
||||
settleAbandonedOpen(sshClient, token);
|
||||
finish(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ test("bounded SFTP open times out and closes a late channel", async () => {
|
||||
assert.equal(timers.active.size, 1);
|
||||
assert.equal([...timers.active][0].hasRef(), true);
|
||||
await assert.rejects(result, (error) => error.code === "SFTP_CHANNEL_OPEN_TIMEOUT");
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(timers.active.size, 0);
|
||||
|
||||
const channel = createChannel();
|
||||
@@ -72,7 +72,7 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
|
||||
assert.equal([...timers.active][0].hasRef(), true);
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(result, /cancelled/);
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(invalidations, 0, "one channel request must not destroy the shared SSH transport");
|
||||
assert.equal(timers.active.size, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
|
||||
@@ -82,28 +82,44 @@ test("bounded SFTP open cancellation settles immediately and closes a late chann
|
||||
assert.equal(timers.active.size, 0);
|
||||
});
|
||||
|
||||
test("repeated unresponsive SFTP opens cannot retain channel callbacks", async () => {
|
||||
const sshClient = {
|
||||
destroyed: false,
|
||||
pendingChannelCallbacks: [],
|
||||
sftp(callback) {
|
||||
if (this.destroyed) throw new Error("Not connected");
|
||||
this.pendingChannelCallbacks.push(callback);
|
||||
},
|
||||
end() {},
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.pendingChannelCallbacks.length = 0;
|
||||
},
|
||||
};
|
||||
for (const reason of ["timeout", "cancel"]) {
|
||||
test(`abandoned ${reason} opens cannot accumulate requests on a shared transport`, async () => {
|
||||
const callbacks = [];
|
||||
let physicalCloses = 0;
|
||||
const sshClient = new EventEmitter();
|
||||
sshClient.sftp = callback => { callbacks.push(callback); };
|
||||
sshClient.end = sshClient.destroy = () => { physicalCloses++; };
|
||||
const controller = new AbortController();
|
||||
const opening = openBoundedSftpChannel(sshClient, { timeoutMs: 5, signal: controller.signal });
|
||||
if (reason === "cancel") controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(opening);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
}
|
||||
assert.equal(callbacks.length, 1, "retries must not allocate more abandoned SSH channel requests");
|
||||
assert.equal(physicalCloses, 0, "existing terminal and SFTP channels remain connected");
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await assert.rejects(
|
||||
openBoundedSftpChannel(sshClient, { timeoutMs: 2 }),
|
||||
/timed out|Not connected/,
|
||||
);
|
||||
}
|
||||
assert.equal(sshClient.pendingChannelCallbacks.length, 0);
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0 || late.closeCalls > 0);
|
||||
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
assert.equal(callbacks.length, 2, "a settled abandoned request must release admission");
|
||||
const healthy = createChannel();
|
||||
callbacks[1](null, healthy);
|
||||
assert.equal(await next, healthy);
|
||||
assert.equal(physicalCloses, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("healthy SFTP channel openings remain parallel", async () => {
|
||||
const callbacks = [];
|
||||
const sshClient = { sftp(callback) { callbacks.push(callback); } };
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
assert.equal(callbacks.length, 2);
|
||||
const channels = [createChannel(), createChannel()];
|
||||
callbacks.forEach((callback, index) => callback(null, channels[index]));
|
||||
assert.deepEqual(await Promise.all([first, second]), channels);
|
||||
});
|
||||
|
||||
test("bounded SFTP open removes cancellation listeners after success and failure", async () => {
|
||||
@@ -131,3 +147,37 @@ test("bounded SFTP open converts synchronous setup errors into rejections", asyn
|
||||
const sshClient = { sftp() { throw new Error("sync failure"); } };
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), /sync failure/);
|
||||
});
|
||||
|
||||
test("transport closure releases abandoned-open bookkeeping without a late callback", async () => {
|
||||
const sshClient = new EventEmitter();
|
||||
const callbacks = [];
|
||||
sshClient.sftp = callback => callbacks.push(callback);
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient, { timeoutMs: 5 }));
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
sshClient.emit("close");
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient, { timeoutMs: 50 });
|
||||
const channel = createChannel();
|
||||
callbacks[1](null, channel);
|
||||
assert.equal(await next, channel);
|
||||
const late = createChannel();
|
||||
callbacks[0](null, late);
|
||||
assert.ok(late.endCalls > 0);
|
||||
});
|
||||
|
||||
test("all abandoned parallel opens must settle before admitting a new one", async () => {
|
||||
const sshClient = new EventEmitter();
|
||||
const callbacks = [];
|
||||
sshClient.sftp = callback => callbacks.push(callback);
|
||||
const first = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
|
||||
const second = openBoundedSftpChannel(sshClient, { timeoutMs: 5 });
|
||||
await Promise.all([assert.rejects(first), assert.rejects(second)]);
|
||||
assert.equal(sshClient.listenerCount("close"), 1);
|
||||
callbacks[0](new Error("first failed"));
|
||||
await assert.rejects(openBoundedSftpChannel(sshClient), error => error.code === "SFTP_CHANNEL_OPEN_PENDING");
|
||||
callbacks[1](new Error("second failed"));
|
||||
assert.equal(sshClient.listenerCount("close"), 0);
|
||||
const next = openBoundedSftpChannel(sshClient);
|
||||
callbacks[2](null, createChannel());
|
||||
await next;
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
// Publish a prepared regular file without replacing any destination entry.
|
||||
// Hardlinks make complete bytes visible atomically. FAT-like filesystems use an
|
||||
// exclusively created handle instead: partial contents can be visible there,
|
||||
// but no pathname cleanup may remove a concurrent writer's replacement.
|
||||
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
|
||||
// verify an open destination handle before later metadata stamping.
|
||||
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.link(source, target);
|
||||
// The hardlink shares the published inode; stat either name.
|
||||
const linkedStat = await fs.promises.lstat(source);
|
||||
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
|
||||
} catch (error) {
|
||||
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) throw error;
|
||||
}
|
||||
|
||||
let input;
|
||||
let output;
|
||||
let failure;
|
||||
let publishedIdentity;
|
||||
try {
|
||||
input = preparedHandle ?? await fs.promises.open(source, "r");
|
||||
const stat = await input.stat();
|
||||
assertNotCancelled();
|
||||
output = await fs.promises.open(target, "wx", stat.mode & 0o7777);
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let position = 0;
|
||||
while (position < stat.size) {
|
||||
assertNotCancelled();
|
||||
const { bytesRead } = await input.read(buffer, 0, Math.min(buffer.length, stat.size - position), position);
|
||||
if (!bytesRead) throw new Error("Prepared local file ended before publication completed");
|
||||
let written = 0;
|
||||
while (written < bytesRead) {
|
||||
assertNotCancelled();
|
||||
const { bytesWritten } = await output.write(buffer, written, bytesRead - written, position + written);
|
||||
if (!bytesWritten) throw new Error("Local publication made no write progress");
|
||||
written += bytesWritten;
|
||||
}
|
||||
position += bytesRead;
|
||||
}
|
||||
assertNotCancelled();
|
||||
// Restore metadata after writes (which may clear special permission bits),
|
||||
// through the owned handle rather than a potentially replaced pathname.
|
||||
await output.chmod(stat.mode & 0o7777);
|
||||
await output.utimes(stat.atime, stat.mtime);
|
||||
const ownedStat = await output.stat();
|
||||
const targetStat = await fs.promises.lstat(target);
|
||||
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
for (const handle of [output, preparedHandle ? undefined : input]) {
|
||||
try { await handle?.close(); } catch (error) { failure ??= error; }
|
||||
}
|
||||
}
|
||||
if (failure) {
|
||||
// Once exclusive creation succeeded, leave the partial destination intact.
|
||||
// The caller must retain its complete prepared file and any original backup.
|
||||
if (output) failure.localPublicationIncomplete = true;
|
||||
throw failure;
|
||||
}
|
||||
return publishedIdentity;
|
||||
}
|
||||
|
||||
module.exports = { publishLocalFileExclusive };
|
||||
@@ -484,13 +484,13 @@ function shutdownHost({ preserveScopedMetadata = false } = {}) {
|
||||
sessionIdleManager.clearAll();
|
||||
}
|
||||
|
||||
function echoCommandToSession(session, sessionId, command) {
|
||||
function echoCommandToSession(session, sessionId, command, { syntheticEcho = true } = {}) {
|
||||
if (!electronModule || !session?.webContentsId || !command) return;
|
||||
const contents = electronModule.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", {
|
||||
sessionId,
|
||||
data: formatSyntheticEcho(command),
|
||||
syntheticEcho: true,
|
||||
syntheticEcho,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ function createExecHandlerApi(ctx) {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
|
||||
@@ -313,6 +315,8 @@ function createExecHandlerApi(ctx) {
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
@@ -77,7 +77,41 @@ function terminalPlainTextToHtml(plainText, hostLabel, timestamp) {
|
||||
return wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp);
|
||||
}
|
||||
|
||||
function buildExtendedForegroundPalette(htmlContent) {
|
||||
const colors = new Set(Array.from(
|
||||
htmlContent.matchAll(/var\(--term-custom-([a-f0-9]{6}), #[a-f0-9]{6}\)/g),
|
||||
(match) => match[1],
|
||||
));
|
||||
const light = [];
|
||||
const dark = [];
|
||||
for (const hex of colors) {
|
||||
const channels = hex.match(/../g).map((channel) => parseInt(channel, 16));
|
||||
const linearChannels = channels.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
const luminance = linearChannels[0] * 0.2126 + linearChannels[1] * 0.7152 + linearChannels[2] * 0.0722;
|
||||
const maxLuminance = 1.05 / 4.5 - 0.05;
|
||||
let adjusted = channels;
|
||||
// A single linear-light scaling reaches the contrast target without an
|
||||
// iterative search for every color on each streaming snapshot.
|
||||
if (luminance > maxLuminance) {
|
||||
const scale = maxLuminance / luminance;
|
||||
adjusted = linearChannels.map((channel) => {
|
||||
const value = channel * scale;
|
||||
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055;
|
||||
return Math.floor(srgb * 255);
|
||||
});
|
||||
}
|
||||
const lightHex = adjusted.map((channel) => channel.toString(16).padStart(2, "0")).join("");
|
||||
light.push(`--term-custom-${hex}: #${lightHex};`);
|
||||
dark.push(`--term-custom-${hex}: #${hex};`);
|
||||
}
|
||||
return { light: light.join("\n "), dark: dark.join("\n ") };
|
||||
}
|
||||
|
||||
function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
const extendedPalette = buildExtendedForegroundPalette(htmlContent);
|
||||
const dateStr = new Date(timestamp).toLocaleString();
|
||||
const safeHostLabel = escapeHtml(hostLabel || "Unknown");
|
||||
const safeDateStr = escapeHtml(dateStr);
|
||||
@@ -86,23 +120,76 @@ function wrapTerminalHtmlContent(htmlContent, hostLabel, timestamp) {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Session Log - ${safeHostLabel}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* Day palette (default): readable on light browser/OS themes. */
|
||||
--term-default-bg: #ffffff;
|
||||
--term-default-fg: #24292f;
|
||||
--term-header-fg: #6a737d;
|
||||
--term-border: #d0d7de;
|
||||
/* ANSI palette (basic 0-7, bright 8-15) tuned for the light background. */
|
||||
--ansi-0: #000000;
|
||||
--ansi-1: #cd3131;
|
||||
--ansi-2: #008000;
|
||||
--ansi-3: #747700;
|
||||
--ansi-4: #0451a5;
|
||||
--ansi-5: #bc05bc;
|
||||
--ansi-6: #007c9b;
|
||||
--ansi-7: #555555;
|
||||
--ansi-8: #666666;
|
||||
--ansi-9: #cd3131;
|
||||
--ansi-10: #008500;
|
||||
--ansi-11: #797600;
|
||||
--ansi-12: #0451a5;
|
||||
--ansi-13: #bc05bc;
|
||||
--ansi-14: #007c9b;
|
||||
--ansi-15: #767676;
|
||||
${extendedPalette.light}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--term-default-bg: #1e1e1e;
|
||||
--term-default-fg: #d4d4d4;
|
||||
--term-header-fg: #888888;
|
||||
--term-border: #444444;
|
||||
--ansi-0: #000000;
|
||||
--ansi-1: #cd3131;
|
||||
--ansi-2: #0dbc79;
|
||||
--ansi-3: #e5e510;
|
||||
--ansi-4: #2472c8;
|
||||
--ansi-5: #bc3fbc;
|
||||
--ansi-6: #11a8cd;
|
||||
--ansi-7: #e5e5e5;
|
||||
--ansi-8: #666666;
|
||||
--ansi-9: #f14c4c;
|
||||
--ansi-10: #23d18b;
|
||||
--ansi-11: #f5f543;
|
||||
--ansi-12: #3b8eea;
|
||||
--ansi-13: #d670d6;
|
||||
--ansi-14: #29b8db;
|
||||
--ansi-15: #ffffff;
|
||||
${extendedPalette.dark}
|
||||
}
|
||||
}
|
||||
body {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Monaco, Menlo, monospace;
|
||||
background: var(--term-default-bg);
|
||||
color: var(--term-default-fg);
|
||||
font-family: ui-monospace, 'Cascadia Mono', 'JetBrains Mono', 'SF Mono',
|
||||
Consolas, Monaco, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
padding: 20px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 1px solid #444;
|
||||
border-bottom: 1px solid var(--term-border);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
color: #888;
|
||||
color: var(--term-header-fg);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -919,3 +919,45 @@ test("manual session log rejects renderer-supplied filePath without selection to
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("html wrapper adapts to light/dark browser color scheme", () => {
|
||||
const bridge = loadBridgeWithDialog({});
|
||||
const html = bridge.wrapTerminalHtmlContent("hello", "host-1", 0);
|
||||
// Day palette is the default; dark applies only via prefers-color-scheme.
|
||||
assert.match(html, /--term-default-bg:\s*#ffffff/);
|
||||
assert.match(html, /color-scheme:\s*light dark/);
|
||||
assert.match(html, /<meta name="color-scheme" content="light dark">/);
|
||||
assert.match(html, /@media \(prefers-color-scheme: dark\)/);
|
||||
assert.match(html, /--term-default-bg:\s*#1e1e1e/);
|
||||
// Fonts remain monospace with a platform-appropriate stack.
|
||||
assert.match(html, /font-family:[^;]*ui-monospace[^;]*monospace/);
|
||||
});
|
||||
|
||||
test("all light-mode ANSI foregrounds remain readable on the default background", () => {
|
||||
const { terminalDataToHtml } = loadBridgeWithDialog({});
|
||||
let data = "";
|
||||
for (let index = 0; index < 256; index++) data += `\x1b[38;5;${index}mcolor ${index}\x1b[0m\n`;
|
||||
data += "\x1b[38;2;255;255;255mwhite\x1b[0m\n\x1b[38;2;255;255;0myellow\x1b[0m\n\x1b[38;2;18;52;86mdark\x1b[0m";
|
||||
const html = terminalDataToHtml(data, "host", 0);
|
||||
const [lightCss, darkCss] = html.split("@media");
|
||||
const luminance = (hex) => {
|
||||
const channels = hex.match(/[a-f0-9]{2}/gi).map((channel) => {
|
||||
const value = parseInt(channel, 16) / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
const background = luminance(lightCss.match(/--term-default-bg:\s*(#[a-f0-9]{6})/i)[1]);
|
||||
const palette = [...lightCss.matchAll(/--(ansi-\d+|term-custom-[a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)];
|
||||
assert.equal(palette.length, 257);
|
||||
for (const [, index, color] of palette) {
|
||||
const foreground = luminance(color);
|
||||
const contrast = (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
|
||||
assert.ok(contrast >= 4.5, `ANSI ${index} (${color}) contrast ${contrast.toFixed(2)} is below 4.5:1`);
|
||||
}
|
||||
for (const [, hex, color] of lightCss.matchAll(/--term-custom-([a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)) {
|
||||
assert.ok(darkCss.includes(`--term-custom-${hex}: #${hex};`));
|
||||
if (1.05 / (luminance(hex) + 0.05) >= 4.5) assert.equal(color, `#${hex}`);
|
||||
}
|
||||
assert.match(html, /color: var\(--term-custom-ffffff, #ffffff\)[^>]*>white/);
|
||||
});
|
||||
@@ -755,11 +755,10 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 3, true, "the backup should be restored after cancellation");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal rolls back a published file when cancellation wins the final rename", async (t) => {
|
||||
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
@@ -778,26 +777,27 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) controller.abort();
|
||||
const originalLink = fs.promises.link;
|
||||
let published = false;
|
||||
fs.promises.link = async (...args) => {
|
||||
await originalLink(...args);
|
||||
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
|
||||
published = true;
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
t.after(() => { fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
await assert.doesNotReject(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-final-rename-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 4, true, "the published file should be rolled back to the backup");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
assert.equal(published, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
|
||||
@@ -820,20 +820,20 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
const originalLink = fs.promises.link;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 4) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 2) backupPath = args[1];
|
||||
if (renameCalls === 3) controller.abort();
|
||||
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -843,7 +843,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, /Backup:/);
|
||||
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
||||
return true;
|
||||
@@ -877,11 +877,6 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
@@ -889,7 +884,12 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
const originalLink = fs.promises.link;
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -899,7 +899,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
|
||||
|
||||
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
|
||||
|
||||
@@ -90,7 +91,12 @@ function regEnumSubkeys(keyPath) {
|
||||
|
||||
/**
|
||||
* Locate an executable on the system PATH using `where.exe`.
|
||||
* Returns the first valid, non-alias path, or `null` if not found.
|
||||
* Returns the first valid path, or `null` if not found.
|
||||
*
|
||||
* Windows App Execution Aliases (MSIX/Store installs, e.g. winget PowerShell 7)
|
||||
* are kept as a last-resort candidate: they are reparse points that fail
|
||||
* `fs.existsSync()`, but spawning the alias path launches the packaged app
|
||||
* just fine. A regular executable always wins when one is on the PATH.
|
||||
*
|
||||
* @param {string} name Executable name, e.g. "pwsh"
|
||||
* @returns {string|null}
|
||||
@@ -103,25 +109,16 @@ function findExecutableOnPath(name) {
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let aliasCandidate = null;
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
|
||||
try {
|
||||
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
|
||||
if (
|
||||
localAppData &&
|
||||
candidate.toLowerCase().startsWith(
|
||||
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
|
||||
path.sep,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore — just use the candidate.
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
aliasCandidate ??= candidate;
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
if (aliasCandidate) return aliasCandidate;
|
||||
} catch (_err) {
|
||||
// Not found on PATH.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
// shellDiscovery destructures execFileSync at require time, so the fake
|
||||
// `where.exe` must be installed before the module under test is loaded.
|
||||
const realExecFileSync = childProcess.execFileSync;
|
||||
const whereResults = new Map();
|
||||
|
||||
childProcess.execFileSync = function fakeExecFileSync(file, args) {
|
||||
const argv0 = String(file).toLowerCase();
|
||||
const isWhere =
|
||||
argv0 === "where.exe" || argv0.endsWith("\\where.exe") || argv0.endsWith("/where.exe");
|
||||
if (isWhere) {
|
||||
const lines = whereResults.get(args[0]);
|
||||
if (lines !== undefined) {
|
||||
if (lines === null) {
|
||||
throw new Error("not found");
|
||||
}
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
}
|
||||
// reg.exe / wsl.exe probes from other detectors: treated as missing.
|
||||
throw new Error(`unexpected execFileSync call: ${file} ${JSON.stringify(args)}`);
|
||||
};
|
||||
|
||||
const shellDiscovery = require("./shellDiscovery.cjs");
|
||||
|
||||
function makeHarness(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-shell-discovery-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const previousLocalAppData = process.env.LOCALAPPDATA;
|
||||
process.env.LOCALAPPDATA = tmp;
|
||||
t.after(() => {
|
||||
if (previousLocalAppData === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = previousLocalAppData;
|
||||
}
|
||||
});
|
||||
|
||||
const aliasPath = path.join(
|
||||
tmp,
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
"pwsh.exe",
|
||||
);
|
||||
// The alias fixture intentionally does NOT exist on disk: real App
|
||||
// Execution Aliases fail fs.statSync() with EACCES, so existsSync() is
|
||||
// false on a real machine too.
|
||||
|
||||
return { tmp, aliasPath };
|
||||
}
|
||||
|
||||
test("findExecutableOnPath accepts a WindowsApps execution alias as the only candidate", (t) => {
|
||||
const { aliasPath } = makeHarness(t);
|
||||
whereResults.set("pwsh", [aliasPath]);
|
||||
|
||||
assert.equal(shellDiscovery.findExecutableOnPath("pwsh"), aliasPath);
|
||||
whereResults.delete("pwsh");
|
||||
});
|
||||
|
||||
test("findExecutableOnPath prefers a regular executable over an execution alias", (t) => {
|
||||
const { tmp, aliasPath } = makeHarness(t);
|
||||
const realPwsh = path.join(tmp, "Program Files", "PowerShell", "7", "pwsh.exe");
|
||||
fs.mkdirSync(path.dirname(realPwsh), { recursive: true });
|
||||
fs.writeFileSync(realPwsh, "");
|
||||
whereResults.set("pwsh", [aliasPath, realPwsh]);
|
||||
|
||||
assert.equal(shellDiscovery.findExecutableOnPath("pwsh"), realPwsh);
|
||||
whereResults.delete("pwsh");
|
||||
});
|
||||
|
||||
test("findExecutableOnPath returns null when where.exe finds nothing", (t) => {
|
||||
makeHarness(t);
|
||||
whereResults.set("pwsh", null);
|
||||
|
||||
assert.equal(shellDiscovery.findExecutableOnPath("pwsh"), null);
|
||||
whereResults.delete("pwsh");
|
||||
});
|
||||
|
||||
test("discoverWindowsShells lists PowerShell 7 from an MSIX alias and marks it default", (t) => {
|
||||
const { aliasPath } = makeHarness(t);
|
||||
whereResults.set("pwsh", [aliasPath]);
|
||||
whereResults.set("powershell", null);
|
||||
t.after(() => {
|
||||
whereResults.delete("pwsh");
|
||||
whereResults.delete("powershell");
|
||||
});
|
||||
|
||||
const shells = shellDiscovery.discoverWindowsShells();
|
||||
const pwsh = shells.find((s) => s.id === "pwsh");
|
||||
|
||||
assert.ok(pwsh, "expected a discovered pwsh shell entry");
|
||||
assert.equal(pwsh.command, aliasPath);
|
||||
assert.equal(pwsh.isDefault, true);
|
||||
});
|
||||
@@ -59,6 +59,7 @@ const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
@@ -741,22 +742,9 @@ function resolvePosixExecutable(name, opts = {}) {
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
function isWindowsAppExecutionAlias(filePath) {
|
||||
if (!filePath || process.platform !== "win32") return false;
|
||||
|
||||
const normalizedPath = path.normalize(filePath).toLowerCase();
|
||||
const windowsAppsDir = path.join(
|
||||
process.env.LOCALAPPDATA || "",
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
).toLowerCase();
|
||||
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name, opts = {}) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
|
||||
@@ -776,11 +764,20 @@ function findExecutable(name, opts = {}) {
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Windows App Execution Aliases (MSIX/Store installs, e.g. winget pwsh 7)
|
||||
// fail fs.existsSync() because they are reparse points, yet ConPTY can
|
||||
// spawn the alias path directly. Keep the first alias as a last resort
|
||||
// behind any regular executable found on the PATH.
|
||||
let aliasCandidate = null;
|
||||
for (const candidate of candidates) {
|
||||
if (isWindowsAppExecutionAliasPath(candidate)) {
|
||||
aliasCandidate ??= candidate;
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
if (aliasCandidate) return aliasCandidate;
|
||||
} catch (err) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
}
|
||||
@@ -2535,6 +2532,7 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
findExecutable,
|
||||
getDefaultLocalShell,
|
||||
startLocalSession,
|
||||
startTelnetSession,
|
||||
startMoshSession,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const Module = require("node:module");
|
||||
const childProcess = require("node:child_process");
|
||||
|
||||
function loadBridgeWithFakePty() {
|
||||
const bridgePath = require.resolve("./terminalBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request) {
|
||||
if (request === "node-pty") {
|
||||
return {
|
||||
spawn() {
|
||||
throw new Error("node-pty spawn is not exercised by this suite");
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.apply(this, arguments);
|
||||
};
|
||||
try {
|
||||
return require("./terminalBridge.cjs");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = loadBridgeWithFakePty();
|
||||
|
||||
function withWin32Platform(fn) {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" });
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
// findExecutable() re-requires child_process inside the call, so patching the
|
||||
// module object's execFileSync at runtime intercepts its `where.exe` probes.
|
||||
function withWhereFake(resultsByname, fn) {
|
||||
const realExecFileSync = childProcess.execFileSync;
|
||||
childProcess.execFileSync = function fakeExecFileSync(file, args) {
|
||||
const argv0 = String(file).toLowerCase();
|
||||
const isWhere =
|
||||
argv0 === "where.exe" || argv0.endsWith("\\where.exe") || argv0.endsWith("/where.exe");
|
||||
if (isWhere) {
|
||||
const lines = resultsByname.get(args[0]);
|
||||
if (lines !== undefined) {
|
||||
if (lines === null) {
|
||||
throw new Error("not found");
|
||||
}
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected execFileSync call: ${file} ${JSON.stringify(args)}`);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
childProcess.execFileSync = realExecFileSync;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide machine-specific fallback paths (e.g. an MSI pwsh under Program Files)
|
||||
// so the tests behave identically regardless of what the host has installed.
|
||||
function withExistsSyncAllowlist(allowed, fn) {
|
||||
const realExistsSync = fs.existsSync;
|
||||
fs.existsSync = (p) => allowed.has(String(p));
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.existsSync = realExistsSync;
|
||||
}
|
||||
}
|
||||
|
||||
function makeHarness(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-local-shell-"));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
const previousLocalAppData = process.env.LOCALAPPDATA;
|
||||
process.env.LOCALAPPDATA = tmp;
|
||||
t.after(() => {
|
||||
if (previousLocalAppData === undefined) {
|
||||
delete process.env.LOCALAPPDATA;
|
||||
} else {
|
||||
process.env.LOCALAPPDATA = previousLocalAppData;
|
||||
}
|
||||
});
|
||||
|
||||
const aliasPath = path.join(tmp, "Microsoft", "WindowsApps", "pwsh.exe");
|
||||
// Intentionally not created on disk: real App Execution Aliases fail
|
||||
// fs.statSync() with EACCES, so existsSync() is false for them anyway.
|
||||
|
||||
const realPwshPath = path.join(tmp, "PowerShell", "7", "pwsh.exe");
|
||||
fs.mkdirSync(path.dirname(realPwshPath), { recursive: true });
|
||||
fs.writeFileSync(realPwshPath, "");
|
||||
|
||||
const windowsPowerShellPath = path.join(tmp, "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
fs.mkdirSync(path.dirname(windowsPowerShellPath), { recursive: true });
|
||||
fs.writeFileSync(windowsPowerShellPath, "");
|
||||
|
||||
return { tmp, aliasPath, realPwshPath, windowsPowerShellPath };
|
||||
}
|
||||
|
||||
test("getDefaultLocalShell resolves an MSIX pwsh execution alias", (t) => {
|
||||
const { aliasPath } = makeHarness(t);
|
||||
|
||||
withWin32Platform(() =>
|
||||
withWhereFake(new Map([["pwsh", [aliasPath]]]), () => {
|
||||
assert.equal(bridge.getDefaultLocalShell(), aliasPath);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("findExecutable prefers a regular executable listed after an execution alias", (t) => {
|
||||
const { aliasPath, realPwshPath } = makeHarness(t);
|
||||
|
||||
withWin32Platform(() =>
|
||||
withWhereFake(new Map([["pwsh", [aliasPath, realPwshPath]]]), () => {
|
||||
assert.equal(bridge.findExecutable("pwsh"), realPwshPath);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("getDefaultLocalShell still falls back to Windows PowerShell when no pwsh exists", (t) => {
|
||||
const { windowsPowerShellPath } = makeHarness(t);
|
||||
|
||||
withWin32Platform(() =>
|
||||
withWhereFake(
|
||||
new Map([
|
||||
["pwsh", null],
|
||||
["powershell", [windowsPowerShellPath]],
|
||||
]),
|
||||
() =>
|
||||
withExistsSyncAllowlist(new Set([windowsPowerShellPath]), () => {
|
||||
assert.equal(bridge.getDefaultLocalShell(), windowsPowerShellPath);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("getDefaultLocalShell keeps the powershell.exe last-resort fallback", (t) => {
|
||||
makeHarness(t);
|
||||
|
||||
withWin32Platform(() =>
|
||||
withWhereFake(
|
||||
new Map([
|
||||
["pwsh", null],
|
||||
["powershell", null],
|
||||
]),
|
||||
() =>
|
||||
withExistsSyncAllowlist(new Set(), () => {
|
||||
assert.equal(bridge.getDefaultLocalShell(), "powershell.exe");
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,9 @@
|
||||
*/
|
||||
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
// ANSI palette colors are emitted as CSS variables (with hex fallbacks) so the
|
||||
// wrapping HTML page can provide light and dark palettes. Indexes 0-7 are the
|
||||
// basic colors, 8-15 the bright colors.
|
||||
const DEFAULT_FOREGROUND = "#d4d4d4";
|
||||
const DEFAULT_BACKGROUND = "#1e1e1e";
|
||||
const BASIC_COLORS = [
|
||||
@@ -35,6 +38,11 @@ const BRIGHT_COLORS = [
|
||||
"#ffffff",
|
||||
];
|
||||
|
||||
function ansiColorVar(index) {
|
||||
const fallback = index < 8 ? BASIC_COLORS[index] : BRIGHT_COLORS[index - 8];
|
||||
return `var(--ansi-${index}, ${fallback})`;
|
||||
}
|
||||
|
||||
class TerminalTextRenderer {
|
||||
constructor(options = {}) {
|
||||
this.lines = [[]];
|
||||
@@ -307,17 +315,17 @@ class TerminalTextRenderer {
|
||||
} else if (code === 27) {
|
||||
this.style.inverse = false;
|
||||
} else if (code >= 30 && code <= 37) {
|
||||
this.style.fg = BASIC_COLORS[code - 30];
|
||||
this.style.fg = ansiColorVar(code - 30);
|
||||
} else if (code === 39) {
|
||||
this.style.fg = null;
|
||||
} else if (code >= 40 && code <= 47) {
|
||||
this.style.bg = BASIC_COLORS[code - 40];
|
||||
this.style.bg = ansiColorVar(code - 40);
|
||||
} else if (code === 49) {
|
||||
this.style.bg = null;
|
||||
} else if (code >= 90 && code <= 97) {
|
||||
this.style.fg = BRIGHT_COLORS[code - 90];
|
||||
this.style.fg = ansiColorVar(8 + code - 90);
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
this.style.bg = BRIGHT_COLORS[code - 100];
|
||||
this.style.bg = ansiColorVar(8 + code - 100);
|
||||
} else if ((code === 38 || code === 48) && codes[i + 1] === 5) {
|
||||
const color = colorFromAnsi256(codes[i + 2]);
|
||||
if (color) {
|
||||
@@ -585,8 +593,23 @@ function getTrimmedLineLength(line) {
|
||||
function styleToCss(style) {
|
||||
if (!style) return "";
|
||||
const declarations = [];
|
||||
const fg = style.inverse ? (style.bg || DEFAULT_BACKGROUND) : style.fg;
|
||||
const bg = style.inverse ? (style.fg || DEFAULT_FOREGROUND) : style.bg;
|
||||
let fg = style.inverse
|
||||
? (style.bg || `var(--term-default-bg, ${DEFAULT_BACKGROUND})`)
|
||||
: style.fg;
|
||||
let bg = style.inverse
|
||||
? (style.fg || `var(--term-default-fg, ${DEFAULT_FOREGROUND})`)
|
||||
: style.bg;
|
||||
// Theme the normal page foreground, but preserve the original color pair
|
||||
// when output supplies a background (including a foreground inverted into
|
||||
// one). Darkening foregrounds independently can make these runs unreadable.
|
||||
if (style.bg || (style.inverse && style.fg)) {
|
||||
const originalColor = (color) => color?.replace(/var\(--[\w-]+, (#[a-f0-9]{6})\)/g, "$1");
|
||||
fg = originalColor(fg) || DEFAULT_FOREGROUND;
|
||||
bg = originalColor(bg);
|
||||
} else if (fg?.startsWith("#")) {
|
||||
// The wrapper supplies a readable light variant for extended/true colors.
|
||||
fg = `var(--term-custom-${fg.slice(1)}, ${fg})`;
|
||||
}
|
||||
if (fg) declarations.push(`color: ${fg}`);
|
||||
if (bg) declarations.push(`background-color: ${bg}`);
|
||||
if (style.bold) declarations.push("font-weight: 700");
|
||||
@@ -608,8 +631,7 @@ function stylesEqual(a, b) {
|
||||
|
||||
function colorFromAnsi256(value) {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
||||
if (value < 8) return BASIC_COLORS[value];
|
||||
if (value < 16) return BRIGHT_COLORS[value - 8];
|
||||
if (value < 16) return ansiColorVar(value);
|
||||
if (value < 232) {
|
||||
const n = value - 16;
|
||||
const r = Math.floor(n / 36);
|
||||
|
||||
@@ -214,8 +214,8 @@ test("RIS resets SGR so post-reset text is not colored from pre-entry style", ()
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /shell-after/);
|
||||
// Pre-entry red SGR must not wrap post-RIS shell text.
|
||||
assert.equal(/color:\s*#cd3131[^"]*"[^>]*>shell-after/.test(html), false);
|
||||
assert.match(html, /color:\s*#cd3131[^"]*"[^>]*>before/);
|
||||
assert.equal(/color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>shell-after/.test(html), false);
|
||||
assert.match(html, /color:\s*var\(--ansi-1[^)]*\)[^"]*"[^>]*>before/);
|
||||
});
|
||||
|
||||
test("RIS rebases screen so later cursor-home does not overwrite history", () => {
|
||||
@@ -248,3 +248,43 @@ test("split C1 CSI alternate-screen enter still omits TUI paint", () => {
|
||||
renderer.feed("\x9b?1049l$ done\n");
|
||||
assert.equal(renderer.finish(), "before\n$ done");
|
||||
});
|
||||
|
||||
test("SGR colors are emitted as themeable CSS variables with hex fallbacks", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[31mred \x1b[91mbright-red \x1b[44mbg-blue \x1b[0mplain\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
// Basic / bright / background colors map to --ansi-N variables so the
|
||||
// wrapping HTML page can swap palettes for light and dark themes.
|
||||
assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>red/);
|
||||
assert.match(html, /color:\s*var\(--ansi-9, #f14c4c\)[^"]*"[^>]*>bright-red/);
|
||||
assert.match(html, /color:\s*#f14c4c; background-color:\s*#2472c8/);
|
||||
});
|
||||
|
||||
test("256-color base and cube foregrounds use themeable variables", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[38;5;1mbase\x1b[0m \x1b[38;5;208mcube\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /color:\s*var\(--ansi-1, #cd3131\)[^"]*"[^>]*>base/);
|
||||
assert.match(html, /color:\s*var\(--term-custom-ff8700, #ff8700\)[^"]*"[^>]*>cube/);
|
||||
});
|
||||
|
||||
test("inverse text falls back to themeable default background/foreground", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[7minversed\x1b[0m\n");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(
|
||||
html,
|
||||
/color:\s*var\(--term-default-bg, #1e1e1e\); background-color:\s*var\(--term-default-fg, #d4d4d4\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit backgrounds preserve original foreground/background pairs across themes", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("\x1b[37;41mERROR\x1b[0m \x1b[40mTEXT\x1b[0m \x1b[38;5;15;48;5;1mindexed\x1b[0m \x1b[31;7minverse\x1b[0m \x1b[48;2;0;0;0mRGB background\x1b[0m");
|
||||
const html = renderer.toHtmlContent();
|
||||
assert.match(html, /color: #e5e5e5; background-color: #cd3131[^>]*>ERROR/);
|
||||
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>TEXT/);
|
||||
assert.match(html, /color: #ffffff; background-color: #cd3131[^>]*>indexed/);
|
||||
assert.match(html, /color: #1e1e1e; background-color: #cd3131[^>]*>inverse/);
|
||||
assert.match(html, /color: #d4d4d4; background-color: #000000[^>]*>RGB background/);
|
||||
});
|
||||
+237
-221
@@ -950,219 +950,151 @@ function stableLocalFileIdentity(statLike) {
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const assertNotCancelled = typeof options.assertNotCancelled === "function"
|
||||
? options.assertNotCancelled
|
||||
: () => {};
|
||||
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
|
||||
const assertNotCancelled = options.assertNotCancelled || (() => {});
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
const targetDir = path.dirname(targetPath);
|
||||
const targetBase = path.basename(targetPath);
|
||||
const readyPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.ready`);
|
||||
const backupPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.backup`);
|
||||
let preparedPath = stagedPath;
|
||||
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
|
||||
const readyPath = `${base}.ready`;
|
||||
const backupPath = `${base}.backup`;
|
||||
const restoreProbePath = `${base}.restore-check`;
|
||||
let backedUp = false;
|
||||
let published = false;
|
||||
let publishedIdentity = null;
|
||||
let committed = false;
|
||||
let keepRecoveryFiles = false;
|
||||
let preparedHandle;
|
||||
let originalHandle;
|
||||
let restoreProbeCreated = false;
|
||||
let localMtimePrepared = false;
|
||||
try {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(stagedPath, readyPath);
|
||||
} catch (err) {
|
||||
if (err?.code !== "EXDEV") throw err;
|
||||
await fs.promises.copyFile(stagedPath, readyPath);
|
||||
preparedPath = readyPath;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") throw error;
|
||||
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
|
||||
}
|
||||
if (preparedPath !== readyPath) preparedPath = readyPath;
|
||||
// Stamp the private prepared file before applying possibly unreadable
|
||||
// destination permissions. Publication carries these times to the target.
|
||||
const mtimeMs = Number(options.sourceSoftIdentity?.mtimeMs);
|
||||
if (Number.isFinite(mtimeMs) && mtimeMs >= 1000) {
|
||||
const when = new Date(Math.floor(mtimeMs / 1000) * 1000);
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(readyPath, when, when), 15_000, "Prepared destination utimes",
|
||||
).then(() => { localMtimePrepared = true; }).catch(() => {});
|
||||
}
|
||||
// Keep read access to our private bytes before destination permissions
|
||||
// can remove it; the no-hardlink fallback copies through this handle.
|
||||
preparedHandle = await fs.promises.open(readyPath, "r");
|
||||
let appliedMode = null;
|
||||
let targetStable = false;
|
||||
let expectedStableIdentity = null;
|
||||
let validatedTarget;
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const validatedTarget = typeof options.validateTarget === "function"
|
||||
validatedTarget = typeof options.validateTarget === "function"
|
||||
? await options.validateTarget()
|
||||
: null;
|
||||
const existingMode = Number.isInteger(validatedTarget?.existingMode)
|
||||
: undefined;
|
||||
const mode = Number.isInteger(validatedTarget?.existingMode)
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode)
|
||||
? options.existingMode & 0o7777
|
||||
: null;
|
||||
if (existingMode !== null && existingMode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, existingMode);
|
||||
appliedMode = existingMode;
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
if (mode !== null && mode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, mode);
|
||||
appliedMode = mode;
|
||||
continue;
|
||||
}
|
||||
expectedStableIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity
|
||||
? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":")
|
||||
: null);
|
||||
targetStable = true;
|
||||
stable = true;
|
||||
break;
|
||||
}
|
||||
if (!targetStable) {
|
||||
throw new Error("Local download target kept changing before replacement");
|
||||
}
|
||||
if (!stable) throw new Error("Local download target kept changing before replacement");
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
// Another process may have replaced the destination between validateTarget
|
||||
// and rename. Re-check the moved backup before publishing the download.
|
||||
if (backedUp && expectedStableIdentity) {
|
||||
let backupStat;
|
||||
const expectedAbsent = validatedTarget?.targetIdentity === "missing" || validatedTarget?.targetIdentity === null;
|
||||
const expectedIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity ? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":") : null);
|
||||
if (!expectedAbsent) {
|
||||
try {
|
||||
backupStat = await fs.promises.lstat(backupPath);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Local download target disappeared during replacement: ${targetPath}`,
|
||||
{ cause: err },
|
||||
);
|
||||
originalHandle = await fs.promises.open(targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
||||
// An unreadable original cannot use copy-based recovery. Verify the
|
||||
// non-overwriting alternative before moving its only visible name.
|
||||
try {
|
||||
await fs.promises.link(targetPath, restoreProbePath);
|
||||
restoreProbeCreated = true;
|
||||
await fs.promises.unlink(restoreProbePath);
|
||||
restoreProbeCreated = false;
|
||||
} catch (restoreError) {
|
||||
throw new Error("Cannot safely replace unreadable local destination: hardlink recovery unavailable", { cause: restoreError });
|
||||
}
|
||||
} else if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (!backupStat.isFile() || stableLocalFileIdentity(backupStat) !== expectedStableIdentity) {
|
||||
// If another writer already recreated targetPath, do not restore the
|
||||
// mismatched backup over it — leave both intact and fail closed.
|
||||
let targetOccupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
targetOccupied = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (targetOccupied) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
} catch (restoreErr) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after a concurrent replacement was detected. `
|
||||
+ `Backup: ${backupPath}; target: ${targetPath}`,
|
||||
{ cause: restoreErr },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
}
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (backedUp && expectedIdentity) {
|
||||
const stat = await fs.promises.lstat(backupPath);
|
||||
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
}
|
||||
// After the backup move, a concurrent writer may recreate targetPath. Never
|
||||
// clobber that file: leave it in place, keep the original in backupPath for
|
||||
// recovery, and fail closed before publishing the download.
|
||||
if (backedUp) {
|
||||
let recreated = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
recreated = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (recreated) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
// Prevent the catch path from renaming backup over the concurrent file.
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
}
|
||||
assertNotCancelled();
|
||||
// Capture identity of the ready file before publish so rollback can refuse
|
||||
// to clobber a concurrent replacement of the published target.
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
publishedIdentity = stableLocalFileIdentity(await fs.promises.lstat(readyPath));
|
||||
} catch {
|
||||
publishedIdentity = null;
|
||||
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error("Local download target changed during replacement", { cause: error });
|
||||
}
|
||||
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.rename(readyPath, targetPath);
|
||||
published = true;
|
||||
assertNotCancelled();
|
||||
options.onCommit?.();
|
||||
// Publication is the commit boundary. A late cancel must not perform a
|
||||
// check-then-unlink rollback against a name another process may now own.
|
||||
committed = true;
|
||||
// Hand the published inode identity to the caller for descriptor-based
|
||||
// metadata stamping after publication.
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
|
||||
if (stagedPath !== readyPath) await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (err) {
|
||||
let restoreError = null;
|
||||
if (err?.leaveConcurrentTarget) {
|
||||
// Caller already decided not to touch a concurrent target/backup pair.
|
||||
} else if (backedUp) {
|
||||
let targetMatchesPublished = false;
|
||||
let targetMissing = false;
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (error) {
|
||||
if (committed) throw error;
|
||||
if (backedUp && !keepRecoveryFiles) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
targetMatchesPublished = !!(
|
||||
published
|
||||
&& publishedIdentity
|
||||
&& stableLocalFileIdentity(targetStat) === publishedIdentity
|
||||
);
|
||||
} catch (statErr) {
|
||||
if (statErr?.code === "ENOENT") targetMissing = true;
|
||||
else restoreError = statErr;
|
||||
}
|
||||
if (!restoreError) {
|
||||
if (published && !targetMissing && !targetMatchesPublished) {
|
||||
// Concurrent writer replaced our published file — keep both.
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
if (published && targetMatchesPublished) {
|
||||
await fs.promises.unlink(targetPath).catch(() => {});
|
||||
}
|
||||
if (targetMissing || targetMatchesPublished || !published) {
|
||||
try {
|
||||
// Only restore when the path is free or still holds our publish.
|
||||
if (!published) {
|
||||
let occupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
occupied = true;
|
||||
} catch (occErr) {
|
||||
if (occErr?.code !== "ENOENT") throw occErr;
|
||||
}
|
||||
if (occupied) {
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
restoreError = recoveryErr;
|
||||
}
|
||||
}
|
||||
// The pathname may have changed between open and rename. Never copy
|
||||
// an earlier inode over the original actually moved into the backup.
|
||||
let restoreHandle;
|
||||
if (originalHandle) {
|
||||
const [heldStat, backupStat] = await Promise.all([
|
||||
originalHandle.stat(), fs.promises.lstat(backupPath),
|
||||
]);
|
||||
if (stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(backupStat)) restoreHandle = originalHandle;
|
||||
}
|
||||
}
|
||||
} else if (published) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
if (publishedIdentity && stableLocalFileIdentity(targetStat) === publishedIdentity) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
if (recoveryErr?.code !== "ENOENT") restoreError = recoveryErr;
|
||||
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
|
||||
await fs.promises.unlink(backupPath).catch(() => {});
|
||||
backedUp = false;
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
}
|
||||
}
|
||||
if (restoreError) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after replacement failed. `
|
||||
+ `Backup: ${backupPath}; prepared replacement: ${readyPath}; `
|
||||
+ `original staged path: ${stagedPath}; target: ${targetPath}`,
|
||||
{ cause: restoreError },
|
||||
if (keepRecoveryFiles) {
|
||||
const failure = new Error(
|
||||
`${error.message}. Recovery files preserved. Backup: ${backedUp ? backupPath : "none"}; `
|
||||
+ `prepared replacement: ${readyPath}; target: ${targetPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
failure.recoveryFailed = true;
|
||||
if (backedUp) failure.remoteBackupPath = backupPath;
|
||||
throw failure;
|
||||
}
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
throw err;
|
||||
throw error;
|
||||
} finally {
|
||||
await preparedHandle?.close().catch(() => {});
|
||||
await originalHandle?.close().catch(() => {});
|
||||
if (restoreProbeCreated) await fs.promises.unlink(restoreProbePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,11 +1139,30 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
: 15_000;
|
||||
|
||||
if (transfer.targetType === "local" && transfer.targetPath) {
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(transfer.targetPath, when, when),
|
||||
mtimeTimeoutMs,
|
||||
"Destination utimes",
|
||||
);
|
||||
if (transfer.localMtimePrepared) return;
|
||||
// Verify and stamp the same open file. A later pathname replacement
|
||||
// must never receive metadata belonging to this completed transfer.
|
||||
await awaitBestEffortBounded((async () => {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.promises.open(transfer.targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code !== "EACCES" && error?.code !== "EPERM") throw error;
|
||||
// O_WRONLY neither creates nor truncates a write-only destination.
|
||||
handle = await fs.promises.open(transfer.targetPath, fs.constants.O_WRONLY);
|
||||
}
|
||||
try {
|
||||
const currentStat = await handle.stat();
|
||||
const publishedIdentity = transfer.publishedLocalIdentity;
|
||||
const expectedIdentity = typeof publishedIdentity === "string"
|
||||
? publishedIdentity : stableLocalFileIdentity(publishedIdentity);
|
||||
if (!currentStat.isFile()
|
||||
|| (expectedIdentity && stableLocalFileIdentity(currentStat) !== expectedIdentity)) return;
|
||||
await handle.utimes(when, when);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
})(), mtimeTimeoutMs, "Destination utimes");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6082,6 +6033,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
} = await inspectLocalPromotionTarget(targetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
await promoteLocalTransfer(downloadTargetPath, promotionTargetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
existingMode,
|
||||
async validateTarget() {
|
||||
const latestTarget = await inspectLocalPromotionTarget(targetPath);
|
||||
@@ -6096,8 +6048,10 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6185,11 +6139,14 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
if (transfer.resumable && transfer.stagedLocalPath) {
|
||||
await promoteLocalTransfer(transfer.stagedLocalPath, targetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6792,7 +6749,13 @@ async function pauseTransfer(_event, payload) {
|
||||
) {
|
||||
return { success: false, reason: "This transfer cannot be paused yet" };
|
||||
}
|
||||
if (transfer.pauseOperation) return transfer.pauseOperation;
|
||||
// A repeated pause is still a newer user decision: invalidate any resume
|
||||
// waiting for verification or outstanding ranges, even if already paused.
|
||||
transfer.pauseRequestToken = Symbol("pause");
|
||||
if (transfer.pauseOperation) {
|
||||
transfer.pauseSuperseded = false;
|
||||
return transfer.pauseOperation;
|
||||
}
|
||||
if (transfer.paused && transfer.lifecycleState === "paused") {
|
||||
const result = {
|
||||
success: true,
|
||||
@@ -6844,7 +6807,7 @@ async function pauseTransfer(_event, payload) {
|
||||
if (usesContiguousRangeCheckpoint) {
|
||||
await transfer.waitForPause();
|
||||
if (!transfer.paused || transfer.pauseSuperseded) {
|
||||
return { success: false, reason: "Pause was superseded by resume" };
|
||||
return { success: false, superseded: true, supersededBy: "resume", reason: "Pause was superseded by resume" };
|
||||
}
|
||||
// Concurrent path already tracks contiguous durable bytes — do not spend
|
||||
// hundreds of ms waiting for writeStream drain before acknowledging pause.
|
||||
@@ -6959,7 +6922,7 @@ async function pauseTransfer(_event, payload) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
if (!transfer.paused || transfer.pauseSuperseded) {
|
||||
return { success: false, reason: "Pause was superseded by resume" };
|
||||
return { success: false, superseded: true, supersededBy: "resume", reason: "Pause was superseded by resume" };
|
||||
}
|
||||
// Confirm pause as soon as soft-drain + durable checkpoint are ready.
|
||||
// Source identity (remote sample reads on download) used to block this IPC
|
||||
@@ -7014,6 +6977,20 @@ async function resumeTransfer(_event, payload) {
|
||||
reason: transfer.pauseUnavailableReason || "This transfer cannot be resumed safely",
|
||||
};
|
||||
}
|
||||
const pauseRequestToken = transfer.pauseRequestToken;
|
||||
const invalidResumeResult = (reason) => ({
|
||||
success: false, reason,
|
||||
...(transfer.pauseRequestToken !== pauseRequestToken ? { superseded: true, supersededBy: "pause" } : {}),
|
||||
});
|
||||
const resumeInvalidReason = () => {
|
||||
if (activeTransfers.get(payload?.transferId) !== transfer || transfer.cancelled) {
|
||||
return "Transfer is no longer active";
|
||||
}
|
||||
if (transfer.pauseRequestToken !== pauseRequestToken) {
|
||||
return "Resume was superseded by a newer pause";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (transfer.pauseOperation) {
|
||||
transfer.pauseSuperseded = true;
|
||||
try { transfer.cancelPauseWait?.(); } catch { }
|
||||
@@ -7023,6 +7000,8 @@ async function resumeTransfer(_event, payload) {
|
||||
if (currentTransfer !== transfer || transfer.cancelled) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
const initialInvalidReason = resumeInvalidReason();
|
||||
if (initialInvalidReason) return invalidResumeResult(initialInvalidReason);
|
||||
// Already flowing (e.g. double-click resume): do not pipe() again.
|
||||
if (!transfer.paused) {
|
||||
transfer.lifecycleState = "transferring";
|
||||
@@ -7057,7 +7036,7 @@ async function resumeTransfer(_event, payload) {
|
||||
await transfer.captureSourceFingerprint?.();
|
||||
}
|
||||
if (!transfer.sourceFingerprint) {
|
||||
return { success: false, reason: "Could not verify the source file for resume" };
|
||||
return invalidResumeResult("Could not verify the source file for resume");
|
||||
}
|
||||
await transfer.verifySourceFingerprint(transfer.sourceFingerprint);
|
||||
}
|
||||
@@ -7065,12 +7044,11 @@ async function resumeTransfer(_event, payload) {
|
||||
if (transfer.cancelled || activeTransfers.get(payload?.transferId) !== transfer) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
reason: error?.message || "Could not verify the source file for resume",
|
||||
};
|
||||
return invalidResumeResult(error?.message || "Could not verify the source file for resume");
|
||||
}
|
||||
}
|
||||
const verifiedInvalidReason = resumeInvalidReason();
|
||||
if (verifiedInvalidReason) return invalidResumeResult(verifiedInvalidReason);
|
||||
// Soft-drained concurrent pause may leave a sparse tail past the contiguous
|
||||
// checkpoint. Wait with Resume's own budget, then single-flight truncate so
|
||||
// background settle cannot race new writes after unpause.
|
||||
@@ -7081,15 +7059,14 @@ async function resumeTransfer(_event, payload) {
|
||||
{ maxWaitMs: RESUME_RANGE_SETTLE_MS },
|
||||
);
|
||||
if (!settled?.ok) {
|
||||
return {
|
||||
success: false,
|
||||
reason: settled?.reason || "The current file is still finishing. Try resume again.",
|
||||
};
|
||||
return invalidResumeResult(settled?.reason || "The current file is still finishing. Try resume again.");
|
||||
}
|
||||
if (transfer.cancelled || activeTransfers.get(payload?.transferId) !== transfer) {
|
||||
return { success: false, reason: "Transfer is no longer active" };
|
||||
}
|
||||
}
|
||||
const finalInvalidReason = resumeInvalidReason();
|
||||
if (finalInvalidReason) return invalidResumeResult(finalInvalidReason);
|
||||
transfer.paused = false;
|
||||
transfer.pauseSuperseded = false;
|
||||
transfer.lifecycleEpoch += 1;
|
||||
@@ -7264,6 +7241,37 @@ function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
|
||||
function registerHandlers(ipcMain, options = {}) {
|
||||
const terminalWorkerManager = options.terminalWorkerManager || null;
|
||||
if (terminalWorkerManager) {
|
||||
// Control replies can arrive out of order across windows. Only the latest
|
||||
// request may publish lifecycle state; retain tokens only while in flight.
|
||||
const workerControlRequests = new Map();
|
||||
const withWorkerControl = async (transferId, action, work) => {
|
||||
const token = Symbol("control");
|
||||
const state = workerControlRequests.get(transferId) || { token, action, pending: 0 };
|
||||
state.token = token;
|
||||
state.action = action;
|
||||
state.pending += 1;
|
||||
workerControlRequests.set(transferId, state);
|
||||
const isCurrent = () => workerControlRequests.get(transferId) === state && state.token === token;
|
||||
const superseded = () => ({ success: false, superseded: true, supersededBy: state.action });
|
||||
try {
|
||||
const result = await work(isCurrent, superseded);
|
||||
if (isCurrent() && !result?.success && !result?.superseded) {
|
||||
if (action === "pause") state.action = "resume";
|
||||
else if (action === "resume") state.action = "pause";
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return superseded();
|
||||
if (action === "pause") state.action = "resume";
|
||||
else if (action === "resume") state.action = "pause";
|
||||
throw error;
|
||||
} finally {
|
||||
state.pending -= 1;
|
||||
if (state.pending === 0 && workerControlRequests.get(transferId) === state) {
|
||||
workerControlRequests.delete(transferId);
|
||||
}
|
||||
}
|
||||
};
|
||||
const nextWorkerLifecycleEpoch = (transferId, suggestedEpoch) => {
|
||||
const entry = workerTransferLifecycleEpochs.get(transferId);
|
||||
const current = Math.max(0, Number(entry?.epoch) || 0);
|
||||
@@ -7289,6 +7297,7 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
&& workerTransferLifecycleEpochs.get(payload.transferId) === lifecycleEntry
|
||||
) {
|
||||
workerTransferLifecycleEpochs.delete(payload.transferId);
|
||||
workerControlRequests.delete(payload.transferId);
|
||||
}
|
||||
};
|
||||
// Renderer (or outer main) already admitted — skip a second queue so
|
||||
@@ -7314,20 +7323,22 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
ipcMain.handle("netcatty:transfer:cancel", (event, payload) => (
|
||||
cancelQueuedTransfer(payload?.transferId)
|
||||
? { success: true }
|
||||
: workerRequest(event, "netcatty:transfer:cancel", payload)
|
||||
: withWorkerControl(payload?.transferId, "cancel", () => workerRequest(event, "netcatty:transfer:cancel", payload))
|
||||
));
|
||||
ipcMain.handle("netcatty:transfer:pause", async (event, payload) => {
|
||||
const queued = pauseQueuedTransfer(payload?.transferId);
|
||||
if (queued) return queued;
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "pausing",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "pausing",
|
||||
return withWorkerControl(payload?.transferId, "pause", async (isCurrent, superseded) => {
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "pausing",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "pausing",
|
||||
});
|
||||
try {
|
||||
const result = await workerRequest(event, "netcatty:transfer:pause", payload);
|
||||
if (!isCurrent()) return superseded();
|
||||
if (!result?.success) {
|
||||
const rollbackEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
@@ -7351,6 +7362,7 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
} catch (error) {
|
||||
if (!isCurrent()) throw error;
|
||||
const rollbackEpoch = nextWorkerLifecycleEpoch(payload?.transferId);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
@@ -7360,24 +7372,28 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
ipcMain.handle("netcatty:transfer:resume", async (event, payload) => {
|
||||
const queuedResume = resumeQueuedTransfer(payload?.transferId);
|
||||
if (queuedResume) return queuedResume;
|
||||
const result = await workerRequest(event, "netcatty:transfer:resume", payload);
|
||||
if (result?.success) {
|
||||
// Normalize into main-process epoch space (may advance past worker-local).
|
||||
// Soft-resume UI must stamp THIS epoch or later worker progress is stale.
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId, result.lifecycleEpoch);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "transferring",
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
}
|
||||
return result;
|
||||
return withWorkerControl(payload?.transferId, "resume", async (isCurrent, superseded) => {
|
||||
const result = await workerRequest(event, "netcatty:transfer:resume", payload);
|
||||
if (!isCurrent()) return superseded();
|
||||
if (result?.success) {
|
||||
// Normalize into main-process epoch space (may advance past worker-local).
|
||||
// Soft-resume UI must stamp THIS epoch or later worker progress is stale.
|
||||
const lifecycleEpoch = nextWorkerLifecycleEpoch(payload?.transferId, result.lifecycleEpoch);
|
||||
broadcastGlobalTransferEvent({
|
||||
type: "resumed",
|
||||
transferId: payload?.transferId,
|
||||
lifecycleEpoch,
|
||||
lifecycleState: "transferring",
|
||||
});
|
||||
return { ...result, lifecycleEpoch };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
ipcMain.handle("netcatty:transfer:prioritize", (event, payload) => (
|
||||
prioritizeQueuedTransfer(payload?.transferId)
|
||||
|
||||
@@ -299,3 +299,85 @@ test("worker-backed pause and resume fan authoritative lifecycle to every window
|
||||
delete require.cache[require.resolve(bridgePath)];
|
||||
}
|
||||
});
|
||||
|
||||
for (const latestAction of ["pause", "resume", "cancel"]) {
|
||||
test(`stale worker pause retains latest ${latestAction} after that newer request has settled`, async (t) => {
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let finishFirst;
|
||||
const firstGate = new Promise((resolve) => { finishFirst = resolve; });
|
||||
let requests = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
||||
});
|
||||
const payload = { transferId: `worker-last-action-${latestAction}` };
|
||||
const first = handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
await handlers.get(`netcatty:transfer:${latestAction}`)(null, payload);
|
||||
finishFirst({ success: true });
|
||||
const stale = await first;
|
||||
assert.equal(stale.superseded, true);
|
||||
assert.equal(stale.supersededBy, latestAction, "completed newer request must retain intent for older pending replies");
|
||||
});
|
||||
}
|
||||
|
||||
for (const outcome of ["resume-success", "pause-success", "pause-failure", "pause-error", "resume-error"]) {
|
||||
test(`worker ${outcome} arriving after a newer pause cannot broadcast resumed`, async (t) => {
|
||||
const sent = [];
|
||||
const restore = withElectronVersionStub();
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === "electron") return { BrowserWindow: { getAllWindows: () => [{
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: (_channel, payload) => sent.push(payload) },
|
||||
}] } };
|
||||
return originalLoad(request, parent, isMain);
|
||||
};
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { Module._load = originalLoad; restore(); delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let resolveFirst;
|
||||
let rejectFirst;
|
||||
const firstGate = new Promise((resolve, reject) => { resolveFirst = resolve; rejectFirst = reject; });
|
||||
let requests = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: async () => ++requests === 1 ? firstGate : { success: true } },
|
||||
});
|
||||
const payload = { transferId: `worker-order-${outcome}` };
|
||||
const channel = outcome.startsWith("resume") ? "resume" : "pause";
|
||||
const first = handlers.get(`netcatty:transfer:${channel}`)(null, payload).catch(() => null);
|
||||
await handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
const afterPause = sent.length;
|
||||
if (outcome.endsWith("error")) rejectFirst(new Error("channel closed"));
|
||||
else resolveFirst({ success: outcome.endsWith("success"), reason: "pause unavailable" });
|
||||
assert.deepEqual(await first, { success: false, superseded: true, supersededBy: "pause" });
|
||||
assert.equal(sent.slice(afterPause).some((event) => event.type === "resumed"), false);
|
||||
assert.equal(sent.at(-1).type, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"]) {
|
||||
test(`superseded result follows rollback after latest ${action} fails`, async (t) => {
|
||||
const bridgePath = require.resolve("./transferBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
t.after(() => { delete require.cache[bridgePath]; });
|
||||
const bridge = require(bridgePath);
|
||||
const handlers = new Map();
|
||||
let finish;
|
||||
let calls = 0;
|
||||
bridge.registerHandlers({ handle: (channel, fn) => handlers.set(channel, fn) }, {
|
||||
terminalWorkerManager: { request: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; })
|
||||
: Promise.resolve({ success: false, reason: "unavailable" }) },
|
||||
});
|
||||
const payload = { transferId: `rollback-${action}` };
|
||||
const older = handlers.get("netcatty:transfer:pause")(null, payload);
|
||||
await handlers.get(`netcatty:transfer:${action}`)(null, payload);
|
||||
finish({ success: true });
|
||||
assert.equal((await older).supersededBy, action === "pause" ? "resume" : "pause");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const bridge = require("./transferBridge.cjs");
|
||||
const temp = require("./tempDirBridge.cjs");
|
||||
|
||||
for (const restore of [false, true]) {
|
||||
test(`local ${restore ? "restore" : "publish"} never overwrites a last-moment concurrent file`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let injected = false;
|
||||
for (const method of ["rename", "link"]) {
|
||||
const original = fs.promises[method];
|
||||
t.after(() => { fs.promises[method] = original; });
|
||||
fs.promises[method] = async (...args) => {
|
||||
if (!injected && args[1] === target && String(args[0]).endsWith(restore ? ".backup" : ".ready")) {
|
||||
injected = true;
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
}
|
||||
return original.apply(fs.promises, args);
|
||||
};
|
||||
}
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target,
|
||||
restore ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}));
|
||||
assert.equal(injected, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const backup = fs.readdirSync(root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup, "retain original for recovery");
|
||||
assert.equal(fs.readFileSync(path.join(root, backup), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("an explicitly absent destination is never moved aside if another writer creates it", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-absent")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
validateTarget: async () => {
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
return { targetIdentity: "missing", existingMode: null };
|
||||
},
|
||||
}));
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
for (const failCopy of [false, true]) {
|
||||
test(`exclusive copy fallback ${failCopy ? "retains recovery files after a write failure" : "publishes complete bytes without hardlinks"}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-fallback")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(2 * 1024 * 1024 + 7, 173);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
if (failCopy) {
|
||||
const open = fs.promises.open;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
handle.write = async () => {
|
||||
// A replacement entry must survive even when the owned old handle fails.
|
||||
fs.unlinkSync(target);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
throw new Error("disk write failed");
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.open = open; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target), /Recovery files preserved/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
} else {
|
||||
await bridge._promoteLocalTransferForTests(staged, target);
|
||||
assert.deepEqual(fs.readFileSync(target), payload);
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("cancellation before publication restores the original destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-cancel")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled: () => { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const interruption of ["cancel", "replace", "restore-replace"]) {
|
||||
test(`fallback ${interruption} during copying preserves complete recovery files`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mid-copy")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(3 * 1024 * 1024, 42);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
const open = fs.promises.open;
|
||||
let cancelled = false;
|
||||
let writes = 0;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const write = handle.write.bind(handle);
|
||||
handle.write = async (...writeArgs) => {
|
||||
const result = await write(...writeArgs);
|
||||
if (++writes === 1) {
|
||||
if (interruption === "cancel") cancelled = true;
|
||||
else { fs.unlinkSync(target); fs.writeFileSync(target, "concurrent"); }
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.link = link; fs.promises.open = open; });
|
||||
let committed = false;
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
...(interruption === "restore-replace" ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}),
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
}), /Recovery files preserved/);
|
||||
assert.equal(committed, false);
|
||||
if (interruption === "cancel") assert.equal(writes, 1);
|
||||
else assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("cancelled replacement restores original timestamps through the copy fallback", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restore-times")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original", { mode: 0o640 });
|
||||
const atime = new Date("2019-01-02T00:00:00Z");
|
||||
const mtime = new Date("2020-02-03T00:00:00Z");
|
||||
fs.utimesSync(target, atime, mtime);
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mtimeMs, mtime.getTime());
|
||||
assert.equal(stat.atimeMs, atime.getTime());
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const replaceAfterCommit of [false, true]) {
|
||||
test(`local publication prepares timestamps before restrictive permissions${replaceAfterCommit ? " and leaves post-commit replacement alone" : ""}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mtime")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
const transfer = {
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
};
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: 0,
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
onCommit(identity) {
|
||||
transfer.publishedLocalIdentity = identity;
|
||||
transfer.localMtimePrepared = true;
|
||||
if (replaceAfterCommit) {
|
||||
fs.renameSync(target, path.join(root, "published"));
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
}
|
||||
},
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests(transfer);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), replaceAfterCommit ? 1_600_000_000 : 1_700_000_000);
|
||||
if (!replaceAfterCommit) assert.equal(stat.mode & 0o777, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("fallback close failure retains the original backup and prepared replacement", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-close")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => { await close(); throw new Error("close failed"); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target), (error) => {
|
||||
assert.equal(error.recoveryFailed, true);
|
||||
assert.equal(fs.readFileSync(error.remoteBackupPath, "utf8"), "original");
|
||||
return true;
|
||||
});
|
||||
const ready = fs.readdirSync(root).find((name) => name.endsWith(".ready"));
|
||||
assert.equal(fs.readFileSync(path.join(root, ready), "utf8"), "download");
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`copy fallback publishes prepared bytes with destination mode ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mode")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, mode);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
});
|
||||
}
|
||||
|
||||
test("direct local timestamp preservation supports a write-only target", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("write-only timestamp fallback leaves a later pathname replacement unchanged", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
const published = path.join(root, "published");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
let replaced = false;
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === fs.constants.O_WRONLY) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => {
|
||||
fs.renameSync(target, published);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
replaced = true;
|
||||
return utimes(...times);
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor(fs.statSync(target).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(Math.floor(fs.statSync(published).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("copy fallback refuses to move an unreadable existing destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restrictive")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(1024 * 1024 + 3, 7);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original", { mode: 0o200 });
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
return open(...args);
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, { existingMode: 0o200 }), /hardlink recovery unavailable/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(stat.size, "original".length);
|
||||
fs.chmodSync(target, 0o600); // grant readback for verification
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`unreadable original remains at destination when safe restoration is unavailable: ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-unreadable")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
fs.chmodSync(target, mode);
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[1] === "r" && (args[0] === target || String(args[0]).endsWith(".backup"))) {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
return open(...args);
|
||||
});
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) cancelled = true;
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode,
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}));
|
||||
assert.equal(fs.existsSync(target), true, "unrestorable original must not be moved away");
|
||||
assert.equal(fs.statSync(target).mode & 0o777, mode);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("copy recovery retains original read access after its permissions become restrictive", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-held-handle")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) {
|
||||
fs.chmodSync(args[1], 0);
|
||||
cancelled = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}), /Transfer cancelled/);
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
@@ -1607,6 +1607,125 @@ test("pause soft-drains concurrent ranges but resume waits before truncating", a
|
||||
assert.equal(durableBytes, payload.length);
|
||||
});
|
||||
|
||||
for (const settleWrites of [true, false]) {
|
||||
test(`a newer pause supersedes resume while concurrent writes are draining: settle=${settleWrites}`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-repause-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Several MB so concurrent fanout stays saturated while we hold writes.
|
||||
const payload = Buffer.alloc(UPLOAD_TRANSFER_CONCURRENCY * TRANSFER_CHUNK_SIZE * 4, 65);
|
||||
const localPath = path.join(tempDir, "upload.bin");
|
||||
await fs.promises.writeFile(localPath, payload);
|
||||
let holdWrites = true;
|
||||
const pendingWrites = [];
|
||||
let durableBytes = 0;
|
||||
const truncateCalls = [];
|
||||
const fastSftp = createFastSftp({
|
||||
open(_remotePath, _flags, callback) {
|
||||
callback(null, Buffer.from("remote-handle"));
|
||||
},
|
||||
write(_handle, _buffer, _offset, length, position, callback) {
|
||||
const complete = () => {
|
||||
durableBytes = Math.max(durableBytes, position + length);
|
||||
callback(null);
|
||||
};
|
||||
if (holdWrites) pendingWrites.push({ position, complete });
|
||||
else setImmediate(complete);
|
||||
},
|
||||
close(_handle, callback) {
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
const client = {
|
||||
sftp: createFastSftp({}),
|
||||
stat() {
|
||||
return Promise.resolve({ size: durableBytes });
|
||||
},
|
||||
truncate(_remotePath, size) {
|
||||
truncateCalls.push(size);
|
||||
durableBytes = size;
|
||||
return Promise.resolve();
|
||||
},
|
||||
rename() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
delete() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
client: {
|
||||
sftp(callback) {
|
||||
callback(null, fastSftp);
|
||||
},
|
||||
},
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["target", client]]) });
|
||||
|
||||
const running = transferBridge.startTransfer(
|
||||
{ sender: createSender() },
|
||||
{
|
||||
transferId: "upload-resume-repause",
|
||||
sourcePath: localPath,
|
||||
targetPath: "/tmp/upload-soft.bin",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "target",
|
||||
totalBytes: payload.length,
|
||||
resumable: true,
|
||||
},
|
||||
);
|
||||
|
||||
const readyDeadline = Date.now() + 1000;
|
||||
while (pendingWrites.length < UPLOAD_TRANSFER_CONCURRENCY && Date.now() < readyDeadline) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.ok(pendingWrites.length >= 1, "expected in-flight concurrent writes");
|
||||
|
||||
const started = Date.now();
|
||||
// Hold writes so active ranges never drain — soft-drain must still resolve.
|
||||
const paused = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
||||
const elapsed = Date.now() - started;
|
||||
assert.equal(paused.success, true);
|
||||
// Soft drain is PAUSE_RANGE_DRAIN_MS (~50ms); allow headroom without full drain.
|
||||
assert.ok(elapsed < 1500, `soft pause took too long: ${elapsed}ms`);
|
||||
|
||||
const outOfOrderWrite = pendingWrites.pop();
|
||||
assert.ok(outOfOrderWrite?.position > 0, "expected a range beyond the contiguous checkpoint");
|
||||
outOfOrderWrite.complete();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let resumeSettled = false;
|
||||
const resuming = transferBridge.resumeTransfer(null, { transferId: "upload-resume-repause" })
|
||||
.then((result) => {
|
||||
resumeSettled = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const truncatedBeforeDrain = truncateCalls.length > 0;
|
||||
const resumedBeforeDrain = resumeSettled;
|
||||
|
||||
const newerPause = await transferBridge.pauseTransfer(null, { transferId: "upload-resume-repause" });
|
||||
assert.equal(newerPause.success, true);
|
||||
if (settleWrites) {
|
||||
holdWrites = false;
|
||||
for (const { complete } of pendingWrites.splice(0)) complete();
|
||||
}
|
||||
const resumeResult = await resuming;
|
||||
holdWrites = false;
|
||||
for (const { complete } of pendingWrites.splice(0)) complete();
|
||||
await transferBridge.cancelTransfer(null, { transferId: "upload-resume-repause" });
|
||||
await running;
|
||||
assert.equal(resumeResult.success, false, "old resume must not restart writes after a newer pause");
|
||||
if (settleWrites) assert.match(resumeResult.reason, /superseded.*pause/i);
|
||||
assert.equal(resumeResult.superseded, true, "direct callers need a structured superseded result too");
|
||||
assert.equal(resumeResult.supersededBy, "pause");
|
||||
assert.equal(truncatedBeforeDrain, false);
|
||||
assert.equal(resumedBeforeDrain, false);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
test("cancelling resume during soft-drain does not truncate the staged file", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-transfer-resume-cancel-"));
|
||||
t.after(async () => {
|
||||
@@ -1766,6 +1885,8 @@ test("resuming while a fast pause is pending settles the pause request", async (
|
||||
assert.deepEqual(await pausing, {
|
||||
success: false,
|
||||
reason: "Pause was superseded by resume",
|
||||
superseded: true,
|
||||
supersededBy: "resume",
|
||||
});
|
||||
|
||||
finishWrite();
|
||||
@@ -10532,6 +10653,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
let delayedOpen = null;
|
||||
let abandonedChannelClosed = false;
|
||||
let openCalls = 0;
|
||||
let physicalEndCalls = 0;
|
||||
let physicalDestroyCalls = 0;
|
||||
const abandonedSftp = createFastSftp({
|
||||
end() {
|
||||
abandonedChannelClosed = true;
|
||||
@@ -10551,6 +10674,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
return Promise.resolve({ size: 10 });
|
||||
},
|
||||
client: {
|
||||
end() { physicalEndCalls++; },
|
||||
destroy() { physicalDestroyCalls++; },
|
||||
sftp(callback) {
|
||||
openCalls += 1;
|
||||
if (openCalls === 1) {
|
||||
@@ -10588,6 +10713,8 @@ test("SFTP downloads cancelled while opening do not block the session", async (t
|
||||
const cancelled = await cancelledPromise;
|
||||
assert.equal(cancelled.error, "Transfer cancelled");
|
||||
assert.equal(abandonedChannelClosed, true);
|
||||
assert.equal(physicalEndCalls, 0, "cancelling one download must preserve the shared SSH connection");
|
||||
assert.equal(physicalDestroyCalls, 0, "terminal and browsing channels must not be destroyed");
|
||||
|
||||
const next = await Promise.race([
|
||||
start("download-after-cancel"),
|
||||
@@ -12269,55 +12396,31 @@ test("local promotion does not clobber a target recreated after backup", async (
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
});
|
||||
|
||||
test("local promotion rollback does not clobber a concurrent post-publish target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-postpub-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`);
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "download.staged");
|
||||
const targetPath = path.join(tempDir, "target.bin");
|
||||
const original = Buffer.from("original-target-content");
|
||||
const concurrent = Buffer.from("post-publish-concurrent");
|
||||
const downloaded = Buffer.from("downloaded-replacement");
|
||||
await fs.promises.writeFile(targetPath, original);
|
||||
await fs.promises.writeFile(stagedPath, downloaded);
|
||||
const originalStat = await fs.promises.lstat(targetPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
async validateTarget() {
|
||||
return {
|
||||
existingMode: null,
|
||||
stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat),
|
||||
targetIdentity: [
|
||||
originalStat.dev,
|
||||
originalStat.ino,
|
||||
originalStat.size,
|
||||
originalStat.mtimeMs,
|
||||
originalStat.ctimeMs,
|
||||
].join(":"),
|
||||
};
|
||||
},
|
||||
assertNotCancelled() {
|
||||
// After ready is published onto targetPath, simulate concurrent replace + cancel.
|
||||
try {
|
||||
if (fs.readFileSync(targetPath).equals(downloaded)) {
|
||||
fs.writeFileSync(targetPath, concurrent);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err.message || err).includes("cancelled")) throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
/cancelled/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
|
||||
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
|
||||
assert.ok(backupName, "original should remain in backup");
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
await fs.promises.writeFile(targetPath, "original");
|
||||
await fs.promises.writeFile(stagedPath, "download");
|
||||
let cancelled = false;
|
||||
let committed = false;
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
fs.promises.link = async (...args) => {
|
||||
await link.apply(fs.promises, args);
|
||||
if (args[1] === targetPath && String(args[0]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
});
|
||||
assert.equal(committed, true);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
test("local promotion succeeds when backup still matches validated identity", async (t) => {
|
||||
@@ -12376,6 +12479,109 @@ test("preserveTransferredDestinationMtime stamps local targets from sourceSoftId
|
||||
assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("concurrent"));
|
||||
const before = await fs.promises.stat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
// Identity recorded at publication no longer matches the pathname's file.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
|
||||
// A vanished destination is likewise left alone.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath: path.join(tempDir, "missing.bin"),
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
const publishedStat = await fs.promises.lstat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat),
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("local publication returns an identity usable for timestamp preservation", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "stage");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(stagedPath, "payload");
|
||||
let publishedLocalIdentity;
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
onCommit(identity) { publishedLocalIdentity = identity; },
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
const oldPath = path.join(tempDir, "published");
|
||||
await fs.promises.writeFile(targetPath, "payload");
|
||||
const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath));
|
||||
let replaced = false;
|
||||
const replace = async () => {
|
||||
if (replaced) return;
|
||||
replaced = true;
|
||||
await fs.promises.rename(targetPath, oldPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000);
|
||||
};
|
||||
const originalUtimes = fs.promises.utimes;
|
||||
const originalOpen = fs.promises.open;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (name === targetPath) await replace();
|
||||
return originalUtimes(name, ...args);
|
||||
});
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
if (args[0] === targetPath) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => { await replace(); return utimes(...times); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
});
|
||||
|
||||
test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => {
|
||||
const hangingClient = {
|
||||
async chmod() {
|
||||
@@ -12751,3 +12957,54 @@ test("in-place isolated OPEN keeps path gate until late callback after channel e
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close"));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
await fs.promises.chmod(targetPath, 0o200);
|
||||
const sourceMtimeMs = 1_700_000_000_000;
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000);
|
||||
assert.equal(after.mode & 0o777, 0o200);
|
||||
});
|
||||
|
||||
for (const failureMode of ["rejection", "timeout"]) {
|
||||
test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const sourcePath = path.join(tempDir, "source");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(sourcePath, "payload");
|
||||
await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000);
|
||||
let preparationFailed = false;
|
||||
const utimes = fs.promises.utimes;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (String(name).endsWith(".ready")) {
|
||||
preparationFailed = true;
|
||||
if (failureMode === "timeout") return new Promise(() => {});
|
||||
throw Object.assign(new Error("transient metadata failure"), { code: "EIO" });
|
||||
}
|
||||
return utimes(name, ...args);
|
||||
});
|
||||
transferBridge.init({ sftpClients: new Map() });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "local-mtime-retry", sourcePath, targetPath,
|
||||
sourceType: "local", targetType: "local", totalBytes: 7, resumable: true,
|
||||
});
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(preparationFailed, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload");
|
||||
});
|
||||
|
||||
}
|
||||
+55
-5
@@ -95,6 +95,8 @@ const _mcpLineMetas = new Map(); // sessionId -> trailing fragment metadata
|
||||
const _mcpPendingMetas = new Map(); // sessionId -> metadata from filtered-empty chunks
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
const _mcpDroppingWrappedLine = new Set(); // sessionIds with a split marker echo line in progress
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> probe marker awaiting its command
|
||||
const _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
function clearMcpSessionState(sessionId) {
|
||||
@@ -106,6 +108,31 @@ function clearMcpSessionState(sessionId) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
_mcpAbortedProbes.delete(sessionId);
|
||||
}
|
||||
|
||||
function filterProbePromptFragment(sessionId, fragment) {
|
||||
const reset = fragment.match(/(__NCMCP_(?:(?!__NCMCP_)[A-Za-z0-9_])+)_R/);
|
||||
if (reset) {
|
||||
const aborted = _mcpAbortedProbes.get(sessionId) || new Set();
|
||||
aborted.add(reset[1]);
|
||||
if (aborted.size > 32) aborted.delete(aborted.values().next().value);
|
||||
_mcpAbortedProbes.set(sessionId, aborted);
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
const marker = _mcpProbePrompts.get(sessionId);
|
||||
if (!marker) return false;
|
||||
const line = fragment.replace(/^\r/, "");
|
||||
if (line.startsWith(`${marker}_S`) || line.startsWith(`${marker}_E`)) {
|
||||
_mcpProbePrompts.delete(sessionId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
@@ -115,6 +142,14 @@ function _endsWithMarkerPrefix(s) {
|
||||
for (let i = 1; i < p.length; i++) {
|
||||
if (s.endsWith(p.slice(0, i))) return true;
|
||||
}
|
||||
// The shell-neutral live probe begins with this harmless builtin. Its
|
||||
// echo can arrive before the random marker (e.g. " tru"), so retain the
|
||||
// partial prefix just like a split marker. Ordinary text is still released
|
||||
// by the existing bounded flush if no marker follows.
|
||||
const probePrefix = " true __NCMCP_";
|
||||
for (let i = 2; i < probePrefix.length; i++) {
|
||||
if (s.endsWith(probePrefix.slice(0, i))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -141,7 +176,7 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
_mcpPendingMetas.delete(sessionId);
|
||||
|
||||
// Fast path: nothing suspicious in the combined data
|
||||
if (!_mcpDroppingWrappedLine.has(sessionId) && !data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
if (!_mcpDroppingWrappedLine.has(sessionId) && !_mcpProbePrompts.has(sessionId) && !data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
|
||||
const deliveryMeta = held ? stateMeta : sameChunkMeta;
|
||||
return {
|
||||
data,
|
||||
@@ -169,7 +204,8 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
const probePrompt = filterProbePromptFragment(sessionId, tail);
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
let tailMeta = !held && tail === chunk ? sameChunkMeta : stateMeta;
|
||||
if (heldIngressAlreadyAcknowledged && !hasPluginPipelineIngress(tailMeta)) {
|
||||
tailMeta = { ...(tailMeta || {}), pluginPipelineIngressBytes: 0 };
|
||||
@@ -186,14 +222,19 @@ function filterMcpChunk(sessionId, chunk, meta) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
if (droppedAny) _mcpDroppingWrappedLine.add(sessionId);
|
||||
// The reserved prefix already identifies an internal line. Its
|
||||
// remaining marker or suffix may arrive after the timed flush.
|
||||
if (probePrompt || droppedAny || tail.includes("__NCMCP_")) {
|
||||
_mcpDroppingWrappedLine.add(sessionId);
|
||||
}
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
}
|
||||
break;
|
||||
}
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (droppedAny || line.includes("__NCMCP_")) {
|
||||
const probePrompt = filterProbePromptFragment(sessionId, line);
|
||||
if (probePrompt || droppedAny || line.includes("__NCMCP_")) {
|
||||
droppedAny = false;
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
} else {
|
||||
@@ -244,7 +285,16 @@ function flushMcpBufferedOutput(sessionId) {
|
||||
_mcpLineMetas.delete(sessionId);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
if (_mcpDroppingWrappedLine.has(sessionId)) {
|
||||
_mcpDroppingWrappedLine.delete(sessionId);
|
||||
// Retain a bounded partial marker so a delayed Q/R suffix still updates
|
||||
// the probe lifecycle, rather than losing its identity at the timer.
|
||||
if (held && /^\r?__NCMCP_[A-Za-z0-9_]*$/.test(held) && held.length <= 256) {
|
||||
_mcpLineBufs.set(sessionId, held);
|
||||
if (heldMeta) _mcpLineMetas.set(sessionId, heldMeta);
|
||||
return;
|
||||
}
|
||||
// A timed flush is not a line boundary. Keep discarding a known
|
||||
// internal line until its newline arrives, including slow prompts
|
||||
// between the probe completion marker and an echo-disabled wrapper.
|
||||
if (heldMeta) _mcpPendingMetas.set(sessionId, mergeTerminalDataMeta(_mcpPendingMetas.get(sessionId), heldMeta));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1607,3 +1607,176 @@ test("startPluginConnection reopens a previously closed terminal data session",
|
||||
assert.equal(invoked[0].wasClosed, false);
|
||||
assert.equal(closedTerminalDataSessions.has("session-1"), false);
|
||||
});
|
||||
|
||||
|
||||
test("live-shell probe suppresses the intermediate prompt across chunks", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("probe-session", (chunk) => received.push(chunk));
|
||||
for (const data of [
|
||||
"__NCMCP_probe___P:bash\r\n",
|
||||
"__NCMCP_probe___Q",
|
||||
"user@host:~$ ",
|
||||
" __NCMCP_probe__=0; wrapped-command\r\n",
|
||||
"__NCMCP_probe___S\r\n",
|
||||
"file.txt\r\n",
|
||||
]) {
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "probe-session", data });
|
||||
}
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("live-shell probe buffers echoed builtin prefixes until the marker arrives", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("probe-echo", (chunk) => received.push(chunk));
|
||||
for (const data of [" tru", "e __NCM", "CP_probe__; probe-command\r\n", "file.txt\r\n"]) {
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "probe-echo", data });
|
||||
}
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("ordinary text resembling the probe prefix is eventually delivered", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("ordinary-text", (chunk) => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "ordinary-text", data: "ordinary tru" });
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "ordinary tru");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("slow echo-disabled probe prompt stays hidden across delayed flushes", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("slow-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "slow-probe", data });
|
||||
send("__NCMCP_probe___Q");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("slow prompt> ");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("split probe completion markers stay hidden across delayed flushes", async () => {
|
||||
for (const prefix of ["__NCMCP_", "__NCMCP_probe___"]) {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("split-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "split-probe", data });
|
||||
send(prefix);
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("__NCMCP_probe___Q".slice(prefix.length) + "slow prompt> ");
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("multiline probe prompts remain hidden until the matching command starts", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("multiline-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "multiline-probe", data });
|
||||
send("__NCMCP_probe___");
|
||||
await sleep(120);
|
||||
send("Qfirst prompt line\r\nsecond prompt line\r\n> ");
|
||||
await sleep(120);
|
||||
send("__NCMCP_other___S\r\nstill prompt\r\n");
|
||||
assert.equal(received.join(""), "");
|
||||
send("\r\n__NCMCP_probe___S\r\nfile.txt\r\n");
|
||||
assert.equal(received.join(""), "file.txt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("aborted probes release multiline suppression and ignore a late completion", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("aborted-probe", (chunk) => received.push(chunk));
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "aborted-probe", data });
|
||||
send("__NCMCP_probe___Qfirst prompt\r\nsecond prompt\r\n");
|
||||
send("__NCMCP_probe___R\r\n");
|
||||
send("normal output\r\n");
|
||||
send("__NCMCP_buffered___Q");
|
||||
send("__NCMCP_buffered___R\r\n");
|
||||
send("after buffered cancellation\r\n");
|
||||
send("__NCMCP_late___R\r\n");
|
||||
send("__NCMCP_late___Q\r\nlate prompt\r\n");
|
||||
assert.equal(received.join(""), "normal output\r\nafter buffered cancellation\r\nlate prompt\r\n");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("real PTY multiline prompt is displayed only after the AI command", {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== "1", timeout: 10000,
|
||||
}, async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
const terminal = require("node-pty").spawn("/bin/bash", ["--noprofile", "--norc", "--noediting"], {
|
||||
name: "dumb", cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: "dumb", PS1: "FIRST_LINE\nLAST_LINE> ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
|
||||
});
|
||||
let raw = "";
|
||||
const received = [];
|
||||
const send = (data) => preload.handlers.get("netcatty:data")({}, { sessionId: "real-multiline", data });
|
||||
preload.api.onSessionData("real-multiline", (data) => received.push(data));
|
||||
terminal.onData((data) => { raw += data; send(data); });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes("LAST_LINE>")) {
|
||||
if (Date.now() > deadline) throw new Error("Missing multiline shell prompt");
|
||||
await sleep(10);
|
||||
}
|
||||
raw = "";
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
terminal.write("stty -echo\r");
|
||||
await ready();
|
||||
received.length = 0;
|
||||
const result = await require("./bridges/ai/ptyExec.cjs").execViaPty(terminal, "printf 'visible-result\\n'", {
|
||||
shellKind: "posix", probeLiveShell: true, stripMarkers: true, timeoutMs: 3000,
|
||||
onProbeAborted: (marker) => send(`${marker}_R\n`),
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
await sleep(160);
|
||||
const display = received.join("");
|
||||
assert.match(display, /visible-result/);
|
||||
assert.equal((display.match(/FIRST_LINE/g) || []).length, 1, display);
|
||||
assert.equal((display.match(/LAST_LINE/g) || []).length, 1, display);
|
||||
assert.equal(display.includes("__NCMCP_"), false, display);
|
||||
} finally {
|
||||
terminal.kill();
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -290,6 +290,13 @@ function createWorkerAiExecHandler({
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
@@ -486,6 +493,13 @@ function createWorkerAiJobStartHandler({
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
onProbeAborted: (marker) => {
|
||||
event?.sender?.send?.("netcatty:data", {
|
||||
sessionId,
|
||||
data: `${marker}_R\n`,
|
||||
});
|
||||
},
|
||||
chatSessionId,
|
||||
expectedPrompt: getFreshIdlePrompt(session),
|
||||
typedInput: true,
|
||||
|
||||
@@ -18,6 +18,10 @@ class FakePty extends EventEmitter {
|
||||
|
||||
write(data) {
|
||||
this.writes.push(String(data));
|
||||
if (String(data).includes("command sh -c")) {
|
||||
const marker = String(data).match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +53,7 @@ function createFakeEvent() {
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_"));
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.ok(wrapper, "expected wrapped command to be written to the PTY");
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
@@ -257,7 +261,7 @@ test("worker background job probes unset remote shellKind before wrapping", asyn
|
||||
// Login fish is a soft hint (not pinned); wrapper should be fish-native.
|
||||
assert.equal(sessions.get("ssh-fish").shellKind, undefined);
|
||||
assert.equal(sessions.get("ssh-fish")._loginShellKind, "fish");
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_"));
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
@@ -342,7 +346,7 @@ test("worker chat cancel during shellKind probe aborts job start before PTY writ
|
||||
assert.equal(started.ok, false);
|
||||
assert.equal(started.error, "Cancelled");
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_")).length,
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"cancelled pending start must not type a wrapper into the PTY",
|
||||
);
|
||||
@@ -378,7 +382,7 @@ test("worker exec keeps shell-selected defaults: powershell frees $(), dangerous
|
||||
});
|
||||
await nextTick();
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_")),
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"expected the unblocked command to reach the PTY",
|
||||
);
|
||||
await allowedPromise.catch(() => {});
|
||||
@@ -417,7 +421,7 @@ test("worker exec honors an explicitly empty configured blocklist", async () =>
|
||||
await nextTick();
|
||||
|
||||
assert.ok(
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_")),
|
||||
pty.writes.some((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")),
|
||||
"disabled defaults must allow the command to reach the PTY wrapper",
|
||||
);
|
||||
await execution.catch(() => {});
|
||||
@@ -482,7 +486,7 @@ test("worker exec on an unclassified posix session still blocks command substitu
|
||||
assert.equal(blocked.ok, false);
|
||||
assert.match(blocked.error, /Command blocked by safety policy/);
|
||||
assert.equal(
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_")).length,
|
||||
pty.writes.filter((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c")).length,
|
||||
0,
|
||||
"blocked commands must not reach the PTY",
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getActiveConnection,
|
||||
hasActivePortForwardRuntime,
|
||||
reconcileWithBackend,
|
||||
resetReconnectAttempts,
|
||||
setReconnectCallback,
|
||||
startAllPortForwards,
|
||||
startPortForward,
|
||||
@@ -890,6 +891,191 @@ test("inactive backend events remove the runtime tunnel immediately", async () =
|
||||
assert.equal(getActiveConnection(disconnectedRule.id), undefined);
|
||||
});
|
||||
|
||||
test("auto-start rules reconnect after an unexpected inactive event", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_tunnelId: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const reconnectRule = rule({ id: "inactive-event-reconnect-rule" });
|
||||
setReconnectCallback(async () => ({ success: true }));
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
statusListener?.("inactive");
|
||||
|
||||
const connection = getActiveConnection(reconnectRule.id);
|
||||
assert.ok(connection?.reconnectTimerCallback);
|
||||
assert.equal(connection.status, "connecting");
|
||||
});
|
||||
|
||||
for (const disableDuringDelay of [false, true]) {
|
||||
test(`inactive reconnect respects auto-start disabled ${disableDuringDelay ? "during delay" : "before disconnect"}`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
let autoStart = true;
|
||||
let reconnects = 0;
|
||||
const statuses: string[] = [];
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const reconnectRule = rule({ id: `disabled-inactive-${disableDuringDelay}`, autoStart: true });
|
||||
setReconnectCallback(async () => {
|
||||
reconnects += 1;
|
||||
return { success: true };
|
||||
}, () => autoStart);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], (status) => statuses.push(status), true);
|
||||
if (!disableDuringDelay) autoStart = false;
|
||||
statusListener?.("inactive");
|
||||
if (disableDuringDelay) {
|
||||
assert.ok(getActiveConnection(reconnectRule.id)?.reconnectTimerCallback);
|
||||
autoStart = false;
|
||||
}
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.equal(getActiveConnection(reconnectRule.id), undefined);
|
||||
assert.equal(statuses.at(-1), "inactive");
|
||||
});
|
||||
}
|
||||
|
||||
for (const autoStartAfterReplacement of [false, true]) {
|
||||
test(`stale reconnect leaves a replacement tunnel intact with auto-start ${autoStartAfterReplacement}`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
let autoStart = true;
|
||||
let reconnects = 0;
|
||||
const reconnectRule = rule({ id: `replaced-inactive-${autoStartAfterReplacement}`, autoStart: true });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "replacement", status: "active", type: "local" }],
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => autoStart);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const statuses: string[] = [];
|
||||
await startPortForward(reconnectRule, host(), [], [], [], (status) => statuses.push(status), true);
|
||||
statusListener?.("inactive");
|
||||
assert.ok(getActiveConnection(reconnectRule.id)?.reconnectTimerCallback);
|
||||
await reconcileWithBackend();
|
||||
const replacement = getActiveConnection(reconnectRule.id);
|
||||
assert.equal(replacement?.tunnelId, "replacement");
|
||||
const previousStatuses = [...statuses];
|
||||
autoStart = autoStartAfterReplacement;
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(getActiveConnection(reconnectRule.id), replacement);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.deepEqual(statuses, previousStatuses);
|
||||
statusListener?.("active");
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.status, "active");
|
||||
});
|
||||
}
|
||||
|
||||
test("final retry error followed by inactive preserves exhaustion until explicit recovery", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
let reconnects = 0;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
const reconnectRule = rule({ id: "exhausted-inactive-rule", autoStart: true });
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
const connection = getActiveConnection(reconnectRule.id)!;
|
||||
connection.reconnectAttempts = 5;
|
||||
statusListener?.("error");
|
||||
statusListener?.("inactive");
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.equal(getActiveConnection(reconnectRule.id), undefined);
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
statusListener?.("inactive");
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`inactive during failed startup schedules only one retry (${rejects ? "throw" : "reply"})`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
const reconnectRule = rule({ id: `inactive-start-failed-${rejects}`, autoStart: true });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => {
|
||||
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 4;
|
||||
statusListener?.("inactive");
|
||||
if (rejects) throw new Error("SSH connection closed before ready");
|
||||
return { success: false, error: "SSH connection closed before ready" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 5);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test("inactive close events preserve an already scheduled reconnect", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
@@ -919,6 +1105,14 @@ test("inactive close events preserve an already scheduled reconnect", async (t)
|
||||
assert.ok(scheduled?.reconnectTimerCallback);
|
||||
assert.equal(scheduled.status, "connecting");
|
||||
|
||||
const pendingRetry = scheduled.reconnectTimerCallback;
|
||||
statusListener?.("error", "connection closed");
|
||||
assert.equal(scheduled.status, "connecting");
|
||||
assert.equal(scheduled.error, "Reconnecting (1/5)...");
|
||||
assert.equal(scheduled.reconnectTimerCallback, pendingRetry);
|
||||
assert.deepEqual((await reconcileWithBackend()).gone, []);
|
||||
assert.equal(getActiveConnection(reconnectRule.id), scheduled);
|
||||
|
||||
statusListener?.("inactive");
|
||||
|
||||
assert.equal(getActiveConnection(reconnectRule.id), scheduled);
|
||||
|
||||
@@ -82,14 +82,17 @@ let reconnectCallback: ((
|
||||
ruleId: string,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void
|
||||
) => Promise<{ success: boolean; error?: string }>) | null = null;
|
||||
let shouldReconnectRule: ((ruleId: string) => boolean) | undefined;
|
||||
|
||||
/**
|
||||
* Set the reconnect callback (called by state hook to enable auto-reconnect)
|
||||
*/
|
||||
export const setReconnectCallback = (
|
||||
callback: typeof reconnectCallback
|
||||
callback: typeof reconnectCallback,
|
||||
shouldReconnect?: (ruleId: string) => boolean,
|
||||
): void => {
|
||||
reconnectCallback = callback;
|
||||
shouldReconnectRule = callback ? shouldReconnect : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -212,7 +215,7 @@ const scheduleReconnectIfNeeded = (
|
||||
enableReconnect: boolean,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
): boolean => {
|
||||
if (!enableReconnect || !reconnectCallback) {
|
||||
if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) {
|
||||
return false;
|
||||
}
|
||||
if (rulesPendingCleanup.has(ruleId)) {
|
||||
@@ -222,6 +225,11 @@ const scheduleReconnectIfNeeded = (
|
||||
|
||||
const currentConn = activeConnections.get(ruleId);
|
||||
if (currentConn?.reconnectSuppressed) return false;
|
||||
if (currentConn?.reconnectTimerCallback) {
|
||||
currentConn.status = 'connecting';
|
||||
currentConn.error = `Reconnecting (${currentConn.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`;
|
||||
return true;
|
||||
}
|
||||
const attempts = (currentConn?.reconnectAttempts ?? 0) + 1;
|
||||
|
||||
if (attempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||
@@ -242,6 +250,13 @@ const scheduleReconnectIfNeeded = (
|
||||
currentConn.reconnectTimeoutId = undefined;
|
||||
currentConn.reconnectDueAt = undefined;
|
||||
currentConn.reconnectTimerCallback = undefined;
|
||||
if (activeConnections.get(ruleId) !== currentConn) return;
|
||||
if (shouldReconnectRule?.(ruleId) === false) {
|
||||
currentConn.unsubscribe?.();
|
||||
activeConnections.delete(ruleId);
|
||||
onStatusChange('inactive');
|
||||
return;
|
||||
}
|
||||
if (reconnectCallback) {
|
||||
currentConn.reconnectStartAuthorized = true;
|
||||
reconnectCallback(ruleId, onStatusChange);
|
||||
@@ -1018,6 +1033,22 @@ export const startPortForward = async (
|
||||
conn.locallyInitiated = false;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!manualStopsInProgress.has(rule.id)
|
||||
&& !rulesPendingCleanup.has(rule.id)
|
||||
&& !exhaustedReconnectRules.has(rule.id)
|
||||
) {
|
||||
const reconnectScheduled = scheduleReconnectIfNeeded(
|
||||
rule.id,
|
||||
enableReconnect,
|
||||
onStatusChange,
|
||||
);
|
||||
if (reconnectScheduled) {
|
||||
conn?.unsubscribe?.();
|
||||
if (conn) conn.unsubscribe = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
conn?.unsubscribe?.();
|
||||
clearReconnectTimer(rule.id);
|
||||
activeConnections.delete(rule.id);
|
||||
|
||||
@@ -23,6 +23,28 @@ function detectLocalOs(platformLike) {
|
||||
return "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `filePath` points inside `%LOCALAPPDATA%\Microsoft\WindowsApps`,
|
||||
* i.e. it is a Windows App Execution Alias (MSIX/Store install stub).
|
||||
*
|
||||
* Aliases are zero-byte reparse points: `fs.statSync()` fails with EACCES and
|
||||
* `fs.existsSync()` returns false, yet spawning the alias path still launches
|
||||
* the packaged application. Consumers must not use existence checks to reject
|
||||
* these paths. Deliberately free of `node:path` — this module is also bundled
|
||||
* for the renderer.
|
||||
*/
|
||||
function isWindowsAppExecutionAliasPath(filePath) {
|
||||
if (!filePath || typeof filePath !== "string") return false;
|
||||
// Read via globalThis so renderer bundles (where `process` is not a global)
|
||||
// stay valid; alias detection is a main-process concern and safely
|
||||
// returns false when the env is unavailable.
|
||||
const localAppData = globalThis.process?.env?.LOCALAPPDATA;
|
||||
if (!localAppData) return false;
|
||||
const normalize = (p) => p.replace(/\\/g, "/").replace(/\/+/g, "/").toLowerCase();
|
||||
const aliasDir = `${normalize(localAppData)}/microsoft/windowsapps/`;
|
||||
return normalize(filePath).startsWith(aliasDir);
|
||||
}
|
||||
|
||||
function classifyLocalShellType(shellPath, platformLike) {
|
||||
const shellName = getExecutableBaseName(shellPath);
|
||||
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
|
||||
@@ -39,4 +61,5 @@ function classifyLocalShellType(shellPath, platformLike) {
|
||||
module.exports = {
|
||||
classifyLocalShellType,
|
||||
detectLocalOs,
|
||||
isWindowsAppExecutionAliasPath,
|
||||
};
|
||||
@@ -65,6 +65,7 @@
|
||||
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
|
||||
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
|
||||
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
|
||||
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
|
||||
"test:xterm-macos-selection": "electron scripts/xterm-macos-column-selection.live.test.cjs",
|
||||
"test:xterm-keyword-highlight-performance": "electron scripts/xterm-keyword-highlight-performance.live.test.cjs",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* global __dirname, process, setTimeout, clearTimeout, console */
|
||||
// Opt-in real Claude Code permission check; all model traffic stays on loopback.
|
||||
// Usage: node scripts/fixtures/research-permission-probe.cjs [claude executable]
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { prepareAiCliSettings } = require('../ai-automation.cjs');
|
||||
const repository = path.resolve(__dirname, '../..');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-research-permission-'));
|
||||
const settings = path.join(root, 'settings.json');
|
||||
prepareAiCliSettings({ configPath: settings, denyWeb: true, allowBrave: true, allowWrites: false });
|
||||
for (const name of ['web-search', 'web-fetch', 'unrelated-helper']) {
|
||||
fs.writeFileSync(path.join(root, name), '#!/bin/sh\nprintf RESEARCH_FIXTURE_OK\n', { mode: 0o755 });
|
||||
}
|
||||
const workflow = fs.readFileSync(path.join(repository, '.github/workflows/ai-automation.yml'), 'utf8');
|
||||
const blocks = [...workflow.matchAll(/--allowedTools "Read" "Bash\(web-search \*\)"([\s\S]*?)--disallowedTools/g)];
|
||||
assert.equal(blocks.length, 2, 'classification and follow-up research must both be checked');
|
||||
let command;
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', data => { raw += data; });
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(raw || '{}');
|
||||
if (req.url.includes('count_tokens')) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{"input_tokens":10}');
|
||||
return;
|
||||
}
|
||||
const finished = (body.messages || []).some(message => Array.isArray(message.content)
|
||||
&& message.content.some(item => item.type === 'tool_result'));
|
||||
const block = finished ? { type: 'text', text: 'DONE' }
|
||||
: { type: 'tool_use', id: 'tool_fixture', name: 'Bash', input: { command } };
|
||||
const message = { id: 'msg_fixture', type: 'message', role: 'assistant', model: body.model,
|
||||
content: [block], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null,
|
||||
usage: { input_tokens: 20, output_tokens: 10 } };
|
||||
if (!body.stream) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
const emit = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
emit('message_start', { message: { ...message, content: [], stop_reason: null } });
|
||||
emit('content_block_start', { index: 0, content_block: finished
|
||||
? { type: 'text', text: '' } : { ...block, input: {} } });
|
||||
emit('content_block_delta', { index: 0, delta: finished
|
||||
? { type: 'text_delta', text: 'DONE' }
|
||||
: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
|
||||
emit('content_block_stop', { index: 0 });
|
||||
emit('message_delta', { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 10 } });
|
||||
emit('message_stop', {});
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
async function check(index, helper, expectRun, baseline = false) {
|
||||
const allowed = [...blocks[index][0].split('--disallowedTools')[0].matchAll(/"([^"]+)"/g)]
|
||||
.map(match => match[1].replaceAll('$research_dir', root))
|
||||
.filter(rule => !baseline || !rule.includes(root));
|
||||
command = `${path.join(root, helper)} smoke 2>&1 | head -40`;
|
||||
const args = ['--bare', '-p', '--permission-mode', 'dontAsk', '--settings', settings,
|
||||
'--allowedTools', ...allowed, '--disallowedTools', 'WebSearch', 'WebFetch', 'Edit', 'Write', 'NotebookEdit',
|
||||
'--output-format', 'stream-json', '--verbose', '--model', 'claude-sonnet-4-6', 'Run the approved helper once.'];
|
||||
const env = { PATH: `${root}${path.delimiter}${process.env.PATH}`, HOME: process.env.HOME,
|
||||
TMPDIR: process.env.TMPDIR, CLAUDE_CONFIG_DIR: path.join(root, 'config'),
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`, ANTHROPIC_AUTH_TOKEN: 'synthetic-local-fixture',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
||||
let output = '', errors = '';
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.argv[2] || 'claude', args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const timeout = setTimeout(() => child.kill('SIGTERM'), 20000);
|
||||
child.stdout.on('data', data => { output += data; });
|
||||
child.stderr.on('data', data => { errors += data; });
|
||||
child.once('error', error => { clearTimeout(timeout); reject(error); });
|
||||
child.once('exit', value => { clearTimeout(timeout); resolve(value); });
|
||||
});
|
||||
assert.equal(code, 0, errors);
|
||||
const events = output.trim().split('\n').map(line => JSON.parse(line));
|
||||
const result = events.findLast(event => event.type === 'result');
|
||||
assert.ok(result, 'Claude must finish the model/tool turn');
|
||||
const ran = events.some(event => Array.isArray(event.message?.content)
|
||||
&& event.message.content.some(item => item.type === 'tool_result'
|
||||
&& JSON.stringify(item.content).includes('RESEARCH_FIXTURE_OK') && !item.is_error));
|
||||
assert.equal(ran, expectRun, `${index}:${helper}:baseline=${baseline}`);
|
||||
assert.equal((result.permission_denials || []).length, expectRun ? 0 : 1);
|
||||
console.log(JSON.stringify({ route: index === 0 ? 'classification' : 'follow-up', helper, baseline, ran }));
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
for (const index of [0, 1]) {
|
||||
await check(index, 'web-search', false, true);
|
||||
await check(index, 'web-search', true);
|
||||
await check(index, 'web-fetch', true);
|
||||
await check(index, 'unrelated-helper', false);
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,142 @@
|
||||
"use strict";
|
||||
/* global process, __dirname, console */
|
||||
if (!process.versions.electron) {
|
||||
require("node:test")("background terminals stop painting without stopping output", {
|
||||
skip: "run with Electron to exercise real terminal visibility and rendering",
|
||||
}, () => {});
|
||||
} else {
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const esbuild = require("esbuild");
|
||||
const temp = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const userData = fs.mkdtempSync(`${temp.getTempFilePath("background-rendering")}-`);
|
||||
app.setPath("userData", userData);
|
||||
app.on("window-all-closed", () => {});
|
||||
let win;
|
||||
const cleanup = (code) => {
|
||||
win?.destroy();
|
||||
fs.rmSync(userData, { recursive: true, force: true });
|
||||
app.exit(code);
|
||||
};
|
||||
const bundle = esbuild.buildSync({
|
||||
stdin: {
|
||||
contents: [
|
||||
'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime";',
|
||||
'export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";',
|
||||
'export {resolveInactiveTerminalPaneStyle} from "./components/terminalPaneVisibility";',
|
||||
].join("\n"),
|
||||
loader: "ts", resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "cjs", platform: "browser", target: "chrome148", write: false,
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
void app.whenReady().then(async () => {
|
||||
win = new BrowserWindow({
|
||||
show: true, width: 1100, height: 740,
|
||||
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
|
||||
});
|
||||
await win.loadURL("data:text/html,<body style='margin:0;background:%23111'><div id='root'></div>");
|
||||
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
|
||||
const result = await win.webContents.executeJavaScript(`(async () => {
|
||||
const assert = require('node:assert/strict');
|
||||
const loaded = {exports:{}};
|
||||
((module,exports)=>{${bundle}})(loaded,loaded.exports);
|
||||
const {createXTermRuntime,DEFAULT_TERMINAL_SETTINGS,resolveInactiveTerminalPaneStyle} = loaded.exports;
|
||||
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const ref = current => ({current});
|
||||
const panes = [];
|
||||
const root = document.getElementById('root');
|
||||
root.style.cssText = 'position:relative;width:1050px;height:660px;overflow:hidden';
|
||||
const visibleStyle = {left:'0px',top:'0px',width:'1050px',height:'660px'};
|
||||
const apply = (pane, visible, layout = visibleStyle) => {
|
||||
const style = visible ? layout : resolveInactiveTerminalPaneStyle(layout,{width:1050,height:660},false,true);
|
||||
pane.el.style.cssText = 'position:absolute;background:#111';
|
||||
Object.assign(pane.el.style,style);
|
||||
pane.el.style.zIndex = visible ? '10' : '0';
|
||||
};
|
||||
for(let index=0;index<5;index++) {
|
||||
const el=document.createElement('div');root.appendChild(el);
|
||||
const pane={el,frames:0};apply(pane,true);
|
||||
const r=createXTermRuntime({
|
||||
container:el,host:{id:'test-'+index,label:'test',hostname:'localhost',protocol:'ssh'},
|
||||
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
|
||||
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
|
||||
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,cursorBlink:false}),
|
||||
terminalBackend:{write:()=>{},resize:()=>{},openExternalAvailable:false},
|
||||
sessionRef:ref('test-'+index),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
|
||||
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(false),
|
||||
onBroadcastInputRef:ref(undefined),sessionId:'test-'+index,statusRef:ref('connected'),
|
||||
commandBufferRef:ref(''),requestSearchFocus:()=>{},
|
||||
});
|
||||
r.fitAddon.fit();r.term.onRender(()=>pane.frames++);
|
||||
pane.r=r;panes.push(pane);
|
||||
await new Promise(resolve=>r.term.write('ready\\r\\n',resolve));
|
||||
}
|
||||
assert.ok(root.querySelector('canvas'),'exercise WebGL, not only a fake renderer');
|
||||
const write=(pane,data)=>new Promise(resolve=>pane.r.term.write(data,resolve));
|
||||
const size=pane=>[pane.r.term.cols,pane.r.term.rows];
|
||||
const beforeSizes=panes.map(size);
|
||||
for(let i=1;i<5;i++)apply(panes[i],false);
|
||||
await wait(200);
|
||||
const before=panes.map(p=>p.frames);
|
||||
for(let tick=0;tick<12;tick++) {
|
||||
await Promise.all(panes.map(p=>write(p,'background-'+tick+'\\r\\n')));
|
||||
await wait(30);
|
||||
}
|
||||
const hiddenFrames=panes.map((p,i)=>p.frames-before[i]);
|
||||
assert.ok(hiddenFrames[0]>0,'visible terminal still paints');
|
||||
assert.deepEqual(hiddenFrames.slice(1),[0,0,0,0],'background terminals must not paint');
|
||||
assert.deepEqual(panes.map(size),beforeSizes,'hiding preserves terminal dimensions');
|
||||
for(const pane of panes) {
|
||||
const b=pane.r.term.buffer.active;
|
||||
assert.equal(b.getLine(b.baseY+b.cursorY-1).translateToString(true),'background-11','hidden output is already parsed');
|
||||
}
|
||||
// Cached right-hand split offsets can exceed a restored window's width.
|
||||
apply(panes[4],false,{...visibleStyle,left:'1600px'});
|
||||
await wait(150);
|
||||
const offsetFrames=panes[4].frames;
|
||||
await write(panes[4],'cached-offset\\r\\n');await wait(100);
|
||||
assert.equal(panes[4].frames,offsetFrames,'cached split offsets must not leave hidden panes painting');
|
||||
assert.ok(panes[4].el.getBoundingClientRect().right<=0,'park the entire cached split pane');
|
||||
assert.deepEqual(size(panes[4]),beforeSizes[4]);
|
||||
// No zero-width fitting during a window resize while tabs are parked.
|
||||
root.style.width='850px';
|
||||
assert.equal(panes[1].el.clientWidth,1050);
|
||||
for(let tick=0;tick<12;tick++) {
|
||||
const pane=panes[1+tick%4];
|
||||
await write(pane,'switch-'+tick+'\\r\\n');
|
||||
const start=pane.frames;apply(pane,true);await wait(100);
|
||||
assert.ok(pane.frames>start,'reveal must repaint');
|
||||
assert.deepEqual(size(pane),beforeSizes[1]);apply(pane,false);await wait(30);
|
||||
}
|
||||
// A full-screen program must keep interpreting cursor operations while hidden.
|
||||
const tui=panes[2];
|
||||
await write(tui,'\\x1b[?1049h\\x1b[2J\\x1b[HOLD');
|
||||
await wait(100);const tuiBefore=tui.frames;
|
||||
await write(tui,'\\x1b[HNEW\\x1b[2;1Hsecond row');await wait(100);
|
||||
assert.equal(tui.frames,tuiBefore);
|
||||
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
|
||||
apply(tui,true,{...visibleStyle,width:'850px'});tui.r.fitAddon.fit();await wait(100);
|
||||
assert.ok(tui.frames>tuiBefore);
|
||||
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
|
||||
await write(tui,'\\x1b[?1049l');
|
||||
// Two visible split panes paint; parked panes continue parsing without painting.
|
||||
for(const pane of panes)apply(pane,false);
|
||||
apply(panes[0],true,{left:'0px',top:'0px',width:'425px',height:'660px'});
|
||||
apply(panes[1],true,{left:'425px',top:'0px',width:'425px',height:'660px'});
|
||||
panes[0].r.fitAddon.fit();panes[1].r.fitAddon.fit();await wait(150);
|
||||
const splitBefore=panes.map(p=>p.frames);
|
||||
await Promise.all(panes.map(p=>write(p,'split-final\\r\\n')));await wait(100);
|
||||
assert.ok(panes[0].frames>splitBefore[0] && panes[1].frames>splitBefore[1]);
|
||||
assert.deepEqual(panes.slice(2).map((p,i)=>p.frames-splitBefore[i+2]),[0,0,0]);
|
||||
const summary={hiddenFrames,rapidSwitches:12,alternateScreen:true,split:true,sizes:beforeSizes};
|
||||
panes.forEach(p=>p.r.dispose());root.replaceChildren();
|
||||
assert.equal(document.querySelectorAll('.xterm').length,0);
|
||||
return summary;
|
||||
})()`);
|
||||
console.log("TERMINAL_BACKGROUND_RENDERING_OK", JSON.stringify(result));
|
||||
cleanup(0);
|
||||
}).catch(error=>{console.error(error);cleanup(1);});
|
||||
}
|
||||
@@ -117,6 +117,33 @@ if (!process.versions.electron) {
|
||||
const write = data => new Promise(resolve => term.write(data, resolve));
|
||||
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
// #3271: inspect the first actual renderer frame for tail -200f and
|
||||
// subsequent coalesced log ticks, before the deferred catch-up can run.
|
||||
let streamingFrames = 0;
|
||||
let uncoloredStreamingFrame = false;
|
||||
const streamingRender = term.onRender(() => {
|
||||
streamingFrames += 1;
|
||||
for (let row = 0; row < term.rows; row += 1) {
|
||||
const line = term.buffer.active.getLine(term.buffer.active.viewportY + row);
|
||||
const column = line?.translateToString(true).indexOf("ERROR") ?? -1;
|
||||
if (column >= 0 && line.getCell(column).getFgColor() !== 0xf87171) {
|
||||
uncoloredStreamingFrame = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
for (const lines of [200, 12, 12, 12]) {
|
||||
const batch = Array.from({ length: lines }, () => "log ERROR").join("\\r\\n") + "\\r\\n";
|
||||
noteTerminalOutputPressureData(term, batch);
|
||||
await write(batch);
|
||||
await waitPaint();
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
}
|
||||
streamingRender.dispose();
|
||||
if (uncoloredStreamingFrame) throw new Error("Streaming output rendered before keyword color");
|
||||
if (streamingFrames < 4) throw new Error("Streaming renderer frames were not observed");
|
||||
await highlighter.whenSettled();
|
||||
term.reset();
|
||||
|
||||
await write("row-1 ERROR\\r\\nrow-2 ERROR\\r\\nrow-3 ERROR\\r\\nrow-4 ERROR");
|
||||
await write("\\x1b[1;");
|
||||
await write(
|
||||
|
||||
+3
-1
@@ -92,6 +92,8 @@ declare global {
|
||||
): Promise<{ transferId: string; totalBytes?: number; error?: string; cancelled?: boolean }>;
|
||||
pauseTransfer?(transferId: string): Promise<{
|
||||
success: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: 'direct' | 'download' | 'upload';
|
||||
downloadCheckpointBytes?: number;
|
||||
@@ -100,7 +102,7 @@ declare global {
|
||||
lifecycleEpoch?: number;
|
||||
reason?: string;
|
||||
}>;
|
||||
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number }>;
|
||||
resumeTransfer?(transferId: string): Promise<{ success: boolean; reason?: string; lifecycleEpoch?: number; superseded?: boolean; supersededBy?: "pause" | "resume" | "cancel" }>;
|
||||
prioritizeTransfer?(transferId: string): Promise<{ success: boolean }>;
|
||||
setGlobalTransferConcurrency?(limit: number): Promise<{ success: boolean; limit: number }>;
|
||||
cleanupTransferArtifacts?(payload: {
|
||||
|
||||
Reference in new issue
Block a user