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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1073-test-apps-ws-test-ts-fails-5-of-8-in-the → main 14 commits fork
6 files
changed +41
−24
scripts/gate.ts −1
| @@ -40,7 +40,6 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 40 | 40 | issue: 261, |
| 41 | 41 | }, |
| 42 | 42 | { 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 }, | |
| 44 | 43 | { |
| 45 | 44 | file: "test/board-windows.test.ts", |
| 46 | 45 | reason: |
src/app.ts +2
| @@ -206,6 +206,8 @@ export interface ForgeConfig { | ||
| 206 | 206 | * The listener is bound with SO_REUSEPORT so the new process can share it. |
| 207 | 207 | */ |
| 208 | 208 | 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; | |
| 209 | 211 | /** A standby: reads are answered, every mutation is a 503 (`FORGE_READ_ONLY=1`; `http/read_only.ts`, #9657). */ |
| 210 | 212 | readOnly?: boolean; |
| 211 | 213 | /** 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>(); | ||
| 1439 | 1439 | * it the socket is closed rather than the memory kept, and the forge never |
| 1440 | 1440 | * buffers a frame on its own account. |
| 1441 | 1441 | */ |
| 1442 | const WS_MAX_MESSAGE = 16 * 1024 * 1024; | |
| 1442 | export const WS_MAX_MESSAGE = 16 * 1024 * 1024; | |
| 1443 | 1443 | /** |
| 1444 | 1444 | * Seconds a socket may go without a frame or a pong before it is dropped. |
| 1445 | 1445 | * Bun pings on its own at half this, so a quiet tab that is still there |
| @@ -1574,9 +1574,11 @@ function closeBoth( | ||
| 1574 | 1574 | * close of one side carried to the other. Text stays text and binary |
| 1575 | 1575 | * stays binary; the forge reads neither. |
| 1576 | 1576 | */ |
| 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, | |
| 1580 | 1582 | closeOnBackpressureLimit: true, |
| 1581 | 1583 | idleTimeout: WS_IDLE_SECONDS, |
| 1582 | 1584 | |
| @@ -1604,7 +1606,7 @@ export const appWebSocket: Bun.WebSocketHandler<AppSocket> = { | ||
| 1604 | 1606 | typeof data === "string" |
| 1605 | 1607 | ? Buffer.byteLength(data) |
| 1606 | 1608 | : (data as ArrayBuffer).byteLength; |
| 1607 | if (size > WS_MAX_MESSAGE) { | |
| 1609 | if (size > maxMessage) { | |
| 1608 | 1610 | closeBoth(ws, 1009, "message too big"); |
| 1609 | 1611 | return; |
| 1610 | 1612 | } |
| @@ -1649,7 +1651,7 @@ export const appWebSocket: Bun.WebSocketHandler<AppSocket> = { | ||
| 1649 | 1651 | if (code === 1006) upstream.close(1001, "the browser went away"); |
| 1650 | 1652 | else upstream.close(sendable(code) ? code : 1000, reason); |
| 1651 | 1653 | }, |
| 1652 | }; | |
| 1654 | }); | |
| 1653 | 1655 | |
| 1654 | 1656 | function grant( |
| 1655 | 1657 | runtime: AppsRuntime, |
src/http/server.ts +2 −2
| @@ -569,7 +569,7 @@ import { appsRuntime } from "../apps/runtime.ts"; | ||
| 569 | 569 | import { holdAppHost } from "../apps/holding.ts"; |
| 570 | 570 | import { isArcadeHost, serveArcade } from "../arcade/host.ts"; |
| 571 | 571 | import { |
| 572 | appWebSocket, | |
| 572 | appWebSocketWith, | |
| 573 | 573 | type AppSocket, |
| 574 | 574 | isWebSocketHandshake, |
| 575 | 575 | proxyApp, |
| @@ -2701,7 +2701,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2701 | 2701 | maxRequestBodySize: REQUEST_MAX, |
| 2702 | 2702 | // The only sockets the forge holds are an app's, carried through the |
| 2703 | 2703 | // proxy; its own live surfaces are SSE (`sse.ts`) and stay so. |
| 2704 | websocket: withShells(appWebSocket), | |
| 2704 | websocket: withShells(appWebSocketWith(app.config.wsMaxMessage)), | |
| 2705 | 2705 | fetch: handle, |
| 2706 | 2706 | // A last resort: `handle` answers a throw itself, so it is counted. |
| 2707 | 2707 | error: (error) => failure(app, error), |
src/spawn/cell.ts +1 −1
| @@ -518,7 +518,7 @@ export async function forward( | ||
| 518 | 518 | /** |
| 519 | 519 | * Carry a websocket handshake to the child's loopback port: the child's side |
| 520 | 520 | * 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`, | |
| 522 | 522 | * the forge's one pump. `undefined` means the request became a socket. |
| 523 | 523 | */ |
| 524 | 524 | export async function forwardSocket( |
test/apps-ws.test.ts +28 −14
| @@ -16,6 +16,7 @@ import { AppsRuntime } from "../src/apps/runtime.ts"; | ||
| 16 | 16 | import { |
| 17 | 17 | newApp, |
| 18 | 18 | openEnvUrl, |
| 19 | redeployEnv, | |
| 19 | 20 | stopEnv, |
| 20 | 21 | updateAppSettings, |
| 21 | 22 | } from "../src/actions/apps.ts"; |
| @@ -27,6 +28,8 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 27 | 28 | import { serve, type Servers } from "../src/http/server.ts"; |
| 28 | 29 | |
| 29 | 30 | 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; | |
| 30 | 33 | |
| 31 | 34 | /** |
| 32 | 35 | * The app under test: `/ws` echoes what it is sent, text as text and |
| @@ -218,7 +221,7 @@ describe("apps: websockets through the proxy", () => { | ||
| 218 | 221 | |
| 219 | 222 | beforeAll(async () => { |
| 220 | 223 | dataDir = await mkdtemp(join(tmpdir(), "forge-apps-ws-")); |
| 221 | app = createApp({ dataDir, port: 0 }); | |
| 224 | app = createApp({ dataDir, port: 0, wsMaxMessage: LIMIT }); | |
| 222 | 225 | servers = await serve(app); |
| 223 | 226 | runtime = new AppsRuntime(app, { |
| 224 | 227 | domain: DOMAIN, |
| @@ -405,14 +408,14 @@ describe("apps: websockets through the proxy", () => { | ||
| 405 | 408 | const inbound = connect("/ws"); |
| 406 | 409 | expect(await inbound.opened).toBe(true); |
| 407 | 410 | await seenBy(inbound.next); |
| 408 | inbound.ws.send(new Uint8Array(16 * MIB + 1)); | |
| 411 | inbound.ws.send(new Uint8Array(LIMIT + 1)); | |
| 409 | 412 | expect((await inbound.closed).code).not.toBe(1000); |
| 410 | 413 | |
| 411 | 414 | // From the app: the forge will not carry it. |
| 412 | 415 | const outbound = connect("/ws"); |
| 413 | 416 | expect(await outbound.opened).toBe(true); |
| 414 | 417 | await seenBy(outbound.next); |
| 415 | outbound.ws.send(`big:${16 * MIB + 1}`); | |
| 418 | outbound.ws.send(`big:${LIMIT + 1}`); | |
| 416 | 419 | expect(await outbound.closed).toEqual({ |
| 417 | 420 | code: 1009, |
| 418 | 421 | reason: "message too big", |
| @@ -423,10 +426,10 @@ describe("apps: websockets through the proxy", () => { | ||
| 423 | 426 | const atLimit = connect("/ws"); |
| 424 | 427 | expect(await atLimit.opened).toBe(true); |
| 425 | 428 | 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); | |
| 430 | 433 | atLimit.ws.close(); |
| 431 | 434 | }, 30_000); |
| 432 | 435 | |
| @@ -535,12 +538,23 @@ describe("apps: websockets through the proxy", () => { | ||
| 535 | 538 | await seenBy(client.next); |
| 536 | 539 | const stopped = await stopEnv(app, alice, repo, "production"); |
| 537 | 540 | 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"); | |
| 541 | 545 | |
| 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); | |
| 546 | 560 | }); |