Skip to content

Commit aa77591

Browse files
wan9chiclaude
andcommitted
test(cache): number remote cache blobs and corrupt them through the backend
Cases referred to blobs through the test backend's storage: its blob IDs were sequential, and `corrupt_archive` overwrote `remote-cache/blobs/1` directly. A backend that stores blobs differently, such as the public cache service with its random blob IDs, couldn't run those cases. The tap now numbers blobs in upload order in the request lines, and `remote-cache-server corrupt-blob NUMBER` overwrites a stored blob through the backend. The test backend uses random UUIDs as blob IDs, like the service. The only snapshot change is `corrupt_archive`'s command line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 42c30dc commit aa77591

6 files changed

Lines changed: 70 additions & 28 deletions

File tree

‎crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,9 @@ steps = [
306306
],
307307
] },
308308
{ argv = [
309-
"vtt",
310-
"write-file",
311-
"remote-cache/blobs/1",
312-
"corrupt",
309+
"remote-cache-server",
310+
"corrupt-blob",
311+
"1",
313312
], comment = "Overwrite the stored archive." },
314313
[
315314
"vt",

‎crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/corrupt_archive.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ $ vtt write-file dist/output.txt built
1414
[remote-cache] POST /store 200
1515
```
1616

17-
## `vtt write-file remote-cache/blobs/1 corrupt`
17+
## `remote-cache-server corrupt-blob 1`
1818

1919
Overwrite the stored archive.
2020

‎packages/tools/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ An E2E case starts its own backend in its first step and stops it in its last, s
1414

1515
- `remote-cache-server start` starts the backend in the background on free loopback ports and returns once it's ready. The backend runs in its own session and doesn't use the terminal, so the step can finish and Ctrl-C in later steps doesn't reach it. It writes its endpoint, `http://127.0.0.1:<port>/projects/test`, to `remote-cache/server.json`, and its output to `remote-cache/server.log`.
1616
- `remote-cache-server run COMMAND [ARGS...]` runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint. The fixed base path gives the endpoint a namespace path. The command inherits stdio and handles Ctrl-C, which `run` ignores. `run` exits with the command's exit code.
17+
- `remote-cache-server corrupt-blob NUMBER` overwrites a stored blob, numbered as in the request lines, with other bytes.
1718
- `remote-cache-server stop` stops the backend. It fails if the backend answered a request with a 5xx status or couldn't be reached.
1819

1920
A backend whose case directory disappears stops by itself, as does one that has had no requests for ten minutes, e.g. because its case timed out before the stop step.
2021

21-
The endpoint is a tap in front of the backend. It forwards every request and response unchanged and records a line for each response, in the order of the responses. Each line has the method, the path below the base path, and the status. Successful fetch responses add their kind. After the command exits, `run` prints the lines for the requests it caused to stderr, and `stop` prints any that are left:
22+
The endpoint is a tap in front of the backend. It forwards every request and response unchanged and records a line for each response, in the order of the responses. Each line has the method, the path below the base path, and the status. Successful fetch responses add their kind. Blob IDs are random, so blob paths show the blob's number in upload order instead, keeping snapshots deterministic. After the command exits, `run` prints the lines for the requests it caused to stderr, and `stop` prints any that are left:
2223

2324
```text
2425
[remote-cache] POST /fetch 404
@@ -27,7 +28,7 @@ The endpoint is a tap in front of the backend. It forwards every request and res
2728
[remote-cache] GET /blob/1 200
2829
```
2930

30-
The backend keeps its state in `remote-cache/`. `state.json` holds the entries, associations, and next blob ID, with keys and values hex-encoded. Each blob is a file in `remote-cache/blobs/` named by its blob ID. Blob IDs are sequential strings, keeping snapshots deterministic.
31+
The backend keeps its state in `remote-cache/`. `state.json` holds the entries and associations, with keys and values hex-encoded. Each blob is a file in `remote-cache/blobs/` named by its blob ID, a random UUID.
3132

3233
The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache server API](https://github.com/voidzero-dev/vite-task/pull/713). A fetch that matches neither key gets a `404` with the plain-text body `Not found`. Keys, values, and blobs are opaque bytes without length limits. There is no authentication.
3334

‎packages/tools/src/remote-cache/backend.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Busboy } from '@fastify/busboy';
22
import { decode } from 'cbor2/decoder';
33
import { encode } from 'cbor2/encoder';
4+
import { randomUUID } from 'node:crypto';
45
import { once } from 'node:events';
56
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
67
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
@@ -14,7 +15,6 @@ interface Entry {
1415

1516
/** The contents of `state.json`. Keys and values are hex-encoded. */
1617
interface State {
17-
next_blob_id: number;
1818
entries: Record<string, Entry>;
1919
associations: Record<string, string>;
2020
}
@@ -100,14 +100,16 @@ function cbor(response: ServerResponse, value: unknown): void {
100100
export interface Backend {
101101
/** Where the backend listens, e.g. `http://127.0.0.1:1234`, without a path. */
102102
origin: string;
103+
/** Replace the contents of the blob with ID `id`. */
104+
writeBlob(id: string, contents: Uint8Array): Promise<void>;
103105
close(): Promise<void>;
104106
}
105107

106108
/**
107109
* Start a test backend on a free loopback port that keeps its state in
108110
* `directory`: entries and associations in `state.json`, and each blob in
109-
* `blobs/` under its ID. Keys, values, and blobs remain opaque bytes. A fetch
110-
* that matches neither key gets a plain-text 404.
111+
* `blobs/` under its ID, a random UUID. Keys, values, and blobs remain opaque
112+
* bytes. A fetch that matches neither key gets a plain-text 404.
111113
*/
112114
export async function startBackend({
113115
basePath,
@@ -120,10 +122,9 @@ export async function startBackend({
120122
const blobDirectory = join(directory, 'blobs');
121123
const state: State = existsSync(stateFile)
122124
? JSON.parse(readFileSync(stateFile, 'utf8'))
123-
: { next_blob_id: 1, entries: {}, associations: {} };
125+
: { entries: {}, associations: {} };
124126
const entries = new Map(Object.entries(state.entries));
125127
const associations = new Map(Object.entries(state.associations));
126-
let nextBlobId = state.next_blob_id;
127128

128129
async function handle(
129130
request: IncomingMessage,
@@ -182,12 +183,11 @@ export async function startBackend({
182183
const key = toHex(fields.get('key')!);
183184
const blob = parts.get('blob');
184185
mkdirSync(blobDirectory, { recursive: true });
185-
const blobId = blob === undefined ? null : String(nextBlobId++);
186+
const blobId = blob === undefined ? null : randomUUID();
186187
if (blobId !== null) writeFileSync(join(blobDirectory, blobId), blob!);
187188
entries.set(key, { value: toHex(fields.get('value')!), blob_id: blobId });
188189
associations.set(toHex(fields.get('secondary_key')!), key);
189190
const saved: State = {
190-
next_blob_id: nextBlobId,
191191
entries: Object.fromEntries(entries),
192192
associations: Object.fromEntries(associations),
193193
};
@@ -212,6 +212,7 @@ export async function startBackend({
212212
const { port } = server.address() as AddressInfo;
213213
return {
214214
origin: `http://127.0.0.1:${port}`,
215+
writeBlob: async (id, contents) => writeFileSync(join(blobDirectory, id), contents),
215216
close: () =>
216217
new Promise((resolve) => {
217218
server.close(() => resolve());

‎packages/tools/src/remote-cache/cli.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { resolve } from 'node:path';
88
import { fileURLToPath } from 'node:url';
99
import { logFile, serverFile, stateDirectory, type ServerInfo } from './state.ts';
1010

11-
const usage = 'Usage: remote-cache-server start | run COMMAND [ARGS...] | stop';
11+
const usage =
12+
'Usage: remote-cache-server start | run COMMAND [ARGS...] | corrupt-blob NUMBER | stop';
1213
const directory = resolve(stateDirectory);
1314

1415
function fail(message: string): never {
@@ -23,12 +24,19 @@ function readInfo(): ServerInfo {
2324
return JSON.parse(readFileSync(serverFile(directory), 'utf8')) as ServerInfo;
2425
}
2526

26-
/** POST to the backend's control server and return the JSON response. */
27-
function control<T>(info: ServerInfo, path: string): Promise<T> {
27+
/**
28+
* POST to the backend's control server and return the JSON response. A 404
29+
* fails with `notFound`.
30+
*/
31+
function control<T>(info: ServerInfo, path: string, notFound = 'unknown request'): Promise<T> {
2832
return new Promise((resolve) => {
2933
const unreachable = (error: Error) =>
3034
fail(`the backend is unreachable (${error.message}). See ${logFile(stateDirectory)}.`);
3135
const call = request(`${info.control}${path}`, { method: 'POST', agent: false }, (response) => {
36+
if (response.statusCode === 404) fail(notFound);
37+
if (response.statusCode !== 200) {
38+
fail(`the backend failed (${response.statusCode}). See ${logFile(stateDirectory)}.`);
39+
}
3240
const chunks: Buffer[] = [];
3341
response.on('data', (chunk: Buffer) => chunks.push(chunk));
3442
response.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()) as T));
@@ -79,6 +87,11 @@ async function run([command, ...args]: string[]): Promise<void> {
7987
process.exitCode = code;
8088
}
8189

90+
async function corruptBlob([number]: string[]): Promise<void> {
91+
if (number === undefined || !/^[1-9]\d*$/.test(number)) fail(usage);
92+
await control(readInfo(), `/corrupt-blob/${number}`, `no blob ${number} has been stored.`);
93+
}
94+
8295
async function stop(): Promise<void> {
8396
const { requests, anomalies } = await control<{ requests: string[]; anomalies: string[] }>(
8497
readInfo(),
@@ -92,5 +105,6 @@ async function stop(): Promise<void> {
92105
const [subcommand, ...args] = process.argv.slice(2);
93106
if (subcommand === 'start') await start();
94107
else if (subcommand === 'run') await run(args);
108+
else if (subcommand === 'corrupt-blob') await corruptBlob(args);
95109
else if (subcommand === 'stop') await stop();
96110
else fail(usage);

‎packages/tools/src/remote-cache/daemon.ts‎

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { basePath, serverFile, type ServerInfo } from './state.ts';
88

99
// The process that `remote-cache-server start` leaves running for one e2e case.
1010
// It serves the backend at the endpoint through a tap that forwards requests
11-
// and responses unchanged and records a line for each response. A control
12-
// server hands those lines to `remote-cache-server run` and `stop`.
11+
// and responses unchanged and records a line for each response. Blob IDs in
12+
// the lines become numbers in upload order, so they're the same on every run.
13+
// A control server hands the lines to `remote-cache-server run` and `stop`,
14+
// and changes blobs for `remote-cache-server corrupt-blob`.
1315

1416
const directory = process.argv[2]!;
1517
/** Stop after this long without requests, e.g. when a case timed out before its stop step. */
@@ -28,31 +30,38 @@ const backend = await startBackend({ basePath, directory });
2830
const requests: string[] = [];
2931
/** Problems on the backend's side, which make `stop` fail. */
3032
const anomalies: string[] = [];
33+
/** The ID of each stored blob, in upload order. Blob number `n` is at `n - 1`. */
34+
const blobs: string[] = [];
3135
let lastUse = Date.now();
3236
let stopping = false;
3337

3438
function endToEnd(headers: IncomingHttpHeaders): IncomingHttpHeaders {
3539
return Object.fromEntries(Object.entries(headers).filter(([name]) => !hopByHop.has(name)));
3640
}
3741

38-
/** The kind of a successful fetch response. */
39-
function fetchKind(body: Buffer): string | undefined {
42+
/** The fields of a CBOR map. */
43+
function cborFields(body: Buffer): Record<string, unknown> {
4044
try {
4145
const value: unknown = decode(body);
42-
if (typeof value === 'object' && value !== null && 'kind' in value) {
43-
return typeof value.kind === 'string' ? value.kind : undefined;
44-
}
46+
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>;
4547
} catch {
4648
// Not CBOR. vp reports the malformed response itself.
4749
}
48-
return undefined;
50+
return {};
51+
}
52+
53+
/** `route` with a stored blob's ID replaced by its number. */
54+
function numbered(route: string): string {
55+
const id = /^\/blob\/(.+)$/.exec(route)?.[1];
56+
const index = id === undefined ? -1 : blobs.indexOf(id);
57+
return index === -1 ? route : `/blob/${index + 1}`;
4958
}
5059

5160
const tap = createServer((request, response) => {
5261
lastUse = Date.now();
5362
const path = new URL(request.url ?? '/', 'http://localhost').pathname;
5463
const route = path.startsWith(basePath) ? path.slice(basePath.length) : path;
55-
const label = `${request.method} ${route}`;
64+
const label = `${request.method} ${numbered(route)}`;
5665
let clientGone = false;
5766
const upstream = forward(
5867
`${backend.origin}${request.url}`,
@@ -61,13 +70,17 @@ const tap = createServer((request, response) => {
6170
const status = reply.statusCode!;
6271
if (status >= 500) anomalies.push(`${label} got ${status} from the backend`);
6372
response.writeHead(status, endToEnd(reply.headers));
73+
// Fetch and store responses are small CBOR maps with the kind or blob ID.
6474
const chunks: Buffer[] = [];
65-
if (route === '/fetch' && status === 200) {
75+
if ((route === '/fetch' || route === '/store') && status === 200) {
6676
reply.on('data', (chunk: Buffer) => chunks.push(chunk));
6777
}
6878
reply.pipe(response);
6979
response.on('finish', () => {
70-
const kind = chunks.length > 0 ? fetchKind(Buffer.concat(chunks)) : undefined;
80+
const fields = chunks.length > 0 ? cborFields(Buffer.concat(chunks)) : {};
81+
const blobId = fields['blob_id'];
82+
if (route === '/store' && typeof blobId === 'string') blobs.push(blobId);
83+
const kind = route === '/fetch' ? fields['kind'] : undefined;
7184
requests.push([label, status, kind].filter((part) => part !== undefined).join(' '));
7285
});
7386
},
@@ -94,8 +107,22 @@ const control = createServer((request, response) => {
94107
response.writeHead(200, { 'content-type': 'application/json' });
95108
response.end(JSON.stringify(value));
96109
};
110+
const corrupt = /^\/corrupt-blob\/(\d+)$/.exec(request.url ?? '')?.[1];
97111
if (request.method === 'POST' && request.url === '/take') {
98112
reply(requests.splice(0));
113+
} else if (request.method === 'POST' && corrupt !== undefined) {
114+
const id = blobs[Number(corrupt) - 1];
115+
if (id === undefined) {
116+
response.writeHead(404).end();
117+
return;
118+
}
119+
backend.writeBlob(id, Buffer.from('corrupt')).then(
120+
() => reply(null),
121+
(error: unknown) => {
122+
console.error(error);
123+
response.writeHead(500).end();
124+
},
125+
);
99126
} else if (request.method === 'POST' && request.url === '/stop') {
100127
response.on('finish', () => void stop());
101128
reply({ requests: requests.splice(0), anomalies });

0 commit comments

Comments
 (0)