mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): harden mounted terminal lifecycle
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
parent
7d56109ecb
commit
e01df6138c
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
@@ -10,6 +10,9 @@ const callbacks = new Map();
|
||||
const calls = [];
|
||||
let nextCallback = 1;
|
||||
let channel;
|
||||
let resizeCallback;
|
||||
let canvasWidth = 840;
|
||||
let attachResolver = null;
|
||||
|
||||
before(async () => {
|
||||
Object.assign(globalThis, {
|
||||
@@ -29,6 +32,9 @@ before(async () => {
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
@@ -44,9 +50,9 @@ before(async () => {
|
||||
bottom: 408,
|
||||
height: 408,
|
||||
left: 0,
|
||||
right: 840,
|
||||
right: canvasWidth,
|
||||
top: 0,
|
||||
width: 840,
|
||||
width: canvasWidth,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
@@ -66,10 +72,22 @@ before(async () => {
|
||||
calls.push({ command, args });
|
||||
if (command === "terminal_attach") {
|
||||
channel = args.onFrame;
|
||||
return Promise.resolve({
|
||||
const response = {
|
||||
sessionId: "session-1",
|
||||
subscriptionId: "subscription-1",
|
||||
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||
};
|
||||
return attachResolver
|
||||
? new Promise((resolve) => {
|
||||
attachResolver = () => resolve(response);
|
||||
})
|
||||
: Promise.resolve(response);
|
||||
}
|
||||
if (command === "terminal_resize") {
|
||||
return Promise.resolve({
|
||||
columns: args.columns,
|
||||
generation: args.columns,
|
||||
screenLines: args.rows,
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
@@ -86,6 +104,11 @@ before(async () => {
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
afterEach(() => {
|
||||
calls.length = 0;
|
||||
canvasWidth = 840;
|
||||
attachResolver = null;
|
||||
});
|
||||
|
||||
function emit(message, index = 0) {
|
||||
const id = Number(channel.toJSON().slice("__CHANNEL__:".length));
|
||||
@@ -93,26 +116,30 @@ function emit(message, index = 0) {
|
||||
}
|
||||
|
||||
test("mounted bootstrap passes GUI context and ACKs only after consuming a frame", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { StrictMode, createElement } = await import("react");
|
||||
const { act, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
|
||||
render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
StrictMode,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: "channel-1",
|
||||
channelName: "general",
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: "channel-1",
|
||||
channelName: "general",
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: "thread-1",
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -132,20 +159,21 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
const frameMessage = {
|
||||
type: "frame",
|
||||
payload: {
|
||||
bracketedPaste: true,
|
||||
cursor: { column: 0, line: 0, visible: true },
|
||||
focusReporting: true,
|
||||
full: true,
|
||||
rows: [],
|
||||
sequence: 7,
|
||||
subscriptionId: "subscription-1",
|
||||
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
emit({
|
||||
type: "frame",
|
||||
payload: {
|
||||
bracketedPaste: true,
|
||||
cursor: { column: 0, line: 0, visible: true },
|
||||
focusReporting: true,
|
||||
full: true,
|
||||
rows: [],
|
||||
sequence: 7,
|
||||
subscriptionId: "subscription-1",
|
||||
viewport: { columns: 100, generation: 0, screenLines: 24 },
|
||||
},
|
||||
});
|
||||
emit(frameMessage);
|
||||
});
|
||||
await waitFor(() =>
|
||||
assert.ok(calls.some(({ command }) => command === "terminal_ack")),
|
||||
@@ -158,4 +186,58 @@ test("mounted bootstrap passes GUI context and ACKs only after consuming a frame
|
||||
subscriptionId: "subscription-1",
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
emit(frameMessage, 1);
|
||||
});
|
||||
assert.equal(
|
||||
calls.filter(({ command }) => command === "terminal_ack").length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("resize during attach declares only the newest viewport ready", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { act, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
|
||||
attachResolver = () => {};
|
||||
const view = render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: "channel-1",
|
||||
channelName: "general",
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() => assert.equal(typeof attachResolver, "function"));
|
||||
|
||||
canvasWidth = 1_008;
|
||||
act(() => resizeCallback());
|
||||
await act(async () => attachResolver());
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
calls.some(({ command }) => command === "terminal_viewport_ready"),
|
||||
),
|
||||
);
|
||||
|
||||
const readyCalls = calls.filter(
|
||||
({ command }) => command === "terminal_viewport_ready",
|
||||
);
|
||||
assert.equal(readyCalls.at(-1).args.viewport.columns, 120);
|
||||
assert.equal(
|
||||
readyCalls.every(({ args }) => args.viewport.columns === 120),
|
||||
true,
|
||||
);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export function TerminalBootstrap({
|
||||
const [sessions, setSessions] = React.useState<Session[]>([]);
|
||||
const [activeKey, setActiveKey] = React.useState<string | null>(null);
|
||||
const [available, setAvailable] = React.useState(() => isTauri());
|
||||
const acknowledgedFramesRef = React.useRef(new Set<string>());
|
||||
const acknowledgedSequenceRef = React.useRef(new Map<string, number>());
|
||||
const sessionsRef = React.useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
@@ -147,14 +147,17 @@ export function TerminalBootstrap({
|
||||
update((session) => ({ ...session, connection }));
|
||||
if (sizeRef.current !== size) {
|
||||
const currentSize = sizeRef.current;
|
||||
return connection
|
||||
.resize(
|
||||
currentSize.columns,
|
||||
currentSize.rows,
|
||||
currentSize.pixelWidth,
|
||||
currentSize.pixelHeight,
|
||||
)
|
||||
.then((viewport) => connection.viewportReady(viewport));
|
||||
resizeChainRef.current = resizeChainRef.current
|
||||
.then(async () => {
|
||||
const viewport = await connection.resize(
|
||||
currentSize.columns,
|
||||
currentSize.rows,
|
||||
currentSize.pixelWidth,
|
||||
currentSize.pixelHeight,
|
||||
);
|
||||
await connection.viewportReady(viewport);
|
||||
})
|
||||
.catch(fail);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -167,15 +170,15 @@ export function TerminalBootstrap({
|
||||
if (available && context && sessions.length === 0) createSession();
|
||||
}, [available, context, createSession, sessions.length]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
for (const session of sessionsRef.current) {
|
||||
void session.connection?.detach().catch(report);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const active = sessions.find((session) => session.key === activeKey) ?? null;
|
||||
const send = (operation: Promise<void> | undefined) => operation?.catch(fail);
|
||||
@@ -232,11 +235,20 @@ export function TerminalBootstrap({
|
||||
(session) => session.delivery?.frame === frame,
|
||||
)?.delivery;
|
||||
if (!delivery) return;
|
||||
const deliveryKey = `${delivery.frame.subscriptionId}:${delivery.frame.sequence}`;
|
||||
if (acknowledgedFramesRef.current.has(deliveryKey)) return;
|
||||
acknowledgedFramesRef.current.add(deliveryKey);
|
||||
const { sequence, subscriptionId } = delivery.frame;
|
||||
const lastAcknowledged =
|
||||
acknowledgedSequenceRef.current.get(subscriptionId) ?? -1;
|
||||
if (sequence <= lastAcknowledged) return;
|
||||
acknowledgedSequenceRef.current.set(subscriptionId, sequence);
|
||||
delivery.acknowledge().catch((error) => {
|
||||
acknowledgedFramesRef.current.delete(deliveryKey);
|
||||
if (
|
||||
acknowledgedSequenceRef.current.get(subscriptionId) === sequence
|
||||
) {
|
||||
acknowledgedSequenceRef.current.set(
|
||||
subscriptionId,
|
||||
lastAcknowledged,
|
||||
);
|
||||
}
|
||||
fail(error);
|
||||
});
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user