Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 aed49e8fe8 Merge remote-tracking branch 'origin/main' into cursor/fix-quit-transfer-freeze-c6aa
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:43:19 +00:00
Cursor Agentand陈大猫 7a6a7a2bc3 fix(quit): report a transfer refusal before resuming
A slow resume could miss the 5s dirty-check timeout and let the app
quit anyway. Reply with reason "transfers" first, then resume only the
rows that were transferring or queued before the prompt paused them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-21 08:43:18 +00:00
campaign f143fa16a0 fix(quit): don't freeze the main process when the user keeps unfinished transfers
Quitting with an unfinished transfer pauses it and asks for confirmation in the
renderer. Declining was reported to main as "dirty editors", so
handleBeforeQuit raised a synchronous "Unsaved changes" message box. That box
blocks the whole main process (IPC, every SFTP transfer) until it is dismissed,
names editors that do not exist, and the paused transfer was never resumed.

The renderer now reports the refusal with reason "transfers" and resumes the
tasks it paused for the prompt; main keeps the quit cancelled but skips the
native editor dialog for that case. Real unsaved editors behave as before.
2026-10-21 06:44:40 +00:00
8 changed files with 180 additions and 17 deletions

No files matched your search

@@ -0,0 +1,87 @@
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 { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
for (const accept of [false, true]) {
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
if (!g.window) g.window = {};
g.window.confirm = () => accept;
let renderer: ReactTestRenderer | undefined;
let releaseResume!: () => void;
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
t.after(async () => {
releaseResume();
await act(async () => renderer?.unmount());
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
const calls: string[] = [];
const reports: Array<[boolean, string | undefined]> = [];
let resumeSettled = false;
let query!: () => Promise<void>;
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
tasks: [
{ id: "running", status: "transferring" },
{ id: "queued", status: "queued" },
{ id: "already-paused", status: "paused" },
{ id: "pausing", status: "pausing" },
{ id: "interrupted", status: "interrupted" },
{ id: "child", parentTaskId: "running", status: "transferring" },
],
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
calls.push(`resume:${id}`);
await resumeGate;
resumeSettled = true;
});
t.mock.method(netcattyBridge, "get", () => ({
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
calls.push("report");
reports.push([dirty, reason]);
},
}));
function Probe() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(Probe)); });
const paused = [
"pause:running",
"pause:queued",
"pause:already-paused",
"pause:pausing",
"pause:interrupted",
];
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
query(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
}),
]);
} finally {
clearTimeout(timer);
}
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
if (accept) {
assert.deepEqual(reports, [[false, undefined]]);
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
} else {
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
}
});
}
+23 -2
View File
@@ -356,30 +356,51 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// on a slow quit and then quitting anyway after the timeout is
// exactly the same outcome.
let hasDirty = false;
let reason: "editors" | "transfers" | undefined;
// Captured before pause. Only transferring/queued rows were active
// because of this prompt.
let tasksToResume: { id: string }[] = [];
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (hasDirty) {
reason = "editors";
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
}
if (!hasDirty) {
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
));
if (unfinishedTasks.length > 0) {
const resumable = unfinishedTasks.filter((task) => (
task.status === "transferring" || task.status === "queued"
));
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
if (hasDirty) {
// The user chose to stay. Main must hear that before any resume:
// resume can outlive the 5s dirty-check timeout, and a late reply
// lets the app quit anyway. The synchronous dialog is also skipped
// because this prompt was already answered.
reason = "transfers";
tasksToResume = resumable;
}
}
}
} catch (err) {
console.error('[App] dirty-editors check failed:', err);
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
} catch (err) {
// Reporting itself shouldn't throw, but if the IPC bridge is in a
// bad state we'd rather log than bubble out of the listener and
// disable the quit guard for the rest of the session.
console.error('[App] reportDirtyEditorsResult failed:', err);
}
if (tasksToResume.length > 0) {
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
}
});
return unsub;
}, [enabled, t]);
+4 -1
View File
@@ -63,7 +63,10 @@ function queryDirtyEditors(webContents, timeoutMs, options = {}) {
// consume the listener slot and let the real reply fall through. A
// missing/falsy sender is anomalous and treated as a wrong-window reply.
if (evt?.sender !== webContents) return;
settle(payload?.hasDirty === true);
// "transfers": the user already declined the renderer's unfinished-
// transfer quit prompt. Still truthy, so existing callers keep quit
// cancelled; handleBeforeQuit uses it to skip the editor dialog.
settle(payload?.hasDirty === true ? (payload?.reason === "transfers" ? "transfers" : true) : false);
}
ipcMain.on("app:dirty-editors-result", onResult);
@@ -221,3 +221,15 @@ test("resolves false when no ipcMain can be resolved", async () => {
assert.equal(await queryDirtyEditors(wc, 5000), false);
assert.deepEqual(wc.sent, []);
});
test("carries a declined unfinished-transfer prompt as a truthy \"transfers\" result", async () => {
const ipcMain = makeIpcMain();
const wc = makeWebContents();
const promise = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: true, reason: "transfers" });
assert.equal(await promise, "transfers");
// A clean reply that names a reason is still clean.
const clean = queryDirtyEditors(wc, 5000, { ipcMain });
ipcMain.emit("app:dirty-editors-result", { sender: wc }, { hasDirty: false, reason: "transfers" });
assert.equal(await clean, false);
});
+20 -12
View File
@@ -155,6 +155,7 @@ async function handleBeforeQuit({
setQuitConfirmed,
commitQuit,
cancelPendingUpdateInstall,
dialog: injectedDialog,
}) {
if (quitConfirmed) return { committed: false, skipped: "quit-confirmed" };
if (quitGuardChannelBusy) {
@@ -207,6 +208,7 @@ async function handleBeforeQuit({
queryDirtyEditors(win.webContents, timeoutMs, { ipcMain }).then((hasDirty) => ({
win,
hasDirty: Boolean(hasDirty),
transfersOnly: hasDirty === "transfers",
})),
),
);
@@ -249,18 +251,24 @@ async function handleBeforeQuit({
// App Lock overlay sits above renderer toasts (z-index). When the app is
// already locked, focusing the dirty window does not make the unsaved
// warning visible — surface a native dialog above the lock screen (Codex P2).
try {
const { dialog } = require("electron");
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
// Skip it when every refusal came from the renderer's unfinished-transfer
// prompt: the user already answered, and this synchronous box would block
// the main process (and every running transfer) behind a wrong message.
const dirtyEditorsRefused = dirtyResults.some((result) => result.hasDirty && !result.transfersOnly);
if (dirtyEditorsRefused) {
try {
const dialog = injectedDialog || require("electron").dialog;
dialog.showMessageBoxSync({
type: "warning",
buttons: ["OK"],
defaultId: 0,
message: "Unsaved changes",
detail:
"One or more editors have unsaved changes. If the app is locked, unlock it, then save or discard before quitting.",
});
} catch {
// ignore missing dialog in tests
}
}
if (windowManager?.isQuittingForUpdate?.()) {
+32
View File
@@ -444,3 +444,35 @@ test("handleBeforeQuit focuses only dirty windows when multiple renderers reply"
"focus",
]);
});
test("handleBeforeQuit does not raise the blocking editor dialog when only the transfer quit prompt was declined", async () => {
const run = async (dirtyValue) => {
const calls = [];
const win = { isDestroyed: () => false, webContents: { isDestroyed: () => false, isCrashed: () => false, id: 1 } };
const result = await handleBeforeQuit({
event: { preventDefault() { calls.push("preventDefault"); } },
mainWindows: [win],
queryDirtyEditors: async () => dirtyValue,
appLockController: { setLocked(reason) { calls.push(`lock:${reason}`); } },
windowManager: { showAndFocusMainWindow() { calls.push("showAndFocus"); } },
app: { quit() { calls.push("app.quit"); } },
ipcMain: {},
quitConfirmed: false,
quitGuardChannelBusy: false,
timeoutMs: 10,
setQuitGuardChannelBusy() {},
setQuitConfirmed(value) { calls.push(`quitConfirmed:${value}`); },
dialog: { showMessageBoxSync() { calls.push("dialog"); return 0; } },
});
return { calls, result };
};
const declined = await run("transfers");
assert.equal(declined.result.skipped, "dirty", "the declined prompt still cancels quit");
assert.ok(!declined.calls.includes("dialog"), "no synchronous native dialog after the user already answered");
assert.ok(!declined.calls.includes("app.quit"));
const dirty = await run(true);
assert.equal(dirty.result.skipped, "dirty");
assert.deepEqual(dirty.calls.filter((c) => c === "dialog"), ["dialog"], "real unsaved editors keep the dialog");
});
+1 -1
View File
@@ -1462,7 +1462,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
},
// Renderer reports the dirty-check result back to the main process.
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
reportDirtyEditorsResult: (hasDirty, reason) => ipcRenderer.send("app:dirty-editors-result", { hasDirty, reason }),
// Port Forwarding API
startPortForward: async (options) => {
+1 -1
View File
@@ -298,7 +298,7 @@ declare global {
// Listener is called with no arguments; return value is an unsubscribe function.
onCheckDirtyEditors?(listener: () => void): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean): void;
reportDirtyEditorsResult?(hasDirty: boolean, reason?: "editors" | "transfers"): void;
onLanguageChanged?(cb: (language: string) => void): () => void;