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
53 changes: 51 additions & 2 deletions apps/paddock/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ beforeEach(async () => {
}),
dbPath: ":memory:",
};
ctx = { config, db: new Db(":memory:"), cipher: await Cipher.from(config.secret) };
ctx = { config, db: new Db(":memory:"), cipher: await Cipher.from(config.secret), buildId: BUILD };
route = buildRouter(ctx);
upstream = [];
hub.reset();
Expand Down Expand Up @@ -264,9 +264,13 @@ async function guestSession(paddockId: string, conversationId = "c1"): Promise<s
return `paddock_session=${encodeURIComponent(token)}`;
}

function call(cookie: string | null, method: string, path: string, body?: unknown): Promise<Response> {
/** The build id this fake server is, and what a current tab sends. */
const BUILD = "build-1";

function call(cookie: string | null, method: string, path: string, body?: unknown, build: string | null = BUILD): Promise<Response> {
const headers: Record<string, string> = {};
if (cookie) headers.cookie = cookie;
if (build) headers["x-paddock-build"] = build;
if (body !== undefined) headers["content-type"] = "application/json";
return route(new Request(`http://paddock.test${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }));
}
Expand Down Expand Up @@ -305,6 +309,51 @@ async function addComputer(cookie: string, name?: string): Promise<string> {
return ((await res.json()) as { data: { id: string } }).data.id;
}

describe("a tab running an older build", () => {
test("every answer names the build, refusals included", async () => {
const owner = await paddockFor(OWNER);
expect((await call(owner.cookie, "GET", `/f/${owner.id}/api/conversations`)).headers.get("x-paddock-build")).toBe(BUILD);
expect((await call(owner.cookie, "GET", "/api/config")).headers.get("x-paddock-build")).toBe(BUILD);
expect((await call(null, "GET", "/api/nope")).headers.get("x-paddock-build")).toBe(BUILD);
const cfg = (await (await call(null, "GET", "/api/config")).json()) as { buildId: string };
expect(cfg.buildId).toBe(BUILD);
});

test("is turned away from the strip, and nowhere else", async () => {
const owner = await paddockFor(OWNER);
for (const stale of [null, "build-0"]) {
const res = await call(owner.cookie, "GET", `/f/${owner.id}/api/conversations`, undefined, stale);
expect(res.status).toBe(409);
expect(((await res.json()) as { error: string }).error).toBe("stale_client");
// The rest of the machine still works for it: the tab it is on, the
// files, a prompt. Refusing those would break the tab harder than the
// loop being stopped ever did.
expect((await call(owner.cookie, "GET", `/f/${owner.id}/api/conversations/c1`, undefined, stale)).status).toBe(200);
expect((await call(owner.cookie, "GET", `/f/${owner.id}/api/sandboxes/${BOX}/files?path=/`, undefined, stale)).status).toBe(200);
expect((await call(owner.cookie, "POST", `/f/${owner.id}/api/conversations/c1/prompts`, { prompt: "hi" }, stale)).status).toBe(200);
}
});

test("an unstamped server turns nobody away", async () => {
ctx.buildId = undefined;
const owner = await paddockFor(OWNER);
const res = await call(owner.cookie, "GET", `/f/${owner.id}/api/conversations`, undefined, null);
expect(res.status).toBe(200);
expect(res.headers.get("x-paddock-build")).toBeNull();
});

test("the HTML it serves carries the build it belongs to", async () => {
const dir = join(tmpdir(), `paddock-static-${randomToken(6)}`);
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "index.html"), "<!doctype html><html><head><title>p</title></head><body></body></html>");
ctx.config = { ...ctx.config, staticDir: dir };
const html = await (await call(null, "GET", "/anything")).text();
expect(html).toContain(`<head><meta name="paddock-build" content="${BUILD}">`);
rmSync(dir, { recursive: true, force: true });
});
});

describe("one Fountain call per burst", () => {
const lists = () => upstream.filter((u) => u.method === "GET" && u.path === "/api/conversations");

Expand Down
51 changes: 48 additions & 3 deletions apps/paddock/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function buildRouter(ctx: AppContext): (req: Request) => Promise<Response
return handleProxy(ctx, req, p.id!, "/" + (p.rest ?? ""), id);
});

return async (req: Request): Promise<Response> => {
const dispatch = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const path = url.pathname;
try {
Expand All @@ -115,8 +115,27 @@ export function buildRouter(ctx: AppContext): (req: Request) => Promise<Response
return errorResponse(err);
}
};

// Every answer says which build gave it, so a tab running an older one can
// tell (`src/lib/build.ts`). Errors and streams included: the first answer
// an old tab gets after a deploy is as likely to be a refusal as anything.
return async (req: Request): Promise<Response> => {
const res = await dispatch(req);
if (ctx.buildId) {
try {
res.headers.set(BUILD_HEADER, ctx.buildId);
} catch {
// An immutable header set (a response handed straight through from
// fetch). The next answer will carry it.
}
}
return res;
};
}

/** The request header a browser sends naming the build it is running. */
export const BUILD_HEADER = "x-paddock-build";

/** `:name` captures a segment; a trailing `*` captures the rest as `rest`. */
function match(pattern: string[], segments: string[]): Record<string, string> | null {
const params: Record<string, string> = {};
Expand All @@ -142,7 +161,33 @@ async function serveStatic(ctx: AppContext, path: string): Promise<Response> {
if (rel.includes("..")) return json({ error: "not_found" }, 404);
const file = Bun.file(`${ctx.config.staticDir}/${rel}`);
if (await file.exists()) return new Response(file);
const index = Bun.file(`${ctx.config.staticDir}/index.html`);
if (await index.exists()) return new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } });
const html = await indexHtml(ctx);
if (html !== null) return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
return json({ error: "not_found" }, 404);
}

let indexCache: { dir: string | null; buildId: string | undefined; html: string | null } | null = null;

/**
* The SPA's shell, stamped with the build it belongs to.
*
* The bundle learns its own build id from this tag rather than from its first
* API answer, because a deploy that lands between the HTML and that first call
* would otherwise teach an old bundle the new id — and it would never reload.
* Read once; the file does not change under a running server.
*/
async function indexHtml(ctx: AppContext): Promise<string | null> {
const dir = ctx.config.staticDir;
if (indexCache && indexCache.dir === dir && indexCache.buildId === ctx.buildId) return indexCache.html;
const index = Bun.file(`${dir}/index.html`);
let html: string | null = null;
if (await index.exists()) {
html = await index.text();
if (ctx.buildId) {
const tag = `<meta name="paddock-build" content="${ctx.buildId}">`;
html = html.includes("<head>") ? html.replace("<head>", `<head>${tag}`) : `${tag}${html}`;
}
}
indexCache = { dir, buildId: ctx.buildId, html };
return html;
}
2 changes: 1 addition & 1 deletion apps/paddock/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function config(ctx: AppContext): Response {
// `anonymousStart` is what tells the SPA whether to start a computer or show
// the sign-in screen. It is a capability of the deployment, not of the
// caller, which is why it sits on the unauthenticated config route.
return json({ fountainUrl: ctx.config.fountainUrl, anonymousStart: ctx.config.anonymousStart });
return json({ fountainUrl: ctx.config.fountainUrl, anonymousStart: ctx.config.anonymousStart, buildId: ctx.buildId ?? null });
}

/**
Expand Down
7 changes: 7 additions & 0 deletions apps/paddock/server/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface AppContext {
db: Db;
cipher: Cipher;
config: Config;
/**
* Which build this server is, stamped on every response and into the HTML
* it serves so a tab left open across a deploy notices and reloads (see
* `src/lib/build.ts`). Absent means unstamped — the dev server, and tests
* that do not care.
*/
buildId?: string;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions apps/paddock/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,23 @@ const ctx: AppContext = {
config,
db: new Db(config.dbPath),
cipher: await Cipher.from(config.secret),
buildId: await buildIdOf(config.staticDir),
};

const fetch = buildRouter(ctx);

/**
* The build id is the built HTML's hash. Vite writes the bundle's content
* hashes into `index.html`, so any change to the client changes this, and a
* server-only change does not make every open tab reload for nothing.
*/
async function buildIdOf(staticDir: string | null): Promise<string | undefined> {
if (!staticDir) return undefined;
const index = Bun.file(`${staticDir}/index.html`);
if (!(await index.exists())) return undefined;
return Bun.hash(await index.text()).toString(36);
}

Bun.serve({ port: config.port, fetch, idleTimeout: 0 });

/**
Expand Down
12 changes: 12 additions & 0 deletions apps/paddock/server/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { HttpError, readJson, str } from "./http";
import { withPromptLock } from "./prompt-lock";
import { hub } from "./hub";
import { agentHint, cached, forget, keyFor, rememberAgent } from "./machine-cache";
import { BUILD_HEADER } from "./app";

/** What a role may do to one tab. Anything absent is a 404. */
function tabAllowed(method: string, sub: string, role: Role, claimed: boolean): boolean {
Expand Down Expand Up @@ -135,6 +136,17 @@ export async function handleProxy(ctx: AppContext, req: Request, paddockId: stri
// Filtered, always. The owner's raw conversation list would show a guest
// every other conversation on the account, which is nobody's business here.
if (method === "GET" && path === "/api/conversations") {
// The strip is what every tab polls, so it is where a tab running an
// older build is turned away. A current build reloads on the mismatch it
// sees in the response header; an older one, which knows nothing of
// builds, logs a failed poll and keeps the strip it has — and, crucially,
// stops. The bundle this replaced reopened its stream on every successful
// poll and replayed the tab's history each time, and a server fix that
// made the poll faster made that loop faster; refusing the poll is the
// one lever the server has over a tab nobody is going to find and reload.
if (ctx.buildId && req.headers.get(BUILD_HEADER) !== ctx.buildId) {
throw new HttpError(409, "stale_client", "This tab is running an older paddock. Reload the page.");
}
const tabs = await visibleTabs(client, here, allowed);
return jsonRes({ data: tabs.map((t) => t.conversation) });
}
Expand Down
Loading
Loading