Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6aa0078
feat(debug-files): --type elf accepts directories of .so files
krassx Oct 7, 2026
7ace775
fix(debug-files): empty ELF directory exits 10; don't descend dir sym…
krassx Oct 7, 2026
a741636
fix(debug-files): fail ELF dir scan on any I/O error; harden tests
krassx Oct 7, 2026
d7655c5
perf(upload): stream the presigned PUT body and the SHA-1 instead of …
krassx Oct 7, 2026
20a7eb3
perf(elf): memory-map libraries for the identity scan instead of read…
krassx Oct 7, 2026
5dd0fd6
docs(changelog): note the streaming/mmap memory reduction
krassx Oct 7, 2026
7b454ac
perf: stop loading whole files into memory across the upload/identify…
krassx Oct 7, 2026
530ba3c
perf(sourcemaps): stream map JSON with struson instead of a serde_jso…
krassx Oct 7, 2026
01e17d9
test(elf): gate the unix-only fixture helper (dead code is an error o…
krassx Oct 7, 2026
1346a32
Merge branch 'feat/elf-directory-input' into perf/stream-uploads-low-…
krassx Oct 7, 2026
bdd4794
Merge branch 'perf/stream-uploads-low-memory' into perf/low-memory-audit
krassx Oct 7, 2026
eee6fbe
Merge branch 'perf/low-memory-audit' into perf/json-streaming
krassx Oct 7, 2026
1ab176a
docs(elf): state the mmap contract - directories must not be written …
krassx Oct 7, 2026
362fa0d
Merge perf/stream-uploads-low-memory; fix(dsym): drop the stale 1 GiB…
krassx Oct 7, 2026
650d52a
Merge branch 'perf/low-memory-audit' into perf/json-streaming
krassx Oct 7, 2026
c02b954
fix(inject): rewrite a map's content in place instead of fs::copy fro…
krassx Oct 7, 2026
0f388c2
fix(upload): follow 307/308 on streamed PUTs ourselves; correct stale…
krassx Oct 7, 2026
4fec18f
Merge branch 'perf/low-memory-audit' into perf/json-streaming
krassx Oct 7, 2026
3aa9013
fix(mapjson): copy any valid JSON number; anonymous scratch file; war…
krassx Oct 7, 2026
0d99de4
fix(inject): keep the map's scratch file on the map's own volume
krassx Oct 7, 2026
c47cc54
Merge remote-tracking branch 'origin/main' into perf/json-streaming
krassx Oct 7, 2026
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Peak RSS: `upload build` with a 300 MB artefact + 200 MB mapping 661 MB -> 32 MB, a 200 MB
ProGuard mapping 241 MB -> 32 MB, an ELF zip of four 100 MB libraries 463 MB -> 36 MB. Wire
format and exit codes unchanged.
- **Source maps are streamed instead of parsed into a JSON tree.** Reading a map's debug-id,
stripping `sourcesContent` (`--strip-sources-content`, including nested indexed-map sections) and
`sourcemaps inject` rewriting a map's ids now walk the document with `struson`, and the inject
debug-id hash is computed incrementally instead of over a concatenated copy. Peak RSS for an
89 MB map: upload 183 MB -> 32 MB, `--strip-sources-content` 262 MB -> 32 MB, `inject` 298 MB ->
6 MB. Debug-ids are unchanged. Two byte-level differences: a stripped copy keeps the map's
original key order (it was alphabetical), and `inject` writes `debug_id` / `debugId` last. Both
are still valid maps; an invalid-UTF-8 map outside a string value now reports as invalid JSON
(exit 11) rather than I/O (exit 10).

## [0.7.13] - 2026-10-07

Expand Down
56 changes: 56 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ tempfile = "3"
# [dependencies] table (NOT a cfg(target) table).
self-replace = "1"

# Streaming JSON reader/writer: lets the source-map paths read or rewrite a few
# top-level keys of a tens-of-MB map without building a serde_json::Value tree.
struson = "0.7"

# Unix double-fork for `xcode post-action`'s background (daemon) mode. This is a
# TARGET-specific table, so it MUST come AFTER the main [dependencies] table —
# placed mid-table, TOML would capture every following key into the unix-only
Expand Down
102 changes: 45 additions & 57 deletions src/cli/debug_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1919,23 +1919,6 @@ fn is_stylesheet_or_declaration_map(path: &Path, extra: &ExtraSuffixes) -> bool
})
}

/// Remove every `sourcesContent` a source map can carry; `true` when one was there.
///
/// Not just the top-level key: an INDEXED map (spec §Index-Map) keeps its source inside
/// `sections[i].map`, and those sections nest. Stripping only the top level left the source in the
/// upload while reporting success — the flag's whole promise, failing silently.
fn remove_sources_content(map: &mut serde_json::Map<String, serde_json::Value>) -> bool {
let mut removed = map.remove("sourcesContent").is_some();
if let Some(serde_json::Value::Array(sections)) = map.get_mut("sections") {
for section in sections {
if let Some(serde_json::Value::Object(inner)) = section.get_mut("map") {
removed |= remove_sources_content(inner);
}
}
}
removed
}

/// A copy of `map_path` with `sourcesContent` removed, plus its recomputed identity — or `None`
/// when nothing needs stripping (flag off, no `sourcesContent`, or the map is not the JSON object we
/// expect, which is a privacy preference's business to ignore rather than to fail over).
Expand All @@ -1954,40 +1937,53 @@ fn stripped_copy_without_sources(
// Typed, not a bare `?` into anyhow: an I/O failure on the map must classify the way
// `sourcemap::identify` classifies one on the SAME file moments earlier — exit 10, which
// integrators are documented not to fall back on — rather than exit 1 ("unexpected").
let bytes = crate::symbols::mapped::map_file(map_path).map_err(crate::error::Error::Io)?;
let Ok(serde_json::Value::Object(mut map)) = serde_json::from_slice(&bytes) else {
return Ok(None);
};
if !remove_sources_content(&mut map) {
return Ok(None);
}
let stripped = serde_json::to_vec(&map).map_err(|e| {
input_invalid(format!(
"{}: could not re-serialize the map without its sources: {e}",
map_path.display()
))
})?;
let name = map_path
.file_name()
.map(Path::new)
.unwrap_or_else(|| Path::new("bundle.js.map"));
let out = tmpdir.join(name);
std::fs::write(&out, &stripped).map_err(crate::error::Error::Io)?;
// Streamed input -> output: only the dropped `sourcesContent` values (the bulk of a
// map) are never held, and nothing else is either, so memory is flat however big the map.
let before_bytes = std::fs::metadata(map_path)
.map_err(crate::error::Error::Io)?
.len();
let input =
std::io::BufReader::new(std::fs::File::open(map_path).map_err(crate::error::Error::Io)?);
let output =
std::io::BufWriter::new(std::fs::File::create(&out).map_err(crate::error::Error::Io)?);
match crate::inject::mapjson::strip_sources_content(input, output) {
Ok(true) => {}
// Nothing to strip: the copy is moot.
Ok(false) => {
let _ = std::fs::remove_file(&out);
return Ok(None);
}
// Not the JSON object we expect. A privacy preference is not a reason to fail the
// upload, but it must not fail SILENTLY either: say that the sources are NOT stripped.
Err(crate::inject::mapjson::MapJsonError::Json(why)) => {
tracing::warn!(
path = %map_path.display(),
reason = %why,
"--strip-sources-content could not process this map; uploading it UNSTRIPPED"
);
let _ = std::fs::remove_file(&out);
return Ok(None);
}
Err(crate::inject::mapjson::MapJsonError::Io(e)) => {
let _ = std::fs::remove_file(&out);
return Err(crate::error::Error::Io(e).into());
}
}
// The identity describes what is UPLOADED (the stripped copy): its streamed hash and size.
// The debug-id is carried over untouched: it is the key, and re-deriving it here would
// break the pair with the bundle that `sourcemaps inject` stamped.
let identity = sourcemap::identify(&out)?;
tracing::info!(
path = %map_path.display(),
before_bytes = bytes.len(),
after_bytes = stripped.len(),
before_bytes,
after_bytes = identity.size_bytes,
"stripped sourcesContent from the uploaded copy"
);
let identity = sourcemap::SourcemapIdentity {
debug_id: crate::inject::read_debug_id(&out)?,
content_sha1_hex: {
use sha1::Digest;
let digest: [u8; 20] = sha1::Sha1::digest(&stripped).into();
hex::encode(digest)
},
size_bytes: stripped.len() as u64,
};
Ok(Some((out, identity)))
}

Expand Down Expand Up @@ -3401,18 +3397,7 @@ mod sourcemap_upload_tests {
}

async fn uploaded_map_json(server: &MockServer) -> serde_json::Value {
let requests = server.received_requests().await.unwrap();
let put = requests
.iter()
.find(|r| r.method.as_str() == "PUT")
.expect("no PUT");
let mut archive =
zip::ZipArchive::new(std::io::Cursor::new(put.body.clone())).expect("not a zip");
assert_eq!(archive.len(), 1, "one entry: just the map");
let mut entry = archive.by_index(0).unwrap();
let mut body = String::new();
std::io::Read::read_to_string(&mut entry, &mut body).unwrap();
serde_json::from_str(&body).expect("uploaded map is not JSON")
serde_json::from_slice(&uploaded_map_bytes(server).await).expect("uploaded map is not JSON")
}

/// `sourcesContent` embeds the ORIGINAL SOURCE in the map, which is how symbolication shows
Expand Down Expand Up @@ -3497,14 +3482,17 @@ mod sourcemap_upload_tests {
.body,
)
.unwrap();
let uploaded = uploaded_map_json(&server).await;
let uploaded = uploaded_map_bytes(&server).await;
let expected = {
use sha1::Digest;
let digest: [u8; 20] =
sha1::Sha1::digest(serde_json::to_vec(&uploaded).unwrap()).into();
let digest: [u8; 20] = sha1::Sha1::digest(&uploaded).into();
hex::encode(digest)
};
assert_eq!(post["hash"].as_str().unwrap(), expected);
assert!(
!String::from_utf8_lossy(&uploaded).contains("sourcesContent"),
"and the hash is of the STRIPPED bytes"
);
}

/// Without the flag, the source rides along exactly as before — that is what makes the
Expand Down
Loading
Loading