Skip to content

Commit 8bb4ba6

Browse files
authored
test(web): mark three open server-function gaps as expected failures (#3112)
* test(web): mark three open server-function gaps as expected failures Each is verified against `next` and stated as the behaviour that is wanted, so the suite stays green while the gap is open and turns red the day it closes. - A result the codec cannot encode is delivered as `undefined`. The function already ran; only the encoding failed, and it failed after the head was committed, so the status is spent and no error tag can be added. The truncated body decodes to the same answer a void function gives, so a write that succeeded is indistinguishable from one that returned nothing. - A streamed result has no backpressure: the stream is built with no `pull` and no queuing strategy, and every codec node is enqueued as soon as it is parsed. A consumer reading three chunks over 60ms left the producer 3695 items ahead; on a large or infinite stream one slow client buffers the whole result in server memory. - The decode depth cap guards the seroval path only, and the body format is chosen by the caller, so selecting the JSON format opts out of it: depth 5000 decodes where the capped path answers 400. `.fails` rather than the repo's `test.skip` idiom because the point is to notice the fix. Tests only; no runtime change, so no changeset. * test(web): fix the depth gap's header, and make two assertions honest Review of the first commit turned up three things, one of which made a test worthless: - BODY_FORMAT_HEADER is not exported from the server entry, so the import was `undefined` and the request carried a header literally named "undefined". The JSON format was never selected and the function received no argument at all: the handler answered {"depth":0} where the gap needs {"depth":500}. The test failed, but not for its own reason — exactly the failure mode `.fails` cannot show you. Local const, as `server-functions-failure-signal.spec.tsx` already does. - Depth 5000 sat on the repo's own cliff (shared.ts notes ~5900 nested objects overflow V8's default stack on CI). 500 is comfortably past the 64-level cap and nowhere near it. - The backpressure ceiling was wall-clock, and under `.fails` a starved CI that produced fewer than 500 would have turned red for no reason. Counted in event-loop turns instead: bounded stays near the queue size on any machine, unbounded tracks the turn count. Also adopts MATRIX.md's spelling (`test.fails` with a `// GAP:` comment), which is the repo's documented idiom for this and which the first commit wrongly described as absent, and drops a dead assertion after rejects.toThrow(). * test(web): point each gap marker at the issue tracking it MATRIX.md's convention is a `// GAP:` comment; naming the issue in it means whoever closes one finds the test that turns red.
1 parent 21cf936 commit 8bb4ba6

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* Gaps that are open on `next` today. Each test states the behaviour that
3+
* is wanted and is marked `test.fails`, per the convention in
4+
* `test/lifecycle-matrix/MATRIX.md`: the marker is the point — the suite
5+
* stays green while the gap is open and turns red the day it closes, at
6+
* which point the marker comes off and the test becomes an ordinary guard.
7+
* Each carries the issue that tracks it: #3117, #3118, #3119.
8+
*
9+
* Like the other server-function specs, these run against the built
10+
* bundles (server-functions/dist/*, wired up in vite.config.server.mjs).
11+
*/
12+
import { AsyncLocalStorage } from "node:async_hooks";
13+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
14+
import {
15+
handleServerFunctionRequest,
16+
registerServerFunction
17+
} from "@solidjs/web/server-functions/server";
18+
import { createServerReference } from "@solidjs/web/server-functions/client";
19+
20+
const RequestContext = Symbol.for("solid.RequestContext");
21+
const BODY_FORMAT_HEADER = "X-Server-Function-Format";
22+
23+
beforeAll(() => {
24+
(globalThis as any)[RequestContext] = new AsyncLocalStorage();
25+
});
26+
27+
afterAll(() => {
28+
delete (globalThis as any)[RequestContext];
29+
});
30+
31+
function scriptedPost(id: string) {
32+
return new Request(`https://app.example/_server/data/${id}`, {
33+
method: "POST",
34+
body: "[]",
35+
headers: {
36+
"Sec-Fetch-Site": "same-origin",
37+
"X-Server-Function-Instance": "server-function:test"
38+
}
39+
});
40+
}
41+
42+
/**
43+
* Routes the client stub through the handler the way a socket does: the
44+
* body is drained into a buffer first, so a stream that errors mid-flight
45+
* arrives as a TRUNCATED body rather than as a live exception. That is the
46+
* difference between an in-process call and a deployed one, and it is the
47+
* difference this gap hides behind.
48+
*/
49+
function connectBufferedTransport() {
50+
const original = globalThis.fetch;
51+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
52+
const address = input instanceof Request ? input.url : input.toString();
53+
const request = new Request(new URL(address, "https://app.example"), init);
54+
request.headers.set("Sec-Fetch-Site", "same-origin");
55+
const response = await handleServerFunctionRequest(request);
56+
57+
const chunks: Uint8Array[] = [];
58+
try {
59+
for await (const chunk of response.body ?? []) chunks.push(chunk as Uint8Array);
60+
} catch {
61+
/* the wire cut here; whatever arrived is what the client sees */
62+
}
63+
return new Response(chunks.length ? Buffer.concat(chunks) : null, {
64+
status: response.status,
65+
headers: response.headers
66+
});
67+
}) as typeof fetch;
68+
return () => {
69+
globalThis.fetch = original;
70+
};
71+
}
72+
73+
describe("a result the codec cannot encode", () => {
74+
// GAP (#3117): the caller receives `undefined`. The function already ran and
75+
// committed its side effects; only the ENCODING failed, and it failed
76+
// after the head was committed, so the status is spent and no error tag
77+
// can be added. The truncated body decodes to the answer a void function
78+
// gives, so a write that succeeded is indistinguishable from one that
79+
// returned nothing — and a data layer may retry it.
80+
test.fails("reaches the caller as a failure rather than as undefined", async () => {
81+
let ran = 0;
82+
registerServerFunction("gap-encode-failure", async () => {
83+
ran++;
84+
return {
85+
ok: true,
86+
get unencodable() {
87+
throw new Error("cannot encode");
88+
}
89+
};
90+
});
91+
92+
const restore = connectBufferedTransport();
93+
let outcome: { resolved: true; value: unknown } | { resolved: false; error: unknown };
94+
try {
95+
outcome = { resolved: true, value: await createServerReference("gap-encode-failure")() };
96+
} catch (error) {
97+
outcome = { resolved: false, error };
98+
} finally {
99+
restore();
100+
}
101+
102+
expect(ran).toBe(1);
103+
expect(outcome.resolved).toBe(false);
104+
expect((outcome as { error: unknown }).error).toBeInstanceOf(Error);
105+
});
106+
});
107+
108+
describe("a streamed result nobody is reading", () => {
109+
// GAP (#3118): the producer runs unboundedly ahead. The response stream is built
110+
// with no `pull` and no queuing strategy, and every codec node is
111+
// enqueued the moment it is parsed, so the producer runs as fast as it
112+
// can resolve whether or not anyone reads. On a large or infinite stream
113+
// one slow client buffers the whole result in server memory, invisibly
114+
// to application code.
115+
//
116+
// Counted in event-loop turns rather than wall-clock: a bounded producer
117+
// stays near the queue size whatever the machine, an unbounded one
118+
// tracks the turn count.
119+
test.fails("does not let the producer run ahead of the consumer", async () => {
120+
let produced = 0;
121+
registerServerFunction("gap-backpressure", async function* () {
122+
while (produced < 100_000) {
123+
produced++;
124+
yield { n: produced };
125+
await new Promise(resolve => setImmediate(resolve));
126+
}
127+
});
128+
129+
const response = await handleServerFunctionRequest(scriptedPost("gap-backpressure"));
130+
const reader = response.body!.getReader();
131+
await reader.read();
132+
for (let turn = 0; turn < 200; turn++) {
133+
await new Promise(resolve => setImmediate(resolve));
134+
}
135+
await reader.cancel();
136+
137+
expect(produced).toBeLessThan(50);
138+
});
139+
});
140+
141+
describe("the decode depth cap", () => {
142+
// GAP (#3119): the cap is opt-out. `depthLimit: 64` exists "because payloads may
143+
// come from an untrusted peer" and guards the seroval path only, while
144+
// the body format is chosen by the CALLER — selecting the JSON format
145+
// hands the payload to a bare JSON.parse and skips the cap entirely.
146+
test.fails("holds whichever body format the caller selects", async () => {
147+
registerServerFunction("gap-depth", async (value: unknown) => {
148+
let depth = 0;
149+
let cursor: any = value;
150+
while (cursor && typeof cursor === "object" && "a" in cursor) {
151+
depth++;
152+
cursor = cursor.a;
153+
}
154+
return { depth };
155+
});
156+
157+
// comfortably past the 64-level cap, and well short of the ~5900
158+
// nested objects that overflow V8's default stack in JSON.stringify
159+
const root: any = {};
160+
let cursor = root;
161+
for (let i = 0; i < 500; i++) {
162+
cursor.a = {};
163+
cursor = cursor.a;
164+
}
165+
166+
const response = await handleServerFunctionRequest(
167+
new Request("https://app.example/_server/data/gap-depth", {
168+
method: "POST",
169+
body: JSON.stringify([root]),
170+
headers: {
171+
"Sec-Fetch-Site": "same-origin",
172+
"X-Server-Function-Instance": "server-function:test",
173+
[BODY_FORMAT_HEADER]: "8"
174+
}
175+
})
176+
);
177+
178+
// the capped path answers 400 for a payload past the limit
179+
expect(response.status).toBe(400);
180+
});
181+
});

0 commit comments

Comments
 (0)