Repository: prefix-dev/rattler-build
Pull request #1579: feat: optimize CarriageReturnToNewline for better performance with buffer processing
Author: login: zelosleone
Created: 2025-04-22T13:34:04Z | Merged: 2025-04-23T16:19:34Z

this pr directly adds the condition to replace \r with \n only if the following character is not \n

also added some checks, like for empty buffers we just skip them so they dont overload the loop, plus i made sure to use smaller buffer threshold (32 bytes) which significantly sped up the output printing. since the github windows runner increased its time, we now don't scan chunks one by one we just directly process them. 

results are incredibly interesting, hopefully its not just my local machine

from 176.24 sec to 123 sec now

@wolfv would be perfect if you also tested it locally to check the e2e tests time


Diff:
diff --git a/Cargo.lock b/Cargo.lock
index ecac25505..c07f25c37 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -71,6 +71,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "anes"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+
 [[package]]
 name = "ansi-to-tui"
 version = "7.0.0"
@@ -201,9 +207,9 @@ dependencies = [
 
 [[package]]
 name = "async-compression"
-version = "0.4.22"
+version = "0.4.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64"
+checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07"
 dependencies = [
  "bzip2",
  "flate2",
@@ -556,6 +562,12 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
 
+[[package]]
+name = "cast"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+
 [[package]]
 name = "castaway"
 version = "0.2.3"
@@ -612,6 +624,33 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
 [[package]]
 name = "cipher"
 version = "0.4.4"
@@ -624,9 +663,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.36"
+version = "4.5.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04"
+checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -645,9 +684,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.36"
+version = "4.5.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5"
+checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
 dependencies = [
  "anstream",
  "anstyle",
@@ -839,6 +878,42 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "criterion"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
+dependencies = [
+ "anes",
+ "cast",
+ "ciborium",
+ "clap",
+ "criterion-plot",
+ "is-terminal",
+ "itertools 0.10.5",
+ "num-traits",
+ "once_cell",
+ "oorandom",
+ "plotters",
+ "rayon",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "tinytemplate",
+ "walkdir",
+]
+
+[[package]]
+name = "criterion-plot"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
+dependencies = [
+ "cast",
+ "itertools 0.10.5",
+]
+
 [[package]]
 name = "crossbeam-deque"
 version = "0.8.6"
@@ -1611,6 +1686,16 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "half"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+]
+
 [[package]]
 name = "halfbrown"
 version = "0.2.5"
@@ -1675,6 +1760,12 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
 
+[[package]]
+name = "hermit-abi"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e"
+
 [[package]]
 name = "hex"
 version = "0.4.3"
@@ -2146,6 +2237,17 @@ version = "2.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
 
+[[package]]
+name = "is-terminal"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
+dependencies = [
+ "hermit-abi 0.5.0",
+ "libc",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "is_ci"
 version = "1.2.0"
@@ -2158,6 +2260,15 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.12.1"
@@ -2329,9 +2440,9 @@ dependencies = [
 
 [[package]]
 name = "libm"
-version = "0.2.11"
+version = "0.2.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
+checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72"
 
 [[package]]
 name = "libredox"
@@ -2378,12 +2489,6 @@ dependencies = [
  "scopeguard",
 ]
 
-[[package]]
-name = "lockfree-object-pool"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"
-
 [[package]]
 name = "log"
 version = "0.4.27"
@@ -2734,6 +2839,12 @@ version = "1.21.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
 
+[[package]]
+name = "oorandom"
+version = "11.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+
 [[package]]
 name = "opendal"
 version = "0.52.0"
@@ -2765,9 +2876,9 @@ dependencies = [
 
 [[package]]
 name = "opendal"
-version = "0.53.0"
+version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5ebd1183902124c6b3ee0a9383683513dd8cca3d25a5d065593f969a44f979e"
+checksum = "c0f407ca2797ca6c010720aa3cedfa0187430e4f70b729c75c33142d44021f59"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -2779,10 +2890,11 @@ dependencies = [
  "futures",
  "getrandom 0.2.15",
  "http",
+ "http-body",
  "log",
  "md-5",
  "percent-encoding",
- "quick-xml 0.36.2",
+ "quick-xml 0.37.4",
  "reqsign",
  "reqwest",
  "serde",
@@ -3099,6 +3211,34 @@ dependencies = [
  "time",
 ]
 
+[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "plotters-svg",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
+name = "plotters-svg"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
+dependencies = [
+ "plotters-backend",
+]
+
 [[package]]
 name = "polling"
 version = "3.7.4"
@@ -3219,9 +3359,9 @@ dependencies = [
 
 [[package]]
 name = "quinn-proto"
-version = "0.11.10"
+version = "0.11.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
+checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b"
 dependencies = [
  "bytes",
  "getrandom 0.3.2",
@@ -3414,6 +3554,7 @@ dependencies = [
  "comfy-table",
  "console",
  "content_inspector",
+ "criterion",
  "crossterm",
  "dunce",
  "flate2",
@@ -3435,7 +3576,7 @@ dependencies = [
  "miette",
  "minijinja",
  "num_cpus",
- "opendal 0.53.0",
+ "opendal 0.53.1",
  "pathdiff",
  "petgraph 0.8.1",
  "ratatui",
@@ -4349,9 +4490,9 @@ dependencies = [
 
 [[package]]
 name = "scc"
-version = "2.3.3"
+version = "2.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea091f6cac2595aa38993f04f4ee692ed43757035c36e67c180b6828356385b1"
+checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4"
 dependencies = [
  "sdd",
 ]
@@ -4697,9 +4838,9 @@ dependencies = [
 
 [[package]]
 name = "signal-hook-registry"
-version = "1.4.2"
+version = "1.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
 dependencies = [
  "libc",
 ]
@@ -5147,6 +5288,16 @@ dependencies = [
  "zerovec",
 ]
 
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "tinyvec"
 version = "1.9.0"
@@ -5219,6 +5370,7 @@ checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
 dependencies = [
  "bytes",
  "futures-core",
+ "futures-io",
  "futures-sink",
  "pin-project-lite",
  "tokio",
@@ -6592,15 +6744,13 @@ dependencies = [
 
 [[package]]
 name = "zopfli"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946"
+checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
 dependencies = [
  "bumpalo",
  "crc32fast",
- "lockfree-object-pool",
  "log",
- "once_cell",
  "simd-adler32",
 ]
 
diff --git a/Cargo.toml b/Cargo.toml
index 9ac152376..2c4a1365c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -108,7 +108,7 @@ fs-err = "3.1.0"
 which = "7.0.3"
 clap_complete = "4.5.47"
 clap_complete_nushell = "4.5.5"
-tokio-util = "0.7.14"
+tokio-util = { version = "0.7.14", features = ["codec", "compat"] }
 
 tar = "0.4.44"
 zip = { version = "2.6.1", default-features = false, features = [
@@ -188,6 +188,11 @@ rstest = "0.25.0"
 tracing-test = "0.2.5"
 tracing-indicatif = "0.3.9"
 serial_test = "3.2.0"
+criterion = { version = "0.5.1", features = ["html_reports"] }
+
+[[bench]]
+name = "line_breaks"
+harness = false
 
 [patch.crates-io]
 # rattler = { git = "https://github.com/wolfv/rattler", branch = "pub-schema" }
diff --git a/benches/line_breaks.rs b/benches/line_breaks.rs
new file mode 100644
index 000000000..f4cffb5ae
--- /dev/null
+++ b/benches/line_breaks.rs
@@ -0,0 +1,112 @@
+use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
+use rattler_build::script::CrLfNormalizer;
+use tokio_util::bytes::BytesMut;
+use tokio_util::codec::Decoder;
+
+fn normalize_line_breaks(c: &mut Criterion) {
+    let mut group = c.benchmark_group("line_breaks");
+    group.sample_size(10000);
+
+    // Test 1: Realistic command output with carriage returns
+    let command_output = [
+        "pixi is based\r\n",
+        "rattler is based\r\n",
+        "conda-forge is based\r\n",
+        "pixi build is based\r\n",
+        "rattler-build is based\r\n",
+        "prefix.dev is based\r\n",
+    ]
+    .join("");
+    let large_command_output = command_output.repeat(100); // Make it larger for stable benchmarking
+
+    group.throughput(Throughput::Bytes(large_command_output.len() as u64));
+    group.bench_with_input(
+        BenchmarkId::new("command_output", large_command_output.len()),
+        &large_command_output,
+        |b, s| {
+            b.iter(|| {
+                let mut normalizer = CrLfNormalizer::default();
+                let mut buffer = BytesMut::from(black_box(s.as_bytes()));
+                normalizer.decode(&mut buffer).unwrap();
+            });
+        },
+    );
+
+    // Test 2: Build script output with mixed line breaks
+    let build_script_output = [
+        "line 1\r",
+        "line 2\r",
+        "line 3\r",
+        "line 4\r",
+        "line 5\r",
+        "line 6\r",
+        "line 7\r",
+        "line 8\r",
+        "line 9\r",
+        "line 10\r",
+        "done\n",
+    ]
+    .join("");
+    let large_build_script_output = build_script_output.repeat(100);
+
+    group.throughput(Throughput::Bytes(large_build_script_output.len() as u64));
+    group.bench_with_input(
+        BenchmarkId::new("build_script_output", large_build_script_output.len()),
+        &large_build_script_output,
+        |b, s| {
+            b.iter(|| {
+                let mut normalizer = CrLfNormalizer::default();
+                let mut buffer = BytesMut::from(black_box(s.as_bytes()));
+                normalizer.decode(&mut buffer).unwrap();
+            });
+        },
+    );
+
+    // Test 3: Simulated chunked process output (as seen in run_process_with_replacements)
+    let process_output = "This is line 1\r\nThis is line 2\r\nThis is line 3\r\n".repeat(50);
+    let chunks: Vec<&[u8]> = process_output.as_bytes().chunks(16).collect();
+
+    group.throughput(Throughput::Bytes(process_output.len() as u64));
+    group.bench_with_input(
+        BenchmarkId::new("chunked_process_output", chunks.len()),
+        &chunks,
+        |b, chunks| {
+            b.iter(|| {
+                let mut normalizer = CrLfNormalizer::default();
+                for &chunk in chunks.iter() {
+                    let mut buffer = BytesMut::from(black_box(chunk));
+                    normalizer.decode(&mut buffer).unwrap();
+                }
+            });
+        },
+    );
+
+    // Test 4: Handling split CRLF sequence across chunks
+    let part1 = "This is a test line ending with CR\r";
+    let part2 = "\nThis is the next line";
+    let large_test = (0..1000)
+        .map(|i| if i % 2 == 0 { part1 } else { part2 })
+        .collect::<String>();
+
+    let chunks: Vec<&[u8]> = large_test.as_bytes().chunks(part1.len()).collect();
+
+    group.throughput(Throughput::Bytes(large_test.len() as u64));
+    group.bench_with_input(
+        BenchmarkId::new("split_crlf_chunks", chunks.len()),
+        &chunks,
+        |b, chunks| {
+            b.iter(|| {
+                let mut normalizer = CrLfNormalizer::default();
+                for &chunk in chunks.iter() {
+                    let mut buffer = BytesMut::from(black_box(chunk));
+                    normalizer.decode(&mut buffer).unwrap();
+                }
+            });
+        },
+    );
+
+    group.finish();
+}
+
+criterion_group!(benches, normalize_line_breaks);
+criterion_main!(benches);
diff --git a/py-rattler-build/Cargo.lock b/py-rattler-build/Cargo.lock
index a44c2bb72..33be23e61 100644
--- a/py-rattler-build/Cargo.lock
+++ b/py-rattler-build/Cargo.lock
@@ -35,7 +35,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
 dependencies = [
  "cfg-if",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "once_cell",
  "version_check",
  "zerocopy 0.7.35",
@@ -182,9 +182,9 @@ dependencies = [
 
 [[package]]
 name = "async-compression"
-version = "0.4.22"
+version = "0.4.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59a194f9d963d8099596278594b3107448656ba73831c9d8c783e613ce86da64"
+checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07"
 dependencies = [
  "bzip2",
  "flate2",
@@ -590,9 +590,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "4.5.36"
+version = "4.5.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04"
+checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -611,9 +611,9 @@ dependencies = [
 
 [[package]]
 name = "clap_builder"
-version = "4.5.36"
+version = "4.5.37"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5"
+checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2"
 dependencies = [
  "anstream",
  "anstyle",
@@ -724,7 +724,7 @@ version = "0.1.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
 dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "once_cell",
  "tiny-keccak",
 ]
@@ -1396,9 +1396,9 @@ dependencies = [
 
 [[package]]
 name = "getrandom"
-version = "0.2.15"
+version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
 dependencies = [
  "cfg-if",
  "js-sys",
@@ -2231,9 +2231,9 @@ dependencies = [
 
 [[package]]
 name = "libm"
-version = "0.2.11"
+version = "0.2.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
+checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72"
 
 [[package]]
 name = "libredox"
@@ -2274,12 +2274,6 @@ dependencies = [
  "scopeguard",
 ]
 
-[[package]]
-name = "lockfree-object-pool"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e"
-
 [[package]]
 name = "log"
 version = "0.4.27"
@@ -2634,7 +2628,7 @@ dependencies = [
  "chrono",
  "crc32c",
  "futures",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "http",
  "log",
  "md-5",
@@ -2651,9 +2645,9 @@ dependencies = [
 
 [[package]]
 name = "opendal"
-version = "0.53.0"
+version = "0.53.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5ebd1183902124c6b3ee0a9383683513dd8cca3d25a5d065593f969a44f979e"
+checksum = "c0f407ca2797ca6c010720aa3cedfa0187430e4f70b729c75c33142d44021f59"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -2663,12 +2657,13 @@ dependencies = [
  "chrono",
  "crc32c",
  "futures",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "http",
+ "http-body",
  "log",
  "md-5",
  "percent-encoding",
- "quick-xml 0.36.2",
+ "quick-xml 0.37.4",
  "reqsign",
  "reqwest",
  "serde",
@@ -3059,9 +3054,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3"
-version = "0.24.1"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17da310086b068fbdcefbba30aeb3721d5bb9af8db4987d6735b2183ca567229"
+checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219"
 dependencies = [
  "cfg-if",
  "indoc",
@@ -3078,9 +3073,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-build-config"
-version = "0.24.1"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e27165889bd793000a098bb966adc4300c312497ea25cf7a690a9f0ac5aa5fc1"
+checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999"
 dependencies = [
  "once_cell",
  "target-lexicon",
@@ -3088,9 +3083,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-ffi"
-version = "0.24.1"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05280526e1dbf6b420062f3ef228b78c0c54ba94e157f5cb724a609d0f2faabc"
+checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33"
 dependencies = [
  "libc",
  "pyo3-build-config",
@@ -3098,9 +3093,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-macros"
-version = "0.24.1"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c3ce5686aa4d3f63359a5100c62a127c9f15e8398e5fdeb5deef1fed5cd5f44"
+checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9"
 dependencies = [
  "proc-macro2",
  "pyo3-macros-backend",
@@ -3110,9 +3105,9 @@ dependencies = [
 
 [[package]]
 name = "pyo3-macros-backend"
-version = "0.24.1"
+version = "0.24.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4cf6faa0cbfb0ed08e89beb8103ae9724eb4750e3a78084ba4017cbe94f3855"
+checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -3172,9 +3167,9 @@ dependencies = [
 
 [[package]]
 name = "quinn-proto"
-version = "0.11.10"
+version = "0.11.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
+checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b"
 dependencies = [
  "bytes",
  "getrandom 0.3.2",
@@ -3272,7 +3267,7 @@ version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
 dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
 ]
 
 [[package]]
@@ -3364,7 +3359,7 @@ dependencies = [
  "miette",
  "minijinja",
  "num_cpus",
- "opendal 0.53.0",
+ "opendal 0.53.1",
  "pathdiff",
  "petgraph 0.8.1",
  "rattler",
@@ -3585,7 +3580,7 @@ dependencies = [
  "base64 0.22.1",
  "dirs",
  "fs-err",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "google-cloud-auth",
  "http",
  "itertools 0.14.0",
@@ -3825,7 +3820,7 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
 dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "libredox",
  "thiserror 2.0.12",
 ]
@@ -3917,7 +3912,7 @@ dependencies = [
  "base64 0.22.1",
  "chrono",
  "form_urlencoded",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "hex",
  "hmac",
  "home",
@@ -4012,7 +4007,7 @@ dependencies = [
  "anyhow",
  "async-trait",
  "futures",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "http",
  "hyper",
  "parking_lot 0.11.2",
@@ -4059,7 +4054,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
 dependencies = [
  "cc",
  "cfg-if",
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "libc",
  "untrusted",
  "windows-sys 0.52.0",
@@ -4510,9 +4505,9 @@ dependencies = [
 
 [[package]]
 name = "signal-hook-registry"
-version = "1.4.2"
+version = "1.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
 dependencies = [
  "libc",
 ]
@@ -4529,7 +4524,7 @@ version = "0.14.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "aa2bcf6c6e164e81bc7a5d49fc6988b3d515d9e8c07457d7b74ffb9324b9cd40"
 dependencies = [
- "getrandom 0.2.15",
+ "getrandom 0.2.16",
  "halfbrown",
  "ref-cast",
  "serde",
@@ -5000,6 +4995,7 @@ checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
 dependencies = [
  "bytes",
  "futures-core",
+ "futures-io",
  "futures-sink",
  "pin-project-lite",
  "tokio",
@@ -6292,15 +6288,13 @@ dependencies = [
 
 [[package]]
 name = "zopfli"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946"
+checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
 dependencies = [
  "bumpalo",
  "crc32fast",
- "lockfree-object-pool",
  "log",
- "once_cell",
  "simd-adler32",
 ]
 
diff --git a/src/script/mod.rs b/src/script/mod.rs
index 13f1c4647..e8bdee5a6 100644
--- a/src/script/mod.rs
+++ b/src/script/mod.rs
@@ -4,6 +4,7 @@ mod sandbox;
 pub use sandbox::{SandboxArguments, SandboxConfiguration};
 
 use crate::script::interpreter::Interpreter;
+use futures::TryStreamExt;
 use indexmap::IndexMap;
 use interpreter::{
     BashInterpreter, CmdExeInterpreter, NuShellInterpreter, PerlInterpreter, PythonInterpreter,
@@ -13,16 +14,20 @@ use itertools::Itertools;
 use minijinja::Value;
 use rattler_conda_types::Platform;
 use rattler_shell::shell;
-use std::collections::HashSet;
-use std::ffi::OsStr;
 use std::{
     collections::HashMap,
+    collections::HashSet,
+    ffi::OsStr,
+    io,
     path::{Path, PathBuf},
-    pin::Pin,
     process::Stdio,
-    task::{Context, Poll},
 };
-use tokio::io::{AsyncBufReadExt, AsyncRead, ReadBuf};
+use tokio::io::{AsyncBufReadExt, AsyncRead};
+use tokio_util::{
+    bytes::BytesMut,
+    codec::{Decoder, FramedRead},
+    compat::FuturesAsyncReadCompatExt,
+};
 
 use crate::{
     env_vars::{self},
@@ -530,33 +535,51 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
-                    }
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+pub struct CrLfNormalizer {
+    last_was_cr: bool,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        let mut bytes = src.split_off(0);
+        let mut read_index = 0;
+        let mut write_index = 0;
+        while read_index < bytes.len() {
+            match bytes[read_index] {
+                b'\r' => {
+                    bytes[write_index] = b'\n';
+                    write_index += 1;
+                    self.last_was_cr = true;
+                }
+                b'\n' if self.last_was_cr => {
+                    // Skip writing the newline if the last byte was a carriage return.
+                    self.last_was_cr = false
+                }
+                b => {
+                    bytes[write_index] = b;
+                    write_index += 1;
+                    self.last_was_cr = false;
                 }
-                Poll::Ready(Ok(()))
             }
-            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
-            Poll::Pending => Poll::Pending,
+            read_index += 1;
+        }
+
+        if write_index == 0 {
+            Ok(None)
+        } else {
+            bytes.truncate(write_index);
+            Ok(Some(bytes))
         }
     }
 }
@@ -614,8 +637,8 @@ async fn run_process_with_replacements(
     let stdout = child.stdout.take().expect("Failed to take stdout");
     let stderr = child.stderr.take().expect("Failed to take stderr");
 
-    let stdout_wrapped = CarriageReturnToNewline { inner: stdout };
-    let stderr_wrapped = CarriageReturnToNewline { inner: stderr };
+    let stdout_wrapped = normalize_crlf(stdout);
+    let stderr_wrapped = normalize_crlf(stderr);
 
     let mut stdout_lines = tokio::io::BufReader::new(stdout_wrapped).lines();
     let mut stderr_lines = tokio::io::BufReader::new(stderr_wrapped).lines();
@@ -669,3 +692,120 @@ async fn run_process_with_replacements(
         stderr: stderr_log.into_bytes(),
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use tokio_util::bytes::BytesMut;
+
+    #[test]
+    fn test_crlf_normalizer_no_crlf() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::from("test string with no CR or LF");
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), "test string with no CR or LF");
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_crlf() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::from("line1\r\nline2\r\nline3");
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), "line1\nline2\nline3");
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_cr_only() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::from("line1\rline2\rline3");
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), "line1\nline2\nline3");
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_cr_at_end() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::from("line1\r");
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), "line1\n");
+        assert!(normalizer.last_was_cr);
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_split_crlf() {
+        let mut normalizer = CrLfNormalizer::default();
+
+        // decoder gets the \r until final part of the buffer so that it doesnt try to solve it as none
+        let mut buffer1 = BytesMut::from("line1\r");
+        let result1 = normalizer.decode(&mut buffer1).unwrap();
+        assert!(result1.is_some());
+        assert_eq!(result1.unwrap(), "line1\n");
+        assert!(normalizer.last_was_cr);
+
+        let mut buffer2 = BytesMut::from("\nline2");
+        let result2 = normalizer.decode(&mut buffer2).unwrap();
+        assert!(result2.is_some());
+        assert_eq!(result2.unwrap(), "line2");
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_multiple_cr_at_end() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::from("line1\r\r\r");
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_some());
+        assert_eq!(result.unwrap(), "line1\n\n\n");
+        assert!(normalizer.last_was_cr);
+
+        let eof_result = normalizer.decode_eof(&mut BytesMut::new()).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_empty_buffer() {
+        let mut normalizer = CrLfNormalizer::default();
+        let mut buffer = BytesMut::new();
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_none());
+
+        let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
+        assert!(eof_result.is_none());
+    }
+
+    #[test]
+    fn test_crlf_normalizer_with_pending_cr_and_empty_buffer() {
+        let mut normalizer = CrLfNormalizer { last_was_cr: true };
+        let mut buffer = BytesMut::new();
+
+        let result = normalizer.decode(&mut buffer).unwrap();
+        assert!(result.is_none());
+
+        let eof_result = normalizer.decode_eof(&mut buffer).unwrap();
+        assert!(eof_result.is_none());
+    }
+}


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR optimizes the CarriageReturnToNewline processing to improve performance when processing buffers of varying sizes. Key changes include:
- Replacing carriage returns with newlines only when the following character is not a newline.
- Skipping processing on empty buffers and using a smaller buffer threshold (32 bytes) for a fast-path.
- Implementing a chunked processing loop to avoid scanning each byte individually.


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/script/mod.rs:557**
* [nitpick] Consider replacing the hard-coded buffer size thresholds (32 and 64) with named constants to improve readability and maintainability.
```
if len <= 32 {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-04-22T13:40:48Z
user: login: wolfv
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T13:50:51Z
user: login: wolfv
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T13:51:23Z
user: login: baszalmstra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T15:23:22Z
user: login: baszalmstra
body: I think we should add a nimber of unit tests to verify the correct behavior 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:22:59Z
user: login: baszalmstra
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:27:49Z
user: login: zelosleone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:05:16Z
user: login: zelosleone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:05:24Z
user: login: zelosleone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:07:03Z
user: login: zelosleone
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:08:22Z
user: login: wolfv
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T19:11:13Z
user: login: baszalmstra
body: I strongly doubt that this code has such a big impact on the performance. You are measuring the e2e tests which has lots of variables that affect its performance. Unless this part of the code is 90% of the overall runtime small micro optimizations around caches and such are never gonna contribute a major speedup.

I also think it makes the code less readable which is ultimately what I care about most. Thats why Im hammering on simplicity.

If you really want to squeeze out every bit of performance here you should write a benchmark that feeds this code lots of text to get a much better representation of speed.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T05:36:39Z
user: login: baszalmstra
body: Nice! Feel free to also add the benchmark!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T10:35:07Z

Review comments:
diff_hunk: @@ -547,12 +547,45 @@ impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
             Poll::Ready(Ok(())) => {
                 // Get a mutable slice of the *newly* filled part of the buffer
                 let newly_filled = &mut buf.filled_mut()[initial_filled..];
+                let len = newly_filled.len();
 
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+                if len == 0 {
+                    return Poll::Ready(Ok(()));
+                }
+
+                // Fast path for small buffers
+                if len <= 32 {
+                    let has_cr = newly_filled.iter().take(len).any(|&byte| byte == b'\r');
+
+                    if !has_cr {
+                        return Poll::Ready(Ok(()));
+                    }
+                }
+
+                let mut i = 0;
+                while i < len {
+                    let chunk_end = std::cmp::min(i + 64, len);
+                    let chunk_has_cr = newly_filled
+                        .iter()
+                        .skip(i)
+                        .take(chunk_end - i)
+                        .any(|&byte| byte == b'\r');
+
+                    if !chunk_has_cr {
+                        i = chunk_end;
+                        continue;
+                    }
+
+                    while i < chunk_end {
+                        if newly_filled[i] == b'\r'
+                            && (i + 1 >= len || newly_filled[i + 1] != b'\n')
+                        {
+                            newly_filled[i] = b'\n';
+                        }
+                        i += 1;
path: src/script/mod.rs
user: login: wolfv
body: I think this still needs to _erase_ `\r` if necessary, right?
created_at: 2025-04-22T13:50:51Z
author_association: MEMBER
diff_hunk: @@ -547,12 +547,45 @@ impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
             Poll::Ready(Ok(())) => {
                 // Get a mutable slice of the *newly* filled part of the buffer
                 let newly_filled = &mut buf.filled_mut()[initial_filled..];
+                let len = newly_filled.len();
 
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+                if len == 0 {
+                    return Poll::Ready(Ok(()));
+                }
+
+                // Fast path for small buffers
+                if len <= 32 {
+                    let has_cr = newly_filled.iter().take(len).any(|&byte| byte == b'\r');
+
+                    if !has_cr {
+                        return Poll::Ready(Ok(()));
+                    }
+                }
+
+                let mut i = 0;
+                while i < len {
path: src/script/mod.rs
user: login: wolfv
body: I think using Rust native `chunks()` iterator would look nicer.

E.g. 

```rust
let chunk_size = 64;
for chunk in newly_filled.chunks(chunk_size) {
    ...
}
```
created_at: 2025-04-22T13:51:23Z
author_association: MEMBER
diff_hunk: @@ -530,33 +534,85 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
-                    }
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        let mut result = BytesMut::with_capacity(src.len());
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
+            result.put_u8(b'\n');
+            if src[0] == b'\n' {
+                read_idx = 1;
+            }
+        }
+
+        while read_idx < src.len() {
+            match src[read_idx] {
+                b'\r' if read_idx + 1 < src.len() && src[read_idx + 1] == b'\n' => {
+                    result.put_u8(b'\n');
+                    read_idx += 2;
+                }
+                b'\r' if read_idx + 1 == src.len() => {
+                    self.pending_cr = true;
+                    read_idx += 1;
+                    break;
+                }
+                b'\r' => {
+                    result.put_u8(b'\n');
+                    read_idx += 1;
+                }
+                b => {
+                    result.put_u8(b);
+                    read_idx += 1;
                 }
path: src/script/mod.rs
user: login: baszalmstra
body: ```suggestion
        while read_idx < src.len() {        
            match src[read_idx] {
                '\r' => {  
                    // Skip it.
                }
                b => {
                    if self.pending_cr && b != '\n' {
                       result.put_u8('\r');
                    }
                    result.put_u8(b);
                }
             }
             self.pending_cr = src[read_idx] == '\r';
             read_idx += 1;
```
created_at: 2025-04-22T15:23:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
path: src/script/mod.rs
user: login: baszalmstra
body: Why subtract the capacity?
created_at: 2025-04-22T16:20:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
path: src/script/mod.rs
user: login: baszalmstra
body: Why is this block needed?
created_at: 2025-04-22T16:20:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
+            self.output_buffer.put_u8(b'\n');
+            if src[0] == b'\n' {
+                read_idx = 1;
+            }
+        }
+
+        while read_idx < src.len() {
+            let b = src[read_idx];
+            read_idx += 1;
+
+            if b == b'\r' {
+                if read_idx < src.len() {
+                    if src[read_idx] == b'\n' {
+                        read_idx += 1;
                     }
+                    self.output_buffer.put_u8(b'\n');
+                } else {
+                    self.pending_cr = true;
+                    break;
                 }
-                Poll::Ready(Ok(()))
+            } else {
+                self.output_buffer.put_u8(b);
             }
-            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
-            Poll::Pending => Poll::Pending,
+        }
+
+        let _processed = src.split_to(read_idx);
+
+        let has_output = !self.output_buffer.is_empty();
+        Ok(has_output.then(|| self.output_buffer.split().freeze().into()))
+    }
+
+    fn decode_eof(&mut self, buf: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        let final_chunk = self.decode(buf)?;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
path: src/script/mod.rs
user: login: baszalmstra
body: Is this correct? You are not processing any additional \r in the final block?
created_at: 2025-04-22T16:21:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
+            self.output_buffer.put_u8(b'\n');
+            if src[0] == b'\n' {
+                read_idx = 1;
+            }
+        }
+
+        while read_idx < src.len() {
+            let b = src[read_idx];
+            read_idx += 1;
+
+            if b == b'\r' {
+                if read_idx < src.len() {
+                    if src[read_idx] == b'\n' {
+                        read_idx += 1;
                     }
+                    self.output_buffer.put_u8(b'\n');
+                } else {
+                    self.pending_cr = true;
+                    break;
                 }
-                Poll::Ready(Ok(()))
+            } else {
+                self.output_buffer.put_u8(b);
             }
-            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
-            Poll::Pending => Poll::Pending,
+        }
+
+        let _processed = src.split_to(read_idx);
+
+        let has_output = !self.output_buffer.is_empty();
+        Ok(has_output.then(|| self.output_buffer.split().freeze().into()))
path: src/script/mod.rs
user: login: baszalmstra
body: You can also use std::mem::take instead of cloning the bytes
created_at: 2025-04-22T16:27:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
+            self.output_buffer.put_u8(b'\n');
+            if src[0] == b'\n' {
+                read_idx = 1;
+            }
+        }
+
+        while read_idx < src.len() {
+            let b = src[read_idx];
+            read_idx += 1;
+
+            if b == b'\r' {
+                if read_idx < src.len() {
+                    if src[read_idx] == b'\n' {
+                        read_idx += 1;
                     }
+                    self.output_buffer.put_u8(b'\n');
+                } else {
+                    self.pending_cr = true;
+                    break;
                 }
-                Poll::Ready(Ok(()))
+            } else {
+                self.output_buffer.put_u8(b);
             }
-            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
-            Poll::Pending => Poll::Pending,
+        }
+
+        let _processed = src.split_to(read_idx);
+
+        let has_output = !self.output_buffer.is_empty();
+        Ok(has_output.then(|| self.output_buffer.split().freeze().into()))
+    }
+
+    fn decode_eof(&mut self, buf: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        let final_chunk = self.decode(buf)?;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
path: src/script/mod.rs
user: login: zelosleone
body: yes, in general i think decode method should take care of this in my understanding, since it will stop processing them if true before. i will try to add an unit test to see if my logic is correct 
created_at: 2025-04-22T17:05:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
+            self.output_buffer.put_u8(b'\n');
+            if src[0] == b'\n' {
+                read_idx = 1;
+            }
+        }
+
+        while read_idx < src.len() {
+            let b = src[read_idx];
+            read_idx += 1;
+
+            if b == b'\r' {
+                if read_idx < src.len() {
+                    if src[read_idx] == b'\n' {
+                        read_idx += 1;
                     }
+                    self.output_buffer.put_u8(b'\n');
+                } else {
+                    self.pending_cr = true;
+                    break;
                 }
-                Poll::Ready(Ok(()))
+            } else {
+                self.output_buffer.put_u8(b);
             }
-            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
-            Poll::Pending => Poll::Pending,
+        }
+
+        let _processed = src.split_to(read_idx);
+
+        let has_output = !self.output_buffer.is_empty();
+        Ok(has_output.then(|| self.output_buffer.split().freeze().into()))
path: src/script/mod.rs
user: login: zelosleone
body: thanks for this, will do
created_at: 2025-04-22T17:05:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
+            .reserve(src.len().saturating_sub(self.output_buffer.capacity()));
+        let mut read_idx = 0;
+
+        if std::mem::replace(&mut self.pending_cr, false) {
path: src/script/mod.rs
user: login: zelosleone
body: it checks the previous buffer for \r lines, so basically we check when \r is at the end of the buffer and on the next buffer \n is at the beginning
created_at: 2025-04-22T17:07:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,33 +534,83 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    pending_cr: bool,
+    output_buffer: BytesMut,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return if std::mem::replace(&mut self.pending_cr, false) {
+                Ok(Some(BytesMut::from(&b"\n"[..])))
+            } else {
+                Ok(None)
+            };
+        }
+
+        self.output_buffer.clear();
+        self.output_buffer
path: src/script/mod.rs
user: login: zelosleone
body: i think it was required to check the memory capacity during buffers to avoid overfilling it. so only when we see a difference we use it, which should be pretty fast and performance wise p good as well
created_at: 2025-04-22T17:08:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -530,34 +535,40 @@ impl Output {
 }
 
 /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes.
-struct CarriageReturnToNewline<R: AsyncRead + Unpin> {
-    inner: R,
+pub fn normalize_crlf<R: AsyncRead + Unpin>(reader: R) -> impl AsyncRead + Unpin {
+    FramedRead::new(reader, CrLfNormalizer::default())
+        .into_async_read()
+        .compat()
 }
 
-impl<R: AsyncRead + Unpin> AsyncRead for CarriageReturnToNewline<R> {
-    fn poll_read(
-        mut self: Pin<&mut Self>,
-        cx: &mut Context<'_>,
-        buf: &mut ReadBuf<'_>,
-    ) -> Poll<std::io::Result<()>> {
-        let initial_filled = buf.filled().len();
-
-        // Poll the inner reader
-        match Pin::new(&mut self.inner).poll_read(cx, buf) {
-            Poll::Ready(Ok(())) => {
-                // Get a mutable slice of the *newly* filled part of the buffer
-                let newly_filled = &mut buf.filled_mut()[initial_filled..];
-
-                for byte in newly_filled.iter_mut() {
-                    if *byte == b'\r' {
-                        *byte = b'\n';
-                    }
+/// Codec that normalizes CR and CRLF to LF
+#[derive(Default)]
+struct CrLfNormalizer {
+    last_was_cr: bool,
+}
+
+impl Decoder for CrLfNormalizer {
+    type Item = BytesMut;
+    type Error = io::Error;
+
+    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
+        if src.is_empty() {
+            return Ok(None);
+        }
+        let mut bytes = src.split_off(0);
+        let mut w = 0;
+        for r in 0..bytes.len() {
+            self.last_was_cr = match (bytes[r], self.last_was_cr) {
+                (b'\n', true) => false,
+                (b, _) => {
path: src/script/mod.rs
user: login: baszalmstra
body: You are again mixing match and if statements here. I find that harder to understand. Just use multiple matches. You are also checking if the byte is a CR twice.
created_at: 2025-04-23T05:28:02Z
author_association: CONTRIBUTOR

PR comments:
user: login: wolfv
created_at: 2025-04-22T16:13:24Z
body: Looks good
author_association: MEMBER
user: login: baszalmstra
created_at: 2025-04-22T16:18:40Z
body: What was broken rhat you had to revert?
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T16:55:07Z
body: > What was broken rhat you had to revert?

Basically the original approach of #1525 was not working correctly, I made a test on #linebreak folder with a recipe that has scripts on windows/unix as well, so lines were not showing correctly after the approach and it was back to old behavior
author_association: CONTRIBUTOR
user: login: baszalmstra
created_at: 2025-04-22T17:01:45Z
body: No I mean why did you revert the “simplify” commit? What was broken about it? 

I would really like to see some unit tests..
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T17:08:33Z
body: > I think we should add a nimber of unit tests to verify the correct behavior

Will do this as well, thank you!
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T17:09:04Z
body: > No I mean why did you revert the “simplify” commit? What was broken about it?
> 
> I would really like to see some unit tests..

Ah, you can run the recipe on line breaks with reverted commit and your commit, script results should have 10 lines shown, there are end to end tests for it

Let me also show you the output, one sec
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T17:12:27Z
body: with simplified commit:
![image](https://github.com/user-attachments/assets/a58647fe-81c7-4a84-ae2f-e859a6bcad6d)

with revert:
![image](https://github.com/user-attachments/assets/6650956e-afd8-45da-a2df-13a1e54dd7e2)

sorry for the light theme :(


`cargo run -p rattler-build -- build --recipe test-data/recipes/line-breaks/recipe.yaml` should show it
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T17:42:11Z
body: @baszalmstra I added the unit tests as well, also tried with the simplified commit on the unit tests and results with simplified commit:

```
---- script::tests::test_crlf_normalizer_with_multiple_cr_at_end stdout ----
thread 'script::tests::test_crlf_normalizer_with_multiple_cr_at_end' panicked at src\script\mod.rs:805:9:
assertion `left == right` failed
  left: b"line1\r\r"
 right: "line1\n\n"

---- script::tests::test_crlf_normalizer_with_cr_only stdout ----
thread 'script::tests::test_crlf_normalizer_with_cr_only' panicked at src\script\mod.rs:757:9:
assertion `left == right` failed
  left: b"line1\rline2\rline3"
 right: "line1\nline2\nline3"
```
author_association: CONTRIBUTOR
user: login: baszalmstra
created_at: 2025-04-22T18:06:30Z
body: I guess I misunderstood the assignment then because those test outputs look correct to me?

I understood that if a \r without a \n behind it was encountered it would simply retain the \r.
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T18:31:50Z
body: > I guess I misunderstood the assignment then because those test outputs look correct to me?
> 
> I understood that if a \r without a \n behind it was encountered it would simply retain the \r.

No no, it was my mistake to not to explain the core issue. Specially with the manual testing reports, I simply informed as "edge cases" but could have worded it better to you about how lines before final line isn't showing on the script and I didn't even mention about how we have a script + recipe to test it. Sorry for that really, could definitely have worded better and informed you before taking your time for simplifying the logic.

Btw, before the changes + default behavior was lines were not breaking into new lines, that's why we see them overlapping to blue lines. That's also why unit tests failed with the simplified logic, it would fail before introducing this wrapper as well. Because they were getting displayed/outed as one single line and it would hide other lines except the final line. This PR basically tries to solve it including other edge cases as well.
author_association: CONTRIBUTOR
user: login: baszalmstra
created_at: 2025-04-22T18:52:53Z
body: I just pushed a change that passes all tests and is much simpler. :)
author_association: CONTRIBUTOR
user: login: baszalmstra
created_at: 2025-04-22T19:01:24Z
body: And now it doesnt even need to allocate any additional bytes. :) 

@zelosleone I was mostly surprised that you just reverted the code without checking what the actual problem was. But Im happy you added tests to verify it! 
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T19:05:28Z
body: > I just pushed a change that passes all tests and is much simpler. :)

One day i will reach this level 🥹 I need to study your solution now so that i know why yours worked with simplified and elegant code :D 

I couldn't understand the problem of why the tests would fail without overcomplicating it, mainly because of that and trying to solve it would imply i would mess up your solution to add more complexity because honestly the only thing i could think of was adding more if/else to it. So instead, since you also wanted to check the tests, I added the tests. Thank you Bas, you are definitely a rock star :)
author_association: CONTRIBUTOR
user: login: baszalmstra
created_at: 2025-04-22T19:24:31Z
body: Would be curious to any speed improvements this gives you now @zelosleone 
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T19:36:43Z
body: > Would be curious to any speed improvements this gives you now @zelosleone

Github runners seems to improved actually but on local there is a decrease in improvement

167 seconds of full runtime of the e2e tests in this branch now
116 seconds of full runtime of the e2e tests in main branch x.x
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T19:56:38Z
body: https://www.reddit.com/r/rust/comments/w625ur/is_there_any_advantage_of_using_match_instead_of/
https://users.rust-lang.org/t/matching-vs-if-else/73564

Okay after reading this discussion, i decided to see if we can go with something else. since match seems a better approach for type safety as well, and i think there were some cache conflicts where we tried to write the same cache with both read/write operations which caused a huge performance drop. So i researched on google on how to solve this, there were very good arguments against using if/else instead of match. So that we use compiler's optimization to the fullness and make sure enums are correctly handles too etc.

Right now the performance runtime is 116 seconds, same as main branch. Can you check? @baszalmstra 
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T20:10:30Z
body: Okay, I did not expect this improvement on github runners as well. From formatting (pre commit) to e2e tests to CI/tests etc all jobs improved in runtime optimization!?

@baszalmstra @wolfv Everything besides windows e2e tests on github runners improved, before latest changes e2e test for windows runner improved but it was a lot of slower on local, why did that happen? I will take a look.
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-22T20:58:02Z
body: It seems like i cannot really optimize the github runner build on windows at all, although on local it's pretty fast ranging from 105 seconds to 116 seconds now. :(
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-23T10:20:13Z
body: > I strongly doubt that this code has such a big impact on the performance. You are measuring the e2e tests which has lots of variables that affect its performance. Unless this part of the code is 90% of the overall runtime small micro optimizations around caches and such are never gonna contribute a major speedup.
> 
> I also think it makes the code less readable which is ultimately what I care about most. Thats why Im hammering on simplicity.
> 
> If you really want to squeeze out every bit of performance here you should write a benchmark that feeds this code lots of text to get a much better representation of speed.

Okay, after writing a benchmark https://gist.github.com/zelosleone/cdde5003385c75390c93286ace6cb16e and using it, i can 100% say its just bas's solution was slower on e2e tests and prob network issues, and some others contributed to it. its perfectly valid and in fact, 35% beats my solution. I will be switching to Bas's solution
author_association: CONTRIBUTOR
user: login: zelosleone
created_at: 2025-04-23T10:49:47Z
body: > Nice! Feel free to also add the benchmark!

Added them as well
author_association: CONTRIBUTOR