Skip to content

Commit 5f7137e

Browse files
olwangclaude
andcommitted
rsscript: cache AOT run output, skip re-lowering when source is unchanged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 025b6ec commit 5f7137e

2 files changed

Lines changed: 162 additions & 28 deletions

File tree

crates/rsscript/src/cli/mod.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,20 @@ pub(crate) fn select_runtime_path(
186186
))
187187
}
188188

189+
/// Resolves the generated package name for `input_path` without performing a
190+
/// full lowering, so the run cache directory can be located before deciding
191+
/// whether re-lowering is needed. Returns `None` for a package directory whose
192+
/// manifest can't be read (the caller then falls back to lowering).
193+
pub(crate) fn cli_input_package_name(input_path: &str) -> Option<String> {
194+
if is_package_directory(input_path) {
195+
package_lowering_input(Path::new(input_path))
196+
.ok()
197+
.map(|input| input.package.name)
198+
} else {
199+
Some(generated_package_name(input_path))
200+
}
201+
}
202+
189203
pub(crate) fn generated_package_name(path: &str) -> String {
190204
Path::new(path)
191205
.file_stem()
@@ -194,6 +208,75 @@ pub(crate) fn generated_package_name(path: &str) -> String {
194208
.to_string()
195209
}
196210

211+
/// Computes a content fingerprint of everything that affects the generated Rust
212+
/// package for `input_path`, so an unchanged run can reuse the cached package and
213+
/// skip re-lowering. Covers the raw source(s)/interfaces, the runtime path, the
214+
/// release flag, and a compiler-version marker (so a rebuilt `rss` invalidates
215+
/// stale generated output). Returns `None` if any input can't be read, which
216+
/// forces the cautious full lower+write path.
217+
pub(crate) fn run_input_fingerprint(
218+
input_path: &str,
219+
runtime_path: &Path,
220+
release: bool,
221+
) -> Option<String> {
222+
let mut parts: Vec<String> = Vec::new();
223+
// Compiler-version marker: a recompiled `rss` may lower differently.
224+
parts.push(format!("rss-version:{}", env!("CARGO_PKG_VERSION")));
225+
parts.push(format!("runtime:{}", runtime_path.display()));
226+
parts.push(format!("release:{release}"));
227+
228+
if is_package_directory(input_path) {
229+
let input = package_lowering_input(Path::new(input_path)).ok()?;
230+
parts.push(format!("package:{}", input.package.name));
231+
// Sources/interfaces already carry their contents; include the native
232+
// dependency identity (path + features + bindings) since it changes the
233+
// generated Cargo.toml and lowering.
234+
let mut sources = input.sources.clone();
235+
sources.sort();
236+
for (path, contents) in &sources {
237+
parts.push(format!("src:{path}\n{contents}"));
238+
}
239+
let mut interfaces = input.interfaces.clone();
240+
interfaces.sort();
241+
for (path, contents) in &interfaces {
242+
parts.push(format!("iface:{path}\n{contents}"));
243+
}
244+
for dependency in &input.native_dependencies {
245+
parts.push(format!(
246+
"native:{}|{}|{}|{}",
247+
dependency.crate_name,
248+
dependency.path,
249+
dependency.cargo_features.join(","),
250+
dependency
251+
.bindings
252+
.iter()
253+
.map(|(key, value)| format!("{key}={value}"))
254+
.collect::<Vec<_>>()
255+
.join(",")
256+
));
257+
}
258+
} else {
259+
let source = fs::read_to_string(input_path).ok()?;
260+
parts.push(format!("file:{input_path}\n{source}"));
261+
}
262+
263+
Some(stable_hash_hex(&parts.join("\u{1e}")))
264+
}
265+
266+
/// Reads the fingerprint stored alongside a cached generated package.
267+
pub(crate) fn read_cached_fingerprint(cache_dir: &Path) -> Option<String> {
268+
fs::read_to_string(cache_dir.join(".rss-cache-hash"))
269+
.ok()
270+
.map(|value| value.trim().to_string())
271+
.filter(|value| !value.is_empty())
272+
}
273+
274+
/// Stores the fingerprint alongside the cached generated package.
275+
pub(crate) fn write_cached_fingerprint(cache_dir: &Path, fingerprint: &str) {
276+
let _ = fs::create_dir_all(cache_dir);
277+
let _ = fs::write(cache_dir.join(".rss-cache-hash"), fingerprint);
278+
}
279+
197280
pub(crate) fn run_cache_dir(input_path: &str, package_name: &str) -> PathBuf {
198281
let key = stable_input_key(input_path);
199282
run_cache_root_dir().join(format!(

crates/rsscript/src/cli/run_cmd.rs

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ use rsscript::{
66
};
77

88
use super::{
9-
cleanup_temp_dir, default_runtime_path, generated_target_dir_from_env,
10-
lower_cli_input_to_rust_package, print_diagnostics, print_usage, required_flag_value,
11-
run_cache_dir,
9+
cleanup_temp_dir, cli_input_package_name, default_runtime_path, generated_target_dir_from_env,
10+
lower_cli_input_to_rust_package, print_diagnostics, print_usage, read_cached_fingerprint,
11+
required_flag_value, run_cache_dir, run_input_fingerprint, write_cached_fingerprint,
1212
};
1313

1414
#[derive(Debug)]
@@ -93,6 +93,35 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
9393
return ExitCode::from(2);
9494
}
9595
};
96+
// Fast path: for a default-cache run (no `--out-dir`, not a dry run), reuse a
97+
// previously lowered + compiled package when the source, runtime, and release
98+
// flag are byte-for-byte unchanged. This skips re-lowering entirely and lets
99+
// cargo's own up-to-date check make the rebuild a near no-op (often a direct
100+
// run of the cached binary). Correctness: the fingerprint covers every input
101+
// that affects generated output, so any change forces the full path below.
102+
if !options.dry_run && options.out_dir.is_none() {
103+
if let Some(package_name) = cli_input_package_name(path) {
104+
let cache_dir = run_cache_dir(path, &package_name);
105+
let cached_package_present = cache_dir.join("Cargo.toml").is_file()
106+
&& cache_dir.join("src/main.rs").is_file();
107+
if cached_package_present {
108+
if let Some(fingerprint) =
109+
run_input_fingerprint(path, &runtime_path, options.release)
110+
{
111+
if read_cached_fingerprint(&cache_dir).as_deref() == Some(fingerprint.as_str()) {
112+
return run_cached_package(
113+
&cache_dir,
114+
options.release,
115+
&options.program_args,
116+
options.json,
117+
stream_stdio,
118+
);
119+
}
120+
}
121+
}
122+
}
123+
}
124+
96125
let package = match lower_cli_input_to_rust_package(path, &runtime_path, options.json) {
97126
Ok(package) => package,
98127
Err(exit_code) => return exit_code,
@@ -123,6 +152,7 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
123152
return ExitCode::SUCCESS;
124153
}
125154

155+
let is_default_cache = options.out_dir.is_none();
126156
let package_dir = options
127157
.out_dir
128158
.map(PathBuf::from)
@@ -135,8 +165,50 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
135165
}
136166
return ExitCode::from(2);
137167
}
168+
// Record the input fingerprint so the next run of unchanged source hits the
169+
// fast path above. Only for the default cache dir; a user-chosen `--out-dir`
170+
// is left untouched. Written after the package files so a partial write never
171+
// leaves a fingerprint claiming a stale package is current.
172+
if is_default_cache {
173+
if let Some(fingerprint) = run_input_fingerprint(path, &runtime_path, options.release) {
174+
write_cached_fingerprint(&package_dir, &fingerprint);
175+
}
176+
}
177+
build_and_run_package(
178+
&package_dir,
179+
options.release,
180+
&options.program_args,
181+
options.json,
182+
stream_stdio,
183+
)
184+
}
185+
186+
/// Runs the fast-path cache hit: the generated package in `cache_dir` is already
187+
/// up to date for the current source, so cargo's incremental check makes this a
188+
/// near no-op build (or a direct run of the cached binary).
189+
fn run_cached_package(
190+
cache_dir: &Path,
191+
release: bool,
192+
program_args: &[&str],
193+
json: bool,
194+
stream_stdio: bool,
195+
) -> ExitCode {
196+
build_and_run_package(cache_dir, release, program_args, json, stream_stdio)
197+
}
198+
199+
/// Invokes `cargo run` for the generated package in `package_dir` and translates
200+
/// its result into an [`ExitCode`], remapping backend diagnostics for the
201+
/// captured (non-streaming) path. Shared by the full lower+write path and the
202+
/// cached fast path so both behave identically.
203+
fn build_and_run_package(
204+
package_dir: &Path,
205+
release: bool,
206+
program_args: &[&str],
207+
json: bool,
208+
stream_stdio: bool,
209+
) -> ExitCode {
138210
let mut cargo = Command::new("cargo");
139-
for arg in cargo_run_args(&package_dir, options.release, &options.program_args) {
211+
for arg in cargo_run_args(package_dir, release, program_args) {
140212
cargo.arg(arg);
141213
}
142214
if let Some(target_dir) = generated_target_dir_from_env() {
@@ -154,15 +226,9 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
154226
Ok(status) => status,
155227
Err(error) => {
156228
eprintln!("failed to run cargo: {error}");
157-
if cleanup_package_dir {
158-
cleanup_temp_dir(&package_dir);
159-
}
160229
return ExitCode::from(2);
161230
}
162231
};
163-
if cleanup_package_dir {
164-
cleanup_temp_dir(&package_dir);
165-
}
166232
return if status.success() {
167233
ExitCode::SUCCESS
168234
} else {
@@ -176,9 +242,6 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
176242
Ok(output) => output,
177243
Err(error) => {
178244
eprintln!("failed to run cargo: {error}");
179-
if cleanup_package_dir {
180-
cleanup_temp_dir(&package_dir);
181-
}
182245
return ExitCode::from(2);
183246
}
184247
};
@@ -187,27 +250,18 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
187250
print!("{stdout}");
188251
}
189252
if output.status.success() {
190-
if cleanup_package_dir {
191-
cleanup_temp_dir(&package_dir);
192-
}
193253
return ExitCode::SUCCESS;
194254
}
195255

196256
let stderr = String::from_utf8_lossy(&output.stderr);
197257
let diagnostics = parse_runtime_diagnostics(&stderr);
198258
if !diagnostics.is_empty() {
199-
if cleanup_package_dir {
200-
cleanup_temp_dir(&package_dir);
201-
}
202-
print_diagnostics(options.json, &diagnostics);
259+
print_diagnostics(json, &diagnostics);
203260
return ExitCode::from(1);
204261
}
205-
match check_generated_rust_package(&package_dir) {
262+
match check_generated_rust_package(package_dir) {
206263
Ok(result) if !result.diagnostics.is_empty() => {
207-
if cleanup_package_dir {
208-
cleanup_temp_dir(&package_dir);
209-
}
210-
print_diagnostics(options.json, &result.diagnostics);
264+
print_diagnostics(json, &result.diagnostics);
211265
return if result
212266
.diagnostics
213267
.iter()
@@ -223,9 +277,6 @@ fn run_generated_rust_inner(args: &[String], stream_stdio: bool) -> ExitCode {
223277
eprintln!("{error}");
224278
}
225279
}
226-
if cleanup_package_dir {
227-
cleanup_temp_dir(&package_dir);
228-
}
229280
if !stderr.trim().is_empty() {
230281
eprintln!("{}", stderr.trim());
231282
}

0 commit comments

Comments
 (0)