Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions apps/web/app/api/gen2/workspaces/[workspaceId]/instance/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
ensure: vi.fn(),
detail: vi.fn(),
}));

vi.mock("@/lib/http/api-route", () => ({
withUser: (
handler: (input: {
user: { id: string };
params: { workspaceId: string };
}) => Promise<Response>,
) => {
return async (
_request: Request,
context: { params: Promise<{ workspaceId: string }> },
) =>
handler({
user: { id: "user-1" },
params: await context.params,
});
},
}));
vi.mock("@/lib/gen2/instance", () => ({
ensureGen2Instance: mocks.ensure,
stopGen2Instance: vi.fn(),
}));
vi.mock("@/lib/gen2/workspaces", () => ({
getGen2WorkspaceDetail: mocks.detail,
}));

import { maxDuration, POST } from "./route";

const workspaceId = "e010bd2c-a3c1-438f-acef-166287a3b1cb";
const workspace = {
id: workspaceId,
status: "ready",
sandboxId: "sandbox-1",
};

describe("gen2 instance route", () => {
beforeEach(() => {
mocks.ensure.mockResolvedValue(workspace);
mocks.detail.mockResolvedValue(workspace);
});

afterEach(() => vi.resetAllMocks());

it("allows host wake and Firecracker startup their bounded route budget", () => {
expect(maxDuration).toBe(300);
});

it("returns the ready workspace after startup", async () => {
const response = await POST(
new Request(
`https://codev.test/api/gen2/workspaces/${workspaceId}/instance`,
{
method: "POST",
},
),
{ params: Promise.resolve({ workspaceId }) },
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ workspace });
expect(mocks.ensure).toHaveBeenCalledWith(workspaceId, "user-1");
expect(mocks.detail).toHaveBeenCalledWith(workspaceId, "user-1");
});

it("returns 202 when another member still owns provisioning", async () => {
mocks.ensure.mockResolvedValueOnce({
...workspace,
status: "provisioning",
});
mocks.detail.mockResolvedValueOnce({
...workspace,
status: "provisioning",
sandboxId: null,
});

const response = await POST(
new Request(
`https://codev.test/api/gen2/workspaces/${workspaceId}/instance`,
{
method: "POST",
},
),
{ params: Promise.resolve({ workspaceId }) },
);

expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
workspace: { ...workspace, status: "provisioning", sandboxId: null },
});
});

it("uses the returned workspace state when a concurrent stop wins", async () => {
mocks.detail.mockResolvedValueOnce({
...workspace,
status: "stopped",
sandboxId: null,
});

const response = await POST(
new Request(
`https://codev.test/api/gen2/workspaces/${workspaceId}/instance`,
{
method: "POST",
},
),
{ params: Promise.resolve({ workspaceId }) },
);

expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({
workspace: { ...workspace, status: "stopped", sandboxId: null },
});
});
});
12 changes: 8 additions & 4 deletions apps/web/app/api/gen2/workspaces/[workspaceId]/instance/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { withUser } from "@/lib/http/api-route";
import { ensureGen2Instance, stopGen2Instance } from "@/lib/gen2/instance";
import { getGen2WorkspaceDetail } from "@/lib/gen2/workspaces";

/** Provisioning a Firecracker guest can outlast a normal request. */
/** One bounded host-wake attempt plus guest creation fits Vercel's 300s limit. */
export const maxDuration = 300;

type Params = { workspaceId: string };
Expand All @@ -14,9 +14,13 @@ type Params = { workspaceId: string };
export const POST = withUser<Params>(
async ({ user, params: { workspaceId } }) => {
await ensureGen2Instance(workspaceId, user.id);
return Response.json({
workspace: await getGen2WorkspaceDetail(workspaceId, user.id),
});
const workspace = await getGen2WorkspaceDetail(workspaceId, user.id);
return Response.json(
{ workspace },
{
status: workspace.status === "ready" && workspace.sandboxId ? 200 : 202,
},
);
},
{ errorStatus: 502 },
);
Expand Down
77 changes: 76 additions & 1 deletion apps/web/components/gen2/terminal-pane.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const terminalMocks = vi.hoisted(() => ({
instances: [] as Array<{ onDataHandler?: (data: string) => void }>,
}));

vi.mock("@xterm/xterm", () => ({
Terminal: class {
rows = 24;
cols = 80;
onData = vi.fn();
onDataHandler?: (data: string) => void;

constructor() {
terminalMocks.instances.push(this);
}

onData(handler: (data: string) => void) {
this.onDataHandler = handler;
}

loadAddon() {}
open() {}
dispose() {}
Expand All @@ -25,6 +38,7 @@ const workspaceId = "11111111-1111-4111-8111-111111111111";

describe("Gen2TerminalPane", () => {
beforeEach(() => {
terminalMocks.instances.length = 0;
class ResizeObserverStub {
observe() {}
disconnect() {}
Expand Down Expand Up @@ -71,4 +85,65 @@ describe("Gen2TerminalPane", () => {
fireEvent.click(resume);
await waitFor(() => expect(onResumeWorkspace).toHaveBeenCalledOnce());
});

it("serializes rapid terminal input so characters arrive in order", async () => {
let releaseFirstInput!: () => void;
const firstInput = new Promise<void>((resolve) => {
releaseFirstInput = resolve;
});
const inputCalls: string[] = [];

vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body ?? "{}")) as {
action?: string;
data?: string;
};
if (request.action === "start") {
return new Response(JSON.stringify({ sessionId: "terminal-1" }), {
status: 201,
});
}
if (request.action === "input") {
inputCalls.push(request.data ?? "");
if (inputCalls.length === 1) await firstInput;
return new Response(null, { status: 204 });
}
if (request.action === "poll") {
return new Promise<Response>(() => {});
}
return new Response(null, { status: 204 });
}),
);

const { unmount } = render(
<Gen2TerminalPane
workspaceId={workspaceId}
visible
canStart
onExit={vi.fn()}
onResumeWorkspace={vi.fn(async () => true)}
/>,
);

fireEvent.click(screen.getByRole("button", { name: "Start terminal" }));
await waitFor(() =>
expect(terminalMocks.instances[0]?.onDataHandler).toBeDefined(),
);
const sendInput = terminalMocks.instances[0]!.onDataHandler!;
sendInput("p");
sendInput("w");
sendInput("d");

try {
await waitFor(() => expect(inputCalls).toEqual(["p"]));
expect(inputCalls).toEqual(["p"]);
} finally {
releaseFirstInput();
}

await waitFor(() => expect(inputCalls).toEqual(["p", "w", "d"]));
unmount();
});
});
14 changes: 11 additions & 3 deletions apps/web/components/gen2/terminal-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,20 @@ export function Gen2TerminalPane({
setStatus("idle");
return;
}
sessionRef.current = payload.sessionId;
const sessionId = payload.sessionId;
sessionRef.current = sessionId;
afterRef.current = 0;
setStatus("live");
let inputQueue = Promise.resolve();
term.onData((data) => {
void post({ action: "input", sessionId: payload.sessionId, data })
.then((inputResponse) => {
inputQueue = inputQueue
.then(async () => {
if (sessionRef.current !== sessionId) return;
const inputResponse = await post({
action: "input",
sessionId,
data,
});
if ([404, 502, 503].includes(inputResponse.status)) {
markWorkspacePaused();
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components/gen2/workspace-room.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ function stubFetch(instance: () => Response) {

const ready = () =>
new Response(
JSON.stringify({ workspace: { ...workspace, status: "ready" } }),
JSON.stringify({
workspace: { ...workspace, status: "ready", sandboxId: "sandbox-1" },
}),
{ status: 200 },
);

Expand Down
48 changes: 22 additions & 26 deletions apps/web/components/gen2/workspace-room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ArrowLeft, Check, Link2 } from "lucide-react";
import type { Gen2WorkspaceDetail } from "@codev/contracts";

import { Gen2ChatPanel } from "./chat-panel";
import { ensureGen2WorkspaceReady } from "@/lib/gen2/startup-client";
import { Gen2Workbench, type Gen2WorkbenchHandle } from "./workbench";

const STATUS_LABEL: Record<Gen2WorkspaceDetail["status"], string> = {
Expand All @@ -29,6 +30,7 @@ export function Gen2WorkspaceRoom({
const [refreshToken, setRefreshToken] = useState(0);
const [runtimeReady, setRuntimeReady] = useState(false);
const workbenchRef = useRef<Gen2WorkbenchHandle | null>(null);
const startupInFlightRef = useRef<Promise<boolean> | null>(null);
const ready = current.status === "ready" && runtimeReady;
const displayStatus =
current.status === "ready" && !runtimeReady
Expand All @@ -47,35 +49,29 @@ export function Gen2WorkspaceRoom({
* decides whether anything needs doing, and a second member opening the
* same workspace joins the boot already in progress.
*/
const ensureRunning = useCallback(async () => {
const ensureRunning = useCallback(() => {
if (startupInFlightRef.current) return startupInFlightRef.current;

setRuntimeReady(false);
try {
const response = await fetch(
`/api/gen2/workspaces/${current.id}/instance`,
{ method: "POST" },
);
const payload = (await response.json().catch(() => ({}))) as {
workspace?: Gen2WorkspaceDetail;
error?: string;
};
if (payload.workspace) setCurrent(payload.workspace);
if (!response.ok) {
setCurrent((value) => ({
...value,
lastError: payload.error ?? "The machine could not start.",
}));
return false;
setCurrent((value) => ({ ...value, lastError: null }));
const attempt = (async () => {
const result = await ensureGen2WorkspaceReady(current.id);
if (result.workspace) {
setCurrent(result.workspace);
setRuntimeReady(true);
refresh();
return true;
}
setRuntimeReady(true);
refresh();
return true;
} catch {
setCurrent((value) => ({
...value,
lastError: "The machine could not be reached. Try again.",
}));
setCurrent((value) => ({ ...value, lastError: result.error }));
return false;
}
})();
const tracked = attempt.finally(() => {
if (startupInFlightRef.current === tracked) {
startupInFlightRef.current = null;
}
});
startupInFlightRef.current = tracked;
return tracked;
}, [current.id, refresh]);

const bootedRef = useRef(false);
Expand Down
Loading
Loading