Compare commits

..
Author SHA1 Message Date
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +08:00
陈大猫 4beccc3d61 Merge pull request #2928 from binaricat/cursor/issue-2926-31561097764
fix(terminal): re-run onConnect scripts on manual reconnect
2026-08-12 16:42:39 +08:00
bincxz 33b3beaea0 Merge remote-tracking branch 'origin/main' into codex/pr-2928 2026-08-12 16:14:56 +08:00
bincxz e65af2fd7b fix(terminal): safely rerun connect scripts on manual reconnect 2026-08-12 16:14:43 +08:00
陈大猫 5d63a9476c Merge pull request #2932 from binaricat/codex/fix-2924-dropbear-stats-limit
fix(ssh): keep server stats safe for Dropbear
2026-08-12 15:35:20 +08:00
netcatty-bot 0d92c5d738 fix: address Codex review on PR #2928 2026-08-12 05:26:14 +00:00
netcatty-bot eb3cd06278 fix: address Codex review on PR #2928 2026-08-12 04:42:43 +00:00
netcatty-bot 9beb7d3108 fix(#2926): automated Cursor CLI fix 2026-08-12 04:00:36 +00:00
12 changed files with 834 additions and 35 deletions

No files matched your search

@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+107 -18
View File
@@ -225,9 +225,16 @@ import {
} from "./terminal/terminalRuntimeMount";
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
import { TerminalView } from "./terminal/TerminalView";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
type ConnectAutomationBatch,
} from "./terminal/connectAutomationBatch";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
@@ -369,6 +376,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
@@ -2517,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
const batch = createConnectAutomationBatch();
connectScriptsBatchRef.current = batch;
connectScriptsInFlightRef.current = true;
const batchStillActive = () => (
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
);
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
let timerFired = false;
const timer = window.setTimeout(() => {
timerFired = true;
if (!batchStillActive()) return;
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
@@ -2548,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2556,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2568,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
connectScriptsCompletedIdsRef.current.add(snippet.id);
}
@@ -2581,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2591,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
})
.catch(async (err) => {
if (!batchStillActive()) return;
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
connectScriptsConsumedRef.current = true;
@@ -2606,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runConnectScriptsSequential({
scripts: [pendingScriptToMark],
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2613,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2621,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
});
} catch (pendingErr) {
if (!batchStillActive()) return;
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
if (
!batch.controller.signal.aborted
&& connectScriptsBatchRef.current === batch
) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
});
}, 400);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
if (!timerFired && connectScriptsBatchRef.current === batch) {
batch.controller.abort();
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
} else if (statusRef.current !== "connected") {
void cancelConnectAutomationBatch(batch).catch(() => {});
}
};
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
useEffect(() => {
@@ -3358,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
if (!termRef.current) return;
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
// Claim the retry before awaiting close. A close/cancel/unmount during the
// awaited backend cleanup invalidates this token and stops the continuation.
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
reconnectPreparationTokenRef.current = retryToken;
@@ -3382,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const connectAutomationBatch = connectScriptsBatchRef.current;
if (connectAutomationBatch) {
try {
await cancelConnectAutomationBatch(connectAutomationBatch);
} catch (error) {
finishReconnectPreparation();
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
if (mode === "auto" && retryTokenStillCurrent()) {
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
}
return;
}
if (!retryTokenStillCurrent()) {
finishReconnectPreparation();
return;
}
if (connectScriptsBatchRef.current === connectAutomationBatch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
}
if (mode === "manual") {
clearAutoReconnect();
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
restoreState === "restored-disconnected" ? "restored" : "manual",
)) {
// The prior batch is now stopped, so its callbacks cannot mutate these
// newly allocated guards for the fresh manual connection.
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
connectScriptsInFlightRef.current = false;
}
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
// An automatic reconnect replaces the transport but remains the same user
// session. Stop old automation above, then preserve the existing policy of
// not starting onConnect scripts again on the replacement transport.
connectScriptsConsumedRef.current = true;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
try {
await cleanupSession();
} catch (error) {
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});
@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./restoredSessionGate.ts";
@@ -62,6 +63,88 @@ test("host startup command policy distinguishes restored and automatic reconnect
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
});
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
});
test("manual reconnect resets connect automation before opening a new session", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const cancelBatchIndex = source.indexOf(
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
);
const stopFailureDisconnectedIndex = source.indexOf(
'updateStatus("disconnected")',
stopFailureRetryIndex,
);
const resetConsumedIndex = source.indexOf(
"connectScriptsConsumedRef.current = false",
manualBranchIndex,
);
const resetCompletedIndex = source.indexOf(
"connectScriptsCompletedIdsRef.current = new Set()",
manualBranchIndex,
);
const resetInFlightIndex = source.indexOf(
"connectScriptsInFlightRef.current = false",
manualBranchIndex,
);
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
const autoSuppressIndex = source.indexOf(
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
autoElseIndex,
);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(cancelBatchIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(stopFailureRetryIndex, -1);
assert.notEqual(stopFailureDisconnectedIndex, -1);
assert.notEqual(resetConsumedIndex, -1);
assert.notEqual(resetCompletedIndex, -1);
assert.notEqual(resetInFlightIndex, -1);
assert.notEqual(connectingIndex, -1);
assert.notEqual(autoElseIndex, -1);
assert.notEqual(autoSuppressIndex, -1);
assert.ok(
cancelBatchIndex < manualBranchIndex
&& cancelBatchIndex < resetConsumedIndex
&& cancelBatchIndex < resetCompletedIndex
&& cancelBatchIndex < resetInFlightIndex,
"every reconnect must await onConnect cancellation before manual guards are cleared",
);
assert.ok(
stopFailureRetryIndex < stopFailureDisconnectedIndex
&& stopFailureDisconnectedIndex < manualBranchIndex,
"automatic reconnect must return to disconnected when stopping the old batch fails",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
"automatic reconnect must keep the existing connect-automation consumption decision",
);
assert.ok(
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
"automatic reconnect must not reset connect-automation refs",
);
assert.match(
source.slice(autoElseIndex, connectingIndex),
/connectScriptsConsumedRef\.current = true/,
"automatic reconnect must stop the old batch without queuing it again",
);
});
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
@@ -21,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
export const shouldSuppressHostStartupCommandOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode === "automatic";
/**
* Manual and restored reconnects are a fresh "connect" for automation scripts.
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
*/
export const shouldResetConnectAutomationOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode !== "automatic";
+55 -11
View File
@@ -39,10 +39,15 @@ const pendingScreenSnapshots = new Map();
const scriptLogTokens = new Map();
/** @type {Map<string, Promise<void>>} */
const sessionRunChains = new Map();
/** @type {Map<string, number>} */
const queuedRunGenerations = new Map();
/** @type {Map<string, number>} */
const cancelledQueuedRunGenerations = new Map();
/** @type {Map<string, { abort: (reason?: Error) => void }>} */
const runAbortControls = new Map();
/** @type {Map<string, { connected?: boolean, name?: string, hostname?: string, username?: string }>} */
const rendererSessionMetaById = new Map();
let scriptRuntimeGeneration = 0;
let disposeTerminalDataTap = null;
let disposeWorkerOutputTap = null;
let disposeWorkerSessionClosed = null;
@@ -78,6 +83,9 @@ function enqueueSessionRun(sessionId, task) {
}
function init(deps) {
scriptRuntimeGeneration += 1;
queuedRunGenerations.clear();
cancelledQueuedRunGenerations.clear();
for (const [runId, control] of runAbortControls.entries()) {
const run = runs.get(runId);
if (run && !run.endedAt) {
@@ -593,6 +601,8 @@ async function runScriptOnSession({
async function handleScriptRun(_event, payload = {}) {
const {
runId: requestedRunId,
returnWhenQueued = false,
scriptId,
scriptLabel,
content,
@@ -612,22 +622,51 @@ async function handleScriptRun(_event, payload = {}) {
if (!content || !String(content).trim()) {
throw new Error("Script content is empty");
}
if (requestedRunId !== undefined && targets.length !== 1) {
throw new Error("A caller-provided runId requires exactly one target session");
}
if (returnWhenQueued && targets.length !== 1) {
throw new Error("returnWhenQueued requires exactly one target session");
}
const runIds = [];
const queueRun = (sid) => {
const runId = randomUUID();
const generation = scriptRuntimeGeneration;
const runId = requestedRunId === undefined ? randomUUID() : String(requestedRunId).trim();
if (!runId || runId.length > 128) {
throw new Error("Invalid script run id");
}
if (runs.has(runId) || queuedRunGenerations.has(runId)) {
throw new Error(`Script run id already exists: ${runId}`);
}
runIds.push(runId);
return enqueueSessionRun(sid, () => runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId: sid,
content,
permissionMode,
sessionMeta: payload.sessionMeta,
}));
queuedRunGenerations.set(runId, generation);
return enqueueSessionRun(sid, () => {
if (queuedRunGenerations.get(runId) === generation) {
queuedRunGenerations.delete(runId);
}
if (generation !== scriptRuntimeGeneration) return undefined;
if (cancelledQueuedRunGenerations.get(runId) === generation) {
cancelledQueuedRunGenerations.delete(runId);
return undefined;
}
return runScriptOnSession({
runId,
scriptId,
scriptLabel,
sessionId: sid,
content,
permissionMode,
sessionMeta: payload.sessionMeta,
});
});
};
if (returnWhenQueued) {
void queueRun(targets[0]).catch(() => {});
return { runIds, runId: runIds[0] };
}
if (mode === "sequential") {
for (const sid of targets) {
await queueRun(sid);
@@ -641,7 +680,12 @@ async function handleScriptRun(_event, payload = {}) {
function handleScriptStop(_event, payload = {}) {
const run = runs.get(payload.runId);
if (!run) return { ok: false };
if (!run) {
const generation = queuedRunGenerations.get(payload.runId);
if (generation === undefined) return { ok: false };
cancelledQueuedRunGenerations.set(payload.runId, generation);
return { ok: true };
}
run.aborted = true;
run.paused = false;
run.status = "failed";
+250
View File
@@ -202,6 +202,90 @@ test("same session script runs are serialized through the session mutex", async
assert.deepEqual(writeOrder, ["slow-run", "fast-run"]);
});
test("a caller can cancel its queued script by run id without stopping the active script", async () => {
const handlers = new Map();
const writes = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) {
writes.push(String(payload.data || ""));
},
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) {
handlers.set(channel, handler);
},
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "unrelated-active-run",
scriptId: "manual",
sessionId: "session-queued-cancel",
content: `
await nct.dialog.confirm('hold queue');
await nct.screen.sendLine('active-finished');
`,
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
const queued = await runHandler({}, {
runId: "queued-connect-run",
returnWhenQueued: true,
scriptId: "connect",
sessionId: "session-queued-cancel",
content: "await nct.screen.sendLine('queued-ran');",
permissionMode: "auto",
});
assert.deepEqual(queued, {
runId: "queued-connect-run",
runIds: ["queued-connect-run"],
});
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "queued-connect-run" }),
{ ok: true },
);
assert.deepEqual(
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: activeDialogRequestId,
value: true,
}),
{ ok: true },
);
await activeRun;
await delay(30);
assert.ok(writes.some((data) => data.includes("active-finished")));
assert.ok(!writes.some((data) => data.includes("queued-ran")));
});
test("stopping a script releases the session queue so it can run again", async () => {
const handlers = new Map();
const sentRunUpdates = [];
@@ -358,6 +442,172 @@ test("reinitializing the script bridge drops an accepted old-run sendLine tail",
assert.deepEqual(replacementWrites, []);
});
test("reinitializing the script bridge invalidates accepted queued runs", async () => {
const handlers = new Map();
const oldWrites = [];
const replacementWrites = [];
let activeDialogRequestId;
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { oldWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
activeDialogRequestId = payload.requestId;
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
}),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const activeRun = runHandler({}, {
runId: "active-before-reinit",
scriptId: "active-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.dialog.confirm('hold queue');",
permissionMode: "auto",
});
await waitUntil(() => activeDialogRequestId);
await runHandler({}, {
runId: "queued-before-reinit",
returnWhenQueued: true,
scriptId: "queued-before-reinit",
sessionId: "session-reinit-queue",
content: "await nct.screen.sendLine('stale-queued-write');",
permissionMode: "auto",
});
scriptBridge.init({
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { replacementWrites.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
getMainWindow: () => null,
});
await activeRun;
await delay(60);
assert.deepEqual(oldWrites, []);
assert.deepEqual(replacementWrites, []);
});
test("an old queued run cannot unregister a reused run id after reinitialization", async () => {
const handlers = new Map();
const writes = [];
let oldDialogRequestId;
let replacementDialogRequestId;
const makeMainWindow = (setDialogRequestId) => () => ({
webContents: {
send(channel, payload) {
if (channel === "netcatty:script:dialog-request") {
setDialogRequestId(payload.requestId);
}
if (channel === "netcatty:script:screen-snapshot-request") {
setImmediate(() => {
handlers.get("netcatty:script:screen-snapshot-response")({}, {
requestId: payload.requestId,
snapshot: { rows: 24, cols: 80, currentRow: 0, lines: [] },
});
});
}
},
},
});
const baseDeps = {
sessions: new Map(),
electronModule: {
app: { getVersion: () => "test", getPath: () => process.cwd() },
},
terminalBridge: {
writeToSession(_event, payload) { writes.push(String(payload.data || "")); },
},
terminalWorkerManager: null,
};
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { oldDialogRequestId = id; }),
});
scriptBridge.registerHandlers({
handle(channel, handler) { handlers.set(channel, handler); },
});
const runHandler = handlers.get("netcatty:script:run");
const oldActive = runHandler({}, {
runId: "old-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold old queue');",
permissionMode: "auto",
});
await waitUntil(() => oldDialogRequestId);
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('stale-reused-write');",
permissionMode: "auto",
});
scriptBridge.init({
...baseDeps,
getMainWindow: makeMainWindow((id) => { replacementDialogRequestId = id; }),
});
const replacementActive = runHandler({}, {
runId: "replacement-active-for-reuse",
sessionId: "session-reinit-reuse",
content: "await nct.dialog.confirm('hold replacement queue');",
permissionMode: "auto",
});
await runHandler({}, {
runId: "reused-queued-id",
returnWhenQueued: true,
sessionId: "session-reinit-reuse",
content: "await nct.screen.sendLine('replacement-reused-write');",
permissionMode: "auto",
});
await oldActive;
await waitUntil(() => replacementDialogRequestId);
assert.deepEqual(
await handlers.get("netcatty:script:stop")({}, { runId: "reused-queued-id" }),
{ ok: true },
);
await handlers.get("netcatty:script:dialog-response")({}, {
requestId: replacementDialogRequestId,
value: true,
});
await replacementActive;
await delay(60);
assert.ok(!writes.some((data) => data.includes("stale-reused-write")));
assert.ok(!writes.some((data) => data.includes("replacement-reused-write")));
});
test("late startup snapshots from a stopped script do not seed the next run", async () => {
const handlers = new Map();
const writes = [];
+4
View File
@@ -117,6 +117,10 @@ export interface ScriptDialogRequest {
}
export interface ScriptRunParams {
/** Optional caller-generated id so a queued run can be cancelled before it starts. */
runId?: string;
/** Return after the run enters the backend queue instead of waiting for completion. */
returnWhenQueued?: boolean;
scriptId?: string;
scriptLabel?: string;
content: string;