1use std::{
15 collections::BTreeMap,
16 fs,
17 io::Write,
18 net::{SocketAddr, TcpListener, TcpStream},
19 path::{Path, PathBuf},
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24 thread::JoinHandle,
25 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
26};
27
28use base64::Engine as _;
29use serde_json::{Value, json};
30
31use crate::{
32 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
33 lifecycle::{note_kept_evidence, remove_stored_tree_deferred},
34 run_store::{RunIntegrity, RunMetadata},
35 rust_project::PreparedRustProject,
36 served_tests::ServedTest,
37};
38
39const ENTRY_FILE: &str = "supercov-worker-entry.mjs";
42const REGISTRY_FILE: &str = "supercov-worker-registry.mjs";
43
44const REGISTRY: &str = r#"// Generated by Supercov: remembers this isolate's wasm instances.
47const instances = (globalThis.__supercovWasm ??= []);
48const Instance = WebAssembly.Instance;
49function Recorded(module, imports) {
50 const instance = new Instance(module, imports);
51 instances.push(instance);
52 return instance;
53}
54Recorded.prototype = Instance.prototype;
55WebAssembly.Instance = Recorded;
56const instantiate = WebAssembly.instantiate;
57WebAssembly.instantiate = async function (source, imports) {
58 const result = await instantiate.call(WebAssembly, source, imports);
59 instances.push(result instanceof Instance ? result : result.instance);
60 return result;
61};
62"#;
63
64const TAKE: &str = r#"(() => {
69 const seen = (globalThis.__supercovTaken ??= new WeakSet());
70 const out = [];
71 for (const instance of globalThis.__supercovWasm ?? []) {
72 const exports = instance.exports;
73 for (const name of Object.keys(exports)) {
74 if (!name.startsWith('__supercov_region_')) continue;
75 const crate = name.slice('__supercov_region_'.length);
76 const packed = BigInt.asUintN(64, BigInt(exports[name]()));
77 const at = Number(packed >> 32n), length = Number(packed & 0xffffffffn);
78 if (!length || !exports.memory) continue;
79 const view = new DataView(exports.memory.buffer, at, length);
80 const first = !seen.has(exports);
81 if (!first && view.getUint32(40, true) === 0 && view.getUint32(44, true) === 0) continue;
82 seen.add(exports);
83 const bytes = new Uint8Array(exports.memory.buffer, at, length);
84 const encode = (from, to) => {
85 let text = '';
86 for (let index = from; index < to; index += 0x8000)
87 text += String.fromCharCode.apply(null, bytes.subarray(index, Math.min(to, index + 0x8000)));
88 return btoa(text);
89 };
90 const bodyAt = view.getUint32(32, true);
91 const segments = [[0, encode(0, bodyAt)]];
92 for (let page = 0; bodyAt + page * 4096 < length; page++) {
93 if (bytes[128 + (page >> 3)] & (1 << (page & 7))) {
94 const from = bodyAt + page * 4096;
95 segments.push([from, encode(from, Math.min(length, from + 4096))]);
96 }
97 }
98 out.push([crate, length, segments]);
99 exports['__supercov_clear_' + crate]();
100 }
101 }
102 return out;
103})()"#;
104
105const POLL_INTERVAL: Duration = Duration::from_millis(5);
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111struct WorkerConfig {
112 path: PathBuf,
113 main: String,
114 inspector_port: Option<u16>,
115}
116
117fn worker_configs(root: &Path) -> Vec<PathBuf> {
123 const SKIPPED: &[&str] = &[
124 "node_modules",
125 "target",
126 "build",
127 "dist",
128 ".git",
129 ".supercov",
130 ".wrangler",
131 ];
132 let mut found = Vec::new();
133 let mut directories = vec![(root.to_path_buf(), 0)];
134 while let Some((directory, depth)) = directories.pop() {
135 let Ok(entries) = fs::read_dir(&directory) else {
136 continue;
137 };
138 for entry in entries.flatten() {
139 let path = entry.path();
140 let name = entry.file_name();
141 let Some(name) = name.to_str() else {
142 continue;
143 };
144 let Ok(kind) = entry.file_type() else {
145 continue;
146 };
147 if kind.is_dir() {
148 if depth < 6 && !SKIPPED.contains(&name) {
149 directories.push((path, depth + 1));
150 }
151 continue;
152 }
153 if !kind.is_file()
154 || !matches!(name, "wrangler.toml" | "wrangler.json" | "wrangler.jsonc")
155 {
156 continue;
157 }
158 let builds_rust = read_config(&path).is_some_and(|(_, document)| {
159 build_command(&document).is_some_and(|command| command.contains("worker-build"))
160 });
161 if builds_rust && path.with_file_name("Cargo.toml").is_file() {
162 found.push(path);
163 }
164 }
165 }
166 found.sort();
167 found
168}
169
170fn read_config(path: &Path) -> Option<(String, Value)> {
172 let text = fs::read_to_string(path).ok()?;
173 let document = if path
174 .extension()
175 .is_some_and(|extension| extension == "toml")
176 {
177 let table = text.parse::<toml::Table>().ok()?;
178 serde_json::to_value(table).ok()?
179 } else {
180 serde_json::from_str(&strip_jsonc(&text)).ok()?
181 };
182 Some((text, document))
183}
184
185fn build_command(document: &Value) -> Option<String> {
186 match document.get("build")?.get("command")? {
187 Value::String(command) => Some(command.clone()),
188 Value::Array(parts) => Some(
189 parts
190 .iter()
191 .filter_map(Value::as_str)
192 .collect::<Vec<_>>()
193 .join(" "),
194 ),
195 _ => None,
196 }
197}
198
199fn parse_config(path: &Path) -> Option<WorkerConfig> {
200 let (_, document) = read_config(path)?;
201 Some(WorkerConfig {
202 path: path.to_owned(),
203 main: document.get("main")?.as_str()?.to_owned(),
204 inspector_port: document
205 .get("dev")
206 .and_then(|dev| dev.get("inspector_port"))
207 .and_then(Value::as_u64)
208 .and_then(|port| u16::try_from(port).ok()),
209 })
210}
211
212fn strip_jsonc(text: &str) -> String {
215 let mut out = String::with_capacity(text.len());
216 let mut characters = text.chars().peekable();
217 while let Some(character) = characters.next() {
218 match character {
219 '"' => {
220 out.push('"');
221 while let Some(inner) = characters.next() {
222 out.push(inner);
223 match inner {
224 '\\' => {
225 if let Some(escaped) = characters.next() {
226 out.push(escaped);
227 }
228 }
229 '"' => break,
230 _ => {}
231 }
232 }
233 }
234 '/' if characters.peek() == Some(&'/') => {
235 for inner in characters.by_ref() {
236 if inner == '\n' {
237 out.push('\n');
238 break;
239 }
240 }
241 }
242 '/' if characters.peek() == Some(&'*') => {
243 characters.next();
244 let mut previous = '\0';
245 for inner in characters.by_ref() {
246 if previous == '*' && inner == '/' {
247 break;
248 }
249 previous = inner;
250 }
251 }
252 _ => out.push(character),
253 }
254 }
255 let mut cleaned = String::with_capacity(out.len());
257 let mut in_string = false;
258 let mut escaped = false;
259 let bytes = out.as_bytes();
260 for (index, character) in out.char_indices() {
261 if in_string {
262 cleaned.push(character);
263 if escaped {
264 escaped = false;
265 } else if character == '\\' {
266 escaped = true;
267 } else if character == '"' {
268 in_string = false;
269 }
270 continue;
271 }
272 if character == '"' {
273 in_string = true;
274 } else if character == ',' {
275 let next = bytes[index + 1..]
276 .iter()
277 .find(|byte| !byte.is_ascii_whitespace());
278 if matches!(next, Some(b'}' | b']')) {
279 continue;
280 }
281 }
282 cleaned.push(character);
283 }
284 cleaned
285}
286
287fn rewrite_config(config: &WorkerConfig, entry: &str, port: Option<u16>) -> Result<(), String> {
290 let path = &config.path;
291 let text = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?;
292 let rewritten = if path
293 .extension()
294 .is_some_and(|extension| extension == "toml")
295 {
296 let mut document = text
297 .parse::<toml_edit::DocumentMut>()
298 .map_err(|error| format!("{}: {error}", path.display()))?;
299 document["main"] = toml_edit::value(entry);
300 if let (None, Some(port)) = (config.inspector_port, port) {
301 if !document.contains_key("dev") {
302 document["dev"] = toml_edit::table();
303 }
304 document["dev"]["inspector_port"] = toml_edit::value(i64::from(port));
305 }
306 document.to_string()
307 } else {
308 let mut document: Value = serde_json::from_str(&strip_jsonc(&text))
309 .map_err(|error| format!("{}: {error}", path.display()))?;
310 document["main"] = json!(entry);
311 if let (None, Some(port)) = (config.inspector_port, port) {
312 if !document.get("dev").is_some_and(Value::is_object) {
313 document["dev"] = json!({});
314 }
315 document["dev"]["inspector_port"] = json!(port);
316 }
317 serde_json::to_string_pretty(&document).map_err(|error| error.to_string())?
318 };
319 fs::write(path, rewritten).map_err(|error| format!("{}: {error}", path.display()))
320}
321
322fn point_at_entry(config: &WorkerConfig, port: Option<u16>) -> Result<(), String> {
327 if config.main.trim_start_matches("./") == ENTRY_FILE {
328 return Ok(());
329 }
330 let directory = config.path.parent().unwrap_or(Path::new("."));
331 fs::write(directory.join(REGISTRY_FILE), REGISTRY).map_err(|error| error.to_string())?;
332 let main = config.main.trim_start_matches("./");
333 let entry = format!(
334 "// Generated by Supercov: the project's entry, with its wasm instances remembered.\nimport './{REGISTRY_FILE}';\nexport * from './{main}';\nexport {{ default }} from './{main}';\n"
335 );
336 fs::write(directory.join(ENTRY_FILE), entry).map_err(|error| error.to_string())?;
337 rewrite_config(config, ENTRY_FILE, port)
338}
339
340fn free_port() -> Result<u16, String> {
342 let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| error.to_string())?;
343 listener
344 .local_addr()
345 .map(|address| address.port())
346 .map_err(|error| error.to_string())
347}
348
349pub(crate) fn wasm_target_cfg(
352 crate_directory: &Path,
353) -> Option<crate::rust_instrumenter::TargetCfg> {
354 let output = std::process::Command::new("rustc")
355 .args(["--print", "cfg", "--target", "wasm32-unknown-unknown"])
356 .current_dir(crate_directory)
357 .output()
358 .ok()?;
359 if !output.status.success() {
360 return None;
361 }
362 let values = String::from_utf8_lossy(&output.stdout)
363 .lines()
364 .filter_map(|line| line.parse::<cargo_platform::Cfg>().ok())
365 .collect::<Vec<_>>();
366 Some(crate::rust_instrumenter::TargetCfg::new(&values))
367}
368
369fn now_ms() -> i64 {
370 SystemTime::now()
371 .duration_since(UNIX_EPOCH)
372 .map(|elapsed| elapsed.as_millis() as i64)
373 .unwrap_or(0)
374}
375
376struct Poller {
379 stop: Arc<AtomicBool>,
380 thread: Option<JoinHandle<()>>,
381}
382
383impl Poller {
384 fn start(port: u16, windows: PathBuf) -> Self {
385 let stop = Arc::new(AtomicBool::new(false));
386 let flag = Arc::clone(&stop);
387 let thread = std::thread::Builder::new()
388 .name("supercov-worker-poller".into())
389 .spawn(move || poll(port, &windows, &flag))
390 .ok();
391 Self { stop, thread }
392 }
393
394 fn finish(mut self) {
396 self.stop.store(true, Ordering::Release);
397 if let Some(thread) = self.thread.take() {
398 let _ = thread.join();
399 }
400 }
401}
402
403impl Drop for Poller {
404 fn drop(&mut self) {
405 self.stop.store(true, Ordering::Release);
406 }
407}
408
409fn poll(port: u16, windows: &Path, stop: &AtomicBool) {
410 let Ok(mut file) = fs::OpenOptions::new()
411 .create(true)
412 .append(true)
413 .open(windows)
414 else {
415 return;
416 };
417 let address = SocketAddr::from(([127, 0, 0, 1], port));
418 while !stop.load(Ordering::Acquire) {
421 let Ok(stream) = TcpStream::connect_timeout(&address, Duration::from_millis(200)) else {
422 std::thread::sleep(Duration::from_millis(100));
423 continue;
424 };
425 let _ = stream.set_nodelay(true);
426 let _ = stream.set_read_timeout(Some(Duration::from_millis(20)));
427 let Ok((mut socket, _)) =
428 tungstenite::client::client(format!("ws://127.0.0.1:{port}/ws"), stream)
429 else {
430 std::thread::sleep(Duration::from_millis(100));
431 continue;
432 };
433 session(&mut socket, &mut file, stop);
434 let _ = socket.close(None);
435 }
436}
437
438fn session(socket: &mut tungstenite::WebSocket<TcpStream>, file: &mut fs::File, stop: &AtomicBool) {
442 use tungstenite::{Error, Message};
443 let send = |socket: &mut tungstenite::WebSocket<TcpStream>, message: Value| {
444 socket
445 .send(Message::text(message.to_string()))
446 .map_err(|_| ())
447 };
448 if send(socket, json!({"id": 1, "method": "Runtime.enable"})).is_err() {
449 return;
450 }
451 let mut contexts = Vec::<i64>::new();
452 let mut next_id = 2_u64;
453 let mut awaiting: Option<(u64, Instant)> = None;
456 let mut window_start = now_ms();
457 let mut last_take = Instant::now() - POLL_INTERVAL;
458 let mut final_sent = false;
459 loop {
460 let stopping = stop.load(Ordering::Acquire);
461 if stopping && awaiting.is_none() && (final_sent || contexts.is_empty()) {
462 return;
463 }
464 if awaiting.is_none()
465 && let Some(context) = contexts.last()
466 && (stopping || last_take.elapsed() >= POLL_INTERVAL)
467 {
468 let id = next_id;
469 next_id += 1;
470 let request = json!({
471 "id": id,
472 "method": "Runtime.evaluate",
473 "params": {"expression": TAKE, "contextId": context, "returnByValue": true},
474 });
475 if send(socket, request).is_err() {
476 return;
477 }
478 awaiting = Some((id, Instant::now()));
479 last_take = Instant::now();
480 final_sent = stopping;
481 }
482 match socket.read() {
483 Ok(Message::Text(text)) => {
484 let Ok(message) = serde_json::from_str::<Value>(text.as_str()) else {
485 continue;
486 };
487 match message.get("method").and_then(Value::as_str) {
488 Some("Runtime.executionContextCreated") => {
489 if let Some(id) = message["params"]["context"]["id"].as_i64() {
490 contexts.push(id);
491 }
492 }
493 Some("Runtime.executionContextDestroyed") => {
494 if let Some(id) = message["params"]["executionContextId"].as_i64() {
495 contexts.retain(|context| *context != id);
496 }
497 }
498 Some("Runtime.executionContextsCleared") => contexts.clear(),
499 _ => {}
500 }
501 if let Some((id, _)) = awaiting
502 && message.get("id").and_then(Value::as_u64) == Some(id)
503 {
504 awaiting = None;
505 let end = now_ms();
506 let regions = &message["result"]["result"]["value"];
507 if regions
508 .as_array()
509 .is_some_and(|regions| !regions.is_empty())
510 {
511 let line = json!({"start": window_start, "end": end, "regions": regions});
512 let _ = writeln!(file, "{line}");
513 }
514 window_start = end;
515 }
516 }
517 Ok(Message::Close(_)) => return,
518 Ok(_) => {}
519 Err(Error::Io(error))
520 if matches!(
521 error.kind(),
522 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
523 ) => {}
524 Err(_) => return,
525 }
526 if awaiting.is_some_and(|(_, sent)| sent.elapsed() > Duration::from_secs(5)) {
529 awaiting = None;
530 }
531 }
532}
533
534struct Window {
536 start: i64,
537 end: i64,
538 regions: Vec<(String, Vec<u8>)>,
540}
541
542fn read_windows(directory: &Path) -> Result<Vec<Window>, String> {
543 let mut files = fs::read_dir(directory)
544 .map(|entries| {
545 entries
546 .filter_map(Result::ok)
547 .map(|entry| entry.path())
548 .filter(|path| {
549 path.extension()
550 .is_some_and(|extension| extension == "windows")
551 })
552 .collect::<Vec<_>>()
553 })
554 .unwrap_or_default();
555 files.sort();
556 let mut windows = Vec::new();
557 for file in &files {
558 let text =
559 fs::read_to_string(file).map_err(|error| format!("{}: {error}", file.display()))?;
560 for line in text.lines() {
562 let Ok(value) = serde_json::from_str::<Value>(line) else {
563 continue;
564 };
565 if let Some(window) = window_from(&value) {
566 windows.push(window);
567 }
568 }
569 }
570 Ok(windows)
571}
572
573fn window_from(value: &Value) -> Option<Window> {
574 let mut regions = Vec::new();
575 for region in value.get("regions")?.as_array()? {
576 let crate_key = region.get(0)?.as_str()?.to_owned();
577 let length = usize::try_from(region.get(1)?.as_u64()?).ok()?;
578 let mut bytes = vec![0_u8; length];
579 for segment in region.get(2)?.as_array()? {
580 let offset = usize::try_from(segment.get(0)?.as_u64()?).ok()?;
581 let data = base64::engine::general_purpose::STANDARD
582 .decode(segment.get(1)?.as_str()?)
583 .ok()?;
584 bytes
585 .get_mut(offset..offset + data.len())?
586 .copy_from_slice(&data);
587 }
588 regions.push((crate_key, bytes));
589 }
590 Some(Window {
591 start: value.get("start")?.as_i64()?,
592 end: value.get("end")?.as_i64()?,
593 regions,
594 })
595}
596
597pub struct RustWorkerServer {
599 project: PreparedRustProject,
600 pollers: Vec<Poller>,
601 integrity: RunIntegrity,
602 source_inputs: crate::source_manifest::Inputs,
603 source_roots: Option<Vec<String>>,
604 directory: PathBuf,
606}
607
608impl RustWorkerServer {
609 pub fn present(root: &Path) -> bool {
611 !worker_configs(root).is_empty()
612 }
613
614 pub fn prepare(
618 root: &Path,
619 workspace: &Path,
620 run_id: &str,
621 command: &[String],
622 diagnostics: &mut dyn Write,
623 ) -> Result<Vec<Self>, String> {
624 let configs = worker_configs(workspace);
625 let mut by_workspace = BTreeMap::<PathBuf, Vec<WorkerConfig>>::new();
628 for path in configs {
629 let Some(config) = parse_config(&path) else {
630 writeln!(
631 diagnostics,
632 "[supercov] {} names no `main`; its Worker is not measured",
633 path.display()
634 )
635 .map_err(|error| error.to_string())?;
636 continue;
637 };
638 let crate_directory = path.parent().unwrap_or(workspace).to_owned();
639 let cargo_root = cargo_workspace_root(&crate_directory).unwrap_or(crate_directory);
640 by_workspace.entry(cargo_root).or_default().push(config);
641 }
642 let mut servers = Vec::new();
643 for (index, (cargo_root, configs)) in by_workspace.into_iter().enumerate() {
644 let Ok(relative) = cargo_root.strip_prefix(workspace) else {
645 continue;
646 };
647 let source_roots = crate::source_discovery::configured_source_roots(
648 &std::env::vars().collect::<BTreeMap<_, _>>(),
649 );
650 let mut integrity_inputs = crate::rust_run::collect_integrity_inputs(root, command)?;
653 crate::source_discovery::fold_roots_into_configuration(
654 &mut integrity_inputs.execution_configuration,
655 source_roots.as_deref(),
656 );
657 let source_inputs =
658 crate::source_capture::capture(root, "rust", integrity_inputs.assertion_paths())?;
659 let integrity = create_explicit_run_integrity(
660 root,
661 &integrity_inputs,
662 &FrontendIntegrityInputs::embedded_rust(),
663 )
664 .map_err(|error| error.to_string())?;
665 let cfg = wasm_target_cfg(&cargo_root);
666 let mut project =
667 crate::rust_project::prepare_rust_project_for(&cargo_root, None, cfg.as_ref())
668 .map_err(|error| error.to_string())?;
669 crate::rust_wasm_evidence::root_paths(&mut project, relative);
670 let directory = root
671 .join(".supercov/work")
672 .join(run_id)
673 .join(format!("rust-worker-{index}"));
674 let evidence = directory.join("evidence");
675 fs::create_dir_all(&evidence).map_err(|error| error.to_string())?;
676 let mut pollers = Vec::new();
677 for (number, config) in configs.iter().enumerate() {
678 let port = match config.inspector_port {
679 Some(port) => port,
680 None => free_port()?,
681 };
682 point_at_entry(config, Some(port))?;
683 pollers.push(Poller::start(
684 port,
685 evidence.join(format!("{number:04}.windows")),
686 ));
687 }
688 servers.push(Self {
689 project,
690 pollers,
691 integrity,
692 source_inputs,
693 source_roots,
694 directory,
695 });
696 }
697 Ok(servers)
698 }
699
700 pub fn source_files(&self) -> usize {
701 self.project.source_files.len()
702 }
703
704 pub fn source_fingerprint(&self) -> String {
707 self.integrity.fingerprint.source.clone()
708 }
709
710 pub fn environment(&self) -> BTreeMap<String, String> {
719 let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
720 if !rustflags.is_empty() {
721 rustflags.push(' ');
722 }
723 rustflags.push_str("--cap-lints=warn -A unused_parens");
724 BTreeMap::from([("RUSTFLAGS".into(), rustflags)])
725 }
726
727 pub fn publish(
732 mut self,
733 root: &Path,
734 _evidence_run_id: &str,
735 metadata: RunMetadata,
736 served: &[ServedTest],
737 diagnostics: &mut dyn Write,
738 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
739 for poller in std::mem::take(&mut self.pollers) {
740 poller.finish();
741 }
742 let evidence_directory = self.directory.join("evidence");
743 let result = read_windows(&evidence_directory).and_then(|windows| {
744 crate::rust_wasm_evidence::publish(
745 root,
746 crate::rust_wasm_evidence::WasmRun {
747 language: "Rust Worker",
748 archive: "rust-worker-evidence.raw.gz",
749 project: &self.project,
750 integrity: &self.integrity,
751 source_inputs: &self.source_inputs,
752 source_roots: self.source_roots.clone(),
753 },
754 windows
755 .into_iter()
756 .map(|window| crate::rust_wasm_evidence::WasmRecord {
757 owner: crate::rust_wasm_evidence::Owner::Window(window.start, window.end),
758 regions: window.regions,
759 })
760 .collect(),
761 metadata,
762 served,
763 diagnostics,
764 )
765 });
766 let result = result.map_err(|error| {
767 let run = self
768 .directory
769 .parent()
770 .and_then(Path::file_name)
771 .map(|name| name.to_string_lossy().into_owned())
772 .unwrap_or_default();
773 note_kept_evidence(
774 root,
775 &evidence_directory,
776 &format!("{run}-rust-worker"),
777 error,
778 )
779 });
780 let _ = remove_stored_tree_deferred(root, &self.directory);
781 result
782 }
783}
784
785pub const REAL_WRANGLER: &str = "SUPERCOV_WRANGLER";
788pub const WRANGLER_COMMAND: &str = "__wrangler";
790pub const WRANGLER_POLL_COMMAND: &str = "__wrangler-poll";
791
792pub struct CargoTestWorkers {
803 environment: Vec<(String, std::ffi::OsString)>,
804}
805
806impl CargoTestWorkers {
807 pub fn prepare(workspace: &Path, tools: &Path) -> Result<Option<Self>, String> {
812 let configs = worker_configs(workspace)
813 .iter()
814 .filter_map(|path| parse_config(path))
815 .collect::<Vec<_>>();
816 if configs.is_empty() {
817 return Ok(None);
818 }
819 let path = std::env::var_os("PATH").unwrap_or_default();
820 let Some(real) = on_path(&path, "wrangler") else {
821 return Ok(None);
822 };
823 for config in &configs {
824 point_at_entry(config, None)?;
825 }
826 let supercov = std::env::current_exe().map_err(|error| error.to_string())?;
827 fs::create_dir_all(tools).map_err(|error| format!("{}: {error}", tools.display()))?;
828 write_stand_in(tools, &supercov)?;
829 let mut directories = vec![tools.to_owned()];
830 directories.extend(std::env::split_paths(&path));
831 let joined = std::env::join_paths(directories).map_err(|error| error.to_string())?;
832 Ok(Some(Self {
833 environment: vec![
834 ("PATH".into(), joined),
835 (REAL_WRANGLER.into(), real.into_os_string()),
836 ],
837 }))
838 }
839
840 pub fn environment(&self) -> impl Iterator<Item = (&str, &std::ffi::OsStr)> {
842 self.environment
843 .iter()
844 .map(|(name, value)| (name.as_str(), value.as_os_str()))
845 }
846}
847
848fn on_path(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
851 let names = if cfg!(windows) {
852 vec![
853 format!("{name}.cmd"),
854 format!("{name}.exe"),
855 name.to_owned(),
856 ]
857 } else {
858 vec![name.to_owned()]
859 };
860 std::env::split_paths(path).find_map(|directory| {
861 names
862 .iter()
863 .map(|name| directory.join(name))
864 .find(|candidate| fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file()))
865 })
866}
867
868fn write_stand_in(tools: &Path, supercov: &Path) -> Result<(), String> {
869 #[cfg(unix)]
870 {
871 use std::os::unix::fs::PermissionsExt;
872 let quoted = supercov.to_string_lossy().replace('\'', r"'\''");
873 let script = tools.join("wrangler");
874 fs::write(
875 &script,
876 format!("#!/bin/sh\nexec '{quoted}' {WRANGLER_COMMAND} \"$@\"\n"),
877 )
878 .map_err(|error| format!("{}: {error}", script.display()))?;
879 fs::set_permissions(&script, fs::Permissions::from_mode(0o755))
880 .map_err(|error| format!("{}: {error}", script.display()))?;
881 }
882 #[cfg(windows)]
883 {
884 let script = tools.join("wrangler.cmd");
885 fs::write(
886 &script,
887 format!("@\"{}\" {WRANGLER_COMMAND} %*\r\n", supercov.display()),
888 )
889 .map_err(|error| format!("{}: {error}", script.display()))?;
890 }
891 Ok(())
892}
893
894fn starts_dev(arguments: &[std::ffi::OsString]) -> bool {
897 const VALUED: &[&str] = &["--cwd", "-c", "--config", "-e", "--env", "--env-file"];
898 let mut arguments = arguments.iter().map(|argument| argument.to_string_lossy());
899 while let Some(argument) = arguments.next() {
900 if VALUED.contains(&argument.as_ref()) {
901 arguments.next();
902 } else if !argument.starts_with('-') {
903 return argument == "dev";
904 }
905 }
906 false
907}
908
909fn named_inspector_port(arguments: &[std::ffi::OsString]) -> Option<u16> {
912 let mut arguments = arguments.iter().map(|argument| argument.to_string_lossy());
913 while let Some(argument) = arguments.next() {
914 if argument == "--inspector-port" {
915 return arguments.next()?.parse().ok();
916 }
917 if let Some(port) = argument.strip_prefix("--inspector-port=") {
918 return port.parse().ok();
919 }
920 }
921 None
922}
923
924pub fn launch_wrangler(arguments: Vec<std::ffi::OsString>) -> i32 {
929 let Some(real) = std::env::var_os(REAL_WRANGLER) else {
930 eprintln!("[supercov] {WRANGLER_COMMAND} runs only as the wrangler of a measured test");
931 return 127;
932 };
933 let mut arguments = arguments;
934 let evidence = std::env::var_os("SUPERCOV_RUST_EVIDENCE_DIR").map(PathBuf::from);
935 if starts_dev(&arguments)
936 && let Some(evidence) = evidence
937 {
938 let port = match named_inspector_port(&arguments) {
939 Some(port) => Some(port),
940 None => free_port().ok().inspect(|port| {
941 arguments.push("--inspector-port".into());
942 arguments.push(port.to_string().into());
943 }),
944 };
945 if let (Some(port), Ok(supercov)) = (port, std::env::current_exe()) {
946 let windows = evidence.join(format!("worker-{}.windows", std::process::id()));
947 let _ = std::process::Command::new(supercov)
948 .arg(WRANGLER_POLL_COMMAND)
949 .arg(std::process::id().to_string())
950 .arg(port.to_string())
951 .arg(&windows)
952 .stdin(std::process::Stdio::null())
953 .stdout(std::process::Stdio::null())
954 .stderr(std::process::Stdio::null())
955 .spawn();
956 }
957 }
958 let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
959 if !rustflags.is_empty() {
960 rustflags.push(' ');
961 }
962 rustflags.push_str("--cap-lints=warn -A unused_parens");
963 let mut command = std::process::Command::new(&real);
964 command.args(&arguments).env("RUSTFLAGS", rustflags);
965 #[cfg(unix)]
966 {
967 use std::os::unix::process::CommandExt;
968 let error = command.exec();
969 eprintln!(
970 "[supercov] could not start {}: {error}",
971 Path::new(&real).display()
972 );
973 126
974 }
975 #[cfg(not(unix))]
976 {
977 match command.status() {
978 Ok(status) => status.code().unwrap_or(1),
979 Err(error) => {
980 eprintln!(
981 "[supercov] could not start {}: {error}",
982 Path::new(&real).display()
983 );
984 126
985 }
986 }
987 }
988}
989
990pub fn poll_wrangler(arguments: Vec<std::ffi::OsString>) -> i32 {
994 let [pid, port, windows] = arguments.as_slice() else {
995 return 2;
996 };
997 let (Ok(pid), Ok(port)) = (
998 pid.to_string_lossy().parse::<u32>(),
999 port.to_string_lossy().parse::<u16>(),
1000 ) else {
1001 return 2;
1002 };
1003 let windows = PathBuf::from(windows);
1004 let part = partial(&windows);
1005 let poller = Poller::start(port, part.clone());
1006 while process_alive(pid) {
1007 std::thread::sleep(Duration::from_millis(20));
1008 }
1009 poller.finish();
1010 if fs::symlink_metadata(&part).is_ok() {
1011 let _ = fs::rename(&part, &windows);
1012 }
1013 0
1014}
1015
1016fn partial(windows: &Path) -> PathBuf {
1017 let mut name = windows.as_os_str().to_owned();
1018 name.push(".part");
1019 PathBuf::from(name)
1020}
1021
1022#[cfg(unix)]
1023fn process_alive(pid: u32) -> bool {
1024 let Ok(pid) = libc::pid_t::try_from(pid) else {
1025 return false;
1026 };
1027 let answered = unsafe { libc::kill(pid, 0) };
1029 answered == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1030}
1031
1032#[cfg(not(unix))]
1033fn process_alive(_pid: u32) -> bool {
1034 false
1035}
1036
1037const POLLER_SETTLE: Duration = Duration::from_secs(3);
1040
1041pub fn materialize_worker_windows(
1046 directory: &Path,
1047 token: &str,
1048) -> Result<std::collections::BTreeSet<String>, String> {
1049 let listed = |extension: &str| -> Vec<PathBuf> {
1050 let mut files = fs::read_dir(directory)
1051 .map(|entries| {
1052 entries
1053 .filter_map(Result::ok)
1054 .map(|entry| entry.path())
1055 .filter(|path| {
1056 path.file_name()
1057 .and_then(|name| name.to_str())
1058 .is_some_and(|name| {
1059 name.starts_with("worker-") && name.ends_with(extension)
1060 })
1061 })
1062 .collect::<Vec<_>>()
1063 })
1064 .unwrap_or_default();
1065 files.sort();
1066 files
1067 };
1068 let started = Instant::now();
1069 while !listed(".windows.part").is_empty() && started.elapsed() < POLLER_SETTLE {
1070 std::thread::sleep(Duration::from_millis(20));
1071 }
1072 let files = listed(".windows")
1075 .into_iter()
1076 .chain(listed(".windows.part"))
1077 .collect::<Vec<_>>();
1078 let mut crates = std::collections::BTreeSet::new();
1079 for (number, file) in files.iter().enumerate() {
1080 let text =
1081 fs::read_to_string(file).map_err(|error| format!("{}: {error}", file.display()))?;
1082 for (line, value) in text
1083 .lines()
1084 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
1085 .enumerate()
1086 {
1087 let Some(window) = window_from(&value) else {
1088 continue;
1089 };
1090 for (index, (crate_key, bytes)) in window.regions.into_iter().enumerate() {
1091 if region_token(&bytes) != Some(token) {
1092 continue;
1093 }
1094 let region = directory.join(format!(
1095 "{token}-worker-{number:04}-{line:06}-{index}.region"
1096 ));
1097 fs::write(®ion, &bytes)
1098 .map_err(|error| format!("{}: {error}", region.display()))?;
1099 crates.insert(crate_key);
1100 }
1101 }
1102 fs::remove_file(file).map_err(|error| format!("{}: {error}", file.display()))?;
1103 }
1104 Ok(crates)
1105}
1106
1107fn region_token(bytes: &[u8]) -> Option<&str> {
1109 let field = bytes.get(56..72)?;
1110 let end = field
1111 .iter()
1112 .position(|byte| *byte == 0)
1113 .unwrap_or(field.len());
1114 std::str::from_utf8(&field[..end]).ok()
1115}
1116
1117pub(crate) fn cargo_workspace_root(directory: &Path) -> Option<PathBuf> {
1119 let output = std::process::Command::new("cargo")
1120 .args(["locate-project", "--workspace", "--message-format", "plain"])
1121 .current_dir(directory)
1122 .output()
1123 .ok()?;
1124 if !output.status.success() {
1125 return None;
1126 }
1127 let manifest = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
1128 manifest.parent().map(Path::to_path_buf)
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133 use super::*;
1134
1135 #[test]
1136 fn jsonc_comments_and_trailing_commas_are_dropped_and_strings_kept() {
1137 let text = r#"{
1138 // the Worker
1139 "name": "a // not a comment",
1140 /* block */ "main": "build/worker/shim.mjs",
1141 "build": { "command": "worker-build --release", },
1142 "list": [1, 2, ],
1143 "quote": "say \"hi\", ]"
1144}"#;
1145 let value: Value = serde_json::from_str(&strip_jsonc(text)).unwrap();
1146 assert_eq!(value["name"], "a // not a comment");
1147 assert_eq!(value["main"], "build/worker/shim.mjs");
1148 assert_eq!(value["build"]["command"], "worker-build --release");
1149 assert_eq!(value["list"], json!([1, 2]));
1150 assert_eq!(value["quote"], "say \"hi\", ]");
1151 }
1152
1153 #[test]
1154 fn the_stand_in_reads_dev_and_an_inspector_port_wherever_they_stand() {
1155 let os = |arguments: &[&str]| {
1156 arguments
1157 .iter()
1158 .map(std::ffi::OsString::from)
1159 .collect::<Vec<_>>()
1160 };
1161 assert!(starts_dev(&os(&["dev", "--local"])));
1162 assert!(starts_dev(&os(&["--cwd", "worker", "dev"])));
1163 assert!(starts_dev(&os(&[
1164 "-c",
1165 "wrangler.toml",
1166 "--env=staging",
1167 "dev"
1168 ])));
1169 assert!(starts_dev(&os(&["--local", "dev"])));
1170 assert!(!starts_dev(&os(&["deploy"])));
1171 assert!(!starts_dev(&os(&[])));
1172 assert_eq!(
1173 named_inspector_port(&os(&["dev", "--inspector-port", "9230"])),
1174 Some(9230)
1175 );
1176 assert_eq!(
1177 named_inspector_port(&os(&["dev", "--inspector-port=9231"])),
1178 Some(9231)
1179 );
1180 assert_eq!(named_inspector_port(&os(&["dev", "--port", "8787"])), None);
1181 assert_eq!(
1182 named_inspector_port(&os(&["dev", "--inspector-port"])),
1183 None
1184 );
1185 }
1186
1187 #[test]
1191 fn a_take_becomes_a_region_file_of_the_test_that_started_the_worker() {
1192 let directory = temporary("materialize");
1193 let region = |token: &str| {
1194 let mut bytes = vec![0_u8; 4096 + 256];
1195 bytes[56..56 + token.len()].copy_from_slice(token.as_bytes());
1196 bytes[200] = 7;
1197 bytes
1198 };
1199 let segment =
1200 |bytes: &[u8]| json!([0, base64::engine::general_purpose::STANDARD.encode(bytes)]);
1201 let ours = region("abc123");
1202 let theirs = region("fff999");
1203 let line = json!({
1204 "start": 1,
1205 "end": 2,
1206 "regions": [
1207 ["abc123k1", ours.len(), [segment(&ours)]],
1208 ["fff999k2", theirs.len(), [segment(&theirs)]],
1209 ],
1210 });
1211 fs::write(
1212 directory.join("worker-41.windows"),
1213 format!("{line}\n{{\"start\": 3, \"end\""),
1214 )
1215 .unwrap();
1216 let crates = materialize_worker_windows(&directory, "abc123").unwrap();
1217 assert_eq!(
1218 crates,
1219 std::collections::BTreeSet::from(["abc123k1".to_owned()])
1220 );
1221 let mut names = fs::read_dir(&directory)
1222 .unwrap()
1223 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1224 .collect::<Vec<_>>();
1225 names.sort();
1226 assert_eq!(names, ["abc123-worker-0000-000000-0.region"]);
1227 assert_eq!(fs::read(directory.join(&names[0])).unwrap(), ours);
1228 assert!(
1230 materialize_worker_windows(&directory, "abc123")
1231 .unwrap()
1232 .is_empty()
1233 );
1234 assert_eq!(region_token(&ours), Some("abc123"));
1235 assert_eq!(region_token(&[0_u8; 10]), None);
1236 fs::remove_dir_all(&directory).unwrap();
1237 }
1238
1239 #[cfg(unix)]
1244 #[test]
1245 fn the_stand_in_is_put_first_only_where_a_wrangler_is_installed() {
1246 let workspace = temporary("stand-in");
1247 fs::create_dir_all(workspace.join("worker/src")).unwrap();
1248 fs::write(
1249 workspace.join("worker/Cargo.toml"),
1250 "[package]\nname = \"w\"\n",
1251 )
1252 .unwrap();
1253 fs::write(workspace.join("worker/src/lib.rs"), "").unwrap();
1254 fs::write(
1255 workspace.join("worker/wrangler.toml"),
1256 "name = \"w\"\nmain = \"build/worker/shim.mjs\"\n\n[build]\ncommand = \"worker-build --release\"\n",
1257 )
1258 .unwrap();
1259 let bin = workspace.join("bin");
1260 fs::create_dir_all(&bin).unwrap();
1261 let real = bin.join("wrangler");
1262 fs::write(&real, "#!/bin/sh\n").unwrap();
1263 let found = on_path(bin.as_os_str(), "wrangler");
1264 assert_eq!(found.as_deref(), Some(real.as_path()));
1265 assert_eq!(on_path(std::ffi::OsStr::new(""), "wrangler"), None);
1266 let tools = workspace.join("tools");
1267 let config = parse_config(&workspace.join("worker/wrangler.toml")).unwrap();
1268 point_at_entry(&config, None).unwrap();
1269 let pointed = fs::read_to_string(workspace.join("worker/wrangler.toml")).unwrap();
1270 assert!(
1271 pointed.contains(&format!("main = \"{ENTRY_FILE}\"")),
1272 "{pointed}"
1273 );
1274 assert!(!pointed.contains("inspector_port"), "{pointed}");
1275 let entry = fs::read_to_string(workspace.join("worker").join(ENTRY_FILE)).unwrap();
1276 point_at_entry(
1278 &parse_config(&workspace.join("worker/wrangler.toml")).unwrap(),
1279 None,
1280 )
1281 .unwrap();
1282 assert_eq!(
1283 fs::read_to_string(workspace.join("worker").join(ENTRY_FILE)).unwrap(),
1284 entry
1285 );
1286 assert!(entry.contains("./build/worker/shim.mjs"), "{entry}");
1287 fs::create_dir_all(&tools).unwrap();
1288 write_stand_in(&tools, Path::new("/opt/it's/supercov")).unwrap();
1289 let script = fs::read_to_string(tools.join("wrangler")).unwrap();
1290 assert_eq!(
1291 script,
1292 format!("#!/bin/sh\nexec '/opt/it'\\''s/supercov' {WRANGLER_COMMAND} \"$@\"\n")
1293 );
1294 fs::remove_dir_all(&workspace).unwrap();
1295 }
1296
1297 fn temporary(name: &str) -> PathBuf {
1298 let path = std::env::temp_dir().join(format!(
1299 "supercov-rust-worker-{}-{}-{name}",
1300 std::process::id(),
1301 SystemTime::now()
1302 .duration_since(UNIX_EPOCH)
1303 .unwrap()
1304 .as_nanos()
1305 ));
1306 fs::create_dir_all(&path).unwrap();
1307 path
1308 }
1309
1310 #[test]
1311 fn only_a_configuration_that_builds_with_worker_build_beside_a_crate_is_a_rust_worker() {
1312 let root = temporary("configs");
1313 let write = |relative: &str, text: &str| {
1314 let path = root.join(relative);
1315 fs::create_dir_all(path.parent().unwrap()).unwrap();
1316 fs::write(path, text).unwrap();
1317 };
1318 write(
1319 "worker/wrangler.toml",
1320 "main = \"build/worker/shim.mjs\"\n[build]\ncommand = \"cargo install -q worker-build && worker-build --release\"\n",
1321 );
1322 write("worker/Cargo.toml", "[package]\nname = \"w\"\n");
1323 write("js/wrangler.toml", "main = \"src/index.js\"\n");
1325 write(
1326 "orphan/wrangler.jsonc",
1327 "{ \"main\": \"x.mjs\", // c\n \"build\": { \"command\": \"worker-build\" } }",
1328 );
1329 write(
1330 "json/wrangler.jsonc",
1331 "{ \"main\": \"build/worker/shim.mjs\", /* c */ \"build\": { \"command\": \"worker-build --dev\", }, }",
1332 );
1333 write("json/Cargo.toml", "[package]\nname = \"j\"\n");
1334 write(
1335 "node_modules/dep/wrangler.toml",
1336 "main = \"m\"\n[build]\ncommand = \"worker-build\"\n",
1337 );
1338 write("node_modules/dep/Cargo.toml", "");
1339 let found = worker_configs(&root)
1340 .into_iter()
1341 .map(|path| path.strip_prefix(&root).unwrap().to_owned())
1342 .collect::<Vec<_>>();
1343 assert_eq!(
1344 found,
1345 [
1346 PathBuf::from("json/wrangler.jsonc"),
1347 PathBuf::from("worker/wrangler.toml")
1348 ]
1349 );
1350 fs::remove_dir_all(root).unwrap();
1351 }
1352
1353 #[test]
1354 fn the_copy_points_at_the_entry_and_an_inspector_port_and_keeps_the_rest() {
1355 let root = temporary("rewrite");
1356 let toml_path = root.join("wrangler.toml");
1357 fs::write(
1358 &toml_path,
1359 "# the Worker\nname = \"grades\"\nmain = \"build/worker/shim.mjs\"\n\n[build]\ncommand = \"worker-build --release\"\n",
1360 )
1361 .unwrap();
1362 let config = parse_config(&toml_path).unwrap();
1363 rewrite_config(&config, ENTRY_FILE, Some(9444)).unwrap();
1364 let rewritten = fs::read_to_string(&toml_path).unwrap();
1365 assert!(rewritten.contains("# the Worker"), "{rewritten}");
1366 let (_, document) = read_config(&toml_path).unwrap();
1367 assert_eq!(document["main"], ENTRY_FILE);
1368 assert_eq!(document["dev"]["inspector_port"], 9444);
1369 assert_eq!(document["build"]["command"], "worker-build --release");
1370
1371 let json_path = root.join("wrangler.jsonc");
1373 fs::write(
1374 &json_path,
1375 "{\n // mine\n \"main\": \"./build/worker/shim.mjs\",\n \"dev\": { \"inspector_port\": 9555 },\n}\n",
1376 )
1377 .unwrap();
1378 let config = parse_config(&json_path).unwrap();
1379 assert_eq!(config.inspector_port, Some(9555));
1380 assert_eq!(config.main, "./build/worker/shim.mjs");
1381 rewrite_config(&config, ENTRY_FILE, Some(1)).unwrap();
1382 let (_, document) = read_config(&json_path).unwrap();
1383 assert_eq!(document["main"], ENTRY_FILE);
1384 assert_eq!(document["dev"]["inspector_port"], 9555);
1385 fs::remove_dir_all(root).unwrap();
1386 }
1387
1388 #[test]
1389 fn a_window_is_rebuilt_from_its_header_and_touched_pages() {
1390 let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes);
1391 let value = json!({
1392 "start": 10,
1393 "end": 15,
1394 "regions": [["abc", 8192, [[0, encode(&[1, 2, 3])], [4096, encode(&[9; 4])]]]],
1395 });
1396 let window = window_from(&value).unwrap();
1397 assert_eq!((window.start, window.end), (10, 15));
1398 let (crate_key, bytes) = &window.regions[0];
1399 assert_eq!(crate_key, "abc");
1400 assert_eq!(bytes.len(), 8192);
1401 assert_eq!(&bytes[..4], &[1, 2, 3, 0]);
1402 assert_eq!(&bytes[4096..4101], &[9, 9, 9, 9, 0]);
1403 let bad = json!({"start": 0, "end": 1, "regions": [["a", 4, [[2, encode(&[1, 2, 3])]]]]});
1405 assert!(window_from(&bad).is_none());
1406 }
1407}