[local-task] Clamp file read/write lengths to the platform transfer limit (#24816)

currently loading parameter files with "file" mode fails with:

```
IndexError: Error creating vm context with modules: external/+_repo_rules4+iree/runtime/src/iree/hal/drivers/local_task/task_queue.c:2714: OUT_OF_RANGE; short read: requested 13282859520 bytes, got 397957632; while invoking native function hal.fence.await; while calling variadic import;
```

The io_uring and IOCP backends narrowed the span length to their native
transfer count (sqe->len is 32 bits, ReadFile/WriteFile take a DWORD).
Spans over 4GB transferred the wrong amount, and a span that is an exact
multiple of 4GB wrapped to zero: the operation completed successfully
having moved no bytes, which the caller cannot distinguish from EOF.

Clamp instead. Short transfers are part of the file read/write contract,
so callers resubmit for the remainder. The ceiling is INT_MAX rounded
down to a page, matching the kernel's MAX_RW_COUNT.

Adds a CTS test covering the exact-4GB case, using a sparse reservation
so the oversized span costs address space rather than committed memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: Thomas Ziereis <ziereis@roofline.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
diff --git a/runtime/src/iree/async/cts/file/BUILD.bazel b/runtime/src/iree/async/cts/file/BUILD.bazel
index aaf6772..cc7fdea 100644
--- a/runtime/src/iree/async/cts/file/BUILD.bazel
+++ b/runtime/src/iree/async/cts/file/BUILD.bazel
@@ -32,6 +32,7 @@
         "//runtime/src/iree/async/cts/util:registry",
         "//runtime/src/iree/async/cts/util:test_base",
         "//runtime/src/iree/base",
+        "//runtime/src/iree/base:core_headers",
         "//runtime/src/iree/io:file_handle",
         "//runtime/src/iree/testing:temp_file",
     ],
diff --git a/runtime/src/iree/async/cts/file/CMakeLists.txt b/runtime/src/iree/async/cts/file/CMakeLists.txt
index dc1107b..138098c 100644
--- a/runtime/src/iree/async/cts/file/CMakeLists.txt
+++ b/runtime/src/iree/async/cts/file/CMakeLists.txt
@@ -23,6 +23,7 @@
     iree::async::cts::util::registry
     iree::async::cts::util::test_base
     iree::base
+    iree::base::core_headers
     iree::io::file_handle
     iree::testing::temp_file
   TESTONLY
diff --git a/runtime/src/iree/async/cts/file/file_test.cc b/runtime/src/iree/async/cts/file/file_test.cc
index a2920a0..22d3b3e 100644
--- a/runtime/src/iree/async/cts/file/file_test.cc
+++ b/runtime/src/iree/async/cts/file/file_test.cc
@@ -13,10 +13,19 @@
 #include "iree/async/file.h"
 
 #include <algorithm>
+#include <cstdint>
 #include <cstring>
 #include <string>
 #include <vector>
 
+#include "iree/base/target_platform.h"
+
+#if defined(IREE_PLATFORM_WINDOWS)
+#include <windows.h>
+#else
+#include <sys/mman.h>
+#endif  // IREE_PLATFORM_WINDOWS
+
 #include "iree/async/cts/file/native_file.h"
 #include "iree/async/cts/util/registry.h"
 #include "iree/async/cts/util/test_base.h"
@@ -28,6 +37,54 @@
 namespace iree::async::cts {
 
 //===----------------------------------------------------------------------===//
+// Sparse buffer
+//===----------------------------------------------------------------------===//
+
+// A large writable byte range backed by lazily faulted pages, so only the pages
+// actually touched cost physical memory. Lets a test build a span whose length
+// is the thing under test without committing gigabytes for I/O that touches a
+// few kilobytes.
+//
+// Holds nothing (`operator bool` is false) if the reservation is refused, which
+// lets callers skip rather than fail on hosts short on address space.
+class SparseBuffer {
+ public:
+  explicit SparseBuffer(uint64_t length) : length_(length) {
+    if (length_ > (uint64_t)SIZE_MAX) return;
+#if defined(IREE_PLATFORM_WINDOWS)
+    // MEM_COMMIT charges the system commit limit even for untouched pages, so
+    // this can fail on machines with little swap configured.
+    data_ = (uint8_t*)VirtualAlloc(NULL, (SIZE_T)length_,
+                                   MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
+#else
+    void* mapping =
+        mmap(NULL, (size_t)length_, PROT_READ | PROT_WRITE,
+             MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, /*fd=*/-1, /*off=*/0);
+    if (mapping != MAP_FAILED) data_ = (uint8_t*)mapping;
+#endif  // IREE_PLATFORM_WINDOWS
+  }
+
+  ~SparseBuffer() {
+    if (!data_) return;
+#if defined(IREE_PLATFORM_WINDOWS)
+    VirtualFree(data_, 0, MEM_RELEASE);
+#else
+    munmap(data_, (size_t)length_);
+#endif  // IREE_PLATFORM_WINDOWS
+  }
+
+  SparseBuffer(const SparseBuffer&) = delete;
+  SparseBuffer& operator=(const SparseBuffer&) = delete;
+
+  explicit operator bool() const { return data_ != nullptr; }
+  uint8_t* data() const { return data_; }
+
+ private:
+  uint64_t length_;
+  uint8_t* data_ = nullptr;
+};
+
+//===----------------------------------------------------------------------===//
 // Test fixture with temp file helpers
 //===----------------------------------------------------------------------===//
 
@@ -275,6 +332,71 @@
   iree_async_file_release(file);
 }
 
+// A span longer than what a backend can transfer in one call must be clamped
+// down to a short read, never handed through as-is.
+//
+// Backends fail this two different ways. Where the native count is 32 bits
+// (io_uring's sqe->len, IOCP's DWORD), a bare cast of exactly 4GB wraps to zero
+// and completes having moved nothing, which the caller cannot distinguish from
+// EOF. Where the count is a size_t (pread/pwrite), there is no wrap but the
+// syscall may reject the length outright -- Darwin returns EINVAL above
+// INT_MAX, while Linux quietly caps at MAX_RW_COUNT.
+//
+// The span is address space, not committed memory, so only the handful of pages
+// the read fills are ever faulted in.
+TEST_P(FileTest, ReadSpanLargerThan4GiBIsClampedNotTruncated) {
+  if (sizeof(iree_host_size_t) < 8) {
+    GTEST_SKIP() << "span lengths over 4GB are unrepresentable on 32-bit";
+  }
+
+  // Exactly 4GB: the length a 32-bit narrowing cast turns into zero.
+  static constexpr uint64_t kSpanLength = 4ull * 1024 * 1024 * 1024;
+
+  static constexpr size_t kFileSize = 8192;
+  std::vector<uint8_t> pattern(kFileSize);
+  for (size_t i = 0; i < kFileSize; ++i) {
+    pattern[i] = static_cast<uint8_t>(i & 0xFF);
+  }
+  std::string path = CreateTempFileWithContents(pattern.data(), kFileSize);
+
+  SparseBuffer span_memory(kSpanLength);
+  if (!span_memory) {
+    GTEST_SKIP() << "unable to reserve a 4GB sparse buffer";
+  }
+
+  iree_async_file_t* file = ImportTempFileForRead(path);
+  ASSERT_NE(file, nullptr);
+
+  iree_async_file_read_operation_t read_op;
+  memset(&read_op, 0, sizeof(read_op));
+  read_op.base.type = IREE_ASYNC_OPERATION_TYPE_FILE_READ;
+  read_op.file = file;
+  read_op.offset = 0;
+  read_op.buffer = iree_async_span_from_ptr(span_memory.data(),
+                                            (iree_host_size_t)kSpanLength);
+  read_op.bytes_read = 0;
+
+  CompletionTracker tracker;
+  read_op.base.completion_fn = CompletionTracker::Callback;
+  read_op.base.user_data = &tracker;
+
+  IREE_ASSERT_OK(iree_async_proactor_submit_one(proactor_, &read_op.base));
+  PollUntil(/*min_completions=*/1,
+            /*total_budget=*/iree_make_duration_ms(10000));
+
+  EXPECT_EQ(tracker.call_count, 1);
+  IREE_EXPECT_OK(tracker.ConsumeStatus());
+
+  // The file fits inside the clamp, so a correct backend reads all of it and
+  // reports a short read against the oversized span.
+  EXPECT_EQ(read_op.bytes_read, kFileSize)
+      << "expected the file contents; 0 means the transfer length was "
+         "truncated to zero rather than clamped";
+  EXPECT_EQ(memcmp(span_memory.data(), pattern.data(), kFileSize), 0);
+
+  iree_async_file_release(file);
+}
+
 // Read at offset beyond EOF returns 0 bytes (EOF).
 TEST_P(FileTest, ReadBeyondEOFReturnsZeroBytes) {
   const char kTestData[] = "data";
diff --git a/runtime/src/iree/async/platform/io_uring/proactor_submit.c b/runtime/src/iree/async/platform/io_uring/proactor_submit.c
index f80e59c..136c8a5 100644
--- a/runtime/src/iree/async/platform/io_uring/proactor_submit.c
+++ b/runtime/src/iree/async/platform/io_uring/proactor_submit.c
@@ -725,6 +725,22 @@
   sqe->user_data = (uint64_t)(uintptr_t)base_operation;
 }
 
+// Maximum bytes transferred by a single READ/WRITE SQE: INT_MAX rounded down to
+// a 4KB page.
+//
+// sqe->len is 32 bits, so longer spans must be clamped instead of narrowed: a
+// span that is an exact multiple of 4GB would wrap to zero and complete with no
+// bytes transferred, which the caller cannot distinguish from EOF. Short
+// transfers are part of the file read/write contract, so callers resubmit for
+// the remainder.
+//
+// Any ceiling below 4GB would be correct; this one matches MAX_RW_COUNT
+// (INT_MAX & PAGE_MASK) on a 4KB-page kernel. Kernels with larger pages cap
+// slightly lower and shorten the transfer themselves, which is the same short
+// read callers already handle.
+#define IREE_ASYNC_IO_URING_MAX_RW_LENGTH \
+  ((uint32_t)INT32_MAX & ~UINT32_C(4095))
+
 // Fills an SQE for a FILE_READ operation.
 // Uses IORING_OP_READ for positioned file I/O (pread semantics).
 //
@@ -732,7 +748,7 @@
 //   fd   = file descriptor
 //   off  = file offset
 //   addr = buffer address
-//   len  = buffer length
+//   len  = buffer length (clamped, see IREE_ASYNC_IO_URING_MAX_RW_LENGTH)
 static void iree_async_proactor_io_uring_fill_file_read(
     iree_io_uring_sqe_t* sqe, iree_async_operation_t* base_operation) {
   iree_async_file_read_operation_t* read_op =
@@ -743,7 +759,8 @@
   sqe->fd = read_op->file->primitive.value.fd;
   sqe->off = read_op->offset;
   sqe->addr = (uint64_t)(uintptr_t)iree_async_span_ptr(read_op->buffer);
-  sqe->len = (uint32_t)read_op->buffer.length;
+  sqe->len = (uint32_t)iree_min(read_op->buffer.length,
+                                IREE_ASYNC_IO_URING_MAX_RW_LENGTH);
   sqe->user_data = (uint64_t)(uintptr_t)base_operation;
 }
 
@@ -754,7 +771,7 @@
 //   fd   = file descriptor
 //   off  = file offset
 //   addr = buffer address
-//   len  = buffer length
+//   len  = buffer length (clamped, see IREE_ASYNC_IO_URING_MAX_RW_LENGTH)
 static void iree_async_proactor_io_uring_fill_file_write(
     iree_io_uring_sqe_t* sqe, iree_async_operation_t* base_operation) {
   iree_async_file_write_operation_t* write_op =
@@ -765,7 +782,8 @@
   sqe->fd = write_op->file->primitive.value.fd;
   sqe->off = write_op->offset;
   sqe->addr = (uint64_t)(uintptr_t)iree_async_span_ptr(write_op->buffer);
-  sqe->len = (uint32_t)write_op->buffer.length;
+  sqe->len = (uint32_t)iree_min(write_op->buffer.length,
+                                IREE_ASYNC_IO_URING_MAX_RW_LENGTH);
   sqe->user_data = (uint64_t)(uintptr_t)base_operation;
 }
 
diff --git a/runtime/src/iree/async/platform/iocp/proactor_submit.c b/runtime/src/iree/async/platform/iocp/proactor_submit.c
index c546e72..8262f5c 100644
--- a/runtime/src/iree/async/platform/iocp/proactor_submit.c
+++ b/runtime/src/iree/async/platform/iocp/proactor_submit.c
@@ -1060,6 +1060,16 @@
 // File I/O submit handlers
 //===----------------------------------------------------------------------===//
 
+// Maximum bytes transferred by a single ReadFile/WriteFile call. Held to the
+// same ceiling as the io_uring backend so both clamp identically.
+//
+// The count argument is a DWORD, so longer spans must be clamped instead of
+// narrowed: a span that is an exact multiple of 4GB would wrap to zero and
+// complete with no bytes transferred, which the caller cannot distinguish from
+// EOF. Short transfers are part of the file read/write contract, so callers
+// resubmit for the remainder.
+#define IREE_ASYNC_IOCP_MAX_RW_LENGTH ((uint32_t)INT32_MAX & ~UINT32_C(4095))
+
 static iree_status_t iree_async_proactor_iocp_submit_file_open(
     iree_async_proactor_iocp_t* proactor,
     iree_async_file_open_operation_t* open_op) {
@@ -1177,7 +1187,8 @@
   carrier->overlapped.OffsetHigh = (DWORD)(read_op->offset >> 32);
 
   void* buffer_ptr = iree_async_span_ptr(read_op->buffer);
-  DWORD buffer_length = (DWORD)read_op->buffer.length;
+  DWORD buffer_length =
+      (DWORD)iree_min(read_op->buffer.length, IREE_ASYNC_IOCP_MAX_RW_LENGTH);
 
   BOOL read_ok = ReadFile(file_handle, buffer_ptr, buffer_length, NULL,
                           &carrier->overlapped);
@@ -1220,7 +1231,8 @@
   carrier->overlapped.OffsetHigh = (DWORD)(write_op->offset >> 32);
 
   const void* buffer_ptr = iree_async_span_ptr(write_op->buffer);
-  DWORD buffer_length = (DWORD)write_op->buffer.length;
+  DWORD buffer_length =
+      (DWORD)iree_min(write_op->buffer.length, IREE_ASYNC_IOCP_MAX_RW_LENGTH);
 
   BOOL write_ok = WriteFile(file_handle, buffer_ptr, buffer_length, NULL,
                             &carrier->overlapped);
diff --git a/runtime/src/iree/async/platform/posix/worker.c b/runtime/src/iree/async/platform/posix/worker.c
index 169f805..a359534 100644
--- a/runtime/src/iree/async/platform/posix/worker.c
+++ b/runtime/src/iree/async/platform/posix/worker.c
@@ -81,6 +81,16 @@
   return status;
 }
 
+// Maximum bytes transferred by a single pread/pwrite call: INT_MAX rounded down
+// to a 4KB page.
+//
+// POSIX leaves the result implementation-defined once nbyte exceeds SSIZE_MAX,
+// and implementations diverge well below that: Darwin fails with EINVAL for
+// nbyte > INT_MAX while Linux silently caps at MAX_RW_COUNT. Clamping under
+// both turns an oversized span into a short transfer everywhere instead of an
+// error on some platforms, and callers resubmit for the remainder.
+#define IREE_ASYNC_POSIX_MAX_RW_LENGTH ((size_t)INT32_MAX & ~(size_t)4095)
+
 // Executes a FILE_READ operation using the pread() syscall.
 static iree_status_t iree_async_posix_execute_file_read(
     iree_async_file_read_operation_t* op) {
@@ -88,7 +98,7 @@
 
   int fd = op->file->primitive.value.fd;
   void* buffer = iree_async_span_ptr(op->buffer);
-  size_t length = op->buffer.length;
+  size_t length = iree_min(op->buffer.length, IREE_ASYNC_POSIX_MAX_RW_LENGTH);
   off_t offset = (off_t)op->offset;
 
   // Execute the pread syscall (blocking).
@@ -114,7 +124,7 @@
 
   int fd = op->file->primitive.value.fd;
   const void* buffer = iree_async_span_ptr(op->buffer);
-  size_t length = op->buffer.length;
+  size_t length = iree_min(op->buffer.length, IREE_ASYNC_POSIX_MAX_RW_LENGTH);
   off_t offset = (off_t)op->offset;
 
   // Execute the pwrite syscall (blocking).