diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 394860d..62345fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -533,30 +533,8 @@ jobs: cp package/Dockerfile package/deb_smoke.cpp smoke_ctx/ cp debs/*.deb smoke_ctx/ ls -l smoke_ctx - # Retry the build: the arm64 image installs deps from ports.ubuntu.com, - # which intermittently times out from the CI network. --no-cache on - # retries forces a fresh apt-get update instead of reusing a failed - # layer. - attempts=3 - for attempt in $(seq 1 "${attempts}"); do - echo "::group::docker build (attempt ${attempt}/${attempts})" - cache_flag="" - if [[ "${attempt}" -gt 1 ]]; then - cache_flag="--no-cache" - fi - if docker build ${cache_flag} --platform ${{ matrix.docker_platform }} \ - -t fastslide-deb-smoke smoke_ctx; then - echo "::endgroup::" - exit 0 - fi - echo "::endgroup::" - echo "docker build failed on attempt ${attempt}/${attempts}" - if [[ "${attempt}" -lt "${attempts}" ]]; then - sleep $((attempt * 15)) - fi - done - echo "docker build failed after ${attempts} attempts" >&2 - exit 1 + docker build --platform ${{ matrix.docker_platform }} \ + -t fastslide-deb-smoke smoke_ctx # TEMPORARILY DISABLED: the source build pulls simpletiff from its pinned git # wrap (subprojects/simpletiff.wrap @ 3886522), which predates the # -before- fix and so fails to compile under GCC. Re-enable diff --git a/BUILD.bazel b/BUILD.bazel index 88fc844..fbdab18 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -499,6 +499,7 @@ cc_library( ":runtime_cache_interface", ":runtime_format_descriptor", ":runtime_lru_tile_cache", + ":runtime_path_utils", ":runtime_reader_registry", ":runtime_tile_writer", ], @@ -1414,6 +1415,18 @@ cc_library( ], ) +cc_library( + name = "runtime_path_utils", + hdrs = ["include/fastslide/runtime/io/path_utils.h"], + copts = WASM_COPTS, + includes = ["include"], + visibility = ["//visibility:public"], + deps = [ + "@aifocore-cpp//:result", + "@aifocore-cpp//:utilities-core", + ], +) + cc_library( name = "runtime_file_reader", srcs = ["src/runtime/io/file_reader.cpp"], @@ -1567,27 +1580,10 @@ cc_library( visibility = ["//visibility:public"], deps = [ ":fastslide_lib", - ":runtime_global_cache_manager", ":runtime_png_decoder", ], ) -# C API tile-cache tests. Require a real slide via FASTSLIDE_BENCHMARK_FILE; -# skipped otherwise. Run with: -# FASTSLIDE_BENCHMARK_FILE=/abs/path/CMU-3.ndpi \ -# bazelisk test //:cache_c_api_test --test_output=all -cc_test( - name = "cache_c_api_test", - size = "small", - srcs = ["src/c/cache_c_api_test.cpp"], - deps = [ - ":fastslide_c", - ":formats_ndpitiff", - "@googletest//:gtest", - "@googletest//:gtest_main", - ], -) - # Slide information tool cc_binary( name = "fastslidetool", @@ -1916,6 +1912,17 @@ cc_test( ], ) +cc_test( + name = "path_utils_test", + size = "small", + srcs = ["src/runtime/io/path_utils_test.cpp"], + deps = [ + ":runtime_path_utils", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + # Format tests cc_test( name = "aperio_metadata_parser_test", diff --git a/benchmarks/cache_benchmark.cpp b/benchmarks/cache_benchmark.cpp deleted file mode 100644 index 4c81c70..0000000 --- a/benchmarks/cache_benchmark.cpp +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2025 Jonas Teuwen. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Benchmark for the internal tile cache: compares repeated overlapping -// region reads with and without a decode cache attached. Overlapping windows -// deliberately land inside the same native tile-grid cells so a cache turns -// the repeated decodes into hits. -// -// Provide a slide via the environment variable, e.g.: -// FASTSLIDE_BENCHMARK_FILE=/abs/path/CMU-3.ndpi \ -// bazelisk run @fastslide/benchmarks:cache_benchmark - -#include -#include -#include -#include -#include -#include -#include - -#include "aifocore/status/result.h" -#include "benchmark/benchmark.h" -#include "fastslide/runtime/lru_tile_cache.h" -#include "fastslide/runtime/reader_registry.h" -#include "fastslide/slide_reader.h" - -namespace { - -// Slide path from FASTSLIDE_BENCHMARK_FILE (default: "CMU-3.ndpi", expected to -// be resolvable from the working directory; prefer an absolute path). -const char* GetBenchmarkFilePath() { - const char* env_path = std::getenv("FASTSLIDE_BENCHMARK_FILE"); - return env_path != nullptr ? env_path : "CMU-3.ndpi"; -} - -// A single read window at a given level. -struct Window { - uint32_t x; - uint32_t y; - uint32_t width; - uint32_t height; -}; - -// Build a set of overlapping windows anchored near the origin of `level` that -// all fall within a 2x2 native-tile footprint, so repeated reads reuse the -// same decoded tiles. `window` is the read size (e.g. 256), `step` the offset -// stride (e.g. 64) producing heavy overlap. -std::vector BuildOverlappingWindows(uint32_t level_width, - uint32_t level_height, - uint32_t tile_size, uint32_t window, - uint32_t step) { - std::vector windows; - // Footprint spanning two native tiles in each dimension (bounded by level). - const uint32_t span = std::min(2 * tile_size, level_width); - const uint32_t span_y = std::min(2 * tile_size, level_height); - for (uint32_t oy = 0; oy + window <= span_y; oy += step) { - for (uint32_t ox = 0; ox + window <= span; ox += step) { - windows.push_back(Window{ox, oy, window, window}); - } - } - if (windows.empty()) { - // Level smaller than a single window: fall back to one clamped read. - windows.push_back(Window{0, 0, std::min(window, level_width), - std::min(window, level_height)}); - } - return windows; -} - -// Opens the benchmark slide and caches the level-0 geometry. A fresh reader is -// created per fixture instance so cache attachment is isolated between the -// no-cache and with-cache variants. -class ReaderContext { - public: - bool Open() { - auto reader_or = fastslide::runtime::GetGlobalRegistry().CreateReader( - GetBenchmarkFilePath()); - if (!reader_or.ok()) { - return false; - } - reader_ = std::move(reader_or.value()); - - auto level_info_or = reader_->GetLevelInfo(0); - if (!level_info_or.ok()) { - return false; - } - const auto level_info = level_info_or.value(); - level_width_ = level_info.dimensions[0]; - level_height_ = level_info.dimensions[1]; - - const auto tile = reader_->GetTileSize(); - tile_size_ = tile[0] != 0 ? tile[0] : 256; - return true; - } - - fastslide::SlideReader* reader() const { return reader_.get(); } - - uint32_t level_width() const { return level_width_; } - - uint32_t level_height() const { return level_height_; } - - uint32_t tile_size() const { return tile_size_; } - - private: - std::unique_ptr reader_; - uint32_t level_width_{0}; - uint32_t level_height_{0}; - uint32_t tile_size_{256}; -}; - -// Reads every window once and returns bytes processed, or -1 on error. -int64_t ReadWindows(const fastslide::SlideReader& reader, - const std::vector& windows) { - int64_t total_bytes = 0; - for (const auto& w : windows) { - fastslide::RegionSpec region{ - .top_left = {w.x, w.y}, .size = {w.width, w.height}, .level = 0}; - auto result = reader.ReadRegion(region); - if (!result.ok()) { - return -1; - } - total_bytes += static_cast(w.width) * w.height * sizeof(uint32_t); - } - return total_bytes; -} - -// Common driver: reads `windows` every iteration. When `capacity_bytes > 0` a -// per-reader LRU cache is attached, so the first iteration warms it and later -// iterations should hit. -void RunOverlapping(benchmark::State& state, size_t capacity_bytes) { - ReaderContext ctx; - if (!ctx.Open()) { - state.SkipWithError( - "Failed to open slide (set FASTSLIDE_BENCHMARK_FILE to an absolute " - "path to a supported slide)"); - return; - } - - std::shared_ptr cache; - if (capacity_bytes > 0) { - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - state.SkipWithError("Failed to create tile cache"); - return; - } - cache = std::move(cache_or.value()); - ctx.reader()->SetCache(cache); - } - - const uint32_t window = static_cast(state.range(0)); - const uint32_t step = std::max(1, window / 4); - const std::vector windows = BuildOverlappingWindows( - ctx.level_width(), ctx.level_height(), ctx.tile_size(), window, step); - - int64_t total_bytes = 0; - for (auto _ : state) { - const int64_t bytes = ReadWindows(*ctx.reader(), windows); - if (bytes < 0) { - state.SkipWithError("Failed to read region"); - break; - } - total_bytes += bytes; - } - - state.SetItemsProcessed(state.iterations() * - static_cast(windows.size())); - state.SetBytesProcessed(total_bytes); - state.counters["windows"] = static_cast(windows.size()); - if (cache) { - const auto stats = cache->GetStats(); - state.counters["hit_ratio"] = stats.hit_ratio; - state.counters["hits"] = static_cast(stats.hits); - state.counters["misses"] = static_cast(stats.misses); - } -} - -void BM_OverlappingReads_NoCache(benchmark::State& state) { - RunOverlapping(state, 0); -} - -void BM_OverlappingReads_WithCache(benchmark::State& state) { - // 1 GiB is ample to hold the small overlapping footprint's native tiles. - RunOverlapping(state, static_cast(1) << 30); -} - -BENCHMARK(BM_OverlappingReads_NoCache) - ->Arg(256) - ->Arg(512) - ->Unit(benchmark::kMicrosecond); -BENCHMARK(BM_OverlappingReads_WithCache) - ->Arg(256) - ->Arg(512) - ->Unit(benchmark::kMicrosecond); - -} // namespace - -BENCHMARK_MAIN(); diff --git a/docs/source/caching.rst b/docs/source/caching.rst index c8c4062..3c76446 100644 --- a/docs/source/caching.rst +++ b/docs/source/caching.rst @@ -68,14 +68,21 @@ System Components │ - Configurable capacity │ └─────────────────────────────────────┘ │ - │ attached via + │ injected via ▼ ┌─────────────────────────────────────┐ - │ SlideReader::SetCache(cache) │ - │ - Opt-in per reader │ - │ - nullptr disables caching │ + │ ReaderDependencies │ + │ - tile_cache │ + │ - enable_caching │ └──────────────┬──────────────────────┘ - │ used by + │ passed to + ▼ + ┌─────────────────────────────────────┐ + │ Format Plugins │ + │ - CreateMrxsReader() │ + │ - CreateAperioReader() │ + └──────────────┬──────────────────────┘ + │ creates ▼ ┌─────────────────────────────────────┐ │ Slide Readers │ @@ -119,29 +126,49 @@ The simplest and most efficient approach: #include "fastslide/runtime/global_cache_manager.h" #include "fastslide/runtime/reader_registry.h" + #include "fastslide/runtime/reader_dependencies.h" // Configure global cache at application startup (2 GiB) auto& cache_manager = fastslide::GlobalCacheManager::Instance(); cache_manager.SetCapacityBytes(static_cast(2) << 30); - // Create a reader and attach the shared global cache. - auto reader = fastslide::runtime::GetGlobalRegistry() - .CreateReader("slide.mrxs").value(); - reader->SetCache(cache_manager.GetCache()); - - // First read - cache miss, decodes from disk - auto region1 = reader->ReadRegion({ - .top_left = {1000, 2000}, .size = {512, 512}, .level = 0}); + // Register formats + fastslide::ReaderRegistry registry; + registry.RegisterFormat( + fastslide::formats::mrxs::CreateMrxsFormatDescriptor()); + registry.RegisterFormat( + fastslide::formats::aperio::CreateAperioFormatDescriptor()); - // Second read - cache hit, no disk I/O! - auto region2 = reader->ReadRegion({ - .top_left = {1000, 2000}, .size = {512, 512}, .level = 0}); + // Create reader with global cache (automatic injection) + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + auto reader_or = registry.CreateReader("slide.mrxs", deps); - // Check cache statistics - auto stats = cache_manager.GetStats(); - std::cout << "Cache hits: " << stats.hits << "\n"; - std::cout << "Cache misses: " << stats.misses << "\n"; - std::cout << "Hit ratio: " << (stats.hit_ratio * 100.0) << "%\n"; + if (reader_or.ok()) { + auto reader = std::move(*reader_or); + + // First read - cache miss, decodes from disk + auto region1 = reader->ReadRegion({ + .top_left = {1000, 2000}, + .size = {512, 512}, + .level = 0 + }); + + // Second read - cache hit, no disk I/O! + auto region2 = reader->ReadRegion({ + .top_left = {1000, 2000}, + .size = {512, 512}, + .level = 0 + }); + + // Check cache statistics + auto stats = cache_manager.GetStats(); + std::cout << "Cache hits: " << stats.hits << "\n"; + std::cout << "Cache misses: " << stats.misses << "\n"; + std::cout << "Hit ratio: " << (stats.hit_ratio * 100.0) << "%\n"; + std::cout << "Memory: " + << (stats.memory_usage_bytes / 1024.0 / 1024.0) + << " MB\n"; + } Per-Reader Cache ---------------- @@ -152,83 +179,17 @@ For isolated caching between readers: #include "fastslide/runtime/lru_tile_cache.h" - // Create a custom cache for this reader (512 MiB) - auto cache = fastslide::LRUTileCache::Create( - static_cast(512) << 20).value(); - - // Attach it to the reader. - auto reader = registry.CreateReader("slide.mrxs").value(); - reader->SetCache(cache); - -C API -===== - -The C API exposes the same cache, so C and Rust consumers get decode reuse -without reimplementing tile-grid-aware caching: - -.. code-block:: c - - #include "fastslide/c/fastslide.h" - - fastslide_registry_initialize(); - - // Per-reader cache (256 MiB); 0 opens without a cache. - FastSlideSlideReader* reader = - fastslide_create_reader_with_cache("slide.svs", (size_t)256 << 20); - - // ... or attach later / switch to the shared global cache: - fastslide_slide_reader_set_cache(reader, (size_t)512 << 20); - fastslide_global_cache_set_capacity_bytes((size_t)2 << 30); - fastslide_slide_reader_use_global_cache(reader); - - FastSlideCacheStats stats; - if (fastslide_slide_reader_get_cache_stats(reader, &stats)) { - printf("hit ratio: %.1f%%\n", stats.hit_ratio * 100.0); + // Create custom cache for this reader (512 MiB) + auto cache_or = fastslide::LRUTileCache::Create( + static_cast(512) << 20); + if (!cache_or.ok()) { + // Handle error + return cache_or.status(); } - fastslide_slide_reader_free(reader); - -Rust API -======== - -The ``fastslide`` crate surfaces the cache on ``SlideReader``: -.. code-block:: rust - - use fastslide::{SlideReader, set_global_cache_capacity}; - - // Per-reader cache (256 MiB). - let reader = SlideReader::open_with_cache("slide.svs", 256 << 20)?; - assert!(reader.is_cache_enabled()); - - // Or attach after opening / use the shared global cache. - reader.set_cache(512 << 20)?; - set_global_cache_capacity(2 << 30)?; - reader.use_global_cache()?; - - if let Some(stats) = reader.cache_stats() { - println!("hit ratio: {:.1}%", stats.hit_ratio * 100.0); - } - -Python API -========== - -``FastSlide.from_file_path`` accepts a ``cache`` argument (an int byte -capacity, a ``CacheManager``/``TileCache``, or ``None``): - -.. code-block:: python - - import fastslide - - # Per-slide LRU cache (256 MiB). - with fastslide.FastSlide.from_file_path("slide.svs", cache=256 << 20) as slide: - slide.read_region((0, 0), 0, (256, 256)) - slide.read_region((0, 0), 0, (256, 256)) # served from cache - print(slide.cache_stats.hit_ratio) - - # Share the process-wide global cache across slides. - fastslide.GlobalCacheManager.instance().set_capacity_bytes(2 << 30) - with fastslide.FastSlide.from_file_path("slide.svs") as slide: - slide.use_global_cache() + // Inject via dependencies + auto deps = fastslide::ReaderDependencies::WithCache(*cache_or); + auto reader_or = registry.CreateReader("slide.mrxs", deps); Use Cases for Per-Reader Cache ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -245,11 +206,13 @@ To disable caching entirely: .. code-block:: cpp - // Option 1: Never attach a cache (default) - readers decode every tile. - auto reader = registry.CreateReader("slide.mrxs").value(); + // Option 1: No cache in dependencies (default) + auto reader_or = registry.CreateReader("slide.mrxs"); - // Option 2: Detach an existing cache. - reader->SetCache(nullptr); + // Option 2: Explicitly disable + fastslide::ReaderDependencies deps; + deps.enable_caching = false; + auto reader_or = registry.CreateReader("slide.mrxs", deps); Cache Statistics ---------------- @@ -359,9 +322,9 @@ Configure a large cache for interactive panning and zooming: fastslide::ReaderRegistry registry; registry.RegisterFormat(/* ... */); - // Create reader and attach the global cache - auto reader = registry.CreateReader("slide.svs").value(); - reader->SetCache(cache.GetCache()); + // Create reader with global cache + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + auto reader = registry.CreateReader("slide.svs", deps).value(); // User interaction loop for (const auto& pan_event : user_interactions) { @@ -385,24 +348,26 @@ Separate caches for training and validation: // Training cache (~4 GiB) auto train_cache = fastslide::LRUTileCache::Create(static_cast(4) << 30).value(); + fastslide::ReaderDependencies train_deps; + train_deps.tile_cache = train_cache; // Validation cache (~1 GiB) auto val_cache = fastslide::LRUTileCache::Create(static_cast(1) << 30).value(); + fastslide::ReaderDependencies val_deps; + val_deps.tile_cache = val_cache; - // Create readers with isolated caches (attach the shared cache per reader) + // Create readers with isolated caches std::vector> train_readers; for (const auto& path : training_slides) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(train_cache); - train_readers.push_back(std::move(reader)); + train_readers.push_back( + registry.CreateReader(path, train_deps).value()); } std::vector> val_readers; for (const auto& path : validation_slides) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(val_cache); - val_readers.push_back(std::move(reader)); + val_readers.push_back( + registry.CreateReader(path, val_deps).value()); } // Training loop with separate cache statistics @@ -586,12 +551,12 @@ The cache is thread-safe and can be accessed from multiple threads: auto& cache_mgr = fastslide::GlobalCacheManager::Instance(); cache_mgr.SetCapacityBytes(static_cast(8) << 30); // 8 GiB + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + // Create multiple readers sharing the same cache std::vector> readers; for (const auto& path : slide_paths) { - auto reader = registry.CreateReader(path).value(); - reader->SetCache(cache_mgr.GetCache()); - readers.push_back(std::move(reader)); + readers.push_back(registry.CreateReader(path, deps).value()); } // Process in parallel - cache is thread-safe @@ -611,9 +576,10 @@ If caching isn't providing benefits: .. code-block:: cpp - // Check that a cache is attached to the reader - if (!reader->IsCacheEnabled()) { - std::cerr << "ERROR: No cache attached to reader!\n"; + // Check that cache is enabled + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); + if (!deps.HasCache()) { + std::cerr << "ERROR: Cache not available!\n"; } // Verify cache is being used @@ -686,8 +652,7 @@ Best Practices .. code-block:: cpp - auto& cache = fastslide::GlobalCacheManager::Instance(); - reader->SetCache(cache.GetCache()); + auto deps = fastslide::ReaderDependencies::WithGlobalCache(); 3. **Monitor Statistics Periodically** @@ -749,7 +714,7 @@ Implement custom caching strategies by inheriting from ``ITileCache``: // Use custom cache auto custom_cache = std::make_shared(); - reader->SetCache(custom_cache); + auto deps = fastslide::ReaderDependencies::WithCache(custom_cache); Distributed Caching ------------------- @@ -834,36 +799,18 @@ C++ Classes Stats GetStats() const override; aifocore::Status SetCapacityBytes(size_t capacity_bytes); -``SlideReader`` (cache methods) - Caching is attached per reader; there is no dependency-injection struct. +``ReaderDependencies`` + Dependency injection container. .. code-block:: cpp - void SetCache(std::shared_ptr cache); // nullptr disables - std::shared_ptr GetCache() const; - bool IsCacheEnabled() const; - -C API ------ - -Declared in ``fastslide/c/slide_reader.h`` and ``fastslide/c/registry.h``: - -.. code-block:: c - - FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes); - int fastslide_slide_reader_set_cache( - FastSlideSlideReader* reader, size_t capacity_bytes); // 0 disables - int fastslide_slide_reader_use_global_cache(FastSlideSlideReader* reader); - int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader); - void fastslide_slide_reader_clear_cache(FastSlideSlideReader* reader); - int fastslide_slide_reader_get_cache_stats( - const FastSlideSlideReader* reader, FastSlideCacheStats* out_stats); - - int fastslide_global_cache_set_capacity_bytes(size_t capacity_bytes); - int fastslide_global_cache_get_stats(FastSlideCacheStats* out_stats); - void fastslide_global_cache_clear(void); + static ReaderDependencies WithGlobalCache(); + static ReaderDependencies WithCache( + std::shared_ptr cache); + + std::shared_ptr tile_cache; + bool enable_caching = true; + bool HasCache() const; Python Classes -------------- diff --git a/include/fastslide/c/registry.h b/include/fastslide/c/registry.h index e9ab672..4417c1a 100644 --- a/include/fastslide/c/registry.h +++ b/include/fastslide/c/registry.h @@ -72,41 +72,6 @@ typedef struct { FASTSLIDE_API FastSlideSlideReader* fastslide_create_reader_with_options( const char* file_path, const FastSlideOpenOptions* options); -/// @brief Create a slide reader with a per-reader LRU tile cache attached. -/// -/// Equivalent to `fastslide_create_reader` followed by -/// `fastslide_slide_reader_set_cache(reader, cache_capacity_bytes)`. A -/// `cache_capacity_bytes` of 0 behaves like `fastslide_create_reader` (no -/// cache). -/// -/// @param file_path Path to slide file -/// @param cache_capacity_bytes Cache capacity in bytes (0 = no cache) -/// @return Slide reader handle or NULL on failure -FASTSLIDE_API FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes); - -// Global tile cache - -/// @brief Resize the process-wide global tile cache. -/// -/// Replaces the global cache with a new LRU cache of the requested capacity, -/// dropping any currently cached tiles. Readers attached via -/// `fastslide_slide_reader_use_global_cache` share this cache. -/// -/// @param capacity_bytes New global cache capacity in bytes (must be > 0) -/// @return 1 on success, 0 on failure. -FASTSLIDE_API int fastslide_global_cache_set_capacity_bytes( - size_t capacity_bytes); - -/// @brief Read the global tile cache's statistics. -/// @param out_stats Output statistics (must be non-null) -/// @return 1 on success, 0 on invalid arguments. -FASTSLIDE_API int fastslide_global_cache_get_stats( - FastSlideCacheStats* out_stats); - -/// @brief Clear all tiles from the process-wide global tile cache. -FASTSLIDE_API void fastslide_global_cache_clear(void); - // Utility functions /// @brief Get supported file extensions diff --git a/include/fastslide/c/slide_reader.h b/include/fastslide/c/slide_reader.h index 91e4562..6c24e80 100644 --- a/include/fastslide/c/slide_reader.h +++ b/include/fastslide/c/slide_reader.h @@ -428,64 +428,6 @@ FASTSLIDE_API int fastslide_slide_reader_enable_icc_transform( FastSlideSlideReader* reader, FastSlideColorSpace target_space, FastSlideRenderingIntent intent, int use_lut); -// Tile caching - -/// @brief Snapshot of an internal tile cache's statistics. -/// -/// Mirrors the C++ `fastslide::runtime::ITileCache::Stats` type. `hit_ratio` -/// is in [0, 1]; it is 0 when no lookups have happened yet. -typedef struct { - size_t capacity_bytes; ///< Configured cache capacity in bytes. - size_t size; ///< Number of tiles currently cached. - size_t hits; ///< Cumulative cache hits. - size_t misses; ///< Cumulative cache misses. - double hit_ratio; ///< hits / (hits + misses), or 0 if none. - size_t memory_usage_bytes; ///< Approximate bytes of decoded tile data held. -} FastSlideCacheStats; - -/// @brief Attach a per-reader LRU tile cache (decode reuse) to the reader. -/// -/// The reader caches decoded native tiles so overlapping or repeated -/// `read_region` calls that map to the same tile-grid cell avoid re-decoding. -/// Caching is opt-in: readers created via `fastslide_create_reader` have no -/// cache until this is called. -/// -/// @param reader Slide reader handle -/// @param capacity_bytes Cache capacity in bytes; 0 disables and detaches any -/// existing cache. -/// @return 1 on success, 0 on failure (e.g. allocation failure). -FASTSLIDE_API int fastslide_slide_reader_set_cache(FastSlideSlideReader* reader, - size_t capacity_bytes); - -/// @brief Attach the process-wide global tile cache to the reader. -/// -/// All readers that call this share one cache instance (see -/// `fastslide_global_cache_set_capacity_bytes`). Prefer this when opening many -/// slides that should share a single memory budget. -/// -/// @param reader Slide reader handle -/// @return 1 on success, 0 on failure. -FASTSLIDE_API int fastslide_slide_reader_use_global_cache( - FastSlideSlideReader* reader); - -/// @brief Whether the reader currently has a tile cache attached. -/// @param reader Slide reader handle -/// @return 1 if a cache is attached, 0 otherwise (including null reader). -FASTSLIDE_API int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader); - -/// @brief Clear all tiles from the reader's cache (no-op if none attached). -/// @param reader Slide reader handle -FASTSLIDE_API void fastslide_slide_reader_clear_cache( - FastSlideSlideReader* reader); - -/// @brief Read the reader cache's statistics. -/// @param reader Slide reader handle -/// @param out_stats Output statistics (must be non-null) -/// @return 1 on success, 0 if no cache is attached or on invalid arguments. -FASTSLIDE_API int fastslide_slide_reader_get_cache_stats( - const FastSlideSlideReader* reader, FastSlideCacheStats* out_stats); - // Memory management /// @brief Free slide reader handle diff --git a/include/fastslide/core/tile_plan.h b/include/fastslide/core/tile_plan.h index 268339b..049d051 100644 --- a/include/fastslide/core/tile_plan.h +++ b/include/fastslide/core/tile_plan.h @@ -199,20 +199,32 @@ struct OutputSpec { }; /// @brief Convert a DataType to the closest OutputSpec::PixelFormat +/// +/// The tile sinks memcpy raw samples out of the decoded tile using the +/// canvas's sample width, so this mapping must preserve the source's bytes per +/// sample. `PixelFormat` has no signed members; the signed types therefore map +/// onto the unsigned member of the same width, which keeps the bit pattern +/// intact. Falling back to `kFloat32` instead would widen a 16-bit sample to +/// four bytes and make the sinks stride off the end of the tile. +/// /// @param dtype Source data type -/// @return Matching pixel format (kUInt8, kUInt16, kUInt32, or kFloat32) +/// @return Pixel format with the same width as @p dtype constexpr OutputSpec::PixelFormat ToOutputPixelFormat( fastslide::DataType dtype) { switch (dtype) { case fastslide::DataType::kUInt8: return OutputSpec::PixelFormat::kUInt8; case fastslide::DataType::kUInt16: + case fastslide::DataType::kInt16: return OutputSpec::PixelFormat::kUInt16; case fastslide::DataType::kUInt32: + case fastslide::DataType::kInt32: return OutputSpec::PixelFormat::kUInt32; - default: + case fastslide::DataType::kFloat32: + case fastslide::DataType::kFloat64: return OutputSpec::PixelFormat::kFloat32; } + return OutputSpec::PixelFormat::kUInt8; } /// @brief Complete tile reading plan diff --git a/include/fastslide/image.h b/include/fastslide/image.h index 0c44ba0..5aebb5c 100644 --- a/include/fastslide/image.h +++ b/include/fastslide/image.h @@ -89,6 +89,13 @@ constexpr DataType DataTypeFromSampleFormat(uint16_t bits_per_sample, case 3: // IEEE floating point. return bits_per_sample >= 64 ? DataType::kFloat64 : DataType::kFloat32; case 2: // Two's-complement signed integer. + // `DataType` has no signed 8-bit member. Map an 8-bit signed page onto + // kUInt8 rather than widening it to kInt16: the bit pattern survives and, + // more importantly, so does the one-byte sample width that the tile sinks + // assume when they copy out of the decoded strip. + if (bits_per_sample <= 8) { + return DataType::kUInt8; + } return bits_per_sample <= 16 ? DataType::kInt16 : DataType::kInt32; case 1: // Unsigned integer. default: diff --git a/include/fastslide/readers/isyntax/third_party/isyntax.h b/include/fastslide/readers/isyntax/third_party/isyntax.h index 9a7d49c..c39d1cb 100644 --- a/include/fastslide/readers/isyntax/third_party/isyntax.h +++ b/include/fastslide/readers/isyntax/third_party/isyntax.h @@ -38,6 +38,12 @@ typedef struct isyntax_xml_cpp_state_t isyntax_xml_cpp_state_t; #include "fastslide/readers/isyntax/third_party/third_party/yxml.h" +// Capacities of the inline arrays in `isyntax_image_t` and `isyntax_t`. Both +// are filled from counts parsed out of the file's XML header, so every writer +// must bound the count against these before indexing. +#define ISYNTAX_MAX_LEVELS 16 +#define ISYNTAX_MAX_IMAGES 16 + enum isyntax_image_type_enum { ISYNTAX_IMAGE_TYPE_NONE = 0, ISYNTAX_IMAGE_TYPE_MACROIMAGE = 1, @@ -305,7 +311,7 @@ typedef struct isyntax_image_t { int32_t offset_y; int32_t level_count; int32_t max_scale; - isyntax_level_t levels[16]; + isyntax_level_t levels[ISYNTAX_MAX_LEVELS]; int32_t compressor_version; bool compression_is_lossy; int32_t lossy_image_compression_ratio; @@ -376,7 +382,7 @@ typedef struct isyntax_t { isyntax_open_flags_t open_flags; int64_t filesize; file_handle_t file_handle; - isyntax_image_t images[16]; + isyntax_image_t images[ISYNTAX_MAX_IMAGES]; int32_t image_count; isyntax_block_header_template_t block_header_templates[64]; int32_t block_header_template_count; diff --git a/include/fastslide/readers/simpletiff_decode_utils.h b/include/fastslide/readers/simpletiff_decode_utils.h index afae7eb..77675d3 100644 --- a/include/fastslide/readers/simpletiff_decode_utils.h +++ b/include/fastslide/readers/simpletiff_decode_utils.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -176,6 +177,40 @@ inline aifocore::Result ReadTileOrStrip( decoded_w, decoded_h)); } + // `buffer` is sized from the actual payload, but the geometry we advertise + // below comes from the IFD. The two can disagree: a JPEG page declaring + // SamplesPerPixel=4 still decodes to three bytes per pixel, and an + // undersized LZW/Deflate payload inflates to fewer rows than declared. + // Consumers stride using the advertised geometry, so a buffer that does not + // back it becomes an out-of-bounds read. Reject the contradiction instead of + // guessing which side is right. + const size_t decoded_pixels = + static_cast(decoded_w) * static_cast(decoded_h); + const bool pixels_overflowed = + decoded_pixels / static_cast(decoded_h) != + static_cast(decoded_w); + if (pixels_overflowed || + decoded_pixels > std::numeric_limits::max() / bytes_per_pixel) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Decoded geometry {}x{} at {} bytes/pixel " + "overflows for page {}", + decoded_w, decoded_h, bytes_per_pixel, + page_index)); + } + const size_t required_bytes = decoded_pixels * bytes_per_pixel; + if (buffer.size() < required_bytes) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format( + "Page {} tile/strip {} decoded to {} bytes but its {}x{} geometry " + "at {} bytes/pixel (SamplesPerPixel={}, BitsPerSample={}) requires " + "{}", + page_index, tile_or_strip_index, buffer.size(), decoded_w, + decoded_h, bytes_per_pixel, page_header.samples_per_pixel, + page_header.bits_per_sample, required_bytes)); + } + return DecodedInterleavedView{ .data = std::span(buffer.data(), buffer.size()), .width = static_cast(decoded_w), diff --git a/include/fastslide/runtime/io/path_utils.h b/include/fastslide/runtime/io/path_utils.h new file mode 100644 index 0000000..5c7200d --- /dev/null +++ b/include/fastslide/runtime/io/path_utils.h @@ -0,0 +1,116 @@ +// Copyright 2026 Jonas Teuwen. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_ +#define AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_ + +#include +#include + +#include "aifocore/status/result.h" +#include "aifocore/utilities/fmt.h" + +/** + * @file path_utils.h + * @brief Containment checks for filenames taken from slide files. + * + * Several formats are directory bundles whose manifest names the sibling files + * holding the pixel data: MIRAX reads `FILE_0`.. out of `Slidedat.ini`, + * OME-Zarr reads `datasets[].path` out of `zarr.json`. Those strings are + * attacker controlled, so joining them onto the bundle directory without + * checking lets a crafted slide address any file the process can open. + */ + +namespace fastslide { +namespace runtime { +namespace io { + +/// @brief Join `relative` onto `root` without allowing an escape from `root`. +/// +/// Rejects absolute paths and any `..` component, then resolves the result and +/// confirms it is still inside `root`. Resolution goes through +/// `std::filesystem::weakly_canonical`, so a symlink pointing out of the bundle +/// is rejected as well -- the check would otherwise pass on a path that +/// contains no `..` at all. +/// +/// @param root Bundle directory the result must stay within. +/// @param relative Path read out of the slide file. +/// @return Resolved absolute path, or `kInvalidArgument` if it escapes `root`. +[[nodiscard]] inline aifocore::Result +ResolveContainedPath(const std::filesystem::path& root, + const std::filesystem::path& relative) { + namespace fs = std::filesystem; + + if (relative.empty()) { + return AIFOCORE_MAKE_STATUS(aifocore::StatusCode::kInvalidArgument, + "Slide references an empty path"); + } + if (relative.is_absolute() || relative.has_root_name() || + relative.has_root_directory()) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Slide references absolute path '{}'", + relative.string())); + } + for (const auto& component : relative) { + if (component == "..") { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Slide references parent directory in '{}'", + relative.string())); + } + } + + std::error_code err; + const fs::path resolved_root = fs::weakly_canonical(root, err); + if (err) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Cannot resolve slide directory '{}': {}", + root.string(), err.message())); + } + const fs::path resolved = fs::weakly_canonical(root / relative, err); + if (err) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Cannot resolve slide path '{}': {}", + relative.string(), err.message())); + } + + // Compare component-wise rather than as strings so a sibling directory whose + // name merely starts with the root's name is not mistaken for a child. + auto root_it = resolved_root.begin(); + auto resolved_it = resolved.begin(); + for (; root_it != resolved_root.end(); ++root_it, ++resolved_it) { + if (resolved_it == resolved.end() || *resolved_it != *root_it) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format( + "Slide path '{}' resolves to '{}', outside the slide directory " + "'{}'", + relative.string(), resolved.string(), resolved_root.string())); + } + } + + return resolved; +} + +} // namespace io +} // namespace runtime + +using runtime::io::ResolveContainedPath; + +} // namespace fastslide + +#endif // AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_RUNTIME_IO_PATH_UTILS_H_ diff --git a/include/fastslide/slide_options.h b/include/fastslide/slide_options.h index 727c7f5..ca37f72 100644 --- a/include/fastslide/slide_options.h +++ b/include/fastslide/slide_options.h @@ -16,12 +16,17 @@ #define AIFO_FASTSLIDE_INCLUDE_FASTSLIDE_SLIDE_OPTIONS_H_ #include +#include #include +#include #include "fastslide/utilities/colors.h" namespace fastslide { +// Forward declarations +class TileCache; + /// @brief Color space for image data enum class ColorSpace { kRGB, ///< Standard RGB color space @@ -51,6 +56,7 @@ enum class RenderingIntent { /// Example usage: /// @code /// DependencyBundle deps; +/// deps.tile_cache = std::make_shared(1024 * 1024 * 1024); // 1GB /// deps.background_color = ColorRGB{255, 255, 255}; // White background /// /// SlideOpenOptions options; @@ -59,6 +65,12 @@ enum class RenderingIntent { /// auto reader = registry.CreateReader("slide.mrxs", options); /// @endcode struct DependencyBundle { + /// @brief Optional tile cache for decoded tiles + /// + /// Readers can use this cache to store decoded tiles for faster access. + /// If nullptr, readers should manage their own caching or disable caching. + std::shared_ptr tile_cache; + /// @brief Background color for empty regions /// /// Used when filling regions that don't have tile data (e.g., sparse MRXS @@ -89,16 +101,26 @@ struct DependencyBundle { /// Example usage: /// @code /// SlideOpenOptions options; +/// options.enable_caching = true; +/// options.cache_size_mb = 512; /// options.dependencies.max_threads = 4; /// /// auto reader = SlideReaderRegistry::GetInstance().CreateReader( /// "slide.svs", options); /// @endcode -/// -/// @note Tile caching is configured separately via the reader's `SetCache` -/// (see `fastslide::runtime::LRUTileCache` / -/// `fastslide::runtime::GlobalCacheManager`), not through this struct. struct SlideOpenOptions { + /// @brief Enable internal tile caching + /// + /// If true and no external cache is provided via dependencies, the reader + /// should create its own internal cache. + bool enable_caching = true; + + /// @brief Cache size in megabytes + /// + /// Hint for cache size if the reader creates its own cache. Ignored if + /// an external cache is provided via dependencies. + uint32_t cache_size_mb = 256; + /// @brief Read-only mode /// /// If true, the reader should open files in read-only mode and not attempt diff --git a/meson.build b/meson.build index f60b8fc..cb0486d 100644 --- a/meson.build +++ b/meson.build @@ -336,12 +336,6 @@ if get_option('build_c_api') cpp_args : fastslide_cpp_args, install : true) endif - - # Dependency object so tests (and other consumers) can link the C API. - fastslide_c_dep = declare_dependency( - include_directories : inc, - link_with : fastslide_c_lib, - dependencies : [fastslide_dep]) endif # --------------------------------------------------------------------------- diff --git a/package/Dockerfile b/package/Dockerfile index e48f178..6389062 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -11,13 +11,6 @@ # docker build --platform linux/amd64 -t fastslide-deb-smoke . FROM ubuntu:24.04 -# Harden apt against transient mirror flakiness. The arm64 image resolves to -# ports.ubuntu.com (amd64 uses archive.ubuntu.com), which intermittently times -# out from CI networks and would otherwise fail the very first apt-get update. -# Retrying each fetch and bounding the connect timeout lets a hiccup self-heal. -RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\n' \ - > /etc/apt/apt.conf.d/80-fastslide-retries - RUN apt-get update \ && apt-get install -y --no-install-recommends g++ pkg-config ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/rust/fastslide-sys/src/lib.rs b/rust/fastslide-sys/src/lib.rs index b289838..840958a 100644 --- a/rust/fastslide-sys/src/lib.rs +++ b/rust/fastslide-sys/src/lib.rs @@ -239,18 +239,6 @@ pub struct FastSlideOpenOptions { pub icc_use_lut: c_int, } -/// Tile cache statistics mirroring `FastSlideCacheStats` in `slide_reader.h`. -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct FastSlideCacheStats { - pub capacity_bytes: usize, - pub size: usize, - pub hits: usize, - pub misses: usize, - pub hit_ratio: c_double, - pub memory_usage_bytes: usize, -} - // =========================================================================== // C API // =========================================================================== @@ -273,13 +261,6 @@ unsafe extern "C" { file_path: *const c_char, options: *const FastSlideOpenOptions, ) -> *mut FastSlideSlideReader; - pub fn fastslide_create_reader_with_cache( - file_path: *const c_char, - cache_capacity_bytes: usize, - ) -> *mut FastSlideSlideReader; - pub fn fastslide_global_cache_set_capacity_bytes(capacity_bytes: usize) -> c_int; - pub fn fastslide_global_cache_get_stats(out_stats: *mut FastSlideCacheStats) -> c_int; - pub fn fastslide_global_cache_clear(); pub fn fastslide_registry_get_supported_extensions( registry: *mut FastSlideRegistry, extensions: *mut *mut *mut c_char, @@ -460,23 +441,6 @@ unsafe extern "C" { use_lut: c_int, ) -> c_int; - // ---- slide_reader.h: tile caching ---- - pub fn fastslide_slide_reader_set_cache( - reader: *mut FastSlideSlideReader, - capacity_bytes: usize, - ) -> c_int; - pub fn fastslide_slide_reader_use_global_cache( - reader: *mut FastSlideSlideReader, - ) -> c_int; - pub fn fastslide_slide_reader_is_cache_enabled( - reader: *const FastSlideSlideReader, - ) -> c_int; - pub fn fastslide_slide_reader_clear_cache(reader: *mut FastSlideSlideReader); - pub fn fastslide_slide_reader_get_cache_stats( - reader: *const FastSlideSlideReader, - out_stats: *mut FastSlideCacheStats, - ) -> c_int; - // ---- slide_image.h: per-image (per-series) API ---- pub fn fastslide_slide_image_free(image: *mut FastSlideSlideImage); pub fn fastslide_slide_image_get_level_count(image: *const FastSlideSlideImage) -> c_int; diff --git a/rust/fastslide/src/lib.rs b/rust/fastslide/src/lib.rs index 7bae851..b5143d8 100644 --- a/rust/fastslide/src/lib.rs +++ b/rust/fastslide/src/lib.rs @@ -58,11 +58,8 @@ pub use metadata::{ Bounds, ChannelMetadata, ColorRgb, Coordinate, DataType, Dimensions, ImageFormat, LevelInfo, PlanarConfig, RegionSpec, SlideProperties, StackInfo, }; -pub use reader::{CacheStats, ColorSpace, OpenOptions, RenderingIntent, SlideReader}; -pub use registry::{ - c_api_version, clear_global_cache, global_cache_stats, is_supported, - set_global_cache_capacity, supported_extensions, version, -}; +pub use reader::{ColorSpace, OpenOptions, RenderingIntent, SlideReader}; +pub use registry::{c_api_version, is_supported, supported_extensions, version}; pub use slide_image::SlideImage; #[cfg(test)] @@ -134,37 +131,4 @@ mod tests { assert_eq!(info.z_spacing_um, Some(0.5)); assert_eq!(info.t_interval_s, None); } - - // Reads a 256x256 level-0 region twice and checks the cache serves the - // second read. Skipped unless FASTSLIDE_BENCHMARK_FILE points at a slide. - #[test] - fn cache_reuses_decoded_tiles() { - let Ok(path) = std::env::var("FASTSLIDE_BENCHMARK_FILE") else { - eprintln!("skipping: FASTSLIDE_BENCHMARK_FILE not set"); - return; - }; - - let reader = SlideReader::open_with_cache(&path, 256 << 20) - .expect("open_with_cache should succeed"); - assert!(reader.is_cache_enabled()); - - let region = RegionSpec::new( - Coordinate { x: 0, y: 0 }, - Dimensions { - width: 256, - height: 256, - }, - 0, - ); - - let first = reader.read_region(®ion).expect("first read"); - let second = reader.read_region(®ion).expect("second read"); - assert_eq!(first.data(), second.data(), "cached read must be identical"); - - let stats = reader.cache_stats().expect("cache stats"); - assert!(stats.hits > 0, "repeated read should hit the cache"); - - reader.disable_cache().expect("disable"); - assert!(!reader.is_cache_enabled()); - } } diff --git a/rust/fastslide/src/reader.rs b/rust/fastslide/src/reader.rs index 45b657d..4419633 100644 --- a/rust/fastslide/src/reader.rs +++ b/rust/fastslide/src/reader.rs @@ -149,38 +149,6 @@ impl OpenOptions { } } -/// Statistics for a reader's (or the global) internal tile cache. -/// -/// The analogue of the C++ `fastslide::runtime::ITileCache::Stats`. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct CacheStats { - /// Configured cache capacity in bytes. - pub capacity_bytes: usize, - /// Number of tiles currently cached. - pub size: usize, - /// Cumulative cache hits. - pub hits: usize, - /// Cumulative cache misses. - pub misses: usize, - /// `hits / (hits + misses)`, or `0.0` when there have been no lookups. - pub hit_ratio: f64, - /// Approximate bytes of decoded tile data currently held. - pub memory_usage_bytes: usize, -} - -impl From for CacheStats { - fn from(stats: sys::FastSlideCacheStats) -> Self { - Self { - capacity_bytes: stats.capacity_bytes, - size: stats.size, - hits: stats.hits, - misses: stats.misses, - hit_ratio: stats.hit_ratio, - memory_usage_bytes: stats.memory_usage_bytes, - } - } -} - /// A whole-slide image reader. /// /// Open one with [`SlideReader::open`] (the analogue of the C++ @@ -236,32 +204,6 @@ impl SlideReader { }) } - /// Open a slide file with a per-reader LRU tile cache attached. - /// - /// The reader caches decoded native tiles so overlapping or repeated - /// [`SlideReader::read_region`] calls that map to the same tile-grid cell - /// avoid re-decoding. A `capacity_bytes` of `0` opens without a cache - /// (identical to [`SlideReader::open`]). - /// - /// Initializes the format registry on first use. - pub fn open_with_cache(path: impl AsRef, capacity_bytes: usize) -> Result { - ensure_initialized(); - - let path = path.as_ref(); - let c_path = CString::new(path.to_string_lossy().as_bytes()) - .map_err(|_| Error::new("open_with_cache", "path contains an interior NUL byte"))?; - - // SAFETY: `c_path` is a valid NUL-terminated string for the call. - let ptr = - unsafe { sys::fastslide_create_reader_with_cache(c_path.as_ptr(), capacity_bytes) }; - if ptr.is_null() { - return Err(Error::last("open_with_cache")); - } - Ok(Self { - inner: Arc::new(ReaderHandle { ptr }), - }) - } - fn ptr(&self) -> *const sys::FastSlideSlideReader { self.inner.ptr } @@ -313,65 +255,6 @@ impl SlideReader { Ok(()) } - /// Attach a per-reader LRU tile cache of the given byte capacity. - /// - /// A `capacity_bytes` of `0` detaches any existing cache. Replaces any - /// cache previously attached (including the global cache). - pub fn set_cache(&self, capacity_bytes: usize) -> Result<()> { - let ok = unsafe { sys::fastslide_slide_reader_set_cache(self.inner.ptr, capacity_bytes) }; - if ok == 0 { - return Err(Error::last("set_cache")); - } - Ok(()) - } - - /// Detach any tile cache from this reader. - pub fn disable_cache(&self) -> Result<()> { - self.set_cache(0) - } - - /// Attach the process-wide global tile cache to this reader. - /// - /// All readers sharing the global cache draw from one memory budget; see - /// [`crate::set_global_cache_capacity`]. - pub fn use_global_cache(&self) -> Result<()> { - let ok = unsafe { sys::fastslide_slide_reader_use_global_cache(self.inner.ptr) }; - if ok == 0 { - return Err(Error::last("use_global_cache")); - } - Ok(()) - } - - /// Whether a tile cache is currently attached to this reader. - #[must_use] - pub fn is_cache_enabled(&self) -> bool { - unsafe { sys::fastslide_slide_reader_is_cache_enabled(self.ptr()) != 0 } - } - - /// Clear all tiles from this reader's cache (no-op if none attached). - pub fn clear_cache(&self) { - unsafe { sys::fastslide_slide_reader_clear_cache(self.inner.ptr) }; - } - - /// Statistics for this reader's tile cache, or `None` if no cache is - /// attached. - #[must_use] - pub fn cache_stats(&self) -> Option { - let mut stats = sys::FastSlideCacheStats { - capacity_bytes: 0, - size: 0, - hits: 0, - misses: 0, - hit_ratio: 0.0, - memory_usage_bytes: 0, - }; - let ok = unsafe { sys::fastslide_slide_reader_get_cache_stats(self.ptr(), &mut stats) }; - if ok == 0 { - return None; - } - Some(stats.into()) - } - /// Number of pyramid levels of the primary image. #[must_use] pub fn level_count(&self) -> i32 { diff --git a/rust/fastslide/src/registry.rs b/rust/fastslide/src/registry.rs index fb649b5..bf0565f 100644 --- a/rust/fastslide/src/registry.rs +++ b/rust/fastslide/src/registry.rs @@ -21,8 +21,6 @@ use std::sync::Once; use fastslide_sys as sys; -use crate::error::{Error, Result}; -use crate::reader::CacheStats; use crate::util::{collect_strings, cstr_to_string}; static INIT: Once = Once::new(); @@ -74,39 +72,3 @@ pub fn is_supported(path: impl AsRef) -> bool { }; unsafe { sys::fastslide_is_supported(c_path.as_ptr()) != 0 } } - -/// Resize the process-wide global tile cache. -/// -/// Replaces the global cache with a new LRU cache of the requested capacity, -/// dropping any currently cached tiles. Readers attached via -/// [`crate::SlideReader::use_global_cache`] share this cache. -pub fn set_global_cache_capacity(capacity_bytes: usize) -> Result<()> { - let ok = unsafe { sys::fastslide_global_cache_set_capacity_bytes(capacity_bytes) }; - if ok == 0 { - return Err(Error::last("set_global_cache_capacity")); - } - Ok(()) -} - -/// Statistics for the process-wide global tile cache. -#[must_use] -pub fn global_cache_stats() -> Option { - let mut stats = sys::FastSlideCacheStats { - capacity_bytes: 0, - size: 0, - hits: 0, - misses: 0, - hit_ratio: 0.0, - memory_usage_bytes: 0, - }; - let ok = unsafe { sys::fastslide_global_cache_get_stats(&mut stats) }; - if ok == 0 { - return None; - } - Some(stats.into()) -} - -/// Clear all tiles from the process-wide global tile cache. -pub fn clear_global_cache() { - unsafe { sys::fastslide_global_cache_clear() }; -} diff --git a/src/c/cache_c_api_test.cpp b/src/c/cache_c_api_test.cpp deleted file mode 100644 index 858fc79..0000000 --- a/src/c/cache_c_api_test.cpp +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2025 Jonas Teuwen. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Correctness tests for the C API tile-cache surface. These require a real -// slide, provided via FASTSLIDE_BENCHMARK_FILE; the tests are skipped when it -// is unset so the suite stays hermetic by default. - -#include -#include -#include - -#include "fastslide/c/fastslide.h" -#include "gtest/gtest.h" - -namespace { - -const char* BenchmarkFile() { - return std::getenv("FASTSLIDE_BENCHMARK_FILE"); -} - -// Reads a 256x256 level-0 region and returns its raw bytes, or empty on error. -std::vector ReadRegionBytes(FastSlideSlideReader* reader) { - FastSlideImage* image = fastslide_slide_reader_read_region_coords( - reader, /*x=*/0, /*y=*/0, /*width=*/256, /*height=*/256, /*level=*/0, - /*z=*/0, /*t=*/0); - if (image == nullptr) { - return {}; - } - const uint8_t* data = fastslide_image_get_data(image); - const size_t size = fastslide_image_get_size_bytes(image); - std::vector bytes; - if (data != nullptr && size > 0) { - bytes.assign(data, data + size); - } - fastslide_image_free(image); - return bytes; -} - -class CacheCApiTest : public ::testing::Test { - protected: - void SetUp() override { - if (BenchmarkFile() == nullptr) { - GTEST_SKIP() << "Set FASTSLIDE_BENCHMARK_FILE to a supported slide to " - "run the C API cache tests."; - } - ASSERT_EQ(fastslide_registry_initialize(), 1); - } -}; - -TEST_F(CacheCApiTest, CacheProducesIdenticalPixelsAndHits) { - FastSlideSlideReader* reader = fastslide_create_reader_with_cache( - BenchmarkFile(), /*cache_capacity_bytes=*/static_cast(256) << 20); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - const std::vector first = ReadRegionBytes(reader); - ASSERT_FALSE(first.empty()); - - FastSlideCacheStats after_first{}; - ASSERT_EQ(fastslide_slide_reader_get_cache_stats(reader, &after_first), 1); - EXPECT_GT(after_first.misses, 0u) - << "cold read should populate the cache (misses)"; - - const std::vector second = ReadRegionBytes(reader); - ASSERT_EQ(first.size(), second.size()); - EXPECT_EQ(first, second) << "cached read must be byte-identical"; - - FastSlideCacheStats after_second{}; - ASSERT_EQ(fastslide_slide_reader_get_cache_stats(reader, &after_second), 1); - EXPECT_GT(after_second.hits, 0u) - << "repeated read of the same tiles should hit the cache"; - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, NoCacheReportsDisabled) { - FastSlideSlideReader* reader = fastslide_create_reader(BenchmarkFile()); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 0); - - FastSlideCacheStats stats{}; - EXPECT_EQ(fastslide_slide_reader_get_cache_stats(reader, &stats), 0) - << "no cache attached: stats query should fail"; - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, SetCacheZeroDisables) { - FastSlideSlideReader* reader = fastslide_create_reader_with_cache( - BenchmarkFile(), static_cast(64) << 20); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - EXPECT_EQ(fastslide_slide_reader_set_cache(reader, 0), 1); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 0); - - fastslide_slide_reader_free(reader); -} - -TEST_F(CacheCApiTest, GlobalCacheConfigurable) { - ASSERT_EQ( - fastslide_global_cache_set_capacity_bytes(static_cast(128) << 20), - 1); - fastslide_global_cache_clear(); - - FastSlideSlideReader* reader = fastslide_create_reader(BenchmarkFile()); - ASSERT_NE(reader, nullptr) << fastslide_get_last_error(); - ASSERT_EQ(fastslide_slide_reader_use_global_cache(reader), 1); - EXPECT_EQ(fastslide_slide_reader_is_cache_enabled(reader), 1); - - (void)ReadRegionBytes(reader); - (void)ReadRegionBytes(reader); - - FastSlideCacheStats global_stats{}; - ASSERT_EQ(fastslide_global_cache_get_stats(&global_stats), 1); - EXPECT_EQ(global_stats.capacity_bytes, static_cast(128) << 20); - - fastslide_slide_reader_free(reader); -} - -} // namespace diff --git a/src/c/registry.cpp b/src/c/registry.cpp index d1948b9..dfa01c6 100644 --- a/src/c/registry.cpp +++ b/src/c/registry.cpp @@ -13,8 +13,6 @@ #include #include -#include "fastslide/runtime/cache_interface.h" -#include "fastslide/runtime/global_cache_manager.h" #include "fastslide/runtime/plugin_loader.h" #include "fastslide/runtime/reader_registry.h" #include "fastslide/slide_reader.h" @@ -176,69 +174,6 @@ FastSlideSlideReader* fastslide_create_reader_with_options( return reader; } -FastSlideSlideReader* fastslide_create_reader_with_cache( - const char* file_path, size_t cache_capacity_bytes) { - FastSlideSlideReader* reader = fastslide_create_reader(file_path); - if (reader == nullptr) { - return nullptr; - } - - if (cache_capacity_bytes != 0) { - if (!fastslide_slide_reader_set_cache(reader, cache_capacity_bytes)) { - // Cache allocation failed; do not hand back a reader without the - // caching the caller explicitly requested. - fastslide_slide_reader_free(reader); - return nullptr; - } - } - - return reader; -} - -// Global tile cache - -namespace { - -FastSlideCacheStats GlobalCacheStatsToC( - const fastslide::runtime::ITileCache::Stats& stats) { - FastSlideCacheStats out; - out.capacity_bytes = stats.capacity_bytes; - out.size = stats.size; - out.hits = stats.hits; - out.misses = stats.misses; - out.hit_ratio = stats.hit_ratio; - out.memory_usage_bytes = stats.memory_usage_bytes; - return out; -} - -} // namespace - -int fastslide_global_cache_set_capacity_bytes(size_t capacity_bytes) { - fastslide_clear_last_error(); - const auto status = - fastslide::runtime::GlobalCacheManager::Instance().SetCapacityBytes( - capacity_bytes); - if (!status.ok()) { - fastslide_set_last_error(std::string(status.message()).c_str()); - return 0; - } - return 1; -} - -int fastslide_global_cache_get_stats(FastSlideCacheStats* out_stats) { - if (out_stats == nullptr) { - fastslide_set_last_error("out_stats cannot be null"); - return 0; - } - *out_stats = GlobalCacheStatsToC( - fastslide::runtime::GlobalCacheManager::Instance().GetStats()); - return 1; -} - -void fastslide_global_cache_clear(void) { - fastslide::runtime::GlobalCacheManager::Instance().Clear(); -} - // Utility functions int fastslide_registry_get_supported_extensions(FastSlideRegistry* registry, diff --git a/src/c/slide_reader.cpp b/src/c/slide_reader.cpp index 103b017..9f39df2 100644 --- a/src/c/slide_reader.cpp +++ b/src/c/slide_reader.cpp @@ -15,9 +15,6 @@ #include #include "fastslide/c/image.h" -#include "fastslide/runtime/cache_interface.h" -#include "fastslide/runtime/global_cache_manager.h" -#include "fastslide/runtime/lru_tile_cache.h" #include "fastslide/slide_reader.h" #include "internal/debug.h" #include "internal/error.h" @@ -942,77 +939,6 @@ int fastslide_slide_reader_enable_icc_transform( return 1; } -namespace { - -FastSlideCacheStats CacheStatsToC( - const fastslide::runtime::ITileCache::Stats& stats) { - FastSlideCacheStats out; - out.capacity_bytes = stats.capacity_bytes; - out.size = stats.size; - out.hits = stats.hits; - out.misses = stats.misses; - out.hit_ratio = stats.hit_ratio; - out.memory_usage_bytes = stats.memory_usage_bytes; - return out; -} - -} // namespace - -int fastslide_slide_reader_set_cache(FastSlideSlideReader* reader, - size_t capacity_bytes) { - FASTSLIDE_REQUIRE_READER(reader, 0); - - if (capacity_bytes == 0) { - reader->reader->SetCache(nullptr); - return 1; - } - - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - SetLastError(std::string(cache_or.status().message()).c_str()); - return 0; - } - reader->reader->SetCache(std::move(cache_or.value())); - return 1; -} - -int fastslide_slide_reader_use_global_cache(FastSlideSlideReader* reader) { - FASTSLIDE_REQUIRE_READER(reader, 0); - reader->reader->SetCache( - fastslide::runtime::GlobalCacheManager::Instance().GetCache()); - return 1; -} - -int fastslide_slide_reader_is_cache_enabled( - const FastSlideSlideReader* reader) { - FASTSLIDE_REQUIRE_READER(reader, 0); - return reader->reader->IsCacheEnabled() ? 1 : 0; -} - -void fastslide_slide_reader_clear_cache(FastSlideSlideReader* reader) { - if (!reader || !reader->reader) { - return; - } - auto cache = reader->reader->GetCache(); - if (cache) { - cache->Clear(); - } -} - -int fastslide_slide_reader_get_cache_stats(const FastSlideSlideReader* reader, - FastSlideCacheStats* out_stats) { - FASTSLIDE_REQUIRE_READER(reader, 0); - FASTSLIDE_REQUIRE_NOT_NULL(out_stats, "out_stats", 0); - - auto cache = reader->reader->GetCache(); - if (!cache) { - SetLastError("reader has no cache attached"); - return 0; - } - *out_stats = CacheStatsToC(cache->GetStats()); - return 1; -} - void fastslide_slide_reader_free(FastSlideSlideReader* reader) { delete reader; } diff --git a/src/core/tile_plan_test.cpp b/src/core/tile_plan_test.cpp index 9bcce73..3113bdf 100644 --- a/src/core/tile_plan_test.cpp +++ b/src/core/tile_plan_test.cpp @@ -553,5 +553,38 @@ TEST(BatchTilePlanTest, DeduplicatedOperations) { EXPECT_EQ(batch.GetUniqueOperations(), 1); // Only one unique tile } +/// @brief The output format must never be wider than the source data type. +/// +/// The tile sinks copy raw samples out of a decoded tile using the canvas's +/// sample width. +TEST(OutputPixelFormatTest, PreservesSampleWidth) { + const auto width_of = [](OutputSpec::PixelFormat format) -> size_t { + switch (format) { + case OutputSpec::PixelFormat::kUInt8: + return 1; + case OutputSpec::PixelFormat::kUInt16: + return 2; + case OutputSpec::PixelFormat::kUInt32: + case OutputSpec::PixelFormat::kFloat32: + return 4; + } + return 0; + }; + + for (const auto dtype : + {DataType::kUInt8, DataType::kUInt16, DataType::kInt16, + DataType::kUInt32, DataType::kInt32, DataType::kFloat32, + DataType::kFloat64}) { + EXPECT_LE(width_of(ToOutputPixelFormat(dtype)), GetDataTypeSize(dtype)) + << "output format wider than source for DataType " + << static_cast(dtype); + } + + EXPECT_EQ(ToOutputPixelFormat(DataType::kInt16), + OutputSpec::PixelFormat::kUInt16); + EXPECT_EQ(ToOutputPixelFormat(DataType::kInt32), + OutputSpec::PixelFormat::kUInt32); +} + } // namespace core } // namespace fastslide diff --git a/src/image_test.cpp b/src/image_test.cpp index 9661ac0..bc21a0c 100644 --- a/src/image_test.cpp +++ b/src/image_test.cpp @@ -306,4 +306,26 @@ TEST(DataTypeFromBitsPerSampleTest, MapsCommonBitDepths) { EXPECT_EQ(DataTypeFromBitsPerSample(32), DataType::kFloat32); } +/// The chosen type must keep the storage width of the page it describes; +/// otherwise the tile sinks stride a decoded strip by the wrong sample size. +TEST(DataTypeFromSampleFormatTest, PreservesStorageWidth) { + constexpr uint16_t kUnsigned = 1; + constexpr uint16_t kSigned = 2; + constexpr uint16_t kFloat = 3; + + for (const uint16_t bits : {uint16_t{8}, uint16_t{16}, uint16_t{32}}) { + for (const uint16_t format : {kUnsigned, kSigned, kFloat}) { + if (format == kFloat && bits < 32) { + continue; // No sub-32-bit IEEE format is defined. + } + EXPECT_EQ(GetDataTypeSize(DataTypeFromSampleFormat(bits, format)), + bits / 8u) + << "bits=" << bits << " sample_format=" << format; + } + } + + // Signed 8-bit has no exact DataType member; it must still stay one byte. + EXPECT_EQ(DataTypeFromSampleFormat(8, kSigned), DataType::kUInt8); +} + } // namespace fastslide diff --git a/src/python/_fastslide.pyi b/src/python/_fastslide.pyi index 2a11b0e..e243b38 100644 --- a/src/python/_fastslide.pyi +++ b/src/python/_fastslide.pyi @@ -303,11 +303,7 @@ class SlideImages: class FastSlide: @staticmethod - def from_file_path( - file_path: object, - apply_icc: bool = False, - cache: object | None = None, - ) -> FastSlide: + def from_file_path(file_path: object, apply_icc: bool = False) -> FastSlide: """Create FastSlide from file path (accepts str or pathlib.Path) Args: @@ -315,9 +311,6 @@ class FastSlide: apply_icc: When True and the slide has an embedded ICC profile, read_region returns sRGB-corrected pixels (perceptual intent). Slides without a profile are returned unchanged. - cache: Optional tile cache to attach. Accepts an int byte capacity - (a new per-slide LRU cache), a CacheManager, a TileCache, or - None to disable caching. """ @staticmethod @@ -496,7 +489,7 @@ class FastSlide: """Get the best level for a given downsample factor""" def set_cache(self, cache: object) -> None: - """Set cache (accepts int bytes, TileCache, CacheManager, or None to disable).""" + """Set cache (accepts TileCache, CacheManager, or None to disable).""" def get_cache(self) -> TileCache: """Get current cache""" @@ -505,16 +498,6 @@ class FastSlide: def cache_enabled(self) -> bool: """True if caching is enabled""" - def use_global_cache(self) -> None: - """Attach the process-wide global tile cache to this slide.""" - - def clear_cache(self) -> None: - """Clear all tiles from this slide's cache (no-op if none attached).""" - - @property - def cache_stats(self) -> RuntimeCacheStats | None: - """Cache statistics, or None if no cache is attached.""" - def close(self) -> None: """Close the slide reader and release resources""" diff --git a/src/python/fastslide.cpp b/src/python/fastslide.cpp index 99c0ace..c151805 100644 --- a/src/python/fastslide.cpp +++ b/src/python/fastslide.cpp @@ -63,32 +63,6 @@ void ThrowPyErrorFromStatus(const aifocore::Status& status) { throw std::runtime_error(status.ToString()); } -/// @brief Resolve a Python cache argument into an ITileCache. -/// -/// Accepts `None` (no cache), an `int` byte capacity (a new per-slide -/// `LRUTileCache`), a `CacheManager`, or a `TileCache`. Raises on an invalid -/// capacity or unsupported type. -std::shared_ptr ResolveCacheObject( - const nb::object& cache) { - if (cache.is_none()) { - return nullptr; - } - if (nb::isinstance(cache)) { - const auto capacity_bytes = nb::cast(cache); - auto cache_or = fastslide::runtime::LRUTileCache::Create(capacity_bytes); - if (!cache_or.ok()) { - ThrowPyErrorFromStatus(cache_or.status()); - } - return std::move(cache_or.value()); - } - if (nb::isinstance(cache)) { - auto manager = - nb::cast>(cache); - return manager ? manager->GetCache() : nullptr; - } - return nb::cast>(cache); -} - /// @brief Build a zero-copy numpy view of an Image's pixel buffer. /// /// The returned `nb::ndarray` keeps `image_handle` alive via nanobind's @@ -444,8 +418,7 @@ NB_MODULE(_fastslide, m) { nb::class_(m, "FastSlide") .def_static( "from_file_path", - [](const nb::object& file_path, bool apply_icc, - const nb::object& cache) { + [](const nb::object& file_path, bool apply_icc) { std::string path_str; if (nb::isinstance(file_path)) { path_str = nb::cast(file_path); @@ -456,11 +429,7 @@ NB_MODULE(_fastslide, m) { } else { path_str = nb::cast(file_path); } - auto slide = FastSlide::FromFilePath(path_str, apply_icc); - if (slide && !cache.is_none()) { - slide->SetCache(ResolveCacheObject(cache)); - } - return slide; + return FastSlide::FromFilePath(path_str, apply_icc); }, "Create FastSlide from file path (accepts str or pathlib.Path)\n\n" "Args:\n" @@ -468,12 +437,8 @@ NB_MODULE(_fastslide, m) { " apply_icc: When True and the slide has an embedded ICC\n" " profile, read_region returns sRGB-corrected pixels\n" " (perceptual intent). Slides without a profile are\n" - " returned unchanged.\n" - " cache: Optional tile cache to attach. Accepts an int byte\n" - " capacity (a new per-slide LRU cache), a CacheManager, a\n" - " TileCache, or None to disable caching.", - nb::arg("file_path"), nb::arg("apply_icc") = false, - nb::arg("cache").none() = nb::none()) + " returned unchanged.", + nb::arg("file_path"), nb::arg("apply_icc") = false) .def_static("from_uri", &FastSlide::FromUri, "Create FastSlide from URI (future)", nb::arg("uri")) @@ -628,47 +593,30 @@ NB_MODULE(_fastslide, m) { // Cache management. // - // `set_cache` accepts an int byte capacity (a new per-slide LRU cache), - // a `TileCache`, a `CacheManager`, or None. A single entrypoint - // dispatches on argument type so callers do not need to unwrap the - // manager themselves. + // `set_cache` accepts either a `TileCache` or a `CacheManager`. The + // single Python entrypoint dispatches on argument type so callers do + // not need to unwrap the manager themselves. .def( "set_cache", [](FastSlide& self, const nb::object& cache) { - self.SetCache(ResolveCacheObject(cache)); + if (cache.is_none()) { + self.SetCache(nullptr); + return; + } + if (nb::isinstance(cache)) { + auto manager = nb::cast>(cache); + self.SetCache(manager ? manager->GetCache() : nullptr); + return; + } + self.SetCache( + nb::cast>( + cache)); }, - "Set cache (accepts int bytes, TileCache, CacheManager, or None to " - "disable).", + "Set cache (accepts TileCache, CacheManager, or None to disable).", nb::arg("cache").none()) .def("get_cache", &FastSlide::GetCache, "Get current cache") .def_prop_ro("cache_enabled", &FastSlide::IsCacheEnabled, "True if caching is enabled") - .def( - "use_global_cache", - [](FastSlide& self) { - self.SetCache( - fastslide::runtime::GlobalCacheManager::Instance().GetCache()); - }, - "Attach the process-wide global tile cache to this slide.") - .def( - "clear_cache", - [](FastSlide& self) { - if (auto cache = self.GetCache()) { - cache->Clear(); - } - }, - "Clear all tiles from this slide's cache (no-op if none attached).") - .def_prop_ro( - "cache_stats", - [](FastSlide& self) -> nb::object { - auto cache = self.GetCache(); - if (!cache) { - return nb::none(); - } - return nb::cast(cache->GetStats()); - }, - "Cache statistics (RuntimeCacheStats), or None if no cache is " - "attached.") // Resource management .def("close", &FastSlide::Close, diff --git a/src/readers/isyntax/third_party/open.cpp b/src/readers/isyntax/third_party/open.cpp index 2adc66d..db41103 100644 --- a/src/readers/isyntax/third_party/open.cpp +++ b/src/readers/isyntax/third_party/open.cpp @@ -36,6 +36,7 @@ #include "aifocore/platform/portability.h" #include "aifocore/status/result.h" +#include "aifocore/utilities/fmt.h" #include "fastslide/readers/isyntax/third_party/open_helpers.h" #include "fastslide/readers/isyntax/third_party/platform/common.h" #include "fastslide/readers/isyntax/third_party/seektable.h" @@ -205,6 +206,18 @@ aifocore::Result ReadXmlHeader(std::FILE* fp, isyntax_t* isyntax, aifocore::Status InitializeLevelGeometry(isyntax_t* isyntax, isyntax_image_t* wsi_image) { + // `level_count` comes from the XML header and indexes the fixed-capacity + // `wsi_image->levels` array in the loops below. The parser clamps it, so a + // value outside the representable range means the header contradicted + // itself; refuse the file rather than describing geometry we did not parse. + if (wsi_image->level_count < 1 || + wsi_image->level_count > ISYNTAX_MAX_LEVELS) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("iSyntax: level count {} outside supported " + "range 1..{}", + wsi_image->level_count, ISYNTAX_MAX_LEVELS)); + } isyntax::open::InitializeLevelGeometry(isyntax, wsi_image); return aifocore::Status(); } diff --git a/src/readers/isyntax/third_party/xml_parser.cpp b/src/readers/isyntax/third_party/xml_parser.cpp index 74123de..7db0d57 100644 --- a/src/readers/isyntax/third_party/xml_parser.cpp +++ b/src/readers/isyntax/third_party/xml_parser.cpp @@ -674,6 +674,19 @@ static bool HandleAttrEnd(isyntax_t* isyntax, yxml_t* x) { ASSERT(parser->attribute_index == 0); ASSERT(std::strcmp(x->attr, "ObjectType") == 0); if (std::strcmp(parser->attrbuf, "DPScannedImage") == 0) { + // Each DPScannedImage node claims the next slot of the fixed-capacity + // `isyntax->images` array. The header controls how many such nodes it + // declares, so without this bound it can write past the array. + if (isyntax->image_count >= ISYNTAX_MAX_IMAGES) { + SetXmlErrorOnce( + isyntax, + AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kResourceExhausted, + aifocore::fmt::format( + "iSyntax XML error: more than {} DPScannedImage objects", + ISYNTAX_MAX_IMAGES))); + return false; + } parser->current_image = isyntax->images + isyntax->image_count; parser->running_image_index = isyntax->image_count++; } diff --git a/src/readers/isyntax/third_party/xml_semantics.cpp b/src/readers/isyntax/third_party/xml_semantics.cpp index 6ebd9d6..7057237 100644 --- a/src/readers/isyntax/third_party/xml_semantics.cpp +++ b/src/readers/isyntax/third_party/xml_semantics.cpp @@ -18,6 +18,7 @@ #include "fastslide/readers/isyntax/third_party/xml_semantics.h" +#include #include #include #include @@ -449,10 +450,16 @@ void ParseDimensionRange(isyntax_t* isyntax, isyntax_image_t* image, case 2: break; // always 3 color channels case 3: { - image->level_count = range.numsteps; - image->max_scale = range.numsteps - 1; + // `numsteps` is attacker-controlled and indexes the fixed + // `image->levels` array, and is also used as a shift distance below. + // Bound it to the array capacity so neither can run out of range; the + // Status check in InitializeLevelGeometry rejects the file afterwards. + const int32_t level_count = + std::clamp(range.numsteps, 1, ISYNTAX_MAX_LEVELS); + image->level_count = level_count; + image->max_scale = level_count - 1; image->level0_padding = - (kPerLevelPadding << range.numsteps) - kPerLevelPadding; + (kPerLevelPadding << level_count) - kPerLevelPadding; image->width = image->width_including_padding - 2 * image->level0_padding; image->height = diff --git a/src/readers/mrxs/mrxs_data_reader.cpp b/src/readers/mrxs/mrxs_data_reader.cpp index cac6f98..f923bd4 100644 --- a/src/readers/mrxs/mrxs_data_reader.cpp +++ b/src/readers/mrxs/mrxs_data_reader.cpp @@ -138,10 +138,30 @@ aifocore::Result> MrxsDataReader::ReadData( aifocore::fmt::format("Invalid size: {}", size)); } + if (size > constants::kMaxTileSize) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("Requested read of {} bytes exceeds maximum {}", + size, constants::kMaxTileSize)); + } + // Open file FileReader file; AIFOCORE_ASSIGN_OR_RETURN(file, FileReader::Open(datafile_path, "rb")); + // Mirror the bounds check ReadTileData performs. Without it a crafted + // non-hierarchical index record can name any offset and length, and + // `ReadBytes` allocates before it discovers the file is shorter. + int64_t file_size; + AIFOCORE_ASSIGN_OR_RETURN(file_size, file.GetSize()); + if (offset > file_size - size) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format( + "Read extends beyond file: offset={}, size={}, file_size={}", + offset, size, file_size)); + } + // Seek to offset AIFOCORE_RETURN_IF_ERROR(file.Seek(offset)); diff --git a/src/readers/mrxs/mrxs_metadata_loader.cpp b/src/readers/mrxs/mrxs_metadata_loader.cpp index bad691a..fe8c3d0 100644 --- a/src/readers/mrxs/mrxs_metadata_loader.cpp +++ b/src/readers/mrxs/mrxs_metadata_loader.cpp @@ -25,6 +25,7 @@ #include "fastslide/readers/mrxs/mrxs_ini_parser.h" #include "fastslide/readers/mrxs/mrxs_layer_parser.h" #include "fastslide/readers/mrxs/mrxs_position_reader.h" +#include "fastslide/runtime/io/path_utils.h" namespace fastslide { namespace mrxs { @@ -131,12 +132,33 @@ aifocore::Result ReadSlidedatIni(const fs::path& slidedat_path, return info; } +/// @brief Reject INI-supplied filenames that point outside the slide directory. +/// +/// `FILE_n` and `INDEXFILE` are joined onto the slide directory at several +/// points during reading, and their contents are returned to the caller as +/// tile or associated-image bytes. Validating them here, once, keeps every +/// downstream join safe. +aifocore::Status ValidateReferencedPaths(const fs::path& dirname, + const SlideDataInfo& info) { + for (const std::string& datafile : info.datafile_paths) { + AIFOCORE_RETURN_IF_ERROR( + runtime::io::ResolveContainedPath(dirname, datafile).status()); + } + if (!info.index_filename.empty()) { + AIFOCORE_RETURN_IF_ERROR( + runtime::io::ResolveContainedPath(dirname, info.index_filename) + .status()); + } + return aifocore::Status::OkStatus(); +} + } // namespace aifocore::Result MrxsMetadataLoader::Load( const fs::path& slidedat_path, const fs::path& dirname) { SlideDataInfo info; AIFOCORE_ASSIGN_OR_RETURN(info, ReadSlidedatIni(slidedat_path, dirname)); + AIFOCORE_RETURN_IF_ERROR(ValidateReferencedPaths(dirname, info)); // IMPORTANT: Camera positions MUST be loaded during initialization; // they are required for accurate tile positioning at read time. diff --git a/src/readers/omezarr/omezarr.cpp b/src/readers/omezarr/omezarr.cpp index 6871152..b80d3bc 100644 --- a/src/readers/omezarr/omezarr.cpp +++ b/src/readers/omezarr/omezarr.cpp @@ -35,6 +35,7 @@ #include "fastslide/readers/omezarr/omezarr_metadata.h" #include "fastslide/readers/omezarr/omezarr_plan_builder.h" #include "fastslide/readers/omezarr/omezarr_tile_executor.h" +#include "fastslide/runtime/io/path_utils.h" namespace fs = std::filesystem; @@ -214,8 +215,13 @@ aifocore::Status OmeZarrReader::LoadMetadata() { pyramid_.reserve(ngff_.datasets.size()); for (const auto& dataset : ngff_.datasets) { OmeZarrLevelInfo level; - level.array_dir = (root_dir_ / dataset.path).string(); - const fs::path array_json_path = fs::path(level.array_dir) / "zarr.json"; + // `dataset.path` comes straight out of the store's zarr.json, so it has to + // be confined to the store directory before it reaches the filesystem. + AIFOCORE_ASSIGN_OR_RETURN( + const fs::path array_dir, + runtime::io::ResolveContainedPath(root_dir_, dataset.path)); + level.array_dir = array_dir.string(); + const fs::path array_json_path = array_dir / "zarr.json"; AIFOCORE_ASSIGN_OR_RETURN(const std::string array_text, ReadFileToString(array_json_path)); AIFOCORE_ASSIGN_OR_RETURN( diff --git a/src/readers/qptiff/metadata_parser.cpp b/src/readers/qptiff/metadata_parser.cpp index 64dbd30..a01ec2f 100644 --- a/src/readers/qptiff/metadata_parser.cpp +++ b/src/readers/qptiff/metadata_parser.cpp @@ -15,8 +15,12 @@ #include "fastslide/readers/qptiff/metadata_parser.h" #include +#include #include +#include #include +#include +#include #include #include @@ -28,6 +32,27 @@ namespace fastslide { namespace formats { namespace qptiff { +namespace { + +/// @brief Parse an unsigned integer from untrusted XML text. +/// +/// `std::stoull` throws on both malformed and out-of-range input, which would +/// escape this translation unit and, through the C API, cross an `extern "C"` +/// boundary. Returns 0 for anything that is not a clean in-range integer, +/// matching the treatment of an absent element. +uint64_t ParseUInt64OrZero(std::string_view text) { + uint64_t value = 0; + const char* begin = text.data(); + const char* end = begin + text.size(); + const std::from_chars_result result = std::from_chars(begin, end, value); + if (result.ec != std::errc() || result.ptr != end) { + return 0; + } + return value; +} + +} // namespace + aifocore::Status QpTiffMetadataParser::ParseSlideMetadata( const std::string& xml_content, QpTiffSlideMetadata& metadata) { @@ -81,12 +106,11 @@ aifocore::Result QpTiffMetadataParser::ParseChannelInfo( // Extract exposure time std::string exposure_str = GetText(&root, "ExposureTime"); - channel.exposure_time = exposure_str.empty() ? 0 : std::stoull(exposure_str); + channel.exposure_time = ParseUInt64OrZero(exposure_str); // Extract signal units std::string signal_units_str = GetText(&root, "SignalUnits"); - channel.signal_units = - signal_units_str.empty() ? 0 : std::stoull(signal_units_str); + channel.signal_units = ParseUInt64OrZero(signal_units_str); // Extract and parse color std::string color_str = GetText(&root, "Color"); diff --git a/src/runtime/decoders/bmp_decoder.cpp b/src/runtime/decoders/bmp_decoder.cpp index 704986a..3e144cd 100644 --- a/src/runtime/decoders/bmp_decoder.cpp +++ b/src/runtime/decoders/bmp_decoder.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include "aifocore/status/result.h" @@ -176,8 +177,22 @@ aifocore::Result DecodeBmpToRgb( const int32_t height = std::abs(height_raw); const bool top_down = height_raw < 0; - const uint32_t row_stride_src = - ((static_cast(width) * 3U) + 3U) & ~3U; + // `biWidth` is a 32-bit field, so computing the 4-byte-aligned row stride in + // 32-bit arithmetic wraps once `width * 3` exceeds 2^32: a declared width of + // 0x55555556 collapses the stride to 4. That understates the pixel array, so + // the truncation check below would accept a ~58-byte file while the row loop + // still walks `width` pixels. Compute in 64-bit and reject anything that will + // not fit. With a correct stride the truncation check then bounds the whole + // pixel array by the input size, which also caps the output allocation -- + // BI_RGB is uncompressed, so it cannot expand. + const uint64_t row_stride_64 = + ((static_cast(width) * 3ULL) + 3ULL) & ~3ULL; + if (row_stride_64 > std::numeric_limits::max()) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format("BMP row stride overflows for width {}", width)); + } + const uint32_t row_stride_src = static_cast(row_stride_64); const std::size_t pixel_array_bytes = static_cast(row_stride_src) * diff --git a/src/runtime/decoders/bmp_decoder_test.cpp b/src/runtime/decoders/bmp_decoder_test.cpp index aa0ed96..7f37acb 100644 --- a/src/runtime/decoders/bmp_decoder_test.cpp +++ b/src/runtime/decoders/bmp_decoder_test.cpp @@ -242,5 +242,32 @@ TEST(BmpDecoderTest, DecodesLargerImage) { } } +/// @brief A width whose 24 bpp row stride wraps 32 bits must be rejected. +/// +/// `width * 3` overflows for widths at or above 0x55555556. Computed in 32-bit +/// arithmetic the stride collapses to a handful of bytes, so the truncation +/// check would accept this 54-byte header while the row loop still walked +/// billions of pixels. +TEST(BmpDecoderTest, RejectsWidthWhoseRowStrideOverflows) { + std::vector bmp(54, 0); + bmp[0] = 'B'; + bmp[1] = 'M'; + const uint32_t data_offset = 54; + std::memcpy(&bmp[10], &data_offset, 4); + const uint32_t info_size = 40; + std::memcpy(&bmp[14], &info_size, 4); + const int32_t width = 0x55555556; // 3 * width == 0x100000002 (mod 2^32 = 2) + std::memcpy(&bmp[18], &width, 4); + const int32_t height = 1; + std::memcpy(&bmp[22], &height, 4); + const uint16_t planes = 1; + std::memcpy(&bmp[26], &planes, 2); + const uint16_t bpp = 24; + std::memcpy(&bmp[28], &bpp, 2); + + const auto out_or = DecodeBmpToRgb(bmp); + EXPECT_FALSE(out_or.ok()); +} + } // namespace } // namespace fastslide::runtime::decoders diff --git a/src/runtime/io/path_utils_test.cpp b/src/runtime/io/path_utils_test.cpp new file mode 100644 index 0000000..82f1307 --- /dev/null +++ b/src/runtime/io/path_utils_test.cpp @@ -0,0 +1,94 @@ +// Copyright 2026 Jonas Teuwen. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "fastslide/runtime/io/path_utils.h" + +#include +#include + +#include "gtest/gtest.h" + +namespace fastslide::runtime::io { +namespace { + +namespace fs = std::filesystem; + +/// @brief Scratch bundle directory plus an out-of-bundle sibling. +class ResolveContainedPathTest : public ::testing::Test { + protected: + void SetUp() override { + base_ = fs::temp_directory_path() / + fs::path("fastslide_path_utils_test_" + + std::to_string( + ::testing::UnitTest::GetInstance()->random_seed()) + + "_" + std::to_string(counter_++)); + bundle_ = base_ / "slide"; + fs::create_directories(bundle_ / "sub"); + fs::create_directories(base_ / "outside"); + + std::ofstream(bundle_ / "sub" / "data.dat") << "inside"; + std::ofstream(base_ / "outside" / "secret.dat") << "outside"; + } + + void TearDown() override { + std::error_code err; + fs::remove_all(base_, err); + } + + fs::path base_; + fs::path bundle_; + static int counter_; +}; + +int ResolveContainedPathTest::counter_ = 0; + +TEST_F(ResolveContainedPathTest, AcceptsPathInsideBundle) { + const auto resolved = ResolveContainedPath(bundle_, "sub/data.dat"); + ASSERT_TRUE(resolved.ok()) << resolved.status().message(); + EXPECT_EQ(resolved.value().filename(), "data.dat"); +} + +TEST_F(ResolveContainedPathTest, RejectsParentTraversal) { + EXPECT_FALSE(ResolveContainedPath(bundle_, "../outside/secret.dat").ok()); + EXPECT_FALSE(ResolveContainedPath(bundle_, "sub/../../outside/x").ok()); +} + +TEST_F(ResolveContainedPathTest, RejectsAbsolutePath) { + EXPECT_FALSE(ResolveContainedPath(bundle_, "/etc/passwd").ok()); +} + +TEST_F(ResolveContainedPathTest, RejectsEmptyPath) { + EXPECT_FALSE(ResolveContainedPath(bundle_, "").ok()); +} + +/// A symlink escapes without using any `..` component, so the textual check +/// alone would let it through. +TEST_F(ResolveContainedPathTest, RejectsSymlinkPointingOutsideBundle) { + std::error_code err; + fs::create_symlink(base_ / "outside", bundle_ / "link", err); + if (err) { + GTEST_SKIP() << "symlinks unavailable: " << err.message(); + } + EXPECT_FALSE(ResolveContainedPath(bundle_, "link/secret.dat").ok()); +} + +/// `slide_extra` shares a textual prefix with `slide` but is not inside it. +TEST_F(ResolveContainedPathTest, RejectsSiblingSharingNamePrefix) { + fs::create_directories(base_ / "slide_extra"); + std::ofstream(base_ / "slide_extra" / "f.dat") << "x"; + EXPECT_FALSE(ResolveContainedPath(bundle_, "../slide_extra/f.dat").ok()); +} + +} // namespace +} // namespace fastslide::runtime::io diff --git a/src/runtime/tile_writer/paint_dispatch.cpp b/src/runtime/tile_writer/paint_dispatch.cpp index e045bb3..62da0ce 100644 --- a/src/runtime/tile_writer/paint_dispatch.cpp +++ b/src/runtime/tile_writer/paint_dispatch.cpp @@ -14,16 +14,33 @@ #include "fastslide/runtime/tile_writer.h" +#include #include +#include #include #include #include "aifocore/status/result.h" +#include "aifocore/utilities/fmt.h" #include "fastslide/core/tile_plan.h" #include "fastslide/image.h" namespace fastslide::runtime { +namespace { + +/// @brief Multiply without wrapping. +/// @return False when the product would exceed `size_t`. +bool CheckedMul(size_t a, size_t b, size_t* out) { + if (a != 0 && b > std::numeric_limits::max() / a) { + return false; + } + *out = a * b; + return true; +} + +} // namespace + aifocore::Status Canvas::PaintTileLocked(const core::TileReadOp& op, std::span pixel_data, uint32_t tile_width, @@ -34,6 +51,44 @@ aifocore::Status Canvas::PaintTileLocked(const core::TileReadOp& op, "Canvas has null image pointer"); } + // The paint sinks below take a bare pointer and derive every source offset + // from the declared tile geometry, which originates in file metadata. If the + // decoded buffer is shorter than that geometry implies, they read past its + // end and the stale bytes land in the image handed back to the caller. + // Reconcile the two here, once, before any sink sees the pointer. + if (tile_channels == 0) { + return AIFOCORE_MAKE_STATUS(aifocore::StatusCode::kInvalidArgument, + "Tile declares zero channels"); + } + + // `CopyTilePlanar` strides the source as a single-channel plane; every other + // sink reads interleaved samples. + const bool planar_plane_source = + !use_rgb8_blending_ && !use_rgb16_copy_blending_ && + config_.planar_config == PlanarConfig::kSeparate; + const uint32_t source_channels = planar_plane_source ? 1U : tile_channels; + + size_t required = output_image_->GetBytesPerSample(); + const bool fits = CheckedMul(required, tile_width, &required) && + CheckedMul(required, tile_height, &required) && + CheckedMul(required, source_channels, &required); + if (!fits) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format( + "Tile geometry {}x{}x{} overflows an address-space-sized buffer", + tile_width, tile_height, source_channels)); + } + if (pixel_data.size() < required) { + return AIFOCORE_MAKE_STATUS( + aifocore::StatusCode::kInvalidArgument, + aifocore::fmt::format( + "Tile buffer holds {} bytes but declared geometry {}x{}x{} at {} " + "bytes/sample requires {}", + pixel_data.size(), tile_width, tile_height, source_channels, + output_image_->GetBytesPerSample(), required)); + } + if (use_rgb8_blending_) { return PaintTileRgb8Blended(op, pixel_data, tile_width, tile_height, tile_channels); diff --git a/src/runtime/tile_writer_test.cpp b/src/runtime/tile_writer_test.cpp index 714c935..4ad0fd3 100644 --- a/src/runtime/tile_writer_test.cpp +++ b/src/runtime/tile_writer_test.cpp @@ -171,6 +171,33 @@ TEST(CanvasTest, PaintSingleTileRGB) { EXPECT_FALSE(output.Empty()); } +/// @brief A tile buffer shorter than its declared geometry must be refused. +/// +/// The paint sinks derive every source offset from the declared tile geometry, +/// which comes from file metadata. A malicious or corrupt slide can declare a +/// larger tile than it actually stores; without this check the sinks read past +/// the end of the decoded buffer and the stale bytes reach the caller. +TEST(CanvasTest, RejectsTileBufferShorterThanDeclaredGeometry) { + auto plan = CreateSimpleRGBPlan(256, 256); + Canvas canvas(plan); + + // Declare a 256x256x3 tile but supply one byte less than that needs. + std::vector truncated(256U * 256U * 3U - 1U, 0); + + const auto& op = plan.operations[0]; + const auto status = canvas.PaintTile(op, truncated, 256, 256, 3); + EXPECT_FALSE(status.ok()); +} + +TEST(CanvasTest, RejectsTileDeclaringZeroChannels) { + auto plan = CreateSimpleRGBPlan(256, 256); + Canvas canvas(plan); + + auto pixel_data = CreateTestPixelData(256, 256, 3); + const auto& op = plan.operations[0]; + EXPECT_FALSE(canvas.PaintTile(op, pixel_data, 256, 256, 0).ok()); +} + TEST(CanvasTest, PaintPartialTile) { auto plan = CreateSimpleRGBPlan(512, 512); Canvas canvas(plan); diff --git a/tests/meson.build b/tests/meson.build index c7f53c8..c5b3797 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -28,6 +28,7 @@ if gtest_dep.found() 'lru_tile_cache_test' : '../src/runtime/lru_tile_cache_test.cpp', 'cache_interface_test' : '../src/runtime/cache_interface_test.cpp', 'binary_utils_test' : '../src/runtime/io/binary_utils_test.cpp', + 'path_utils_test' : '../src/runtime/io/path_utils_test.cpp', 'png_decoder_test' : '../src/runtime/decoders/png_decoder_test.cpp', 'bmp_decoder_test' : '../src/runtime/decoders/bmp_decoder_test.cpp', 'mrxs_index_reader_test' : '../src/readers/mrxs/mrxs_index_reader_test.cpp', @@ -68,16 +69,4 @@ if gtest_dep.found() 'FASTSLIDE_DICOM_TESTDATA_DIR' : meson.current_source_dir() / '../src/readers/dicom/testdata', }) - - # C API tile-cache test. Links the C API library; the test itself skips - # unless FASTSLIDE_BENCHMARK_FILE points at a supported slide. - if get_option('build_c_api') - cache_c_api = executable('cache_c_api_test', - '../src/c/cache_c_api_test.cpp', - include_directories : test_inc, - dependencies : [fastslide_c_dep, gtest_dep], - cpp_args : ['-D__ANSI__']) - test('cache_c_api_test', cache_c_api, - env : {'TEST_TMPDIR' : meson.current_build_dir()}) - endif endif diff --git a/tests/python/cache_test.py b/tests/python/cache_test.py index 75933a2..6be73b2 100644 --- a/tests/python/cache_test.py +++ b/tests/python/cache_test.py @@ -683,64 +683,6 @@ def test_multiple_cache_managers_independence(self) -> None: assert new_stats3.capacity_bytes == 300 -class TestFromFilePathCache: - """Test the `from_file_path(cache=...)` kwarg and slide cache accessors. - - These require a real slide, provided via FASTSLIDE_BENCHMARK_FILE, and are - skipped otherwise so the suite stays hermetic by default. - """ - - @pytest.fixture - def slide_path(self) -> str: - import os - - path = os.environ.get("FASTSLIDE_BENCHMARK_FILE") - if not path or not os.path.exists(path): - pytest.skip("Set FASTSLIDE_BENCHMARK_FILE to a supported slide.") - return path - - def test_open_with_int_capacity_caches_reads(self, slide_path: str) -> None: - """`cache=` attaches an LRU cache and repeated reads hit it.""" - import numpy as np - - with fastslide.FastSlide.from_file_path(slide_path, cache=256 << 20) as slide: - assert slide.cache_enabled - assert slide.cache_stats is not None - - region1 = slide.read_region((0, 0), 0, (256, 256)).numpy() - region2 = slide.read_region((0, 0), 0, (256, 256)).numpy() - - assert np.array_equal(region1, region2) - assert slide.cache_stats.hits > 0 - - def test_open_without_cache_reports_disabled(self, slide_path: str) -> None: - """Default open attaches no cache.""" - with fastslide.FastSlide.from_file_path(slide_path) as slide: - assert not slide.cache_enabled - assert slide.cache_stats is None - - def test_clear_cache_resets_entries(self, slide_path: str) -> None: - """`clear_cache` empties the attached cache.""" - with fastslide.FastSlide.from_file_path(slide_path, cache=64 << 20) as slide: - slide.read_region((0, 0), 0, (256, 256)) - assert slide.cache_stats.size > 0 - - slide.clear_cache() - assert slide.cache_stats.size == 0 - - def test_use_global_cache(self, slide_path: str) -> None: - """`use_global_cache` attaches the shared singleton cache.""" - global_cache = fastslide.GlobalCacheManager.instance() - global_cache.set_capacity_bytes(128 << 20) - global_cache.clear() - - with fastslide.FastSlide.from_file_path(slide_path) as slide: - slide.use_global_cache() - assert slide.cache_enabled - slide.read_region((0, 0), 0, (256, 256)) - assert global_cache.get_stats().capacity_bytes == 128 << 20 - - # Test configuration and utilities