fix(canvas): surface the real cause of a canvas runtime startup failure (#3903)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-24 19:28:50 +08:00
committed by GitHub
parent 6e0c81df18
commit dca3ee3484
22 changed files with 681 additions and 84 deletions
+2 -1
View File
@@ -1,12 +1,13 @@
"use client";
import { WebAppFrame } from "./web-app-frame";
import type { WebAppStartupFailureReason } from "./web-app-startup";
export type CanvasPageProps = {
runtimeUrl?: string | null;
title: string;
onLoad?: () => void;
onError?: () => void;
onError?: (reason: WebAppStartupFailureReason) => void;
};
/**
@@ -14,7 +14,11 @@ vi.mock("@/components/theme/app-theme", () => ({
useTheme: () => theme,
}));
function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") {
function acknowledge(
frame: HTMLElement,
result: "ready" | "failed" = "ready",
code: "document_error" | "context_unavailable" = "document_error",
) {
const probe = (frame as HTMLIFrameElement).contentWindow;
const message = (
probe as unknown as { postMessage: ReturnType<typeof vi.fn> }
@@ -27,7 +31,7 @@ function acknowledge(frame: HTMLElement, result: "ready" | "failed" = "ready") {
version: WEB_APP_STARTUP_VERSION,
nonce: message.nonce,
result,
...(result === "failed" ? { code: "document_error" } : {}),
...(result === "failed" ? { code } : {}),
},
source: probe as unknown as Window,
}),
@@ -107,7 +111,7 @@ describe("WebAppFrame updates and failure handling", () => {
expect(screen.getByTitle("Canvas")).toBe(frame);
});
it("ignores wrong-frame results and becomes unavailable at the deadline", async () => {
it("ignores wrong-frame results and reports a timeout reason at the deadline", async () => {
vi.useFakeTimers();
const onError = vi.fn();
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
@@ -137,6 +141,7 @@ describe("WebAppFrame updates and failure handling", () => {
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe("loading");
act(() => vi.advanceTimersByTime(15_000));
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith("timeout");
expect(screen.getByTestId("web-app-frame").getAttribute("data-frame-state")).toBe(
"unavailable",
);
@@ -144,6 +149,24 @@ describe("WebAppFrame updates and failure handling", () => {
vi.useRealTimers();
});
it.each([["document_error"], ["context_unavailable"]] as const)(
"passes the guest-reported %s code to onError",
async (code) => {
const onError = vi.fn();
render(<WebAppFrame runtimeUrl="/runtime/one/" title="Canvas" onError={onError} />);
const frame = screen.getByTitle("Canvas");
const postMessage = vi.fn();
Object.defineProperty(frame, "contentWindow", {
configurable: true,
value: { postMessage },
});
fireEvent.load(frame);
acknowledge(frame, "failed", code);
await waitFor(() => expect(onError).toHaveBeenCalledOnce());
expect(onError).toHaveBeenCalledWith(code);
},
);
it("uses the phone safe-area inset and renders no iframe without a capability", () => {
responsive.isMobile = true;
const { rerender } = render(<WebAppFrame title="Canvas" />);
+76 -49
View File
@@ -11,6 +11,7 @@ import {
createWebAppStartupProbe,
parseWebAppStartupResult,
WEB_APP_STARTUP_TIMEOUT_MS,
type WebAppStartupFailureReason,
} from "./web-app-startup";
type WebAppFrameProps = {
@@ -20,7 +21,7 @@ type WebAppFrameProps = {
title: string;
className?: string;
onLoad?: () => void;
onError?: () => void;
onError?: (reason: WebAppStartupFailureReason) => void;
};
type FrameState = "loading" | "ready" | "unavailable";
@@ -32,9 +33,71 @@ type WebAppFrameLifecycleProps = {
iframeRef: IframeReference;
sendAppearance: () => void;
onLoad?: () => void;
onError?: () => void;
onError?: (reason: WebAppStartupFailureReason) => void;
};
type WebAppStartupOutcome =
| { result: "ready" }
| { result: "failed"; reason: WebAppStartupFailureReason };
type StartupAttempt = {
nonce: string;
frameWindow: Window | null;
settled: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
function useWebAppStartupMessageListener({
runtimeUrl,
iframeRef,
attemptRef,
finishAttempt,
}: {
runtimeUrl?: string | null;
iframeRef: IframeReference;
attemptRef: { current: StartupAttempt | null };
finishAttempt: (outcome: WebAppStartupOutcome) => void;
}) {
useLayoutEffect(() => {
if (!runtimeUrl) return;
const attempt: StartupAttempt = {
nonce: createWebAppStartupNonce(),
frameWindow: null,
settled: false,
timer: null,
};
attemptRef.current = attempt;
attempt.timer = setTimeout(
() => finishAttempt({ result: "failed", reason: "timeout" }),
WEB_APP_STARTUP_TIMEOUT_MS,
);
const handleStartupMessage = (event: MessageEvent<unknown>) => {
if (attemptRef.current !== attempt || attempt.settled) return;
const currentFrameWindow = iframeRef.current?.contentWindow;
if (
!currentFrameWindow ||
attempt.frameWindow !== currentFrameWindow ||
event.source !== currentFrameWindow
) {
return;
}
const result = parseWebAppStartupResult(event.data, attempt.nonce);
if (!result) return;
finishAttempt(
result.result === "ready" ? { result: "ready" } : { result: "failed", reason: result.code },
);
};
window.addEventListener("message", handleStartupMessage);
return () => {
window.removeEventListener("message", handleStartupMessage);
if (attempt.timer !== null) clearTimeout(attempt.timer);
attempt.timer = null;
if (attemptRef.current === attempt) attemptRef.current = null;
};
}, [finishAttempt, iframeRef, runtimeUrl]);
}
function useWebAppFrameLifecycle({
runtimeUrl,
iframeRef,
@@ -47,12 +110,7 @@ function useWebAppFrameLifecycle({
const onLoadRef = useRef(onLoad);
const onErrorRef = useRef(onError);
const sendAppearanceRef = useRef(sendAppearance);
const attemptRef = useRef<{
nonce: string;
frameWindow: Window | null;
settled: boolean;
timer: ReturnType<typeof setTimeout> | null;
} | null>(null);
const attemptRef = useRef<StartupAttempt | null>(null);
useEffect(() => {
onLoadRef.current = onLoad;
@@ -70,7 +128,7 @@ function useWebAppFrameLifecycle({
if (frameReadyRef.current) sendAppearance();
}, [sendAppearance]);
const finishAttempt = useCallback((result: "ready" | "failed") => {
const finishAttempt = useCallback((outcome: WebAppStartupOutcome) => {
const attempt = attemptRef.current;
if (!attempt || attempt.settled) return;
attempt.settled = true;
@@ -78,10 +136,10 @@ function useWebAppFrameLifecycle({
clearTimeout(attempt.timer);
attempt.timer = null;
}
if (result === "failed") {
if (outcome.result === "failed") {
frameReadyRef.current = false;
setFrameState("unavailable");
onErrorRef.current?.();
onErrorRef.current?.(outcome.reason);
return;
}
frameReadyRef.current = true;
@@ -93,39 +151,7 @@ function useWebAppFrameLifecycle({
});
}, []);
useLayoutEffect(() => {
if (!runtimeUrl) return;
const attempt = {
nonce: createWebAppStartupNonce(),
frameWindow: null as Window | null,
settled: false,
timer: null as ReturnType<typeof setTimeout> | null,
};
attemptRef.current = attempt;
attempt.timer = setTimeout(() => finishAttempt("failed"), WEB_APP_STARTUP_TIMEOUT_MS);
const handleStartupMessage = (event: MessageEvent<unknown>) => {
if (attemptRef.current !== attempt || attempt.settled) return;
const currentFrameWindow = iframeRef.current?.contentWindow;
if (
!currentFrameWindow ||
attempt.frameWindow !== currentFrameWindow ||
event.source !== currentFrameWindow
) {
return;
}
const result = parseWebAppStartupResult(event.data, attempt.nonce);
if (!result) return;
finishAttempt(result.result);
};
window.addEventListener("message", handleStartupMessage);
return () => {
window.removeEventListener("message", handleStartupMessage);
if (attempt.timer !== null) clearTimeout(attempt.timer);
attempt.timer = null;
if (attemptRef.current === attempt) attemptRef.current = null;
};
}, [finishAttempt, iframeRef, runtimeUrl]);
useWebAppStartupMessageListener({ runtimeUrl, iframeRef, attemptRef, finishAttempt });
const handleLoad = useCallback(() => {
const attempt = attemptRef.current;
@@ -136,9 +162,7 @@ function useWebAppFrameLifecycle({
frameWindow.postMessage(createWebAppStartupProbe(attempt.nonce), "*");
}, [iframeRef]);
const handleError = useCallback(() => finishAttempt("failed"), [finishAttempt]);
return { frameState, handleLoad, handleError };
return { frameState, handleLoad };
}
/**
@@ -158,7 +182,7 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*");
}, [resolvedTheme]);
const { frameState, handleLoad, handleError } = useWebAppFrameLifecycle({
const { frameState, handleLoad } = useWebAppFrameLifecycle({
runtimeUrl,
iframeRef,
sendAppearance,
@@ -179,6 +203,10 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
aria-busy={frameState === "loading"}
>
{runtimeUrl && frameState !== "unavailable" ? (
// No onError here: React never attaches a DOM "error" listener for iframe/object/embed
// (it only listens for "load"), so a document-load failure is unobservable from the
// frame element itself. It surfaces only through the guest's own postMessage handshake
// (the "document_error" reason) or the startup timeout.
<iframe
key={runtimeUrl}
title={title}
@@ -189,7 +217,6 @@ export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: W
loading="eager"
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
onLoad={handleLoad}
onError={handleError}
/>
) : null}
{frameState !== "ready" && (
+15 -3
View File
@@ -15,14 +15,26 @@ export type WebAppStartupProbe = {
nonce: string;
};
export type WebAppStartupResult = {
type WebAppStartupResultBase = {
type: typeof WEB_APP_STARTUP_RESULT_TYPE;
version: typeof WEB_APP_STARTUP_VERSION;
nonce: string;
result: "ready" | "failed";
code?: "document_error" | "context_unavailable";
};
export type WebAppStartupResult =
| (WebAppStartupResultBase & { result: "ready" })
| (WebAppStartupResultBase & {
result: "failed";
code: "document_error" | "context_unavailable";
});
/**
* Every cause the host can distinguish for a failed startup: the two codes
* the guest bootstrap reports over the wire, plus a host-observed deadline
* with no code at all.
*/
export type WebAppStartupFailureReason = "document_error" | "context_unavailable" | "timeout";
export function createWebAppStartupNonce(): string {
return generateUUID();
}
@@ -4,6 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
import type { Canvas } from "@/lib/api/domains/canvas-api";
const CANVAS_UNAVAILABLE_TITLE = "Canvas unavailable";
const CANVAS_HOST_STATE_TEST_ID = "canvas-host-state";
const COPY: Record<string, string> = {
"canvases:editCanvas": "Edit canvas",
"canvases:releasesAndPermissions": "Releases and permissions",
@@ -28,8 +31,16 @@ const COPY: Record<string, string> = {
"canvases:shareCanvasDescription":
"Prepare a verified bundle or source archive for review and sharing.",
"canvases:ready": "Ready",
"canvases:unavailable": "Canvas unavailable",
"canvases:unavailable": CANVAS_UNAVAILABLE_TITLE,
"canvases:unavailableDescription": "The canvas runtime is unavailable.",
"canvases:runtimeFailed": "Canvas runtime failed to start",
"canvases:runtimeFailedDescription": "The canvas runtime did not start correctly.",
"canvases:runtimeFailedDocumentErrorDescription":
"The canvas application failed to load. Try again or contact the canvas author.",
"canvases:runtimeFailedContextUnavailableDescription":
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
"canvases:runtimeFailedTimeoutDescription":
"The canvas application did not start in time. Try again.",
"canvases:retry": "Try again",
};
@@ -229,14 +240,53 @@ describe("canvas host chrome", () => {
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data");
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toContain(
CANVAS_UNAVAILABLE_TITLE,
);
expect(
screen
.getByTestId("canvas-host-state-panel")
.contains(screen.getByTestId("canvas-host-state")),
.contains(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID)),
).toBe(true);
});
it("names the runtime as the cause, not the release, for each startup-failure reason", () => {
const cases = [
[
"document_error",
"The canvas application failed to load. Try again or contact the canvas author.",
],
[
"context_unavailable",
"The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
],
["timeout", "The canvas application did not start in time. Try again."],
] as const;
for (const [reason, description] of cases) {
const { unmount } = render(
<CanvasHostStatePanel
state="runtime_failed"
runtimeFailureReason={reason}
error={null}
onRetry={vi.fn()}
/>,
);
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
"Canvas runtime failed to start",
);
expect(screen.getByText(description)).toBeTruthy();
unmount();
}
});
it("keeps a genuinely unavailable release distinct from a runtime-startup failure", () => {
render(<CanvasHostStatePanel state="unavailable" error={null} onRetry={vi.fn()} />);
expect(screen.getByTestId(CANVAS_HOST_STATE_TEST_ID).textContent).toBe(
CANVAS_UNAVAILABLE_TITLE,
);
});
it("announces readiness politely without adding a visible status toolbar", () => {
render(
<CanvasHostBody
@@ -255,6 +305,29 @@ describe("canvas host chrome", () => {
expect(announcement.textContent).toContain("Ready");
expect(announcement.parentElement?.getAttribute("role")).toBe("status");
expect(announcement.parentElement?.getAttribute("aria-live")).toBe("polite");
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain("Canvas unavailable");
expect(screen.getByTestId("canvas-host-route").textContent).not.toContain(
CANVAS_UNAVAILABLE_TITLE,
);
});
});
describe("canvas host body prop forwarding", () => {
it("forwards the startup-failure reason from the body to the state panel", () => {
render(
<CanvasHostBody
canvasId="canvas-1"
title="Task canvas"
state="runtime_failed"
runtimeUrl={null}
error={null}
runtimeFailureReason="timeout"
onRuntimeReady={vi.fn()}
onRuntimeError={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(
screen.getByText("The canvas application did not start in time. Try again."),
).toBeTruthy();
});
});
@@ -16,6 +16,7 @@ import { Button } from "@kandev/ui/button";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
import { CanvasPage } from "@/components/plugins/canvas-page";
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
import {
canvasCanEnableWorkspaceData,
canvasHref,
@@ -40,8 +41,15 @@ export type CanvasHostState =
| "offline"
| "invalid_release"
| "unavailable"
| "runtime_failed"
| "archived";
const RUNTIME_FAILED_DESCRIPTIONS: Record<WebAppStartupFailureReason, string> = {
document_error: "canvases:runtimeFailedDocumentErrorDescription",
context_unavailable: "canvases:runtimeFailedContextUnavailableDescription",
timeout: "canvases:runtimeFailedTimeoutDescription",
};
const STATE_COPY: Record<CanvasHostState, { title: string; description: string }> = {
loading_metadata: {
title: "canvases:loadingCanvas",
@@ -66,6 +74,10 @@ const STATE_COPY: Record<CanvasHostState, { title: string; description: string }
description: "canvases:invalidReleaseDescription",
},
unavailable: { title: "canvases:unavailable", description: "canvases:unavailableDescription" },
runtime_failed: {
title: "canvases:runtimeFailed",
description: "canvases:runtimeFailedDescription",
},
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
};
@@ -94,6 +106,7 @@ export function CanvasHostBody({
state,
runtimeUrl,
error,
runtimeFailureReason,
onRuntimeReady,
onRuntimeError,
onRetry,
@@ -103,8 +116,9 @@ export function CanvasHostBody({
state: CanvasHostState;
runtimeUrl: string | null;
error: string | null;
runtimeFailureReason?: WebAppStartupFailureReason | null;
onRuntimeReady: () => void;
onRuntimeError: () => void;
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
onRetry: () => void;
}) {
const { t } = useTranslation();
@@ -134,7 +148,12 @@ export function CanvasHostBody({
/>
</>
) : (
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
<CanvasHostStatePanel
state={state}
error={error}
runtimeFailureReason={runtimeFailureReason}
onRetry={onRetry}
/>
)}
</div>
);
@@ -194,14 +213,20 @@ export function CanvasHostHeader({
export function CanvasHostStatePanel({
state,
error,
runtimeFailureReason,
onRetry,
}: {
state: CanvasHostState;
error: string | null;
runtimeFailureReason?: WebAppStartupFailureReason | null;
onRetry: () => void;
}) {
const { t } = useTranslation();
const copy = STATE_COPY[state];
const descriptionKey =
state === "runtime_failed" && runtimeFailureReason
? RUNTIME_FAILED_DESCRIPTIONS[runtimeFailureReason]
: copy.description;
return (
<div
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
@@ -211,7 +236,7 @@ export function CanvasHostStatePanel({
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
{t(copy.title)}
</h2>
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
<p className="text-sm text-muted-foreground">{error || t(descriptionKey)}</p>
{state !== "loading_metadata" && state !== "loading_runtime" && (
<Button
variant="outline"
@@ -18,6 +18,7 @@ import {
MobileCanvasActions,
type CanvasHostState,
} from "./canvas-host-components";
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api";
type CanvasHostRouteViewProps = {
@@ -29,6 +30,7 @@ type CanvasHostRouteViewProps = {
runtimeUrl: string | null;
state: CanvasHostState;
error: string | null;
runtimeFailureReason: WebAppStartupFailureReason | null;
menuOpen: boolean;
promotionOpen: boolean;
workspaceDataOpen: boolean;
@@ -50,7 +52,7 @@ type CanvasHostRouteViewProps = {
onRename: () => void;
onSelectCanvas: (canvas: Canvas) => void;
onRuntimeReady: () => void;
onRuntimeError: () => void;
onRuntimeError: (reason: WebAppStartupFailureReason) => void;
onRetry: () => void;
onPromotionCompleted: () => void;
onChanged: () => void;
@@ -116,12 +118,20 @@ function CanvasHostRouteBody({
state,
runtimeUrl,
error,
runtimeFailureReason,
onRuntimeReady,
onRuntimeError,
onRetry,
}: Pick<
CanvasHostRouteViewProps,
"canvasId" | "runtimeUrl" | "state" | "error" | "onRuntimeReady" | "onRuntimeError" | "onRetry"
| "canvasId"
| "runtimeUrl"
| "state"
| "error"
| "runtimeFailureReason"
| "onRuntimeReady"
| "onRuntimeError"
| "onRetry"
> & { title: string }) {
return (
<CanvasHostBody
@@ -130,6 +140,7 @@ function CanvasHostRouteBody({
state={state}
runtimeUrl={runtimeUrl}
error={error}
runtimeFailureReason={runtimeFailureReason}
onRuntimeReady={onRuntimeReady}
onRuntimeError={onRuntimeError}
onRetry={onRetry}
@@ -262,6 +273,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
state={props.state}
runtimeUrl={props.runtimeUrl}
error={props.error}
runtimeFailureReason={props.runtimeFailureReason}
onRuntimeReady={props.onRuntimeReady}
onRuntimeError={props.onRuntimeError}
onRetry={props.onRetry}
@@ -76,12 +76,18 @@ vi.mock("@/components/page-shell", () => ({
}));
vi.mock("@/components/plugins/canvas-page", () => ({
CanvasPage: ({ runtimeUrl, onError }: { runtimeUrl?: string; onError?: () => void }) => (
CanvasPage: ({
runtimeUrl,
onError,
}: {
runtimeUrl?: string;
onError?: (reason: string) => void;
}) => (
<button
type="button"
data-testid={FRAME_TEST_ID}
data-runtime-url={runtimeUrl ?? ""}
onClick={onError}
onClick={() => onError?.("context_unavailable")}
>
frame
</button>
@@ -172,8 +178,11 @@ describe("CanvasHostRoute runtime recovery", () => {
fireEvent.click(screen.getByTestId(FRAME_TEST_ID));
await waitFor(() =>
expect(screen.getByTestId("canvas-host-state").textContent).toContain("unavailable"),
expect(screen.getByTestId("canvas-host-state").textContent).toContain(
"Canvas runtime failed to start",
),
);
expect(screen.getByTestId("canvas-host-state").textContent).not.toContain("Canvas unavailable");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() => {
@@ -212,7 +221,9 @@ describe("CanvasHostRoute runtime recovery", () => {
);
});
});
});
describe("CanvasHostRoute chrome and lifecycle", () => {
it("passes raw desktop overflow items to the standalone page shell", async () => {
mockRenderPageShellOverflow.value = true;
render(<CanvasHostRoute canvasId="canvas-1" />);
@@ -20,6 +20,7 @@ import {
canvasPresentationUserId,
recordCanvasPresentation,
} from "@/lib/canvas-presentation-storage";
import type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
import { useCanvasHostCanvases } from "./canvas-host-picker";
import { type CanvasHostState } from "./canvas-host-components";
import { CanvasHostRouteView } from "./canvas-host-route-view";
@@ -323,11 +324,19 @@ function useCanvasHost(canvasId: string) {
setState((current) => (current === "loading_runtime" ? "ready" : current));
}, []);
const markRuntimeUnavailable = useCallback(() => {
clearRuntimeRenewal();
setRuntimeUrl(null);
setState("unavailable");
}, [clearRuntimeRenewal]);
const [runtimeFailureReason, setRuntimeFailureReason] =
useState<WebAppStartupFailureReason | null>(null);
const markRuntimeUnavailable = useCallback(
(reason: WebAppStartupFailureReason) => {
clearRuntimeRenewal();
setRuntimeUrl(null);
setError(null);
setRuntimeFailureReason(reason);
setState("runtime_failed");
},
[clearRuntimeRenewal],
);
useEffect(() => {
renewRuntimeRef.current = renewRuntime;
@@ -354,6 +363,7 @@ function useCanvasHost(canvasId: string) {
runtimeUrl,
state,
error,
runtimeFailureReason,
lifecycleRevision,
load,
refresh,
@@ -420,6 +430,7 @@ export function CanvasHostRoute({
runtimeUrl,
state,
error,
runtimeFailureReason,
load,
refresh,
markRuntimeReady,
@@ -464,6 +475,7 @@ export function CanvasHostRoute({
runtimeUrl={runtimeUrl}
state={state}
error={error}
runtimeFailureReason={runtimeFailureReason}
menuOpen={menuOpen}
promotionOpen={promotionOpen}
workspaceDataOpen={workspaceDataOpen}
@@ -92,7 +92,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
}
});
test("keeps the existing recoverable failure when a proxy ignores no-transform", async ({
test("surfaces a runtime startup failure when a proxy ignores no-transform", async ({
browser,
testPage,
apiClient,
@@ -114,9 +114,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
timeout: 30_000,
});
await expect(opened.page.getByTestId("canvas-host-state")).toHaveText(
"Canvas runtime failed to start",
{ timeout: 30_000 },
);
await expect(opened.page.getByTestId("web-app-frame")).toHaveCount(0);
await expect(
opened.page.getByRole("button", { name: "Try again", exact: true }),
@@ -214,9 +215,10 @@ test.describe("authenticated same-origin canvas runtime", () => {
await proxy.clearAuthentication(context);
await page.reload();
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
timeout: 30_000,
});
await expect(page.getByTestId("canvas-host-state")).toHaveText(
"Canvas runtime failed to start",
{ timeout: 30_000 },
);
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
@@ -99,9 +99,10 @@ test.describe("authenticated same-origin canvas runtime on mobile", () => {
await proxy.clearAuthentication(context);
await page.reload();
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
timeout: 30_000,
});
await expect(page.getByTestId("canvas-host-state")).toHaveText(
"Canvas runtime failed to start",
{ timeout: 30_000 },
);
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "The active release failed validation. Review releases or edit the canvas.",
"unavailable": "Canvas unavailable",
"unavailableDescription": "The active canvas release is not available.",
"runtimeFailed": "Canvas runtime failed to start",
"runtimeFailedDescription": "The canvas runtime failed to start.",
"runtimeFailedDocumentErrorDescription": "The canvas application failed to load. Try again.",
"runtimeFailedContextUnavailableDescription": "The canvas application could not reach the Kandev runtime. Try again or contact the canvas author.",
"runtimeFailedTimeoutDescription": "The canvas application took too long to start. Try again.",
"archived": "Canvas archived",
"archivedDescription": "Restore this canvas from workspace settings to open it again.",
"loadFailed": "Failed to load the canvas.",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "アクティブなリリースが検証に失敗しました。リリースを確認するか、キャンバスを編集してください。",
"unavailable": "キャンバスを利用できません",
"unavailableDescription": "アクティブなキャンバスリリースを利用できません。",
"runtimeFailed": "キャンバスランタイムの起動に失敗しました",
"runtimeFailedDescription": "キャンバスランタイムの起動に失敗しました。",
"runtimeFailedDocumentErrorDescription": "キャンバスアプリケーションの読み込みに失敗しました。再度お試しください。",
"runtimeFailedContextUnavailableDescription": "キャンバスアプリケーションが Kandev ランタイムに接続できませんでした。再度お試しいただくか、キャンバスの作成者に連絡してください。",
"runtimeFailedTimeoutDescription": "キャンバスアプリケーションの起動に時間がかかりすぎました。再度お試しください。",
"archived": "キャンバスはアーカイブ済みです",
"archivedDescription": "再度開くには、ワークスペース設定からこのキャンバスを復元してください。",
"loadFailed": "キャンバスの読み込みに失敗しました。",
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "Ţĥē àćţĩvē ŕēĺēàśē ƒàĩĺēď vàĺĩďàţĩōń. Ŕēvĩēŵ ŕēĺēàśēś ōŕ ēďĩţ ţĥē ćàńvàś.",
"unavailable": "Ćàńvàś ũńàvàĩĺàƀĺē",
"unavailableDescription": "Ţĥē àćţĩvē ćàńvàś ŕēĺēàśē ĩś ńōţ àvàĩĺàƀĺē.",
"runtimeFailed": "Ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ",
"runtimeFailedDescription": "Ţĥē ćàńvàś ŕũńţĩḿē ƒàĩĺēď ţō śţàŕţ.",
"runtimeFailedDocumentErrorDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ƒàĩĺēď ţō ĺōàď. Ţŕŷ àĝàĩń.",
"runtimeFailedContextUnavailableDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ćōũĺď ńōţ ŕēàćĥ ţĥē Ķàńďēv ŕũńţĩḿē. Ţŕŷ àĝàĩń ōŕ ćōńţàćţ ţĥē ćàńvàś àũţĥōŕ.",
"runtimeFailedTimeoutDescription": "Ţĥē ćàńvàś àƥƥĺĩćàţĩōń ţōōķ ţōō ĺōńĝ ţō śţàŕţ. Ţŕŷ àĝàĩń.",
"archived": "Ćàńvàś àŕćĥĩvēď",
"archivedDescription": "Ŕēśţōŕē ţĥĩś ćàńvàś ƒŕōḿ ŵōŕķśƥàćē śēţţĩńĝś ţō ōƥēń ĩţ àĝàĩń.",
"loadFailed": "Ƒàĩĺēď ţō ĺōàď ţĥē ćàńvàś.",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "A versão ativa falhou a validação. Reveja as versões ou edite o canvas.",
"unavailable": "Canvas indisponível",
"unavailableDescription": "A versão ativa do canvas não está disponível.",
"runtimeFailed": "Falha ao iniciar o motor de execução do canvas",
"runtimeFailedDescription": "O motor de execução do canvas falhou ao iniciar.",
"runtimeFailedDocumentErrorDescription": "A aplicação do canvas falhou ao carregar. Tente novamente.",
"runtimeFailedContextUnavailableDescription": "A aplicação do canvas não conseguiu alcançar o runtime do Kandev. Tente novamente ou contacte o autor do canvas.",
"runtimeFailedTimeoutDescription": "A aplicação do canvas demorou demasiado tempo a iniciar. Tente novamente.",
"archived": "Canvas arquivado",
"archivedDescription": "Restaure este canvas nas definições do workspace para o abrir novamente.",
"loadFailed": "Não foi possível carregar o canvas.",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活动版本未通过验证。请查看版本或编辑画布。",
"unavailable": "画布不可用",
"unavailableDescription": "活动画布版本不可用。",
"runtimeFailed": "画布运行时启动失败",
"runtimeFailedDescription": "画布运行时启动失败。",
"runtimeFailedDocumentErrorDescription": "画布应用加载失败。请重试。",
"runtimeFailedContextUnavailableDescription": "画布应用无法连接到 Kandev 运行时。请重试或联系画布作者。",
"runtimeFailedTimeoutDescription": "画布应用启动超时。请重试。",
"archived": "画布已归档",
"archivedDescription": "请在工作区设置中恢复此画布,然后再次打开。",
"loadFailed": "加载画布失败。",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活動版本未通過驗證。請查看版本或編輯畫布。",
"unavailable": "畫布不可用",
"unavailableDescription": "活動畫布版本不可用。",
"runtimeFailed": "畫布執行時啓動失敗",
"runtimeFailedDescription": "畫布執行時啓動失敗。",
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
"runtimeFailedContextUnavailableDescription": "畫布應用無法連接到 Kandev 執行時。請重試或聯絡畫布作者。",
"runtimeFailedTimeoutDescription": "畫布應用啓動超時。請重試。",
"archived": "畫布已歸檔",
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
"loadFailed": "載入畫布失敗。",
+5
View File
@@ -22,6 +22,11 @@
"invalidReleaseDescription": "活動版本未通過驗證。請檢視版本或編輯畫布。",
"unavailable": "畫布不可用",
"unavailableDescription": "活動畫布版本不可用。",
"runtimeFailed": "畫布執行時啟動失敗",
"runtimeFailedDescription": "畫布執行時啟動失敗。",
"runtimeFailedDocumentErrorDescription": "畫布應用載入失敗。請重試。",
"runtimeFailedContextUnavailableDescription": "畫布應用無法連線到 Kandev 執行時。請重試或聯絡畫布作者。",
"runtimeFailedTimeoutDescription": "畫布應用啟動超時。請重試。",
"archived": "畫布已歸檔",
"archivedDescription": "請在工作區設定中恢復此畫布,然後再次開啟。",
"loadFailed": "載入畫布失敗。",
@@ -0,0 +1,180 @@
---
created: 2026-09-21
status: done
requirements:
- REQ-CANVASES-AGENT-WEB-APPS-007
system_design:
- ../../specs/canvases/system-design/canvas-host-chrome.md
legacy_specs: []
---
# Fix Plan: Name The Real Cause Of A Canvas Runtime Startup Failure
## Overview
The canvas host reports every startup failure as a release problem. Carry the cause the guest
bootstrap already reports through the frame boundary to the host state panel, and give the host a
runtime-startup-failure state that is separate from the release-unavailable state.
## Confirmed root cause
`apps/backend/internal/plugins/webapp/runtime_bootstrap.go:43,68` posts `document_error` or
`context_unavailable` with a failed startup result.
`apps/web/components/plugins/web-app-startup.ts:23` carries and validates that code.
`apps/web/components/plugins/web-app-frame.tsx:119` passes only `result.result` into
`finishAttempt`, whose signature at line 73 is `(result: "ready" | "failed")`. `onError`
(lines 23, 35) takes no argument, so the cause cannot cross the boundary even in principle.
`apps/web/components/settings/canvas-host-route.tsx:457` wires `onRuntimeError` to
`markRuntimeUnavailable` (line 320), which sets host state to `unavailable`. That is the same state
`stateForCanvas` produces from `active_release_status === "unavailable"` (line 32), so
`canvas-host-components.tsx:63` renders `canvases:unavailableDescription`, "The active canvas
release is not available." (`apps/web/src/locales/en/canvases.json:24`).
Measured receipt: a scratch component test drove all three failure paths against the current
component and recorded what the host receives.
| Driven failure | `onError` calls | argument count |
| --- | --- | --- |
| guest posts `document_error` | 1 | 0 |
| guest posts `context_unavailable` | 1 | 0 |
| no acknowledgement for 15s | 1 | 0 |
Zero arguments in every case is the defect: the cause is computed, parsed, validated, and dropped.
The scratch file was deleted after the measurement; the regression coverage lands in the work order.
There are three failure origins: the two guest codes and the `WEB_APP_STARTUP_TIMEOUT_MS` deadline
(`web-app-frame.tsx:105`). The iframe element's own `onError` looked like a fourth, but React
attaches no DOM `"error"` listener for `iframe`/`object`/`embed` (only `"load"`; confirmed against
the installed `react-dom-client.development.js`), so that handler could never fire and was deleted
rather than wired up.
## Scope
### In scope
- A host-facing failure-cause union in `web-app-startup.ts` covering the two guest codes and the
startup timeout.
- Carrying the cause through `WebAppFrame` -> `CanvasPage` -> `CanvasHostBody` ->
`CanvasHostRoute`.
- A `runtime_failed` host state with per-cause descriptions, keeping Retry and release actions.
- English copy plus the four translated catalogs and the pseudo catalog.
- Component regression coverage in the three existing test files.
### Out of scope
- The CSP and sandbox token set. #3827 owns that and it is already on `main`.
- Reporting a guest CSP violation to the host. See the decision below.
- `applyCanvasHostError` (`canvas-host-route.tsx:131`), which keeps `offline`/`unavailable` for host
request failures and already overrides the description with the returned error text.
- `plugins:webAppUnavailable`, the frame's own overlay copy, which the canvas host never shows
because it swaps to the state panel.
- Any backend change.
## Decisions
- **A new state, not a description variant.** `unavailable` must keep meaning "the active release is
not available" per `AC-CANVASES-AGENT-WEB-APPS-007.1`, and the state drives the title as well as
the description. `offline` is already taken by `canvas-host-route.tsx:131` for
`navigator.onLine === false`; overloading it would hide a second cause behind one label.
- **Three causes, three descriptions, one title.** `AC-CANVASES-AGENT-WEB-APPS-007.10` requires the
timeout to be distinguishable from both guest codes. A fourth, iframe-load-error cause was
considered and dropped: React never attaches a DOM `"error"` listener for `iframe`, so there is no
reachable signal to give it a description for.
- **Translations ship with this change, against the card's instruction.** The card asks for
`en/canvases.json` only. Measured against the current tree, that leaves CI red:
adding one `en` key and running `node apps/web/scripts/check-i18n-keys.mjs` exits 1 with
"pseudo catalog is out of sync with en (1 missing, 0 extra)" and "4 real-locale catalog issue(s)"
naming `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`. The probe key was reverted and the checker returns to
exit 0. Locale catalogs are documentation-coverage exempt, so this widens no delivery obligation.
- **Guest CSP violations stay out.** Forwarding `securitypolicyviolation` detail would add a new
guest-controlled string field to the host wire contract, which needs bounding, validation, and a
security review of rendering untrusted text in host UI. `context_unavailable` already routes an
investigator to the runtime rather than the release, which is the entire saving this card is
about. File separately if the blocked-URI detail is wanted.
## ASCII UI preview
`UI-01: Canvas host state panel`. Entry point: a canvas whose active release is valid and whose
application fails its startup handshake. Structure is required; spacing is illustrative. The panel is
`CanvasHostStatePanel`; Retry is the existing button. Desktop and phone share this composition, so
one view covers both.
Current behavior, evidenced by `canvases.json:23-24` and `canvas-host-components.tsx:63`:
```text
+--------------------------------------------------+
| Canvas unavailable |
| The active canvas release is not available. |
| [ Try again ] |
+--------------------------------------------------+
```
Proposed, cause = cannot reach the runtime capability API:
```text
+--------------------------------------------------+
| Canvas did not start |
| The canvas application could not reach the |
| Kandev runtime. Its release is valid. |
| [ Try again ] |
+--------------------------------------------------+
```
Proposed, cause = no acknowledgement at the deadline:
```text
+--------------------------------------------------+
| Canvas did not start |
| The canvas application did not finish |
| starting in time. Its release is valid. |
| [ Try again ] |
+--------------------------------------------------+
```
A genuinely unavailable release keeps the first drawing unchanged. Final wording is the work order's;
the required structure is one title naming the application, one description naming the cause, and the
existing recovery action. Criteria: `AC-CANVASES-AGENT-WEB-APPS-007.9` for the distinct state and
`AC-CANVASES-AGENT-WEB-APPS-007.10` for the per-cause description.
## Work orders
- [x] [Task 01: Surface the canvas runtime startup failure cause](task-01-surface-runtime-startup-failure-cause.md)
## Existing E2E assertions on the current copy
Three specs assert the title `Canvas unavailable`, and they do not all exercise the same path.
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts:398` fails
`/api/v1/canvases/<id>/runtime` with 503. That is the host's own capability request, which settles
through `applyCanvasHostError` and is out of scope, so this assertion should survive unchanged.
- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts:178` and
`apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts:102` clear proxy
authentication and reload. Either the canvas metadata request fails first, keeping the current
title, or the guest document fails and the new state applies. Which one wins is not decidable by
reading; the work order runs them and updates the assertion only for the path that actually moves.
## Verification strategy
Component tests in the three existing files, the repository i18n gate, and the two proxy E2E specs
above. Commands are in the work order.
## Risks
- `onError` gaining a parameter is a component-contract change. `CanvasPage` is the only
`WebAppFrame` consumer and `CanvasHostBody` the only `CanvasPage` consumer, so the blast radius is
three files; an optional parameter keeps existing call sites compiling.
- A new `CanvasHostState` member must be added to `STATE_COPY`, which is a total
`Record<CanvasHostState, ...>`; omitting it is a type error rather than a silent gap.
- New copy must avoid U+2014, which `check-no-em-dash-ui.mjs` gates.
## Verification results
Shipped in `fc4cd24ea` (implementation) and `e962ff312` (reconciling the spec and this plan down to
the three causes the host actually observes, after Review round 1 caught the four-cause claim
against the code). Component suite green (`web-app-frame.test.tsx`, `canvas-host-route.test.tsx`,
`canvas-host-components.test.tsx`, `canvas-page.test.tsx`), `pnpm run i18n:check` and
`i18n:ratchet` clean across all six locales, `make fmt lint typecheck test` and `make lint-format`
green, and both proxy E2E specs (`canvas-authenticated-proxy.spec.ts`,
`mobile-canvas-authenticated-proxy.spec.ts`) pass against the new `Canvas runtime failed to start`
title. Full receipts are in the task's Kandev task plan, not duplicated here.
@@ -0,0 +1,140 @@
---
id: "01-surface-runtime-startup-failure-cause"
title: "Surface the canvas runtime startup failure cause"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-CANVASES-AGENT-WEB-APPS-007
acceptance_criteria:
- AC-CANVASES-AGENT-WEB-APPS-007.5
- AC-CANVASES-AGENT-WEB-APPS-007.9
- AC-CANVASES-AGENT-WEB-APPS-007.10
system_design:
- ../../specs/canvases/system-design/canvas-host-chrome.md
---
# Task 01: Surface The Canvas Runtime Startup Failure Cause
## Summary
Carry the startup-failure cause from the canvas frame to the canvas host, and render a
runtime-startup-failure state whose copy names the application or its runtime instead of the release.
## In scope
- Add a `WebAppStartupFailureReason` union to `apps/web/components/plugins/web-app-startup.ts`
covering `document_error`, `context_unavailable`, and a startup timeout. An iframe load error was
considered and dropped: React attaches no DOM `"error"` listener for `iframe`/`object`/`embed`
(only `"load"`), so the existing `handleError` wiring at `web-app-frame.tsx:139` (pre-fix) could
never fire; it is dead code, deleted rather than given a reason.
- Settle each attempt in `web-app-frame.tsx` with readiness or one reason, and give `onError` that
reason as an argument. Pass the guest `code` from the parsed result at line 119; supply the
timeout reason at line 105.
- Forward the reason through `canvas-page.tsx` and `canvas-host-components.tsx` to
`canvas-host-route.tsx`.
- Add a `runtime_failed` member to `CanvasHostState`, record the reason in the host, and resolve the
panel description from it. Leave the `unavailable` state reachable only from release status and
host request failures.
- Add the new copy to `en` and to `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`, and regenerate the pseudo
catalog. Use `pnpm run i18n:zh-hant` for the Traditional pair.
## Out of scope
- CSP, sandbox tokens, or forwarding guest CSP-violation detail.
- `applyCanvasHostError` and the `offline` state.
- `plugins:webAppUnavailable`.
- Backend, persistence, permission, or observability changes.
## Acceptance
- A frame that reports `context_unavailable` or `document_error` while the active release is valid
renders the runtime-startup-failure state, and its description names the application or runtime.
The release-unavailable description does not appear.
- A canvas whose `active_release_status` is `unavailable` still renders `canvases:unavailable` and
`canvases:unavailableDescription`.
- The 15-second timeout renders the runtime-startup-failure state with a description distinct from
both guest causes, and Retry stays available.
## ASCII UI preview
`UI-01: Canvas host state panel`, unchanged from the
[plan preview](plan.md#ascii-ui-preview). Required structure: one title naming the canvas
application, one description naming the cause, and the existing Retry action. Wording is this work
order's to finalize within that structure.
## Verification
```bash
# From apps/web:
pnpm exec vitest run components/plugins/web-app-frame.test.tsx components/settings/canvas-host-route.test.tsx components/settings/canvas-host-components.test.tsx
pnpm run i18n:check
pnpm run typecheck
pnpm run lint
```
```bash
# From apps/web, for the two proxy specs that assert the current title:
pnpm e2e:run --grep "recovers after proxy authentication expires"
```
Baseline before the change: the three component test files pass with 15 tests, and
`node scripts/check-i18n-keys.mjs` exits 0.
Confirm which path each proxy spec settles on before editing its assertion. Leave
`plugin-canvas.spec.ts` alone unless it actually fails; it exercises the host capability request,
not the frame startup handshake. See
[the plan's E2E note](plan.md#existing-e2e-assertions-on-the-current-copy).
## Files likely touched
- `apps/web/components/plugins/web-app-startup.ts`
- `apps/web/components/plugins/web-app-frame.tsx`
- `apps/web/components/plugins/web-app-frame.test.tsx`
- `apps/web/components/plugins/canvas-page.tsx`
- `apps/web/components/settings/canvas-host-components.tsx`
- `apps/web/components/settings/canvas-host-components.test.tsx`
- `apps/web/components/settings/canvas-host-route.tsx`
- `apps/web/components/settings/canvas-host-route.test.tsx`
- `apps/web/components/settings/canvas-host-route-view.tsx`
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/canvases.json`
- `apps/web/e2e/tests/canvas/canvas-authenticated-proxy.spec.ts` (only if it moves)
- `apps/web/e2e/tests/canvas/mobile-canvas-authenticated-proxy.spec.ts` (only if it moves)
## Dependencies
None. The CSP fix this defect was found behind is already on `main` at 93a7cba93.
## Risks
- Reusing `unavailable` for the new state would re-create the defect. Assert the release path and the
startup path render different `canvas-host-state` text.
- Omitting the new state from `STATE_COPY` is a type error, not a silent gap, because the map is a
total `Record<CanvasHostState, ...>`.
- New copy containing U+2014 fails `check-no-em-dash-ui.mjs`.
## Parallelism
`sequential`
## Inputs
- `REQ-CANVASES-AGENT-WEB-APPS-007`, especially `.5`, `.9`, and `.10`.
- [Canvas host chrome design](../../specs/canvases/system-design/canvas-host-chrome.md),
section "Startup-failure cause propagation".
- The guest bootstrap contract in
`apps/backend/internal/plugins/webapp/runtime_bootstrap.go`.
- The measured root-cause table in [plan.md](plan.md#confirmed-root-cause).
## Results
Shipped in `fc4cd24ea`, reconciled to three causes (dropping the never-reachable iframe load-error
case) in `e962ff312` after Review round 1. The threading is `web-app-startup.ts`
(`WebAppStartupFailureReason` union) -> `web-app-frame.tsx` (`StartupAttempt`,
`finishAttempt(outcome)`, `onError?: (reason) => void`) -> `canvas-page.tsx` ->
`canvas-host-route.tsx` (`runtimeFailureReason` state, `markRuntimeUnavailable(reason)` ->
`state: "runtime_failed"`) -> `canvas-host-route-view.tsx` -> `canvas-host-components.tsx`
(`RUNTIME_FAILED_DESCRIPTIONS`). New `canvases:` keys shipped in `en`, `pseudo`, `pt-pt`, `zh-cn`,
`zh-hk`, `zh-tw`. All three verification commands and both proxy E2E specs pass; verbatim receipts
are in the task's Kandev task plan.
@@ -257,7 +257,8 @@ blocked by permissions, invalid, or using a prior release.
- **AC-CANVASES-AGENT-WEB-APPS-007.5:** A runtime URL or iframe load event
alone shall not display Ready. Until the current frame acknowledges startup,
the host shall display Loading. After 15 seconds without acknowledgement,
it shall show an unavailable state with Retry and Releases actions.
it shall show the `AC-CANVASES-AGENT-WEB-APPS-007.9` state with Retry and
Releases actions.
- **AC-CANVASES-AGENT-WEB-APPS-007.6:** Retry, release replacement, token
renewal, and canvas navigation shall ignore acknowledgements from previous
frame attempts. An unavailable frame shall not cover recovery controls.
@@ -270,6 +271,18 @@ blocked by permissions, invalid, or using a prior release.
external origins. Ordinary permission review shall not appear as a validation
failure. Active and retained valid releases shall have distinct labels.
- **AC-CANVASES-AGENT-WEB-APPS-007.9:** When a canvas application fails
startup acknowledgement with a valid active release, the host shall show a
runtime-startup-failure state attributing the failure to the application or
runtime, distinct from the release-unavailable state of
`AC-CANVASES-AGENT-WEB-APPS-007.1`.
- **AC-CANVASES-AGENT-WEB-APPS-007.10:** The runtime-startup-failure
description shall distinguish at least three causes: guest-reported
`document_error`, an application unable to reach its runtime API, and
host-observed `timeout` without acknowledgement by the
`AC-CANVASES-AGENT-WEB-APPS-007.5` deadline. Pre-bootstrap failure uses
`timeout`. Unknown causes shall not blame releases.
### REQ-CANVASES-AGENT-WEB-APPS-008: Bounded agent authoring
**Intent:** A task agent cannot fill canvas storage through repeated create or
@@ -19,6 +19,7 @@ geometry. The runtime lifecycle and authentication remain unchanged.
| --- | --- |
| 006.10, 006.11 | Single canvas host header |
| 006.1-.6, 007.1-.3, 007.5-.6 | Responsive composition and state preservation below |
| 007.5, 007.9, 007.10 | Startup-failure cause propagation |
## Single canvas host header
@@ -50,9 +51,38 @@ runtime when actions, status, or viewport width change.
Delivery and browser coverage are in
[work order 03](../../../plans/canvas-same-origin-auth/task-03-panel-toolbars.md).
## Startup-failure cause propagation
The guest bootstrap already reports why startup failed. The host must carry that
cause to the state panel instead of collapsing every failure onto the
release-unavailable state.
Name the reported causes in one host-facing union owned by the web-app startup
contract: a guest-reported `document_error`, an unreachable runtime capability
API, and an absent acknowledgement at the startup deadline. The first two come
from the guest message; the last is observed by the host, which never invents a
cause it did not observe. A browser failure before the bootstrap runs produces
no guest result, so `WebAppFrame` reports the host-observed `timeout`. The host
cannot distinguish that case from another missing acknowledgement.
`WebAppFrame` settles each startup attempt with either readiness or one of those
causes, and its error callback carries the cause. `CanvasPage` forwards it
unchanged. `CanvasHostRoute` records the cause and enters a runtime-startup-failure
state that is separate from the release-unavailable state. Release status keeps
producing the release-unavailable state, so a valid release is never described as
unavailable.
`CanvasHostStatePanel` resolves the runtime-startup-failure description from the
recorded cause and keeps the existing Retry and release actions. Host request
failures keep their current state and continue to display the returned error text.
## Verification
Work order 03 defines component and browser evidence for desktop Dockview,
standalone navigation, phone actions, state announcements, and Retry. Runtime
startup and authentication checks remain in work orders 01 and 02.
No backend, persistence, permission, or observability changes are introduced.
Startup-failure cause propagation is verified by component tests in
`web-app-frame.test.tsx`, `canvas-host-route.test.tsx`, and
`canvas-host-components.test.tsx`. No backend change is introduced.