Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/chuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ tauri.workspace = true
tauri-runtime-blitz.workspace = true

[dev-dependencies]
# `flavor = "multi_thread"` in the script-fetch test: the blocking fetch takes
# the `block_in_place` path, which only the multi-threaded runtime has, and
# that is the path the capture actually uses.
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
# Assembles `fixtures/panel.wat` into a module in-process, so the capture test
# owns its guest instead of borrowing blitz-wasm's demo, and so it needs no
# wasm32 target and no nested cargo build. See the header of that file.
Expand Down
202 changes: 196 additions & 6 deletions apps/chuzz/src/document_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,20 +312,100 @@ pub(crate) const WEB_API_SHIM: &str = r#"
}
})();
"#;
/// Serves the sources prefetched above, falling back to the default fetcher
/// for `file:` and `data:` URLs.
/// Serves the sources prefetched above, and fetches the rest from the network.
///
/// The prefetch can only see the scripts the parsed HTML names. A page that
/// builds a `<script>` from JavaScript, or imports a module, asks for a URL
/// nobody knew about until the page was already running, and that request used
/// to reach `DefaultScriptFetcher`, which serves `file:` and `data:` only. The
/// script was dropped with `unsupported URL scheme for script: https`, which
/// reads like a policy decision rather than the missing feature it is. It cost
/// a quarter of a hundred-site corpus, and it hides well: scripts the parser
/// found load perfectly, so a page fails only in the parts it assembles itself.
///
/// `ScriptFetcher::fetch` is synchronous because classic scripts must execute
/// in document order, so the network call has to block. It runs on the shared
/// provider, which keeps the per-origin connection cap that the rest of the
/// page's loads obey.
#[cfg(all(feature = "capture", feature = "javascript"))]
struct PrefetchedScripts {
struct PageScripts {
scripts: HashMap<Url, String>,
net: Arc<NetProvider>,
runtime: tokio::runtime::Handle,
}

/// How long a runtime-discovered script may take before the page moves on.
///
/// Bounded because this blocks script execution: a server that accepts the
/// connection and never answers would otherwise hang the whole capture, and a
/// missing script is a far better outcome than a run that never finishes.
#[cfg(all(feature = "capture", feature = "javascript"))]
const RUNTIME_SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);

#[cfg(all(feature = "capture", feature = "javascript"))]
impl blitz_script::ScriptFetcher for PrefetchedScripts {
impl blitz_script::ScriptFetcher for PageScripts {
fn fetch(&self, url: &Url) -> Result<String, blitz_script::FetchError> {
if let Some(source) = self.scripts.get(url) {
return Ok(source.clone());
}
blitz_script::DefaultScriptFetcher.fetch(url)
match url.scheme() {
"http" | "https" => self.fetch_over_network(url),
// `file:` and `data:` still belong to the default fetcher; it
// decodes data URLs correctly and needs no network.
_ => blitz_script::DefaultScriptFetcher.fetch(url),
}
}
}

#[cfg(all(feature = "capture", feature = "javascript"))]
impl PageScripts {
fn fetch_over_network(&self, url: &Url) -> Result<String, blitz_script::FetchError> {
let net = Arc::clone(&self.net);
let target = url.clone();
let fetch = async move {
tokio::time::timeout(
RUNTIME_SCRIPT_TIMEOUT,
net.fetch_async(Request::get(target)),
)
.await
};

// This is called from inside the runtime that is driving the page, so
// blocking the thread outright would stall the executor it is waiting
// on. `block_in_place` hands the other tasks to a sibling worker first;
// it exists only on the multi-threaded runtime, hence the flavour test
// rather than an unwrap that would be right until someone builds a
// current-thread one.
let joined = match tokio::runtime::Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(|| handle.block_on(fetch))
}
// Not on a runtime at all: the stored handle drives it, and
// blocking this thread stalls nothing that is waiting on us.
Err(_) => self.runtime.block_on(fetch),
// On a current-thread runtime there is one worker and it is this
// one, so blocking it and re-entering it both deadlock. Refusing
// the script drops it exactly as this code path did before, which
// is bad but survivable; a capture that never returns is not.
// Unreachable in the shipped configuration, where the capture
// runtime is multi-threaded, and spelled out rather than left to
// an unwrap that would be correct only until someone changed the
// builder.
Ok(_) => {
return Err(blitz_script::FetchError::InvalidData(
"cannot fetch a script from a current-thread runtime".to_owned(),
));
}
};

match joined {
Ok(Ok((_, bytes))) => Ok(decode_body(&bytes)),
Ok(Err(error)) => Err(blitz_script::FetchError::InvalidData(format!("{error:?}"))),
Err(_) => Err(blitz_script::FetchError::InvalidData(format!(
"script fetch timed out after {}s",
RUNTIME_SCRIPT_TIMEOUT.as_secs()
))),
}
}
}

Expand Down Expand Up @@ -403,7 +483,11 @@ pub async fn load_for_capture(
scripts.insert(url, decode_body(&bytes));
}
}
let mut document = document.with_fetcher(PrefetchedScripts { scripts });
let mut document = document.with_fetcher(PageScripts {
scripts,
net: Arc::clone(&net_provider),
runtime: tokio::runtime::Handle::current(),
});
document.eval(WEB_API_SHIM);
document.execute_scripts();
// Pump the script runtime until the page has built its DOM, then keep
Expand Down Expand Up @@ -473,3 +557,109 @@ impl CapturedDocument {
}
}
}

#[cfg(all(test, feature = "capture", feature = "javascript"))]
mod tests {
use super::*;

/// A script the page asks for at runtime is fetched, not refused.
///
/// The prefetch map is deliberately empty: that is the state a page reaches
/// when its own JavaScript builds a `<script>` for a URL the parser never
/// saw. Before this path existed the request fell through to a fetcher that
/// serves `file:` and `data:` only, and the script was dropped.
///
/// Served from a real socket rather than a stub fetcher, because the thing
/// worth proving is that a synchronous `fetch` can complete a network round
/// trip from inside the runtime that is driving the page. A stub would pass
/// without ever testing that.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_script_discovered_at_runtime_is_fetched_over_the_network() {
use std::io::{Read, Write};

const SOURCE: &str = "globalThis.__loaded = 42;";

let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("loopback is available");
let port = listener
.local_addr()
.expect("the socket has an address")
.port();
// Non-blocking with a deadline rather than a plain `accept`. If the
// fetcher regresses to refusing the URL no connection is ever made, and
// a blocking accept would hang this test forever instead of failing it.
// A test that wedges on the very defect it exists to catch is worse
// than no test: it stops CI rather than reporting.
listener
.set_nonblocking(true)
.expect("the listener takes a nonblocking mode");
let server = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
Err(_) => return false,
}
};
stream
.set_nonblocking(false)
.expect("the accepted stream returns to blocking reads");
let mut seen = Vec::new();
let mut byte = [0u8; 1];
while !seen.ends_with(b"\r\n\r\n") {
match stream.read(&mut byte) {
Ok(0) | Err(_) => break,
Ok(_) => seen.push(byte[0]),
}
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/javascript\r\nContent-Length: {}\r\n\r\n{}",
SOURCE.len(),
SOURCE
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
true
});

let fetcher = PageScripts {
scripts: HashMap::new(),
net: Arc::new(NetProvider::new(None)),
runtime: tokio::runtime::Handle::current(),
};
let url = Url::parse(&format!("http://127.0.0.1:{port}/late.js")).expect("a valid URL");

let fetched =
tokio::task::spawn_blocking(move || blitz_script::ScriptFetcher::fetch(&fetcher, &url))
.await
.expect("the blocking fetch does not panic");

let connected = server.join().expect("the server thread finishes");
assert!(connected, "the fetcher never opened a connection");
assert_eq!(
fetched.expect("a runtime-discovered script is fetched"),
SOURCE
);
}

/// A `data:` script still resolves without touching the network.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_data_url_script_still_resolves_locally() {
let fetcher = PageScripts {
scripts: HashMap::new(),
net: Arc::new(NetProvider::new(None)),
runtime: tokio::runtime::Handle::current(),
};
// "let x = 1" as base64.
let url = Url::parse("data:text/javascript;base64,bGV0IHggPSAx").expect("a valid data URL");

let fetched = blitz_script::ScriptFetcher::fetch(&fetcher, &url);

assert_eq!(fetched.expect("a data URL needs no network"), "let x = 1");
}
}
Loading