From 0afe9934c4a6c359349000f99d24660037d1e672 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:41 +0900 Subject: [PATCH 1/9] fix(math): preserve exact integers and signed zero when rounding Fixes #554. Signed-off-by: LunaStev --- std/math/float.wave | 18 ++++- tests/cases/shared/test116.wave | 134 ++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 tests/cases/shared/test116.wave diff --git a/std/math/float.wave b/std/math/float.wave index c11bde6c..c6c1c4e2 100644 --- a/std/math/float.wave +++ b/std/math/float.wave @@ -262,13 +262,23 @@ pub fun ceil_f64(value: f64) -> f64 { } pub fun round_f32(value: f32) -> f32 { - if (value < 0.0) { return ceil_f32(value - 0.5); } - return floor_f32(value + 0.5); + // Subtraction from truncation preserves the halfway decision. Adding 0.5 + // to the original value can round an integer or its fractional part first. + var truncated: f32 = trunc_f32(value); + if (abs_f32(value - truncated) >= 0.5) { + return truncated + copy_sign_f32(1.0, value); + } + return truncated; } pub fun round_f64(value: f64) -> f64 { - if (value < 0.0) { return ceil_f64(value - 0.5); } - return floor_f64(value + 0.5); + // Subtraction from truncation preserves the halfway decision. Adding 0.5 + // to the original value can round an integer or its fractional part first. + var truncated: f64 = trunc_f64(value); + if (abs_f64(value - truncated) >= 0.5) { + return truncated + copy_sign_f64(1.0, value); + } + return truncated; } pub fun fract_f32(value: f32) -> f32 { diff --git a/tests/cases/shared/test116.wave b/tests/cases/shared/test116.wave new file mode 100644 index 00000000..c0652313 --- /dev/null +++ b/tests/cases/shared/test116.wave @@ -0,0 +1,134 @@ +// Round exact integers, halfway neighbors, and IEEE-754 special values. +import("std::math::float")::{ + round_f32, + float_from_bits_f32, + float_to_bits_f32, + infinity_f32, + round_f64, + float_from_bits_f64, + float_to_bits_f64, + infinity_f64, +}; + +fun check_f32() -> i32 { + var integer: f32 = 8388609.0; + if (round_f32(integer) != integer || round_f32(-integer) != -integer) { + return 1; + } + var below_half: f32 = float_from_bits_f32(0x3EFFFFFF as u32); + if (float_to_bits_f32(round_f32(below_half)) != 0) { + return 2; + } + if (float_to_bits_f32(round_f32(-below_half)) != (0x80000000 as u32)) { + return 3; + } + if (round_f32(0.5) != 1.0 || round_f32(-0.5) != -1.0) { + return 4; + } + var above_half: f32 = float_from_bits_f32((0x3EFFFFFF as u32) + 2); + if (round_f32(above_half) != 1.0 || round_f32(-above_half) != -1.0) { + return 5; + } + var negative_zero: f32 = float_from_bits_f32(0x80000000 as u32); + if (float_to_bits_f32(round_f32(negative_zero)) != (0x80000000 as u32)) { + return 6; + } + if (float_to_bits_f32(round_f32(float_from_bits_f32(1))) != 0) { + return 7; + } + if (float_to_bits_f32(round_f32(-float_from_bits_f32(1))) != (0x80000000 as u32)) { + return 8; + } + if (float_to_bits_f32(round_f32(float_from_bits_f32(0x7FC12345 as u32))) != (0x7FC12345 as u32)) { + return 9; + } + if (round_f32(infinity_f32()) != infinity_f32() || round_f32(-infinity_f32()) != -infinity_f32()) { + return 10; + } + var index: i32 = -32; + while (index <= 32) { + var whole: f32 = index as f32; + var offset: f32 = 0.5; + if (whole < 0.0) { + offset = -0.5; + } + var halfway: f32 = whole + offset; + var expected: f32 = whole + offset * 2.0; + if (round_f32(halfway) != expected || round_f32(whole) != whole) { + return 11; + } + if (round_f32(whole + 0.25) != whole || round_f32(whole - 0.25) != whole) { + return 12; + } + index += 1; + } + return 0; +} + +fun check_f64() -> i32 { + var integer: f64 = 4503599627370497.0; + if (round_f64(integer) != integer || round_f64(-integer) != -integer) { + return 1; + } + var below_half: f64 = float_from_bits_f64(0x3FDFFFFFFFFFFFFF as u64); + if (float_to_bits_f64(round_f64(below_half)) != 0) { + return 2; + } + if (float_to_bits_f64(round_f64(-below_half)) != (0x8000000000000000 as u64)) { + return 3; + } + if (round_f64(0.5) != 1.0 || round_f64(-0.5) != -1.0) { + return 4; + } + var above_half: f64 = float_from_bits_f64((0x3FDFFFFFFFFFFFFF as u64) + 2); + if (round_f64(above_half) != 1.0 || round_f64(-above_half) != -1.0) { + return 5; + } + var negative_zero: f64 = float_from_bits_f64(0x8000000000000000 as u64); + if (float_to_bits_f64(round_f64(negative_zero)) != (0x8000000000000000 as u64)) { + return 6; + } + if (float_to_bits_f64(round_f64(float_from_bits_f64(1))) != 0) { + return 7; + } + if (float_to_bits_f64(round_f64(-float_from_bits_f64(1))) != (0x8000000000000000 as u64)) { + return 8; + } + if (float_to_bits_f64(round_f64(float_from_bits_f64(0x7FF8123456789ABC as u64))) != (0x7FF8123456789ABC as u64)) { + return 9; + } + if (round_f64(infinity_f64()) != infinity_f64() || round_f64(-infinity_f64()) != -infinity_f64()) { + return 10; + } + var index: i32 = -32; + while (index <= 32) { + var whole: f64 = index as f64; + var offset: f64 = 0.5; + if (whole < 0.0) { + offset = -0.5; + } + var halfway: f64 = whole + offset; + var expected: f64 = whole + offset * 2.0; + if (round_f64(halfway) != expected || round_f64(whole) != whole) { + return 11; + } + if (round_f64(whole + 0.25) != whole || round_f64(whole - 0.25) != whole) { + return 12; + } + index += 1; + } + return 0; +} + +fun main() -> i32 { + var result32: i32 = check_f32(); + var result64: i32 = check_f64(); + // The exit code identifies the failed check: f32 uses 1..31, f64 33..63. + if (result32 != 0) { + return result32; + } + if (result64 != 0) { + return 32 + result64; + } + return 0; +} From a52ea351fadc4eac7ba188a17511a7f8fe391c31 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:42 +0900 Subject: [PATCH 2/9] fix(math): reject non-finite and overflowing approximate comparisons Fixes #555. Signed-off-by: LunaStev --- std/math/float.wave | 48 +++++++++-- tests/cases/shared/test117.wave | 138 ++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 tests/cases/shared/test117.wave diff --git a/std/math/float.wave b/std/math/float.wave index c6c1c4e2..22ff54ff 100644 --- a/std/math/float.wave +++ b/std/math/float.wave @@ -290,19 +290,51 @@ pub fun fract_f64(value: f64) -> f64 { } pub fun approx_eq_f32(a: f32, b: f32, absolute_tolerance: f32, relative_tolerance: f32) -> bool { - if (a == b) { return true; } - if (is_nan_f32(a) || is_nan_f32(b)) { return false; } + // Exact equality includes signed zeros and equal infinities regardless of + // tolerance. Otherwise both tolerances must be finite and nonnegative. + if (a == b) { + return true; + } + if (!is_finite_f32(a) || !is_finite_f32(b) + || !is_finite_f32(absolute_tolerance) || absolute_tolerance < 0.0 + || !is_finite_f32(relative_tolerance) || relative_tolerance < 0.0) { + return false; + } var difference: f32 = abs_f32(a - b); - if (difference <= absolute_tolerance) { return true; } - return difference <= max_f32(abs_f32(a), abs_f32(b)) * relative_tolerance; + if (difference <= absolute_tolerance) { + return true; + } + var scale: f32 = max_f32(abs_f32(a), abs_f32(b)); + if (is_finite_f32(difference)) { + return difference / scale <= relative_tolerance; + } + // Only opposite-sign finite operands can overflow the difference. Scale + // them before subtracting; the normalized difference is bounded by two. + return abs_f32(a / scale - b / scale) <= relative_tolerance; } pub fun approx_eq_f64(a: f64, b: f64, absolute_tolerance: f64, relative_tolerance: f64) -> bool { - if (a == b) { return true; } - if (is_nan_f64(a) || is_nan_f64(b)) { return false; } + // Exact equality includes signed zeros and equal infinities regardless of + // tolerance. Otherwise both tolerances must be finite and nonnegative. + if (a == b) { + return true; + } + if (!is_finite_f64(a) || !is_finite_f64(b) + || !is_finite_f64(absolute_tolerance) || absolute_tolerance < 0.0 + || !is_finite_f64(relative_tolerance) || relative_tolerance < 0.0) { + return false; + } var difference: f64 = abs_f64(a - b); - if (difference <= absolute_tolerance) { return true; } - return difference <= max_f64(abs_f64(a), abs_f64(b)) * relative_tolerance; + if (difference <= absolute_tolerance) { + return true; + } + var scale: f64 = max_f64(abs_f64(a), abs_f64(b)); + if (is_finite_f64(difference)) { + return difference / scale <= relative_tolerance; + } + // Only opposite-sign finite operands can overflow the difference. Scale + // them before subtracting; the normalized difference is bounded by two. + return abs_f64(a / scale - b / scale) <= relative_tolerance; } pub fun sqrt_f32(value: f32) -> f32 { diff --git a/tests/cases/shared/test117.wave b/tests/cases/shared/test117.wave new file mode 100644 index 00000000..2fd46aa6 --- /dev/null +++ b/tests/cases/shared/test117.wave @@ -0,0 +1,138 @@ +// Approximate equality must not manufacture equality through overflow. +import("std::math::float")::{ + approx_eq_f32, + float_from_bits_f32, + infinity_f32, + nan_f32, + approx_eq_f64, + float_from_bits_f64, + infinity_f64, + nan_f64, +}; + +fun check_f32() -> i32 { + var inf: f32 = infinity_f32(); + var nan: f32 = nan_f32(); + var largest: f32 = float_from_bits_f32(0x7F7FFFFF as u32); + if (approx_eq_f32(inf, 1.0, 0.0, 0.01) || approx_eq_f32(inf, -inf, 0.0, 0.01)) { + return 1; + } + if (!approx_eq_f32(inf, inf, 0.0, 0.0) || !approx_eq_f32(-inf, -inf, 0.0, 0.0)) { + return 2; + } + if (approx_eq_f32(nan, nan, 1.0, 1.0) || approx_eq_f32(nan, 1.0, 1.0, 1.0) || approx_eq_f32(1.0, nan, 1.0, 1.0)) { + return 3; + } + if (!approx_eq_f32(0.0, float_from_bits_f32(0x80000000 as u32), 0.0, 0.0)) { + return 4; + } + if (approx_eq_f32(largest, -largest, 0.0, 1.5) || approx_eq_f32(-largest, largest, largest, 0.0)) { + return 5; + } + if (!approx_eq_f32(largest, -largest, 0.0, 2.0)) { + return 6; + } + if (approx_eq_f32(1.0, 2.0, -1.0, 1.0) || approx_eq_f32(1.0, 2.0, 1.0, -1.0)) { + return 7; + } + if (approx_eq_f32(1.0, 2.0, nan, 1.0) || approx_eq_f32(1.0, 2.0, 1.0, nan)) { + return 8; + } + if (approx_eq_f32(1.0, 2.0, inf, 1.0) || approx_eq_f32(1.0, 2.0, 1.0, inf)) { + return 9; + } + if (!approx_eq_f32(1.0, 1.0, -1.0, nan)) { + return 10; + } + if (!approx_eq_f32(1.0, 1.125, 0.125, 0.0) || approx_eq_f32(1.0, 1.125, 0.0625, 0.0)) { + return 11; + } + if (!approx_eq_f32(2.0, 1.0, 0.0, 0.5) || approx_eq_f32(2.0, 1.0, 0.0, 0.25)) { + return 12; + } + if (!approx_eq_f32(float_from_bits_f32(1), 0.0, float_from_bits_f32(1), 0.0)) { + return 13; + } + if (approx_eq_f32(float_from_bits_f32(1), 0.0, 0.0, 0.0)) { + return 14; + } + if (!approx_eq_f32(float_from_bits_f32(1), float_from_bits_f32(2), 0.0, 0.5)) { + return 15; + } + var adjacent: f32 = float_from_bits_f32((0x7F7FFFFF as u32) - 1); + var spacing: f32 = largest - adjacent; + if (!approx_eq_f32(largest, adjacent, spacing, 0.0) || approx_eq_f32(largest, adjacent, 0.0, 0.0)) { + return 16; + } + return 0; +} + +fun check_f64() -> i32 { + var inf: f64 = infinity_f64(); + var nan: f64 = nan_f64(); + var largest: f64 = float_from_bits_f64(0x7FEFFFFFFFFFFFFF as u64); + if (approx_eq_f64(inf, 1.0, 0.0, 0.01) || approx_eq_f64(inf, -inf, 0.0, 0.01)) { + return 1; + } + if (!approx_eq_f64(inf, inf, 0.0, 0.0) || !approx_eq_f64(-inf, -inf, 0.0, 0.0)) { + return 2; + } + if (approx_eq_f64(nan, nan, 1.0, 1.0) || approx_eq_f64(nan, 1.0, 1.0, 1.0) || approx_eq_f64(1.0, nan, 1.0, 1.0)) { + return 3; + } + if (!approx_eq_f64(0.0, float_from_bits_f64(0x8000000000000000 as u64), 0.0, 0.0)) { + return 4; + } + if (approx_eq_f64(largest, -largest, 0.0, 1.5) || approx_eq_f64(-largest, largest, largest, 0.0)) { + return 5; + } + if (!approx_eq_f64(largest, -largest, 0.0, 2.0)) { + return 6; + } + if (approx_eq_f64(1.0, 2.0, -1.0, 1.0) || approx_eq_f64(1.0, 2.0, 1.0, -1.0)) { + return 7; + } + if (approx_eq_f64(1.0, 2.0, nan, 1.0) || approx_eq_f64(1.0, 2.0, 1.0, nan)) { + return 8; + } + if (approx_eq_f64(1.0, 2.0, inf, 1.0) || approx_eq_f64(1.0, 2.0, 1.0, inf)) { + return 9; + } + if (!approx_eq_f64(1.0, 1.0, -1.0, nan)) { + return 10; + } + if (!approx_eq_f64(1.0, 1.125, 0.125, 0.0) || approx_eq_f64(1.0, 1.125, 0.0625, 0.0)) { + return 11; + } + if (!approx_eq_f64(2.0, 1.0, 0.0, 0.5) || approx_eq_f64(2.0, 1.0, 0.0, 0.25)) { + return 12; + } + if (!approx_eq_f64(float_from_bits_f64(1), 0.0, float_from_bits_f64(1), 0.0)) { + return 13; + } + if (approx_eq_f64(float_from_bits_f64(1), 0.0, 0.0, 0.0)) { + return 14; + } + if (!approx_eq_f64(float_from_bits_f64(1), float_from_bits_f64(2), 0.0, 0.5)) { + return 15; + } + var adjacent: f64 = float_from_bits_f64((0x7FEFFFFFFFFFFFFF as u64) - 1); + var spacing: f64 = largest - adjacent; + if (!approx_eq_f64(largest, adjacent, spacing, 0.0) || approx_eq_f64(largest, adjacent, 0.0, 0.0)) { + return 16; + } + return 0; +} + +fun main() -> i32 { + var result32: i32 = check_f32(); + var result64: i32 = check_f64(); + // The exit code identifies the failed check: f32 uses 1..31, f64 33..63. + if (result32 != 0) { + return result32; + } + if (result64 != 0) { + return 32 + result64; + } + return 0; +} From 4e3a6a2bfc471bac807ac8fa8c8b2ebd4de22d32 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:42 +0900 Subject: [PATCH 3/9] fix(math): avoid overflow between finite interpolation endpoints Fixes #556. Signed-off-by: LunaStev --- std/math/float.wave | 44 ++++++++- tests/cases/shared/test118.wave | 166 ++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 tests/cases/shared/test118.wave diff --git a/std/math/float.wave b/std/math/float.wave index 22ff54ff..ba71e2cf 100644 --- a/std/math/float.wave +++ b/std/math/float.wave @@ -394,9 +394,49 @@ pub fun hypot_f64(a: f64, b: f64) -> f64 { } pub fun lerp_f32(a: f32, b: f32, amount: f32) -> f32 { - return a + (b - a) * amount; + // Endpoints are exact, including signed zero. Away from the endpoints, + // non-finite inputs yield NaN; finite extrapolation may overflow. + if (amount == 0.0) { + return a; + } + if (amount == 1.0) { + return b; + } + if (!is_finite_f32(a) || !is_finite_f32(b) || !is_finite_f32(amount)) { + return nan_f32(); + } + if ((a <= 0.0 && b >= 0.0) || (a >= 0.0 && b <= 0.0)) { + return (1.0 - amount) * a + amount * b; + } + // Same-sign endpoints have a finite difference, preserving precision for + // nearby values. Clamp rounding at b to keep the result monotonic. + var result: f32 = a + (b - a) * amount; + if ((amount > 1.0 && b > a) || (amount <= 1.0 && b <= a)) { + return max_f32(b, result); + } + return min_f32(b, result); } pub fun lerp_f64(a: f64, b: f64, amount: f64) -> f64 { - return a + (b - a) * amount; + // Endpoints are exact, including signed zero. Away from the endpoints, + // non-finite inputs yield NaN; finite extrapolation may overflow. + if (amount == 0.0) { + return a; + } + if (amount == 1.0) { + return b; + } + if (!is_finite_f64(a) || !is_finite_f64(b) || !is_finite_f64(amount)) { + return nan_f64(); + } + if ((a <= 0.0 && b >= 0.0) || (a >= 0.0 && b <= 0.0)) { + return (1.0 - amount) * a + amount * b; + } + // Same-sign endpoints have a finite difference, preserving precision for + // nearby values. Clamp rounding at b to keep the result monotonic. + var result: f64 = a + (b - a) * amount; + if ((amount > 1.0 && b > a) || (amount <= 1.0 && b <= a)) { + return max_f64(b, result); + } + return min_f64(b, result); } diff --git a/tests/cases/shared/test118.wave b/tests/cases/shared/test118.wave new file mode 100644 index 00000000..53b7e52a --- /dev/null +++ b/tests/cases/shared/test118.wave @@ -0,0 +1,166 @@ +// Interpolation across the finite range, close endpoints, and extrapolation. +import("std::math::float")::{ + lerp_f32, + float_from_bits_f32, + float_to_bits_f32, + is_finite_f32, + is_nan_f32, + infinity_f32, + nan_f32, + lerp_f64, + float_from_bits_f64, + float_to_bits_f64, + is_finite_f64, + is_nan_f64, + infinity_f64, + nan_f64, +}; + +fun check_f32() -> i32 { + var largest: f32 = float_from_bits_f32(0x7F7FFFFF as u32); + if (lerp_f32(largest, -largest, 0.5) != 0.0) { + return 1; + } + if (lerp_f32(largest, -largest, 0.0) != largest || lerp_f32(largest, -largest, 1.0) != -largest) { + return 2; + } + var previous: f32 = -largest; + var descending: f32 = largest; + var index: i32 = 0; + while (index <= 256) { + var amount: f32 = index as f32 / 256.0; + var rising: f32 = lerp_f32(-largest, largest, amount); + var falling: f32 = lerp_f32(largest, -largest, amount); + if (!is_finite_f32(rising) || !is_finite_f32(falling)) { + return 3; + } + if (rising < previous || rising > largest || falling > descending || falling < -largest) { + return 4; + } + if (rising != -falling) { + return 5; + } + previous = rising; + descending = falling; + index += 1; + } + var adjacent: f32 = float_from_bits_f32((0x7F7FFFFF as u32) - 1); + previous = adjacent; + index = 0; + while (index <= 32) { + var amount: f32 = index as f32 / 32.0; + var close: f32 = lerp_f32(adjacent, largest, amount); + if (close < previous || close > largest || !is_finite_f32(close)) { + return 6; + } + if (lerp_f32(2.0, 6.0, amount) != 2.0 + 4.0 * amount) { + return 7; + } + var negative: f32 = lerp_f32(-adjacent, -largest, amount); + var reversed: f32 = lerp_f32(largest, adjacent, amount); + if (negative > -adjacent || negative < -largest + || reversed < adjacent || reversed > largest) { + return 13; + } + previous = close; + index += 1; + } + if (lerp_f32(2.0, 6.0, -1.0) != -2.0 || lerp_f32(2.0, 6.0, 2.0) != 10.0) { + return 8; + } + var negative_zero: f32 = float_from_bits_f32(0x80000000 as u32); + if (float_to_bits_f32(lerp_f32(negative_zero, 1.0, 0.0)) != (0x80000000 as u32) || float_to_bits_f32(lerp_f32(1.0, negative_zero, 1.0)) != (0x80000000 as u32)) { + return 9; + } + if (lerp_f32(3.0, nan_f32(), 0.0) != 3.0 || lerp_f32(nan_f32(), 3.0, 1.0) != 3.0) { + return 10; + } + if (!is_nan_f32(lerp_f32(infinity_f32(), 1.0, 0.5)) || !is_nan_f32(lerp_f32(1.0, 2.0, infinity_f32()))) { + return 11; + } + if (!is_nan_f32(lerp_f32(1.0, 2.0, nan_f32()))) { + return 12; + } + return 0; +} + +fun check_f64() -> i32 { + var largest: f64 = float_from_bits_f64(0x7FEFFFFFFFFFFFFF as u64); + if (lerp_f64(largest, -largest, 0.5) != 0.0) { + return 1; + } + if (lerp_f64(largest, -largest, 0.0) != largest || lerp_f64(largest, -largest, 1.0) != -largest) { + return 2; + } + var previous: f64 = -largest; + var descending: f64 = largest; + var index: i32 = 0; + while (index <= 256) { + var amount: f64 = index as f64 / 256.0; + var rising: f64 = lerp_f64(-largest, largest, amount); + var falling: f64 = lerp_f64(largest, -largest, amount); + if (!is_finite_f64(rising) || !is_finite_f64(falling)) { + return 3; + } + if (rising < previous || rising > largest || falling > descending || falling < -largest) { + return 4; + } + if (rising != -falling) { + return 5; + } + previous = rising; + descending = falling; + index += 1; + } + var adjacent: f64 = float_from_bits_f64((0x7FEFFFFFFFFFFFFF as u64) - 1); + previous = adjacent; + index = 0; + while (index <= 32) { + var amount: f64 = index as f64 / 32.0; + var close: f64 = lerp_f64(adjacent, largest, amount); + if (close < previous || close > largest || !is_finite_f64(close)) { + return 6; + } + if (lerp_f64(2.0, 6.0, amount) != 2.0 + 4.0 * amount) { + return 7; + } + var negative: f64 = lerp_f64(-adjacent, -largest, amount); + var reversed: f64 = lerp_f64(largest, adjacent, amount); + if (negative > -adjacent || negative < -largest + || reversed < adjacent || reversed > largest) { + return 13; + } + previous = close; + index += 1; + } + if (lerp_f64(2.0, 6.0, -1.0) != -2.0 || lerp_f64(2.0, 6.0, 2.0) != 10.0) { + return 8; + } + var negative_zero: f64 = float_from_bits_f64(0x8000000000000000 as u64); + if (float_to_bits_f64(lerp_f64(negative_zero, 1.0, 0.0)) != (0x8000000000000000 as u64) || float_to_bits_f64(lerp_f64(1.0, negative_zero, 1.0)) != (0x8000000000000000 as u64)) { + return 9; + } + if (lerp_f64(3.0, nan_f64(), 0.0) != 3.0 || lerp_f64(nan_f64(), 3.0, 1.0) != 3.0) { + return 10; + } + if (!is_nan_f64(lerp_f64(infinity_f64(), 1.0, 0.5)) || !is_nan_f64(lerp_f64(1.0, 2.0, infinity_f64()))) { + return 11; + } + if (!is_nan_f64(lerp_f64(1.0, 2.0, nan_f64()))) { + return 12; + } + return 0; +} + +fun main() -> i32 { + var result32: i32 = check_f32(); + var result64: i32 = check_f64(); + // The exit code identifies the failed check: f32 uses 1..31, f64 33..63. + if (result32 != 0) { + return result32; + } + if (result64 != 0) { + return 32 + result64; + } + return 0; +} From d6a7ef7d259819a7fbfbda2b011f0f5d6ac76c6e Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:42 +0900 Subject: [PATCH 4/9] fix(math): preserve subnormal negative integer powers Fixes #557. Signed-off-by: LunaStev --- std/math/num.wave | 20 ++--- tests/cases/shared/test119.wave | 138 ++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/cases/shared/test119.wave diff --git a/std/math/num.wave b/std/math/num.wave index 16f04dc5..cfc5d94a 100644 --- a/std/math/num.wave +++ b/std/math/num.wave @@ -179,13 +179,14 @@ pub fun pow_i32_checked(base_value: i32, exponent_value: u32) -> MathResult pub fun pow_f32_i32(base_value: f32, exponent_value: i32) -> f32 { var exponent: u32 = exponent_value as u32; - var reciprocal: bool = false; + var base: f32 = base_value; if (exponent_value < 0) { - reciprocal = true; + // Invert before exponentiation so a representable subnormal result + // does not depend on an overflowing positive power. + base = 1.0 / base; exponent = (-(exponent_value + 1)) as u32 + 1; } - var base: f32 = base_value; var result: f32 = 1.0; while (exponent != 0) { if ((exponent & 1) == 1) { @@ -196,21 +197,19 @@ pub fun pow_f32_i32(base_value: f32, exponent_value: i32) -> f32 { base = base * base; } } - if (reciprocal) { - return 1.0 / result; - } return result; } pub fun pow_f64_i32(base_value: f64, exponent_value: i32) -> f64 { var exponent: u32 = exponent_value as u32; - var reciprocal: bool = false; + var base: f64 = base_value; if (exponent_value < 0) { - reciprocal = true; + // Invert before exponentiation so a representable subnormal result + // does not depend on an overflowing positive power. + base = 1.0 / base; exponent = (-(exponent_value + 1)) as u32 + 1; } - var base: f64 = base_value; var result: f64 = 1.0; while (exponent != 0) { if ((exponent & 1) == 1) { @@ -221,9 +220,6 @@ pub fun pow_f64_i32(base_value: f64, exponent_value: i32) -> f64 { base = base * base; } } - if (reciprocal) { - return 1.0 / result; - } return result; } diff --git a/tests/cases/shared/test119.wave b/tests/cases/shared/test119.wave new file mode 100644 index 00000000..bdcf532c --- /dev/null +++ b/tests/cases/shared/test119.wave @@ -0,0 +1,138 @@ +// Integer powers retain subnormals and signed special values. +import("std::math::float")::{ + float_from_bits_f32, + float_to_bits_f32, + infinity_f32, + nan_f32, + is_nan_f32, + float_from_bits_f64, + float_to_bits_f64, + infinity_f64, + nan_f64, + is_nan_f64, +}; +import("std::math::num")::{ + pow_f32_i32, + pow_f64_i32, +}; + +fun check_f32() -> i32 { + if (float_to_bits_f32(pow_f32_i32(2.0, -128)) != (1 as u32 << 21)) { + return 1; + } + // Every subnormal power of two has an exact, independent bit oracle. + var exponent: i32 = 127; + while (exponent <= 149) { + var expected: u32 = 1 as u32 << ((149 - exponent) as u32); + var actual: f32 = pow_f32_i32(2.0, -exponent); + if (float_to_bits_f32(actual) != expected) { + return 2; + } + var negative: f32 = pow_f32_i32(-2.0, -exponent); + if ((exponent & 1) != 0) { + expected = expected | (0x80000000 as u32); + } + if (float_to_bits_f32(negative) != expected) { + return 3; + } + exponent += 1; + } + if (float_to_bits_f32(pow_f32_i32(2.0, -150)) != 0) { + return 4; + } + if (pow_f32_i32(2.0, 128) != infinity_f32()) { + return 5; + } + if (pow_f32_i32(2.0, 10) != 1024.0 || pow_f32_i32(2.0, -10) != 0.0009765625) { + return 6; + } + if (pow_f32_i32(-2.0, 3) != -8.0 || pow_f32_i32(-2.0, -3) != -0.125 || pow_f32_i32(-2.0, -4) != 0.0625) { + return 7; + } + if (pow_f32_i32(0.0, 0) != 1.0 || pow_f32_i32(nan_f32(), 0) != 1.0 || pow_f32_i32(infinity_f32(), 0) != 1.0) { + return 8; + } + if (pow_f32_i32(-1.0, -2147483648) != 1.0 || pow_f32_i32(2.0, -2147483648) != 0.0 || pow_f32_i32(0.5, -2147483648) != infinity_f32()) { + return 9; + } + if (pow_f32_i32(float_from_bits_f32(0x80000000 as u32), -3) != -infinity_f32() || pow_f32_i32(float_from_bits_f32(0x80000000 as u32), -2) != infinity_f32()) { + return 10; + } + if (float_to_bits_f32(pow_f32_i32(-infinity_f32(), -3)) != (0x80000000 as u32) || float_to_bits_f32(pow_f32_i32(-infinity_f32(), -2)) != 0) { + return 11; + } + if (!is_nan_f32(pow_f32_i32(nan_f32(), -3)) || !is_nan_f32(pow_f32_i32(nan_f32(), 3))) { + return 12; + } + if (float_to_bits_f32(pow_f32_i32(float_from_bits_f32(0x80000000 as u32), 3)) != (0x80000000 as u32)) { + return 13; + } + return 0; +} + +fun check_f64() -> i32 { + if (float_to_bits_f64(pow_f64_i32(2.0, -1024)) != (1 as u64 << 50)) { + return 1; + } + // Every subnormal power of two has an exact, independent bit oracle. + var exponent: i32 = 1023; + while (exponent <= 1074) { + var expected: u64 = 1 as u64 << ((1074 - exponent) as u64); + var actual: f64 = pow_f64_i32(2.0, -exponent); + if (float_to_bits_f64(actual) != expected) { + return 2; + } + var negative: f64 = pow_f64_i32(-2.0, -exponent); + if ((exponent & 1) != 0) { + expected = expected | (0x8000000000000000 as u64); + } + if (float_to_bits_f64(negative) != expected) { + return 3; + } + exponent += 1; + } + if (float_to_bits_f64(pow_f64_i32(2.0, -1075)) != 0) { + return 4; + } + if (pow_f64_i32(2.0, 1024) != infinity_f64()) { + return 5; + } + if (pow_f64_i32(2.0, 10) != 1024.0 || pow_f64_i32(2.0, -10) != 0.0009765625) { + return 6; + } + if (pow_f64_i32(-2.0, 3) != -8.0 || pow_f64_i32(-2.0, -3) != -0.125 || pow_f64_i32(-2.0, -4) != 0.0625) { + return 7; + } + if (pow_f64_i32(0.0, 0) != 1.0 || pow_f64_i32(nan_f64(), 0) != 1.0 || pow_f64_i32(infinity_f64(), 0) != 1.0) { + return 8; + } + if (pow_f64_i32(-1.0, -2147483648) != 1.0 || pow_f64_i32(2.0, -2147483648) != 0.0 || pow_f64_i32(0.5, -2147483648) != infinity_f64()) { + return 9; + } + if (pow_f64_i32(float_from_bits_f64(0x8000000000000000 as u64), -3) != -infinity_f64() || pow_f64_i32(float_from_bits_f64(0x8000000000000000 as u64), -2) != infinity_f64()) { + return 10; + } + if (float_to_bits_f64(pow_f64_i32(-infinity_f64(), -3)) != (0x8000000000000000 as u64) || float_to_bits_f64(pow_f64_i32(-infinity_f64(), -2)) != 0) { + return 11; + } + if (!is_nan_f64(pow_f64_i32(nan_f64(), -3)) || !is_nan_f64(pow_f64_i32(nan_f64(), 3))) { + return 12; + } + if (float_to_bits_f64(pow_f64_i32(float_from_bits_f64(0x8000000000000000 as u64), 3)) != (0x8000000000000000 as u64)) { + return 13; + } + return 0; +} + +fun main() -> i32 { + var result32: i32 = check_f32(); + var result64: i32 = check_f64(); + // The exit code identifies the failed check: f32 uses 1..31, f64 33..63. + if (result32 != 0) { + return result32; + } + if (result64 != 0) { + return 32 + result64; + } + return 0; +} From 713ecad2d0e2724b3d254dd22ac4c95c7493dc8b Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:54 +0900 Subject: [PATCH 5/9] fix(fs): read bounded file contents through EOF Fixes #553. Signed-off-by: LunaStev --- .github/workflows/rust.yml | 2 +- std/fs/file.wave | 23 +++-- tests/fixtures/io/read_all.wave | 13 +++ tests/fixtures/io/read_zero.wave | 5 + tests/std_io_regressions.rs | 36 +++++++ tools/test_std_io_runtime.py | 161 +++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 tests/fixtures/io/read_all.wave create mode 100644 tests/fixtures/io/read_zero.wave create mode 100644 tests/std_io_regressions.rs create mode 100644 tools/test_std_io_runtime.py diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index ed1b19cf..f1b262b9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -969,7 +969,7 @@ jobs: env: WAVE_RUNTIME_ARTIFACT_DIR: ${{ runner.temp }} run: >- - cargo test --locked --test runtime_regressions --target aarch64-pc-windows-msvc + cargo test --locked --test runtime_regressions --test std_io_regressions --target aarch64-pc-windows-msvc --no-default-features --features llvm-target-aarch64 --jobs 2 - name: Save failed native runtime compiler and fixture diff --git a/std/fs/file.wave b/std/fs/file.wave index ec151bbe..5d08de00 100644 --- a/std/fs/file.wave +++ b/std/fs/file.wave @@ -205,7 +205,7 @@ pub fun fs_rename(old_path: str, new_path: str) -> i64 { // file is larger than dst_cap; this function does not allocate or return str. // Example: var n: i64 = fs_read_all("notes.txt", &buf[0], 256); pub fun fs_read_all(path: str, dst: ptr, dst_cap: i64) -> i64 { - if (dst_cap < 0) { + if (dst_cap < 0 || (dst == null && dst_cap > 0)) { return IO_ERR_INVALID; } @@ -214,18 +214,17 @@ pub fun fs_read_all(path: str, dst: ptr, dst_cap: i64) -> i64 { return fd; } - var sz: i64 = fs_file_size_fd(fd); - if (sz < 0) { - io_close(fd); - return sz; - } - - if (sz > dst_cap) { - io_close(fd); - return IO_ERR_NO_SPACE; + var n: i64 = io_read_at_most(fd, dst, dst_cap); + if (n == dst_cap) { + // A full buffer is not evidence of EOF, including zero-capacity calls. + var extra: u8 = 0; + var probe: i64 = io_read(fd, &extra, 1); + if (probe < 0) { + n = probe; + } else if (probe > 0) { + n = IO_ERR_NO_SPACE; + } } - - var n: i64 = io_read_exact(fd, dst, sz); var cr: i64 = io_close(fd); if (n < 0) { diff --git a/tests/fixtures/io/read_all.wave b/tests/fixtures/io/read_all.wave new file mode 100644 index 00000000..29a58992 --- /dev/null +++ b/tests/fixtures/io/read_all.wave @@ -0,0 +1,13 @@ +import("std::fs::file")::{fs_read_all}; +fun main() -> i32 { + var data: array; + var result: i64 = fs_read_all("source.bin", &data[0], 128); + var checksum: i64 = 0; + var index: i64 = 0; + while (index < result) { + checksum += (index + 1) * data[index] as i64; + index += 1; + } + println("{} {}", result, checksum); + return 0; +} diff --git a/tests/fixtures/io/read_zero.wave b/tests/fixtures/io/read_zero.wave new file mode 100644 index 00000000..586dba3b --- /dev/null +++ b/tests/fixtures/io/read_zero.wave @@ -0,0 +1,5 @@ +import("std::fs::file")::{fs_read_all}; +fun main() -> i32 { + println("{}", fs_read_all("source.bin", null, 0)); + return 0; +} diff --git a/tests/std_io_regressions.rs b/tests/std_io_regressions.rs new file mode 100644 index 00000000..c7aabca7 --- /dev/null +++ b/tests/std_io_regressions.rs @@ -0,0 +1,36 @@ +//! Bounded native I/O regressions using a temporary copy of the current std. +#![cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + +use std::process::Command; + +#[test] +fn native_standard_io_preserves_data_descriptors_and_deadlines() { + let compiler = env!("CARGO_BIN_EXE_wavec"); + let target = Command::new(compiler) + .args(["print", "default-target"]) + .output() + .unwrap(); + assert!(target.status.success()); + let mut host = String::from_utf8(target.stdout).unwrap().trim().to_owned(); + if cfg!(all(windows, target_env = "msvc")) { + host = host.replace("-windows-gnu", "-windows-msvc"); + } + if llvm::codegen::target::target_spec_for_triple(&host).is_none() { + eprintln!("native I/O fixtures skipped: LLVM target {host} is disabled"); + return; + } + let output = Command::new(if cfg!(windows) { "python" } else { "python3" }) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("WAVE_TEST_COMPILER", compiler) + .env("WAVE_TEST_TARGET", host) + .args(["-m", "unittest", "-v", "tools.test_std_io_runtime"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "native standard I/O: {}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/tools/test_std_io_runtime.py b/tools/test_std_io_runtime.py new file mode 100644 index 00000000..7aef5bd7 --- /dev/null +++ b/tools/test_std_io_runtime.py @@ -0,0 +1,161 @@ +"""Native standard-library regressions, invoked by Cargo with its built wavec.""" +import os +import errno +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +from tools.process_tree import run_process + +ROOT = Path(__file__).resolve().parent.parent +COMPILER = os.environ.get("WAVE_TEST_COMPILER") + + +@unittest.skipUnless(COMPILER, "Cargo supplies WAVE_TEST_COMPILER for native fixtures") +class StandardIoRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workspace = tempfile.TemporaryDirectory(prefix="wave-std-io-") + cls.addClassCleanup(cls.workspace.cleanup) + cls.base = Path(cls.workspace.name) + home = cls.base / "home" + shutil.copytree(ROOT / "std", home / ".wave/lib/wave/std") + cls.env = dict(os.environ, HOME=str(home), USERPROFILE=str(home)) + cls.target = os.environ.get("WAVE_TEST_TARGET") + cls.executables = {} + names = ['read_all', 'read_zero'] + for name in names: + cls.build(name) + + + @classmethod + def compile(cls, name, options): + command = [COMPILER, "build", str(ROOT / f"tests/fixtures/io/{name}.wave")] + if cls.target: + command += ["--target", cls.target] + result = run_process(command + options, env=cls.env, timeout=60, + capture_output=True, text=True) + if result.returncode: + raise RuntimeError(f"{name}: {result.stdout}\n{result.stderr}") + + @classmethod + def build(cls, name): + executable = cls.base / (name + (".exe" if os.name == "nt" else "")) + cls.compile(name, ["-o", str(executable)]) + cls.executables[name] = executable + + def setUp(self): + directory = tempfile.TemporaryDirectory(prefix="case-", dir=self.base) + self.addCleanup(directory.cleanup) + self.directory = Path(directory.name) + + def run_fixture(self, name, *args, **kwargs): + result = run_process([str(self.executables[name]), *args], cwd=self.directory, + timeout=10, capture_output=True, text=True, **kwargs) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout.strip() + + def low_fd_limit(self): + if not sys.platform.startswith("linux"): + return {} + def limit(): + import resource + _, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + resource.setrlimit(resource.RLIMIT_NOFILE, (64, hard)) + return {"preexec_fn": limit} + + + + + + + + + def test_read_empty_exact_capacity_and_oversized_files(self): + for length in (0, 1, 127, 128, 129): + with self.subTest(length=length): + data = bytes(range(length)) + (self.directory / "source.bin").write_bytes(data) + count, checksum = map(int, self.run_fixture("read_all").split()) + if length > 128: + self.assertEqual(count, -4098) + else: + self.assertEqual(count, length) + self.assertEqual(checksum, sum((i+1)*b for i, b in enumerate(data))) + + def test_read_zero_capacity_probes_eof(self): + source = self.directory / "source.bin" + source.write_bytes(b"") + self.assertEqual(self.run_fixture("read_zero"), "0") + source.write_bytes(b"x") + self.assertEqual(self.run_fixture("read_zero"), "-4098") + + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux virtual file") + def test_read_nonempty_virtual_file_with_zero_stat_size(self): + (self.directory / "source.bin").symlink_to("/proc/self/status") + self.assertEqual(os.stat("/proc/self/status").st_size, 0) + self.assertEqual(self.run_fixture("read_all").split()[0], "-4098") + + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux virtual file") + def test_read_small_virtual_file_returns_actual_content(self): + (self.directory / "source.bin").symlink_to("/proc/self/comm") + expected = b"read_all\n" + self.assertEqual(self.run_fixture("read_all"), + f"{len(expected)} {sum((i+1)*b for i,b in enumerate(expected))}") + + @unittest.skipUnless(os.name == "posix", "native FIFO") + def test_read_stream_in_multiple_chunks_until_eof(self): + fifo = self.directory / "source.bin" + os.mkfifo(fifo) + errors = [] + stopped = threading.Event() + def produce(): + writer = None + try: + deadline = time.monotonic() + 5 + while not stopped.is_set(): + try: + writer = os.open(fifo, os.O_WRONLY | os.O_NONBLOCK) + break + except OSError as error: + if error.errno != errno.ENXIO or time.monotonic() >= deadline: + raise + stopped.wait(0.005) + if writer is not None: + for piece in (b"abc", b"defgh", b"ijk"): + os.write(writer, piece) + stopped.wait(0.03) + except BaseException as error: + errors.append(error) + finally: + if writer is not None: + os.close(writer) + thread = threading.Thread(target=produce) + thread.start() + try: + result = self.run_fixture("read_all") + finally: + stopped.set() + thread.join(timeout=2) + self.assertFalse(thread.is_alive()) + self.assertFalse(errors, errors) + self.assertEqual(result, f"11 {sum((i+1)*b for i,b in enumerate(b'abcdefghijk'))}") + + + + + + + + + + + + +if __name__ == "__main__": + unittest.main() From 4330918a1c83951cc347cfd25a2606f335badb6d Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:55 +0900 Subject: [PATCH 6/9] fix(fs): reject same-file copies before truncating opened destinations Fixes #552. Signed-off-by: LunaStev --- std/fs/file.wave | 24 +++++++++++- std/sys/freebsd/amd64/fs.wave | 2 +- std/sys/freebsd/arm64/fs.wave | 2 +- std/sys/freebsd/common/fs.wave | 23 ++++++++++++ std/sys/freebsd/fs.wave | 6 +-- std/sys/freebsd/riscv64/fs.wave | 2 +- std/sys/fs.wave | 10 ++--- std/sys/linux/amd64/fs.wave | 23 ++++++++++++ std/sys/linux/arm64/fs.wave | 23 ++++++++++++ std/sys/linux/fs.wave | 8 ++-- std/sys/linux/loong64/fs.wave | 2 +- std/sys/linux/riscv64/fs.wave | 23 ++++++++++++ std/sys/macos/amd64/fs.wave | 23 ++++++++++++ std/sys/macos/arm64/fs.wave | 23 ++++++++++++ std/sys/macos/fs.wave | 4 +- std/sys/wasm/fs.wave | 32 +++++++++++++++- std/sys/windows/fs.wave | 48 ++++++++++++++++++++++++ tests/fixtures/io/copy.wave | 12 ++++++ tests/fixtures/io/copy_self.wave | 12 ++++++ tests/fixtures/io/invalid_buffers.wave | 14 +++++++ tools/test_std_io_runtime.py | 51 +++++++++++++++++++++++--- 21 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/io/copy.wave create mode 100644 tests/fixtures/io/copy_self.wave create mode 100644 tests/fixtures/io/invalid_buffers.wave diff --git a/std/fs/file.wave b/std/fs/file.wave index 5d08de00..babdec35 100644 --- a/std/fs/file.wave +++ b/std/fs/file.wave @@ -88,6 +88,8 @@ import("std::sys::fs")::{ Stat, stat, fstat, + ftruncate, + same_file, }; // Portable metadata returned by metadata(). Native mode bits and Stat layouts @@ -298,7 +300,7 @@ pub fun fs_copy( scratch_cap: i64, mode: i32 ) -> i64 { - if (scratch_cap <= 0) { + if (scratch_cap <= 0 || scratch == null) { return IO_ERR_INVALID; } @@ -307,12 +309,30 @@ pub fun fs_copy( return src_fd; } - var dst_fd: i64 = fs_open_write(dst_path, mode); + // Hold both opened objects before any destructive operation. Path aliases + // and concurrent renames cannot redirect the subsequent truncation. + var dst_fd: i64 = fs_open(dst_path, FS_O_WRONLY | FS_O_CREAT, mode); if (dst_fd < 0) { io_close(src_fd); return dst_fd; } + var identity: i64 = same_file(src_fd, dst_fd); + if (identity != 0) { + io_close(src_fd); + io_close(dst_fd); + if (identity < 0) { + return identity; + } + return IO_ERR_INVALID; + } + var resized: i64 = ftruncate(dst_fd, 0); + if (resized < 0) { + io_close(src_fd); + io_close(dst_fd); + return resized; + } + var copied: i64 = io_copy(src_fd, dst_fd, scratch, scratch_cap); var src_cr: i64 = io_close(src_fd); diff --git a/std/sys/freebsd/amd64/fs.wave b/std/sys/freebsd/amd64/fs.wave index 1aa9c096..e6bb87ea 100644 --- a/std/sys/freebsd/amd64/fs.wave +++ b/std/sys/freebsd/amd64/fs.wave @@ -7,5 +7,5 @@ pub import("std::sys::freebsd::common::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_NONBLOCK, FS_O_APPEND, FS_O_CREAT, FS_O_TRUNC, FS_O_EXCL, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, Stat, open, close, dup, pipe, dup2, fsync, fcntl, read, write, - getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat + getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat, ftruncate, same_file }; diff --git a/std/sys/freebsd/arm64/fs.wave b/std/sys/freebsd/arm64/fs.wave index 1aa9c096..e6bb87ea 100644 --- a/std/sys/freebsd/arm64/fs.wave +++ b/std/sys/freebsd/arm64/fs.wave @@ -7,5 +7,5 @@ pub import("std::sys::freebsd::common::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_NONBLOCK, FS_O_APPEND, FS_O_CREAT, FS_O_TRUNC, FS_O_EXCL, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, Stat, open, close, dup, pipe, dup2, fsync, fcntl, read, write, - getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat + getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat, ftruncate, same_file }; diff --git a/std/sys/freebsd/common/fs.wave b/std/sys/freebsd/common/fs.wave index c4e1445f..34b7d624 100644 --- a/std/sys/freebsd/common/fs.wave +++ b/std/sys/freebsd/common/fs.wave @@ -158,3 +158,26 @@ pub fun stat(path: str, st: ptr) -> i64 { pub fun fstat(fd: i64, st: ptr) -> i64 { return syscall2(551, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(480, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/freebsd/fs.wave b/std/sys/freebsd/fs.wave index cbd97d05..214705dc 100644 --- a/std/sys/freebsd/fs.wave +++ b/std/sys/freebsd/fs.wave @@ -9,7 +9,7 @@ pub import("std::sys::freebsd::amd64::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_CREAT, FS_O_EXCL, FS_O_TRUNC, FS_O_APPEND, FS_O_NONBLOCK, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(arch="aarch64")] @@ -17,7 +17,7 @@ pub import("std::sys::freebsd::arm64::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_NONBLOCK, FS_O_APPEND, FS_O_CREAT, FS_O_TRUNC, FS_O_EXCL, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, Stat, open, close, dup, pipe, dup2, fsync, fcntl, read, write, - getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat + getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat, ftruncate, same_file }; #[target(arch="riscv64")] @@ -25,5 +25,5 @@ pub import("std::sys::freebsd::riscv64::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_NONBLOCK, FS_O_APPEND, FS_O_CREAT, FS_O_TRUNC, FS_O_EXCL, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, Stat, open, close, dup, pipe, dup2, fsync, fcntl, read, write, - getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat + getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat, ftruncate, same_file }; diff --git a/std/sys/freebsd/riscv64/fs.wave b/std/sys/freebsd/riscv64/fs.wave index 1aa9c096..e6bb87ea 100644 --- a/std/sys/freebsd/riscv64/fs.wave +++ b/std/sys/freebsd/riscv64/fs.wave @@ -7,5 +7,5 @@ pub import("std::sys::freebsd::common::fs")::{ FS_O_RDONLY, FS_O_WRONLY, FS_O_RDWR, FS_O_NONBLOCK, FS_O_APPEND, FS_O_CREAT, FS_O_TRUNC, FS_O_EXCL, FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, Stat, open, close, dup, pipe, dup2, fsync, fcntl, read, write, - getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat + getcwd, chdir, access, lseek, unlink, mkdir, rmdir, rename_path, stat, fstat, ftruncate, same_file }; diff --git a/std/sys/fs.wave b/std/sys/fs.wave index a5dce749..f32dbd51 100644 --- a/std/sys/fs.wave +++ b/std/sys/fs.wave @@ -57,7 +57,7 @@ pub import("std::sys::linux::fs")::{ rename_path, Stat, stat, - fstat, + fstat, ftruncate, same_file, }; #[target(os="macos")] @@ -98,7 +98,7 @@ pub import("std::sys::macos::fs")::{ rmdir, rename_path, stat, - fstat, + fstat, ftruncate, same_file, }; #[target(os="windows")] @@ -108,7 +108,7 @@ pub import("std::sys::windows::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(os="freebsd")] @@ -118,7 +118,7 @@ pub import("std::sys::freebsd::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(os="wasi")] @@ -128,5 +128,5 @@ pub import("std::sys::wasm::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; diff --git a/std/sys/linux/amd64/fs.wave b/std/sys/linux/amd64/fs.wave index 86d1fd53..075ab531 100644 --- a/std/sys/linux/amd64/fs.wave +++ b/std/sys/linux/amd64/fs.wave @@ -204,3 +204,26 @@ pub fun fstat(fd: i64, st: ptr) -> i64 { // syscall: fstat (5) return syscall2(5, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(77, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/linux/arm64/fs.wave b/std/sys/linux/arm64/fs.wave index aad5d69b..8b28e6d7 100644 --- a/std/sys/linux/arm64/fs.wave +++ b/std/sys/linux/arm64/fs.wave @@ -188,3 +188,26 @@ pub fun stat(path: str, st: ptr) -> i64 { pub fun fstat(fd: i64, st: ptr) -> i64 { return syscall2(80, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(46, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/linux/fs.wave b/std/sys/linux/fs.wave index 6ae004c1..aa559b6f 100644 --- a/std/sys/linux/fs.wave +++ b/std/sys/linux/fs.wave @@ -11,7 +11,7 @@ pub import("std::sys::linux::amd64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(arch="aarch64")] @@ -21,7 +21,7 @@ pub import("std::sys::linux::arm64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(arch="riscv64")] @@ -31,7 +31,7 @@ pub import("std::sys::linux::riscv64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(arch="loongarch64")] @@ -41,5 +41,5 @@ pub import("std::sys::linux::loong64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; diff --git a/std/sys/linux/loong64/fs.wave b/std/sys/linux/loong64/fs.wave index bb3decb5..e591f6a9 100644 --- a/std/sys/linux/loong64/fs.wave +++ b/std/sys/linux/loong64/fs.wave @@ -9,5 +9,5 @@ pub import("std::sys::linux::riscv64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; diff --git a/std/sys/linux/riscv64/fs.wave b/std/sys/linux/riscv64/fs.wave index dbae7001..bfdd76f6 100644 --- a/std/sys/linux/riscv64/fs.wave +++ b/std/sys/linux/riscv64/fs.wave @@ -188,3 +188,26 @@ pub fun stat(path: str, st: ptr) -> i64 { pub fun fstat(fd: i64, st: ptr) -> i64 { return syscall2(80, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(46, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/macos/amd64/fs.wave b/std/sys/macos/amd64/fs.wave index ad48786e..4544be61 100644 --- a/std/sys/macos/amd64/fs.wave +++ b/std/sys/macos/amd64/fs.wave @@ -212,3 +212,26 @@ pub fun stat(path: str, st: ptr) -> i64 { pub fun fstat(fd: i64, st: ptr) -> i64 { return syscall2(339, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(201, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/macos/arm64/fs.wave b/std/sys/macos/arm64/fs.wave index 782f9867..225e115a 100644 --- a/std/sys/macos/arm64/fs.wave +++ b/std/sys/macos/arm64/fs.wave @@ -210,3 +210,26 @@ pub fun stat(path: str, st: ptr) -> i64 { pub fun fstat(fd: i64, st: ptr) -> i64 { return syscall2(339, fd, st as i64); } + +// Resize the opened object without changing its current offset. +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + return syscall2(201, fd, length); +} + +// Returns 1 for the same opened file, 0 for distinct files, or a negative error. +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: Stat; + var second: Stat; + var result: i64 = fstat(first_fd, &first); + if (result < 0) { + return result; + } + result = fstat(second_fd, &second); + if (result < 0) { + return result; + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/macos/fs.wave b/std/sys/macos/fs.wave index 4e0079be..3afd1056 100644 --- a/std/sys/macos/fs.wave +++ b/std/sys/macos/fs.wave @@ -11,7 +11,7 @@ pub import("std::sys::macos::amd64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; #[target(arch="aarch64")] @@ -21,5 +21,5 @@ pub import("std::sys::macos::arm64::fs")::{ FS_F_OK, FS_X_OK, FS_W_OK, FS_R_OK, FS_SEEK_SET, FS_SEEK_CUR, FS_SEEK_END, FS_F_GETFL, FS_F_SETFL, open, close, dup, dup2, pipe, fsync, fcntl, read, write, getcwd, - chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, + chdir, access, lseek, unlink, mkdir, rmdir, rename_path, Stat, stat, fstat, ftruncate, same_file, }; diff --git a/std/sys/wasm/fs.wave b/std/sys/wasm/fs.wave index 81bdefdb..a7e4af39 100644 --- a/std/sys/wasm/fs.wave +++ b/std/sys/wasm/fs.wave @@ -64,6 +64,7 @@ const WASI_RIGHT_SYNC: u64 = 16; const WASI_RIGHT_TELL: u64 = 32; const WASI_RIGHT_WRITE: u64 = 64; const WASI_RIGHT_FILESTAT_GET: u64 = 2097152; +const WASI_RIGHT_FILESTAT_SET_SIZE: u64 = 4194304; const WASI_O_CREAT: u16 = 1; const WASI_O_EXCL: u16 = 4; const WASI_O_TRUNC: u16 = 8; @@ -122,9 +123,9 @@ pub fun open(path: str, flags: i32, mode: i32) -> i64 { if (length < 0) { return length; } var rights: u64 = WASI_RIGHT_SEEK | WASI_RIGHT_TELL | WASI_RIGHT_FILESTAT_GET; if ((flags & FS_O_RDWR) != 0) { - rights = rights | WASI_RIGHT_READ | WASI_RIGHT_WRITE | WASI_RIGHT_SYNC; + rights = rights | WASI_RIGHT_READ | WASI_RIGHT_WRITE | WASI_RIGHT_SYNC | WASI_RIGHT_FILESTAT_SET_SIZE; } else if ((flags & FS_O_WRONLY) != 0) { - rights = rights | WASI_RIGHT_WRITE | WASI_RIGHT_SYNC; + rights = rights | WASI_RIGHT_WRITE | WASI_RIGHT_SYNC | WASI_RIGHT_FILESTAT_SET_SIZE; } else { rights = rights | WASI_RIGHT_READ; } @@ -252,3 +253,30 @@ pub fun dup(fd: i64) -> i64 { return SYS_ERR_NOT_IMPLEMENTED; } pub fun dup2(oldfd: i64, newfd: i64) -> i64 { return SYS_ERR_NOT_IMPLEMENTED; } pub fun pipe(fds: ptr) -> i64 { return SYS_ERR_NOT_IMPLEMENTED; } pub fun fcntl(fd: i64, cmd: i32, arg: i64) -> i64 { return SYS_ERR_NOT_IMPLEMENTED; } + +extern(c, "fd_filestat_set_size") fun wasi_fd_set_size(fd: u32, size: u64) -> u16; + +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (fd < 0 || fd > 4294967295 || length < 0) { + return -22; + } + var error: u16 = wasi_fd_set_size(fd as u32, length as u64); + return -(error as i64); +} + +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + if (first_fd < 0 || first_fd > 4294967295 || second_fd < 0 || second_fd > 4294967295) { + return -22; + } + var first: WasiFileStat; + var second: WasiFileStat; + var error: u16 = wasi_fd_filestat_get(first_fd as u32, &first); + if (error != 0) { + return -(error as i64); + } + error = wasi_fd_filestat_get(second_fd as u32, &second); + if (error != 0) { + return -(error as i64); + } + return (first.dev == second.dev && first.ino == second.ino) as i64; +} diff --git a/std/sys/windows/fs.wave b/std/sys/windows/fs.wave index 8ee6cdaf..f692151c 100644 --- a/std/sys/windows/fs.wave +++ b/std/sys/windows/fs.wave @@ -241,3 +241,51 @@ pub fun stat(path: str, st: ptr) -> i64 { } return 0; } + +// FILE_ID_INFO retains the full 128-bit ID, including ReFS identities. +struct WinFileIdInfo { + volume: u64; + id: array; +} +extern(system, "GetFileInformationByHandleEx") fun win_file_id( + handle: ptr, info_class: i32, info: ptr, size: u32 +) -> i32; +extern(system, "SetFileInformationByHandle") fun win_set_file_end( + handle: ptr, info_class: i32, info: ptr, size: u32 +) -> i32; +extern(system, "GetLastError") fun win_file_last_error() -> u32; + +pub fun same_file(first_fd: i64, second_fd: i64) -> i64 { + var first: WinFileIdInfo; + var second: WinFileIdInfo; + // FileIdInfo = 18; FILE_ID_INFO is 24 bytes on both 64-bit targets. + if (win_file_id(win_handle_from_fd(first_fd), 18, &first, 24) == 0) { + return -(win_file_last_error() as i64); + } + if (win_file_id(win_handle_from_fd(second_fd), 18, &second, 24) == 0) { + return -(win_file_last_error() as i64); + } + if (first.volume != second.volume) { + return 0; + } + var index: i64 = 0; + while (index < 16) { + if (first.id[index] != second.id[index]) { + return 0; + } + index += 1; + } + return 1; +} + +pub fun ftruncate(fd: i64, length: i64) -> i64 { + if (length < 0) { + return -22; + } + var end: i64 = length; + // FileEndOfFileInfo = 6; setting EOF does not move the file pointer. + if (win_set_file_end(win_handle_from_fd(fd), 6, &end, 8) == 0) { + return -(win_file_last_error() as i64); + } + return 0; +} diff --git a/tests/fixtures/io/copy.wave b/tests/fixtures/io/copy.wave new file mode 100644 index 00000000..44ab3387 --- /dev/null +++ b/tests/fixtures/io/copy.wave @@ -0,0 +1,12 @@ +import("std::fs::file")::{fs_copy}; +fun main() -> i32 { + var scratch: array; + var result: i64 = 0; + var index: i32 = 0; + while (index < 128) { + result = fs_copy("source.bin", "destination.bin", &scratch[0], 17, 384); + index += 1; + } + println("{}", result); + return 0; +} diff --git a/tests/fixtures/io/copy_self.wave b/tests/fixtures/io/copy_self.wave new file mode 100644 index 00000000..03639adc --- /dev/null +++ b/tests/fixtures/io/copy_self.wave @@ -0,0 +1,12 @@ +import("std::fs::file")::{fs_copy}; +fun main() -> i32 { + var scratch: array; + var result: i64 = 0; + var index: i32 = 0; + while (index < 128) { + result = fs_copy("source.bin", "source.bin", &scratch[0], 17, 384); + index += 1; + } + println("{}", result); + return 0; +} diff --git a/tests/fixtures/io/invalid_buffers.wave b/tests/fixtures/io/invalid_buffers.wave new file mode 100644 index 00000000..ea1a5f14 --- /dev/null +++ b/tests/fixtures/io/invalid_buffers.wave @@ -0,0 +1,14 @@ +import("std::fs::file")::{fs_read_all, fs_copy}; +import("std::io::consts")::{IO_ERR_INVALID}; +fun main() -> i32 { + var value: u8 = 0; + if (fs_read_all("source.bin", null, 1) != IO_ERR_INVALID + || fs_read_all("source.bin", &value, -1) != IO_ERR_INVALID) { + return 1; + } + if (fs_copy("source.bin", "destination.bin", null, 1, 384) != IO_ERR_INVALID + || fs_copy("source.bin", "destination.bin", &value, 0, 384) != IO_ERR_INVALID) { + return 2; + } + return 0; +} diff --git a/tools/test_std_io_runtime.py b/tools/test_std_io_runtime.py index 7aef5bd7..5ebc356e 100644 --- a/tools/test_std_io_runtime.py +++ b/tools/test_std_io_runtime.py @@ -28,7 +28,7 @@ def setUpClass(cls): cls.env = dict(os.environ, HOME=str(home), USERPROFILE=str(home)) cls.target = os.environ.get("WAVE_TEST_TARGET") cls.executables = {} - names = ['read_all', 'read_zero'] + names = ['copy', 'copy_self', 'read_all', 'read_zero', 'invalid_buffers'] for name in names: cls.build(name) @@ -69,12 +69,53 @@ def limit(): resource.setrlimit(resource.RLIMIT_NOFILE, (64, hard)) return {"preexec_fn": limit} + def test_invalid_buffers_do_not_open_or_truncate_files(self): + target = self.directory / "destination.bin" + target.write_bytes(b"untouched") + self.run_fixture("invalid_buffers") + self.assertEqual(target.read_bytes(), b"untouched") + def test_copy_self_preserves_bytes_and_closes_error_handles(self): + source = self.directory / "source.bin" + data = bytes(range(97)) + source.write_bytes(data) + self.assertEqual(self.run_fixture("copy_self", **self.low_fd_limit()), "-4096") + self.assertEqual(source.read_bytes(), data) - - - - + def test_copy_hardlink_preserves_bytes(self): + source = self.directory / "source.bin" + data = bytes(range(97)) + source.write_bytes(data) + os.link(source, self.directory / "destination.bin") + self.assertEqual(self.run_fixture("copy", **self.low_fd_limit()), "-4096") + self.assertEqual(source.read_bytes(), data) + + @unittest.skipIf(os.name == "nt", "Windows symlink creation requires a host privilege") + def test_copy_symlink_preserves_bytes(self): + source = self.directory / "source.bin" + source.write_bytes(b"original") + (self.directory / "destination.bin").symlink_to(source) + self.assertEqual(self.run_fixture("copy"), "-4096") + self.assertEqual(source.read_bytes(), b"original") + + def test_copy_distinct_existing_destination_is_truncated_and_handles_close(self): + data = bytes(range(97)) + (self.directory / "source.bin").write_bytes(data) + target = self.directory / "destination.bin" + target.write_bytes(b"old" * 100) + self.assertEqual(self.run_fixture("copy", **self.low_fd_limit()), "97") + self.assertEqual(target.read_bytes(), data) + + def test_copy_creates_destination(self): + (self.directory / "source.bin").write_bytes(b"new content") + self.assertEqual(self.run_fixture("copy"), "11") + self.assertEqual((self.directory / "destination.bin").read_bytes(), b"new content") + + def test_missing_copy_source_preserves_destination(self): + target = self.directory / "destination.bin" + target.write_bytes(b"untouched") + self.assertLess(int(self.run_fixture("copy")), 0) + self.assertEqual(target.read_bytes(), b"untouched") def test_read_empty_exact_capacity_and_oversized_files(self): for length in (0, 1, 127, 128, 129): From 9c520ad4d86fc570f9bf517cbcd47f01fecfa99e Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:57 +0900 Subject: [PATCH 7/9] fix(process): close unused capture readers before child stream remapping Fixes #559. Signed-off-by: LunaStev --- std/process/spawn.wave | 22 +++++++- tests/fixtures/io/capture.wave | 43 +++++++++++++++ tests/fixtures/io/capture_close.wave | 16 ++++++ tests/fixtures/io/capture_failures.wave | 47 ++++++++++++++++ tests/fixtures/io/capture_fork_failure.wave | 31 +++++++++++ tools/test_std_io_runtime.py | 60 +++++++++++++++++++-- 6 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/io/capture.wave create mode 100644 tests/fixtures/io/capture_close.wave create mode 100644 tests/fixtures/io/capture_failures.wave create mode 100644 tests/fixtures/io/capture_fork_failure.wave diff --git a/std/process/spawn.wave b/std/process/spawn.wave index 36a142b1..a30c16f8 100644 --- a/std/process/spawn.wave +++ b/std/process/spawn.wave @@ -158,6 +158,18 @@ pub fun proc_spawn_exec_raw( stdin_fd: i64, stdout_fd: i64, stderr_fd: i64 +) -> i64 { + return _proc_spawn_exec_closed(path, argv, envp, stdin_fd, stdout_fd, stderr_fd, -1); +} + +fun _proc_spawn_exec_closed( + path: str, + argv: ptr>, + envp: ptr>, + stdin_fd: i64, + stdout_fd: i64, + stderr_fd: i64, + unused_fd: i64 ) -> i64 { var pid: i64 = proc_fork(); if (pid < 0) { @@ -165,6 +177,12 @@ pub fun proc_spawn_exec_raw( } if (pid == 0) { + // Close before remapping: the pipe can occupy a closed standard slot. + // Closing afterward could accidentally close the newly installed stdout. + if (unused_fd >= 0 && io_close(unused_fd) < 0) { + proc_exit(PROC_EXIT_DUP_FAIL); + } + if (_proc_remap_child_fds(stdin_fd, stdout_fd, stderr_fd) < 0) { proc_exit(PROC_EXIT_DUP_FAIL); } @@ -216,7 +234,9 @@ pub fun proc_spawn_capture_stdout( }; } - var pid: i64 = proc_spawn_exec_raw(path, argv, envp, -1, pipe_r.write_fd, -1); + var pid: i64 = _proc_spawn_exec_closed( + path, argv, envp, -1, pipe_r.write_fd, -1, pipe_r.read_fd + ); io_close(pipe_r.write_fd); if (pid < 0) { io_close(pipe_r.read_fd); diff --git a/tests/fixtures/io/capture.wave b/tests/fixtures/io/capture.wave new file mode 100644 index 00000000..a9b0934a --- /dev/null +++ b/tests/fixtures/io/capture.wave @@ -0,0 +1,43 @@ +import("std::process::spawn")::{ProcSpawnStdoutResult, proc_spawn_capture_stdout}; +import("std::process::wait")::{proc_wait, proc_status_exit_code, proc_status_exited}; +import("std::io::fd")::{io_close, io_read_at_most}; +import("std::sys::fs")::{fcntl, FS_F_GETFL}; + +fun open_count() -> i32 { + var count: i32 = 0; + var fd: i64 = 0; + while (fd < 512) { + if (fcntl(fd, FS_F_GETFL, 0) >= 0) { + count += 1; + } + fd += 1; + } + return count; +} + +fun main() -> i32 { + var initial: i32 = open_count(); + var args: array, 2> = ["capture-child" as ptr, null]; + var index: i32 = 0; + while (index < 16) { + var child: ProcSpawnStdoutResult = proc_spawn_capture_stdout("./capture-child", &args[0], null); + if (child.status < 0) { + return 10; + } + var data: array; + var count: i64 = io_read_at_most(child.read_fd, &data[0], 8); + io_close(child.read_fd); + var status: i32 = 0; + if (proc_wait(child.pid, &status) < 0 || !proc_status_exited(status)) { + return 11; + } + if (proc_status_exit_code(status) != 0 || count != 2 || data[0] != 79 || data[1] != 75) { + return 12; + } + index += 1; + } + if (open_count() != initial) { + return 13; + } + return 0; +} diff --git a/tests/fixtures/io/capture_close.wave b/tests/fixtures/io/capture_close.wave new file mode 100644 index 00000000..5a213c8b --- /dev/null +++ b/tests/fixtures/io/capture_close.wave @@ -0,0 +1,16 @@ +import("std::process::spawn")::{ProcSpawnStdoutResult, proc_spawn_capture_stdout}; +import("std::process::wait")::{proc_wait, proc_status_exit_code, proc_status_exited}; +import("std::io::fd")::{io_close}; +fun main() -> i32 { + var args: array, 2> = ["capture-child" as ptr, null]; + var child: ProcSpawnStdoutResult = proc_spawn_capture_stdout("./capture-child", &args[0], null); + if (child.status < 0) { + return 10; + } + io_close(child.read_fd); + var status: i32 = 0; + if (proc_wait(child.pid, &status) < 0 || !proc_status_exited(status)) { + return 11; + } + return proc_status_exit_code(status); +} diff --git a/tests/fixtures/io/capture_failures.wave b/tests/fixtures/io/capture_failures.wave new file mode 100644 index 00000000..b7bdfb72 --- /dev/null +++ b/tests/fixtures/io/capture_failures.wave @@ -0,0 +1,47 @@ +import("std::process::spawn")::{ProcSpawnStdoutResult, proc_spawn_capture_stdout}; +import("std::process::wait")::{proc_wait, proc_status_exit_code, proc_status_exited}; +import("std::process::consts")::{PROC_EXIT_EXEC_FAIL}; +import("std::io::fd")::{io_close, io_read, io_pipe}; + +fun main() -> i32 { + var args: array, 2> = ["missing-executable" as ptr, null]; + var index: i32 = 0; + while (index < 128) { + var child: ProcSpawnStdoutResult = proc_spawn_capture_stdout("./missing-executable", &args[0], null); + if (child.status < 0) { + return 10; + } + var value: u8 = 0; + var count: i64 = io_read(child.read_fd, &value, 1); + io_close(child.read_fd); + var status: i32 = 0; + if (proc_wait(child.pid, &status) < 0 || !proc_status_exited(status)) { + return 11; + } + if (count != 0 || proc_status_exit_code(status) != PROC_EXIT_EXEC_FAIL) { + return 12; + } + index += 1; + } + // The harness supplies a small descriptor limit. Exhaustion must return a + // failed spawn without leaking a half-created pipe or a child process. + var held: array; + var held_count: i64 = 0; + while (held_count < 128) { + if (io_pipe(&held[held_count]) < 0) { + break; + } + held_count += 2; + } + var failed: ProcSpawnStdoutResult = proc_spawn_capture_stdout("./missing-executable", &args[0], null); + var result: i32 = 0; + if (failed.status >= 0 || failed.pid != -1 || failed.read_fd != -1) { + result = 13; + } + var slot: i64 = 0; + while (slot < held_count) { + io_close(held[slot] as i64); + slot += 1; + } + return result; +} diff --git a/tests/fixtures/io/capture_fork_failure.wave b/tests/fixtures/io/capture_fork_failure.wave new file mode 100644 index 00000000..9cccaf84 --- /dev/null +++ b/tests/fixtures/io/capture_fork_failure.wave @@ -0,0 +1,31 @@ +import("std::process::spawn")::{ProcSpawnStdoutResult, proc_spawn_capture_stdout}; +import("std::sys::fs")::{fcntl, FS_F_GETFL}; + +fun open_count() -> i32 { + var count: i32 = 0; + var fd: i64 = 0; + while (fd < 512) { + if (fcntl(fd, FS_F_GETFL, 0) >= 0) { + count += 1; + } + fd += 1; + } + return count; +} + +fun main() -> i32 { + var initial: i32 = open_count(); + var args: array, 2> = ["unused" as ptr, null]; + var index: i32 = 0; + while (index < 128) { + var child: ProcSpawnStdoutResult = proc_spawn_capture_stdout("./unused", &args[0], null); + if (child.status >= 0 || child.pid != -1 || child.read_fd != -1) { + return 10; + } + index += 1; + } + if (open_count() != initial) { + return 11; + } + return 0; +} diff --git a/tools/test_std_io_runtime.py b/tools/test_std_io_runtime.py index 5ebc356e..54a70102 100644 --- a/tools/test_std_io_runtime.py +++ b/tools/test_std_io_runtime.py @@ -29,6 +29,10 @@ def setUpClass(cls): cls.target = os.environ.get("WAVE_TEST_TARGET") cls.executables = {} names = ['copy', 'copy_self', 'read_all', 'read_zero', 'invalid_buffers'] + if os.name == "posix": + names += ["capture", "capture_close"] + if sys.platform.startswith("linux"): + names += ["capture_failures", "capture_fork_failure"] for name in names: cls.build(name) @@ -190,11 +194,57 @@ def produce(): - - - - - + def write_capture_child(self, body): + child = self.directory / "capture-child" + child.write_text(f"#!{sys.executable}\n" + body) + child.chmod(0o700) + + @unittest.skipUnless(os.name == "posix", "fork/exec capture") + def test_capture_has_no_inherited_pipe_reader_and_no_parent_leak(self): + self.write_capture_child("""import os, fcntl, stat +out = os.fstat(1) +for fd in range(256): + try: + info = os.fstat(fd) + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + except OSError: + continue + if (stat.S_ISFIFO(info.st_mode) + and (info.st_dev, info.st_ino) == (out.st_dev, out.st_ino) + and flags & os.O_ACCMODE == os.O_RDONLY): + raise SystemExit(42) +os.write(1, b'OK') +""") + self.run_fixture("capture", **self.low_fd_limit()) + def close_standard_slots(): + os.close(0) + os.close(1) + self.run_fixture("capture", preexec_fn=close_standard_slots) + + + @unittest.skipUnless(os.name == "posix", "fork/exec capture") + def test_capture_writer_observes_consumer_closure(self): + self.write_capture_child("""import os +try: + while True: + os.write(1, b'x' * 65536) +except BrokenPipeError: + pass +""") + self.run_fixture("capture_close") + + @unittest.skipUnless(sys.platform.startswith("linux"), "bounded Linux descriptor limit") + def test_capture_exec_failure_and_pipe_exhaustion_release_resources(self): + self.run_fixture("capture_failures", **self.low_fd_limit()) + + @unittest.skipUnless(sys.platform.startswith("linux") and os.geteuid() != 0, + "unprivileged Linux process limit") + def test_capture_fork_failure_closes_both_pipe_ends(self): + def forbid_fork(): + import resource + _, hard = resource.getrlimit(resource.RLIMIT_NPROC) + resource.setrlimit(resource.RLIMIT_NPROC, (0, hard)) + self.run_fixture("capture_fork_failure", preexec_fn=forbid_fork) From 28eb80673cd85453826df19a01fb894e4768871c Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:01:59 +0900 Subject: [PATCH 8/9] fix(process): terminate every Linux thread on process exit Fixes #565. Signed-off-by: LunaStev --- std/sys/linux/amd64/process.wave | 4 +-- std/sys/linux/arm64/process.wave | 2 +- std/sys/linux/riscv64/process.wave | 2 +- tests/fixtures/io/exit_group.c | 39 ++++++++++++++++++++++++++++++ tests/fixtures/io/exit_group.wave | 4 +++ tools/test_std_io_runtime.py | 22 +++++++++++++++++ 6 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/io/exit_group.c create mode 100644 tests/fixtures/io/exit_group.wave diff --git a/std/sys/linux/amd64/process.wave b/std/sys/linux/amd64/process.wave index e8c9439b..4dc41046 100644 --- a/std/sys/linux/amd64/process.wave +++ b/std/sys/linux/amd64/process.wave @@ -41,8 +41,8 @@ import("std::sys::linux::amd64::syscall")::{ // ----------------------- pub fun exit(code: i32) -> ! { - // syscall: exit (60) - syscall1(60, code as i64); + // exit_group terminates all threads, matching the process-level API. + syscall1(231, code as i64); while (true) { } } diff --git a/std/sys/linux/arm64/process.wave b/std/sys/linux/arm64/process.wave index a87caa87..22157d47 100644 --- a/std/sys/linux/arm64/process.wave +++ b/std/sys/linux/arm64/process.wave @@ -34,7 +34,7 @@ import("std::sys::linux::arm64::syscall")::{ // ----------------------- pub fun exit(code: i32) -> ! { - syscall1(93, code as i64); + syscall1(94, code as i64); while (true) { } } diff --git a/std/sys/linux/riscv64/process.wave b/std/sys/linux/riscv64/process.wave index b60e94b5..af5747bb 100644 --- a/std/sys/linux/riscv64/process.wave +++ b/std/sys/linux/riscv64/process.wave @@ -34,7 +34,7 @@ import("std::sys::linux::syscall")::{ // ----------------------- pub fun exit(code: i32) -> ! { - syscall1(93, code as i64); + syscall1(94, code as i64); while (true) { } } diff --git a/tests/fixtures/io/exit_group.c b/tests/fixtures/io/exit_group.c new file mode 100644 index 00000000..4a27c979 --- /dev/null +++ b/tests/fixtures/io/exit_group.c @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// A native host keeps another thread alive while Wave terminates the process. +#include +#include + +extern void wave_exit(void); +static pthread_barrier_t ready; +static int exit_from_worker; + +static void *worker(void *unused) { + (void)unused; + pthread_barrier_wait(&ready); + if (exit_from_worker) { + wave_exit(); + } + for (;;) { + pause(); + } + return NULL; +} + +int main(int argc, char **argv) { + (void)argv; + exit_from_worker = argc > 1; + if (pthread_barrier_init(&ready, NULL, 2) != 0) { + return 90; + } + pthread_t thread; + if (pthread_create(&thread, NULL, worker, NULL) != 0) { + return 91; + } + pthread_barrier_wait(&ready); + if (!exit_from_worker) { + wave_exit(); + } + for (;;) { + pause(); + } +} diff --git a/tests/fixtures/io/exit_group.wave b/tests/fixtures/io/exit_group.wave new file mode 100644 index 00000000..3df469a9 --- /dev/null +++ b/tests/fixtures/io/exit_group.wave @@ -0,0 +1,4 @@ +import("std::process::core")::{proc_exit}; +export(c, "wave_exit") fun terminate_process() { + proc_exit(37); +} diff --git a/tools/test_std_io_runtime.py b/tools/test_std_io_runtime.py index 54a70102..428df7bf 100644 --- a/tools/test_std_io_runtime.py +++ b/tools/test_std_io_runtime.py @@ -35,6 +35,21 @@ def setUpClass(cls): names += ["capture_failures", "capture_fork_failure"] for name in names: cls.build(name) + if sys.platform.startswith("linux"): + objdir = cls.base / "exit" + cls.compile("exit_group", ["--emit=obj", "--out-dir", str(objdir)]) + cc = shutil.which("clang-21") or shutil.which("clang") or shutil.which("cc") + if not cc: + raise RuntimeError("a C compiler is required for the native thread fixture") + executable = objdir / "exit-host" + result = run_process( + [cc, "-pthread", str(ROOT / "tests/fixtures/io/exit_group.c"), + str(objdir / "exit_group.o"), "-o", str(executable)], + timeout=30, capture_output=True, text=True, + ) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + cls.executables["exit_group"] = executable @classmethod @@ -246,6 +261,13 @@ def forbid_fork(): resource.setrlimit(resource.RLIMIT_NPROC, (0, hard)) self.run_fixture("capture_fork_failure", preexec_fn=forbid_fork) + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux exit_group") + def test_process_exit_terminates_other_native_threads(self): + for args in ([], ["worker"]): + with self.subTest(args=args): + result = run_process([str(self.executables["exit_group"]), *args], + timeout=5, capture_output=True, text=True) + self.assertEqual(result.returncode, 37, result.stdout + result.stderr) if __name__ == "__main__": From d811e0c2accc8997eecb5071f8752f88e9fb44e4 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Sun, 13 Sep 2026 13:02:02 +0900 Subject: [PATCH 9/9] fix(net): bound timed socket I/O without changing shared socket modes Fixes #510. Signed-off-by: LunaStev --- std/net/posix_timeout.wave | 72 ++++++++++++++ std/net/tcp.wave | 32 +++--- std/net/windows_timeout.wave | 14 +++ std/sys/freebsd/amd64/socket.wave | 2 +- std/sys/freebsd/arm64/socket.wave | 2 +- std/sys/freebsd/common/socket.wave | 1 + std/sys/freebsd/riscv64/socket.wave | 2 +- std/sys/freebsd/socket.wave | 6 +- std/sys/linux/amd64/socket.wave | 1 + std/sys/linux/arm64/socket.wave | 1 + std/sys/linux/loong64/socket.wave | 2 +- std/sys/linux/riscv64/socket.wave | 1 + std/sys/linux/socket.wave | 8 +- std/sys/macos/amd64/socket.wave | 1 + std/sys/macos/arm64/socket.wave | 1 + std/sys/macos/socket.wave | 4 +- std/sys/socket.wave | 6 +- std/sys/windows/timed_socket.wave | 108 ++++++++++++++++++++ tests/fixtures/io/tcp_interrupt.c | 62 ++++++++++++ tests/fixtures/io/tcp_interrupt.wave | 10 ++ tests/fixtures/io/tcp_read.wave | 142 +++++++++++++++++++++++++++ tests/fixtures/io/tcp_timeout.wave | 54 ++++++++++ tools/test_std_io_runtime.py | 19 +++- 23 files changed, 516 insertions(+), 35 deletions(-) create mode 100644 std/net/posix_timeout.wave create mode 100644 std/net/windows_timeout.wave create mode 100644 std/sys/windows/timed_socket.wave create mode 100644 tests/fixtures/io/tcp_interrupt.c create mode 100644 tests/fixtures/io/tcp_interrupt.wave create mode 100644 tests/fixtures/io/tcp_read.wave create mode 100644 tests/fixtures/io/tcp_timeout.wave diff --git a/std/net/posix_timeout.wave b/std/net/posix_timeout.wave new file mode 100644 index 00000000..2c54a4f4 --- /dev/null +++ b/std/net/posix_timeout.wave @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// A readiness event can be consumed by an alias before our syscall. Each I/O +// attempt must itself be nonblocking; never change the shared descriptor mode. +import("std::sys::socket")::{send, recv, MSG_NOSIGNAL, MSG_DONTWAIT}; +import("std::net::poll")::{net_wait_readable, net_wait_writable}; +import("std::net::error")::{ + NetIoResult, NetError, net_io_ok, net_io_eof, net_io_error, + net_error_from_native, NET_ERROR_INTERRUPTED, NET_ERROR_WOULD_BLOCK, +}; +import("std::time::clock")::{time_now_monotonic_ns}; + +pub fun net_io_timeout(fd: i64, buffer: ptr, size: i64, timeout_ms: i32, writing: bool) -> NetIoResult { + var start: i64 = 0; + if (timeout_ms >= 0) { + start = time_now_monotonic_ns(); + if (start < 0) { + return net_io_error(0, start); + } + } + var budget: i64 = timeout_ms as i64 * 1000000; + while (true) { + var count: i64 = 0; + if (writing) { + count = send(fd, buffer, size, MSG_DONTWAIT | MSG_NOSIGNAL); + } else { + count = recv(fd, buffer, size, MSG_DONTWAIT); + } + if (count >= 0) { + if (!writing && count == 0) { + return net_io_eof(0); + } + return net_io_ok(count); + } + var error: NetError = net_error_from_native(count); + if (error.kind != NET_ERROR_INTERRUPTED && error.kind != NET_ERROR_WOULD_BLOCK) { + return net_io_error(0, count); + } + + var remaining: i32 = -1; + if (timeout_ms >= 0) { + var now: i64 = time_now_monotonic_ns(); + if (now < 0) { + return net_io_error(0, now); + } + if (now < start) { + return net_io_error(0, -5); + } + var elapsed: i64 = now - start; + if (elapsed >= budget) { + return net_io_error(0, -110); + } + remaining = ((budget - elapsed + 999999) / 1000000) as i32; + } + var ready: i64 = 0; + if (writing) { + ready = net_wait_writable(fd, remaining); + } else { + ready = net_wait_readable(fd, remaining); + } + if (ready == 0) { + return net_io_error(0, -110); + } + if (ready < 0) { + error = net_error_from_native(ready); + if (error.kind != NET_ERROR_INTERRUPTED) { + return net_io_error(0, ready); + } + } + // The next EAGAIN or EINTR recomputes the same monotonic budget. + } + return net_io_error(0, -5); +} diff --git a/std/net/tcp.wave b/std/net/tcp.wave index a2a1cf54..75bb4e24 100644 --- a/std/net/tcp.wave +++ b/std/net/tcp.wave @@ -32,6 +32,14 @@ import("std::net::socket_base")::{ net_send_all_io, net_recv_exact_io, net_shutdown, net_close, }; +#[target(os="linux")] +import("std::net::posix_timeout")::{net_io_timeout}; +#[target(os="macos")] +import("std::net::posix_timeout")::{net_io_timeout}; +#[target(os="freebsd")] +import("std::net::posix_timeout")::{net_io_timeout}; +#[target(os="windows")] +import("std::net::windows_timeout")::{net_io_timeout}; import("std::net::poll")::{net_wait_readable, net_wait_writable, net_wait_connected}; import("std::net::vectored")::{NetIoSlice, net_send_vectored, net_recv_vectored}; import("std::net::socketopt")::{ @@ -338,17 +346,6 @@ pub fun tcp_read_result(stream: TcpStream, buf: ptr, size: i64) -> NetIoResu return net_io_error(0, count); } -fun _tcp_timeout_io_result(ready: i64) -> NetIoResult { - if (ready == 0) { - return NetIoResult { - count: 0, - eof: false, - error: net_error(NET_ERROR_TIMED_OUT, 0) - }; - } - return net_io_error(0, ready); -} - pub fun tcp_wait_read(stream: TcpStream, timeout_ms: i32) -> NetError { if (timeout_ms < -1) { return net_error_from_native(-22); } var ready: i64 = net_wait_readable(stream.fd, timeout_ms); @@ -365,8 +362,11 @@ pub fun tcp_wait_write(stream: TcpStream, timeout_ms: i32) -> NetError { return net_error_from_native(ready); } -// These timeout helpers wait for readiness and perform one I/O operation. +// These timeout helpers bound both readiness and the subsequent I/O attempt. // Use the returned count to continue partial protocol reads or writes. +// On Windows, imported sockets must support overlapped I/O, as socket() does. +// A timeout cannot undo transmitted bytes. On cancellation errors, Winsock +// exposes no partial count; count contains only confirmed completed bytes. pub fun tcp_read_timeout( stream: TcpStream, buf: ptr, size: i64, timeout_ms: i32 ) -> NetIoResult { @@ -374,9 +374,7 @@ pub fun tcp_read_timeout( return net_io_error(0, -22); } if (size == 0) { return net_io_ok(0); } - var ready: i64 = net_wait_readable(stream.fd, timeout_ms); - if (ready <= 0) { return _tcp_timeout_io_result(ready); } - return tcp_read_result(stream, buf, size); + return net_io_timeout(stream.fd, buf, size, timeout_ms, false); } pub fun tcp_write_timeout( @@ -386,9 +384,7 @@ pub fun tcp_write_timeout( return net_io_error(0, -22); } if (size == 0) { return net_io_ok(0); } - var ready: i64 = net_wait_writable(stream.fd, timeout_ms); - if (ready <= 0) { return _tcp_timeout_io_result(ready); } - return tcp_write_result(stream, buf, size); + return net_io_timeout(stream.fd, buf, size, timeout_ms, true); } pub fun tcp_write_result(stream: TcpStream, buf: ptr, size: i64) -> NetIoResult { diff --git a/std/net/windows_timeout.wave b/std/net/windows_timeout.wave new file mode 100644 index 00000000..2c85acc7 --- /dev/null +++ b/std/net/windows_timeout.wave @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +import("std::sys::windows::timed_socket")::{TimedSocketResult, socket_io_timeout}; +import("std::net::error")::{NetIoResult, net_io_ok, net_io_eof, net_io_error}; + +pub fun net_io_timeout(fd: i64, buffer: ptr, size: i64, timeout_ms: i32, writing: bool) -> NetIoResult { + var result: TimedSocketResult = socket_io_timeout(fd, buffer, size, timeout_ms, writing); + if (result.error != 0) { + return net_io_error(result.count, result.error); + } + if (!writing && result.count == 0) { + return net_io_eof(0); + } + return net_io_ok(result.count); +} diff --git a/std/sys/freebsd/amd64/socket.wave b/std/sys/freebsd/amd64/socket.wave index 7a88941e..c24a7a18 100644 --- a/std/sys/freebsd/amd64/socket.wave +++ b/std/sys/freebsd/amd64/socket.wave @@ -9,7 +9,7 @@ pub import("std::sys::freebsd::common::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom }; diff --git a/std/sys/freebsd/arm64/socket.wave b/std/sys/freebsd/arm64/socket.wave index 7a88941e..c24a7a18 100644 --- a/std/sys/freebsd/arm64/socket.wave +++ b/std/sys/freebsd/arm64/socket.wave @@ -9,7 +9,7 @@ pub import("std::sys::freebsd::common::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom }; diff --git a/std/sys/freebsd/common/socket.wave b/std/sys/freebsd/common/socket.wave index 5087288a..3622d09e 100644 --- a/std/sys/freebsd/common/socket.wave +++ b/std/sys/freebsd/common/socket.wave @@ -70,6 +70,7 @@ pub const IPV6_MULTICAST_LOOP: i32 = 11; pub const IPV6_JOIN_GROUP: i32 = 12; pub const IPV6_LEAVE_GROUP: i32 = 13; pub const MSG_NOSIGNAL: i32 = 0x20000; +pub const MSG_DONTWAIT: i32 = 128; pub const POLLIN: i16 = 0x001; pub const POLLOUT: i16 = 0x004; diff --git a/std/sys/freebsd/riscv64/socket.wave b/std/sys/freebsd/riscv64/socket.wave index 7a88941e..c24a7a18 100644 --- a/std/sys/freebsd/riscv64/socket.wave +++ b/std/sys/freebsd/riscv64/socket.wave @@ -9,7 +9,7 @@ pub import("std::sys::freebsd::common::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom }; diff --git a/std/sys/freebsd/socket.wave b/std/sys/freebsd/socket.wave index 70cef0a8..aa972a27 100644 --- a/std/sys/freebsd/socket.wave +++ b/std/sys/freebsd/socket.wave @@ -11,7 +11,7 @@ pub import("std::sys::freebsd::amd64::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom, }; @@ -23,7 +23,7 @@ pub import("std::sys::freebsd::arm64::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom }; @@ -35,7 +35,7 @@ pub import("std::sys::freebsd::riscv64::socket")::{ SO_RCVBUF, SO_SNDBUF, SO_BROADCAST, SO_KEEPALIVE, TCP_NODELAY, IP_TTL, IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, - IPV6_LEAVE_GROUP, MSG_NOSIGNAL, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, + IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, poll, send, recv, sendto, recvfrom }; diff --git a/std/sys/linux/amd64/socket.wave b/std/sys/linux/amd64/socket.wave index 7d92fc1b..060d4aab 100644 --- a/std/sys/linux/amd64/socket.wave +++ b/std/sys/linux/amd64/socket.wave @@ -82,6 +82,7 @@ pub const IPV6_MULTICAST_LOOP: i32 = 19; pub const IPV6_JOIN_GROUP: i32 = 20; pub const IPV6_LEAVE_GROUP: i32 = 21; pub const MSG_NOSIGNAL: i32 = 0x4000; +pub const MSG_DONTWAIT: i32 = 64; // poll events pub const POLLIN: i16 = 0x001; diff --git a/std/sys/linux/arm64/socket.wave b/std/sys/linux/arm64/socket.wave index 439ad2c9..67f20c4a 100644 --- a/std/sys/linux/arm64/socket.wave +++ b/std/sys/linux/arm64/socket.wave @@ -75,6 +75,7 @@ pub const IPV6_MULTICAST_LOOP: i32 = 19; pub const IPV6_JOIN_GROUP: i32 = 20; pub const IPV6_LEAVE_GROUP: i32 = 21; pub const MSG_NOSIGNAL: i32 = 0x4000; +pub const MSG_DONTWAIT: i32 = 64; // poll events pub const POLLIN: i16 = 0x001; diff --git a/std/sys/linux/loong64/socket.wave b/std/sys/linux/loong64/socket.wave index a208540c..dc4c7978 100644 --- a/std/sys/linux/loong64/socket.wave +++ b/std/sys/linux/loong64/socket.wave @@ -12,7 +12,7 @@ pub import("std::sys::linux::riscv64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, diff --git a/std/sys/linux/riscv64/socket.wave b/std/sys/linux/riscv64/socket.wave index b4cd1bb3..6e12f6f2 100644 --- a/std/sys/linux/riscv64/socket.wave +++ b/std/sys/linux/riscv64/socket.wave @@ -75,6 +75,7 @@ pub const IPV6_MULTICAST_LOOP: i32 = 19; pub const IPV6_JOIN_GROUP: i32 = 20; pub const IPV6_LEAVE_GROUP: i32 = 21; pub const MSG_NOSIGNAL: i32 = 0x4000; +pub const MSG_DONTWAIT: i32 = 64; // poll events pub const POLLIN: i16 = 0x001; diff --git a/std/sys/linux/socket.wave b/std/sys/linux/socket.wave index 673990e2..4d80c01c 100644 --- a/std/sys/linux/socket.wave +++ b/std/sys/linux/socket.wave @@ -14,7 +14,7 @@ pub import("std::sys::linux::amd64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, @@ -31,7 +31,7 @@ pub import("std::sys::linux::arm64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, @@ -48,7 +48,7 @@ pub import("std::sys::linux::riscv64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, @@ -65,7 +65,7 @@ pub import("std::sys::linux::loong64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, diff --git a/std/sys/macos/amd64/socket.wave b/std/sys/macos/amd64/socket.wave index 2ed53ed0..d78b8e51 100644 --- a/std/sys/macos/amd64/socket.wave +++ b/std/sys/macos/amd64/socket.wave @@ -72,6 +72,7 @@ pub const IPV6_JOIN_GROUP: i32 = 12; pub const IPV6_LEAVE_GROUP: i32 = 13; pub const SO_NOSIGPIPE: i32 = 0x1022; pub const MSG_NOSIGNAL: i32 = 0; +pub const MSG_DONTWAIT: i32 = 128; pub const POLLIN: i16 = 0x001; pub const POLLOUT: i16 = 0x004; diff --git a/std/sys/macos/arm64/socket.wave b/std/sys/macos/arm64/socket.wave index f41921e7..b094de43 100644 --- a/std/sys/macos/arm64/socket.wave +++ b/std/sys/macos/arm64/socket.wave @@ -71,6 +71,7 @@ pub const IPV6_JOIN_GROUP: i32 = 12; pub const IPV6_LEAVE_GROUP: i32 = 13; pub const SO_NOSIGPIPE: i32 = 0x1022; pub const MSG_NOSIGNAL: i32 = 0; +pub const MSG_DONTWAIT: i32 = 128; pub const POLLIN: i16 = 0x001; pub const POLLOUT: i16 = 0x004; diff --git a/std/sys/macos/socket.wave b/std/sys/macos/socket.wave index 429f4caa..7dff7531 100644 --- a/std/sys/macos/socket.wave +++ b/std/sys/macos/socket.wave @@ -14,7 +14,7 @@ pub import("std::sys::macos::amd64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, SO_NOSIGPIPE, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, SO_NOSIGPIPE, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, @@ -31,7 +31,7 @@ pub import("std::sys::macos::arm64::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, SO_NOSIGPIPE, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, SO_NOSIGPIPE, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, diff --git a/std/sys/socket.wave b/std/sys/socket.wave index d9182b34..47abdc33 100644 --- a/std/sys/socket.wave +++ b/std/sys/socket.wave @@ -52,7 +52,7 @@ pub import("std::sys::linux::socket")::{ IPV6_MULTICAST_LOOP, IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, - MSG_NOSIGNAL, + MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, @@ -111,7 +111,7 @@ pub import("std::sys::macos::socket")::{ IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, SO_NOSIGPIPE, - MSG_NOSIGNAL, + MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, @@ -163,7 +163,7 @@ pub import("std::sys::freebsd::socket")::{ IP_MULTICAST_IF, IP_MULTICAST_TTL, IP_MULTICAST_LOOP, IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP, IPV6_MULTICAST_IF, IPV6_MULTICAST_HOPS, IPV6_MULTICAST_LOOP, - IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, + IPV6_JOIN_GROUP, IPV6_LEAVE_GROUP, MSG_NOSIGNAL, MSG_DONTWAIT, POLLIN, POLLOUT, POLLERR, POLLHUP, POLLNVAL, PollFd, socket, bind, listen, accept, connect, getsockname, getpeername, shutdown, close_socket, setsockopt, getsockopt, diff --git a/std/sys/windows/timed_socket.wave b/std/sys/windows/timed_socket.wave new file mode 100644 index 00000000..c44ce7b2 --- /dev/null +++ b/std/sys/windows/timed_socket.wave @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Timed I/O uses per-operation events on overlapped-capable sockets (the +// default for socket()). It never changes mode or cancels an alias's request. +struct TimedOverlapped { + internal: u64; + internal_high: u64; + offset: u32; + offset_high: u32; + event: ptr; +} +struct TimedBuffer { + length: u32; + data: ptr; +} +pub struct TimedSocketResult { + count: i64; + error: i64; +} +extern(system, "CreateEventW") fun timed_create_event( + security: ptr, manual_reset: i32, initial: i32, name: ptr +) -> ptr; +extern(system, "CloseHandle") fun timed_close_event(event: ptr) -> i32; +extern(system, "GetLastError") fun timed_last_error() -> u32; +extern(system, "WSAGetLastError") fun timed_socket_error() -> i32; +extern(system, "GetTickCount64") fun timed_ticks() -> u64; +extern(system, "WaitForSingleObject") fun timed_wait(event: ptr, timeout: u32) -> u32; +extern(system, "CancelIoEx") fun timed_cancel(socket: ptr, operation: ptr) -> i32; +extern(system, "WSAGetOverlappedResult") fun timed_result( + socket: i64, operation: ptr, count: ptr, wait: i32, flags: ptr +) -> i32; +extern(system, "WSASend") fun timed_send( + socket: i64, buffers: ptr, count: u32, sent: ptr, flags: u32, + operation: ptr, callback: ptr +) -> i32; +extern(system, "WSARecv") fun timed_recv( + socket: i64, buffers: ptr, count: u32, received: ptr, flags: ptr, + operation: ptr, callback: ptr +) -> i32; + +pub fun socket_io_timeout(fd: i64, data: ptr, length: i64, timeout_ms: i32, writing: bool) -> TimedSocketResult { + if (length < 0 || length > 2147483647 || timeout_ms < -1) { + return TimedSocketResult { count: 0, error: -10022 }; + } + var started: u64 = timed_ticks(); + var event: ptr = timed_create_event(null, 1, 0, null); + if (event == null) { + return TimedSocketResult { count: 0, error: -(timed_last_error() as i64) }; + } + // The event's low bit suppresses IOCP packets, even when an async executor + // already owns the socket's completion port. No stack pointer can escape. + var operation: TimedOverlapped = TimedOverlapped { + internal: 0, internal_high: 0, offset: 0, offset_high: 0, + event: ((event as u64) | 1) as ptr + }; + var buffer: TimedBuffer = TimedBuffer { length: length as u32, data: data }; + var count: u32 = 0; + var flags: u32 = 0; + var status: i32 = 0; + if (writing) { + status = timed_send(fd, &buffer, 1, null, 0, &operation, null); + } else { + status = timed_recv(fd, &buffer, 1, null, &flags, &operation, null); + } + if (status != 0) { + var error: i64 = timed_socket_error() as i64; + if (error != 997) { + timed_close_event(event); + return TimedSocketResult { count: 0, error: -error }; + } + } + var wait_error: i64 = 0; + if (status != 0) { + var remaining: u32 = 4294967295; + if (timeout_ms >= 0) { + var elapsed: u64 = timed_ticks() - started; + remaining = 0; + if (elapsed < timeout_ms as u64) { + remaining = (timeout_ms as u64 - elapsed) as u32; + } + } + var waited: u32 = timed_wait(event, remaining); + if (waited != 0) { + if (waited == 258) { + wait_error = -10060; + } else { + wait_error = -(timed_last_error() as i64); + } + // Cancellation is only a request. Drain this exact operation before + // returning caller-owned storage, including the completion race. + timed_cancel(fd as ptr, &operation); + } + } + var completed: i32 = timed_result(fd, &operation, &count, 1, &flags); + var error: i64 = 0; + if (completed == 0) { + error = -(timed_socket_error() as i64); + // Winsock does not update count on failure. Do not read InternalHigh + // as a substitute for the documented completion result. + if (error == -995 && wait_error != 0) { + error = wait_error; + } + } + // A successful completion racing cancellation retains its actual count. + if (timed_close_event(event) == 0 && error == 0) { + error = -(timed_last_error() as i64); + } + return TimedSocketResult { count: count as i64, error: error }; +} diff --git a/tests/fixtures/io/tcp_interrupt.c b/tests/fixtures/io/tcp_interrupt.c new file mode 100644 index 00000000..3a7ed5ae --- /dev/null +++ b/tests/fixtures/io/tcp_interrupt.c @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Repeated EINTR must consume one deadline, without changing an alias's flags. +#include +#include +#include +#include +#include +#include +#include + +extern int wave_timed_read(int64_t fd); +static volatile sig_atomic_t interruptions; +static void interrupt_read(int signal_number) { + (void)signal_number; + ++interruptions; +} + +int main(void) { + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) { + return 10; + } + int alias = dup(sockets[0]); + if (alias < 0) { + return 11; + } + struct sigaction action = {0}; + action.sa_handler = interrupt_read; + sigemptyset(&action.sa_mask); + if (sigaction(SIGALRM, &action, NULL) != 0) { + return 12; + } + for (int nonblocking = 0; nonblocking < 2; ++nonblocking) { + int flags = fcntl(alias, F_GETFL); + if (flags < 0 || fcntl(alias, F_SETFL, flags | (nonblocking ? O_NONBLOCK : 0)) < 0) { + return 13; + } + flags = fcntl(alias, F_GETFL); + struct itimerval timer = {{0, 5000}, {0, 5000}}; + struct timespec start, end; + clock_gettime(CLOCK_MONOTONIC, &start); + if (setitimer(ITIMER_REAL, &timer, NULL) != 0) { + return 14; + } + int result = wave_timed_read(sockets[0]); + timer.it_interval.tv_usec = timer.it_value.tv_usec = 0; + setitimer(ITIMER_REAL, &timer, NULL); + clock_gettime(CLOCK_MONOTONIC, &end); + int64_t elapsed = (end.tv_sec - start.tv_sec) * INT64_C(1000000000) + + end.tv_nsec - start.tv_nsec; + if (result != 0 || elapsed < 30000000 || elapsed > 2000000000 || interruptions < 2) { + return 15; + } + if (fcntl(alias, F_GETFL) != flags || fcntl(sockets[0], F_GETFL) != flags) { + return 16; + } + } + close(alias); + close(sockets[0]); + close(sockets[1]); + return 0; +} diff --git a/tests/fixtures/io/tcp_interrupt.wave b/tests/fixtures/io/tcp_interrupt.wave new file mode 100644 index 00000000..8b3b32c2 --- /dev/null +++ b/tests/fixtures/io/tcp_interrupt.wave @@ -0,0 +1,10 @@ +import("std::net::tcp")::{TcpStream, tcp_read_timeout}; +import("std::net::error")::{NetIoResult, NET_ERROR_TIMED_OUT}; +export(c, "wave_timed_read") fun timed_read(fd: i64) -> i32 { + var value: u8 = 0; + var result: NetIoResult = tcp_read_timeout(TcpStream { fd: fd }, &value, 1, 40); + if (result.error.kind != NET_ERROR_TIMED_OUT || result.count != 0) { + return 1; + } + return 0; +} diff --git a/tests/fixtures/io/tcp_read.wave b/tests/fixtures/io/tcp_read.wave new file mode 100644 index 00000000..e7e07d4c --- /dev/null +++ b/tests/fixtures/io/tcp_read.wave @@ -0,0 +1,142 @@ +import("std::net::tcp")::{ + TcpListener, TcpStream, tcp_bind_loopback, tcp_listener_local_addr_v4, + tcp_connect, tcp_accept, tcp_close, tcp_close_listener, + tcp_read_timeout, tcp_write_timeout, tcp_stream_set_nonblock, +}; +import("std::net::address")::{SocketAddrV4}; +import("std::net::error")::{NetResult, NetIoResult, NET_ERROR_TIMED_OUT}; +import("std::time::clock")::{time_now_monotonic_ns}; + +#[target(os="windows")] +import("std::sys::windows::completion_port")::{ + completion_port_associate, completion_port_handle, completion_port_release_executor, +}; +#[target(os="windows")] +extern(system, "GetQueuedCompletionStatus") fun queued_completion( + port: ptr, bytes: ptr, key: ptr, overlapped: ptr>, timeout: u32 +) -> i32; +#[target(os="windows")] +extern(system, "GetLastError") fun queue_error() -> u32; + +#[target(os="windows")] +fun prepare_completion(fd: i64) -> i64 { + return completion_port_associate(fd); +} +#[target(os="windows")] +fun verify_completion() -> i32 { + var bytes: u32 = 0; + var key: u64 = 0; + var operation: ptr = null; + var result: i32 = queued_completion(completion_port_handle(), &bytes, &key, &operation, 0); + if (result != 0 || operation != null || queue_error() != 258) { + return 29; + } + completion_port_release_executor(); + return 0; +} +#[target(os="linux")] +fun prepare_completion(fd: i64) -> i64 { + return 0; +} +#[target(os="linux")] +fun verify_completion() -> i32 { + return 0; +} +#[target(os="macos")] +fun prepare_completion(fd: i64) -> i64 { + return 0; +} +#[target(os="macos")] +fun verify_completion() -> i32 { + return 0; +} +#[target(os="freebsd")] +fun prepare_completion(fd: i64) -> i64 { + return 0; +} +#[target(os="freebsd")] +fun verify_completion() -> i32 { + return 0; +} + +fun exercise(nonblocking: bool) -> i32 { + var listener: NetResult = tcp_bind_loopback(0); + if (!listener.ok) { + return 10; + } + var address: NetResult = tcp_listener_local_addr_v4(listener.value); + if (!address.ok) { + return 11; + } + var client: NetResult = tcp_connect(address.value); + if (!client.ok) { + return 12; + } + var peer: NetResult = tcp_accept(listener.value); + if (!peer.ok) { + return 13; + } + tcp_close_listener(listener.value); + tcp_stream_set_nonblock(client.value, nonblocking); + if (prepare_completion(client.value.fd) < 0) { + return 28; + } + var data: array; + var index: i32 = 0; + var start: i64 = time_now_monotonic_ns(); + while (index < 16) { + var empty: NetIoResult = tcp_read_timeout(client.value, &data[0], 8, 5); + if (empty.error.kind != NET_ERROR_TIMED_OUT || empty.count != 0 || empty.eof) { + return 14; + } + index += 1; + } + var elapsed: i64 = time_now_monotonic_ns() - start; + if (elapsed < 40000000 || elapsed > 3000000000) { + return 15; + } + var zero: NetIoResult = tcp_read_timeout(client.value, null, 0, 0); + if (zero.count != 0 || zero.error.kind != 0 || zero.eof) { + return 16; + } + var invalid: NetIoResult = tcp_read_timeout(client.value, null, 1, 10); + if (invalid.error.kind == 0) { + return 17; + } + var sent: NetIoResult = tcp_write_timeout(peer.value, "packet" as ptr, 6, 1000); + if (sent.count != 6 || sent.error.kind != 0) { + return 18; + } + var received: NetIoResult = tcp_read_timeout(client.value, &data[0], 8, 1000); + if (received.count != 6 || received.error.kind != 0 || data[0] != 112 || data[5] != 116) { + return 19; + } + var immediate: NetIoResult = tcp_read_timeout(client.value, &data[0], 8, 0); + if (immediate.error.kind != NET_ERROR_TIMED_OUT) { + return 21; + } + sent = tcp_write_timeout(peer.value, "x" as ptr, 1, -1); + received = tcp_read_timeout(client.value, &data[0], 8, -1); + if (sent.count != 1 || received.count != 1 || data[0] != 120) { + return 22; + } + tcp_close(peer.value); + var eof: NetIoResult = tcp_read_timeout(client.value, &data[0], 8, 1000); + var queue_status: i32 = verify_completion(); + if (queue_status != 0) { + return queue_status; + } + tcp_close(client.value); + if (!eof.eof || eof.count != 0) { + return 20; + } + return 0; +} + +fun main() -> i32 { + var result: i32 = exercise(false); + if (result != 0) { + return result; + } + return exercise(true); +} diff --git a/tests/fixtures/io/tcp_timeout.wave b/tests/fixtures/io/tcp_timeout.wave new file mode 100644 index 00000000..637419e4 --- /dev/null +++ b/tests/fixtures/io/tcp_timeout.wave @@ -0,0 +1,54 @@ +import("std::net::tcp")::{ + TcpListener, TcpStream, tcp_bind_loopback, tcp_listener_local_addr_v4, + tcp_connect, tcp_accept, tcp_close, tcp_close_listener, + tcp_set_send_buffer, tcp_set_recv_buffer, tcp_write_timeout, +}; +import("std::net::address")::{SocketAddrV4}; +import("std::net::error")::{NetResult, NetIoResult, NET_ERROR_TIMED_OUT}; +import("std::mem::alloc")::{mem_alloc_zeroed, mem_free}; +import("std::time::clock")::{time_now_monotonic_ns}; + +fun main() -> i32 { + var listener: NetResult = tcp_bind_loopback(0); + if (!listener.ok) { + return 10; + } + var address: NetResult = tcp_listener_local_addr_v4(listener.value); + if (!address.ok) { + return 11; + } + var sender: NetResult = tcp_connect(address.value); + if (!sender.ok) { + return 12; + } + var receiver: NetResult = tcp_accept(listener.value); + if (!receiver.ok) { + return 13; + } + tcp_close_listener(listener.value); + tcp_set_send_buffer(sender.value, 4096); + tcp_set_recv_buffer(receiver.value, 4096); + var buffer: ptr = mem_alloc_zeroed(8388608); + if (buffer == null) { + return 14; + } + var start: i64 = time_now_monotonic_ns(); + var result: NetIoResult = tcp_write_timeout(sender.value, buffer, 8388608, 30); + var elapsed: i64 = time_now_monotonic_ns() - start; + mem_free(buffer, 8388608); + tcp_close(sender.value); + tcp_close(receiver.value); + if (elapsed < 0 || elapsed > 2000000000) { + return 15; + } + if (result.count < 0 || result.count >= 8388608) { + return 16; + } + if (result.error.kind != 0 && result.error.kind != NET_ERROR_TIMED_OUT) { + return 17; + } + if (result.error.kind == 0 && result.count == 0) { + return 18; + } + return 0; +} diff --git a/tools/test_std_io_runtime.py b/tools/test_std_io_runtime.py index 428df7bf..5a7497ef 100644 --- a/tools/test_std_io_runtime.py +++ b/tools/test_std_io_runtime.py @@ -28,7 +28,7 @@ def setUpClass(cls): cls.env = dict(os.environ, HOME=str(home), USERPROFILE=str(home)) cls.target = os.environ.get("WAVE_TEST_TARGET") cls.executables = {} - names = ['copy', 'copy_self', 'read_all', 'read_zero', 'invalid_buffers'] + names = ["copy", "copy_self", "read_all", "read_zero", "tcp_timeout", "tcp_read", "invalid_buffers"] if os.name == "posix": names += ["capture", "capture_close"] if sys.platform.startswith("linux"): @@ -50,6 +50,16 @@ def setUpClass(cls): if result.returncode: raise RuntimeError(result.stdout + result.stderr) cls.executables["exit_group"] = executable + cls.compile("tcp_interrupt", ["--emit=obj", "--out-dir", str(objdir)]) + executable = objdir / "tcp-interrupt-host" + result = run_process( + [cc, str(ROOT / "tests/fixtures/io/tcp_interrupt.c"), + str(objdir / "tcp_interrupt.o"), "-o", str(executable)], + timeout=30, capture_output=True, text=True, + ) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + cls.executables["tcp_interrupt"] = executable @classmethod @@ -206,8 +216,15 @@ def produce(): self.assertFalse(errors, errors) self.assertEqual(result, f"11 {sum((i+1)*b for i,b in enumerate(b'abcdefghijk'))}") + def test_tcp_backpressure_does_not_turn_readiness_into_an_unbounded_send(self): + self.run_fixture("tcp_timeout") + def test_tcp_read_timeout_cancellation_and_later_data(self): + self.run_fixture("tcp_read") + @unittest.skipUnless(sys.platform.startswith("linux"), "native Unix signal and alias probe") + def test_tcp_interruptions_share_one_deadline_and_preserve_alias_mode(self): + self.run_fixture("tcp_interrupt") def write_capture_child(self, body): child = self.directory / "capture-child"