Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7f54ec3

Browse files
authored
fix(canvas): client races surfaced by review — star order, eviction, build status, home seed (#3931)
1 parent b1892f8 commit 7f54ec3

11 files changed

Lines changed: 385 additions & 37 deletions

packages/core/src/canvas/canvasBuildSchemas.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
22
import {
33
type CanvasBuildLifecycle,
44
type CanvasBuildRecord,
5+
currentHeadBuildFailure,
56
hasActiveCanvasBuild,
67
latestFinishedCanvasBuild,
78
publishedCanvasBuild,
@@ -73,6 +74,32 @@ describe("canvas build lifecycle", () => {
7374
expect(publishedCanvasBuild(value)).toBe(ready);
7475
});
7576

77+
it("surfaces a failed build of the current head even when an older published build is also ready", () => {
78+
// b_old (sv-old) is the pinned/published live build; a newer publish (b_new,
79+
// sv-new = current head) failed. latestFinishedCanvasBuild returns the first
80+
// finished build in array order, which here is the ready b_old — position,
81+
// not version identity — so it would hide the failure. currentHeadBuildFailure
82+
// keys off the current version's own build.
83+
const value = lifecycle([
84+
build("b_old", "ready"),
85+
build("b_new", "failed"),
86+
]);
87+
value.builds[0].sourceVersionId = "sv-old";
88+
value.builds[1].sourceVersionId = "sv-new";
89+
value.publishedBuildId = "b_old";
90+
value.currentVersionId = "sv-new";
91+
92+
expect(latestFinishedCanvasBuild(value)?.id).toBe("b_old");
93+
expect(currentHeadBuildFailure(value)?.id).toBe("b_new");
94+
});
95+
96+
it("returns null when the current head built fine or is still in flight", () => {
97+
const value = lifecycle([build("b1", "building")]);
98+
value.currentVersionId = "sv-1";
99+
100+
expect(currentHeadBuildFailure(value)).toBeNull();
101+
});
102+
76103
it("maps the builds endpoint's snake_case body to the client shape", async () => {
77104
const fetchMock = vi.fn(
78105
async () =>

packages/core/src/canvas/canvasBuildSchemas.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,23 @@ export function latestFinishedCanvasBuild(
7171
) ?? null
7272
);
7373
}
74+
75+
/**
76+
* The failed build of the canvas's CURRENT head, if it failed. This is the
77+
* build whose outcome the author actually cares about — a failed newest
78+
* publish must surface even when an older (e.g. pinned/published) build is the
79+
* first finished row in the list, because `latestFinishedCanvasBuild` picks by
80+
* array position, not version identity.
81+
*/
82+
export function currentHeadBuildFailure(
83+
lifecycle: CanvasBuildLifecycle,
84+
): CanvasBuildRecord | null {
85+
if (!lifecycle.currentVersionId) return null;
86+
return (
87+
lifecycle.builds.find(
88+
(build) =>
89+
build.sourceVersionId === lifecycle.currentVersionId &&
90+
build.buildStatus === "failed",
91+
) ?? null
92+
);
93+
}

packages/core/src/canvas/dashboardsService.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { transform } from "esbuild";
22
import { describe, expect, it, vi } from "vitest";
33
import { DashboardsService } from "./dashboardsService";
4-
import type { ProjectApiClient } from "./projectApiClient";
4+
import { type ProjectApiClient, ProjectApiError } from "./projectApiClient";
55

66
// A canvas as the PostHog canvases API returns it.
77
function apiCanvas(overrides: Record<string, unknown> = {}) {
@@ -172,6 +172,80 @@ describe("DashboardsService.ensureHomeCanvas", () => {
172172
});
173173
});
174174

175+
describe("DashboardsService.ensureHomeCanvas races", () => {
176+
it("reuses the winner's canvas when create loses the is_home uniqueness race (409)", async () => {
177+
let lookups = 0;
178+
const { api } = fakeApi({
179+
// First lookup: none. After the 409, the winner's home canvas exists.
180+
"canvases/?channel=chan-1&is_home=true": () => {
181+
lookups += 1;
182+
return lookups === 1
183+
? []
184+
: [
185+
apiCanvas({
186+
id: "home-winner",
187+
is_home: true,
188+
current_version_id: "v1",
189+
}),
190+
];
191+
},
192+
"canvases/": () => {
193+
throw new ProjectApiError("Failed to create canvas (409)", 409);
194+
},
195+
});
196+
const service = new DashboardsService(api);
197+
198+
const record = await service.ensureHomeCanvas("chan-1");
199+
200+
expect(record.id).toBe("home-winner");
201+
});
202+
203+
it("rethrows a non-409 create failure instead of masking it as a race", async () => {
204+
const { api } = fakeApi({
205+
"canvases/?channel=chan-1&is_home=true": [],
206+
"canvases/": () => {
207+
throw new ProjectApiError("Failed to create canvas (403)", 403);
208+
},
209+
});
210+
const service = new DashboardsService(api);
211+
212+
await expect(service.ensureHomeCanvas("chan-1")).rejects.toMatchObject({
213+
status: 403,
214+
});
215+
});
216+
217+
it("retries the seed publish once on a 409 version conflict", async () => {
218+
const home = apiCanvas({
219+
id: "home-1",
220+
is_home: true,
221+
current_version_id: null,
222+
});
223+
let publishCalls = 0;
224+
const { api } = fakeApi({
225+
"canvases/?channel=chan-1&is_home=true": [home],
226+
"canvases/home-1/publish/": (init?: RequestInit) => {
227+
publishCalls += 1;
228+
if (publishCalls === 1) {
229+
throw new ProjectApiError("Failed to seed home canvas (409)", 409);
230+
}
231+
const body = JSON.parse(String(init?.body));
232+
return { current_version_id: body.expected_current_version_id ?? "v1" };
233+
},
234+
"canvases/home-1/": apiCanvas({
235+
id: "home-1",
236+
is_home: true,
237+
current_version_id: "v-fresh",
238+
}),
239+
});
240+
const service = new DashboardsService(api);
241+
242+
const record = await service.ensureHomeCanvas("chan-1");
243+
244+
expect(publishCalls).toBe(2);
245+
expect(record.id).toBe("home-1");
246+
});
247+
});
248+
175249
describe("DashboardsService.resetHomeCanvas", () => {
176250
it("publishes a fresh default guarded on the current head", async () => {
177251
const home = apiCanvas({

packages/core/src/canvas/dashboardsService.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ import type {
1818
DashboardRecord,
1919
} from "./dashboardSchemas";
2020
import { FREEFORM_TEMPLATE_ID } from "./freeformSchemas";
21-
import { PROJECT_API_CLIENT, type ProjectApiClient } from "./projectApiClient";
21+
import {
22+
apiErrorStatus,
23+
PROJECT_API_CLIENT,
24+
type ProjectApiClient,
25+
} from "./projectApiClient";
2226

2327
// Display name (canvas h1) of a channel's auto-created home canvas.
2428
const HOME_CANVAS_NAME = "Home";
@@ -308,8 +312,11 @@ export class DashboardsService {
308312
templateId: FREEFORM_TEMPLATE_ID,
309313
isHome: true,
310314
});
311-
} catch {
312-
// Lost the uniqueness race — another client created it; reuse theirs.
315+
} catch (error) {
316+
// Only the is_home uniqueness race (409) means another client created
317+
// it; reuse theirs. Any other failure (auth, capacity, network) must
318+
// surface, not be masked as a race.
319+
if (apiErrorStatus(error) !== 409) throw error;
313320
record = await this.findHomeCanvas(channelId);
314321
if (!record) throw new Error("Failed to create home canvas");
315322
}
@@ -360,19 +367,30 @@ export class DashboardsService {
360367
network: { origins: [] },
361368
},
362369
};
363-
await this.api.json<unknown>(
364-
`canvases/${encodeURIComponent(record.id)}/publish/`,
365-
"seed home canvas",
366-
{
367-
method: "POST",
368-
headers: { "Content-Type": "application/json" },
369-
body: JSON.stringify({
370-
project,
371-
prompt: "Default home board",
372-
expected_current_version_id: record.currentVersionId ?? null,
373-
}),
374-
},
375-
);
370+
const publish = (expectedVersionId: string | null) =>
371+
this.api.json<unknown>(
372+
`canvases/${encodeURIComponent(record.id)}/publish/`,
373+
"seed home canvas",
374+
{
375+
method: "POST",
376+
headers: { "Content-Type": "application/json" },
377+
body: JSON.stringify({
378+
project,
379+
prompt: "Default home board",
380+
expected_current_version_id: expectedVersionId,
381+
}),
382+
},
383+
);
384+
try {
385+
await publish(record.currentVersionId ?? null);
386+
} catch (error) {
387+
// A concurrent seed can win the guarded publish between our read and
388+
// POST. On the 409 version conflict, re-read the head and retry once
389+
// against the fresh version id rather than failing the channel open.
390+
if (apiErrorStatus(error) !== 409) throw error;
391+
const conflicted = await this.get(record.id);
392+
await publish(conflicted?.currentVersionId ?? null);
393+
}
376394
const fresh = await this.get(record.id);
377395
return fresh ?? record;
378396
}

packages/core/src/canvas/projectApiClient.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ export const PROJECT_API_CLIENT = Symbol.for(
88

99
const MAX_PAGES = 50;
1010

11+
/** An API call that failed with an HTTP status (so callers can branch on it). */
12+
export class ProjectApiError extends Error {
13+
constructor(
14+
message: string,
15+
public readonly status: number,
16+
) {
17+
super(message);
18+
this.name = "ProjectApiError";
19+
}
20+
}
21+
22+
/** The status code of a ProjectApiError, or null for a non-API error. */
23+
export function apiErrorStatus(error: unknown): number | null {
24+
return error instanceof ProjectApiError ? error.status : null;
25+
}
26+
1127
/**
1228
* Thin shared client for the current PostHog project's REST API. Resolves the
1329
* project + auth, then forwards to authenticated fetch. Owners of typed
@@ -36,7 +52,11 @@ export class ProjectApiClient {
3652
init?: RequestInit,
3753
): Promise<T> {
3854
const res = await this.fetch(path, init);
39-
if (!res.ok) throw new Error(`Failed to ${errorLabel} (${res.status})`);
55+
if (!res.ok)
56+
throw new ProjectApiError(
57+
`Failed to ${errorLabel} (${res.status})`,
58+
res.status,
59+
);
4060
return (await res.json()) as T;
4161
}
4262

packages/ui/src/features/canvas/freeform/CanvasBuildStatus.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
WarningCircleIcon,
77
XIcon,
88
} from "@phosphor-icons/react";
9-
import { latestFinishedCanvasBuild } from "@posthog/core/canvas/canvasBuildSchemas";
9+
import {
10+
currentHeadBuildFailure,
11+
latestFinishedCanvasBuild,
12+
} from "@posthog/core/canvas/canvasBuildSchemas";
1013
import { useHostTRPC } from "@posthog/host-router/react";
1114
import { Button } from "@posthog/quill";
1215
import type { CanvasDiagnostic } from "@posthog/shared";
@@ -114,7 +117,11 @@ export function CanvasBuildStatus({
114117
);
115118
}
116119

117-
const latest = latestFinishedCanvasBuild(lifecycle);
120+
// Surface a failed build of the CURRENT head even when an older (pinned /
121+
// published) finished build appears first in the list — array position must
122+
// not hide a failed newest publish.
123+
const failedHead = currentHeadBuildFailure(lifecycle);
124+
const latest = failedHead ?? latestFinishedCanvasBuild(lifecycle);
118125
if (!latest) return null;
119126

120127
if (latest.buildStatus === "failed") {

packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
WarningIcon,
1010
} from "@phosphor-icons/react";
1111
import {
12+
currentHeadBuildFailure,
1213
hasActiveCanvasBuild,
1314
latestFinishedCanvasBuild,
1415
publishedCanvasBuild,
@@ -89,6 +90,10 @@ interface PinnedArtifact {
8990
url: string;
9091
/** Epoch ms the pinned URL was minted (the builds fetch that produced it). */
9192
mintedAt: number;
93+
/** The refresh nonce the pin was adopted under, so a remount also re-stamps
94+
* the pin's mint time (otherwise the expiry timer would keep firing on a URL
95+
* that's already been recovered). */
96+
refreshKey: number;
9297
}
9398

9499
// A freeform (React-in-iframe) canvas. The rendered output is, in priority
@@ -111,6 +116,16 @@ export function FreeformCanvasView({
111116
const setBrowseVersion = useFreeformChatStore((s) => s.setBrowseVersion);
112117
const setRuntimeError = useFreeformChatStore((s) => s.setRuntimeError);
113118

119+
// Protect this thread from LRU eviction while the view is open — a burst of
120+
// background patches must never drop the canvas the user is looking at.
121+
useEffect(() => {
122+
const store = useFreeformChatStore.getState();
123+
store.setThreadMounted(threadId, true);
124+
return () => {
125+
useFreeformChatStore.getState().setThreadMounted(threadId, false);
126+
};
127+
}, [threadId]);
128+
114129
// Right-hand panel state (persisted minimize + width). `startedTaskId` is a
115130
// local bridge so the composer floats to the side immediately on submit,
116131
// before the canvas record's polled generationTaskId catches up.
@@ -237,25 +252,28 @@ export function FreeformCanvasView({
237252
// Pin the artifact to one signed URL per build: every lifecycle refetch mints
238253
// a fresh URL for the same artifact, and adopting each one would reload the
239254
// iframe on every 2s poll while a build runs. Adopt only when the published
240-
// build itself changes — or when a refresh was explicitly requested because
241-
// the pinned URL expired. Adjusted during render (not an effect) so the swap
255+
// build itself changes. Adjusted during render (not an effect) so the swap
242256
// can't flash a stale frame.
243257
const [pinnedArtifact, setPinnedArtifact] = useState<PinnedArtifact | null>(
244258
null,
245259
);
246-
const wantFreshArtifactUrlRef = useRef(false);
260+
// A nonce that, when bumped, remounts the artifact frame so it revalidates
261+
// against the live token endpoint (ETag/304 makes this cheap) — the recovery
262+
// path when the pinned URL expired. Remounting, not URL-string compare, is
263+
// what guarantees a wedged iframe actually retries: the token endpoint is the
264+
// authority, and a new URL for the same bucket would be byte-identical.
265+
const [artifactRefreshKey, setArtifactRefreshKey] = useState(0);
247266
if (publishedBuild?.artifactUrl) {
248-
const shouldAdopt =
267+
const adoptFresh =
249268
!pinnedArtifact ||
250269
pinnedArtifact.buildId !== publishedBuild.id ||
251-
(wantFreshArtifactUrlRef.current &&
252-
pinnedArtifact.url !== publishedBuild.artifactUrl);
253-
if (shouldAdopt) {
254-
wantFreshArtifactUrlRef.current = false;
270+
pinnedArtifact.refreshKey !== artifactRefreshKey;
271+
if (adoptFresh) {
255272
setPinnedArtifact({
256273
buildId: publishedBuild.id,
257274
url: publishedBuild.artifactUrl,
258275
mintedAt: buildsUpdatedAt || Date.now(),
276+
refreshKey: artifactRefreshKey,
259277
});
260278
}
261279
} else if (lifecycle && pinnedArtifact) {
@@ -280,10 +298,15 @@ export function FreeformCanvasView({
280298
if (Date.now() - renderedArtifact.mintedAt < ARTIFACT_URL_FRESH_MS) {
281299
return;
282300
}
283-
wantFreshArtifactUrlRef.current = true;
284-
void queryClient.invalidateQueries({
285-
queryKey: trpc.dashboards.builds.queryKey({ id: dashboardId }),
286-
});
301+
// Refetch mints the current bucket's URL (re-checking the token server-
302+
// side even when the browser would reframe from cache), then remount the
303+
// frame so it revalidates against those endpoints. The remount, not a URL
304+
// string change, is what un-wedges a frame whose module fetches hung.
305+
void queryClient
306+
.invalidateQueries({
307+
queryKey: trpc.dashboards.builds.queryKey({ id: dashboardId }),
308+
})
309+
.then(() => setArtifactRefreshKey((k) => k + 1));
287310
}, ARTIFACT_READY_GRACE_MS);
288311
return () => clearTimeout(timer);
289312
}, [renderedArtifact, dashboardId, queryClient, trpc]);
@@ -480,6 +503,7 @@ export function FreeformCanvasView({
480503
!!lifecycle &&
481504
lifecycle.builds.length > 0 &&
482505
(hasActiveCanvasBuild(lifecycle) ||
506+
!!currentHeadBuildFailure(lifecycle) ||
483507
latestFinishedCanvasBuild(lifecycle)?.buildStatus === "failed");
484508
const showToolbar = interactive || hasBuildSignal;
485509

@@ -707,6 +731,7 @@ export function FreeformCanvasView({
707731
) : pinnedArtifact ? (
708732
<Box className="h-full w-full">
709733
<BuiltCanvas
734+
key={`${pinnedArtifact.buildId}:${artifactRefreshKey}`}
710735
artifactUrl={pinnedArtifact.url}
711736
onDataRequest={onDataRequest}
712737
onError={onError}

0 commit comments

Comments
 (0)