diff --git a/.forge/prs/68873a40-ecae-4935-91f3-b4a34305e427/patch.diff b/.forge/prs/68873a40-ecae-4935-91f3-b4a34305e427/patch.diffnew file mode 100644index 0000000..3da249a--- /dev/null+++ b/.forge/prs/68873a40-ecae-4935-91f3-b4a34305e427/patch.diff@@ -0,0 +1,390 @@1+From 260dbd29cba59b9bda1ac2cfbed54b704ae258bd Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 15:38:21 +00004+Subject: [PATCH] stream oversized blobs to raw/ instead of buffering (#66)5+6+A 400 MB blob made the build abort in the allocator ('memory7+allocation of 400000000 bytes failed'). Now blob_size() decides:8+blobs over MAX_INLINE_BLOB (8 MiB) go through show_to_file(),9+which copies git show stdout to raw/ in chunks, never enters the10+in-memory blob map, and renders a placeholder page. README lookup11+also skips candidates over 1 MiB. Partial files are removed when12+git fails or times out.13+---14+ src/build.rs | 104 +++++++++++++++++++++++++------------15+ src/gitx.rs | 124 +++++++++++++++++++++++++++++++++++++++++++-16+ tests/large_blob.rs | 71 +++++++++++++++++++++++++17+ 3 files changed, 264 insertions(+), 35 deletions(-)18+ create mode 100644 tests/large_blob.rs19+20+diff --git a/src/build.rs b/src/build.rs21+index 90e6651..e3ba54a 10064422+--- a/src/build.rs23++++ b/src/build.rs24+@@ -196,8 +196,13 @@ pub fn find_readme(repo: &Path, branch: &str) -> Option<(String, gitx::Blob)> {25+ ".forge/wiki/README",26+ ".forge/wiki/readme",27+ ] {28+- if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {29+- return Some((candidate.to_string(), blob));30++ let fits = gitx::blob_size(repo, branch, Path::new(candidate))31++ .map(|size| size <= README_MAX_BYTES)32++ .unwrap_or(false);33++ if fits {34++ if let Ok(blob) = gitx::show(repo, branch, Path::new(candidate)) {35++ return Some((candidate.to_string(), blob));36++ }37+ }38+ }39+ None40+@@ -209,6 +214,17 @@ pub fn is_placeholder(bytes: &[u8]) -> bool {41+ bytes.len() >= PREVIEW_MAX_BYTES || bytes.contains(&0)42+ }43+ 44++/// Blobs above this size are streamed straight to `raw/` instead of45++/// being held in memory (f0rge #66). Reading every blob fully used to46++/// abort the process in the allocator on a few-hundred-MB file; the47++/// page for such a blob is a placeholder anyway, so only the raw copy48++/// needs the bytes.49++pub const MAX_INLINE_BLOB: u64 = 8 * 1024 * 1024;50++51++/// READMEs above this size are ignored (next candidate in the chain is52++/// tried). Prevents a huge README from bloating every page's preview.53++pub const README_MAX_BYTES: u64 = 1024 * 1024;54++55+ /// Bytes sniffed for the language-math binary check (first 8KiB).56+ pub const BINARY_SNIFF_BYTES: usize = 8 * 1024;57+ 58+@@ -955,16 +971,27 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu59+ wiki::page_count(repo) > 0,60+ );61+ 62+- // Raw files first: byte-identical copies.63++ // Raw files first: byte-identical copies. Blobs over MAX_INLINE_BLOB64++ // are streamed to disk and never enter the in-memory map; their file65++ // pages become placeholders (f0rge #66).66+ let mut blobs: BTreeMap<String, Vec<u8>> = BTreeMap::new();67++ let mut oversize: BTreeMap<String, u64> = BTreeMap::new();68+ for f in &files {69+- let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;70+ let dest = out.join("raw").join(f);71+ if let Some(parent) = dest.parent() {72+ std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create raw parent: {e}"))?;73+ }74+- std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;75+- blobs.insert(f.clone(), blob.bytes);76++ let size =77++ gitx::blob_size(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;78++ if size > MAX_INLINE_BLOB {79++ gitx::show_to_file(repo, &branch, Path::new(f), &dest)80++ .map_err(|e| format!("hu13: {e}"))?;81++ oversize.insert(f.clone(), size);82++ } else {83++ let blob = gitx::show(repo, &branch, Path::new(f)).map_err(|e| format!("hu13: {e}"))?;84++ std::fs::write(&dest, &blob.bytes).map_err(|e| format!("hu13: write raw {f}: {e}"))?;85++ blobs.insert(f.clone(), blob.bytes);86++ }87+ }88+ 89+ // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file90+@@ -1038,41 +1065,50 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu91+ }92+ 93+ for f in &files {94+- let bytes = blobs.get(f).cloned().unwrap_or_default();95+ let mut body = breadcrumb(f);96+ body.push_str(&format!("<h1>{}</h1>\n", esc(f)));97+- if is_placeholder(&bytes) {98+- body.push_str(&file_meta_bar(f, bytes.len() as u64, None));99+- let kind = if bytes.len() >= PREVIEW_MAX_BYTES {100+- format!("too large to preview ({} bytes)", bytes.len())101+- } else {102+- "binary file".to_string()103+- };104++ if let Some(&size) = oversize.get(f) {105++ body.push_str(&file_meta_bar(f, size, None));106+ body.push_str(&format!(107+- "<p class=\"notice\">placeholder: {kind} — preview omitted. \108++ "<p class=\"notice\">placeholder: too large to preview ({size} bytes) — \109+ <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",110+ href_esc(f)111+ ));112+- } else if is_markdown(f) {113+- let display = String::from_utf8_lossy(&bytes).into_owned();114+- body.push_str(&file_meta_bar(115+- f,116+- bytes.len() as u64,117+- Some(display.lines().count()),118+- ));119+- body.push_str(&markdown_to_html(&display));120+- body.push_str(&format!(121+- "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",122+- href_esc(f)123+- ));124+ } else {125+- let display = String::from_utf8_lossy(&bytes).into_owned();126+- body.push_str(&file_meta_bar(127+- f,128+- bytes.len() as u64,129+- Some(display.lines().count()),130+- ));131+- body.push_str(&render_text_pre(&display));132++ let bytes = blobs.get(f).cloned().unwrap_or_default();133++ if is_placeholder(&bytes) {134++ body.push_str(&file_meta_bar(f, bytes.len() as u64, None));135++ let kind = if bytes.len() >= PREVIEW_MAX_BYTES {136++ format!("too large to preview ({} bytes)", bytes.len())137++ } else {138++ "binary file".to_string()139++ };140++ body.push_str(&format!(141++ "<p class=\"notice\">placeholder: {kind} — preview omitted. \142++ <a href=\"/raw/{}\">download the raw file</a> instead.</p>\n",143++ href_esc(f)144++ ));145++ } else if is_markdown(f) {146++ let display = String::from_utf8_lossy(&bytes).into_owned();147++ body.push_str(&file_meta_bar(148++ f,149++ bytes.len() as u64,150++ Some(display.lines().count()),151++ ));152++ body.push_str(&markdown_to_html(&display));153++ body.push_str(&format!(154++ "<p class=\"muted\"><a href=\"/raw/{}\">view raw</a></p>\n",155++ href_esc(f)156++ ));157++ } else {158++ let display = String::from_utf8_lossy(&bytes).into_owned();159++ body.push_str(&file_meta_bar(160++ f,161++ bytes.len() as u64,162++ Some(display.lines().count()),163++ ));164++ body.push_str(&render_text_pre(&display));165++ }166+ }167+ let html = crate::layout::page_site_with_provenance(168+ &format!("{f} — {name}"),169+diff --git a/src/gitx.rs b/src/gitx.rs170+index ac68f8f..8d09364 100644171+--- a/src/gitx.rs172++++ b/src/gitx.rs173+@@ -7,7 +7,7 @@174+ #![allow(dead_code)]175+ 176+ use std::ffi::{OsStr, OsString};177+-use std::io::Read;178++use std::io::{Read, Write};179+ use std::path::{Path, PathBuf};180+ use std::process::{Command, Stdio};181+ use std::sync::mpsc;182+@@ -669,6 +669,128 @@ pub fn show(repo: &Path, branch: &str, path: &Path) -> Result<Blob, GitError> {183+ })184+ }185+ 186++/// Size in bytes of `branch:path` without reading it (`git cat-file -s`).187++///188++/// Callers use this to avoid loading very large blobs into memory189++/// (f0rge #66): a 400 MB blob used to abort the process in the190++/// allocator.191++pub fn blob_size(repo: &Path, branch: &str, path: &Path) -> Result<u64, GitError> {192++ let mut spec = OsString::from(branch);193++ spec.push(":");194++ spec.push(path.as_os_str());195++ let a_cat: &OsStr = OsStr::new("cat-file");196++ let a_size: &OsStr = OsStr::new("-s");197++ let args: Vec<&OsStr> = vec![a_cat, a_size, spec.as_os_str()];198++ let out = run_git(repo, &args)?;199++ if !out.status_ok {200++ let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();201++ return Err(GitError::NotFound(format!(202++ "`{branch}:{}` not found: {stderr}",203++ path.to_string_lossy()204++ )));205++ }206++ String::from_utf8_lossy(&out.stdout)207++ .trim()208++ .parse::<u64>()209++ .map_err(|e| GitError::Failed {210++ cmd: cmd_string(repo, &args),211++ status: "parse".to_string(),212++ stderr: format!("cat-file -s: {e}"),213++ })214++}215++216++/// Stream `branch:path` to `dest` without buffering it in the process.217++///218++/// Used for blobs above the in-memory cap (f0rge #66). The destination219++/// is removed again if git fails or the timeout fires, so a partial220++/// file never survives a failed build.221++pub fn show_to_file(repo: &Path, branch: &str, path: &Path, dest: &Path) -> Result<(), GitError> {222++ let mut spec = OsString::from(branch);223++ spec.push(":");224++ spec.push(path.as_os_str());225++ let a_show: &OsStr = OsStr::new("show");226++ let args: Vec<&OsStr> = vec![a_show, spec.as_os_str()];227++228++ let mut cmd = Command::new("git");229++ cmd.arg("-C")230++ .arg(repo)231++ .args(&args)232++ .stdout(Stdio::piped())233++ .stderr(Stdio::piped());234++ let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;235++ let stdout = child.stdout.take();236++ let stderr = child.stderr.take();237++238++ let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();239++ if let Some(mut err) = stderr {240++ thread::spawn(move || {241++ let mut buf = Vec::new();242++ let _ = err.read_to_end(&mut buf);243++ let _ = tx_err.send(buf);244++ });245++ } else {246++ let _ = tx_err.send(Vec::new());247++ }248++249++ let dest_owned = dest.to_path_buf();250++ let (tx_copy, rx_copy) = mpsc::channel::<std::io::Result<u64>>();251++ if let Some(mut out) = stdout {252++ thread::spawn(move || {253++ let file = match std::fs::File::create(&dest_owned) {254++ Ok(f) => f,255++ Err(e) => {256++ let _ = tx_copy.send(Err(e));257++ return;258++ }259++ };260++ let mut w = std::io::BufWriter::new(file);261++ let res = std::io::copy(&mut out, &mut w).and_then(|n| w.flush().map(|()| n));262++ let _ = tx_copy.send(res);263++ });264++ } else {265++ let _ = tx_copy.send(Err(std::io::Error::other("git show produced no stdout")));266++ }267++268++ let start = Instant::now();269++ let status = loop {270++ match child.try_wait() {271++ Err(e) => {272++ let _ = std::fs::remove_file(dest);273++ return Err(GitError::Failed {274++ cmd: cmd_string(repo, &args),275++ status: "wait-error".to_string(),276++ stderr: e.to_string(),277++ });278++ }279++ Ok(Some(status)) => break status,280++ Ok(None) => {281++ if start.elapsed() > GIT_TIMEOUT {282++ let _ = child.kill();283++ let _ = child.wait();284++ let _ = std::fs::remove_file(dest);285++ return Err(GitError::Timeout(cmd_string(repo, &args)));286++ }287++ thread::sleep(Duration::from_millis(10));288++ }289++ }290++ };291++ let written = rx_copy292++ .recv_timeout(Duration::from_secs(5))293++ .unwrap_or_else(|_| Err(std::io::Error::other("copy thread hung")));294++ let stderr = rx_err295++ .recv_timeout(Duration::from_secs(5))296++ .unwrap_or_default();297++ if !status.success() || written.is_err() {298++ let _ = std::fs::remove_file(dest);299++ return Err(GitError::Failed {300++ cmd: cmd_string(repo, &args),301++ status: status.to_string(),302++ stderr: String::from_utf8_lossy(&stderr).trim().to_string(),303++ });304++ }305++ Ok(())306++}307++308+ /// Unified diff text for one commit `sha`.309+ ///310+ /// Binary diffs keep git's `Binary files ... differ` note (or gain a311+diff --git a/tests/large_blob.rs b/tests/large_blob.rs312+new file mode 100644313+index 0000000..00414c7314+--- /dev/null315++++ b/tests/large_blob.rs316+@@ -0,0 +1,71 @@317++//! f0rge #66: build must not read every blob fully into memory. A blob318++//! over the inline cap is streamed to `raw/`; its page is a placeholder319++//! and the build still succeeds.320++321++use std::path::{Path, PathBuf};322++use std::process::Command;323++324++fn git(repo: &Path, args: &[&str]) {325++ let st = Command::new("git")326++ .arg("-C")327++ .arg(repo)328++ .args(args)329++ .status()330++ .expect("spawn git");331++ assert!(st.success(), "git {args:?} failed");332++}333++334++fn fresh_dir(prefix: &str) -> PathBuf {335++ let dir = std::env::temp_dir().join(format!(336++ "hu13-large-{prefix}-{}-{}",337++ std::process::id(),338++ std::time::SystemTime::now()339++ .duration_since(std::time::UNIX_EPOCH)340++ .map(|d| d.as_nanos())341++ .unwrap_or(0)342++ ));343++ std::fs::create_dir_all(&dir).unwrap();344++ dir345++}346++347++#[test]348++fn oversized_blob_is_streamed_and_never_aborts() {349++ let repo = fresh_dir("repo");350++ let out = fresh_dir("out");351++ git(&repo, &["init", "-b", "main"]);352++ git(&repo, &["config", "user.name", "hu13-test"]);353++ git(&repo, &["config", "user.email", "[email protected]"]);354++ git(&repo, &["config", "commit.gpgsign", "false"]);355++ std::fs::write(repo.join("README.md"), "# large blob\n").unwrap();356++ // 12 MiB of text: above MAX_INLINE_BLOB (8 MiB), compressible enough357++ // for the test to stay fast.358++ let big = vec![b'a'; 12 * 1024 * 1024];359++ std::fs::write(repo.join("big.txt"), &big).unwrap();360++ git(&repo, &["add", "-A"]);361++ git(&repo, &["commit", "-m", "big"]);362++363++ let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))364++ .arg("build")365++ .arg(&repo)366++ .arg("-o")367++ .arg(&out)368++ .output()369++ .expect("spawn hu13 build");370++ let stderr = String::from_utf8_lossy(&produced.stderr);371++ assert!(produced.status.success(), "build failed: {stderr}");372++373++ // Raw copy is byte-identical.374++ let raw = std::fs::read(out.join("raw/big.txt")).expect("raw big.txt");375++ assert_eq!(raw.len(), big.len());376++ assert!(raw.iter().all(|b| *b == b'a'));377++378++ // Page is a placeholder, not 12 MiB of spans.379++ let page = std::fs::read_to_string(out.join("files/big.txt.html")).expect("page");380++ assert!(page.contains("too large to preview"), "placeholder shown");381++ assert!(page.contains("download the raw file"));382++ assert!(page.len() < 200_000, "page stayed small: {}", page.len());383++384++ // A normal file still gets a real preview.385++ let readme = std::fs::read_to_string(out.join("files/README.md.html")).expect("readme");386++ assert!(readme.contains("<h1>README.md</h1>") || readme.contains("README.md"));387++}388+-- 389+2.56.0390+