Public: anyone can read this; the crew can change it.

test/apps-ws.test.ts fails 5 of 8 in the full gate run, passes alone #10986

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1073-test-apps-ws-test-ts-fails-5-of-8-in-the → main 14 commits fork

  • Merged. scout/1073-test-apps-ws-test-ts-fails-5-of-8-in-the was merged into main as 5e688b5.

Review

Sign in to review this change.
6 files changed +41 −24
scripts/gate.ts −1
@@ -40,7 +40,6 @@ export const EXCLUDED: readonly Exclusion[] = [
4040 issue: 261,
4141 },
4242 { file: "test/apps-sleep.test.ts", reason: "not yet admitted", issue: 261 },
43 { file: "test/apps-ws.test.ts", reason: "not yet admitted", issue: 261 },
4443 {
4544 file: "test/board-windows.test.ts",
4645 reason:
src/app.ts +2
@@ -206,6 +206,8 @@ export interface ForgeConfig {
206206 * The listener is bound with SO_REUSEPORT so the new process can share it.
207207 */
208208 selfSwap?: boolean;
209 /** The most one proxied app websocket frame may carry, either way (`apps/proxy.ts`). Tests lower it. @default 16 MiB */
210 wsMaxMessage?: number;
209211 /** A standby: reads are answered, every mutation is a 503 (`FORGE_READ_ONLY=1`; `http/read_only.ts`, #9657). */
210212 readOnly?: boolean;
211213 /** How long a swapped-in release has to read healthy (`FORGE_SWAP_BUDGET_S`). @default 60000 */
src/apps/proxy.ts +8 −6
@@ -1439,7 +1439,7 @@ const portSockets = new WeakMap<Bun.ServerWebSocket<AppSocket>, () => void>();
14391439 * it the socket is closed rather than the memory kept, and the forge never
14401440 * buffers a frame on its own account.
14411441 */
1442 const WS_MAX_MESSAGE = 16 * 1024 * 1024;
1442 export const WS_MAX_MESSAGE = 16 * 1024 * 1024;
14431443 /**
14441444 * Seconds a socket may go without a frame or a pong before it is dropped.
14451445 * Bun pings on its own at half this, so a quiet tab that is still there
@@ -1574,9 +1574,11 @@ function closeBoth(
15741574 * close of one side carried to the other. Text stays text and binary
15751575 * stays binary; the forge reads neither.
15761576 */
1577 export const appWebSocket: Bun.WebSocketHandler<AppSocket> = {
1578 maxPayloadLength: WS_MAX_MESSAGE,
1579 backpressureLimit: WS_MAX_MESSAGE,
1577 export const appWebSocketWith = (
1578 maxMessage: number = WS_MAX_MESSAGE,
1579 ): Bun.WebSocketHandler<AppSocket> => ({
1580 maxPayloadLength: maxMessage,
1581 backpressureLimit: maxMessage,
15801582 closeOnBackpressureLimit: true,
15811583 idleTimeout: WS_IDLE_SECONDS,
15821584
@@ -1604,7 +1606,7 @@ export const appWebSocket: Bun.WebSocketHandler<AppSocket> = {
16041606 typeof data === "string"
16051607 ? Buffer.byteLength(data)
16061608 : (data as ArrayBuffer).byteLength;
1607 if (size > WS_MAX_MESSAGE) {
1609 if (size > maxMessage) {
16081610 closeBoth(ws, 1009, "message too big");
16091611 return;
16101612 }
@@ -1649,7 +1651,7 @@ export const appWebSocket: Bun.WebSocketHandler<AppSocket> = {
16491651 if (code === 1006) upstream.close(1001, "the browser went away");
16501652 else upstream.close(sendable(code) ? code : 1000, reason);
16511653 },
1652 };
1654 });
16531655
16541656 function grant(
16551657 runtime: AppsRuntime,
src/http/server.ts +2 −2
@@ -569,7 +569,7 @@ import { appsRuntime } from "../apps/runtime.ts";
569569 import { holdAppHost } from "../apps/holding.ts";
570570 import { isArcadeHost, serveArcade } from "../arcade/host.ts";
571571 import {
572 appWebSocket,
572 appWebSocketWith,
573573 type AppSocket,
574574 isWebSocketHandshake,
575575 proxyApp,
@@ -2701,7 +2701,7 @@ export async function serve(app: App): Promise<Servers> {
27012701 maxRequestBodySize: REQUEST_MAX,
27022702 // The only sockets the forge holds are an app's, carried through the
27032703 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
2704 websocket: withShells(appWebSocket),
2704 websocket: withShells(appWebSocketWith(app.config.wsMaxMessage)),
27052705 fetch: handle,
27062706 // A last resort: `handle` answers a throw itself, so it is counted.
27072707 error: (error) => failure(app, error),
src/spawn/cell.ts +1 −1
@@ -518,7 +518,7 @@ export async function forward(
518518 /**
519519 * Carry a websocket handshake to the child's loopback port: the child's side
520520 * is opened first, and only then is the visitor's answered, as `proxyApp`
521 * does for an app (`apps/proxy.ts`). Frames then go through `appWebSocket`,
521 * does for an app (`apps/proxy.ts`). Frames then go through `appWebSocketWith`,
522522 * the forge's one pump. `undefined` means the request became a socket.
523523 */
524524 export async function forwardSocket(
test/apps-ws.test.ts +28 −14
@@ -16,6 +16,7 @@ import { AppsRuntime } from "../src/apps/runtime.ts";
1616 import {
1717 newApp,
1818 openEnvUrl,
19 redeployEnv,
1920 stopEnv,
2021 updateAppSettings,
2122 } from "../src/actions/apps.ts";
@@ -27,6 +28,8 @@ import { createUser, type User } from "../src/core/users.ts";
2728 import { serve, type Servers } from "../src/http/server.ts";
2829
2930 const MIB = 1024 * 1024;
31 /** The forge's frame limit for this file: the real one is 16 MiB, and moving that much three times took ~30 s under load (#1073). */
32 const LIMIT = 2 * MIB;
3033
3134 /**
3235 * The app under test: `/ws` echoes what it is sent, text as text and
@@ -218,7 +221,7 @@ describe("apps: websockets through the proxy", () => {
218221
219222 beforeAll(async () => {
220223 dataDir = await mkdtemp(join(tmpdir(), "forge-apps-ws-"));
221 app = createApp({ dataDir, port: 0 });
224 app = createApp({ dataDir, port: 0, wsMaxMessage: LIMIT });
222225 servers = await serve(app);
223226 runtime = new AppsRuntime(app, {
224227 domain: DOMAIN,
@@ -405,14 +408,14 @@ describe("apps: websockets through the proxy", () => {
405408 const inbound = connect("/ws");
406409 expect(await inbound.opened).toBe(true);
407410 await seenBy(inbound.next);
408 inbound.ws.send(new Uint8Array(16 * MIB + 1));
411 inbound.ws.send(new Uint8Array(LIMIT + 1));
409412 expect((await inbound.closed).code).not.toBe(1000);
410413
411414 // From the app: the forge will not carry it.
412415 const outbound = connect("/ws");
413416 expect(await outbound.opened).toBe(true);
414417 await seenBy(outbound.next);
415 outbound.ws.send(`big:${16 * MIB + 1}`);
418 outbound.ws.send(`big:${LIMIT + 1}`);
416419 expect(await outbound.closed).toEqual({
417420 code: 1009,
418421 reason: "message too big",
@@ -423,10 +426,10 @@ describe("apps: websockets through the proxy", () => {
423426 const atLimit = connect("/ws");
424427 expect(await atLimit.opened).toBe(true);
425428 await seenBy(atLimit.next);
426 atLimit.ws.send(`big:${16 * MIB}`);
427 expect(bytes(await atLimit.next()).length).toBe(16 * MIB);
428 atLimit.ws.send(new Uint8Array(16 * MIB));
429 expect(bytes(await atLimit.next()).length).toBe(16 * MIB);
429 atLimit.ws.send(`big:${LIMIT}`);
430 expect(bytes(await atLimit.next()).length).toBe(LIMIT);
431 atLimit.ws.send(new Uint8Array(LIMIT));
432 expect(bytes(await atLimit.next()).length).toBe(LIMIT);
430433 atLimit.ws.close();
431434 }, 30_000);
432435
@@ -535,12 +538,23 @@ describe("apps: websockets through the proxy", () => {
535538 await seenBy(client.next);
536539 const stopped = await stopEnv(app, alice, repo, "production");
537540 expect(stopped.ok).toBe(true);
538 // 1001, "going away"; Bun's own client reports the code as 1000 and
539 // keeps the reason, so the reason is what is asserted.
540 expect((await client.closed).reason).toBe("the app stopped answering");
541 try {
542 // 1001, "going away"; Bun's own client reports the code as 1000 and
543 // keeps the reason, so the reason is what is asserted.
544 expect((await client.closed).reason).toBe("the app stopped answering");
541545
542 const page = await handshake(HOST, "/ws");
543 expect(page.status).toBe(502);
544 expect(await page.text()).toContain("not running");
545 }, 30_000);
546 const page = await handshake(HOST, "/ws");
547 expect(page.status).toBe(502);
548 expect(await page.text()).toContain("not running");
549 } finally {
550 // The tests run in any order (the gate shuffles them): the app comes
551 // back up for whichever runs next.
552 const again = await redeployEnv(app, alice, repo, "production");
553 if (!again.ok || again.value === undefined)
554 throw new Error("the app could not be brought back");
555 const id = again.value.id;
556 const run = await settled((r) => r.id === id);
557 if (run.state !== "ok") throw new Error(`redeploy failed: ${run.summary}`);
558 }
559 }, 90_000);
546560 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs