Skip to content

Commit 90d256f

Browse files
wan9chiclaude
andauthored
test(cache): make stalled-remote-cache a proxy for the remote cache (#795)
## Motivation The e2e cases for a remote cache that never answers need some requests to stall while others behave normally. For example, an upload case needs its fetch to reach a backend and miss before the upload stalls. `vtt stalled-remote-cache` was its own endpoint and stalled every request, so it couldn't do that. #718 will replace `remote-cache-server` with the real cache service, which can't stall requests itself. So the stalling has to happen in front of whatever backend a test runs. ## Changes - **Proxy.** `vtt stalled-remote-cache [--stall ROUTE]... COMMAND [ARGS...]` is now a proxy for the endpoint in `VP_REMOTE_CACHE_URL`, which must be `http://<host>:<port>/<path>`. It runs the command with `VP_REMOTE_CACHE_URL` set to the proxy. - A request to a stalled route below the endpoint, such as `/store`, is never answered, and it emits a `stalled` milestone. - Other requests are forwarded with `connection: close`, so each one comes in on its own connection and is checked on its own. - A forwarded request gets a 502 if the endpoint can't be reached. - **Fetch cases.** `ctrl_c_during_fetch` and `fast_fail_during_fetch` stall `/fetch`, with an unreachable endpoint behind the proxy. They still run on every platform without Node.js. - **New `ctrl_c_during_upload` case.** It runs `remote-cache-server vtt stalled-remote-cache --stall /store vt run build`. The fetch reaches the backend and misses, the upload stalls, and Ctrl-C cancels it. - **`remote-cache-server`** now ignores Ctrl-C and leaves it to its command, so it still prints its request log afterwards. ## Notes for reviewers - **Lost milestones on Windows.** A milestone there is the console title, and ConPTY sends it on its next render. If the command sets a milestone at about the same time as the proxy, the earlier one can be lost. The doc comment says so. Cases that need `remote-cache-server` are skipped on Windows anyway. - **Conflict with #718.** #718 rewrites `packages/tools/src/remote-cache/cli.ts`, so the line that ignores Ctrl-C needs to move into its version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 040e086 commit 90d256f

7 files changed

Lines changed: 260 additions & 28 deletions

File tree

Lines changed: 147 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,55 @@
1-
use std::io::Read as _;
1+
use std::{
2+
io::{Read as _, Write as _},
3+
net::{Shutdown, TcpListener, TcpStream},
4+
sync::Arc,
5+
};
26

3-
/// stalled-remote-cache `<command>` \[`<args>`...\]
7+
/// stalled-remote-cache \[`--stall` `<route>`\]... `<command>` \[`<args>`...\]
48
///
5-
/// Runs `<command>` with `VP_REMOTE_CACHE_URL` set to a loopback endpoint
6-
/// that accepts requests but never responds, then exits with the command's
7-
/// exit code. Emits a "request" milestone when a request arrives. Ctrl-C is
8-
/// left to the command.
9+
/// Runs `<command>` with `VP_REMOTE_CACHE_URL` set to a loopback proxy for the
10+
/// endpoint in `VP_REMOTE_CACHE_URL`, which must be
11+
/// `http://<host>:<port>/<path>`, then exits with the command's exit code.
12+
/// Requests to a stalled route below the endpoint, such as `/store`, are never
13+
/// forwarded or answered: each emits a "stalled" milestone when it arrives,
14+
/// and its connection is held until the client closes it. Other requests are
15+
/// forwarded, each on its own connection. Ctrl-C is left to the command.
16+
///
17+
/// On Windows a milestone is the console title, which `ConPTY` sends when it
18+
/// next renders, so if the command sets one at about the same time, the
19+
/// earlier title can be lost.
920
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
21+
let mut args = args;
22+
let mut stalled_routes = Vec::new();
23+
while let [flag, route, rest @ ..] = args
24+
&& flag == "--stall"
25+
{
26+
stalled_routes.push(route.clone());
27+
args = rest;
28+
}
1029
let [program, args @ ..] = args else {
11-
return Err("Usage: vtt stalled-remote-cache <command> [args...]".into());
30+
return Err(
31+
"Usage: vtt stalled-remote-cache [--stall <route>]... <command> [args...]".into()
32+
);
1233
};
34+
let upstream = std::env::var("VP_REMOTE_CACHE_URL")
35+
.map_err(|_| "VP_REMOTE_CACHE_URL must be set to the endpoint to proxy")?;
36+
let (authority, path) = upstream
37+
.strip_prefix("http://")
38+
.and_then(|rest| rest.split_once('/'))
39+
.ok_or("VP_REMOTE_CACHE_URL must be http://<host>:<port>/<path>")?;
40+
let proxy = Arc::new(Proxy {
41+
upstream: authority.to_owned(),
42+
base_path: std::format!("/{}", path.trim_end_matches('/')),
43+
stalled_routes,
44+
});
1345
ctrlc::set_handler(|| {})?;
1446

15-
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
16-
let endpoint = std::format!("http://{}/projects/test", listener.local_addr()?);
47+
let listener = TcpListener::bind("127.0.0.1:0")?;
48+
let endpoint = std::format!("http://{}{}", listener.local_addr()?, proxy.base_path);
1749
std::thread::spawn(move || {
18-
for mut stream in listener.incoming().filter_map(Result::ok) {
19-
std::thread::spawn(move || {
20-
let mut buf = [0; 4096];
21-
if stream.read(&mut buf).is_ok_and(|n| n > 0) {
22-
pty_terminal_test_client::mark_milestone("request");
23-
}
24-
// Hold the connection without responding until the client
25-
// closes it.
26-
while stream.read(&mut buf).is_ok_and(|n| n > 0) {}
27-
});
50+
for stream in listener.incoming().filter_map(Result::ok) {
51+
let proxy = Arc::clone(&proxy);
52+
std::thread::spawn(move || proxy.serve(stream));
2853
}
2954
});
3055

@@ -34,3 +59,106 @@ pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
3459
.status()?;
3560
std::process::exit(status.code().unwrap_or(1));
3661
}
62+
63+
struct Proxy {
64+
/// `<host>:<port>` of the endpoint.
65+
upstream: String,
66+
/// The endpoint's path, without a trailing slash.
67+
base_path: String,
68+
/// Routes below `base_path` whose requests stall.
69+
stalled_routes: Vec<String>,
70+
}
71+
72+
impl Proxy {
73+
/// Stall or forward the request on `client`. A forwarded request and its
74+
/// response get `connection: close`, so the client sends its next request
75+
/// on a new connection, which is served separately.
76+
fn serve(&self, mut client: TcpStream) {
77+
let Some((head, body_start)) = read_head(&mut client) else {
78+
return;
79+
};
80+
let target = head.split(' ').nth(1).unwrap_or_default();
81+
let path = target.split_once('?').map_or(target, |(path, _)| path);
82+
if path
83+
.strip_prefix(self.base_path.as_str())
84+
.is_some_and(|route| self.stalled_routes.iter().any(|stalled| stalled == route))
85+
{
86+
pty_terminal_test_client::mark_milestone("stalled");
87+
let mut buf = [0; 4096];
88+
while client.read(&mut buf).is_ok_and(|n| n > 0) {}
89+
return;
90+
}
91+
92+
let Ok(mut upstream) = TcpStream::connect(self.upstream.as_str()) else {
93+
let _ = client.write_all(
94+
b"HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
95+
);
96+
return;
97+
};
98+
let request_head = close_after_response(&head, Some(&self.upstream));
99+
if upstream.write_all(request_head.as_bytes()).is_err()
100+
|| upstream.write_all(&body_start).is_err()
101+
{
102+
return;
103+
}
104+
let (Ok(mut client_reader), Ok(mut upstream_writer)) =
105+
(client.try_clone(), upstream.try_clone())
106+
else {
107+
return;
108+
};
109+
// The rest of the request body.
110+
std::thread::spawn(move || {
111+
let _ = std::io::copy(&mut client_reader, &mut upstream_writer);
112+
let _ = upstream_writer.shutdown(Shutdown::Write);
113+
});
114+
115+
if let Some((head, body_start)) = read_head(&mut upstream)
116+
&& client.write_all(close_after_response(&head, None).as_bytes()).is_ok()
117+
&& client.write_all(&body_start).is_ok()
118+
{
119+
let _ = std::io::copy(&mut upstream, &mut client);
120+
}
121+
let _ = client.shutdown(Shutdown::Both);
122+
}
123+
}
124+
125+
/// Read an HTTP message's head, up to the blank line that ends it, and return
126+
/// it with the bytes read after it.
127+
fn read_head(stream: &mut TcpStream) -> Option<(String, Vec<u8>)> {
128+
let mut bytes = Vec::new();
129+
let mut buf = [0; 4096];
130+
loop {
131+
if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
132+
let rest = bytes.split_off(end + 4);
133+
return Some((String::from_utf8(bytes).ok()?, rest));
134+
}
135+
match stream.read(&mut buf) {
136+
Ok(n) if n > 0 => bytes.extend_from_slice(&buf[..n]),
137+
_ => return None,
138+
}
139+
}
140+
}
141+
142+
/// `head` with `connection: close` in place of its `Connection` header, and
143+
/// with `host` in place of its `Host` header if given.
144+
fn close_after_response(head: &str, host: Option<&str>) -> String {
145+
let mut lines = head.split("\r\n").filter(|line| !line.is_empty());
146+
let mut rewritten = std::format!("{}\r\n", lines.next().unwrap_or_default());
147+
for line in lines {
148+
let name = line.split_once(':').map_or(line, |(name, _)| name).trim();
149+
if name.eq_ignore_ascii_case("connection")
150+
|| (host.is_some() && name.eq_ignore_ascii_case("host"))
151+
{
152+
continue;
153+
}
154+
rewritten.push_str(line);
155+
rewritten.push_str("\r\n");
156+
}
157+
if let Some(host) = host {
158+
rewritten.push_str("host: ");
159+
rewritten.push_str(host);
160+
rewritten.push_str("\r\n");
161+
}
162+
rewritten.push_str("connection: close\r\n\r\n");
163+
rewritten
164+
}

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

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -424,13 +424,20 @@ steps = [
424424
{ argv = [
425425
"vtt",
426426
"stalled-remote-cache",
427+
"--stall",
428+
"/fetch",
427429
"vt",
428430
"run",
429431
"build",
432+
], envs = [
433+
[
434+
"VP_REMOTE_CACHE_URL",
435+
"http://127.0.0.1:0/projects/test",
436+
],
430437
], interactions = [
431-
{ "expect-milestone" = "request" },
438+
{ "expect-milestone" = "stalled" },
432439
{ "write-key" = "ctrl-c" },
433-
], comment = "The endpoint never responds. Ctrl-C stops the fetch, and the task doesn't start." },
440+
], comment = "The proxy never answers the fetch, so nothing needs to listen behind it. Ctrl-C stops the fetch, and the task doesn't start." },
434441
]
435442

436443
[[e2e]]
@@ -439,8 +446,50 @@ steps = [
439446
{ argv = [
440447
"vtt",
441448
"stalled-remote-cache",
449+
"--stall",
450+
"/fetch",
442451
"vt",
443452
"run",
444453
"fail-during-build",
445-
], comment = "The endpoint never responds. fail exits while build's fetch is in flight, which stops the fetch, and build doesn't start." },
454+
], envs = [
455+
[
456+
"VP_REMOTE_CACHE_URL",
457+
"http://127.0.0.1:0/projects/test",
458+
],
459+
], comment = "The proxy never answers the fetch. fail exits while build's fetch is in flight, which stops the fetch, and build doesn't start." },
460+
]
461+
462+
[[e2e]]
463+
name = "ctrl_c_during_upload"
464+
cfg = "not(windows)"
465+
ignore = true
466+
steps = [
467+
{ argv = [
468+
"remote-cache-server",
469+
"vtt",
470+
"stalled-remote-cache",
471+
"--stall",
472+
"/store",
473+
"vt",
474+
"run",
475+
"build",
476+
], envs = [
477+
[
478+
"VP_REMOTE_CACHE",
479+
"read-write",
480+
],
481+
], interactions = [
482+
{ "expect-milestone" = "stalled" },
483+
{ "write-key" = "ctrl-c" },
484+
], comment = "The proxy forwards the fetch to the backend, which has no entry, but never forwards the upload. Ctrl-C cancels it." },
485+
{ argv = [
486+
"vt",
487+
"run",
488+
"--last-details",
489+
], comment = "The details show why build wasn't uploaded." },
490+
{ argv = [
491+
"vt",
492+
"run",
493+
"build",
494+
], comment = "The entry is still in the local cache." },
446495
]

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# ctrl_c_during_fetch
22

3-
## `vtt stalled-remote-cache vt run build`
3+
## `VP_REMOTE_CACHE_URL=http://127.0.0.1:0/projects/test vtt stalled-remote-cache --stall /fetch vt run build`
44

5-
The endpoint never responds. Ctrl-C stops the fetch, and the task doesn't start.
5+
The proxy never answers the fetch, so nothing needs to listen behind it. Ctrl-C stops the fetch, and the task doesn't start.
66

7-
**→ expect-milestone:** `request`
7+
**→ expect-milestone:** `stalled`
88

99
```
1010
```
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ctrl_c_during_upload
2+
3+
## `VP_REMOTE_CACHE=read-write remote-cache-server vtt stalled-remote-cache --stall /store vt run build`
4+
5+
The proxy forwards the fetch to the backend, which has no entry, but never forwards the upload. Ctrl-C cancels it.
6+
7+
**→ expect-milestone:** `stalled`
8+
9+
```
10+
$ vtt write-file dist/output.txt built
11+
```
12+
13+
**← write-key:** `ctrl-c`
14+
15+
```
16+
$ vtt write-file dist/output.txt built
17+
18+
---
19+
vt run: remote-cache#build not uploaded to the remote cache: cancelled. (Run `vt run --last-details` for full details)
20+
[remote-cache] POST /fetch 404
21+
```
22+
23+
## `vt run --last-details`
24+
25+
The details show why build wasn't uploaded.
26+
27+
```
28+
29+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30+
Vite+ Task Runner • Execution Summary
31+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32+
33+
Statistics: 1 task • 0 cache hits • 1 cache miss
34+
Performance: 0% cache hit rate
35+
36+
Task Details:
37+
────────────────────────────────────────────────
38+
[1] remote-cache#build: $ vtt write-file dist/output.txt built ✓
39+
→ Cache miss: no previous cache entry found
40+
⚠ Not uploaded to the remote cache: cancelled
41+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42+
```
43+
44+
## `vt run build`
45+
46+
The entry is still in the local cache.
47+
48+
```
49+
$ vtt write-file dist/output.txt built ◉ cache hit, replaying
50+
51+
---
52+
vt run: cache hit.
53+
```

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# fast_fail_during_fetch
22

3-
## `vtt stalled-remote-cache vt run fail-during-build`
3+
## `VP_REMOTE_CACHE_URL=http://127.0.0.1:0/projects/test vtt stalled-remote-cache --stall /fetch vt run fail-during-build`
44

5-
The endpoint never responds. fail exits while build's fetch is in flight, which stops the fetch, and build doesn't start.
5+
The proxy never answers the fetch. fail exits while build's fetch is in flight, which stops the fetch, and build doesn't start.
66

77
**Exit code:** 1
88

‎packages/tools/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ remote-cache-server cbor-http POST /store --form-cbor "metadata={\"key\": 'A', \
99
remote-cache-server cbor-http POST /fetch --cbor "{\"key\": 'A', \"secondary_key\": 'S'}"
1010
```
1111

12-
`remote-cache-server COMMAND [ARGS...]` starts the backend on a free loopback port and runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint, `http://127.0.0.1:<port>/projects/test`. The fixed base path gives every endpoint a namespace path. The wrapper takes no options and passes all arguments to the command unchanged. The command inherits stdio. When it exits, the server stops and the wrapper exits with the command's exit code.
12+
`remote-cache-server COMMAND [ARGS...]` starts the backend on a free loopback port and runs the command with `VP_REMOTE_CACHE_URL` set to the endpoint, `http://127.0.0.1:<port>/projects/test`. The fixed base path gives every endpoint a namespace path. The wrapper takes no options and passes all arguments to the command unchanged. The command inherits stdio and handles Ctrl-C, which the wrapper ignores. When it exits, the server stops and the wrapper exits with the command's exit code.
1313

1414
After the command exits, the wrapper prints one line to stderr for each request it served, 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:
1515

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const server = createCacheServer({
1616
server.listen(0, '127.0.0.1');
1717
await once(server, 'listening');
1818
const { port } = server.address() as AddressInfo;
19+
// Ctrl-C is left to the command.
20+
process.on('SIGINT', () => {});
1921
const child = spawn(command!, args, {
2022
stdio: 'inherit',
2123
env: { ...process.env, VP_REMOTE_CACHE_URL: `http://127.0.0.1:${port}${basePath}` },

0 commit comments

Comments
 (0)