Skip to content

Commit 02b165f

Browse files
committed
fix: recover failed editor frame handoffs
1 parent ebb6b4b commit 02b165f

4 files changed

Lines changed: 171 additions & 5 deletions

File tree

apps/desktop/src/routes/editor/Editor.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,14 @@ function Inner(props: {
412412
onMount(() => {
413413
const blockPreparingKeys = (event: KeyboardEvent) => {
414414
if (editorReady()) return;
415+
if (
416+
preparingSession?.handoffFailed() &&
417+
event.target instanceof Element &&
418+
event.target.closest("[data-editor-handoff-error]")
419+
) {
420+
event.stopImmediatePropagation();
421+
return;
422+
}
415423
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "w")
416424
return;
417425
if (event.altKey && event.key === "F4") return;
@@ -877,12 +885,45 @@ function Inner(props: {
877885
>
878886
<div
879887
class="relative flex flex-col flex-1 min-h-0"
880-
aria-busy={!editorReady()}
888+
aria-busy={!editorReady() && !preparingSession?.handoffFailed()}
881889
>
882890
<Header
883891
registerTitleSave={registerEditorSave}
884892
disabled={!editorReady()}
885893
/>
894+
<Show when={preparingSession?.handoffFailed()}>
895+
<div class="absolute inset-0 top-13 max-[900px]:top-[72px] z-30 flex items-center justify-center p-6">
896+
<div
897+
data-editor-handoff-error
898+
role="alertdialog"
899+
aria-modal="true"
900+
aria-labelledby="editor-handoff-error-title"
901+
class="max-w-sm rounded-xl border border-ed-line bg-ed-card p-6 text-center shadow-ed-card"
902+
>
903+
<h2
904+
id="editor-handoff-error-title"
905+
class="text-sm font-medium text-ed-text-1"
906+
>
907+
Couldn’t open the editor
908+
</h2>
909+
<p class="mt-2 text-xs text-ed-text-2">
910+
Try again to finish opening your recording.
911+
</p>
912+
<button
913+
type="button"
914+
class="mt-4 rounded-lg bg-ed-accent px-4 py-2 text-xs font-medium text-white"
915+
ref={(button) =>
916+
queueMicrotask(() => {
917+
if (button.isConnected) button.focus();
918+
})
919+
}
920+
onClick={() => void preparingSession?.retryHandoff()}
921+
>
922+
Try again
923+
</button>
924+
</div>
925+
</div>
926+
</Show>
886927
<div
887928
inert={!editorReady()}
888929
class="flex overflow-y-hidden flex-col flex-1 gap-2 w-full min-h-0 leading-5 transition-opacity duration-300 ease-out motion-reduce:transition-none"

apps/desktop/src/routes/editor/EditorErrorScreen.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ export function EditorErrorScreen(props: {
195195
<p class="text-sm text-gray-11">{props.error}</p>
196196
</div>
197197

198+
<Show when={!needsRecovery()}>
199+
<Button
200+
onClick={() => window.location.reload()}
201+
variant="primary"
202+
class="w-full"
203+
>
204+
<IconRefreshCw class="size-4 mr-2" />
205+
Try again
206+
</Button>
207+
</Show>
208+
198209
<Show when={needsRecovery()}>
199210
<div class="bg-gray-2 border border-gray-4 rounded-xl p-4 space-y-4">
200211
<div class="space-y-2">
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createRoot } from "solid-js";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import type { FrameData } from "~/utils/socket";
4+
import { createPreparingEditorSession } from "./preparing-editor-context";
5+
6+
const { commitFrame, stopFrame } = vi.hoisted(() => ({
7+
commitFrame: vi.fn<() => Promise<boolean>>(),
8+
stopFrame: vi.fn(async () => {}),
9+
}));
10+
11+
vi.mock("~/utils/tauri", () => ({
12+
commands: {
13+
commitEditorPreparingFrame: commitFrame,
14+
stopPreparingEditorFrame: stopFrame,
15+
},
16+
events: {},
17+
}));
18+
19+
const frame: FrameData = {
20+
width: 1920,
21+
height: 1080,
22+
renderedFrame: { frameNumber: 0, targetTimeNs: 0n },
23+
};
24+
const bounds = { width: 640, height: 360 };
25+
const cleanups: Array<() => void> = [];
26+
27+
function sessionWithCandidate(retry: () => Promise<unknown>) {
28+
return createRoot((dispose) => {
29+
cleanups.push(dispose);
30+
const session = createPreparingEditorSession();
31+
session.beginHandoff(30, 10, {
32+
instanceId: "first",
33+
fps: 30,
34+
progressive: true,
35+
retry,
36+
requestFrame() {},
37+
});
38+
return { session, dispose };
39+
});
40+
}
41+
42+
afterEach(() => {
43+
for (const dispose of cleanups.splice(0)) dispose();
44+
vi.restoreAllMocks();
45+
vi.clearAllMocks();
46+
});
47+
48+
describe("preparing editor handoff recovery", () => {
49+
it("surfaces a commit failure and enables editing only after retry is accepted", async () => {
50+
vi.spyOn(console, "error").mockImplementation(() => {});
51+
commitFrame.mockRejectedValueOnce(new Error("connection lost"));
52+
commitFrame.mockResolvedValueOnce(true);
53+
const retry = vi.fn(async () => {});
54+
const { session } = sessionWithCandidate(retry);
55+
session.acknowledgeOrdinaryFrame(frame, bounds);
56+
await vi.waitFor(() => expect(session.handoffFailed()).toBe(true));
57+
expect(session.ordinaryReady()).toBe(false);
58+
await session.retryHandoff();
59+
expect(retry).toHaveBeenCalledOnce();
60+
expect(session.handoffFailed()).toBe(false);
61+
expect(session.ordinaryReady()).toBe(false);
62+
session.acknowledgeOrdinaryFrame(frame, bounds);
63+
await vi.waitFor(() => expect(session.ordinaryReady()).toBe(true));
64+
expect(session.handoffFailed()).toBe(false);
65+
});
66+
67+
it("keeps recovery available when replacing the candidate fails", async () => {
68+
vi.spyOn(console, "error").mockImplementation(() => {});
69+
commitFrame.mockRejectedValueOnce(
70+
"Preparing handoff candidate was superseded",
71+
);
72+
const retry = vi.fn(async () => {
73+
throw new Error("replacement failed");
74+
});
75+
const { session } = sessionWithCandidate(retry);
76+
session.acknowledgeOrdinaryFrame(frame, bounds);
77+
await vi.waitFor(() => expect(session.handoffFailed()).toBe(true));
78+
expect(retry).toHaveBeenCalledOnce();
79+
await session.retryHandoff();
80+
expect(retry).toHaveBeenCalledTimes(2);
81+
expect(session.handoffFailed()).toBe(true);
82+
expect(session.ordinaryReady()).toBe(false);
83+
});
84+
85+
it("ignores a rejected handoff after the editor has closed", async () => {
86+
let reject!: (error: Error) => void;
87+
commitFrame.mockImplementationOnce(
88+
() =>
89+
new Promise<boolean>((_, fail) => {
90+
reject = fail;
91+
}),
92+
);
93+
const { session, dispose } = sessionWithCandidate(async () => {});
94+
session.acknowledgeOrdinaryFrame(frame, bounds);
95+
dispose();
96+
reject(new Error("late failure"));
97+
await new Promise<void>((resolve) => queueMicrotask(resolve));
98+
expect(session.handoffFailed()).toBe(false);
99+
expect(session.ordinaryReady()).toBe(false);
100+
});
101+
});

apps/desktop/src/routes/editor/preparing-editor-context.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { attachPreparingFrameTransport } from "./preparing-frame-transport";
2222
import { createPreparingHandoff } from "./preparing-handoff";
2323
import type { createPreparingPlaybackHandoff } from "./preparing-playback-handoff";
2424

25-
function createPreparingEditorSession() {
25+
export function createPreparingEditorSession() {
2626
const model = createPreparingEditorModel();
2727
const handoff = createPreparingHandoff();
2828
const owner = getOwner();
@@ -55,6 +55,16 @@ function createPreparingEditorSession() {
5555
}>();
5656
const [retained, setRetained] = createSignal(false);
5757
const [ordinaryReady, setOrdinaryReady] = createSignal(false);
58+
const [handoffFailed, setHandoffFailed] = createSignal(false);
59+
const retryHandoff = async () => {
60+
setHandoffFailed(false);
61+
try {
62+
await retryCandidate();
63+
} catch (error) {
64+
if (alive && !ordinaryReady()) setHandoffFailed(true);
65+
console.error("Failed to replace preparing editor:", error);
66+
}
67+
};
5868
const lease = createPreparingFrameLease({
5969
start: async (epoch) => {
6070
const url = await commands.createPreparingEditorFrame(epoch);
@@ -144,13 +154,16 @@ function createPreparingEditorSession() {
144154
canvases,
145155
retained,
146156
ordinaryReady,
157+
handoffFailed,
158+
retryHandoff,
147159
acceptSnapshot: subscription.accept,
148160
beginHandoff(
149161
fps: number,
150162
recordingDuration: number,
151163
value: NonNullable<typeof candidate>,
152164
) {
153165
candidate = value;
166+
setHandoffFailed(false);
154167
const target = handoff.begin(
155168
model.playback(),
156169
fps,
@@ -222,16 +235,16 @@ function createPreparingEditorSession() {
222235
return;
223236
}
224237
playbackHandoff?.acknowledge(frameNumber, current.progressive);
238+
setHandoffFailed(false);
225239
setOrdinaryReady(true);
226240
lease.close();
227241
})
228242
.catch((error: unknown) => {
229243
if (!alive || candidate !== current) return;
230244
if (String(error) === "Preparing handoff candidate was superseded") {
231-
void retryCandidate()?.catch((cause: unknown) =>
232-
console.error("Failed to replace preparing editor:", cause),
233-
);
245+
void retryHandoff();
234246
} else {
247+
setHandoffFailed(true);
235248
console.error("Failed to accept preparing editor frame:", error);
236249
}
237250
});

0 commit comments

Comments
 (0)