fix(desktop): retire lifecycle continuations when controls are canceled

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-07 11:11:16 -04:00
parent ee6395b94e
commit 651cb1ba26
2 changed files with 144 additions and 1 deletions

View File

@@ -261,3 +261,125 @@ test("terminal receiver failure is a scope-owned notification, not pre-shell lay
dom.window.close();
}
});
for (const interruption of ["cancel", "account", "community", "unmount"]) {
test(`mounted ${interruption} retires a pending Stop and never starts on late confirmation`, async () => {
const dom = new JSDOM("<div id='root'></div>", {
url: "https://desktop.test",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
});
const { createRoot } = await import("react-dom/client");
const query = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
query.setQueryData(
["relay-agents"],
[{ pubkey: "agent", name: "Agent", ownerPubkey: "owner" }],
);
const original = {
fetch: relayClient.fetchEvents,
publish: relayClient.publishEvent,
};
const prepared = [];
let confirmStop;
window.__TAURI_INTERNALS__ = {
invoke: async (command, args) => {
if (command === "observe_desktop_placement") return;
if (command === "read_desktop_placement")
return ["source", "selection"];
if (command.startsWith("prepare_desktop_")) {
const request = {
id: `request-${prepared.length}`,
kind: command.endsWith("_stop") ? 50180 : 50182,
...args,
};
prepared.push(request);
return request;
}
if (command === "read_desktop_lifecycle_results") return "running";
if (command === "read_desktop_stop_results")
return new Promise((resolve) => {
confirmStop = resolve;
});
throw Error(command);
},
};
relayClient.fetchEvents = async () => [];
relayClient.publishEvent = async (_event, _timeout, _failure, check) =>
check();
const root = createRoot(document.getElementById("root"));
const scope = { owner: "owner", community: "wss://one.example" };
const render = (nextScope) =>
root.render(
React.createElement(
QueryClientProvider,
{ client: query },
React.createElement(DesktopLifecycleControl, {
scope: nextScope,
desktops: [
{ id: "source", name: "Source" },
{ id: "target", name: "Target" },
],
}),
),
);
const click = async (text) =>
React.act(async () =>
[...document.querySelectorAll("button")]
.find((b) => b.textContent === text)
.click(),
);
const select = async (label, value) =>
React.act(async () => {
const element = document.querySelector(`select[aria-label="${label}"]`);
element.value = value;
element.dispatchEvent(
new dom.window.Event("change", { bubbles: true }),
);
});
try {
await React.act(async () => render(scope));
await select("Agent to place", "agent");
await select("Destination Desktop", "target");
await click("Move to destination");
assert.equal(typeof confirmStop, "function");
if (interruption === "cancel") await click("Cancel waiting");
else if (interruption === "unmount")
await React.act(async () => root.unmount());
else
await React.act(async () =>
render({
...scope,
[interruption === "account" ? "owner" : "community"]: "changed",
}),
);
await React.act(async () => confirmStop("stopped"));
assert.equal(prepared.filter((r) => r.action === "start").length, 0);
assert.doesNotMatch(
document.body.textContent,
/Retry same request|Source Stop confirmed/,
);
if (interruption === "cancel")
assert.match(
document.body.textContent,
/Dispatched operations may still finish/,
);
if (interruption === "account" || interruption === "community")
assert.equal(
document.querySelector('select[aria-label="Agent to place"]').value,
"",
);
} finally {
await React.act(async () => root.unmount());
query.clear();
relayClient.fetchEvents = original.fetch;
relayClient.publishEvent = original.publish;
dom.window.close();
}
});
}

View File

@@ -82,11 +82,16 @@ export function DesktopLifecycleControl({
const generation = useRef(0);
useEffect(() => {
active.current = true;
setAgent("");
setDestination("");
setRequest(null);
setStatus("");
setBusy(false);
return () => {
active.current = false;
generation.current++;
};
}, []);
}, [scope.owner, scope.community]);
const run = async (action: "start" | "restart" | "move" | "retry") => {
const token = ++generation.current;
const valid = () => active.current && generation.current === token;
@@ -215,6 +220,22 @@ export function DesktopLifecycleControl({
>
Move to destination
</Button>
{busy && (
<Button
size="sm"
variant="ghost"
onClick={() => {
generation.current++;
setBusy(false);
setRequest(null);
setStatus(
"Stopped waiting. Dispatched operations may still finish; no later launch will be requested by this control.",
);
}}
>
Cancel waiting
</Button>
)}
{request && (
<Button
size="sm"