From 3e59f35077739df13a807c961e59bf58cd562b0e Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Sun, 4 Oct 2026 19:50:13 -0700 Subject: [PATCH 1/6] [CoreAI] Coordinate model acquisition Summary: Acquire a prepared Core AI model for a selected bundle: restore it from the persisted bookmark, or materialize the source and specialize it. - `runtime/coreai_load_coordinator.{h,mm}`: `acquire_bookmark_model` restores from the persisted bookmark, or on a miss materializes the source and specializes it with the persistent default SDK cache, then publishes the new bookmark atomically. Restore errors fail without a source fallback. Sources and SDK pins are retained across completion. - Tests: an acquisition-only fake SDK loader (`runtime/test/coreai_fake_loader.{h,mm}`) lets the coordinator run in `coreai_host_test` without Swift or CoreAI. The acquisition suite calls `acquire_bookmark_model` directly and covers cold specialization, warm restores, restore errors and misses, bookmark-save failures, contention on one key versus independent keys across processes, and a crash after the SDK call. - The backend that uses this lands separately. - README: the "Asset storage" acquisition flow. No changes outside `backends/apple/coreai`. Test Plan: ``` cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_OSX_DEPLOYMENT_TARGET=27.0 -DEXECUTORCH_BUILD_COREAI=ON \ -DEXECUTORCH_BUILD_TESTS=ON -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON cmake --build build --target backends/apple/coreai/all ctest --test-dir build/backends/apple/coreai --output-on-failure --no-tests=error ``` CTest runs `coreai_host_test` (adding the acquisition suite) and `coreai_swift_bridge_test`. --- backends/apple/coreai/CMakeLists.txt | 5 + backends/apple/coreai/README.md | 14 + .../coreai/runtime/coreai_load_coordinator.h | 24 ++ .../coreai/runtime/coreai_load_coordinator.mm | 96 ++++++ .../runtime/test/coreai_acquisition_fixture.h | 61 ++++ .../test/coreai_acquisition_fixture.mm | 285 ++++++++++++++++++ .../runtime/test/coreai_acquisition_test.mm | 180 +++++++++++ .../coreai/runtime/test/coreai_fake_loader.h | 91 ++++++ .../coreai/runtime/test/coreai_fake_loader.mm | 243 +++++++++++++++ .../coreai/runtime/test/coreai_host_test.mm | 7 + 10 files changed, 1006 insertions(+) create mode 100644 backends/apple/coreai/runtime/coreai_load_coordinator.h create mode 100644 backends/apple/coreai/runtime/coreai_load_coordinator.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h create mode 100644 backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_acquisition_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_fake_loader.h create mode 100644 backends/apple/coreai/runtime/test/coreai_fake_loader.mm diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 2c1714a5847..82e6194b60d 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -32,6 +32,7 @@ set(_coreai_runtime_sources runtime/coreai_cache_key.mm runtime/coreai_cache_root.mm runtime/coreai_fault_injection.mm + runtime/coreai_load_coordinator.mm runtime/coreai_manifest.mm runtime/coreai_source_bundle.mm runtime/coreai_validation.mm @@ -66,6 +67,10 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") runtime/test/coreai_source_test.mm runtime/test/coreai_bookmark_fixture.mm runtime/test/coreai_source_fixture.mm + runtime/test/coreai_fake_loader.mm + runtime/test/coreai_acquisition_test.mm + runtime/test/coreai_acquisition_fixture.mm + runtime/ETCoreAITensor.mm ${_coreai_runtime_sources} ) coreai_configure_objc_target(coreai_host_test) diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 13d99d85045..be45e247140 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -62,6 +62,11 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. +Both formats can require device specialization. Every load first attempts SDK +bookmark restoration. A missing bookmark or SDK-confirmed cache miss falls back +to source materialization and persistent specialization, then binds the requested +function and ordered I/O on that same acquired model. + ### Asset storage The assets root must be an absolute path. The application chooses it, should @@ -138,6 +143,15 @@ runtime. Each variant key determines a raw bookmark in `bookmarks`, a bundle directory in `staging`, and one of 256 shared coordination files in `locks`. There is no PTE-wide bookmark list or serialized lifecycle record. +A healthy bookmark hit does not inspect staging or consult NDS; the acquired SDK +model is retained through function binding. On a missing bookmark or confirmed +SDK miss, the runtime materializes the source, specializes it and atomically +writes the returned bookmark bytes. There is no secondary source-URL cache +lookup. SDK errors, unreadable bookmarks and publication failures fail loading +without automatic cache deletion. If publication fails after specialization, the +SDK entry and source remain; the next load retries the ordinary flow. The +backend does not track historical or unrecorded SDK entries. + Raw bookmarks have an 8 MiB backend allocation limit, not an asserted SDK format maximum, and are published atomically because losing one orphans an SDK entry. A fixed pool of at most 256 disk locks coordinates loading and eviction across diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.h b/backends/apple/coreai/runtime/coreai_load_coordinator.h new file mode 100644 index 00000000000..85827ea0fec --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.h @@ -0,0 +1,24 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#import "ETCoreAIBridge.h" +#import "coreai_bookmarks.h" + +namespace executorch::backends::coreai { + +// The key lock outlives all SDK callbacks and raw bookmark publication. +runtime::Result> acquire_bookmark_model( + const Manifest& selected, + const runtime::NamedDataMap* named_data, + NSString* root, + NSString* platform, + NSString* architecture, + id loader); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm new file mode 100644 index 00000000000..a34e0c50ba9 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -0,0 +1,96 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#import "coreai_load_coordinator.h" +#import "coreai_storage.h" + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +Error sdk_error(NSError* error) { + ET_LOG(Error, "Core AI SDK error (%s, %ld): %s", error.domain.UTF8String, + static_cast(error.code), error.description.UTF8String); + return Error::Internal; +} + +Result> acquire( + void (^operation)(ETCoreAIAcquisitionCompletion)) { + ET_CHECK_OR_RETURN_ERROR(storage_fault(StorageOperation::BeforeSDK) == 0, + AccessFailed, "Core AI acquisition interrupted"); + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block id model = nil; + __block NSError* error = nil; + __block ETCoreAIAcquisitionStatus status = ETCoreAIAcquisitionStatusError; + operation(^(ETCoreAIAcquisitionStatus result, id value, + NSError* failure) { + status = result; + model = value; + error = failure; + dispatch_semaphore_signal(ready); + }); + // Do not abandon asynchronous source access on cancellation or timeout. + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + id acquired = model; + // A loader may retain the completion block after invoking it. + model = nil; + if (error != nil) return sdk_error(error); + ET_CHECK_OR_RETURN_ERROR(storage_fault(StorageOperation::AfterSDK) == 0, + AccessFailed, "Core AI acquisition interrupted"); + ET_CHECK_OR_RETURN_ERROR( + (status == ETCoreAIAcquisitionStatusHit && acquired != nil) || + (status == ETCoreAIAcquisitionStatusMiss && acquired == nil), + Internal, "Invalid Core AI acquisition result"); + return acquired; +} + +Result> restore(id loader, + NSData* bookmark) { + return acquire(^(ETCoreAIAcquisitionCompletion completion) { + [loader restoreModelFromBookmark:bookmark completion:completion]; + }); +} + +} // namespace + +Result> acquire_bookmark_model( + const Manifest& selected, const runtime::NamedDataMap* named_data, + NSString* root, NSString* platform, NSString* architecture, + id loader) { + ET_CHECK_OR_RETURN_ERROR(loader != nil, InvalidArgument, + "Missing Core AI model loader"); + auto key = bookmark_key(selected, platform, architecture); + if (!key.ok()) return key.error(); + auto locked = lock_bookmark(root, key.get()); + if (!locked.ok()) return locked.error(); + auto bookmark = read_bookmark(*locked.get()); + if (!bookmark.ok()) return bookmark.error(); + if (bookmark.get() != nil) { + auto model = restore(loader, bookmark.get()); + if (!model.ok()) return model.error(); + if (model.get() != nil) return model; + } + auto staging = prepare_bookmark_staging(*locked.get()); + if (!staging.ok()) return staging.error(); + auto source = + prepare_source_bundle(selected, named_data, staging.get(), key.get()); + if (!source.ok()) return source.error(); + NSURL* url = source.get(); + auto model = acquire(^(ETCoreAIAcquisitionCompletion completion) { + [loader specializeModelAtURL:url completion:completion]; + }); + if (!model.ok()) return model.error(); + ET_CHECK_OR_RETURN_ERROR(model.get() != nil, Internal, + "Core AI specialization returned no model"); + ET_CHECK_OK_OR_RETURN_ERROR( + write_bookmark(*locked.get(), [model.get() copyBookmarkData])); + // SDK pins do not prove source independence; leave staged sources in place. + return model; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h new file mode 100644 index 00000000000..743102fac99 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h @@ -0,0 +1,61 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include "coreai_bookmark_fixture.h" +#include "coreai_fake_loader.h" +#include "coreai_source_fixture.h" +#include "coreai_storage.h" + +namespace executorch::backends::coreai::testing { + +runtime::Result acquisition_staged_url( + NSURL* root, + const Manifest& manifest); + +struct ParallelAcquisitionObservation { + int creation[2] = {-1, -1}; + int joined[2] = {-1, -1}; + bool completed[2] = {false, false}; + runtime::Error errors[2] = { + runtime::Error::Internal, + runtime::Error::Internal}; + bool models[2] = {false, false}; + bool first_entered = false; + bool second_observed = false; + int gate_timeouts = 0; + bool callbacks_drained = false; +}; +ParallelAcquisitionObservation parallel_acquisitions( + ScopedFakeBridgeState& bridge, + BookmarkFakeLoader* loader, + NSURL* root, + const Manifest& first, + const Manifest& second, + TestData& data, + bool same_key); + +// Uses the existing bounded, scoped child process owner. +void set_acquisition_test_executable(const char* path); +::testing::AssertionResult spawn_acquisition_crash( + BookmarkChild& child, + NSURL* root, + StorageOperation boundary, + NSDictionary* manifest); +::testing::AssertionResult spawn_parallel_acquisition( + BookmarkChild& child, + NSURL* root, + NSDictionary* first, + NSDictionary* second, + bool same_key); +// Dispatch before bookmark_child_mode and InitGoogleTest. Unrelated modes +// return -1. +int acquisition_child_mode(int argc, char** argv); + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm new file mode 100644 index 00000000000..154fd7e2ae4 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -0,0 +1,285 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_acquisition_fixture.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "coreai_fault_scope.h" +#include "coreai_load_coordinator.h" + +namespace executorch::backends::coreai::testing { +namespace { +using runtime::Error; +using runtime::Result; + +dispatch_time_t deadline() { return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); } + +struct AcquisitionWorker { + Manifest manifest; + TestData* data = nullptr; + NSString* root = nil; + BookmarkFakeLoader* loader = nil; + Error error = Error::Internal; + bool model = false; + dispatch_semaphore_t started = dispatch_semaphore_create(0); + dispatch_semaphore_t done = dispatch_semaphore_create(0); + + static void* run(void* opaque) { + auto* worker = static_cast(opaque); + dispatch_semaphore_signal(worker->started); + @autoreleasepool { + auto acquired = acquire_bookmark_model(worker->manifest, worker->data, worker->root, @"macOS", + @"arch_b", worker->loader); + worker->error = acquired.ok() ? Error::Ok : acquired.error(); + worker->model = acquired.ok() && acquired.get() != nil; + } + dispatch_semaphore_signal(worker->done); + return nullptr; + } +}; + +struct AcquisitionWorkers { + ScopedFakeBridgeState& bridge; + BookmarkFakeLoader* loader; + dispatch_semaphore_t proceed = dispatch_semaphore_create(0); + pthread_t threads[2]{}; + bool started[2] = {false, false}; + AcquisitionWorker workers[2]; + ParallelAcquisitionObservation& observation; + bool finished = false; + + AcquisitionWorkers(ScopedFakeBridgeState& state, BookmarkFakeLoader* fake, + ParallelAcquisitionObservation& result) + : bridge(state), loader(fake), observation(result) {} + + void finish() { + if (finished) return; + for (int i = 0; i < 2; ++i) dispatch_semaphore_signal(proceed); + for (int i = 0; i < 2; ++i) { + if (!started[i]) continue; + observation.completed[i] = dispatch_semaphore_wait(workers[i].done, deadline()) == 0; + observation.joined[i] = pthread_join(threads[i], nullptr); + observation.errors[i] = workers[i].error; + observation.models[i] = workers[i].model; + } + observation.callbacks_drained = bridge.wait_for_callbacks(deadline()); + if (observation.callbacks_drained) loader.onSpecialize = nil; + finished = true; + } + ~AcquisitionWorkers() { finish(); } +}; + +bool send_child_byte(int fd, char value) { + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (std::chrono::steady_clock::now() < end) { + const auto remaining = std::chrono::duration_cast( + end - std::chrono::steady_clock::now()) + .count(); + pollfd entry{fd, POLLOUT, 0}; + const int ready = poll(&entry, 1, static_cast(remaining > 0 ? remaining : 0)); + if (ready < 0 && errno == EINTR) continue; + if (ready <= 0 || !(entry.revents & POLLOUT)) return false; + const ssize_t written = write(fd, &value, 1); + if (written == 1) return true; + if (written < 0 && (errno == EINTR || errno == EAGAIN)) continue; + return false; + } + return false; +} +} // namespace + +Result acquisition_staged_url(NSURL* root, const Manifest& manifest) { + auto key = test_bookmark_key(manifest); + if (!key.ok()) return key.error(); + return [[[root URLByAppendingPathComponent:@"staging"] URLByAppendingPathComponent:key.get()] + URLByAppendingPathComponent:manifest.path.lastPathComponent]; +} + +ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& bridge, + BookmarkFakeLoader* loader, NSURL* root, + const Manifest& first, const Manifest& second, + TestData& data, bool same_key) { + ParallelAcquisitionObservation observation; + auto timeouts = std::make_shared>(0); + dispatch_semaphore_t entered = dispatch_semaphore_create(0); + AcquisitionWorkers group(bridge, loader, observation); + dispatch_semaphore_t proceed = group.proceed; + loader.onSpecialize = ^(NSURL*) { + dispatch_semaphore_signal(entered); + if (dispatch_semaphore_wait(proceed, deadline()) != 0) ++*timeouts; + }; + for (int i = 0; i < 2; ++i) { + auto& worker = group.workers[i]; + worker.manifest = i == 0 ? first : second; + worker.data = &data; + worker.root = root.path; + worker.loader = loader; + } + observation.creation[0] = + pthread_create(&group.threads[0], nullptr, AcquisitionWorker::run, &group.workers[0]); + group.started[0] = observation.creation[0] == 0; + if (group.started[0]) { + observation.first_entered = dispatch_semaphore_wait(entered, deadline()) == 0; + } + if (observation.first_entered) { + observation.creation[1] = + pthread_create(&group.threads[1], nullptr, AcquisitionWorker::run, &group.workers[1]); + group.started[1] = observation.creation[1] == 0; + if (group.started[1] && dispatch_semaphore_wait(group.workers[1].started, deadline()) == 0) { + const auto second_deadline = same_key + ? dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC) + : deadline(); + const bool second_entered = dispatch_semaphore_wait(entered, second_deadline) == 0; + observation.second_observed = same_key ? !second_entered : second_entered; + } + } + group.finish(); + observation.gate_timeouts = timeouts->load(); + return observation; +} + +void set_acquisition_test_executable(const char* path) { set_bookmark_test_executable(path); } + +::testing::AssertionResult spawn_acquisition_crash(BookmarkChild& child, NSURL* root, + StorageOperation boundary, + NSDictionary* manifest) { + // Serialize the shared fixture in the parent; its builders contain GTest checks. + NSData* payload = encode(@{@"boundary" : @(static_cast(boundary)), @"manifest" : manifest}); + if (payload == nil) return ::testing::AssertionFailure() << "Cannot encode crash child input"; + NSString* argument = [[NSString alloc] initWithData:payload encoding:NSUTF8StringEncoding]; + return child.spawn(@"--bookmark-crash", root.path, argument); +} + +::testing::AssertionResult spawn_parallel_acquisition(BookmarkChild& child, NSURL* root, + NSDictionary* first, NSDictionary* second, + bool same_key) { + NSData* payload = encode(@{@"manifest" : first, @"second" : second, @"same_key" : @(same_key)}); + if (payload == nil) return ::testing::AssertionFailure() << "Cannot encode parallel child input"; + NSString* argument = [[NSString alloc] initWithData:payload encoding:NSUTF8StringEncoding]; + return child.spawn(@"--acquisition-parallel", root.path, argument); +} + +namespace { +[[noreturn]] void parallel_child(NSString* root, const Manifest& first, const Manifest& second, + bool same_key, int fd) { + ScopedFakeBridgeState bridge; + @autoreleasepool { + BookmarkFakeLoader* loader = [[BookmarkFakeLoader alloc] init]; + if (loader == nil) _exit(8); + bridge.state().bookmark_loader = loader; + TestData data; + auto result = parallel_acquisitions(bridge, loader, [NSURL fileURLWithPath:root], first, second, + data, same_key); + if (!result.callbacks_drained) _exit(11); + // These bytes are observations, not child assertions. The parent checks each one. + const bool harness_ok = result.creation[0] == 0 && result.creation[1] == 0 && + result.first_entered && result.joined[0] == 0 && + result.joined[1] == 0 && result.completed[0] && result.completed[1] && + result.gate_timeouts == 0; + const bool both_ok = result.errors[0] == Error::Ok && result.models[0] && + result.errors[1] == Error::Ok && result.models[1]; + const char observations[] = {static_cast(harness_ok), + static_cast(result.second_observed), + static_cast(both_ok), + static_cast(loader->specializations.load()), + static_cast(data.attempts.load()), + static_cast(loader->restores.load())}; + for (char observation : observations) { + if (!send_child_byte(fd, observation)) _exit(12); + } + if (loader->evictions.load() != 0 || data.requests.load() != data.releases.load()) _exit(14); + [loader clearRetainedState]; + bridge.state().bookmark_loader = nil; + loader = nil; + } + auto& state = bridge.state(); + const bool none_live = state.prepared_models.load() == 0 && state.loaders.load() == 0 && + state.missing_bundles.load() == 0; + if (!send_child_byte(fd, static_cast(none_live))) _exit(12); + // No GTest-bearing destructors run before InitGoogleTest, even on a failed child. + _exit(close(fd) == 0 ? 0 : 13); +} +} // namespace + +int acquisition_child_mode(int argc, char** argv) { + if (argc < 2) return -1; + const bool crash = std::strcmp(argv[1], "--bookmark-crash") == 0; + const bool parallel = std::strcmp(argv[1], "--acquisition-parallel") == 0; + if (!crash && !parallel) return -1; + if (argc != 5) return 1; + NSString* root = [NSString stringWithUTF8String:argv[2]]; + NSString* argument = [NSString stringWithUTF8String:argv[3]]; + if (root == nil || argument == nil) return 2; + id payload = + [NSJSONSerialization JSONObjectWithData:[argument dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + if (![payload isKindOfClass:NSDictionary.class]) return 3; + id number = payload[@"boundary"]; + id dictionary = payload[@"manifest"]; + if (![dictionary isKindOfClass:NSDictionary.class]) return 3; + StorageOperation boundary = StorageOperation::AfterSDK; + if (crash) { + if (![number isKindOfClass:NSNumber.class]) return 3; + boundary = static_cast([number intValue]); + if (boundary != StorageOperation::AfterSDK) return 4; + } + auto parsed = parse_manifest(encode(dictionary)); + if (!parsed.ok()) return 5; + auto selected = select_assets(parsed.get(), @"arch_b", @"macOS"); + if (!selected.ok()) return 5; + char* end = nullptr; + errno = 0; + const long descriptor = std::strtol(argv[4], &end, 10); + if (errno != 0 || end == argv[4] || *end != '\0' || descriptor < 0 || descriptor > INT_MAX) + return 6; + const int fd = static_cast(descriptor); + if (fcntl(fd, F_GETFD) < 0) return 6; + signal(SIGPIPE, SIG_IGN); + if (!send_child_byte(fd, 'C')) return 7; + if (parallel) { + id second_dictionary = payload[@"second"]; + id same_key = payload[@"same_key"]; + if (![second_dictionary isKindOfClass:NSDictionary.class] || + ![same_key isKindOfClass:NSNumber.class]) + return 3; + auto second_parsed = parse_manifest(encode(second_dictionary)); + if (!second_parsed.ok()) return 5; + auto second = select_assets(second_parsed.get(), @"arch_b", @"macOS"); + if (!second.ok()) return 5; + parallel_child(root, selected.get(), second.get(), [same_key boolValue], fd); + } + if (close(fd) != 0) return 7; + + ScopedFakeBridgeState bridge; + @autoreleasepool { + // Terminal child paths deliberately bypass assertion-bearing scope destructors. + BookmarkFakeLoader* loader = [[BookmarkFakeLoader alloc] init]; + if (loader == nil) _exit(8); + bridge.state().bookmark_loader = loader; + TestData data; + StorageFaultScope fault(boundary); + fault.armed = false; + fault.observe = ^(StorageOperation event) { + if (event == boundary) _exit(73); + }; + auto result = acquire_bookmark_model(selected.get(), &data, root, @"macOS", @"arch_b", loader); + _exit(result.ok() ? 9 : 10); + } +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm new file mode 100644 index 00000000000..6c1a91eea02 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -0,0 +1,180 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_acquisition_fixture.h" + +#include +#include "coreai_fault_scope.h" +#include "coreai_load_coordinator.h" + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +namespace { + +class CoreAIAcquisitionTest : public ::testing::Test { + protected: + // Declared first so each case's loader, models and data die before it. + ScopedFakeBridgeState bridge; + BookmarkDirectory root; + TestData data; + Manifest manifest; + NSString* key = nil; + + void SetUp() override { + ASSERT_NE(root.url, nil); + auto selected = bookmark_manifest(); + ASSERT_TRUE(selected.ok()); + manifest = selected.get(); + auto identity = test_bookmark_key(manifest); + ASSERT_TRUE(identity.ok()); + key = identity.get(); + } + + Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { + @autoreleasepool { + auto model = + acquire_bookmark_model(manifest, named_data, root.url.path, @"macOS", @"arch_b", loader); + if (!model.ok()) return model.error(); + return model.get() != nil ? Error::Ok : Error::Internal; + } + } + + NSData* saved() { + auto bookmark = saved_bookmark(root.url, key); + EXPECT_TRUE(bookmark.ok()); + return bookmark.ok() ? bookmark.get() : nil; + } +}; + +TEST_F(CoreAIAcquisitionTest, ColdSpecializesStagedSourceAndPersistsBookmark) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(data.attempts.load(), 1); + auto staged = acquisition_staged_url(root.url, manifest); + ASSERT_TRUE(staged.ok()); + EXPECT_TRUE([fake.loader.lastSpecializeURL isEqual:staged.get().path]); + EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); +} + +TEST_F(CoreAIAcquisitionTest, WarmHitRestoresWithoutNamedData) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + NSData* original = saved(); + ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->restores.load(), 1); + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, RestoreErrorFailsClosedWithoutSourceFallback) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + NSData* original = saved(); + const int attempts = data.attempts; + fake.loader.restoreError = [NSError errorWithDomain:NSPOSIXErrorDomain code:EIO userInfo:nil]; + EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(data.attempts.load(), attempts); + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, RestoreMissRespecializesStagedSourceAndReplacesBookmark) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + NSData* original = saved(); + const int attempts = data.attempts; + fake.loader.restoreMiss = YES; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 2); + EXPECT_EQ(data.attempts.load(), attempts); + NSData* replaced = saved(); + EXPECT_NE(replaced, nil); + EXPECT_FALSE([replaced isEqual:original]); +} + +class CoreAIAcquisitionSaveFailureTest : public CoreAIAcquisitionTest, + public ::testing::WithParamInterface {}; +TEST_P(CoreAIAcquisitionSaveFailureTest, PreservesBookmarkAndRetries) { + const bool prior = GetParam(); + BookmarkLoaderScope fake; + NSData* original = nil; + if (prior) { + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + original = saved(); + ASSERT_NE(original, nil); + fake.loader.restoreMiss = YES; + } + { + // A cold acquisition publishes its staged source before the bookmark. + StorageFaultScope fault(StorageOperation::Rename); + fault.match_index = prior ? 1 : 2; + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + } + EXPECT_TRUE(original == nil ? saved() == nil : [saved() isEqual:original]); + EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:fake.loader.lastSpecializeURL]); + const int calls = fake.loader->specializations; + fake.loader.restoreMiss = NO; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), calls + (prior ? 0 : 1)); +} +INSTANTIATE_TEST_SUITE_P(Boundaries, CoreAIAcquisitionSaveFailureTest, ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "PriorBookmark" : "Cold"; + }); + +class CoreAIAcquisitionProcessTest : public CoreAIAcquisitionTest { + protected: + void expect_parallel(bool same_key) { + NSMutableDictionary* first = manifest_dict(); + NSMutableDictionary* second = [first mutableCopy]; + if (!same_key) second[@"bundle_digests"] = @{@"model.aimodel" : fixture_identity('2')}; + BookmarkChild child; + ASSERT_TRUE(spawn_parallel_acquisition(child, root.url, first, second, same_key)); + ASSERT_TRUE(child.receive('C')); + ASSERT_TRUE(child.receive(1)) << "harness completed without timeouts"; + ASSERT_TRUE(child.receive(1)) << "second acquisition blocked or entered as expected"; + ASSERT_TRUE(child.receive(1)) << "both acquisitions succeeded"; + ASSERT_TRUE(child.receive(same_key ? 1 : 2)) << "specializations"; + ASSERT_TRUE(child.receive(same_key ? 1 : 2)) << "named-data attempts"; + ASSERT_TRUE(child.receive(same_key ? 1 : 0)) << "restores"; + ASSERT_TRUE(child.receive(1)) << "no live fake SDK objects"; + ASSERT_TRUE(child.expect_exit(0)); + } +}; + +TEST_F(CoreAIAcquisitionProcessTest, ContendedSameKeySerializesSpecialization) { + expect_parallel(true); +} + +TEST_F(CoreAIAcquisitionProcessTest, IndependentKeysSpecializeInParallel) { + expect_parallel(false); +} + +TEST_F(CoreAIAcquisitionProcessTest, CrashAfterSdkRetainsStagingAndRetries) { + BookmarkChild child; + ASSERT_TRUE(spawn_acquisition_crash(child, root.url, StorageOperation::AfterSDK, manifest_dict())); + ASSERT_TRUE(child.receive('C')); + ASSERT_TRUE(child.expect_exit(73)); + EXPECT_EQ(saved(), nil); + auto staged = acquisition_staged_url(root.url, manifest); + ASSERT_TRUE(staged.ok()); + EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(data.attempts.load(), 0); + EXPECT_NE(saved(), nil); +} + +} // namespace diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.h b/backends/apple/coreai/runtime/test/coreai_fake_loader.h new file mode 100644 index 00000000000..7732fee3507 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -0,0 +1,91 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#import "ETCoreAIBridge.h" + +@class CoreAIFakeBridgeContext; +@class BookmarkFakeLoader; + +namespace executorch::backends::coreai::testing { + +// Configure on the test thread before starting workers; join before changing +// it. +struct FakeBridgeState { + id bookmark_loader = nil; + std::atomic prepared_models{0}; + std::atomic loaders{0}; + // Worker-side contract failures are checked by the scope on the test thread. + std::atomic missing_bundles{0}; +}; + +// Process-visible, not thread-local. Scopes and configuration changes are +// serial. +class ScopedFakeBridgeState final { + public: + ScopedFakeBridgeState(); + ~ScopedFakeBridgeState(); + FakeBridgeState& state(); + // Join caller threads before draining callbacks. + ::testing::AssertionResult wait_for_callbacks(dispatch_time_t deadline); + ScopedFakeBridgeState(const ScopedFakeBridgeState&) = delete; + ScopedFakeBridgeState& operator=(const ScopedFakeBridgeState&) = delete; + + private: + CoreAIFakeBridgeContext* context_; + CoreAIFakeBridgeContext* previous_; +}; + +} // namespace executorch::backends::coreai::testing + +@interface BookmarkFakeModel : NSObject +@property(nonatomic, weak) BookmarkFakeLoader* owner; +@property(nonatomic, copy) NSData* bookmark; +@end + +@interface BookmarkFakeLoader : NSObject { + @public + std::atomic restores; + std::atomic specializations; + std::atomic evictions; +} +@property(nonatomic, strong) + NSMutableDictionary* models; +@property(nonatomic, strong) NSError* restoreError; +@property(nonatomic, strong) NSError* specializeError; +@property(nonatomic, strong) NSError* evictError; +@property(nonatomic) BOOL restoreMiss; +@property(nonatomic) BOOL emptyBookmark; +@property(nonatomic, copy) NSData* specializationBookmark; +@property(nonatomic, copy) void (^onSpecialize)(NSURL*); +@property(nonatomic, copy) void (^onEvict)(NSData*); +@property(nonatomic, strong) BookmarkFakeModel* lastAcquired; +@property(nonatomic, copy) NSString* lastSpecializeURL; +// Call only after workers/callbacks finish and caller-owned handles are +// released. +- (void)clearRetainedState; +@end + +namespace executorch::backends::coreai::testing { + +struct BookmarkLoaderScope final { + BookmarkFakeLoader* loader = nil; + BookmarkLoaderScope(); + ~BookmarkLoaderScope(); + BookmarkLoaderScope(const BookmarkLoaderScope&) = delete; + BookmarkLoaderScope& operator=(const BookmarkLoaderScope&) = delete; + + private: + CoreAIFakeBridgeContext* context_; + id previous_; +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm new file mode 100644 index 00000000000..a997ba361fa --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -0,0 +1,243 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "coreai_fake_loader.h" + +using executorch::backends::coreai::testing::FakeBridgeState; + +// ARC keeps failed asynchronous work from outliving its state storage. +@interface CoreAIFakeBridgeContext : NSObject { + @public + FakeBridgeState state; +} +@property(nonatomic, strong) dispatch_group_t callbacks; +@end + +@implementation CoreAIFakeBridgeContext +- (instancetype)init { + if ((self = [super init])) { + self.callbacks = dispatch_group_create(); + } + return self; +} +@end + +namespace { +CoreAIFakeBridgeContext* current_context = nil; + +NSError* fake_error(ETCoreAIErrorCode code) { + return [NSError errorWithDomain:ETCoreAIErrorDomain code:code userInfo:nil]; +} + +// Release callback captures before publishing that all fake work has drained. +void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { + dispatch_group_t group = context.callbacks; + __block void (^task)() = [work copy]; + dispatch_group_enter(group); + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ + @autoreleasepool { + task(); + task = nil; + } + dispatch_group_leave(group); + }); +} + +::testing::AssertionResult wait_for_callbacks(CoreAIFakeBridgeContext* context, + dispatch_time_t deadline) { + if (dispatch_group_wait(context.callbacks, deadline) != 0) { + return ::testing::AssertionFailure() << "Fake SDK callbacks did not finish"; + } + return ::testing::AssertionSuccess(); +} + +dispatch_time_t cleanup_deadline() { return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); } +} // namespace + +@interface BookmarkFakeModel () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@interface BookmarkFakeLoader () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +@end + +@implementation BookmarkFakeModel +- (instancetype)init { + return [self initWithContext:current_context]; +} +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if (context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = context; + ++context->state.prepared_models; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.prepared_models; + } +} +- (NSData*)copyBookmarkData { + return self.owner.emptyBookmark ? [NSData data] : self.bookmark; +} +- (void)loadFunctionNamed:(NSString*)functionName + inputNames:(NSArray*)inputNames + outputNames:(NSArray*)outputNames + completion:(void (^)(id, NSError*))completion { + // Acquisition tests never bind functions. + completion(nil, fake_error(ETCoreAIErrorUnsupported)); +} +@end + +@implementation BookmarkFakeLoader +- (instancetype)init { + if (current_context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = current_context; + self.models = [NSMutableDictionary dictionary]; + restores = 0; + specializations = 0; + evictions = 0; + ++_context->state.loaders; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.loaders; + } +} +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion { + ++restores; + run_async(self.context, ^{ + @synchronized(self) { + if (self.restoreError != nil) { + completion(ETCoreAIAcquisitionStatusError, nil, self.restoreError); + return; + } + BookmarkFakeModel* model = self.restoreMiss ? nil : self.models[bookmark]; + self.lastAcquired = model; + completion(model == nil ? ETCoreAIAcquisitionStatusMiss : ETCoreAIAcquisitionStatusHit, model, + nil); + } + }); +} +- (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompletion)completion { + ++specializations; + run_async(self.context, ^{ + if (self.onSpecialize != nil) { + self.onSpecialize(url); + } + @synchronized(self) { + self.lastSpecializeURL = url.path; + if (self.specializeError != nil) { + completion(ETCoreAIAcquisitionStatusError, nil, self.specializeError); + return; + } + if (![NSFileManager.defaultManager fileExistsAtPath:url.path]) { + ++self.context->state.missing_bundles; + completion(ETCoreAIAcquisitionStatusError, nil, fake_error(ETCoreAIErrorInvalidModel)); + return; + } + BookmarkFakeModel* model = [[BookmarkFakeModel alloc] initWithContext:self.context]; + model.owner = self; + model.bookmark = self.specializationBookmark != nil + ? self.specializationBookmark + : [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; + self.models[model.bookmark] = model; + self.lastAcquired = model; + completion(ETCoreAIAcquisitionStatusHit, model, nil); + } + }); +} +- (void)evictModelWithBookmark:(NSData*)bookmark completion:(void (^)(NSError*))completion { + ++evictions; + run_async(self.context, ^{ + if (self.onEvict != nil) { + self.onEvict(bookmark); + } + @synchronized(self) { + if (self.evictError == nil) { + [self.models removeObjectForKey:bookmark]; + } + completion(self.evictError); + } + }); +} +- (void)clearRetainedState { + self.onSpecialize = nil; + self.onEvict = nil; + self.lastAcquired = nil; + [self.models removeAllObjects]; +} +@end + +namespace executorch::backends::coreai::testing { + +ScopedFakeBridgeState::ScopedFakeBridgeState() + : context_([[CoreAIFakeBridgeContext alloc] init]), previous_(current_context) { + current_context = context_; +} + +ScopedFakeBridgeState::~ScopedFakeBridgeState() { + auto drained = wait_for_callbacks(cleanup_deadline()); + EXPECT_TRUE(drained); + if (drained) { + id loader = context_->state.bookmark_loader; + if ([loader isKindOfClass:BookmarkFakeLoader.class]) { + [(BookmarkFakeLoader*)loader clearRetainedState]; + } + context_->state.bookmark_loader = nil; + loader = nil; + EXPECT_EQ(context_->state.prepared_models.load(), 0); + EXPECT_EQ(context_->state.loaders.load(), 0); + EXPECT_EQ(context_->state.missing_bundles.load(), 0); + } + EXPECT_EQ(current_context, context_); + current_context = previous_; +} + +FakeBridgeState& ScopedFakeBridgeState::state() { return context_->state; } + +::testing::AssertionResult ScopedFakeBridgeState::wait_for_callbacks(dispatch_time_t deadline) { + return ::wait_for_callbacks(context_, deadline); +} + +BookmarkLoaderScope::BookmarkLoaderScope() + : context_(current_context), + previous_(context_ == nil ? nil : context_->state.bookmark_loader) { + EXPECT_NE(context_, nil) << "BookmarkLoaderScope requires ScopedFakeBridgeState"; + if (context_ != nil) { + loader = [[BookmarkFakeLoader alloc] init]; + context_->state.bookmark_loader = loader; + } +} + +BookmarkLoaderScope::~BookmarkLoaderScope() { + if (context_ == nil) { + return; + } + auto drained = ::wait_for_callbacks(context_, cleanup_deadline()); + EXPECT_TRUE(drained); + if (drained) { + [loader clearRetainedState]; + } + EXPECT_EQ(context_->state.bookmark_loader, loader); + context_->state.bookmark_loader = previous_; + loader = nil; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm index 679321c248b..f388bc5089f 100644 --- a/backends/apple/coreai/runtime/test/coreai_host_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -9,11 +9,18 @@ #import #include #include +#include "coreai_acquisition_fixture.h" #include "coreai_bookmark_fixture.h" int main(int argc, char** argv) { executorch::runtime::runtime_init(); @autoreleasepool { + executorch::backends::coreai::testing::set_acquisition_test_executable( + argv[0]); + const int acquisition_result = + executorch::backends::coreai::testing::acquisition_child_mode(argc, argv); + if (acquisition_result >= 0) + return acquisition_result; executorch::backends::coreai::testing::set_bookmark_test_executable( argv[0]); const int child_result = From 34e435432aaea2eb24af59c9e0b90e48fdc56eaa Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Mon, 26 Oct 2026 17:06:55 -0700 Subject: [PATCH 2/6] [CoreAI] Remove staged sources after durable bookmark publication Summary: Avoid retaining redundant staged model bundles once persistent specialization and durable bookmark publication succeed. Preserve the bookmark and return cleanup failures without automatic retries or eviction, keeping warm restoration independent of staging. Tighten acquisition error coverage and test-fixture behavior. Test Plan: All three Core AI CTest suites passed on macOS 27. The source-lifetime regression verified portable and AOT model inference after source deletion, bookmark restoration in fresh processes, and a restore miss after explicit eviction. Native tests covered publication ordering, unsafe staging entries, and cleanup failures preserving durable bookmarks. The full Core AI Python suite passed: 113 tests. --- backends/apple/coreai/CMakeLists.txt | 22 + backends/apple/coreai/README.md | 30 +- .../coreai/runtime/coreai_load_coordinator.mm | 2 +- .../runtime/test/SDKSourceLifetimeTest.swift | 113 +++++ .../runtime/test/coreai_acquisition_fixture.h | 5 +- .../test/coreai_acquisition_fixture.mm | 6 +- .../runtime/test/coreai_acquisition_test.mm | 397 +++++++++++++++++- .../coreai/runtime/test/coreai_fake_loader.h | 3 - .../coreai/runtime/test/coreai_fake_loader.mm | 15 +- .../coreai/runtime/test/coreai_host_test.mm | 4 +- .../runtime/test/export_bridge_fixture.py | 59 ++- .../runtime/test/test_sdk_source_lifetime.py | 63 +++ 12 files changed, 672 insertions(+), 47 deletions(-) create mode 100644 backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift create mode 100644 backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 82e6194b60d..6f25d75928d 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -346,6 +346,28 @@ if(EXECUTORCH_BUILD_TESTS PROPERTIES TIMEOUT 600 ENVIRONMENT "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" ) + add_executable( + coreai_sdk_source_lifetime_test runtime/test/SDKSourceLifetimeTest.swift + ) + set_target_properties( + coreai_sdk_source_lifetime_test PROPERTIES Swift_LANGUAGE_VERSION 6 + ) + coreai_require_os27(coreai_sdk_source_lifetime_test) + target_compile_options( + coreai_sdk_source_lifetime_test + PRIVATE "$<$:-parse-as-library>" + ) + target_link_libraries( + coreai_sdk_source_lifetime_test PRIVATE executorch::coreai_dependencies + ) + add_test( + NAME coreai_sdk_source_lifetime_test + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/test_sdk_source_lifetime.py" + "$" + ) + set_tests_properties(coreai_sdk_source_lifetime_test PROPERTIES TIMEOUT 240) endif() endif() diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index be45e247140..a253d7f3fcf 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -146,11 +146,21 @@ There is no PTE-wide bookmark list or serialized lifecycle record. A healthy bookmark hit does not inspect staging or consult NDS; the acquired SDK model is retained through function binding. On a missing bookmark or confirmed SDK miss, the runtime materializes the source, specializes it and atomically -writes the returned bookmark bytes. There is no secondary source-URL cache -lookup. SDK errors, unreadable bookmarks and publication failures fail loading -without automatic cache deletion. If publication fails after specialization, the -SDK entry and source remain; the next load retries the ordinary flow. The -backend does not track historical or unrecorded SDK entries. +writes the returned bookmark bytes. After durable publication, it removes the +entire keyed staging directory while holding the per-key lock. Persistent SDK +caching supports inference and bookmark restoration without that extracted copy. +Keep the packaged named data available: a later SDK cache miss requires source +materialization again. There is no secondary source-URL cache lookup. + +SDK errors, unreadable bookmarks and publication failures fail loading without +automatic cache deletion. If publication fails after specialization, the SDK +entry and source remain. If staged-source cleanup fails, loading reports the +error but retains the durable bookmark and any remaining staged files. Neither +failure triggers automatic retries, SDK eviction or source repair. A subsequent +bookmark hit can load without inspecting or retrying staging cleanup. A partial +cleanup followed by an SDK miss fails on the damaged staged entry until it is +explicitly cleared. The backend does not track historical or unrecorded SDK +entries. Raw bookmarks have an 8 MiB backend allocation limit, not an asserted SDK format maximum, and are published atomically because losing one orphans an SDK entry. @@ -197,6 +207,8 @@ types and substitute fake entry points. `EXECUTORCH_BUILD_TESTS=ON`. It links CoreAI and is registered with CTest only when the macOS host meets the configured Core AI deployment target. The test build exports static- and dynamic-shape models for real session tests. +The SDK source-lifetime regression checks live inference and fresh-process +bookmark restoration after removing disposable portable and AOT source copies. CMake's `PYTHON_EXECUTABLE` must use the Python environment with the pinned Core AI requirements above. Backend Python changes regenerate the dedicated fixture directory, replacing previous contents. CTest supplies its location to XCTest; @@ -218,9 +230,11 @@ covers arm64 macOS and iOS device builds. x86_64 builds are blocked by Swift On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the standalone `coreai_host_test` executable with CTest. It tests -manifest parsing, AOT asset selection, cache identity, bookmark I/O, lock -contention, and filesystem storage using Foundation, without Swift or CoreAI SDK -calls. The host test target is omitted for Apple cross-builds. +manifest parsing, AOT asset selection, cache identity, bookmark I/O, acquisition +coordination, lock contention, and filesystem storage using Foundation, without +Swift or CoreAI SDK calls. Acquisition contention tests run two threads inside a +bounded child process; bookmark lock tests separately cover contention between +processes. The host test target is omitted for Apple cross-builds. CI builds and runs these tests on macOS 27. To use the same configuration from the repository root: diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index a34e0c50ba9..9ad967ccc0b 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -89,7 +89,7 @@ Error sdk_error(NSError* error) { "Core AI specialization returned no model"); ET_CHECK_OK_OR_RETURN_ERROR( write_bookmark(*locked.get(), [model.get() copyBookmarkData])); - // SDK pins do not prove source independence; leave staged sources in place. + ET_CHECK_OK_OR_RETURN_ERROR(remove_bookmark_staging(*locked.get())); return model; } diff --git a/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift new file mode 100644 index 00000000000..8691b47591b --- /dev/null +++ b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift @@ -0,0 +1,113 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import CoreAI +import Foundation + +private struct Fixture: Decodable { + let function: String + let inputs: [String] + let outputs: [String] + let expected: [[Float]] +} + +private enum Failure: Error { + case invalidArguments + case missingFunction + case incorrectOutput(String) + case unexpectedSource +} + +private func checkInference(_ model: AIModel, fixture: Fixture) async throws { + guard let function = try model.loadFunction(named: fixture.function) else { + throw Failure.missingFunction + } + guard fixture.inputs.count == 2, fixture.outputs.count == fixture.expected.count else { + throw Failure.invalidArguments + } + var inputs: [String: NDArray] = [:] + for (index, values) in [[Float(5), 9], [Float(1), 2]].enumerated() { + var array = NDArray(shape: [2], scalarType: .float32) + do { + var view = array.mutableView(as: Float.self) + view.withUnsafeMutablePointer { pointer, _, _ in + pointer[0] = values[0] + pointer[1] = values[1] + } + } + inputs[fixture.inputs[index]] = array + } + var outputs = try await function.run(inputs: inputs) + guard outputs.count == fixture.outputs.count else { + throw Failure.incorrectOutput("count") + } + for (index, name) in fixture.outputs.enumerated() { + guard let value = outputs.remove(name), let array = value.ndArray, + array.shape == [2], array.scalarType == .float32, + fixture.expected[index].count == 2 + else { throw Failure.incorrectOutput(name) } + let values = array.view(as: Float.self).withUnsafePointer { pointer, _, strides in + [pointer[0], pointer[strides[0]]] + } + guard zip(values, fixture.expected[index]).allSatisfy({ abs($0 - $1) < 0.001 }) else { + throw Failure.incorrectOutput("\(name): \(values), expected \(fixture.expected[index])") + } + } +} + +@main +private struct SDKSourceLifetimeTest { + static func main() async { + do { + let args = CommandLine.arguments + if args.count == 2, args[1] == "architecture" { + print(AIModel.deviceArchitectureName) + return + } + guard args.count == 5 else { throw Failure.invalidArguments } + let mode = args[1] + let source = URL(fileURLWithPath: args[2]).standardizedFileURL + let fixtureURL = URL(fileURLWithPath: args[3]).standardizedFileURL + let bookmarkURL = URL(fileURLWithPath: args[4]) + let directory = source.deletingLastPathComponent() + guard ["model.aimodel", "model.aimodelc"].contains(source.lastPathComponent), + directory == fixtureURL.deletingLastPathComponent(), + directory.deletingLastPathComponent().lastPathComponent.hasPrefix("coreai-lifetime-") + else { throw Failure.invalidArguments } + if mode == "evict" { + try AIModelCache.deleteEntry(referencedBy: Data(contentsOf: bookmarkURL)) + return + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: fixtureURL)) + if mode == "specialize" { + let model = try await AIModel.specialize( + contentsOf: source, options: .default, cache: .default, cachePolicy: .persistent) + try model.bookmarkData.write(to: bookmarkURL, options: .atomic) + try await checkInference(model, fixture: fixture) + try FileManager.default.removeItem(at: source) + guard !FileManager.default.fileExists(atPath: source.path) else { + throw Failure.unexpectedSource + } + try await checkInference(model, fixture: fixture) + print("Live inference passed after source removal") + } else if mode == "restore" { + guard !FileManager.default.fileExists(atPath: source.path) else { + throw Failure.unexpectedSource + } + guard let model = try AIModel(resolvingBookmark: Data(contentsOf: bookmarkURL)) else { + exit(3) + } + try await checkInference(model, fixture: fixture) + print("Bookmark restoration and inference passed without source") + } else { + throw Failure.invalidArguments + } + } catch { + print("SDK source-lifetime test failed: \(error)") + exit(1) + } + } +} diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h index 743102fac99..963adfd784a 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h @@ -41,8 +41,6 @@ ParallelAcquisitionObservation parallel_acquisitions( TestData& data, bool same_key); -// Uses the existing bounded, scoped child process owner. -void set_acquisition_test_executable(const char* path); ::testing::AssertionResult spawn_acquisition_crash( BookmarkChild& child, NSURL* root, @@ -54,8 +52,7 @@ ::testing::AssertionResult spawn_parallel_acquisition( NSDictionary* first, NSDictionary* second, bool same_key); -// Dispatch before bookmark_child_mode and InitGoogleTest. Unrelated modes -// return -1. +// Dispatch before InitGoogleTest. Unrelated modes return -1. int acquisition_child_mode(int argc, char** argv); } // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm index 154fd7e2ae4..a0af1340b76 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -152,8 +152,6 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid return observation; } -void set_acquisition_test_executable(const char* path) { set_bookmark_test_executable(path); } - ::testing::AssertionResult spawn_acquisition_crash(BookmarkChild& child, NSURL* root, StorageOperation boundary, NSDictionary* manifest) { @@ -161,7 +159,7 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid NSData* payload = encode(@{@"boundary" : @(static_cast(boundary)), @"manifest" : manifest}); if (payload == nil) return ::testing::AssertionFailure() << "Cannot encode crash child input"; NSString* argument = [[NSString alloc] initWithData:payload encoding:NSUTF8StringEncoding]; - return child.spawn(@"--bookmark-crash", root.path, argument); + return child.spawn(@"--acquisition-crash", root.path, argument); } ::testing::AssertionResult spawn_parallel_acquisition(BookmarkChild& child, NSURL* root, @@ -217,7 +215,7 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid int acquisition_child_mode(int argc, char** argv) { if (argc < 2) return -1; - const bool crash = std::strcmp(argv[1], "--bookmark-crash") == 0; + const bool crash = std::strcmp(argv[1], "--acquisition-crash") == 0; const bool parallel = std::strcmp(argv[1], "--acquisition-parallel") == 0; if (!crash && !parallel) return -1; if (argc != 5) return 1; diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index 6c1a91eea02..af80260827e 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -8,14 +8,35 @@ #include "coreai_acquisition_fixture.h" +#include #include +#include #include "coreai_fault_scope.h" +#include "coreai_filesystem_fixture.h" #include "coreai_load_coordinator.h" using namespace executorch::runtime; using namespace executorch::backends::coreai; using namespace executorch::backends::coreai::testing; +@interface InvalidAcquisitionLoader : BookmarkFakeLoader +@property(nonatomic) ETCoreAIAcquisitionStatus restoreStatus; +@property(nonatomic, strong) id restoreModel; +@end + +@implementation InvalidAcquisitionLoader +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion { + ++restores; + completion(self.restoreStatus, self.restoreModel, nil); +} +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion { + ++specializations; + completion(ETCoreAIAcquisitionStatusMiss, nil, nil); +} +@end + namespace { class CoreAIAcquisitionTest : public ::testing::Test { @@ -51,6 +72,28 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { EXPECT_TRUE(bookmark.ok()); return bookmark.ok() ? bookmark.get() : nil; } + + NSURL* staging_key_url() { + return [[[root.url URLByAppendingPathComponent:@"staging"] + URLByAppendingPathComponent:key] copy]; + } + + BOOL staging_key_exists() { + return + [NSFileManager.defaultManager fileExistsAtPath:staging_key_url().path]; + } + + NSURL* lock_url() { + return [[root.url URLByAppendingPathComponent:@"locks"] + URLByAppendingPathComponent:[key stringByAppendingString:@".lock"]]; + } + + ino_t lock_inode() { + struct stat info; + if (lstat(lock_url().fileSystemRepresentation, &info) != 0) + return 0; + return info.st_ino; + } }; TEST_F(CoreAIAcquisitionTest, ColdSpecializesStagedSourceAndPersistsBookmark) { @@ -63,16 +106,23 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { ASSERT_TRUE(staged.ok()); EXPECT_TRUE([fake.loader.lastSpecializeURL isEqual:staged.get().path]); EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); + EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); } TEST_F(CoreAIAcquisitionTest, WarmHitRestoresWithoutNamedData) { BookmarkLoaderScope fake; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); NSData* original = saved(); + EXPECT_FALSE(staging_key_exists()); + const int attempts = data.attempts.load(); ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), 1); EXPECT_EQ(fake.loader->restores.load(), 1); + EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:original]); + EXPECT_FALSE(staging_key_exists()); } TEST_F(CoreAIAcquisitionTest, RestoreErrorFailsClosedWithoutSourceFallback) { @@ -83,6 +133,7 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { fake.loader.restoreError = [NSError errorWithDomain:NSPOSIXErrorDomain code:EIO userInfo:nil]; EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:original]); } @@ -95,10 +146,345 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { fake.loader.restoreMiss = YES; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), 2); - EXPECT_EQ(data.attempts.load(), attempts); + EXPECT_EQ(data.attempts.load(), attempts + 1); NSData* replaced = saved(); EXPECT_NE(replaced, nil); EXPECT_FALSE([replaced isEqual:original]); + EXPECT_FALSE(staging_key_exists()); +} + +TEST_F( + CoreAIAcquisitionTest, + SpecializationErrorFailsClosedPreservingBookmark) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + NSData* original = saved(); + ASSERT_NE(original, nil); + const int attempts = data.attempts.load(); + const int specializations = fake.loader->specializations.load(); + fake.loader.restoreMiss = YES; + fake.loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); + EXPECT_EQ(fake.loader->specializations.load(), specializations + 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(data.attempts.load(), attempts + 1); + EXPECT_TRUE([saved() isEqual:original]); + EXPECT_TRUE([NSFileManager.defaultManager + fileExistsAtPath:fake.loader.lastSpecializeURL]); +} + +TEST_F(CoreAIAcquisitionTest, EmptyBookmarkFailsClosedPreservingPrior) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + NSData* original = saved(); + ASSERT_NE(original, nil); + const int specializations = fake.loader->specializations.load(); + fake.loader.restoreMiss = YES; + fake.loader.emptyBookmark = YES; + EXPECT_EQ(acquire(fake.loader, &data), Error::InvalidExternalData); + EXPECT_EQ(fake.loader->specializations.load(), specializations + 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_TRUE([saved() isEqual:original]); + EXPECT_TRUE([NSFileManager.defaultManager + fileExistsAtPath:fake.loader.lastSpecializeURL]); +} + +TEST_F(CoreAIAcquisitionTest, NilLoaderRejectsWithoutFilesystemEffects) { + NSDictionary* before = nil; + ASSERT_TRUE(asset_tree_snapshot(root.url, before)); + Error error = Error::Ok; + @autoreleasepool { + auto result = acquire_bookmark_model( + manifest, &data, root.url.path, @"macOS", @"arch_b", nil); + ASSERT_FALSE(result.ok()); + error = result.error(); + } + EXPECT_EQ(error, Error::InvalidArgument); + EXPECT_TRUE(snapshot_matches(root.url, before)); + EXPECT_EQ(saved(), nil); + EXPECT_FALSE(staging_key_exists()); +} + +TEST_F(CoreAIAcquisitionTest, RestoreHitWithoutModelFailsClosed) { + NSData* original = nil; + { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + original = saved(); + ASSERT_NE(original, nil); + } + InvalidAcquisitionLoader* loader = [[InvalidAcquisitionLoader alloc] init]; + ASSERT_NE(loader, nil); + loader.restoreStatus = ETCoreAIAcquisitionStatusHit; + Error error = Error::Ok; + @autoreleasepool { + auto result = acquire_bookmark_model( + manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + ASSERT_FALSE(result.ok()); + error = result.error(); + } + EXPECT_EQ(error, Error::Internal); + loader = nil; + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, RestoreMissWithModelFailsClosed) { + NSData* original = nil; + { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + original = saved(); + ASSERT_NE(original, nil); + } + InvalidAcquisitionLoader* loader = [[InvalidAcquisitionLoader alloc] init]; + ASSERT_NE(loader, nil); + BookmarkFakeModel* model = [[BookmarkFakeModel alloc] init]; + ASSERT_NE(model, nil); + loader.restoreStatus = ETCoreAIAcquisitionStatusMiss; + loader.restoreModel = model; + Error error = Error::Ok; + @autoreleasepool { + auto result = acquire_bookmark_model( + manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + ASSERT_FALSE(result.ok()); + error = result.error(); + } + EXPECT_EQ(error, Error::Internal); + loader.restoreModel = nil; + loader = nil; + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, RestoreErrorWithoutErrorFailsClosed) { + NSData* original = nil; + { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + original = saved(); + ASSERT_NE(original, nil); + } + InvalidAcquisitionLoader* loader = [[InvalidAcquisitionLoader alloc] init]; + ASSERT_NE(loader, nil); + loader.restoreStatus = ETCoreAIAcquisitionStatusError; + Error error = Error::Ok; + @autoreleasepool { + auto result = acquire_bookmark_model( + manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + ASSERT_FALSE(result.ok()); + error = result.error(); + } + EXPECT_EQ(error, Error::Internal); + loader = nil; + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, SpecializeMissFailsClosed) { + InvalidAcquisitionLoader* loader = [[InvalidAcquisitionLoader alloc] init]; + ASSERT_NE(loader, nil); + Error error = Error::Ok; + @autoreleasepool { + auto result = acquire_bookmark_model( + manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + ASSERT_FALSE(result.ok()); + error = result.error(); + } + EXPECT_EQ(error, Error::Internal); + EXPECT_EQ(loader->specializations.load(), 1); + EXPECT_EQ(loader->restores.load(), 0); + loader = nil; + EXPECT_EQ(saved(), nil); +} + +TEST_F( + CoreAIAcquisitionTest, + ColdSuccessRemovesWholeStagingAfterBookmarkFlush) { + BookmarkLoaderScope fake; + __block std::vector ops; + Error error = Error::Internal; + { + StorageFaultScope fault(StorageOperation::Remove); + fault.armed = false; + fault.observe = ^(StorageOperation operation) { + ops.push_back(operation); + }; + error = acquire(fake.loader, &data); + } + ASSERT_EQ(error, Error::Ok); + EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); + EXPECT_FALSE(staging_key_exists()); + auto staged = acquisition_staged_url(root.url, manifest); + ASSERT_TRUE(staged.ok()); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); + size_t last_flush = 0; + size_t removes = 0; + size_t first_remove = ops.size(); + for (size_t i = 0; i < ops.size(); ++i) { + if (ops[i] == StorageOperation::FullSync) + last_flush = i; + if (ops[i] == StorageOperation::Remove) { + ++removes; + if (first_remove == ops.size()) + first_remove = i; + } + } + EXPECT_EQ(removes, 3u); + EXPECT_GT(first_remove, last_flush); +} + +TEST_F(CoreAIAcquisitionTest, SpecializeErrorPerformsNoCleanup) { + BookmarkLoaderScope fake; + __block std::vector ops; + StorageFaultScope fault(StorageOperation::Remove); + fault.armed = false; + fault.observe = ^(StorageOperation operation) { + ops.push_back(operation); + }; + fake.loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); + size_t removes = 0; + for (auto operation : ops) { + if (operation == StorageOperation::Remove) + ++removes; + } + EXPECT_EQ(removes, 0u); + EXPECT_EQ(saved(), nil); + EXPECT_EQ(fake.loader->evictions.load(), 0); +} + +TEST_F(CoreAIAcquisitionTest, BookmarkFinalFlushFailurePerformsNoCleanup) { + BookmarkLoaderScope fake; + __block std::vector ops; + Error error = Error::Internal; + { + // Cold source contributes two FullSyncs and the bookmark two more, so + // index four is the post-rename bookmark flush. + StorageFaultScope fault(StorageOperation::FullSync); + fault.match_index = 4; + fault.observe = ^(StorageOperation operation) { + ops.push_back(operation); + }; + error = acquire(fake.loader, &data); + } + EXPECT_EQ(error, Error::AccessFailed); + EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); + size_t removes = 0; + for (auto operation : ops) { + if (operation == StorageOperation::Remove) + ++removes; + } + EXPECT_EQ(removes, 0u); + auto staged = acquisition_staged_url(root.url, manifest); + ASSERT_TRUE(staged.ok()); + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); +} + +TEST_F( + CoreAIAcquisitionTest, + CleanupFirstRemoveFailurePreservesDurableBookmark) { + BookmarkLoaderScope fake; + NSData* bookmark = nil; + int specializations = 0; + { + StorageFaultScope fault(StorageOperation::Remove); + fault.match_index = 1; + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + bookmark = saved(); + ASSERT_NE(bookmark, nil); + EXPECT_TRUE([bookmark isEqual:fake.loader.lastAcquired.bookmark]); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(fake.loader->evictions.load(), 0); + specializations = fake.loader->specializations.load(); + } + EXPECT_EQ(fake.loader->specializations.load(), specializations); + const int attempts = data.attempts.load(); + const int restores = fake.loader->restores.load(); + EXPECT_TRUE(staging_key_exists()); + ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), specializations); + EXPECT_EQ(fake.loader->restores.load(), restores + 1); + EXPECT_EQ(data.attempts.load(), attempts); + EXPECT_TRUE([saved() isEqual:bookmark]); + EXPECT_TRUE(staging_key_exists()); +} + +TEST_F( + CoreAIAcquisitionTest, + CleanupPartialRemoveFailurePreservesDurableBookmark) { + BookmarkLoaderScope fake; + NSData* bookmark = nil; + int specializations = 0; + { + StorageFaultScope fault(StorageOperation::Remove); + fault.match_index = 2; + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + bookmark = saved(); + ASSERT_NE(bookmark, nil); + EXPECT_TRUE([bookmark isEqual:fake.loader.lastAcquired.bookmark]); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + specializations = fake.loader->specializations.load(); + } + EXPECT_EQ(fake.loader->specializations.load(), specializations); + const int attempts = data.attempts.load(); + const int restores = fake.loader->restores.load(); + EXPECT_TRUE(staging_key_exists()); + ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), specializations); + EXPECT_EQ(fake.loader->restores.load(), restores + 1); + EXPECT_EQ(data.attempts.load(), attempts); + EXPECT_TRUE([saved() isEqual:bookmark]); + EXPECT_TRUE(staging_key_exists()); +} + +TEST_F( + CoreAIAcquisitionTest, + CleanupDirectorySyncFailurePreservesDurableBookmark) { + BookmarkLoaderScope fake; + NSData* bookmark = nil; + int specializations = 0; + { + StorageFaultScope scope(StorageOperation::SyncDirectory); + scope.armed = false; + StorageFaultScope* arming = &scope; + scope.observe = ^(StorageOperation operation) { + if (operation == StorageOperation::Remove) + arming->armed = true; + }; + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(scope.hits, 1); + bookmark = saved(); + ASSERT_NE(bookmark, nil); + EXPECT_TRUE([bookmark isEqual:fake.loader.lastAcquired.bookmark]); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + specializations = fake.loader->specializations.load(); + } + const int attempts = data.attempts.load(); + const int restores = fake.loader->restores.load(); + EXPECT_TRUE(staging_key_exists()); + ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), specializations); + EXPECT_EQ(fake.loader->restores.load(), restores + 1); + EXPECT_EQ(data.attempts.load(), attempts); + EXPECT_TRUE([saved() isEqual:bookmark]); +} + +TEST_F(CoreAIAcquisitionTest, KeyLockIdentitySurvivesSuccessfulAcquisition) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + ino_t first = lock_inode(); + ASSERT_NE(first, 0u); + ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(lock_inode(), first); } class CoreAIAcquisitionSaveFailureTest : public CoreAIAcquisitionTest, @@ -111,12 +497,15 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); original = saved(); ASSERT_NE(original, nil); + EXPECT_FALSE(staging_key_exists()); fake.loader.restoreMiss = YES; } { - // A cold acquisition publishes its staged source before the bookmark. + // Cold and rematerialized sources publish before the bookmark, so the + // bookmark rename is the second rename once the first success has + // cleaned the source. StorageFaultScope fault(StorageOperation::Rename); - fault.match_index = prior ? 1 : 2; + fault.match_index = 2; EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); EXPECT_EQ(fault.hits, 1); } @@ -126,6 +515,7 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { fake.loader.restoreMiss = NO; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), calls + (prior ? 0 : 1)); + EXPECT_EQ(staging_key_exists(), prior); } INSTANTIATE_TEST_SUITE_P(Boundaries, CoreAIAcquisitionSaveFailureTest, ::testing::Bool(), [](const ::testing::TestParamInfo& info) { @@ -175,6 +565,7 @@ void expect_parallel(bool same_key) { EXPECT_EQ(fake.loader->restores.load(), 0); EXPECT_EQ(data.attempts.load(), 0); EXPECT_NE(saved(), nil); + EXPECT_FALSE(staging_key_exists()); } } // namespace diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.h b/backends/apple/coreai/runtime/test/coreai_fake_loader.h index 7732fee3507..cf8f70a6a6b 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.h +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -61,12 +61,9 @@ class ScopedFakeBridgeState final { NSMutableDictionary* models; @property(nonatomic, strong) NSError* restoreError; @property(nonatomic, strong) NSError* specializeError; -@property(nonatomic, strong) NSError* evictError; @property(nonatomic) BOOL restoreMiss; @property(nonatomic) BOOL emptyBookmark; -@property(nonatomic, copy) NSData* specializationBookmark; @property(nonatomic, copy) void (^onSpecialize)(NSURL*); -@property(nonatomic, copy) void (^onEvict)(NSData*); @property(nonatomic, strong) BookmarkFakeModel* lastAcquired; @property(nonatomic, copy) NSString* lastSpecializeURL; // Call only after workers/callbacks finish and caller-owned handles are diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index a997ba361fa..54a14287d94 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -154,9 +154,7 @@ - (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompleti } BookmarkFakeModel* model = [[BookmarkFakeModel alloc] initWithContext:self.context]; model.owner = self; - model.bookmark = self.specializationBookmark != nil - ? self.specializationBookmark - : [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; + model.bookmark = [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; self.models[model.bookmark] = model; self.lastAcquired = model; completion(ETCoreAIAcquisitionStatusHit, model, nil); @@ -166,20 +164,11 @@ - (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompleti - (void)evictModelWithBookmark:(NSData*)bookmark completion:(void (^)(NSError*))completion { ++evictions; run_async(self.context, ^{ - if (self.onEvict != nil) { - self.onEvict(bookmark); - } - @synchronized(self) { - if (self.evictError == nil) { - [self.models removeObjectForKey:bookmark]; - } - completion(self.evictError); - } + completion(fake_error(ETCoreAIErrorUnsupported)); }); } - (void)clearRetainedState { self.onSpecialize = nil; - self.onEvict = nil; self.lastAcquired = nil; [self.models removeAllObjects]; } diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm index f388bc5089f..1acfce4320e 100644 --- a/backends/apple/coreai/runtime/test/coreai_host_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -15,14 +15,12 @@ int main(int argc, char** argv) { executorch::runtime::runtime_init(); @autoreleasepool { - executorch::backends::coreai::testing::set_acquisition_test_executable( + executorch::backends::coreai::testing::set_bookmark_test_executable( argv[0]); const int acquisition_result = executorch::backends::coreai::testing::acquisition_child_mode(argc, argv); if (acquisition_result >= 0) return acquisition_result; - executorch::backends::coreai::testing::set_bookmark_test_executable( - argv[0]); const int child_result = executorch::backends::coreai::testing::bookmark_child_mode(argc, argv); if (child_result >= 0) diff --git a/backends/apple/coreai/runtime/test/export_bridge_fixture.py b/backends/apple/coreai/runtime/test/export_bridge_fixture.py index 13709de4716..bd2f0f3cdc0 100644 --- a/backends/apple/coreai/runtime/test/export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -7,6 +7,7 @@ import argparse import json import shutil +import uuid from pathlib import Path import torch @@ -28,14 +29,24 @@ def forward(self, left, right): return left + right, left - right -def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: - """Export into a dedicated fixture directory, replacing its contents.""" - size = torch.export.Dim("size", min=1, max=8) - exported = torch.export.export( - Arithmetic().eval(), - (torch.ones(2), torch.ones(2)), - dynamic_shapes=({0: size}, {0: size}) if dynamic else None, - ) +class WeightedArithmetic(torch.nn.Module): + def __init__(self, first: float, second: float): + super().__init__() + self.first = first + self.second = second + self.network = torch.nn.Sequential( + torch.nn.Linear(2, 4096), torch.nn.ReLU(), torch.nn.Linear(4096, 2) + ) + + def forward(self, left, right): + return ( + self.network(left + right) + self.first, + self.network(left - right) + self.second, + ) + + +def _export_model(model, inputs, output_dir: Path, *, dynamic_shapes=None) -> dict: + exported = torch.export.export(model, inputs, dynamic_shapes=dynamic_shapes) lowered = to_edge_transform_and_lower( exported, transform_passes=get_default_passes(), @@ -56,6 +67,38 @@ def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(store.buffers[entry.buffer_index]) (output_dir / "manifest.json").write_bytes(manifest_bytes) + return manifest + + +def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: + """Export into a dedicated fixture directory, replacing its contents.""" + size = torch.export.Dim("size", min=1, max=8) + _export_model( + Arithmetic().eval(), (torch.ones(2), torch.ones(2)), output_dir, + dynamic_shapes=({0: size}, {0: size}) if dynamic else None, + ) + + +def export_weighted_fixture(output_dir: Path) -> None: + # Unique model contents keep cleanup from evicting another test's SDK entry. + seed = uuid.uuid4().int + model = WeightedArithmetic( + (seed & 0xFFFFFF) / 2**24, ((seed >> 24) & 0xFFFFFF) / 2**24 + ).eval() + inputs = (torch.tensor([5.0, 9.0]), torch.tensor([1.0, 2.0])) + manifest = _export_model(model, inputs, output_dir) + with torch.no_grad(): + expected = [tensor.tolist() for tensor in model(*inputs)] + (output_dir / "fixture.json").write_text( + json.dumps( + { + "function": manifest["function"], + "inputs": manifest["input_names"], + "outputs": manifest["output_names"], + "expected": expected, + } + ) + ) if __name__ == "__main__": diff --git a/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py b/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py new file mode 100644 index 00000000000..4ef03012e84 --- /dev/null +++ b/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py @@ -0,0 +1,63 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(command, expected_status=0): + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + if result.returncode != expected_status: + raise RuntimeError( + f"{command} returned {result.returncode}, expected {expected_status}\n" + f"{result.stdout}\n{result.stderr}" + ) + return result.stdout.strip() + + +def main(probe: Path) -> None: + from export_bridge_fixture import export_weighted_fixture + + architecture = run([str(probe), "architecture"]) + with tempfile.TemporaryDirectory(prefix="coreai-lifetime-") as temporary: + root = Path(temporary) + for kind in ("portable", "compiled"): + directory = root / kind + export_weighted_fixture(directory) + source = directory / "model.aimodel" + if kind == "compiled": + compiled = directory / "model.aimodelc" + run( + [ + "xcrun", "coreai-build", "compile", str(source), + "--output", str(compiled), "--platform", "macOS", + "--architecture", architecture, "--min-deployment-version", "27.0", + ] + ) + shutil.rmtree(source) + source = compiled + bookmark = directory / "saved.bookmark" + arguments = [str(source), str(directory / "fixture.json"), str(bookmark)] + try: + print(run([str(probe), "specialize", *arguments]), flush=True) + if source.exists() or not bookmark.is_file(): + raise AssertionError("Expected a saved bookmark and no original source") + for _ in range(2): + print(run([str(probe), "restore", *arguments]), flush=True) + finally: + if bookmark.is_file(): + run([str(probe), "evict", *arguments]) + run([str(probe), "restore", *arguments], expected_status=3) + print(f"{kind}: persistent cache lifetime and eviction control passed", flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("probe", type=Path) + main(parser.parse_args().probe.resolve()) From 735d5bf2c4f6d5e4343c2f657629006e0ee1d6b3 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Tue, 27 Oct 2026 13:42:57 -0700 Subject: [PATCH 3/6] [CoreAI] Recover interrupted source cleanup and harden lifetime tests Rebuild damaged staged source under the key lock after a cache miss. Treat cleanup after successful restoration or durable publication as best-effort. Exercise the production bridge in lifetime probes and cover cleanup, interruption, and crash boundaries. Test Plan: CMake build and CTest passed (134 native tests, 18 Swift tests, portable and AOT lifetime probes). Full Python suite: 121 passed; updated driver tests: 7 passed. Seven recovery/crash/contention tests passed ten repetitions. Formatters, Ruff, and diff whitespace checks passed. Generic Buck owner validation failed without diagnostics. --- backends/apple/coreai/CMakeLists.txt | 10 +- backends/apple/coreai/README.md | 52 ++-- .../coreai/runtime/coreai_load_coordinator.h | 1 + .../coreai/runtime/coreai_load_coordinator.mm | 111 ++++--- .../runtime/test/SDKSourceLifetimeTest.swift | 134 ++++++-- .../test/coreai_acquisition_fixture.mm | 285 ++++++++++++------ .../runtime/test/coreai_acquisition_test.mm | 161 ++++++++-- .../coreai/runtime/test/coreai_fake_loader.mm | 50 ++- .../coreai/runtime/test/coreai_host_test.mm | 3 +- .../runtime/test/export_bridge_fixture.py | 9 +- .../runtime/test/run_sdk_source_lifetime.py | 114 +++++++ .../test/test_run_sdk_source_lifetime.py | 97 ++++++ .../runtime/test/test_sdk_source_lifetime.py | 63 ---- 13 files changed, 788 insertions(+), 302 deletions(-) create mode 100644 backends/apple/coreai/runtime/test/run_sdk_source_lifetime.py create mode 100644 backends/apple/coreai/runtime/test/test_run_sdk_source_lifetime.py delete mode 100644 backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 6f25d75928d..5f42d880a79 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -347,15 +347,19 @@ if(EXECUTORCH_BUILD_TESTS "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" ) add_executable( - coreai_sdk_source_lifetime_test runtime/test/SDKSourceLifetimeTest.swift + coreai_sdk_source_lifetime_test + runtime/test/SDKSourceLifetimeTest.swift runtime/ETCoreAIModel.swift + runtime/ETCoreAITensor.mm ) set_target_properties( coreai_sdk_source_lifetime_test PROPERTIES Swift_LANGUAGE_VERSION 6 ) + coreai_configure_objc_target(coreai_sdk_source_lifetime_test) coreai_require_os27(coreai_sdk_source_lifetime_test) target_compile_options( coreai_sdk_source_lifetime_test - PRIVATE "$<$:-parse-as-library>" + PRIVATE + "$<$:-parse-as-library;-import-objc-header;${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h>" ) target_link_libraries( coreai_sdk_source_lifetime_test PRIVATE executorch::coreai_dependencies @@ -364,7 +368,7 @@ if(EXECUTORCH_BUILD_TESTS NAME coreai_sdk_source_lifetime_test COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/test_sdk_source_lifetime.py" + "${CMAKE_CURRENT_SOURCE_DIR}/runtime/test/run_sdk_source_lifetime.py" "$" ) set_tests_properties(coreai_sdk_source_lifetime_test PROPERTIES TIMEOUT 240) diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index a253d7f3fcf..5b3f24edd62 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -62,10 +62,10 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. -Both formats can require device specialization. Every load first attempts SDK -bookmark restoration. A missing bookmark or SDK-confirmed cache miss falls back -to source materialization and persistent specialization, then binds the requested -function and ordered I/O on that same acquired model. +Both formats can require device specialization. The acquisition coordinator +first attempts SDK bookmark restoration. A missing bookmark or SDK-confirmed +cache miss falls back to source materialization and persistent specialization. +It returns the acquired model; function binding is the caller's responsibility. ### Asset storage @@ -99,14 +99,13 @@ Missing bundles are written to a staging directory and published with an exclusive rename; each selected NDS payload is fetched once and checked for size before writing. If another loader publishes first, its bundle is validated and used. Incomplete, size-mismatched or otherwise malformed existing bundles fail -normal loading without replacement, since an SDK model may still be using them. -A cache-maintenance caller holding the same entry lock can explicitly replace a -damaged bundle. Replacement preflights the full descriptor-bound tree, then -revalidates entries while removing them without following links. Only -interrupted system calls are retried; other failures are returned to the caller -without automatic retries or repair. Temporary bundles are removed on ordinary -failures before publication; a caller can explicitly retry after addressing the -failure. +normal source preparation without replacement, since an SDK model may still be +using them. The acquisition coordinator can explicitly replace a damaged bundle +under the same entry lock and rebuild it from available named data. Replacement +preflights the full descriptor-bound tree, then revalidates entries while +removing them without following links. Named-data and SDK failures are not +retried. Temporary bundles are removed on ordinary failures before publication; +a caller can explicitly retry after addressing the failure. These are completeness checks, not full content-integrity verification. Same-size content changes are not detected, and a complete stored bundle does not cause its @@ -143,24 +142,22 @@ runtime. Each variant key determines a raw bookmark in `bookmarks`, a bundle directory in `staging`, and one of 256 shared coordination files in `locks`. There is no PTE-wide bookmark list or serialized lifecycle record. -A healthy bookmark hit does not inspect staging or consult NDS; the acquired SDK -model is retained through function binding. On a missing bookmark or confirmed -SDK miss, the runtime materializes the source, specializes it and atomically -writes the returned bookmark bytes. After durable publication, it removes the -entire keyed staging directory while holding the per-key lock. Persistent SDK -caching supports inference and bookmark restoration without that extracted copy. +A healthy bookmark hit does not consult NDS and attempts to clean leftover +staging. On a missing bookmark or confirmed SDK miss, the coordinator prepares +the source, specializes it and atomically writes the returned bookmark bytes. +After durable publication, it attempts to remove the entire keyed staging +directory while holding the per-key lock. Persistent SDK caching supports +inference and bookmark restoration without that extracted copy. Keep the packaged named data available: a later SDK cache miss requires source materialization again. There is no secondary source-URL cache lookup. SDK errors, unreadable bookmarks and publication failures fail loading without automatic cache deletion. If publication fails after specialization, the SDK -entry and source remain. If staged-source cleanup fails, loading reports the -error but retains the durable bookmark and any remaining staged files. Neither -failure triggers automatic retries, SDK eviction or source repair. A subsequent -bookmark hit can load without inspecting or retrying staging cleanup. A partial -cleanup followed by an SDK miss fails on the damaged staged entry until it is -explicitly cleared. The backend does not track historical or unrecorded SDK -entries. +entry and source remain. Cleanup after a bookmark hit or durable publication is +best-effort: failures are logged without discarding the usable model or bookmark. +A later load can finish cleanup or rebuild a partially removed source after an +SDK miss. No cleanup failure triggers SDK eviction or retries within that load. +The backend does not track historical or unrecorded SDK entries. Raw bookmarks have an 8 MiB backend allocation limit, not an asserted SDK format maximum, and are published atomically because losing one orphans an SDK entry. @@ -208,7 +205,10 @@ types and substitute fake entry points. when the macOS host meets the configured Core AI deployment target. The test build exports static- and dynamic-shape models for real session tests. The SDK source-lifetime regression checks live inference and fresh-process -bookmark restoration after removing disposable portable and AOT source copies. +bookmark restoration through the bridge after removing disposable portable and +AOT source copies. The AOT case is skipped when the SDK cannot identify the +device architecture. A killed probe can leave an SDK entry if it dies before +saving its bookmark; cleanup never purges unrelated default-cache entries. CMake's `PYTHON_EXECUTABLE` must use the Python environment with the pinned Core AI requirements above. Backend Python changes regenerate the dedicated fixture directory, replacing previous contents. CTest supplies its location to XCTest; diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.h b/backends/apple/coreai/runtime/coreai_load_coordinator.h index 85827ea0fec..018117a18f9 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.h +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.h @@ -13,6 +13,7 @@ namespace executorch::backends::coreai { // The key lock outlives all SDK callbacks and raw bookmark publication. +// Cleanup after a restore hit or durable publication is best-effort. runtime::Result> acquire_bookmark_model( const Manifest& selected, const runtime::NamedDataMap* named_data, diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index 9ad967ccc0b..56f4b5ade74 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -14,83 +14,122 @@ using runtime::Result; Error sdk_error(NSError* error) { - ET_LOG(Error, "Core AI SDK error (%s, %ld): %s", error.domain.UTF8String, - static_cast(error.code), error.description.UTF8String); + ET_LOG( + Error, + "Core AI SDK error (%s, %ld): %s", + error.domain.UTF8String, + static_cast(error.code), + error.description.UTF8String); return Error::Internal; } Result> acquire( void (^operation)(ETCoreAIAcquisitionCompletion)) { - ET_CHECK_OR_RETURN_ERROR(storage_fault(StorageOperation::BeforeSDK) == 0, - AccessFailed, "Core AI acquisition interrupted"); + ET_CHECK_OR_RETURN_ERROR( + storage_fault(StorageOperation::BeforeSDK) == 0, + AccessFailed, + "Core AI acquisition interrupted"); dispatch_semaphore_t ready = dispatch_semaphore_create(0); __block id model = nil; __block NSError* error = nil; __block ETCoreAIAcquisitionStatus status = ETCoreAIAcquisitionStatusError; - operation(^(ETCoreAIAcquisitionStatus result, id value, - NSError* failure) { - status = result; - model = value; - error = failure; - dispatch_semaphore_signal(ready); - }); + operation( + ^(ETCoreAIAcquisitionStatus result, + id value, + NSError* failure) { + status = result; + model = value; + error = failure; + dispatch_semaphore_signal(ready); + }); // Do not abandon asynchronous source access on cancellation or timeout. dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); id acquired = model; // A loader may retain the completion block after invoking it. model = nil; - if (error != nil) return sdk_error(error); - ET_CHECK_OR_RETURN_ERROR(storage_fault(StorageOperation::AfterSDK) == 0, - AccessFailed, "Core AI acquisition interrupted"); + if (error != nil) + return sdk_error(error); + ET_CHECK_OR_RETURN_ERROR( + storage_fault(StorageOperation::AfterSDK) == 0, + AccessFailed, + "Core AI acquisition interrupted"); ET_CHECK_OR_RETURN_ERROR( (status == ETCoreAIAcquisitionStatusHit && acquired != nil) || (status == ETCoreAIAcquisitionStatusMiss && acquired == nil), - Internal, "Invalid Core AI acquisition result"); + Internal, + "Invalid Core AI acquisition result"); return acquired; } -Result> restore(id loader, - NSData* bookmark) { +Result> restore( + id loader, + NSData* bookmark) { return acquire(^(ETCoreAIAcquisitionCompletion completion) { [loader restoreModelFromBookmark:bookmark completion:completion]; }); } -} // namespace +void cleanup_source(const BookmarkLock& lock) { + const auto error = remove_bookmark_staging(lock); + if (error != Error::Ok) { + ET_LOG( + Error, + "Cannot clean Core AI staging: error=%u", + static_cast(error)); + } +} +} // namespace Result> acquire_bookmark_model( - const Manifest& selected, const runtime::NamedDataMap* named_data, - NSString* root, NSString* platform, NSString* architecture, + const Manifest& selected, + const runtime::NamedDataMap* named_data, + NSString* root, + NSString* platform, + NSString* architecture, id loader) { - ET_CHECK_OR_RETURN_ERROR(loader != nil, InvalidArgument, - "Missing Core AI model loader"); + ET_CHECK_OR_RETURN_ERROR( + loader != nil, InvalidArgument, "Missing Core AI model loader"); auto key = bookmark_key(selected, platform, architecture); - if (!key.ok()) return key.error(); + if (!key.ok()) + return key.error(); auto locked = lock_bookmark(root, key.get()); - if (!locked.ok()) return locked.error(); + if (!locked.ok()) + return locked.error(); auto bookmark = read_bookmark(*locked.get()); - if (!bookmark.ok()) return bookmark.error(); + if (!bookmark.ok()) + return bookmark.error(); if (bookmark.get() != nil) { auto model = restore(loader, bookmark.get()); - if (!model.ok()) return model.error(); - if (model.get() != nil) return model; + if (!model.ok()) + return model.error(); + if (model.get() != nil) { + cleanup_source(*locked.get()); + return model; + } } auto staging = prepare_bookmark_staging(*locked.get()); - if (!staging.ok()) return staging.error(); - auto source = - prepare_source_bundle(selected, named_data, staging.get(), key.get()); - if (!source.ok()) return source.error(); + if (!staging.ok()) + return staging.error(); + auto source = prepare_source_bundle( + selected, + named_data, + staging.get(), + key.get(), + /*replace_corrupt=*/true); + if (!source.ok()) + return source.error(); NSURL* url = source.get(); auto model = acquire(^(ETCoreAIAcquisitionCompletion completion) { [loader specializeModelAtURL:url completion:completion]; }); - if (!model.ok()) return model.error(); - ET_CHECK_OR_RETURN_ERROR(model.get() != nil, Internal, - "Core AI specialization returned no model"); + if (!model.ok()) + return model.error(); + ET_CHECK_OR_RETURN_ERROR( + model.get() != nil, Internal, "Core AI specialization returned no model"); ET_CHECK_OK_OR_RETURN_ERROR( write_bookmark(*locked.get(), [model.get() copyBookmarkData])); - ET_CHECK_OK_OR_RETURN_ERROR(remove_bookmark_staging(*locked.get())); + cleanup_source(*locked.get()); return model; } -} // namespace executorch::backends::coreai +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift index 8691b47591b..505a8d39284 100644 --- a/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift +++ b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift @@ -4,8 +4,8 @@ // This source code is licensed under the BSD-style license found in the // LICENSE file in the root directory of this source tree. -import CoreAI import Foundation +import Synchronization private struct Fixture: Decodable { let function: String @@ -16,41 +16,99 @@ private struct Fixture: Decodable { private enum Failure: Error { case invalidArguments - case missingFunction + case invalidResult case incorrectOutput(String) case unexpectedSource } -private func checkInference(_ model: AIModel, fixture: Fixture) async throws { - guard let function = try model.loadFunction(named: fixture.function) else { - throw Failure.missingFunction +// Bridge results are immutable and consumed only after completion. +private struct BridgeResult: @unchecked Sendable { + let value: Result +} + +// The executable waits on an ordinary thread, not Swift's cooperative pool. +private func waitForResult( + _ operation: (@escaping @Sendable (Result) -> Void) -> Void +) throws -> Value { + let result = Mutex?>(nil) + let ready = DispatchSemaphore(value: 0) + operation { value in + result.withLock { $0 = BridgeResult(value: value) } + ready.signal() + } + ready.wait() + let value = result.withLock { value in + let snapshot = value + value = nil + return snapshot } + guard let value else { throw Failure.invalidResult } + return try value.value.get() +} + +private func acquire( + _ operation: (@escaping ETCoreAIAcquisitionCompletion) -> Void +) throws -> (any ETCoreAIPreparedModel)? { + try waitForResult { completed in + operation { status, model, error in + if let error { + completed(.failure(error)) + } else if (status == .hit && model != nil) || (status == .miss && model == nil) { + completed(.success(model)) + } else { + completed(.failure(Failure.invalidResult)) + } + } + } +} + +private func checkInference(_ model: any ETCoreAIPreparedModel, fixture: Fixture) throws { guard fixture.inputs.count == 2, fixture.outputs.count == fixture.expected.count else { throw Failure.invalidArguments } - var inputs: [String: NDArray] = [:] - for (index, values) in [[Float(5), 9], [Float(1), 2]].enumerated() { - var array = NDArray(shape: [2], scalarType: .float32) - do { - var view = array.mutableView(as: Float.self) - view.withUnsafeMutablePointer { pointer, _, _ in - pointer[0] = values[0] - pointer[1] = values[1] + let session: any ETCoreAISession = try waitForResult { completed in + model.loadFunctionNamed( + fixture.function, inputNames: fixture.inputs, outputNames: fixture.outputs + ) { session, error in + if let error { + completed(.failure(error)) + } else if let session { + completed(.success(session)) + } else { + completed(.failure(Failure.invalidResult)) + } + } + } + let storage = [[Float(5), 9], [Float(1), 2]].map { values in + values.withUnsafeBytes { NSData(bytes: $0.baseAddress, length: $0.count) } + } + let inputs = storage.map { + ETCoreAIInputTensor( + bytes: $0.bytes, byteCount: UInt($0.length), shape: [2], scalarType: .float32) + } + let outputs: [ETCoreAITensor] = try withExtendedLifetime(storage) { + try waitForResult { completed in + session.executeInputs(inputs) { outputs, error in + if let error { + completed(.failure(error)) + } else if let outputs { + completed(.success(outputs)) + } else { + completed(.failure(Failure.invalidResult)) + } } } - inputs[fixture.inputs[index]] = array } - var outputs = try await function.run(inputs: inputs) guard outputs.count == fixture.outputs.count else { throw Failure.incorrectOutput("count") } - for (index, name) in fixture.outputs.enumerated() { - guard let value = outputs.remove(name), let array = value.ndArray, - array.shape == [2], array.scalarType == .float32, - fixture.expected[index].count == 2 + for (index, output) in outputs.enumerated() { + let name = fixture.outputs[index] + guard output.shape == [2], output.scalarType == .float32, + output.data.count == 8, fixture.expected[index].count == 2 else { throw Failure.incorrectOutput(name) } - let values = array.view(as: Float.self).withUnsafePointer { pointer, _, strides in - [pointer[0], pointer[strides[0]]] + let values = output.data.withUnsafeBytes { + [$0.loadUnaligned(as: Float.self), $0.loadUnaligned(fromByteOffset: 4, as: Float.self)] } guard zip(values, fixture.expected[index]).allSatisfy({ abs($0 - $1) < 0.001 }) else { throw Failure.incorrectOutput("\(name): \(values), expected \(fixture.expected[index])") @@ -60,11 +118,11 @@ private func checkInference(_ model: AIModel, fixture: Fixture) async throws { @main private struct SDKSourceLifetimeTest { - static func main() async { + static func main() { do { let args = CommandLine.arguments if args.count == 2, args[1] == "architecture" { - print(AIModel.deviceArchitectureName) + print(ETCoreAISwiftModelLoader.deviceArchitectureName) return } guard args.count == 5 else { throw Failure.invalidArguments } @@ -77,31 +135,41 @@ private struct SDKSourceLifetimeTest { directory == fixtureURL.deletingLastPathComponent(), directory.deletingLastPathComponent().lastPathComponent.hasPrefix("coreai-lifetime-") else { throw Failure.invalidArguments } + let loader = ETCoreAISwiftModelLoader() if mode == "evict" { - try AIModelCache.deleteEntry(referencedBy: Data(contentsOf: bookmarkURL)) + let bookmark = try Data(contentsOf: bookmarkURL) + let _: Void = try waitForResult { completed in + loader.evictModel(withBookmark: bookmark) { error in + if let error { completed(.failure(error)) } else { completed(.success(())) } + } + } return } let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: fixtureURL)) if mode == "specialize" { - let model = try await AIModel.specialize( - contentsOf: source, options: .default, cache: .default, cachePolicy: .persistent) - try model.bookmarkData.write(to: bookmarkURL, options: .atomic) - try await checkInference(model, fixture: fixture) + guard let model = try acquire({ loader.specializeModel(at: source, completion: $0) }) else { + throw Failure.invalidResult + } + try model.copyBookmarkData().write(to: bookmarkURL, options: .atomic) + try checkInference(model, fixture: fixture) try FileManager.default.removeItem(at: source) guard !FileManager.default.fileExists(atPath: source.path) else { throw Failure.unexpectedSource } - try await checkInference(model, fixture: fixture) - print("Live inference passed after source removal") + try checkInference(model, fixture: fixture) + print("Bridge inference passed after source removal") } else if mode == "restore" { guard !FileManager.default.fileExists(atPath: source.path) else { throw Failure.unexpectedSource } - guard let model = try AIModel(resolvingBookmark: Data(contentsOf: bookmarkURL)) else { + let bookmark = try Data(contentsOf: bookmarkURL) + guard + let model = try acquire({ loader.restoreModel(fromBookmark: bookmark, completion: $0) }) + else { exit(3) } - try await checkInference(model, fixture: fixture) - print("Bookmark restoration and inference passed without source") + try checkInference(model, fixture: fixture) + print("Bridge restoration and inference passed without source") } else { throw Failure.invalidArguments } diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm index a0af1340b76..7446e9ffbe6 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -26,7 +26,9 @@ using runtime::Error; using runtime::Result; -dispatch_time_t deadline() { return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); } +dispatch_time_t deadline() { + return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); +} struct AcquisitionWorker { Manifest manifest; @@ -42,8 +44,13 @@ auto* worker = static_cast(opaque); dispatch_semaphore_signal(worker->started); @autoreleasepool { - auto acquired = acquire_bookmark_model(worker->manifest, worker->data, worker->root, @"macOS", - @"arch_b", worker->loader); + auto acquired = acquire_bookmark_model( + worker->manifest, + worker->data, + worker->root, + @"macOS", + @"arch_b", + worker->loader); worker->error = acquired.ok() ? Error::Ok : acquired.error(); worker->model = acquired.ok() && acquired.get() != nil; } @@ -62,57 +69,78 @@ ParallelAcquisitionObservation& observation; bool finished = false; - AcquisitionWorkers(ScopedFakeBridgeState& state, BookmarkFakeLoader* fake, - ParallelAcquisitionObservation& result) + AcquisitionWorkers( + ScopedFakeBridgeState& state, + BookmarkFakeLoader* fake, + ParallelAcquisitionObservation& result) : bridge(state), loader(fake), observation(result) {} void finish() { - if (finished) return; - for (int i = 0; i < 2; ++i) dispatch_semaphore_signal(proceed); + if (finished) + return; + for (int i = 0; i < 2; ++i) + dispatch_semaphore_signal(proceed); for (int i = 0; i < 2; ++i) { - if (!started[i]) continue; - observation.completed[i] = dispatch_semaphore_wait(workers[i].done, deadline()) == 0; + if (!started[i]) + continue; + observation.completed[i] = + dispatch_semaphore_wait(workers[i].done, deadline()) == 0; observation.joined[i] = pthread_join(threads[i], nullptr); observation.errors[i] = workers[i].error; observation.models[i] = workers[i].model; } observation.callbacks_drained = bridge.wait_for_callbacks(deadline()); - if (observation.callbacks_drained) loader.onSpecialize = nil; + if (observation.callbacks_drained) + loader.onSpecialize = nil; finished = true; } - ~AcquisitionWorkers() { finish(); } + ~AcquisitionWorkers() { + finish(); + } }; bool send_child_byte(int fd, char value) { const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(10); while (std::chrono::steady_clock::now() < end) { - const auto remaining = std::chrono::duration_cast( - end - std::chrono::steady_clock::now()) - .count(); + const auto remaining = + std::chrono::duration_cast( + end - std::chrono::steady_clock::now()) + .count(); pollfd entry{fd, POLLOUT, 0}; - const int ready = poll(&entry, 1, static_cast(remaining > 0 ? remaining : 0)); - if (ready < 0 && errno == EINTR) continue; - if (ready <= 0 || !(entry.revents & POLLOUT)) return false; + const int ready = + poll(&entry, 1, static_cast(remaining > 0 ? remaining : 0)); + if (ready < 0 && errno == EINTR) + continue; + if (ready <= 0 || !(entry.revents & POLLOUT)) + return false; const ssize_t written = write(fd, &value, 1); - if (written == 1) return true; - if (written < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (written == 1) + return true; + if (written < 0 && (errno == EINTR || errno == EAGAIN)) + continue; return false; } return false; } -} // namespace +} // namespace Result acquisition_staged_url(NSURL* root, const Manifest& manifest) { auto key = test_bookmark_key(manifest); - if (!key.ok()) return key.error(); - return [[[root URLByAppendingPathComponent:@"staging"] URLByAppendingPathComponent:key.get()] + if (!key.ok()) + return key.error(); + return [[[root URLByAppendingPathComponent:@"staging"] + URLByAppendingPathComponent:key.get()] URLByAppendingPathComponent:manifest.path.lastPathComponent]; } -ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& bridge, - BookmarkFakeLoader* loader, NSURL* root, - const Manifest& first, const Manifest& second, - TestData& data, bool same_key) { +ParallelAcquisitionObservation parallel_acquisitions( + ScopedFakeBridgeState& bridge, + BookmarkFakeLoader* loader, + NSURL* root, + const Manifest& first, + const Manifest& second, + TestData& data, + bool same_key) { ParallelAcquisitionObservation observation; auto timeouts = std::make_shared>(0); dispatch_semaphore_t entered = dispatch_semaphore_create(0); @@ -120,7 +148,8 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid dispatch_semaphore_t proceed = group.proceed; loader.onSpecialize = ^(NSURL*) { dispatch_semaphore_signal(entered); - if (dispatch_semaphore_wait(proceed, deadline()) != 0) ++*timeouts; + if (dispatch_semaphore_wait(proceed, deadline()) != 0) + ++*timeouts; }; for (int i = 0; i < 2; ++i) { auto& worker = group.workers[i]; @@ -129,21 +158,24 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid worker.root = root.path; worker.loader = loader; } - observation.creation[0] = - pthread_create(&group.threads[0], nullptr, AcquisitionWorker::run, &group.workers[0]); + observation.creation[0] = pthread_create( + &group.threads[0], nullptr, AcquisitionWorker::run, &group.workers[0]); group.started[0] = observation.creation[0] == 0; if (group.started[0]) { - observation.first_entered = dispatch_semaphore_wait(entered, deadline()) == 0; + observation.first_entered = + dispatch_semaphore_wait(entered, deadline()) == 0; } if (observation.first_entered) { - observation.creation[1] = - pthread_create(&group.threads[1], nullptr, AcquisitionWorker::run, &group.workers[1]); + observation.creation[1] = pthread_create( + &group.threads[1], nullptr, AcquisitionWorker::run, &group.workers[1]); group.started[1] = observation.creation[1] == 0; - if (group.started[1] && dispatch_semaphore_wait(group.workers[1].started, deadline()) == 0) { + if (group.started[1] && + dispatch_semaphore_wait(group.workers[1].started, deadline()) == 0) { const auto second_deadline = same_key ? dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC) : deadline(); - const bool second_entered = dispatch_semaphore_wait(entered, second_deadline) == 0; + const bool second_entered = + dispatch_semaphore_wait(entered, second_deadline) == 0; observation.second_observed = same_key ? !second_entered : second_entered; } } @@ -152,103 +184,152 @@ ParallelAcquisitionObservation parallel_acquisitions(ScopedFakeBridgeState& brid return observation; } -::testing::AssertionResult spawn_acquisition_crash(BookmarkChild& child, NSURL* root, - StorageOperation boundary, - NSDictionary* manifest) { - // Serialize the shared fixture in the parent; its builders contain GTest checks. - NSData* payload = encode(@{@"boundary" : @(static_cast(boundary)), @"manifest" : manifest}); - if (payload == nil) return ::testing::AssertionFailure() << "Cannot encode crash child input"; - NSString* argument = [[NSString alloc] initWithData:payload encoding:NSUTF8StringEncoding]; +::testing::AssertionResult spawn_acquisition_crash( + BookmarkChild& child, + NSURL* root, + StorageOperation boundary, + NSDictionary* manifest) { + // Serialize the shared fixture in the parent; its builders contain GTest + // checks. + NSData* payload = encode( + @{@"boundary" : @(static_cast(boundary)), + @"manifest" : manifest}); + if (payload == nil) + return ::testing::AssertionFailure() << "Cannot encode crash child input"; + NSString* argument = [[NSString alloc] initWithData:payload + encoding:NSUTF8StringEncoding]; return child.spawn(@"--acquisition-crash", root.path, argument); } -::testing::AssertionResult spawn_parallel_acquisition(BookmarkChild& child, NSURL* root, - NSDictionary* first, NSDictionary* second, - bool same_key) { - NSData* payload = encode(@{@"manifest" : first, @"second" : second, @"same_key" : @(same_key)}); - if (payload == nil) return ::testing::AssertionFailure() << "Cannot encode parallel child input"; - NSString* argument = [[NSString alloc] initWithData:payload encoding:NSUTF8StringEncoding]; +::testing::AssertionResult spawn_parallel_acquisition( + BookmarkChild& child, + NSURL* root, + NSDictionary* first, + NSDictionary* second, + bool same_key) { + NSData* payload = encode( + @{@"manifest" : first, + @"second" : second, + @"same_key" : @(same_key)}); + if (payload == nil) + return ::testing::AssertionFailure() + << "Cannot encode parallel child input"; + NSString* argument = [[NSString alloc] initWithData:payload + encoding:NSUTF8StringEncoding]; return child.spawn(@"--acquisition-parallel", root.path, argument); } namespace { -[[noreturn]] void parallel_child(NSString* root, const Manifest& first, const Manifest& second, - bool same_key, int fd) { +[[noreturn]] void parallel_child( + NSString* root, + const Manifest& first, + const Manifest& second, + bool same_key, + int fd) { ScopedFakeBridgeState bridge; @autoreleasepool { BookmarkFakeLoader* loader = [[BookmarkFakeLoader alloc] init]; - if (loader == nil) _exit(8); + if (loader == nil) + _exit(8); bridge.state().bookmark_loader = loader; TestData data; - auto result = parallel_acquisitions(bridge, loader, [NSURL fileURLWithPath:root], first, second, - data, same_key); - if (!result.callbacks_drained) _exit(11); - // These bytes are observations, not child assertions. The parent checks each one. - const bool harness_ok = result.creation[0] == 0 && result.creation[1] == 0 && - result.first_entered && result.joined[0] == 0 && - result.joined[1] == 0 && result.completed[0] && result.completed[1] && - result.gate_timeouts == 0; + auto result = parallel_acquisitions( + bridge, + loader, + [NSURL fileURLWithPath:root], + first, + second, + data, + same_key); + if (!result.callbacks_drained) + _exit(11); + // These bytes are observations, not child assertions. The parent checks + // each one. + const bool harness_ok = result.creation[0] == 0 && + result.creation[1] == 0 && result.first_entered && + result.joined[0] == 0 && result.joined[1] == 0 && result.completed[0] && + result.completed[1] && result.gate_timeouts == 0; const bool both_ok = result.errors[0] == Error::Ok && result.models[0] && - result.errors[1] == Error::Ok && result.models[1]; - const char observations[] = {static_cast(harness_ok), - static_cast(result.second_observed), - static_cast(both_ok), - static_cast(loader->specializations.load()), - static_cast(data.attempts.load()), - static_cast(loader->restores.load())}; + result.errors[1] == Error::Ok && result.models[1]; + const char observations[] = { + static_cast(harness_ok), + static_cast(result.second_observed), + static_cast(both_ok), + static_cast(loader->specializations.load()), + static_cast(data.attempts.load()), + static_cast(loader->restores.load())}; for (char observation : observations) { - if (!send_child_byte(fd, observation)) _exit(12); + if (!send_child_byte(fd, observation)) + _exit(12); } - if (loader->evictions.load() != 0 || data.requests.load() != data.releases.load()) _exit(14); + if (loader->evictions.load() != 0 || + data.requests.load() != data.releases.load()) + _exit(14); [loader clearRetainedState]; bridge.state().bookmark_loader = nil; loader = nil; } auto& state = bridge.state(); - const bool none_live = state.prepared_models.load() == 0 && state.loaders.load() == 0 && - state.missing_bundles.load() == 0; - if (!send_child_byte(fd, static_cast(none_live))) _exit(12); - // No GTest-bearing destructors run before InitGoogleTest, even on a failed child. + const bool none_live = state.prepared_models.load() == 0 && + state.loaders.load() == 0 && state.missing_bundles.load() == 0; + if (!send_child_byte(fd, static_cast(none_live))) + _exit(12); + // No GTest-bearing destructors run before InitGoogleTest, even on a failed + // child. _exit(close(fd) == 0 ? 0 : 13); } -} // namespace +} // namespace int acquisition_child_mode(int argc, char** argv) { - if (argc < 2) return -1; + if (argc < 2) + return -1; const bool crash = std::strcmp(argv[1], "--acquisition-crash") == 0; const bool parallel = std::strcmp(argv[1], "--acquisition-parallel") == 0; - if (!crash && !parallel) return -1; - if (argc != 5) return 1; + if (!crash && !parallel) + return -1; + if (argc != 5) + return 1; NSString* root = [NSString stringWithUTF8String:argv[2]]; NSString* argument = [NSString stringWithUTF8String:argv[3]]; - if (root == nil || argument == nil) return 2; - id payload = - [NSJSONSerialization JSONObjectWithData:[argument dataUsingEncoding:NSUTF8StringEncoding] - options:0 - error:nil]; - if (![payload isKindOfClass:NSDictionary.class]) return 3; + if (root == nil || argument == nil) + return 2; + id payload = [NSJSONSerialization + JSONObjectWithData:[argument dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + if (![payload isKindOfClass:NSDictionary.class]) + return 3; id number = payload[@"boundary"]; id dictionary = payload[@"manifest"]; - if (![dictionary isKindOfClass:NSDictionary.class]) return 3; + if (![dictionary isKindOfClass:NSDictionary.class]) + return 3; StorageOperation boundary = StorageOperation::AfterSDK; if (crash) { - if (![number isKindOfClass:NSNumber.class]) return 3; + if (![number isKindOfClass:NSNumber.class]) + return 3; boundary = static_cast([number intValue]); - if (boundary != StorageOperation::AfterSDK) return 4; + if (boundary != StorageOperation::BeforeSDK && + boundary != StorageOperation::AfterSDK) + return 4; } auto parsed = parse_manifest(encode(dictionary)); - if (!parsed.ok()) return 5; + if (!parsed.ok()) + return 5; auto selected = select_assets(parsed.get(), @"arch_b", @"macOS"); - if (!selected.ok()) return 5; + if (!selected.ok()) + return 5; char* end = nullptr; errno = 0; const long descriptor = std::strtol(argv[4], &end, 10); - if (errno != 0 || end == argv[4] || *end != '\0' || descriptor < 0 || descriptor > INT_MAX) + if (errno != 0 || end == argv[4] || *end != '\0' || descriptor < 0 || + descriptor > INT_MAX) return 6; const int fd = static_cast(descriptor); - if (fcntl(fd, F_GETFD) < 0) return 6; + if (fcntl(fd, F_GETFD) < 0) + return 6; signal(SIGPIPE, SIG_IGN); - if (!send_child_byte(fd, 'C')) return 7; + if (!send_child_byte(fd, 'C')) + return 7; if (parallel) { id second_dictionary = payload[@"second"]; id same_key = payload[@"same_key"]; @@ -256,28 +337,36 @@ int acquisition_child_mode(int argc, char** argv) { ![same_key isKindOfClass:NSNumber.class]) return 3; auto second_parsed = parse_manifest(encode(second_dictionary)); - if (!second_parsed.ok()) return 5; + if (!second_parsed.ok()) + return 5; auto second = select_assets(second_parsed.get(), @"arch_b", @"macOS"); - if (!second.ok()) return 5; - parallel_child(root, selected.get(), second.get(), [same_key boolValue], fd); + if (!second.ok()) + return 5; + parallel_child( + root, selected.get(), second.get(), [same_key boolValue], fd); } - if (close(fd) != 0) return 7; + if (close(fd) != 0) + return 7; ScopedFakeBridgeState bridge; @autoreleasepool { - // Terminal child paths deliberately bypass assertion-bearing scope destructors. + // Terminal child paths deliberately bypass assertion-bearing scope + // destructors. BookmarkFakeLoader* loader = [[BookmarkFakeLoader alloc] init]; - if (loader == nil) _exit(8); + if (loader == nil) + _exit(8); bridge.state().bookmark_loader = loader; TestData data; StorageFaultScope fault(boundary); fault.armed = false; fault.observe = ^(StorageOperation event) { - if (event == boundary) _exit(73); + if (event == boundary) + _exit(73); }; - auto result = acquire_bookmark_model(selected.get(), &data, root, @"macOS", @"arch_b", loader); + auto result = acquire_bookmark_model( + selected.get(), &data, root, @"macOS", @"arch_b", loader); _exit(result.ok() ? 9 : 10); } } -} // namespace executorch::backends::coreai::testing +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index af80260827e..a514a7ce393 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -43,7 +43,7 @@ - (void)specializeModelAtURL:(NSURL*)url protected: // Declared first so each case's loader, models and data die before it. ScopedFakeBridgeState bridge; - BookmarkDirectory root; + TestDirectory root; TestData data; Manifest manifest; NSString* key = nil; @@ -60,9 +60,10 @@ void SetUp() override { Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { @autoreleasepool { - auto model = - acquire_bookmark_model(manifest, named_data, root.url.path, @"macOS", @"arch_b", loader); - if (!model.ok()) return model.error(); + auto model = acquire_bookmark_model( + manifest, named_data, root.url.path, @"macOS", @"arch_b", loader); + if (!model.ok()) + return model.error(); return model.get() != nil ? Error::Ok : Error::Internal; } } @@ -130,7 +131,9 @@ ino_t lock_inode() { ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); NSData* original = saved(); const int attempts = data.attempts; - fake.loader.restoreError = [NSError errorWithDomain:NSPOSIXErrorDomain code:EIO userInfo:nil]; + fake.loader.restoreError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); EXPECT_EQ(fake.loader->specializations.load(), 1); EXPECT_EQ(fake.loader->evictions.load(), 0); @@ -138,7 +141,9 @@ ino_t lock_inode() { EXPECT_TRUE([saved() isEqual:original]); } -TEST_F(CoreAIAcquisitionTest, RestoreMissRespecializesStagedSourceAndReplacesBookmark) { +TEST_F( + CoreAIAcquisitionTest, + RestoreMissRespecializesStagedSourceAndReplacesBookmark) { BookmarkLoaderScope fake; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); NSData* original = saved(); @@ -393,7 +398,7 @@ ino_t lock_inode() { { StorageFaultScope fault(StorageOperation::Remove); fault.match_index = 1; - EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); bookmark = saved(); ASSERT_NE(bookmark, nil); @@ -412,7 +417,7 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); - EXPECT_TRUE(staging_key_exists()); + EXPECT_FALSE(staging_key_exists()); } TEST_F( @@ -424,7 +429,7 @@ ino_t lock_inode() { { StorageFaultScope fault(StorageOperation::Remove); fault.match_index = 2; - EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); bookmark = saved(); ASSERT_NE(bookmark, nil); @@ -442,7 +447,7 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); - EXPECT_TRUE(staging_key_exists()); + EXPECT_FALSE(staging_key_exists()); } TEST_F( @@ -459,7 +464,7 @@ ino_t lock_inode() { if (operation == StorageOperation::Remove) arming->armed = true; }; - EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(scope.hits, 1); bookmark = saved(); ASSERT_NE(bookmark, nil); @@ -476,6 +481,87 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); + EXPECT_FALSE(staging_key_exists()); +} + +TEST_F(CoreAIAcquisitionTest, WarmHitCleanupFailureStillReturnsModel) { + BookmarkLoaderScope fake; + { + StorageFaultScope fault(StorageOperation::Remove); + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + } + NSData* original = saved(); + StorageFaultScope fault(StorageOperation::Remove); + EXPECT_EQ(acquire(fake.loader, nullptr), Error::Ok); + EXPECT_EQ(fault.hits, 1); + EXPECT_EQ(fake.loader->restores.load(), 1); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(data.attempts.load(), 1); + EXPECT_TRUE([saved() isEqual:original]); +} + +TEST_F(CoreAIAcquisitionTest, PartialCleanupThenMissRebuildsSource) { + BookmarkLoaderScope fake; + { + StorageFaultScope fault(StorageOperation::Remove); + fault.match_index = 2; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fault.hits, 1); + } + ASSERT_TRUE(staging_key_exists()); + NSData* original = saved(); + fake.loader.restoreMiss = YES; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->restores.load(), 1); + EXPECT_EQ(fake.loader->specializations.load(), 2); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(data.attempts.load(), 2); + EXPECT_FALSE([saved() isEqual:original]); + EXPECT_FALSE(staging_key_exists()); +} + +TEST_F( + CoreAIAcquisitionTest, + DamagedSourceCleanupFailureDoesNotReadOrSpecialize) { + BookmarkLoaderScope fake; + { + StorageFaultScope fault(StorageOperation::Remove); + fault.match_index = 2; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + } + NSData* original = saved(); + fake.loader.restoreMiss = YES; + { + StorageFaultScope fault(StorageOperation::Remove); + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + } + EXPECT_EQ(data.attempts.load(), 1); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_TRUE([saved() isEqual:original]); + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_FALSE(staging_key_exists()); +} + +TEST_F(CoreAIAcquisitionTest, MissingNamedDataIsNotRetried) { + BookmarkLoaderScope fake; + data.files.clear(); + EXPECT_EQ(acquire(fake.loader, &data), Error::InvalidExternalData); + EXPECT_EQ(data.attempts.load(), 1); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(saved(), nil); +} + +TEST_F(CoreAIAcquisitionTest, BeforeSdkFaultNeverInvokesLoader) { + BookmarkLoaderScope fake; + StorageFaultScope fault(StorageOperation::BeforeSDK); + EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(fault.hits, 1); + EXPECT_EQ(saved(), nil); } TEST_F(CoreAIAcquisitionTest, KeyLockIdentitySurvivesSuccessfulAcquisition) { @@ -487,8 +573,9 @@ ino_t lock_inode() { EXPECT_EQ(lock_inode(), first); } -class CoreAIAcquisitionSaveFailureTest : public CoreAIAcquisitionTest, - public ::testing::WithParamInterface {}; +class CoreAIAcquisitionSaveFailureTest + : public CoreAIAcquisitionTest, + public ::testing::WithParamInterface {}; TEST_P(CoreAIAcquisitionSaveFailureTest, PreservesBookmarkAndRetries) { const bool prior = GetParam(); BookmarkLoaderScope fake; @@ -510,29 +597,36 @@ ino_t lock_inode() { EXPECT_EQ(fault.hits, 1); } EXPECT_TRUE(original == nil ? saved() == nil : [saved() isEqual:original]); - EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:fake.loader.lastSpecializeURL]); + EXPECT_TRUE([NSFileManager.defaultManager + fileExistsAtPath:fake.loader.lastSpecializeURL]); const int calls = fake.loader->specializations; fake.loader.restoreMiss = NO; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), calls + (prior ? 0 : 1)); - EXPECT_EQ(staging_key_exists(), prior); + EXPECT_FALSE(staging_key_exists()); } -INSTANTIATE_TEST_SUITE_P(Boundaries, CoreAIAcquisitionSaveFailureTest, ::testing::Bool(), - [](const ::testing::TestParamInfo& info) { - return info.param ? "PriorBookmark" : "Cold"; - }); +INSTANTIATE_TEST_SUITE_P( + Boundaries, + CoreAIAcquisitionSaveFailureTest, + ::testing::Bool(), + [](const ::testing::TestParamInfo& info) { + return info.param ? "PriorBookmark" : "Cold"; + }); class CoreAIAcquisitionProcessTest : public CoreAIAcquisitionTest { protected: void expect_parallel(bool same_key) { NSMutableDictionary* first = manifest_dict(); NSMutableDictionary* second = [first mutableCopy]; - if (!same_key) second[@"bundle_digests"] = @{@"model.aimodel" : fixture_identity('2')}; + if (!same_key) + second[@"bundle_digests"] = @{@"model.aimodel" : fixture_identity('2')}; BookmarkChild child; - ASSERT_TRUE(spawn_parallel_acquisition(child, root.url, first, second, same_key)); + ASSERT_TRUE( + spawn_parallel_acquisition(child, root.url, first, second, same_key)); ASSERT_TRUE(child.receive('C')); ASSERT_TRUE(child.receive(1)) << "harness completed without timeouts"; - ASSERT_TRUE(child.receive(1)) << "second acquisition blocked or entered as expected"; + ASSERT_TRUE(child.receive(1)) + << "second acquisition blocked or entered as expected"; ASSERT_TRUE(child.receive(1)) << "both acquisitions succeeded"; ASSERT_TRUE(child.receive(same_key ? 1 : 2)) << "specializations"; ASSERT_TRUE(child.receive(same_key ? 1 : 2)) << "named-data attempts"; @@ -550,15 +644,32 @@ void expect_parallel(bool same_key) { expect_parallel(false); } +TEST_F(CoreAIAcquisitionProcessTest, CrashBeforeSdkRetainsStagingAndRetries) { + BookmarkChild child; + ASSERT_TRUE(spawn_acquisition_crash( + child, root.url, StorageOperation::BeforeSDK, manifest_dict())); + ASSERT_TRUE(child.receive('C')); + ASSERT_TRUE(child.expect_exit(73)); + EXPECT_EQ(saved(), nil); + EXPECT_TRUE(staging_key_exists()); + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 1); + EXPECT_EQ(data.attempts.load(), 0); + EXPECT_FALSE(staging_key_exists()); +} + TEST_F(CoreAIAcquisitionProcessTest, CrashAfterSdkRetainsStagingAndRetries) { BookmarkChild child; - ASSERT_TRUE(spawn_acquisition_crash(child, root.url, StorageOperation::AfterSDK, manifest_dict())); + ASSERT_TRUE(spawn_acquisition_crash( + child, root.url, StorageOperation::AfterSDK, manifest_dict())); ASSERT_TRUE(child.receive('C')); ASSERT_TRUE(child.expect_exit(73)); EXPECT_EQ(saved(), nil); auto staged = acquisition_staged_url(root.url, manifest); ASSERT_TRUE(staged.ok()); - EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); BookmarkLoaderScope fake; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), 1); @@ -568,4 +679,4 @@ void expect_parallel(bool same_key) { EXPECT_FALSE(staging_key_exists()); } -} // namespace +} // namespace diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index 54a14287d94..18dfe5208e7 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -48,16 +48,19 @@ void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { }); } -::testing::AssertionResult wait_for_callbacks(CoreAIFakeBridgeContext* context, - dispatch_time_t deadline) { +::testing::AssertionResult wait_for_callbacks( + CoreAIFakeBridgeContext* context, + dispatch_time_t deadline) { if (dispatch_group_wait(context.callbacks, deadline) != 0) { return ::testing::AssertionFailure() << "Fake SDK callbacks did not finish"; } return ::testing::AssertionSuccess(); } -dispatch_time_t cleanup_deadline() { return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); } -} // namespace +dispatch_time_t cleanup_deadline() { + return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); +} +} // namespace @interface BookmarkFakeModel () @property(nonatomic, strong) CoreAIFakeBridgeContext* context; @@ -130,12 +133,16 @@ - (void)restoreModelFromBookmark:(NSData*)bookmark } BookmarkFakeModel* model = self.restoreMiss ? nil : self.models[bookmark]; self.lastAcquired = model; - completion(model == nil ? ETCoreAIAcquisitionStatusMiss : ETCoreAIAcquisitionStatusHit, model, - nil); + completion( + model == nil ? ETCoreAIAcquisitionStatusMiss + : ETCoreAIAcquisitionStatusHit, + model, + nil); } }); } -- (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompletion)completion { +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion { ++specializations; run_async(self.context, ^{ if (self.onSpecialize != nil) { @@ -149,19 +156,25 @@ - (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompleti } if (![NSFileManager.defaultManager fileExistsAtPath:url.path]) { ++self.context->state.missing_bundles; - completion(ETCoreAIAcquisitionStatusError, nil, fake_error(ETCoreAIErrorInvalidModel)); + completion( + ETCoreAIAcquisitionStatusError, + nil, + fake_error(ETCoreAIErrorInvalidModel)); return; } - BookmarkFakeModel* model = [[BookmarkFakeModel alloc] initWithContext:self.context]; + BookmarkFakeModel* model = + [[BookmarkFakeModel alloc] initWithContext:self.context]; model.owner = self; - model.bookmark = [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; + model.bookmark = + [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; self.models[model.bookmark] = model; self.lastAcquired = model; completion(ETCoreAIAcquisitionStatusHit, model, nil); } }); } -- (void)evictModelWithBookmark:(NSData*)bookmark completion:(void (^)(NSError*))completion { +- (void)evictModelWithBookmark:(NSData*)bookmark + completion:(void (^)(NSError*))completion { ++evictions; run_async(self.context, ^{ completion(fake_error(ETCoreAIErrorUnsupported)); @@ -177,7 +190,8 @@ - (void)clearRetainedState { namespace executorch::backends::coreai::testing { ScopedFakeBridgeState::ScopedFakeBridgeState() - : context_([[CoreAIFakeBridgeContext alloc] init]), previous_(current_context) { + : context_([[CoreAIFakeBridgeContext alloc] init]), + previous_(current_context) { current_context = context_; } @@ -199,16 +213,20 @@ - (void)clearRetainedState { current_context = previous_; } -FakeBridgeState& ScopedFakeBridgeState::state() { return context_->state; } +FakeBridgeState& ScopedFakeBridgeState::state() { + return context_->state; +} -::testing::AssertionResult ScopedFakeBridgeState::wait_for_callbacks(dispatch_time_t deadline) { +::testing::AssertionResult ScopedFakeBridgeState::wait_for_callbacks( + dispatch_time_t deadline) { return ::wait_for_callbacks(context_, deadline); } BookmarkLoaderScope::BookmarkLoaderScope() : context_(current_context), previous_(context_ == nil ? nil : context_->state.bookmark_loader) { - EXPECT_NE(context_, nil) << "BookmarkLoaderScope requires ScopedFakeBridgeState"; + EXPECT_NE(context_, nil) + << "BookmarkLoaderScope requires ScopedFakeBridgeState"; if (context_ != nil) { loader = [[BookmarkFakeLoader alloc] init]; context_->state.bookmark_loader = loader; @@ -229,4 +247,4 @@ - (void)clearRetainedState { loader = nil; } -} // namespace executorch::backends::coreai::testing +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm index 1acfce4320e..d87eefdbbba 100644 --- a/backends/apple/coreai/runtime/test/coreai_host_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -18,7 +18,8 @@ int main(int argc, char** argv) { executorch::backends::coreai::testing::set_bookmark_test_executable( argv[0]); const int acquisition_result = - executorch::backends::coreai::testing::acquisition_child_mode(argc, argv); + executorch::backends::coreai::testing::acquisition_child_mode( + argc, argv); if (acquisition_result >= 0) return acquisition_result; const int child_result = diff --git a/backends/apple/coreai/runtime/test/export_bridge_fixture.py b/backends/apple/coreai/runtime/test/export_bridge_fixture.py index bd2f0f3cdc0..947c47cc86e 100644 --- a/backends/apple/coreai/runtime/test/export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -30,7 +30,10 @@ def forward(self, left, right): class WeightedArithmetic(torch.nn.Module): + """External weights expose any dependency on removed source files.""" + def __init__(self, first: float, second: float): + """Build a weighted model with distinguishable output offsets.""" super().__init__() self.first = first self.second = second @@ -39,6 +42,7 @@ def __init__(self, first: float, second: float): ) def forward(self, left, right): + """Apply the shared network to two distinct input combinations.""" return ( self.network(left + right) + self.first, self.network(left - right) + self.second, @@ -74,12 +78,15 @@ def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: """Export into a dedicated fixture directory, replacing its contents.""" size = torch.export.Dim("size", min=1, max=8) _export_model( - Arithmetic().eval(), (torch.ones(2), torch.ones(2)), output_dir, + Arithmetic().eval(), + (torch.ones(2), torch.ones(2)), + output_dir, dynamic_shapes=({0: size}, {0: size}) if dynamic else None, ) def export_weighted_fixture(output_dir: Path) -> None: + """Export isolated SDK cache contents and their expected output values.""" # Unique model contents keep cleanup from evicting another test's SDK entry. seed = uuid.uuid4().int model = WeightedArithmetic( diff --git a/backends/apple/coreai/runtime/test/run_sdk_source_lifetime.py b/backends/apple/coreai/runtime/test/run_sdk_source_lifetime.py new file mode 100644 index 00000000000..dc6c044b3aa --- /dev/null +++ b/backends/apple/coreai/runtime/test/run_sdk_source_lifetime.py @@ -0,0 +1,114 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def run(command, expected_status=0): + """Run a probe command, asserting its exit status.""" + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + if result.returncode != expected_status: + raise RuntimeError( + f"{command} returned {result.returncode}, expected {expected_status}\n" + f"{result.stdout}\n{result.stderr}" + ) + return result.stdout.strip() + + +def has_device_architecture(architecture: str) -> bool: + """Report whether the SDK identified a device architecture.""" + normalized = architecture.strip().lower() + return bool(normalized and normalized != "unknown") + + +def _export_weighted_fixture(directory: Path) -> None: + from export_bridge_fixture import export_weighted_fixture + + export_weighted_fixture(directory) + + +def compile_source(source: Path, compiled: Path, architecture: str) -> None: + """Compile a portable source to an AOT model.""" + run( + [ + "xcrun", + "coreai-build", + "compile", + str(source), + "--output", + str(compiled), + "--platform", + "macOS", + "--architecture", + architecture, + "--min-deployment-version", + "27.0", + ] + ) + shutil.rmtree(source) + + +def run_variant( + probe: Path, kind: str, source: Path, fixture: Path, bookmark: Path +) -> None: + """Exercise specialize, restore, and evict for one source variant.""" + arguments = [str(source), str(fixture), str(bookmark)] + try: + print(run([str(probe), "specialize", *arguments]), flush=True) + if source.exists() or not bookmark.is_file(): + raise AssertionError("Expected a saved bookmark and no original source") + for _ in range(2): + print(run([str(probe), "restore", *arguments]), flush=True) + except BaseException: + try: + if bookmark.is_file(): + run([str(probe), "evict", *arguments]) + except Exception as cleanup: + print(f"cleanup evict failed: {cleanup}", flush=True) + raise + if bookmark.is_file(): + run([str(probe), "evict", *arguments]) + run([str(probe), "restore", *arguments], expected_status=3) + print(f"{kind}: persistent cache lifetime and eviction control passed", flush=True) + + +def main(probe: Path) -> None: + """Run portable and compiled lifetime probes against a test binary.""" + architecture = run([str(probe), "architecture"]) + with tempfile.TemporaryDirectory(prefix="coreai-lifetime-") as temporary: + root = Path(temporary) + for kind in ("portable", "compiled"): + # AOT compile rejects unknown architectures. + if kind == "compiled" and not has_device_architecture(architecture): + print( + f"compiled: skipped (unsupported architecture {architecture!r})", + flush=True, + ) + continue + directory = root / kind + _export_weighted_fixture(directory) + source = directory / "model.aimodel" + if kind == "compiled": + compiled = directory / "model.aimodelc" + compile_source(source, compiled, architecture) + source = compiled + run_variant( + probe, + kind, + source, + directory / "fixture.json", + directory / "saved.bookmark", + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("probe", type=Path) + main(parser.parse_args().probe.resolve()) diff --git a/backends/apple/coreai/runtime/test/test_run_sdk_source_lifetime.py b/backends/apple/coreai/runtime/test/test_run_sdk_source_lifetime.py new file mode 100644 index 00000000000..548ffe1f9c6 --- /dev/null +++ b/backends/apple/coreai/runtime/test/test_run_sdk_source_lifetime.py @@ -0,0 +1,97 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from executorch.backends.apple.coreai.runtime.test import ( + run_sdk_source_lifetime as driver, +) + + +@pytest.mark.parametrize("architecture", ["unknown", "Unknown", "", " "]) +def test_compiled_variant_skipped_without_supported_architecture( + tmp_path, capsys, architecture +): + """AOT compile rejects unknown architectures, so only portable runs.""" + calls = [] + with ( + patch.object(driver, "run", return_value=architecture), + patch.object( + driver, + "_export_weighted_fixture", + side_effect=lambda d: d.mkdir(parents=True), + ), + patch.object( + driver, "compile_source", side_effect=lambda *args: calls.append("compile") + ), + patch.object( + driver, + "run_variant", + side_effect=lambda *args: calls.append(args[1]), + ), + patch.object(driver.tempfile, "TemporaryDirectory") as temporary, + ): + temporary.return_value.__enter__.return_value = str(tmp_path) + driver.main(Path("probe")) + + assert calls == ["portable"] + assert "compiled: skipped" in capsys.readouterr().out + + +def test_compiled_variant_runs_with_known_architecture(tmp_path): + """Physical known-arch Macs still exercise the AOT path.""" + calls = [] + with ( + patch.object(driver, "run", return_value="h16c"), + patch.object( + driver, + "_export_weighted_fixture", + side_effect=lambda d: d.mkdir(parents=True), + ), + patch.object( + driver, "compile_source", side_effect=lambda *args: calls.append("compile") + ), + patch.object( + driver, + "run_variant", + side_effect=lambda *args: calls.append(args[1]), + ), + patch.object(driver.tempfile, "TemporaryDirectory") as temporary, + ): + temporary.return_value.__enter__.return_value = str(tmp_path) + driver.main(Path("probe")) + + assert calls == ["portable", "compile", "compiled"] + + +@pytest.mark.parametrize( + "failure", [RuntimeError("probe failed"), KeyboardInterrupt("interrupted")] +) +def test_cleanup_failure_preserves_original_error(tmp_path, capsys, failure): + """A failing evict must not mask the probe failure it cleans up after.""" + + def fake_run(command, expected_status=0): + if "specialize" in command: + raise failure + raise RuntimeError("evict failed") + + bookmark = tmp_path / "saved.bookmark" + bookmark.write_bytes(b"bookmark") + with patch.object(driver, "run", side_effect=fake_run): + with pytest.raises(type(failure)) as raised: + driver.run_variant( + Path("probe"), + "portable", + tmp_path / "model.aimodel", + tmp_path / "fixture.json", + bookmark, + ) + + assert raised.value is failure + assert "cleanup evict failed: evict failed" in capsys.readouterr().out diff --git a/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py b/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py deleted file mode 100644 index 4ef03012e84..00000000000 --- a/backends/apple/coreai/runtime/test/test_sdk_source_lifetime.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -import argparse -import shutil -import subprocess -import tempfile -from pathlib import Path - - -def run(command, expected_status=0): - result = subprocess.run(command, capture_output=True, text=True, timeout=60) - if result.returncode != expected_status: - raise RuntimeError( - f"{command} returned {result.returncode}, expected {expected_status}\n" - f"{result.stdout}\n{result.stderr}" - ) - return result.stdout.strip() - - -def main(probe: Path) -> None: - from export_bridge_fixture import export_weighted_fixture - - architecture = run([str(probe), "architecture"]) - with tempfile.TemporaryDirectory(prefix="coreai-lifetime-") as temporary: - root = Path(temporary) - for kind in ("portable", "compiled"): - directory = root / kind - export_weighted_fixture(directory) - source = directory / "model.aimodel" - if kind == "compiled": - compiled = directory / "model.aimodelc" - run( - [ - "xcrun", "coreai-build", "compile", str(source), - "--output", str(compiled), "--platform", "macOS", - "--architecture", architecture, "--min-deployment-version", "27.0", - ] - ) - shutil.rmtree(source) - source = compiled - bookmark = directory / "saved.bookmark" - arguments = [str(source), str(directory / "fixture.json"), str(bookmark)] - try: - print(run([str(probe), "specialize", *arguments]), flush=True) - if source.exists() or not bookmark.is_file(): - raise AssertionError("Expected a saved bookmark and no original source") - for _ in range(2): - print(run([str(probe), "restore", *arguments]), flush=True) - finally: - if bookmark.is_file(): - run([str(probe), "evict", *arguments]) - run([str(probe), "restore", *arguments], expected_status=3) - print(f"{kind}: persistent cache lifetime and eviction control passed", flush=True) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("probe", type=Path) - main(parser.parse_args().probe.resolve()) From 818a7857830d5aabc8367ea51ffa53b56a0c920b Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Tue, 27 Oct 2026 17:19:27 -0700 Subject: [PATCH 4/6] [CoreAI] Log acquisition and durable bookmark milestones Add key-correlated Info logs for lookup, restore hits and misses, specialization start and success, and durable bookmark publication. Keep SDK-internal reuse distinct from observable bookmark hits and cover successful and failed acquisition paths. Test Plan: All three acquisition-layer CTest targets passed with Info logging enabled, including six new lifecycle-log tests. No dependency on the later cache-clear API. --- backends/apple/coreai/README.md | 17 ++- .../coreai/runtime/coreai_load_coordinator.mm | 26 +++- .../runtime/test/coreai_acquisition_test.mm | 120 +++++++++++++++++- 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 5b3f24edd62..e4bdc0368d4 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -146,7 +146,7 @@ A healthy bookmark hit does not consult NDS and attempts to clean leftover staging. On a missing bookmark or confirmed SDK miss, the coordinator prepares the source, specializes it and atomically writes the returned bookmark bytes. After durable publication, it attempts to remove the entire keyed staging -directory while holding the per-key lock. Persistent SDK caching supports +directory while holding the key's stripe lock. Persistent SDK caching supports inference and bookmark restoration without that extracted copy. Keep the packaged named data available: a later SDK cache miss requires source materialization again. There is no secondary source-URL cache lookup. @@ -226,6 +226,21 @@ Ninja builds use one architecture per build directory. Current runtime support covers arm64 macOS and iOS device builds. x86_64 builds are blocked by Swift `Float16` availability, and the tested iOS simulator SDKs do not contain Core AI. +### Lifecycle logging + +Build with `EXECUTORCH_ENABLE_LOGGING=ON` and `EXECUTORCH_LOG_LEVEL=Info` to +observe cache lookup, bookmark restoration hits, missing-bookmark versus +SDK-confirmed misses, specialization start/success, and durable bookmark saves. Each message carries the cache key so events from +multiple partitions can be correlated. These messages do not include bookmark +contents, model data, or per-inference logs. + +A cache hit means that the SDK successfully restored a model from its bookmark. +Specialization start means a request to the SDK's persistent default cache; the +SDK does not report whether that request compiled afresh or reused an internal +artifact. The durable-save message confirms local bookmark publication, +including its final directory flush, not independent verification of SDK-private +artifact storage. Failed operations do not emit their success messages. + ## Host Tests On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index 56f4b5ade74..d239ccb0f64 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -92,7 +92,9 @@ void cleanup_source(const BookmarkLock& lock) { auto key = bookmark_key(selected, platform, architecture); if (!key.ok()) return key.error(); - auto locked = lock_bookmark(root, key.get()); + NSString* cache_key = key.get(); + ET_LOG(Info, "Core AI cache lookup: key=%s", cache_key.UTF8String); + auto locked = lock_bookmark(root, cache_key); if (!locked.ok()) return locked.error(); auto bookmark = read_bookmark(*locked.get()); @@ -103,9 +105,20 @@ void cleanup_source(const BookmarkLock& lock) { if (!model.ok()) return model.error(); if (model.get() != nil) { + ET_LOG( + Info, + "Core AI cache hit: restored model from bookmark, key=%s", + key.get().UTF8String); cleanup_source(*locked.get()); return model; } + ET_LOG( + Info, + "Core AI cache miss: SDK could not restore bookmark, key=%s", + key.get().UTF8String); + } else { + ET_LOG( + Info, "Core AI cache miss: no bookmark, key=%s", key.get().UTF8String); } auto staging = prepare_bookmark_staging(*locked.get()); if (!staging.ok()) @@ -120,14 +133,25 @@ void cleanup_source(const BookmarkLock& lock) { return source.error(); NSURL* url = source.get(); auto model = acquire(^(ETCoreAIAcquisitionCompletion completion) { + ET_LOG( + Info, + "Core AI specialization started: kind=%s, key=%s", + selected.aot_compiled ? "aot" : "source", + cache_key.UTF8String); [loader specializeModelAtURL:url completion:completion]; }); if (!model.ok()) return model.error(); ET_CHECK_OR_RETURN_ERROR( model.get() != nil, Internal, "Core AI specialization returned no model"); + ET_LOG( + Info, "Core AI specialization succeeded: key=%s", key.get().UTF8String); ET_CHECK_OK_OR_RETURN_ERROR( write_bookmark(*locked.get(), [model.get() copyBookmarkData])); + ET_LOG( + Info, + "Core AI cache bookmark saved durably: key=%s", + key.get().UTF8String); cleanup_source(*locked.get()); return model; } diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index a514a7ce393..b70ae5380d6 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -8,8 +8,11 @@ #include "coreai_acquisition_fixture.h" +#include #include #include +#include +#include #include #include "coreai_fault_scope.h" #include "coreai_filesystem_fixture.h" @@ -85,8 +88,7 @@ BOOL staging_key_exists() { } NSURL* lock_url() { - return [[root.url URLByAppendingPathComponent:@"locks"] - URLByAppendingPathComponent:[key stringByAppendingString:@".lock"]]; + return test_bookmark_lock_url(root.url, key); } ino_t lock_inode() { @@ -679,4 +681,118 @@ void expect_parallel(bool same_key) { EXPECT_FALSE(staging_key_exists()); } +class CoreAILifecycleLogTest : public CoreAIAcquisitionTest { + protected: + void SetUp() override { + CoreAIAcquisitionTest::SetUp(); + if (!ET_LOG_ENABLED || LogLevel::Info < LogLevel::ET_MIN_LOG_LEVEL) { + GTEST_SKIP() << "Info logging is compiled out"; + } + } + + template + std::string capture(Operation operation, Error expected = Error::Ok) { + ::testing::internal::CaptureStderr(); + const auto error = operation(); + auto logs = ::testing::internal::GetCapturedStderr(); + EXPECT_EQ(error, expected); + return logs; + } + + void expect_order( + const std::string& logs, + std::initializer_list events) { + size_t position = 0; + for (const char* event : events) { + const auto found = logs.find(event, position); + ASSERT_NE(found, std::string::npos) << event << "\n" << logs; + position = found + 1; + } + EXPECT_NE(logs.find(key.UTF8String), std::string::npos); + } +}; + +TEST_F(CoreAILifecycleLogTest, ColdAcquisitionLogsValidatedMilestonesInOrder) { + BookmarkLoaderScope fake; + const auto logs = capture([&] { return acquire(fake.loader, &data); }); + expect_order( + logs, + { + "Core AI cache lookup:", + "Core AI cache miss: no bookmark", + "Core AI specialization started:", + "Core AI specialization succeeded:", + "Core AI cache bookmark saved durably:", + }); + EXPECT_EQ(logs.find("Core AI cache hit:"), std::string::npos); + EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); +} + +TEST_F(CoreAILifecycleLogTest, WarmHitDoesNotLogSpecializationOrSave) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + const auto logs = capture([&] { return acquire(fake.loader, nullptr); }); + expect_order( + logs, + { + "Core AI cache lookup:", + "Core AI cache hit: restored model from bookmark", + }); + EXPECT_EQ(logs.find("Core AI cache miss:"), std::string::npos); + EXPECT_EQ(logs.find("Core AI specialization"), std::string::npos); + EXPECT_EQ(logs.find("Core AI cache bookmark saved"), std::string::npos); +} + +TEST_F(CoreAILifecycleLogTest, ConfirmedMissLogsRespecialization) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + fake.loader.restoreMiss = YES; + const auto logs = capture([&] { return acquire(fake.loader, &data); }); + expect_order( + logs, + { + "Core AI cache miss: SDK could not restore bookmark", + "Core AI specialization started:", + "Core AI specialization succeeded:", + "Core AI cache bookmark saved durably:", + }); + EXPECT_EQ(logs.find("Core AI cache hit:"), std::string::npos); +} + +TEST_F(CoreAILifecycleLogTest, RestoreErrorIsNotReportedAsHitOrMiss) { + BookmarkLoaderScope fake; + ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); + fake.loader.restoreError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + const auto logs = + capture([&] { return acquire(fake.loader, &data); }, Error::Internal); + EXPECT_EQ(logs.find("Core AI cache hit:"), std::string::npos); + EXPECT_EQ(logs.find("Core AI cache miss:"), std::string::npos); + EXPECT_EQ(logs.find("Core AI specialization"), std::string::npos); + EXPECT_EQ(logs.find("Core AI cache bookmark saved"), std::string::npos); +} + +TEST_F(CoreAILifecycleLogTest, SpecializationErrorDoesNotLogSuccessOrSave) { + BookmarkLoaderScope fake; + fake.loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:EIO + userInfo:nil]; + const auto logs = + capture([&] { return acquire(fake.loader, &data); }, Error::Internal); + EXPECT_NE(logs.find("Core AI specialization started:"), std::string::npos); + EXPECT_EQ(logs.find("Core AI specialization succeeded:"), std::string::npos); + EXPECT_EQ(logs.find("Core AI cache bookmark saved"), std::string::npos); +} + +TEST_F(CoreAILifecycleLogTest, BeforeSdkFaultDoesNotLogSpecializationStart) { + BookmarkLoaderScope fake; + StorageFaultScope fault(StorageOperation::BeforeSDK); + const auto logs = + capture([&] { return acquire(fake.loader, &data); }, Error::AccessFailed); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(logs.find("Core AI specialization"), std::string::npos); + EXPECT_EQ(logs.find("Core AI cache bookmark saved"), std::string::npos); +} + } // namespace From e17fe4df26c9020c9b02ca871076b9c7daf351ba Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Thu, 29 Oct 2026 15:41:37 -0700 Subject: [PATCH 5/6] [CoreAI] Keep acquisition context and cleanup ownership intact Keep host probing separate from manifest parsing and carry the selected runtime context through acquisition. Preserve borrowed-source lifetimes across SDK callbacks and keep staging cleanup bound to the locked directory with strict preflight validation. Test Plan: Scoped host, Swift bridge, and SDK source-lifetime CTest targets passed. Regression tests cover no-op missing staging, strict preflight rejection, and cleanup after root-path replacement. Scoped lint and formatting passed. No Python suite rerun for native-only changes. --- .../apple/coreai/runtime/coreai_bookmarks.h | 3 +- .../apple/coreai/runtime/coreai_bookmarks.mm | 14 ++ .../coreai/runtime/coreai_fault_injection.h | 2 + .../coreai/runtime/coreai_load_coordinator.h | 17 ++- .../coreai/runtime/coreai_load_coordinator.mm | 128 +++++++++++------- .../runtime/test/SDKSourceLifetimeTest.swift | 29 ++-- .../runtime/test/coreai_acquisition_fixture.h | 10 +- .../test/coreai_acquisition_fixture.mm | 43 +++--- .../runtime/test/coreai_acquisition_test.mm | 105 +++++++------- .../runtime/test/coreai_bookmark_test.mm | 52 +++++++ .../coreai/runtime/test/coreai_fake_loader.mm | 15 ++ 11 files changed, 274 insertions(+), 144 deletions(-) diff --git a/backends/apple/coreai/runtime/coreai_bookmarks.h b/backends/apple/coreai/runtime/coreai_bookmarks.h index bdbb094a120..bf5440f8500 100644 --- a/backends/apple/coreai/runtime/coreai_bookmarks.h +++ b/backends/apple/coreai/runtime/coreai_bookmarks.h @@ -36,13 +36,14 @@ class CacheEntryLock final { NSString* key() const { return key_; } - // Missing is nil. // Missing is nil. Empty, oversized, inaccessible, or nonregular files fail // closed. runtime::Result read_bookmark() const; runtime::Error write_bookmark(NSData* data) const; // Returns the staging parent. Source preparation appends key and bundle. runtime::Result prepare_staging_root() const; + // Missing staging is a no-op; unexpected entry types fail before deletion. + runtime::Error remove_staging() const; private: friend runtime::Result> diff --git a/backends/apple/coreai/runtime/coreai_bookmarks.mm b/backends/apple/coreai/runtime/coreai_bookmarks.mm index 8243fb77df2..393653028af 100644 --- a/backends/apple/coreai/runtime/coreai_bookmarks.mm +++ b/backends/apple/coreai/runtime/coreai_bookmarks.mm @@ -268,6 +268,20 @@ FileDescriptor file(retry_eintr([&] { return [root_path_ stringByAppendingPathComponent:kStagingDirectory]; } +Error CacheEntryLock::remove_staging() const { + auto opened = open_child( + root_.get(), kStagingDirectory, StorageCreationMode::OpenExisting); + if (!opened.ok()) { + return opened.error(); + } + FileDescriptor staging(opened.get()); + if (staging.get() < 0) { + return Error::Ok; + } + return remove_storage_directory( + staging.get(), key_, StorageRemovalPolicy::Strict); +} + Result> lock_cache_entry( NSString* root, NSString* key) { diff --git a/backends/apple/coreai/runtime/coreai_fault_injection.h b/backends/apple/coreai/runtime/coreai_fault_injection.h index f8d1cbd412f..4981d05cdd6 100644 --- a/backends/apple/coreai/runtime/coreai_fault_injection.h +++ b/backends/apple/coreai/runtime/coreai_fault_injection.h @@ -21,6 +21,8 @@ enum class FaultPoint { OpenStagingDirectory, BeforeCreateDirectory, BeforeCacheEntryLock, + BeforeSDK, + AfterSDK, }; #if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.h b/backends/apple/coreai/runtime/coreai_load_coordinator.h index 018117a18f9..fa190084522 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.h +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.h @@ -7,19 +7,26 @@ */ #pragma once +#include #import "ETCoreAIBridge.h" -#import "coreai_bookmarks.h" +#import "coreai_manifest.h" namespace executorch::backends::coreai { -// The key lock outlives all SDK callbacks and raw bookmark publication. +struct RuntimeAssets final { + SelectedBundle bundle; + NSString* const platform; + NSString* const architecture; +}; + +runtime::Result select_runtime_assets(const Manifest& manifest); + +// The cache entry lock outlives all SDK callbacks and bookmark publication. // Cleanup after a restore hit or durable publication is best-effort. runtime::Result> acquire_bookmark_model( - const Manifest& selected, + const RuntimeAssets& assets, const runtime::NamedDataMap* named_data, NSString* root, - NSString* platform, - NSString* architecture, id loader); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index d239ccb0f64..5903a2393d9 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -6,27 +6,34 @@ * LICENSE file in the root directory of this source tree. */ #import "coreai_load_coordinator.h" -#import "coreai_storage.h" + +#import "coreai_bookmarks.h" +#import "coreai_cache_key.h" +#import "coreai_fault_injection.h" +#import "coreai_source_bundle.h" + +#include +#include namespace executorch::backends::coreai { namespace { using runtime::Error; using runtime::Result; -Error sdk_error(NSError* error) { +Error acquisition_error(NSError* error) { ET_LOG( Error, - "Core AI SDK error (%s, %ld): %s", + "Core AI acquisition error (%s, %ld): %s", error.domain.UTF8String, static_cast(error.code), error.description.UTF8String); return Error::Internal; } -Result> acquire( +Result> wait_for_acquisition( void (^operation)(ETCoreAIAcquisitionCompletion)) { ET_CHECK_OR_RETURN_ERROR( - storage_fault(StorageOperation::BeforeSDK) == 0, + fault_point(FaultPoint::BeforeSDK) == 0, AccessFailed, "Core AI acquisition interrupted"); dispatch_semaphore_t ready = dispatch_semaphore_create(0); @@ -42,15 +49,18 @@ Error sdk_error(NSError* error) { error = failure; dispatch_semaphore_signal(ready); }); - // Do not abandon asynchronous source access on cancellation or timeout. + // The SDK may still be reading caller-owned source after a bounded timeout. dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); id acquired = model; + NSError* failure = error; // A loader may retain the completion block after invoking it. model = nil; - if (error != nil) - return sdk_error(error); + error = nil; + if (failure != nil) { + return acquisition_error(failure); + } ET_CHECK_OR_RETURN_ERROR( - storage_fault(StorageOperation::AfterSDK) == 0, + fault_point(FaultPoint::AfterSDK) == 0, AccessFailed, "Core AI acquisition interrupted"); ET_CHECK_OR_RETURN_ERROR( @@ -64,13 +74,13 @@ Error sdk_error(NSError* error) { Result> restore( id loader, NSData* bookmark) { - return acquire(^(ETCoreAIAcquisitionCompletion completion) { + return wait_for_acquisition(^(ETCoreAIAcquisitionCompletion completion) { [loader restoreModelFromBookmark:bookmark completion:completion]; }); } -void cleanup_source(const BookmarkLock& lock) { - const auto error = remove_bookmark_staging(lock); +void cleanup_source(const CacheEntryLock& cache_entry) { + const Error error = cache_entry.remove_staging(); if (error != Error::Ok) { ET_LOG( Error, @@ -80,78 +90,104 @@ void cleanup_source(const BookmarkLock& lock) { } } // namespace +Result select_runtime_assets(const Manifest& manifest) { + ET_CHECK_OR_RETURN_ERROR( + [NSProcessInfo.processInfo + isOperatingSystemAtLeastVersion:manifest.minimum_version], + DelegateInvalidCompatibility, + "Core AI model requires a newer operating system"); + ET_CHECK_OR_RETURN_ERROR( + ETCoreAIIsAvailable(), + NotSupported, + "Core AI requires macOS 27 or iOS 27"); +#if TARGET_OS_OSX + NSString* platform = @"macOS"; +#elif TARGET_OS_IOS + NSString* platform = @"iOS"; +#else + NSString* platform = @"unsupported"; +#endif + NSString* architecture = ETCoreAIDeviceArchitectureName(); + auto selected = select_assets(manifest, architecture, platform); + if (!selected.ok()) { + return selected.error(); + } + return RuntimeAssets{ + std::move(selected.get()), [platform copy], [architecture copy]}; +} + Result> acquire_bookmark_model( - const Manifest& selected, + const RuntimeAssets& assets, const runtime::NamedDataMap* named_data, NSString* root, - NSString* platform, - NSString* architecture, id loader) { ET_CHECK_OR_RETURN_ERROR( loader != nil, InvalidArgument, "Missing Core AI model loader"); - auto key = bookmark_key(selected, platform, architecture); - if (!key.ok()) + NSString* specialization = [loader.specializationIdentifier copy]; + auto key = cache_variant_key( + assets.bundle, assets.platform, assets.architecture, specialization); + if (!key.ok()) { return key.error(); + } NSString* cache_key = key.get(); ET_LOG(Info, "Core AI cache lookup: key=%s", cache_key.UTF8String); - auto locked = lock_bookmark(root, cache_key); - if (!locked.ok()) + auto locked = lock_cache_entry(root, cache_key); + if (!locked.ok()) { return locked.error(); - auto bookmark = read_bookmark(*locked.get()); - if (!bookmark.ok()) + } + auto bookmark = locked.get()->read_bookmark(); + if (!bookmark.ok()) { return bookmark.error(); + } if (bookmark.get() != nil) { auto model = restore(loader, bookmark.get()); - if (!model.ok()) + if (!model.ok()) { return model.error(); + } if (model.get() != nil) { ET_LOG( Info, "Core AI cache hit: restored model from bookmark, key=%s", - key.get().UTF8String); + cache_key.UTF8String); cleanup_source(*locked.get()); return model; } ET_LOG( Info, "Core AI cache miss: SDK could not restore bookmark, key=%s", - key.get().UTF8String); + cache_key.UTF8String); } else { ET_LOG( - Info, "Core AI cache miss: no bookmark, key=%s", key.get().UTF8String); + Info, "Core AI cache miss: no bookmark, key=%s", cache_key.UTF8String); } - auto staging = prepare_bookmark_staging(*locked.get()); - if (!staging.ok()) - return staging.error(); auto source = prepare_source_bundle( - selected, - named_data, - staging.get(), - key.get(), - /*replace_corrupt=*/true); - if (!source.ok()) + assets.bundle, named_data, *locked.get(), CorruptBundlePolicy::Replace); + if (!source.ok()) { return source.error(); + } NSURL* url = source.get(); - auto model = acquire(^(ETCoreAIAcquisitionCompletion completion) { - ET_LOG( - Info, - "Core AI specialization started: kind=%s, key=%s", - selected.aot_compiled ? "aot" : "source", - cache_key.UTF8String); - [loader specializeModelAtURL:url completion:completion]; - }); - if (!model.ok()) + auto model = + wait_for_acquisition(^(ETCoreAIAcquisitionCompletion completion) { + ET_LOG( + Info, + "Core AI specialization started: kind=%s, key=%s", + assets.bundle.aot_compiled ? "aot" : "source", + cache_key.UTF8String); + [loader specializeModelAtURL:url completion:completion]; + }); + if (!model.ok()) { return model.error(); + } ET_CHECK_OR_RETURN_ERROR( model.get() != nil, Internal, "Core AI specialization returned no model"); ET_LOG( - Info, "Core AI specialization succeeded: key=%s", key.get().UTF8String); + Info, "Core AI specialization succeeded: key=%s", cache_key.UTF8String); ET_CHECK_OK_OR_RETURN_ERROR( - write_bookmark(*locked.get(), [model.get() copyBookmarkData])); + locked.get()->write_bookmark([model.get() copyBookmarkData])); ET_LOG( Info, "Core AI cache bookmark saved durably: key=%s", - key.get().UTF8String); + cache_key.UTF8String); cleanup_source(*locked.get()); return model; } diff --git a/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift index 505a8d39284..b2c24401297 100644 --- a/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift +++ b/backends/apple/coreai/runtime/test/SDKSourceLifetimeTest.swift @@ -86,8 +86,8 @@ private func checkInference(_ model: any ETCoreAIPreparedModel, fixture: Fixture ETCoreAIInputTensor( bytes: $0.bytes, byteCount: UInt($0.length), shape: [2], scalarType: .float32) } - let outputs: [ETCoreAITensor] = try withExtendedLifetime(storage) { - try waitForResult { completed in + try withExtendedLifetime(storage) { + let outputs: [ETCoreAITensor] = try waitForResult { completed in session.executeInputs(inputs) { outputs, error in if let error { completed(.failure(error)) @@ -98,20 +98,19 @@ private func checkInference(_ model: any ETCoreAIPreparedModel, fixture: Fixture } } } - } - guard outputs.count == fixture.outputs.count else { - throw Failure.incorrectOutput("count") - } - for (index, output) in outputs.enumerated() { - let name = fixture.outputs[index] - guard output.shape == [2], output.scalarType == .float32, - output.data.count == 8, fixture.expected[index].count == 2 - else { throw Failure.incorrectOutput(name) } - let values = output.data.withUnsafeBytes { - [$0.loadUnaligned(as: Float.self), $0.loadUnaligned(fromByteOffset: 4, as: Float.self)] + guard outputs.count == fixture.outputs.count else { + throw Failure.incorrectOutput("count") } - guard zip(values, fixture.expected[index]).allSatisfy({ abs($0 - $1) < 0.001 }) else { - throw Failure.incorrectOutput("\(name): \(values), expected \(fixture.expected[index])") + for (index, output) in outputs.enumerated() { + let name = fixture.outputs[index] + guard output.shape == [2], output.scalarType == .float32, + output.byteCount == 8, fixture.expected[index].count == 2 + else { throw Failure.incorrectOutput(name) } + var values = [Float](repeating: 0, count: 2) + values.withUnsafeMutableBytes { output.copyBytes(to: $0.baseAddress) } + guard zip(values, fixture.expected[index]).allSatisfy({ abs($0 - $1) < 0.001 }) else { + throw Failure.incorrectOutput("\(name): \(values), expected \(fixture.expected[index])") + } } } } diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h index 963adfd784a..edd5eea7f53 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h @@ -10,14 +10,14 @@ #include "coreai_bookmark_fixture.h" #include "coreai_fake_loader.h" +#include "coreai_fault_injection.h" #include "coreai_source_fixture.h" -#include "coreai_storage.h" namespace executorch::backends::coreai::testing { runtime::Result acquisition_staged_url( NSURL* root, - const Manifest& manifest); + const SelectedBundle& bundle); struct ParallelAcquisitionObservation { int creation[2] = {-1, -1}; @@ -36,15 +36,15 @@ ParallelAcquisitionObservation parallel_acquisitions( ScopedFakeBridgeState& bridge, BookmarkFakeLoader* loader, NSURL* root, - const Manifest& first, - const Manifest& second, + const SelectedBundle& first, + const SelectedBundle& second, TestData& data, bool same_key); ::testing::AssertionResult spawn_acquisition_crash( BookmarkChild& child, NSURL* root, - StorageOperation boundary, + FaultPoint boundary, NSDictionary* manifest); ::testing::AssertionResult spawn_parallel_acquisition( BookmarkChild& child, diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm index 7446e9ffbe6..2d951f0c168 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -31,7 +31,7 @@ dispatch_time_t deadline() { } struct AcquisitionWorker { - Manifest manifest; + const SelectedBundle* bundle = nullptr; TestData* data = nullptr; NSString* root = nil; BookmarkFakeLoader* loader = nil; @@ -44,13 +44,9 @@ dispatch_time_t deadline() { auto* worker = static_cast(opaque); dispatch_semaphore_signal(worker->started); @autoreleasepool { + RuntimeAssets assets{*worker->bundle, @"macOS", @"arch_b"}; auto acquired = acquire_bookmark_model( - worker->manifest, - worker->data, - worker->root, - @"macOS", - @"arch_b", - worker->loader); + assets, worker->data, worker->root, worker->loader); worker->error = acquired.ok() ? Error::Ok : acquired.error(); worker->model = acquired.ok() && acquired.get() != nil; } @@ -124,21 +120,23 @@ bool send_child_byte(int fd, char value) { } } // namespace -Result acquisition_staged_url(NSURL* root, const Manifest& manifest) { - auto key = test_bookmark_key(manifest); +Result acquisition_staged_url( + NSURL* root, + const SelectedBundle& bundle) { + auto key = test_cache_key(bundle); if (!key.ok()) return key.error(); return [[[root URLByAppendingPathComponent:@"staging"] URLByAppendingPathComponent:key.get()] - URLByAppendingPathComponent:manifest.path.lastPathComponent]; + URLByAppendingPathComponent:bundle.bundle_name]; } ParallelAcquisitionObservation parallel_acquisitions( ScopedFakeBridgeState& bridge, BookmarkFakeLoader* loader, NSURL* root, - const Manifest& first, - const Manifest& second, + const SelectedBundle& first, + const SelectedBundle& second, TestData& data, bool same_key) { ParallelAcquisitionObservation observation; @@ -153,7 +151,7 @@ ParallelAcquisitionObservation parallel_acquisitions( }; for (int i = 0; i < 2; ++i) { auto& worker = group.workers[i]; - worker.manifest = i == 0 ? first : second; + worker.bundle = i == 0 ? &first : &second; worker.data = &data; worker.root = root.path; worker.loader = loader; @@ -187,7 +185,7 @@ ParallelAcquisitionObservation parallel_acquisitions( ::testing::AssertionResult spawn_acquisition_crash( BookmarkChild& child, NSURL* root, - StorageOperation boundary, + FaultPoint boundary, NSDictionary* manifest) { // Serialize the shared fixture in the parent; its builders contain GTest // checks. @@ -222,8 +220,8 @@ ParallelAcquisitionObservation parallel_acquisitions( namespace { [[noreturn]] void parallel_child( NSString* root, - const Manifest& first, - const Manifest& second, + const SelectedBundle& first, + const SelectedBundle& second, bool same_key, int fd) { ScopedFakeBridgeState bridge; @@ -303,13 +301,12 @@ int acquisition_child_mode(int argc, char** argv) { id dictionary = payload[@"manifest"]; if (![dictionary isKindOfClass:NSDictionary.class]) return 3; - StorageOperation boundary = StorageOperation::AfterSDK; + FaultPoint boundary = FaultPoint::AfterSDK; if (crash) { if (![number isKindOfClass:NSNumber.class]) return 3; - boundary = static_cast([number intValue]); - if (boundary != StorageOperation::BeforeSDK && - boundary != StorageOperation::AfterSDK) + boundary = static_cast([number intValue]); + if (boundary != FaultPoint::BeforeSDK && boundary != FaultPoint::AfterSDK) return 4; } auto parsed = parse_manifest(encode(dictionary)); @@ -359,12 +356,12 @@ int acquisition_child_mode(int argc, char** argv) { TestData data; StorageFaultScope fault(boundary); fault.armed = false; - fault.observe = ^(StorageOperation event) { + fault.observe = ^(FaultPoint event) { if (event == boundary) _exit(73); }; - auto result = acquire_bookmark_model( - selected.get(), &data, root, @"macOS", @"arch_b", loader); + RuntimeAssets assets{selected.get(), @"macOS", @"arch_b"}; + auto result = acquire_bookmark_model(assets, &data, root, loader); _exit(result.ok() ? 9 : 10); } } diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index b70ae5380d6..e2673f38e70 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include "coreai_fault_scope.h" @@ -42,29 +43,40 @@ - (void)specializeModelAtURL:(NSURL*)url namespace { +TEST(CoreAIRuntimeAssetsTest, ProbesHostAndSelectsImmutableBundle) { + auto parsed = parse_manifest(encode(aot_manifest_dict())); + ASSERT_TRUE(parsed.ok()); + auto assets = select_runtime_assets(parsed.get()); + ASSERT_TRUE(assets.ok()); + EXPECT_TRUE([assets->platform isEqual:@"macOS"]); + EXPECT_TRUE([assets->architecture isEqual:@"arch_b"]); + EXPECT_TRUE([assets->bundle.bundle_name isEqual:@"model.arch_b.aimodelc"]); + EXPECT_TRUE([assets->bundle.model_hash isEqual:parsed->model_hash]); +} + class CoreAIAcquisitionTest : public ::testing::Test { protected: // Declared first so each case's loader, models and data die before it. ScopedFakeBridgeState bridge; TestDirectory root; TestData data; - Manifest manifest; + std::optional assets; NSString* key = nil; void SetUp() override { ASSERT_NE(root.url, nil); - auto selected = bookmark_manifest(); + auto selected = bookmark_bundle(); ASSERT_TRUE(selected.ok()); - manifest = selected.get(); - auto identity = test_bookmark_key(manifest); + assets.emplace(RuntimeAssets{selected.get(), @"macOS", @"arch_b"}); + auto identity = test_cache_key(assets->bundle); ASSERT_TRUE(identity.ok()); key = identity.get(); } Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { @autoreleasepool { - auto model = acquire_bookmark_model( - manifest, named_data, root.url.path, @"macOS", @"arch_b", loader); + auto model = + acquire_bookmark_model(*assets, named_data, root.url.path, loader); if (!model.ok()) return model.error(); return model.get() != nil ? Error::Ok : Error::Internal; @@ -88,7 +100,7 @@ BOOL staging_key_exists() { } NSURL* lock_url() { - return test_bookmark_lock_url(root.url, key); + return test_cache_lock_url(root.url, key); } ino_t lock_inode() { @@ -105,7 +117,7 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->specializations.load(), 1); EXPECT_EQ(fake.loader->restores.load(), 0); EXPECT_EQ(data.attempts.load(), 1); - auto staged = acquisition_staged_url(root.url, manifest); + auto staged = acquisition_staged_url(root.url, assets->bundle); ASSERT_TRUE(staged.ok()); EXPECT_TRUE([fake.loader.lastSpecializeURL isEqual:staged.get().path]); EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); @@ -203,8 +215,7 @@ ino_t lock_inode() { ASSERT_TRUE(asset_tree_snapshot(root.url, before)); Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model( - manifest, &data, root.url.path, @"macOS", @"arch_b", nil); + auto result = acquire_bookmark_model(*assets, &data, root.url.path, nil); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -227,8 +238,7 @@ ino_t lock_inode() { loader.restoreStatus = ETCoreAIAcquisitionStatusHit; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model( - manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -253,8 +263,7 @@ ino_t lock_inode() { loader.restoreModel = model; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model( - manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -277,8 +286,7 @@ ino_t lock_inode() { loader.restoreStatus = ETCoreAIAcquisitionStatusError; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model( - manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -292,8 +300,7 @@ ino_t lock_inode() { ASSERT_NE(loader, nil); Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model( - manifest, &data, root.url.path, @"macOS", @"arch_b", loader); + auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -308,12 +315,12 @@ ino_t lock_inode() { CoreAIAcquisitionTest, ColdSuccessRemovesWholeStagingAfterBookmarkFlush) { BookmarkLoaderScope fake; - __block std::vector ops; + __block std::vector ops; Error error = Error::Internal; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); fault.armed = false; - fault.observe = ^(StorageOperation operation) { + fault.observe = ^(FaultPoint operation) { ops.push_back(operation); }; error = acquire(fake.loader, &data); @@ -321,7 +328,7 @@ ino_t lock_inode() { ASSERT_EQ(error, Error::Ok); EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); EXPECT_FALSE(staging_key_exists()); - auto staged = acquisition_staged_url(root.url, manifest); + auto staged = acquisition_staged_url(root.url, assets->bundle); ASSERT_TRUE(staged.ok()); EXPECT_FALSE( [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); @@ -329,9 +336,9 @@ ino_t lock_inode() { size_t removes = 0; size_t first_remove = ops.size(); for (size_t i = 0; i < ops.size(); ++i) { - if (ops[i] == StorageOperation::FullSync) + if (ops[i] == FaultPoint::FullSync) last_flush = i; - if (ops[i] == StorageOperation::Remove) { + if (ops[i] == FaultPoint::Remove) { ++removes; if (first_remove == ops.size()) first_remove = i; @@ -343,10 +350,10 @@ ino_t lock_inode() { TEST_F(CoreAIAcquisitionTest, SpecializeErrorPerformsNoCleanup) { BookmarkLoaderScope fake; - __block std::vector ops; - StorageFaultScope fault(StorageOperation::Remove); + __block std::vector ops; + StorageFaultScope fault(FaultPoint::Remove); fault.armed = false; - fault.observe = ^(StorageOperation operation) { + fault.observe = ^(FaultPoint operation) { ops.push_back(operation); }; fake.loader.specializeError = [NSError errorWithDomain:NSPOSIXErrorDomain @@ -355,7 +362,7 @@ ino_t lock_inode() { EXPECT_EQ(acquire(fake.loader, &data), Error::Internal); size_t removes = 0; for (auto operation : ops) { - if (operation == StorageOperation::Remove) + if (operation == FaultPoint::Remove) ++removes; } EXPECT_EQ(removes, 0u); @@ -365,14 +372,14 @@ ino_t lock_inode() { TEST_F(CoreAIAcquisitionTest, BookmarkFinalFlushFailurePerformsNoCleanup) { BookmarkLoaderScope fake; - __block std::vector ops; + __block std::vector ops; Error error = Error::Internal; { // Cold source contributes two FullSyncs and the bookmark two more, so // index four is the post-rename bookmark flush. - StorageFaultScope fault(StorageOperation::FullSync); + StorageFaultScope fault(FaultPoint::FullSync); fault.match_index = 4; - fault.observe = ^(StorageOperation operation) { + fault.observe = ^(FaultPoint operation) { ops.push_back(operation); }; error = acquire(fake.loader, &data); @@ -381,11 +388,11 @@ ino_t lock_inode() { EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); size_t removes = 0; for (auto operation : ops) { - if (operation == StorageOperation::Remove) + if (operation == FaultPoint::Remove) ++removes; } EXPECT_EQ(removes, 0u); - auto staged = acquisition_staged_url(root.url, manifest); + auto staged = acquisition_staged_url(root.url, assets->bundle); ASSERT_TRUE(staged.ok()); EXPECT_TRUE( [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); @@ -398,7 +405,7 @@ ino_t lock_inode() { NSData* bookmark = nil; int specializations = 0; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); fault.match_index = 1; EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); @@ -429,7 +436,7 @@ ino_t lock_inode() { NSData* bookmark = nil; int specializations = 0; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); fault.match_index = 2; EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); @@ -459,11 +466,11 @@ ino_t lock_inode() { NSData* bookmark = nil; int specializations = 0; { - StorageFaultScope scope(StorageOperation::SyncDirectory); + StorageFaultScope scope(FaultPoint::SyncDirectory); scope.armed = false; StorageFaultScope* arming = &scope; - scope.observe = ^(StorageOperation operation) { - if (operation == StorageOperation::Remove) + scope.observe = ^(FaultPoint operation) { + if (operation == FaultPoint::Remove) arming->armed = true; }; EXPECT_EQ(acquire(fake.loader, &data), Error::Ok); @@ -489,11 +496,11 @@ ino_t lock_inode() { TEST_F(CoreAIAcquisitionTest, WarmHitCleanupFailureStillReturnsModel) { BookmarkLoaderScope fake; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); } NSData* original = saved(); - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); EXPECT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fault.hits, 1); EXPECT_EQ(fake.loader->restores.load(), 1); @@ -506,7 +513,7 @@ ino_t lock_inode() { TEST_F(CoreAIAcquisitionTest, PartialCleanupThenMissRebuildsSource) { BookmarkLoaderScope fake; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); fault.match_index = 2; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); @@ -528,14 +535,14 @@ ino_t lock_inode() { DamagedSourceCleanupFailureDoesNotReadOrSpecialize) { BookmarkLoaderScope fake; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); fault.match_index = 2; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); } NSData* original = saved(); fake.loader.restoreMiss = YES; { - StorageFaultScope fault(StorageOperation::Remove); + StorageFaultScope fault(FaultPoint::Remove); EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); EXPECT_EQ(fault.hits, 1); } @@ -558,7 +565,7 @@ ino_t lock_inode() { TEST_F(CoreAIAcquisitionTest, BeforeSdkFaultNeverInvokesLoader) { BookmarkLoaderScope fake; - StorageFaultScope fault(StorageOperation::BeforeSDK); + StorageFaultScope fault(FaultPoint::BeforeSDK); EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); EXPECT_EQ(fake.loader->specializations.load(), 0); EXPECT_EQ(fake.loader->restores.load(), 0); @@ -593,7 +600,7 @@ ino_t lock_inode() { // Cold and rematerialized sources publish before the bookmark, so the // bookmark rename is the second rename once the first success has // cleaned the source. - StorageFaultScope fault(StorageOperation::Rename); + StorageFaultScope fault(FaultPoint::Rename); fault.match_index = 2; EXPECT_EQ(acquire(fake.loader, &data), Error::AccessFailed); EXPECT_EQ(fault.hits, 1); @@ -649,7 +656,7 @@ void expect_parallel(bool same_key) { TEST_F(CoreAIAcquisitionProcessTest, CrashBeforeSdkRetainsStagingAndRetries) { BookmarkChild child; ASSERT_TRUE(spawn_acquisition_crash( - child, root.url, StorageOperation::BeforeSDK, manifest_dict())); + child, root.url, FaultPoint::BeforeSDK, manifest_dict())); ASSERT_TRUE(child.receive('C')); ASSERT_TRUE(child.expect_exit(73)); EXPECT_EQ(saved(), nil); @@ -664,11 +671,11 @@ void expect_parallel(bool same_key) { TEST_F(CoreAIAcquisitionProcessTest, CrashAfterSdkRetainsStagingAndRetries) { BookmarkChild child; ASSERT_TRUE(spawn_acquisition_crash( - child, root.url, StorageOperation::AfterSDK, manifest_dict())); + child, root.url, FaultPoint::AfterSDK, manifest_dict())); ASSERT_TRUE(child.receive('C')); ASSERT_TRUE(child.expect_exit(73)); EXPECT_EQ(saved(), nil); - auto staged = acquisition_staged_url(root.url, manifest); + auto staged = acquisition_staged_url(root.url, assets->bundle); ASSERT_TRUE(staged.ok()); EXPECT_TRUE( [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); @@ -787,7 +794,7 @@ void expect_order( TEST_F(CoreAILifecycleLogTest, BeforeSdkFaultDoesNotLogSpecializationStart) { BookmarkLoaderScope fake; - StorageFaultScope fault(StorageOperation::BeforeSDK); + StorageFaultScope fault(FaultPoint::BeforeSDK); const auto logs = capture([&] { return acquire(fake.loader, &data); }, Error::AccessFailed); EXPECT_EQ(fake.loader->specializations.load(), 0); diff --git a/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm b/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm index d3a4d46924b..32c002d1b5e 100644 --- a/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm @@ -214,6 +214,58 @@ FileDescriptor file(open( fileExistsAtPath:[expected stringByAppendingPathComponent:key]]); } +TEST_F(CoreAIBookmarkIOTest, CleanupDoesNotCreateMissingStaging) { + StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); + EXPECT_EQ(locked->remove_staging(), Error::Ok); + EXPECT_EQ(fault.matches, 0); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:[root.url URLByAppendingPathComponent:@"staging"].path]); +} + +TEST_F(CoreAIBookmarkIOTest, CleanupRejectsUnexpectedTypesBeforeDeletingFiles) { + auto staging = locked->prepare_staging_root(); + ASSERT_TRUE(staging.ok()); + NSString* entry = [staging.get() stringByAppendingPathComponent:key]; + ASSERT_EQ(mkdir(entry.fileSystemRepresentation, 0700), 0); + NSString* file = [entry stringByAppendingPathComponent:@"data"]; + NSData* bytes = [@"preserved" dataUsingEncoding:NSUTF8StringEncoding]; + ASSERT_TRUE([bytes writeToFile:file atomically:NO]); + NSString* pipe = [entry stringByAppendingPathComponent:@"pipe"]; + ASSERT_EQ(mkfifo(pipe.fileSystemRepresentation, 0600), 0); + + EXPECT_EQ(locked->remove_staging(), Error::InvalidExternalData); + EXPECT_TRUE([[NSData dataWithContentsOfFile:file] isEqual:bytes]); + struct stat info; + ASSERT_EQ(lstat(pipe.fileSystemRepresentation, &info), 0); + EXPECT_TRUE(S_ISFIFO(info.st_mode)); +} + +TEST_F(CoreAIBookmarkIOTest, CleanupUsesLockedRootAfterPathReplacement) { + auto staging = locked->prepare_staging_root(); + ASSERT_TRUE(staging.ok()); + NSString* entry = [staging.get() stringByAppendingPathComponent:key]; + ASSERT_EQ(mkdir(entry.fileSystemRepresentation, 0700), 0); + TestDirectory moved; + ASSERT_NE(moved.url, nil); + ASSERT_EQ( + rename( + root.url.fileSystemRepresentation, + moved.url.fileSystemRepresentation), + 0); + NSURL* replacement = [[root.url URLByAppendingPathComponent:@"staging"] + URLByAppendingPathComponent:key]; + ASSERT_TRUE([NSFileManager.defaultManager createDirectoryAtURL:replacement + withIntermediateDirectories:YES + attributes:nil + error:nil]); + + EXPECT_EQ(locked->remove_staging(), Error::Ok); + NSURL* original = [[moved.url URLByAppendingPathComponent:@"staging"] + URLByAppendingPathComponent:key]; + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:original.path]); + EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:replacement.path]); +} + TEST_F(CoreAIBookmarkIOTest, CreatesDirectoriesWithDefaultPermissions) { NSURL* reference = [root.url URLByAppendingPathComponent:@"reference"]; ASSERT_EQ(mkdir(reference.fileSystemRepresentation, 0777), 0); diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index 18dfe5208e7..e708d5dbd92 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -122,6 +122,9 @@ - (void)dealloc { --_context->state.loaders; } } +- (NSString*)specializationIdentifier { + return @"default"; +} - (void)restoreModelFromBookmark:(NSData*)bookmark completion:(ETCoreAIAcquisitionCompletion)completion { ++restores; @@ -187,6 +190,18 @@ - (void)clearRetainedState { } @end +BOOL ETCoreAIIsAvailable(void) { + return YES; +} + +NSString* ETCoreAIDeviceArchitectureName(void) { + return @"arch_b"; +} + +id ETCoreAICreateModelLoader(void) { + return current_context == nil ? nil : current_context->state.bookmark_loader; +} + namespace executorch::backends::coreai::testing { ScopedFakeBridgeState::ScopedFakeBridgeState() From bb3c6059b598c1956e6041f98d075d89ef2abab2 Mon Sep 17 00:00:00 2001 From: Scott Roy <161522778+metascroy@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:22:26 -0700 Subject: [PATCH 6/6] [CoreAI] Integrate the delegate execute path (#23393) Wires the Core AI bridge into an ExecuTorch `BackendInterface` so `CoreAIBackend` can load and run models (`runtime/coreai_backend.mm`). * `init` parses the manifest, checks the OS floor, selects the platform/architecture assets, acquires a prepared model through the load coordinator and binds the named function with ordered inputs/outputs. * `execute` validates dtype (FP16/FP32), CPU storage, default dim order and contiguity, borrows input bytes without copying, then validates and resizes every output before copying any results. * `init` and `execute` block their calling thread on Swift concurrency work, so the README tells callers not to invoke them from Swift `async` code. * Build: `backends/apple/coreai/CMakeLists.txt` builds `coreaidelegate` (static, or shared against `executorch_shared`) from the backend sources plus `coreai_bridge_obj`, linking `coreai_swift` with the C++ linker so ExecuTorch's C/C++ options never reach swiftc. * Tests: * The fake bridge gains function binding, sessions, execution and the C entry points the backend calls, so the real backend runs in `coreai_host_test` (delegate suite). * `coreai_runtime_smoke` checks final linkage, registration retention and availability. With `EXECUTORCH_BUILD_TESTS=ON` it is built and registered with CTest; otherwise it stays out of the default build. * README: execution semantics, device-architecture selection, the default assets root, "Runtime options", and delegate linkage in "Building". * Changes outside `backends/apple/coreai` are CMake only and only take effect when `EXECUTORCH_BUILD_COREAI=ON`: * `tools/cmake/preset/default.cmake`: `EXECUTORCH_BUILD_COREAI` requires the data loader extension. * `CMakeLists.txt`: add `coreaidelegate` to the backend list. Stack: 7 of 9, based on #23392. Review only this PR's commit. Next: #23394. Test plan: built locally for macOS 27.0; `ctest -N` lists `coreai_host_test`, `coreai_swift_bridge_test` and `coreai_runtime_smoke`, which the Core AI workflow runs on the macOS 27 runner. The smoke binary needs OS 27, so it only runs there (first CI run of it). `lintrunner` is clean. --- CMakeLists.txt | 1 + backends/apple/coreai/CMakeLists.txt | 142 ++- backends/apple/coreai/README.md | 289 +++++- backends/apple/coreai/compiler/preprocess.py | 42 +- .../apple/coreai/runtime/ETCoreAIBridge.h | 51 +- .../apple/coreai/runtime/ETCoreAIError.swift | 30 + ...IModel.swift => ETCoreAIModelLoader.swift} | 229 ----- .../coreai/runtime/ETCoreAITensors.swift | 214 ++++ .../apple/coreai/runtime/coreai_backend.mm | 156 +++ .../apple/coreai/runtime/coreai_backend_id.h | 13 + .../apple/coreai/runtime/coreai_bookmarks.h | 83 -- .../apple/coreai/runtime/coreai_bookmarks.mm | 297 ------ backends/apple/coreai/runtime/coreai_cache.h | 58 ++ backends/apple/coreai/runtime/coreai_cache.mm | 141 +++ .../apple/coreai/runtime/coreai_cache_entry.h | 123 +++ .../coreai/runtime/coreai_cache_entry.mm | 370 +++++++ .../apple/coreai/runtime/coreai_cache_key.h | 13 +- .../apple/coreai/runtime/coreai_cache_key.mm | 57 +- .../apple/coreai/runtime/coreai_cache_root.h | 28 +- .../apple/coreai/runtime/coreai_cache_root.mm | 191 +++- backends/apple/coreai/runtime/coreai_error.h | 20 + backends/apple/coreai/runtime/coreai_error.mm | 48 + .../coreai/runtime/coreai_fault_injection.h | 22 +- backends/apple/coreai/runtime/coreai_file.h | 14 +- .../coreai/runtime/coreai_load_coordinator.h | 18 +- .../coreai/runtime/coreai_load_coordinator.mm | 134 ++- .../apple/coreai/runtime/coreai_manifest.h | 10 +- .../apple/coreai/runtime/coreai_manifest.mm | 45 +- backends/apple/coreai/runtime/coreai_pte.cpp | 256 +++++ backends/apple/coreai/runtime/coreai_pte.h | 42 + .../coreai/runtime/coreai_source_bundle.h | 4 +- .../coreai/runtime/coreai_source_bundle.mm | 164 ++-- .../apple/coreai/runtime/coreai_storage.h | 32 +- .../apple/coreai/runtime/coreai_storage.mm | 237 +++-- .../apple/coreai/runtime/coreai_tensor_io.h | 22 + .../apple/coreai/runtime/coreai_tensor_io.mm | 403 ++++++++ .../apple/coreai/runtime/coreai_validation.h | 14 +- .../apple/coreai/runtime/coreai_validation.mm | 18 +- .../runtime/test/coreai_acquisition_fixture.h | 12 +- .../test/coreai_acquisition_fixture.mm | 97 +- .../runtime/test/coreai_acquisition_test.mm | 81 +- ... => coreai_cache_entry_contention_test.mm} | 36 +- ...fixture.h => coreai_cache_entry_fixture.h} | 15 +- ...xture.mm => coreai_cache_entry_fixture.mm} | 80 +- ...ark_test.mm => coreai_cache_entry_test.mm} | 195 ++-- .../runtime/test/coreai_cache_key_test.mm | 48 +- .../runtime/test/coreai_cache_root_test.mm | 148 ++- .../coreai/runtime/test/coreai_cache_test.mm | 841 ++++++++++++++++ .../runtime/test/coreai_delegate_fixture.h | 123 +++ .../runtime/test/coreai_delegate_fixture.mm | 87 ++ .../runtime/test/coreai_delegate_test.mm | 912 ++++++++++++++++++ .../runtime/test/coreai_eviction_test.mm | 470 +++++++++ .../coreai/runtime/test/coreai_fake_loader.h | 42 +- .../coreai/runtime/test/coreai_fake_loader.mm | 320 +++++- .../runtime/test/coreai_filesystem_fixture.h | 18 +- .../runtime/test/coreai_filesystem_fixture.mm | 20 +- .../coreai/runtime/test/coreai_host_test.mm | 2 +- .../runtime/test/coreai_manifest_fixture.h | 17 +- .../runtime/test/coreai_manifest_test.mm | 67 +- .../runtime/test/coreai_pte_export_test.cpp | 55 ++ .../coreai/runtime/test/coreai_pte_fixture.h | 76 ++ .../coreai/runtime/test/coreai_pte_fixture.mm | 254 +++++ .../coreai/runtime/test/coreai_pte_test.mm | 538 +++++++++++ .../runtime/test/coreai_source_fixture.h | 4 + .../runtime/test/coreai_source_fixture.mm | 20 +- .../test/coreai_source_integrity_test.mm | 304 ++++++ .../test/coreai_source_publication_test.mm | 295 ++++++ .../coreai/runtime/test/coreai_source_test.mm | 587 +---------- .../runtime/test/coreai_storage_test.mm | 46 +- .../coreai/runtime/test/coreai_test_utils.h | 20 + .../runtime/test/export_bridge_fixture.py | 15 +- .../coreai/runtime/test/runtime_smoke.cpp | 48 + .../test/test_export_bridge_fixture.py | 19 +- backends/apple/coreai/test/test_preprocess.py | 8 +- tools/cmake/executorch-config.cmake | 1 + tools/cmake/preset/default.cmake | 4 + 76 files changed, 7922 insertions(+), 2034 deletions(-) create mode 100644 backends/apple/coreai/runtime/ETCoreAIError.swift rename backends/apple/coreai/runtime/{ETCoreAIModel.swift => ETCoreAIModelLoader.swift} (53%) create mode 100644 backends/apple/coreai/runtime/ETCoreAITensors.swift create mode 100644 backends/apple/coreai/runtime/coreai_backend.mm create mode 100644 backends/apple/coreai/runtime/coreai_backend_id.h delete mode 100644 backends/apple/coreai/runtime/coreai_bookmarks.h delete mode 100644 backends/apple/coreai/runtime/coreai_bookmarks.mm create mode 100644 backends/apple/coreai/runtime/coreai_cache.h create mode 100644 backends/apple/coreai/runtime/coreai_cache.mm create mode 100644 backends/apple/coreai/runtime/coreai_cache_entry.h create mode 100644 backends/apple/coreai/runtime/coreai_cache_entry.mm create mode 100644 backends/apple/coreai/runtime/coreai_error.h create mode 100644 backends/apple/coreai/runtime/coreai_error.mm create mode 100644 backends/apple/coreai/runtime/coreai_pte.cpp create mode 100644 backends/apple/coreai/runtime/coreai_pte.h create mode 100644 backends/apple/coreai/runtime/coreai_tensor_io.h create mode 100644 backends/apple/coreai/runtime/coreai_tensor_io.mm rename backends/apple/coreai/runtime/test/{coreai_bookmark_contention_test.mm => coreai_cache_entry_contention_test.mm} (91%) rename backends/apple/coreai/runtime/test/{coreai_bookmark_fixture.h => coreai_cache_entry_fixture.h} (75%) rename backends/apple/coreai/runtime/test/{coreai_bookmark_fixture.mm => coreai_cache_entry_fixture.mm} (85%) rename backends/apple/coreai/runtime/test/{coreai_bookmark_test.mm => coreai_cache_entry_test.mm} (73%) create mode 100644 backends/apple/coreai/runtime/test/coreai_cache_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_delegate_fixture.h create mode 100644 backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_delegate_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_eviction_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_pte_export_test.cpp create mode 100644 backends/apple/coreai/runtime/test/coreai_pte_fixture.h create mode 100644 backends/apple/coreai/runtime/test/coreai_pte_fixture.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_pte_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_source_publication_test.mm create mode 100644 backends/apple/coreai/runtime/test/coreai_test_utils.h create mode 100644 backends/apple/coreai/runtime/test/runtime_smoke.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f180bd6f8c1..9abc10ff8bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -854,6 +854,7 @@ endif() if(EXECUTORCH_BUILD_COREAI) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/backends/apple/coreai) + list(APPEND _executorch_backends coreaidelegate) endif() if(EXECUTORCH_BUILD_MLX) diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 5f42d880a79..33d0d2d1e33 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -27,14 +27,30 @@ endforeach() enable_language(OBJCXX) +# The consolidated shared runtime target is created after this directory. +if(EXECUTORCH_BUILD_SHARED) + set(_coreai_library_type SHARED) + set(_coreai_runtime executorch_shared) + set(_coreai_data_loader "") +else() + set(_coreai_library_type STATIC) + set(_coreai_runtime executorch) + set(_coreai_data_loader extension_data_loader) +endif() + set(_coreai_runtime_sources - runtime/coreai_bookmarks.mm + runtime/coreai_backend.mm + runtime/coreai_cache_entry.mm + runtime/coreai_cache.mm runtime/coreai_cache_key.mm runtime/coreai_cache_root.mm + runtime/coreai_error.mm runtime/coreai_fault_injection.mm runtime/coreai_load_coordinator.mm runtime/coreai_manifest.mm + runtime/coreai_pte.cpp runtime/coreai_source_bundle.mm + runtime/coreai_tensor_io.mm runtime/coreai_validation.mm runtime/coreai_storage.mm ) @@ -62,14 +78,22 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") runtime/test/coreai_storage_test.mm runtime/test/coreai_filesystem_fixture.mm runtime/test/coreai_cache_key_test.mm - runtime/test/coreai_bookmark_test.mm - runtime/test/coreai_bookmark_contention_test.mm + runtime/test/coreai_cache_entry_test.mm + runtime/test/coreai_cache_entry_contention_test.mm runtime/test/coreai_source_test.mm - runtime/test/coreai_bookmark_fixture.mm + runtime/test/coreai_source_integrity_test.mm + runtime/test/coreai_source_publication_test.mm + runtime/test/coreai_cache_entry_fixture.mm runtime/test/coreai_source_fixture.mm runtime/test/coreai_fake_loader.mm runtime/test/coreai_acquisition_test.mm runtime/test/coreai_acquisition_fixture.mm + runtime/test/coreai_delegate_fixture.mm + runtime/test/coreai_delegate_test.mm + runtime/test/coreai_pte_test.mm + runtime/test/coreai_pte_fixture.mm + runtime/test/coreai_cache_test.mm + runtime/test/coreai_eviction_test.mm runtime/ETCoreAITensor.mm ${_coreai_runtime_sources} ) @@ -77,8 +101,9 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") target_compile_definitions(coreai_host_test PRIVATE COREAI_ASSETS_TESTING=1) find_library(COREAI_FOUNDATION_FRAMEWORK Foundation REQUIRED) target_link_libraries( - coreai_host_test PRIVATE executorch GTest::gtest - ${COREAI_FOUNDATION_FRAMEWORK} + coreai_host_test + PRIVATE ${_coreai_runtime} ${_coreai_data_loader} program_schema + GTest::gtest ${COREAI_FOUNDATION_FRAMEWORK} ) add_test(NAME coreai_host_test COMMAND coreai_host_test) set_tests_properties(coreai_host_test PROPERTIES TIMEOUT 120) @@ -196,7 +221,11 @@ else() file(MAKE_DIRECTORY "${_coreai_generated_dir}") endif() set(_coreai_swift_header "${_coreai_generated_dir}/CoreAIBridge-Swift.h") -add_library(coreai_swift STATIC runtime/ETCoreAIModel.swift) +set(_coreai_swift_sources + runtime/ETCoreAIError.swift runtime/ETCoreAITensors.swift + runtime/ETCoreAIModelLoader.swift +) +add_library(coreai_swift STATIC ${_coreai_swift_sources}) coreai_configure_swift_target(coreai_swift CoreAIBridge) set_target_properties( coreai_swift PROPERTIES Swift_MODULE_DIRECTORY "${_coreai_generated_dir}" @@ -234,7 +263,7 @@ else() endif() # The bridge header is an input to Swift even when only its declarations change. set_property( - SOURCE runtime/ETCoreAIModel.swift + SOURCE ${_coreai_swift_sources} APPEND PROPERTY OBJECT_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h" @@ -287,7 +316,7 @@ if(EXECUTORCH_BUILD_TESTS set(_coreai_xctest_libs "${_coreai_platform}/Developer/usr/lib") add_library( coreai_swift_bridge_test MODULE - runtime/ETCoreAIModel.swift runtime/test/SwiftBridgeTests.swift + ${_coreai_swift_sources} runtime/test/SwiftBridgeTests.swift runtime/ETCoreAITensor.mm ) set_target_properties( @@ -315,6 +344,7 @@ if(EXECUTORCH_BUILD_TESTS add_custom_command( OUTPUT "${_coreai_fixture_dir}/manifest.json" "${_coreai_fixture_dir}/dynamic/manifest.json" + "${_coreai_fixture_dir}/model.pte" BYPRODUCTS "${_coreai_fixture_dir}/model.aimodel/main.hash" "${_coreai_fixture_dir}/model.aimodel/main.mlirb" "${_coreai_fixture_dir}/model.aimodel/metadata.json" @@ -334,9 +364,25 @@ if(EXECUTORCH_BUILD_TESTS coreai_bridge_fixture DEPENDS "${_coreai_fixture_dir}/manifest.json" "${_coreai_fixture_dir}/dynamic/manifest.json" + "${_coreai_fixture_dir}/model.pte" ) add_dependencies(coreai_swift_bridge_test coreai_bridge_fixture) + add_executable( + coreai_pte_export_test runtime/test/coreai_pte_export_test.cpp + runtime/coreai_pte.cpp + ) + coreai_configure_objc_target(coreai_pte_export_test) + target_link_libraries( + coreai_pte_export_test PRIVATE ${_coreai_runtime} ${_coreai_data_loader} + program_schema + ) + add_dependencies(coreai_pte_export_test coreai_bridge_fixture) if(_coreai_run_sdk_tests) + add_test(NAME coreai_pte_export_test + COMMAND coreai_pte_export_test "${_coreai_fixture_dir}/model.pte" + "${_coreai_fixture_dir}/manifest.json" + ) + set_tests_properties(coreai_pte_export_test PROPERTIES TIMEOUT 30) add_test(NAME coreai_swift_bridge_test COMMAND xcrun xctest "$" @@ -346,24 +392,21 @@ if(EXECUTORCH_BUILD_TESTS PROPERTIES TIMEOUT 600 ENVIRONMENT "COREAI_BRIDGE_FIXTURE_DIR=${_coreai_fixture_dir}" ) - add_executable( - coreai_sdk_source_lifetime_test - runtime/test/SDKSourceLifetimeTest.swift runtime/ETCoreAIModel.swift - runtime/ETCoreAITensor.mm - ) - set_target_properties( - coreai_sdk_source_lifetime_test PROPERTIES Swift_LANGUAGE_VERSION 6 - ) - coreai_configure_objc_target(coreai_sdk_source_lifetime_test) - coreai_require_os27(coreai_sdk_source_lifetime_test) - target_compile_options( - coreai_sdk_source_lifetime_test - PRIVATE - "$<$:-parse-as-library;-import-objc-header;${CMAKE_CURRENT_SOURCE_DIR}/runtime/ETCoreAIBridge.h>" - ) - target_link_libraries( - coreai_sdk_source_lifetime_test PRIVATE executorch::coreai_dependencies - ) + endif() + add_executable( + coreai_sdk_source_lifetime_test + runtime/test/SDKSourceLifetimeTest.swift ${_coreai_swift_sources} + runtime/ETCoreAITensor.mm + ) + coreai_configure_objc_target(coreai_sdk_source_lifetime_test) + coreai_configure_swift_target( + coreai_sdk_source_lifetime_test coreai_sdk_source_lifetime_test + ) + target_compile_options( + coreai_sdk_source_lifetime_test + PRIVATE "$<$:-parse-as-library>" + ) + if(_coreai_run_sdk_tests) add_test( NAME coreai_sdk_source_lifetime_test COMMAND @@ -375,8 +418,51 @@ if(EXECUTORCH_BUILD_TESTS endif() endif() +add_library( + coreaidelegate ${_coreai_library_type} ${_coreai_runtime_sources} + $ +) +coreai_configure_objc_target(coreaidelegate) +# The delegate contains no Swift sources; its archive is assembled by C++. +set_target_properties(coreaidelegate PROPERTIES LINKER_LANGUAGE CXX) +coreai_require_os27(coreaidelegate) +target_include_directories(coreaidelegate PUBLIC ${_common_include_directories}) +add_dependencies(coreaidelegate coreai_swift) +target_link_libraries( + coreaidelegate PRIVATE coreai_swift ${_coreai_runtime} ${_coreai_data_loader} + program_schema executorch::coreai_dependencies +) +executorch_target_link_options_shared_lib(coreaidelegate) +if(EXECUTORCH_BUILD_SHARED) + set_target_properties( + coreaidelegate PROPERTIES OUTPUT_NAME executorch_backend_coreai + ) + executorch_target_soname_policy(coreaidelegate) + executorch_target_shipped_runtime_path(coreaidelegate) +endif() + +# A consumer is necessary to check Swift linkage and registration retention. +add_executable(coreai_runtime_smoke runtime/test/runtime_smoke.cpp) +if(_coreai_run_sdk_tests) + add_test(NAME coreai_runtime_smoke COMMAND coreai_runtime_smoke) +endif() +if(NOT EXECUTORCH_BUILD_TESTS) + set_target_properties(coreai_runtime_smoke PROPERTIES EXCLUDE_FROM_ALL TRUE) +endif() +coreai_require_os27(coreai_runtime_smoke) +set_target_properties(coreai_runtime_smoke PROPERTIES LINKER_LANGUAGE CXX) +target_link_libraries( + coreai_runtime_smoke PRIVATE coreaidelegate ${_coreai_runtime} +) + +install( + FILES runtime/coreai_cache.h + DESTINATION + ${CMAKE_INSTALL_INCLUDEDIR}/executorch/backends/apple/coreai/runtime +) + install( - TARGETS coreai_swift + TARGETS coreaidelegate coreai_swift EXPORT ExecuTorchTargets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index e4bdc0368d4..e76c209991e 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -30,18 +30,50 @@ The delegate manifest describes `.aimodel` source bundles, which require `inline` packaging, and AOT-compiled `.aimodelc` bundles, which require `aot_compiled_inline`. Both formats require a `function` name, and ordered `input_names` and `output_names` matching the converter's bindings. Dictionary -iteration order is not a binding contract. The `files` object maps relative +iteration order is not a binding contract. `export_hash` identifies the exported +program used to namespace embedded assets. The `files` object maps relative filenames to byte sizes; `bundle_digests` maps each bundle basename to its export-time SHA-256 digest. Asset paths, including bundle and directory names, must remain distinct under case-insensitive, canonically normalized Unicode comparison. Accepted names are preserved, not rewritten. Parsing returns an immutable `Manifest` covering all exported bundles. Pure -platform and architecture selection produces an immutable `SelectedBundle` -with one bundle's path, digest, and files. Neither type can be constructed +platform and architecture selection through `select_bundle` produces an immutable +`SelectedBundle` with one bundle's path, digest, and files. Host probing returns a +`SelectedHostBundle` containing that selection and its platform/architecture. without validation; filesystem materialization and SDK host probing remain outside the manifest parser. +The experimental `CoreAIBackend` runtime supports stateless FP32/FP16 tensor +models in both formats. ExecuTorch tensors must be contiguous. Output shapes +must fit the executor's declared resize and capacity constraints. Stateful +functions, image values, other dtypes, and interleaved layouts are not supported. +Calls on one session must be serialized by the caller. + +Supported inputs are borrowed directly from ExecuTorch storage through Core AI +raw views. The bridge copies shape metadata, not tensor bytes, and does not +allocate intermediate input `NSData`, `Data`, or `NDArray` buffers. Each call +wraps `const_data_ptr()` and the current shape's `nbytes()`, not the allocation's +maximum capacity. Outputs use the SDK's actual returned shape; the backend +resizes them within ExecuTorch's declared bounds before copying only the logical +result bytes. Unused capacity is neither wrapped as input nor written as output. +The backend waits for inference and all input access to finish before returning +from `execute`, including on errors. This uses ExecuTorch's normal input-lifetime +contract; callers must not concurrently mutate, release, or rebind the input +storage. Output copiers retain the SDK arrays and copy directly into validated +ExecuTorch destinations, without an intermediate `Data` allocation or payload +copy. If any destination overlaps an input or another destination, all outputs +are staged before writing destinations in order. This preserves input/output +aliasing and overlapping-output semantics. Staging is not zero-initialized. +This does not guarantee that Core AI itself avoids device transfers or internal +copies. + +After an execution error, do not use the outputs as inference results. Output +shapes may already have changed, but no result bytes are copied unless every +output passes validation and resizing. Malformed SDK outputs return `Internal`. +Output dtype/rank mismatches and incompatible shapes or capacity return +`InvalidArgument`. + ### Calling from Swift When using the Core AI backend, run ExecuTorch model loading, inference and @@ -62,20 +94,34 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. -Both formats can require device specialization. The acquisition coordinator -first attempts SDK bookmark restoration. A missing bookmark or SDK-confirmed -cache miss falls back to source materialization and persistent specialization. -It returns the acquired model; function binding is the caller's responsibility. - -### Asset storage - -The assets root must be an absolute path. The application chooses it, should -reserve it for this backend, and is trusted not to rename, replace or modify its -contents while the backend uses it; the backend does not defend against other -processes of the same user rebinding paths. Written files and changed directories +At load time, the runtime queries `AIModel.deviceArchitectureName` and selects +the exact matching entry in the manifest's `archs` map. No runtime architecture +option is required, and there is no fallback to another architecture. Platform, +deployment floor, and architecture checks happen before asset reads or storage +creation. Missing-architecture errors identify the device architecture, list +available architectures, and request a matching export. Missing selected files +are reported separately from an absent architecture entry. + +Both formats can require device specialization. Every load first attempts SDK +bookmark restoration. A missing bookmark or SDK-confirmed cache miss falls back +to source materialization and persistent specialization, then binds the requested +function and ordered I/O on that same acquired model. + +### Cache directory + +Backend initialization reads the string runtime spec `coreai_cache_dir`. If +absent, it sets the directory to `executorch_coreai` beneath user-domain +`NSCachesDirectory`. The coordinator and storage helpers always receive that +explicit directory. It contains raw bookmarks, source bundles and lock files. + +The cache directory must be an absolute path reserved for this backend. A +symlink in the final component of the configured path is resolved before +opening the cache directory. Entry and source operations retain directory descriptors and do not follow internal +symlinks. The application must still prevent concurrent renaming, replacement, +or modification while SDK calls use source URLs. Written files and changed directories are synced with `fsync`. Bookmark publication uses `F_FULLFSYNC` on the temporary file before rename and on the directory afterward. Source-bundle publication -fully flushes the staging directory after syncing its files and subdirectories, +fully flushes the pending build directory after syncing its files and subdirectories, then fully flushes the destination directory after rename. A failure before rename preserves the old contents; a failure of the final flush reports an error even though the new complete contents are already visible. Use a local APFS @@ -83,7 +129,7 @@ volume; unsupported full-device flushes are reported as errors, not downgraded to `fsync`. Inline bundles are read through `NamedDataMap` and materialized as unchanged -files under the assets root. Named data can be supplied externally; inline +files under the cache directory. Named data can be supplied externally; inline packaging does not require all bytes to reside in one physical PTE. The exporter computes each bundle digest from all delivered relative filenames and file bytes, not just the SDK's bytecode-only `main.hash`. Each AOT architecture has an @@ -94,8 +140,8 @@ When source recovery is needed, existing bundles are checked for the expected file set, sizes, entry types and directory structure without reading asset contents or requesting NDS payloads. Unicode paths are compared in canonical decomposed form; NDS keys and bytes remain unchanged. Reuse fully flushes the -source root, including after a previous publication's final flush failed. -Missing bundles are written to a staging directory and published with an +`sources` directory, including after a previous publication's final flush failed. +Missing bundles are written to a `.pending-` directory and published with an exclusive rename; each selected NDS payload is fetched once and checked for size before writing. If another loader publishes first, its bundle is validated and used. Incomplete, size-mismatched or otherwise malformed existing bundles fail @@ -113,7 +159,7 @@ NDS source to be reread. The export-time digest is a trusted artifact identifier the runtime does not rehash source or stored bytes. Core AI may reject invalid contents, but SDK load failures do not automatically evict the extracted copy. -Preparing the assets root sets `NSURLIsExcludedFromBackupKey` on it, which covers +Preparing the cache directory sets `NSURLIsExcludedFromBackupKey` on it, which covers everything beneath it. Ancestors are not modified. This is backup exclusion, not a control for iCloud Drive synchronization. @@ -127,26 +173,39 @@ Directory creation uses default permissions filtered by the process umask, witho an explicit owner-only restriction. Existing permissions are not changed. Backup exclusion and required durability flushes still apply on reused paths. -Cache-root preparation owns location and backup/protection policy; descriptor +Cache-directory preparation owns location and backup/protection policy; descriptor storage owns no-follow operations, publication, and synchronization. Creation and enumeration use explicit policies, with a missing child distinguishable from an error. Fault injection is a separate, thread-local test interface and compiles away in production. -Each selected bundle has a model key derived from its export digest, bundle -name, and source/AOT mode. A readable variant key combines that model key with +Delegate construction and registration do not create storage directories or load +models. Per-model initialization performs SDK acquisition and function loading, +validating source assets only when recovery requires them. There is no synthetic +zero-input inference at initialization. SDK specialization and function resource +loading can still be substantial. + +The runtime uses `AIModelCache.default` with storage-pressure-only purging and +stores an opaque bookmark for direct restoration. `coreai_cache_dir` does not +choose the SDK artifact directory. The backend does not inspect SDK-private +files, alter their backup flags, or use an App Group cache. + +Each selected bundle has a `bundle_identity` derived from its bundle digest, +name, and source/AOT mode. This identifies the delivered bundle independently of +`export_hash`, which namespaces embedded assets. A readable cache key combines +that bundle identity with the platform and Core AI device architecture, plus a hash of a versioned specialization identity supplied as plain data by the SDK boundary. Function bindings and delivery location are excluded, and no model bytes are hashed at runtime. Each variant key determines a raw bookmark in `bookmarks`, a bundle -directory in `staging`, and one of 256 shared coordination files in `locks`. +directory in `sources`, and one of 256 shared coordination files in `locks`. There is no PTE-wide bookmark list or serialized lifecycle record. -A healthy bookmark hit does not consult NDS and attempts to clean leftover -staging. On a missing bookmark or confirmed SDK miss, the coordinator prepares +A healthy bookmark hit does not consult NDS and attempts to clean the leftover +source bundle. On a missing bookmark or confirmed SDK miss, the coordinator prepares the source, specializes it and atomically writes the returned bookmark bytes. -After durable publication, it attempts to remove the entire keyed staging -directory while holding the key's stripe lock. Persistent SDK caching supports +After durable publication, it attempts to remove the entire keyed source +directory while holding the key's stripe lock. The SDK cache supports inference and bookmark restoration without that extracted copy. Keep the packaged named data available: a later SDK cache miss requires source materialization again. There is no secondary source-URL cache lookup. @@ -165,20 +224,125 @@ A fixed pool of at most 256 disk locks coordinates loading and eviction across threads and processes. A SHA-256 hash of the full validated cache key selects a stripe modulo the configured pool size. Files are created on demand, so new model versions do not grow the pool beyond 256. Different keys on the same -stripe serialize; different stripes remain independent. Bookmarks and staging +stripe serialize; different stripes remain independent. Bookmarks and source bundles remain separate for every key. Runtime operations hold only one stripe at a time. Lock ownership is maintained by the OS, and acquiring a lock does not flush its file or directory. Stripe files are never removed, including during cache clearing or after process exit, so waiters and future openers keep using the -same inode. Removing the assets root externally requires all loads, sessions +same inode. Removing the cache directory externally requires all loads, sessions and maintenance to stop. +### Lifecycle logging + +Build with `EXECUTORCH_ENABLE_LOGGING=ON` and `EXECUTORCH_LOG_LEVEL=Info` to +observe cache lookup, bookmark restoration hits, missing-bookmark versus +SDK-confirmed misses, specialization start/success, durable bookmark saves, and +cache-clearing milestones. Each message carries the cache key so events from +multiple partitions can be correlated. These messages do not include bookmark +contents, model data, or per-inference logs. + +A cache hit means that the SDK successfully restored a model from its bookmark. +Specialization start means a request to the SDK's default cache; the +SDK does not report whether that request compiled afresh or reused an internal +artifact. The durable-save message confirms local bookmark publication, +including its final directory flush, not independent verification of SDK-private +artifact storage. During clearing, SDK entry absence and successful local +cleanup are reported separately. Failed operations do not emit their success +messages, and clearing an absent entry remains quiet. + +### Clearing cached assets + +The public C++ maintenance APIs are in `executorch::backends::coreai`: + +```cpp +runtime::Error clear_cache(const char* coreai_cache_dir = nullptr); +runtime::Error clear_cache_for_pte( + runtime::DataLoader& pte, const char* coreai_cache_dir = nullptr); +runtime::Error clear_cache_for_pte( + const char* pte_path, const char* coreai_cache_dir = nullptr); +``` + +All three APIs use the same default cache directory as backend initialization +when the directory is omitted or null. A supplied path must be absolute; invalid +paths fail without falling back to the default. If loading used a custom cache +directory, pass it when clearing; the PTE does not store runtime overrides. + +Cache clearing inventories keyed bookmarks and staged directories. PTE clearing +finds every Core AI delegate in every method and derives each bundle's identity. +Matching that identity in cache keys clears every recorded platform, +device, and specialization variant without querying the host or SDK. All +manifests are validated before any entry is cleared; their minimum OS and runtime +compatibility do not restrict maintenance. Discovery does not load models or +materialize assets. Actual SDK eviction still requires a usable bookmark and an +available loader. + +These synchronous calls require callers to unload affected models and prevent +concurrent loads, inference and maintenance across processes. Stripe locks are +not a whole-root barrier. Clearing first deletes the recorded SDK entry, then +its source bundle, then its bookmark. Busy or unknown SDK errors preserve the files; +SDK-confirmed absence permits cleanup. Filesystem errors may leave partial +cleanup, but the bookmark is retained until source removal succeeds so cleanup +can be retried. Independent keys are still attempted in sorted order, and the +first key error is returned. Each removal syncs the directory it changed. A +missing root or absent entry is a noncreating no-op. + +Everything under a keyed `sources/` directory belongs to the backend and is +removed with that key. Outside keyed entries, clearing does not delete PTEs, +other files or the stable lock files. Unkeyed interrupted `.pending-*` directories +also remain untouched. Bookmarks are SDK eviction handles, not weights. For an +existing entry with a missing or structurally invalid bookmark, clearing logs an +Info message with the cache key, attempts local cleanup, and continues to other keys. +The message states that SDK eviction could not be confirmed; bookmark contents +are not logged. Absent entries remain quiet no-ops. I/O failures and SDK eviction +errors still preserve bookmarks for retry. Lost bookmarks can leave SDK entries +that these APIs cannot target. No history of removed entries is retained. +Copied or reused exported artifacts can share keys; clearing them also removes +the other copy's warm-cache benefit. Subsequent loads can specialize again. + + +### Persistent SDK Rejection + +SDK errors are not classified as transient or permanent. +Busy errors and all unclassified eviction/restore errors retain the local +bookmark for retry; only successful eviction or a confirmed SDK miss permits +normal removal. If retries keep failing, first stop every process using the +cache directory. Back up or quarantine only the affected key's bookmark and keyed +source bundle outside that directory, leaving the stable lock files in place. A later load +can attempt cold acquisition and publish a new bookmark. +This manual recovery does not confirm SDK eviction or guarantee disk-space +reclamation: keep the backup because it may contain the only remaining eviction +handle. Do not perform manual recovery while loading, inference, or maintenance +is running. + +### Runtime options + +Pass options through the public `Module` API before loading. In an error-returning +loader using the `executorch::runtime` namespace: + +```cpp +BackendOptions<1> options; +ET_CHECK_OK_OR_RETURN_ERROR(options.set_option("coreai_cache_dir", cache_dir)); +LoadBackendOptionsMap map; +ET_CHECK_OK_OR_RETURN_ERROR(map.set_options("CoreAIBackend", options.view())); +ET_CHECK_OK_OR_RETURN_ERROR(module.load(map)); +``` + +No options are required when using the default cache directory. `coreai_cache_dir` +selects an application-chosen cache directory reserved for this backend. The +supplied path is validated during preflight, but warm bookmark hits need no +source. + +Use separate option maps to choose different cache directories for different models. +The path names the cache directory, not a bundle. ExecuTorch option strings +have a 255-byte limit. + ## SDK Bridge -The private Swift module `CoreAIBridge` (`runtime/ETCoreAIModel.swift`) wraps -the `CoreAI` framework behind the Objective-C protocols in +The private Swift module `CoreAIBridge` separates error/completion support, +tensor layout/copying, and model/session lifecycle into three source files. It +wraps the `CoreAI` framework behind the Objective-C protocols in `runtime/ETCoreAIBridge.h`. It restores models from bookmarks, specializes source bundles in the default SDK cache with storage-pressure-only purging, and binds named functions with ordered inputs and outputs. Each loader owns an immutable @@ -226,30 +390,53 @@ Ninja builds use one architecture per build directory. Current runtime support covers arm64 macOS and iOS device builds. x86_64 builds are blocked by Swift `Float16` availability, and the tested iOS simulator SDKs do not contain Core AI. -### Lifecycle logging - -Build with `EXECUTORCH_ENABLE_LOGGING=ON` and `EXECUTORCH_LOG_LEVEL=Info` to -observe cache lookup, bookmark restoration hits, missing-bookmark versus -SDK-confirmed misses, specialization start/success, and durable bookmark saves. Each message carries the cache key so events from -multiple partitions can be correlated. These messages do not include bookmark -contents, model data, or per-inference logs. - -A cache hit means that the SDK successfully restored a model from its bookmark. -Specialization start means a request to the SDK's persistent default cache; the -SDK does not report whether that request compiled afresh or reused an internal -artifact. The durable-save message confirms local bookmark publication, -including its final directory flush, not independent verification of SDK-private -artifact storage. Failed operations do not emit their success messages. +Enable `EXECUTORCH_BUILD_EXTENSION_DATA_LOADER` for the public PTE-file +maintenance API and consumers such as `Module`. +Link the CMake target `coreaidelegate`, not just its archive filename. Its +transitive dependencies supply Swift and Foundation/CoreAI linkage, and its +link interface retains static backend registration. With +`EXECUTORCH_BUILD_SHARED=ON`, the delegate is a shared library linked to the +single consolidated `executorch_shared` runtime. Otherwise it is static. +Consumers of the shared delegate must use that same shared runtime. +Installed CMake targets resolve framework and Swift runtime dependencies using +the consumer's selected SDK and toolchain, not the producer's Xcode paths. +Select the Apple SDK/toolchain when configuring the consumer project. +SwiftPM and XCFramework distribution are not integrated yet. + +The `coreai_runtime_smoke` target checks final linkage of a C++ consumer, +including Swift dependencies, backend registration retention, and the public +cache-clearing overloads. With +`EXECUTORCH_BUILD_TESTS=ON` it is built; otherwise it is excluded from the +default build. SDK-linked tests are registered with CTest only for native macOS +builds when the host meets the target's deployment minimum (at least 27.0). +Cross-builds and older hosts can build these tests without registering them. +Running the smoke test checks registration and availability; it does not perform +model inference. Do not execute SDK27 binaries on an older host. + +## Backend-local PTE Inspection + +The private `inspect_coreai_pte(DataLoader&)` reader extracts Core AI processed +buffers without loading methods, external assets, or SDK models. Inspection does +not derive cache keys or mutate cache state. It verifies FlatBuffer structure +and program semantics, skips delegates without a matching backend ID, and +returns `NotFound` for absent segments or out-of-range references. Legacy +headers may omit the segment extent; selected segment bounds are still checked. ## Host Tests On macOS, `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the standalone `coreai_host_test` executable with CTest. It tests -manifest parsing, AOT asset selection, cache identity, bookmark I/O, acquisition -coordination, lock contention, and filesystem storage using Foundation, without +manifest parsing, PTE inspection, AOT asset selection, cache identity, bookmark +I/O, acquisition coordination, lock contention, filesystem storage, and delegate +initialization and execution against a fake bridge. It uses Foundation without Swift or CoreAI SDK calls. Acquisition contention tests run two threads inside a bounded child process; bookmark lock tests separately cover contention between -processes. The host test target is omitted for Apple cross-builds. +processes. The host test target is omitted for Apple cross-builds. On supported +macOS hosts, CTest also runs the Swift bridge tests, source-lifetime probes, and +`coreai_runtime_smoke`, which link Swift and the Core AI framework. +`coreai_pte_export_test` compares inspected metadata with the manifest from an +actual exported PTE. It uses the generated bridge fixture without loading SDK +models. CI builds and runs these tests on macOS 27. To use the same configuration from the repository root: @@ -262,3 +449,9 @@ cmake -S . -B -G Ninja \ cmake --build --target backends/apple/coreai/all ctest --test-dir /backends/apple/coreai --output-on-failure --no-tests=error ``` + +For a compile-only production smoke build, use a fresh build directory without +`EXECUTORCH_BUILD_TESTS`, set `CMAKE_OSX_ARCHITECTURES=arm64`, and build +`coreai_runtime_smoke`. For iOS also set `CMAKE_SYSTEM_NAME=iOS` and +`CMAKE_OSX_SYSROOT=iphoneos`. Do not run the iOS binary on the host or install +a simulator as part of this build. diff --git a/backends/apple/coreai/compiler/preprocess.py b/backends/apple/coreai/compiler/preprocess.py index cf2f86224a8..7ff320accb1 100644 --- a/backends/apple/coreai/compiler/preprocess.py +++ b/backends/apple/coreai/compiler/preprocess.py @@ -157,10 +157,9 @@ def _get_compile_spec( return None -def _nds_key(model_hash: str, relative_path: str) -> str: - # Namespaced by content hash so identical assets dedup to one entry and the - # hash doubles as an on-device cache key. - return f"coreai/{model_hash}/{relative_path}" +def _nds_key(export_hash: str, relative_path: str) -> str: + # Identical exports share embedded assets under their export hash. + return f"coreai/{export_hash}/{relative_path}" def _asset_metadata(staging: Path) -> Dict[str, Any]: @@ -194,23 +193,23 @@ def _asset_metadata(staging: Path) -> Dict[str, Any]: def _deliver( staging: Path, - model_hash: str, + export_hash: str, packaging: AssetPackaging, manifest_extra: Dict[str, Any], ) -> PreprocessResult: """Embed a staging directory of bundles in the .pte with its manifest. ``staging`` holds the ``.aimodel`` / ``.aimodelc`` bundles, never their - contents, so assets land under ``coreai///`` in the + contents, so assets land under ``coreai///`` in the NamedDataStore. """ manifest = { "packaging": packaging.value, - "hash": model_hash, + "export_hash": export_hash, **manifest_extra, **_asset_metadata(staging), } - return _embed_dir_inline(staging, model_hash, manifest) + return _embed_dir_inline(staging, export_hash, manifest) def _prepare_program_for_conversion(edge_program: ExportedProgram) -> ExportedProgram: @@ -255,21 +254,21 @@ def _convert_to_aiprogram( # Asset embedding helpers. def _embed_dir_inline( - root_dir: Path, model_hash: str, manifest_extra: Dict[str, Any] + root_dir: Path, export_hash: str, manifest_extra: Dict[str, Any] ) -> PreprocessResult: """Flatten every file under ``root_dir`` into the NamedDataStore. ``root_dir`` is the directory *containing* the ``.aimodel`` / ``.aimodelc`` bundles, never a bundle itself, so the bundle name survives into the keys. - Keys are ``coreai/{hash}/{relpath}``; ``manifest_extra`` becomes the + Keys are ``coreai/{export_hash}/{relpath}``; ``manifest_extra`` becomes the manifest (packaging, files, archs, ...). """ store = NamedDataStore() for path in sorted(root_dir.rglob("*")): if path.is_file(): store.add_named_data( - _nds_key(model_hash, path.relative_to(root_dir).as_posix()), + _nds_key(export_hash, path.relative_to(root_dir).as_posix()), path.read_bytes(), alignment=_ASSET_ALIGNMENT, ) @@ -426,15 +425,15 @@ def _compiled_arch_bundles(out_dir: Path) -> List[Tuple[str, Path]]: def _compile_aot(program, opts: Dict[str, Any], tmp_dir: Path) -> Tuple[str, Path]: """Save the intermediate ``.aimodel`` and AOT-compile it to ``.aimodelc``. - Returns ``(model_hash, compiled_out_dir)``. The intermediate ``.aimodel`` + Returns ``(export_hash, compiled_out_dir)``. The intermediate ``.aimodel`` keeps the default OS floor; ``min_deployment_version`` targets the compiled ``.aimodelc`` via ``coreai-build``, so it is not double-specified. """ - model_hash = _save_and_hash(program, tmp_dir / "model.aimodel") + export_hash = _save_and_hash(program, tmp_dir / "model.aimodel") out = tmp_dir / "compiled" out.mkdir() _run_coreai_build(tmp_dir / "model.aimodel", out, opts) - return model_hash, out + return export_hash, out @final @@ -446,8 +445,7 @@ class CoreAIBackend(BackendDetails): ``xcrun coreai-build``; architecture selection is one / a list / all). The bundles are embedded in the ``.pte`` via NamedDataStore. - ``processed_bytes`` is always a small JSON manifest naming what/where. - Runtime execution is not wired up yet. + ``processed_bytes`` contains the manifest used by the runtime delegate. """ @staticmethod @@ -514,15 +512,15 @@ def _preprocess_portable( program, min_os, bindings: Dict[str, Any] ) -> PreprocessResult: with TemporaryDirectory() as tmp: - model_hash = _save_and_hash(program, Path(tmp) / "model.aimodel", min_os) + export_hash = _save_and_hash(program, Path(tmp) / "model.aimodel", min_os) return _deliver( Path(tmp), - model_hash, + export_hash, AssetPackaging.INLINE, { **bindings, # relative path the runtime resolves against its base - "path": f"{model_hash}/model.aimodel", + "path": f"{export_hash}/model.aimodel", "min_deployment_version": _os_version_text(min_os), }, ) @@ -533,17 +531,17 @@ def _preprocess_aot_compiled( program, opts: Dict[str, Any], bindings: Dict[str, Any] ) -> PreprocessResult: with TemporaryDirectory() as tmp: - model_hash, out = _compile_aot(program, opts, Path(tmp)) + export_hash, out = _compile_aot(program, opts, Path(tmp)) return _deliver( out, - model_hash, + export_hash, AssetPackaging.AOT_COMPILED_INLINE, { **bindings, "platform": opts["platform"], "min_deployment_version": opts["min_deployment_version"], "archs": { - arch: f"{model_hash}/{bundle.name}" + arch: f"{export_hash}/{bundle.name}" for arch, bundle in _compiled_arch_bundles(out) }, }, diff --git a/backends/apple/coreai/runtime/ETCoreAIBridge.h b/backends/apple/coreai/runtime/ETCoreAIBridge.h index 73426ac00c6..900aa000aa2 100644 --- a/backends/apple/coreai/runtime/ETCoreAIBridge.h +++ b/backends/apple/coreai/runtime/ETCoreAIBridge.h @@ -26,14 +26,15 @@ typedef NS_ENUM(NSInteger, ETCoreAIErrorCode) { ETCoreAIErrorUnsupported = 2, // Invalid caller arguments or input tensor payloads. ETCoreAIErrorInvalidArgument = 3, - // SDK/system failures or inconsistent SDK outputs, not evidence of - // corruption. + // SDK or system failures, or inconsistent SDK outputs. Does not mean cached + // files are corrupt. ETCoreAIErrorRuntime = 4, }; FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; -// Input storage is borrowed through output copying, never retained or freed. +// Input storage is borrowed until output copying finishes. The bridge never +// retains or frees it. @interface ETCoreAIInputTensor : NSObject @property(nonatomic, readonly) const void* _Nullable bytes; @property(nonatomic, readonly) NSUInteger byteCount; @@ -48,9 +49,9 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; NS_DESIGNATED_INITIALIZER; @end -// The copier retains its source storage; SDK pointers never escape their access -// scope. Construct only after validating the source layout. Copying cannot -// fail. +// Holds the SDK output until copyBytesTo: runs; raw SDK pointers are used only +// inside that call. Construct only after validating the source layout, because +// copying cannot report errors. @interface ETCoreAITensor : NSObject @property(nonatomic, readonly) NSUInteger byteCount; @property(nonatomic, readonly, copy) NSArray* shape; @@ -70,11 +71,12 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; - (void)copyBytesTo:(void* _Nullable)bytes NS_SWIFT_NAME(copyBytes(to:)); @end -// The caller keeps inputs valid until all output copies finish, or completion -// reports failure. Outputs may share input storage; stage copies before writing -// any destination that aliases an input or another output destination. -// Completion is called exactly once, possibly before executeInputs returns, -// with no guarantee of a particular thread or queue. +// Keep inputs valid until output copying finishes or completion reports +// failure. Outputs may share input storage. Stage copies before writing any +// destination that aliases an input or another output destination. +// +// Completion runs exactly once, possibly before executeInputs returns, on an +// unspecified thread or queue. @protocol ETCoreAISession - (void)executeInputs:(NSArray*)inputs completion:(void (^)( @@ -82,12 +84,12 @@ FOUNDATION_EXPORT NSErrorDomain const ETCoreAIErrorDomain; NSError* _Nullable error))completion; @end -// A prepared model pins the SDK entry. Sessions retain their own pin after -// binding. +// Holds the SDK model, keeping its cache entry in use. Bound sessions hold the +// model too, so they stay valid after the prepared model is released. @protocol ETCoreAIPreparedModel - (NSData*)copyBookmarkData; -// Completes once with either a session or an error, never both. May complete -// synchronously on the calling thread. +// Completes once with either a session or an error, never both. Completion may +// run synchronously on the calling thread. - (void)loadFunctionNamed:(NSString*)functionName inputNames:(NSArray*)inputNames outputNames:(NSArray*)outputNames @@ -102,26 +104,27 @@ typedef NS_ENUM(NSInteger, ETCoreAIAcquisitionStatus) { ETCoreAIAcquisitionStatusError = 2, }; -// Hit: nonnull model, nil error. Miss: nil model and error, only on SDK nil. -// Error: nil model, nonnull error. Function errors never produce a Miss. -// Acquisition operations call completion exactly once off the main thread. +// Hit: nonnull model, nil error. +// Miss: nil model and error, only for an SDK nil result. +// Error: nil model, nonnull error. Function errors never report Miss. +// +// Acquisition completions run exactly once off the main thread. typedef void (^ETCoreAIAcquisitionCompletion)( ETCoreAIAcquisitionStatus status, id _Nullable preparedModel, NSError* _Nullable error); @protocol ETCoreAIModelLoading -// Stable for this loader's lifetime; derived from its actual specialization -// options. +// Stable for this loader's lifetime; derived from its specialization options. @property(nonatomic, readonly, copy) NSString* specializationIdentifier; - (void)restoreModelFromBookmark:(NSData*)bookmark completion:(ETCoreAIAcquisitionCompletion)completion; -// Uses the SDK default cache with storage-pressure-only purging. Never returns -// Miss. +// Uses the SDK default cache with storage-pressure-only purging. +// Specialization never reports Miss. - (void)specializeModelAtURL:(NSURL*)url completion:(ETCoreAIAcquisitionCompletion)completion; -// Nil error means SDK-confirmed deletion. No busy/absent classification is -// inferred. +// Nil error means SDK-confirmed deletion; the bridge adds no +// busy/absent classification. - (void)evictModelWithBookmark:(NSData*)bookmark completion:(void (^)(NSError* _Nullable error))completion; @end diff --git a/backends/apple/coreai/runtime/ETCoreAIError.swift b/backends/apple/coreai/runtime/ETCoreAIError.swift new file mode 100644 index 00000000000..65c20c5f472 --- /dev/null +++ b/backends/apple/coreai/runtime/ETCoreAIError.swift @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +func bridgeError( + _ code: ETCoreAIErrorCode, _ message: String, underlying: Error? = nil +) -> NSError { + var info: [String: Any] = [NSLocalizedDescriptionKey: message] + if let underlying { info[NSUnderlyingErrorKey] = underlying } + return NSError(domain: ETCoreAIErrorDomain, code: code.rawValue, userInfo: info) +} + +func mappedError(_ error: Error, code: ETCoreAIErrorCode, context: String) -> NSError { + let error = error as NSError + return error.domain == ETCoreAIErrorDomain + ? error : bridgeError(code, "\(context): \(error.localizedDescription)", underlying: error) +} + +// Objective-C blocks have no Sendable annotation. Each box is invoked by one detached task only. +final class Completion: @unchecked Sendable { + let call: Callback + + init(_ call: Callback) { + self.call = call + } +} diff --git a/backends/apple/coreai/runtime/ETCoreAIModel.swift b/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift similarity index 53% rename from backends/apple/coreai/runtime/ETCoreAIModel.swift rename to backends/apple/coreai/runtime/ETCoreAIModelLoader.swift index 1e7dc71fffb..adf80fbc91f 100644 --- a/backends/apple/coreai/runtime/ETCoreAIModel.swift +++ b/backends/apple/coreai/runtime/ETCoreAIModelLoader.swift @@ -7,29 +7,6 @@ import CoreAI import Foundation -private func bridgeError( - _ code: ETCoreAIErrorCode, _ message: String, underlying: Error? = nil -) -> NSError { - var info: [String: Any] = [NSLocalizedDescriptionKey: message] - if let underlying { info[NSUnderlyingErrorKey] = underlying } - return NSError(domain: ETCoreAIErrorDomain, code: code.rawValue, userInfo: info) -} - -private func mappedError(_ error: Error, code: ETCoreAIErrorCode, context: String) -> NSError { - let error = error as NSError - return error.domain == ETCoreAIErrorDomain - ? error : bridgeError(code, "\(context): \(error.localizedDescription)", underlying: error) -} - -// Objective-C blocks have no Sendable annotation. Each box is invoked by one detached task only. -private final class Completion: @unchecked Sendable { - let call: Callback - - init(_ call: Callback) { - self.call = call - } -} - // Only a successful SDK nil is a miss; thrown errors retain their underlying cause. func acquireCoreAIModel( context: String, completion: @escaping ETCoreAIAcquisitionCompletion, @@ -49,212 +26,6 @@ func acquireCoreAIModel( } } -@available(macOS 27.0, iOS 27.0, *) -struct CoreAIScalarInfo: Sendable { - let sdkType: NDArray.ScalarType - let bridgeType: ETCoreAIScalarType - let byteWidth: Int - - // Only byte-addressable scalar types belong here; packed types need a separate layout policy. - static let supported: [Self] = [ - Self(sdkType: .float16, bridgeType: .float16, byteWidth: 2), - Self(sdkType: .float32, bridgeType: .float32, byteWidth: 4), - ] - - static func from(sdkType: NDArray.ScalarType) throws -> Self { - guard let info = supported.first(where: { $0.sdkType == sdkType }) else { - throw bridgeError(.unsupported, "Unsupported Core AI scalar type: \(sdkType)") - } - return info - } - - static func from(bridgeType: ETCoreAIScalarType) throws -> Self { - guard let info = supported.first(where: { $0.bridgeType == bridgeType }) else { - throw bridgeError(.unsupported, "Unsupported bridge scalar type: \(bridgeType.rawValue)") - } - return info - } - - func byteCount(shape: [Int], errorCode: ETCoreAIErrorCode = .invalidArgument) throws -> Int { - guard shape.allSatisfy({ $0 >= 0 }) else { - throw bridgeError(errorCode, "Negative tensor dimension") - } - // Check suffix products too: SDK dense-stride construction must not overflow on empty tensors. - var count = byteWidth - for dimension in shape.reversed() { - let (next, overflow) = count.multipliedReportingOverflow(by: max(dimension, 1)) - guard !overflow else { - throw bridgeError(errorCode, "Tensor byte count overflows Int") - } - count = next - } - return shape.contains(0) ? 0 : count - } -} - -// ExecuTorch waits synchronously and keeps input storage live through output copying. -// This snapshot transfers only metadata and a pointer to the task; it does not own storage. -@available(macOS 27.0, iOS 27.0, *) -struct CoreAITensorPayload: @unchecked Sendable { - let bytes: UnsafeRawPointer? - let byteCount: Int - let shape: [Int] - let scalarType: ETCoreAIScalarType - - init(_ tensor: ETCoreAIInputTensor) throws { - shape = try tensor.shape.map { number in - guard let dimension = number as? Int, dimension >= 0 else { - throw bridgeError(.invalidArgument, "Tensor dimensions must be nonnegative integers") - } - return dimension - } - scalarType = tensor.scalarType - byteCount = try Self.byteCount(shape: shape, scalarType: scalarType) - guard tensor.byteCount == byteCount else { - throw bridgeError( - .invalidArgument, "Tensor payload byte count does not match shape and dtype") - } - bytes = tensor.bytes - guard byteCount == 0 || bytes != nil else { - throw bridgeError(.invalidArgument, "Nonempty tensor has null data") - } - } - - static func byteCount( - shape: [Int], scalarType: ETCoreAIScalarType, - errorCode: ETCoreAIErrorCode = .invalidArgument - ) throws -> Int { - try CoreAIScalarInfo.from(bridgeType: scalarType).byteCount(shape: shape, errorCode: errorCode) - } -} - -@available(macOS 27.0, iOS 27.0, *) -private func tensorDescriptor(_ value: InferenceValue.Descriptor?) throws -> NDArrayDescriptor { - guard let value else { throw bridgeError(.invalidModel, "Missing tensor descriptor") } - guard case .ndArray(let descriptor) = value else { - throw bridgeError(.unsupported, "Image values are not supported") - } - _ = try CoreAIScalarInfo.from(sdkType: descriptor.scalarType) - guard descriptor.interleaveLayout == nil else { - throw bridgeError(.unsupported, "Interleaved tensor descriptors are not supported") - } - guard descriptor.shape.allSatisfy({ $0 >= -1 }) else { - throw bridgeError(.invalidModel, "Invalid descriptor dimension") - } - return descriptor -} - -@available(macOS 27.0, iOS 27.0, *) -private func validateShape( - _ shape: [Int], descriptor: NDArrayDescriptor, - errorCode: ETCoreAIErrorCode = .invalidArgument -) throws { - guard shape.count == descriptor.shape.count, - zip(shape, descriptor.shape).allSatisfy({ actual, expected in - actual >= 0 && (expected == -1 || actual == expected) - }) - else { - throw bridgeError(errorCode, "Tensor shape does not match the function descriptor") - } -} - -// Offsets are in elements, including padded or transposed SDK layouts. -struct CoreAITensorLayout { - let shape: [Int] - let strides: [Int] - let count: Int - // Row-major contiguous, ignoring strides of size-1 dimensions. - let isDense: Bool - - init(shape: [Int], strides: [Int], width: Int, storageByteCount: Int) throws { - guard shape.count == strides.count, shape.allSatisfy({ $0 >= 0 }), - strides.allSatisfy({ $0 >= 0 }), width > 0, storageByteCount >= 0 - else { throw bridgeError(.unsupported, "Invalid or negative NDArray strides") } - self.shape = shape - self.strides = strides - var count = 1 - var maximumOffset = 0 - for (dimension, stride) in zip(shape, strides) { - let (next, countOverflow) = count.multipliedReportingOverflow(by: dimension) - let (extent, extentOverflow) = max(dimension - 1, 0).multipliedReportingOverflow(by: stride) - let (offset, offsetOverflow) = maximumOffset.addingReportingOverflow(extent) - guard !countOverflow, !extentOverflow, !offsetOverflow else { - throw bridgeError(.runtime, "NDArray layout overflows Int") - } - count = next - maximumOffset = offset - } - self.count = count - var expectedStride = 1 - var isDense = true - for axis in shape.indices.reversed() where shape[axis] != 1 { - isDense = isDense && strides[axis] == expectedStride - expectedStride = expectedStride &* shape[axis] - } - self.isDense = isDense - if count > 0 { - let (span, spanOverflow) = maximumOffset.addingReportingOverflow(1) - let (bytes, byteOverflow) = span.multipliedReportingOverflow(by: width) - guard !spanOverflow, !byteOverflow, bytes <= storageByteCount else { - throw bridgeError(.runtime, "NDArray layout exceeds its storage") - } - } - } - - func offset(for linearIndex: Int) -> Int { - var remainder = linearIndex - var offset = 0 - for axis in shape.indices.reversed() { - offset += (remainder % shape[axis]) * strides[axis] - remainder /= shape[axis] - } - return offset - } -} - -@available(macOS 27.0, iOS 27.0, *) -func prepareOutput(_ array: NDArray) throws -> ETCoreAITensor { - let type = try CoreAIScalarInfo.from(sdkType: array.scalarType) - let byteCount = try type.byteCount(shape: array.shape, errorCode: .runtime) - guard array.interleaveLayout == nil else { - throw bridgeError(.unsupported, "Interleaved output storage is not supported") - } - let view = array.rawView() - let layout = try CoreAITensorLayout( - shape: array.shape, strides: array.strides, width: type.byteWidth, - storageByteCount: view.bytes.byteCount) - let shape = view.shape - let strides = view.strides - guard view.scalarType == type.sdkType, view.interleaveLayout == nil, - shape.count == layout.shape.count, strides.count == layout.strides.count - else { - throw bridgeError(.runtime, "Output view layout changed") - } - for axis in layout.shape.indices { - guard shape[axis] == layout.shape[axis], strides[axis] == layout.strides[axis] else { - throw bridgeError(.runtime, "Output view layout changed") - } - } - // Retain the immutable array, not its scoped pointer. All fallible work is complete. - return ETCoreAITensor( - byteCount: UInt(byteCount), shape: layout.shape.map(NSNumber.init(value:)), - scalarType: type.bridgeType - ) { destination in - let view = array.rawView() - view.bytes.withUnsafeBytes { source in - if layout.isDense { - destination.copyMemory(from: source.baseAddress!, byteCount: byteCount) - return - } - for index in 0.. Self { + guard let info = supported.first(where: { $0.sdkType == sdkType }) else { + throw bridgeError(.unsupported, "Unsupported Core AI scalar type: \(sdkType)") + } + return info + } + + static func from(bridgeType: ETCoreAIScalarType) throws -> Self { + guard let info = supported.first(where: { $0.bridgeType == bridgeType }) else { + throw bridgeError(.unsupported, "Unsupported bridge scalar type: \(bridgeType.rawValue)") + } + return info + } + + func byteCount(shape: [Int], errorCode: ETCoreAIErrorCode = .invalidArgument) throws -> Int { + guard shape.allSatisfy({ $0 >= 0 }) else { + throw bridgeError(errorCode, "Negative tensor dimension") + } + // Check suffix products too: SDK dense-stride construction must not overflow on empty tensors. + var count = byteWidth + for dimension in shape.reversed() { + let (next, overflow) = count.multipliedReportingOverflow(by: max(dimension, 1)) + guard !overflow else { + throw bridgeError(errorCode, "Tensor byte count overflows Int") + } + count = next + } + return shape.contains(0) ? 0 : count + } +} + +// ExecuTorch waits synchronously and keeps input storage live through output copying. +// This snapshot transfers only metadata and a pointer to the task; it does not own storage. +@available(macOS 27.0, iOS 27.0, *) +struct CoreAITensorPayload: @unchecked Sendable { + let bytes: UnsafeRawPointer? + let byteCount: Int + let shape: [Int] + let scalarType: ETCoreAIScalarType + + init(_ tensor: ETCoreAIInputTensor) throws { + shape = try tensor.shape.map { number in + guard let dimension = number as? Int, dimension >= 0 else { + throw bridgeError(.invalidArgument, "Tensor dimensions must be nonnegative integers") + } + return dimension + } + scalarType = tensor.scalarType + byteCount = try Self.byteCount(shape: shape, scalarType: scalarType) + guard tensor.byteCount == byteCount else { + throw bridgeError( + .invalidArgument, "Tensor payload byte count does not match shape and dtype") + } + bytes = tensor.bytes + guard byteCount == 0 || bytes != nil else { + throw bridgeError(.invalidArgument, "Nonempty tensor has null data") + } + } + + static func byteCount( + shape: [Int], scalarType: ETCoreAIScalarType, + errorCode: ETCoreAIErrorCode = .invalidArgument + ) throws -> Int { + try CoreAIScalarInfo.from(bridgeType: scalarType).byteCount(shape: shape, errorCode: errorCode) + } +} + +@available(macOS 27.0, iOS 27.0, *) +func tensorDescriptor(_ value: InferenceValue.Descriptor?) throws -> NDArrayDescriptor { + guard let value else { throw bridgeError(.invalidModel, "Missing tensor descriptor") } + guard case .ndArray(let descriptor) = value else { + throw bridgeError(.unsupported, "Image values are not supported") + } + _ = try CoreAIScalarInfo.from(sdkType: descriptor.scalarType) + guard descriptor.interleaveLayout == nil else { + throw bridgeError(.unsupported, "Interleaved tensor descriptors are not supported") + } + guard descriptor.shape.allSatisfy({ $0 >= -1 }) else { + throw bridgeError(.invalidModel, "Invalid descriptor dimension") + } + return descriptor +} + +@available(macOS 27.0, iOS 27.0, *) +func validateShape( + _ shape: [Int], descriptor: NDArrayDescriptor, + errorCode: ETCoreAIErrorCode = .invalidArgument +) throws { + guard shape.count == descriptor.shape.count, + zip(shape, descriptor.shape).allSatisfy({ actual, expected in + actual >= 0 && (expected == -1 || actual == expected) + }) + else { + throw bridgeError(errorCode, "Tensor shape does not match the function descriptor") + } +} + +// Offsets are in elements, including padded or transposed SDK layouts. +struct CoreAITensorLayout { + let shape: [Int] + let strides: [Int] + let count: Int + // Row-major contiguous, ignoring strides of size-1 dimensions. + let isDense: Bool + + init(shape: [Int], strides: [Int], width: Int, storageByteCount: Int) throws { + guard shape.count == strides.count, shape.allSatisfy({ $0 >= 0 }), + strides.allSatisfy({ $0 >= 0 }), width > 0, storageByteCount >= 0 + else { throw bridgeError(.unsupported, "Invalid or negative NDArray strides") } + self.shape = shape + self.strides = strides + var count = 1 + var maximumOffset = 0 + for (dimension, stride) in zip(shape, strides) { + let (next, countOverflow) = count.multipliedReportingOverflow(by: dimension) + let (extent, extentOverflow) = max(dimension - 1, 0).multipliedReportingOverflow(by: stride) + let (offset, offsetOverflow) = maximumOffset.addingReportingOverflow(extent) + guard !countOverflow, !extentOverflow, !offsetOverflow else { + throw bridgeError(.runtime, "NDArray layout overflows Int") + } + count = next + maximumOffset = offset + } + self.count = count + var expectedStride = 1 + var isDense = true + for axis in shape.indices.reversed() where shape[axis] != 1 { + isDense = isDense && strides[axis] == expectedStride + expectedStride = expectedStride &* shape[axis] + } + self.isDense = isDense + if count > 0 { + let (span, spanOverflow) = maximumOffset.addingReportingOverflow(1) + let (bytes, byteOverflow) = span.multipliedReportingOverflow(by: width) + guard !spanOverflow, !byteOverflow, bytes <= storageByteCount else { + throw bridgeError(.runtime, "NDArray layout exceeds its storage") + } + } + } + + func offset(for linearIndex: Int) -> Int { + var remainder = linearIndex + var offset = 0 + for axis in shape.indices.reversed() { + offset += (remainder % shape[axis]) * strides[axis] + remainder /= shape[axis] + } + return offset + } +} + +@available(macOS 27.0, iOS 27.0, *) +func prepareOutput(_ array: NDArray) throws -> ETCoreAITensor { + let type = try CoreAIScalarInfo.from(sdkType: array.scalarType) + let byteCount = try type.byteCount(shape: array.shape, errorCode: .runtime) + guard array.interleaveLayout == nil else { + throw bridgeError(.unsupported, "Interleaved output storage is not supported") + } + let view = array.rawView() + let layout = try CoreAITensorLayout( + shape: array.shape, strides: array.strides, width: type.byteWidth, + storageByteCount: view.bytes.byteCount) + let shape = view.shape + let strides = view.strides + guard view.scalarType == type.sdkType, view.interleaveLayout == nil, + shape.count == layout.shape.count, strides.count == layout.strides.count + else { + throw bridgeError(.runtime, "Output view layout changed") + } + for axis in layout.shape.indices { + guard shape[axis] == layout.shape[axis], strides[axis] == layout.strides[axis] else { + throw bridgeError(.runtime, "Output view layout changed") + } + } + // Retain the immutable array, not its scoped pointer. All fallible work is complete. + return ETCoreAITensor( + byteCount: UInt(byteCount), shape: layout.shape.map(NSNumber.init(value:)), + scalarType: type.bridgeType + ) { destination in + let view = array.rawView() + view.bytes.withUnsafeBytes { source in + if layout.isDense { + destination.copyMemory(from: source.baseAddress!, byteCount: byteCount) + return + } + for index in 0.. +#include +#include + +namespace executorch::backends::coreai { +namespace { +using namespace executorch::runtime; + +struct Handle final { + size_t input_count = 0; + size_t output_count = 0; + id session = nil; +}; + +Result cache_directory_option(BackendInitContext& context) { + auto option = context.get_runtime_spec("coreai_cache_dir"); + if (!option.ok()) { + if (option.error() == Error::NotFound) { + return cache_directory_path(nullptr); + } + return option.error(); + } + ET_CHECK_OR_RETURN_ERROR( + option.get() != nullptr, + InvalidArgument, + "Core AI cache directory option must not be null"); + return cache_directory_path(option.get()); +} + +Result> bind_session( + id model, + const SelectedBundle& bundle) { + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block id session = nil; + __block NSError* error = nil; + [model loadFunctionNamed:bundle.function + inputNames:bundle.inputs + outputNames:bundle.outputs + completion:^(id result, NSError* failure) { + session = result; + error = failure; + dispatch_semaphore_signal(ready); + }]; + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + id bound = session; + NSError* failure = error; + // The SDK may retain the completion block after invoking it. + session = nil; + error = nil; + if (failure != nil) { + return execution_error(failure); + } + ET_CHECK_OR_RETURN_ERROR( + bound != nil, Internal, "Core AI returned no session"); + return bound; +} + +class CoreAIBackend final : public BackendInterface { + public: + bool is_available() const override { + return ETCoreAIIsAvailable(); + } + + Result init( + BackendInitContext& context, + FreeableBuffer* processed, + ArrayRef) const override { + @autoreleasepool { + ET_CHECK_OR_RETURN_ERROR( + processed && processed->data() && processed->size() > 0, + InvalidProgram, + "Missing Core AI manifest"); + auto manifest = parse_manifest(processed->data(), processed->size()); + if (!manifest.ok()) { + return manifest.error(); + } + auto host_bundle = select_host_bundle(manifest.get()); + if (!host_bundle.ok()) { + return host_bundle.error(); + } + auto configured = cache_directory_option(context); + if (!configured.ok()) { + return configured.error(); + } + id loader = ETCoreAICreateModelLoader(); + ET_CHECK_OR_RETURN_ERROR( + loader != nil, Internal, "Cannot create Core AI loader"); + auto cache_directory = create_cache_directory(configured.get()); + if (!cache_directory.ok()) { + return cache_directory.error(); + } + auto prepared = acquire_model( + host_bundle.get(), + context.get_named_data_map(), + cache_directory.get(), + loader); + if (!prepared.ok()) { + return prepared.error(); + } + auto session = bind_session(prepared.get(), host_bundle->bundle); + if (!session.ok()) { + return session.error(); + } + auto handle = std::unique_ptr(new (std::nothrow) Handle()); + ET_CHECK_OR_RETURN_ERROR( + handle != nullptr, + MemoryAllocationFailed, + "Cannot allocate Core AI handle"); + handle->input_count = host_bundle->bundle.inputs.count; + handle->output_count = host_bundle->bundle.outputs.count; + handle->session = session.get(); + processed->Free(); + return static_cast(handle.release()); + } + } + + Error execute( + BackendExecutionContext&, + DelegateHandle* opaque, + Span args) const override { + @autoreleasepool { + ET_CHECK_OR_RETURN_ERROR( + opaque != nullptr, DelegateInvalidHandle, "Null Core AI handle"); + auto& handle = *static_cast(opaque); + return execute_session( + handle.session, args, handle.input_count, handle.output_count); + } + } + + void destroy(DelegateHandle* handle) const override { + @autoreleasepool { + delete static_cast(handle); + } + } +}; + +CoreAIBackend backend; +[[maybe_unused]] const auto registration = + register_backend({kCoreAIBackendId, &backend}); +} // namespace +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_backend_id.h b/backends/apple/coreai/runtime/coreai_backend_id.h new file mode 100644 index 00000000000..8fbd72951e1 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_backend_id.h @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +namespace executorch::backends::coreai { +inline constexpr char kCoreAIBackendId[] = "CoreAIBackend"; +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_bookmarks.h b/backends/apple/coreai/runtime/coreai_bookmarks.h deleted file mode 100644 index bf5440f8500..00000000000 --- a/backends/apple/coreai/runtime/coreai_bookmarks.h +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ -#pragma once - -#import -#include -#include -#include "coreai_file.h" - -namespace executorch::backends::coreai { - -// With OpenExisting, a missing root is returned unchanged for no-op -// maintenance. -runtime::Result resolve_bookmark_root( - NSString* path, - StorageCreationMode mode); - -enum class CacheEntryPersistence { - BookmarkCapable, - StagingOnly, -}; - -class CacheEntryLock final { - public: - CacheEntryLock(const CacheEntryLock&) = delete; - CacheEntryLock& operator=(const CacheEntryLock&) = delete; - - CacheEntryPersistence persistence() const { - return persistence_; - } - NSString* key() const { - return key_; - } - // Missing is nil. Empty, oversized, inaccessible, or nonregular files fail - // closed. - runtime::Result read_bookmark() const; - runtime::Error write_bookmark(NSData* data) const; - // Returns the staging parent. Source preparation appends key and bundle. - runtime::Result prepare_staging_root() const; - // Missing staging is a no-op; unexpected entry types fail before deletion. - runtime::Error remove_staging() const; - - private: - friend runtime::Result> - acquire_cache_entry_lock( - NSString* root, - NSString* key, - StorageCreationMode mode); - - CacheEntryLock( - int root, - int bookmarks, - int lock_file, - NSString* root_path, - NSString* key, - CacheEntryPersistence persistence); - - FileDescriptor root_; - FileDescriptor bookmarks_; - FileDescriptor lock_file_; - NSString* root_path_; - NSString* key_; - CacheEntryPersistence persistence_; -}; - -// Each acquisition opens an independent descriptor on one stable stripe. -// Different keys can share a stripe; callers must not hold multiple entry locks -// at once. Stripe files are never removed by the backend. -runtime::Result> lock_cache_entry( - NSString* root, - NSString* key); -// Missing roots and entries return a null lock without changing storage. -// Existing staging-only entries return a lock with StagingOnly persistence. -runtime::Result> lock_existing_cache_entry( - NSString* root, - NSString* key); - -} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_bookmarks.mm b/backends/apple/coreai/runtime/coreai_bookmarks.mm deleted file mode 100644 index 393653028af..00000000000 --- a/backends/apple/coreai/runtime/coreai_bookmarks.mm +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ -#import "coreai_bookmarks.h" - -#import "coreai_cache_key.h" -#import "coreai_cache_root.h" -#import "coreai_fault_injection.h" -#import "coreai_storage.h" -#import "coreai_validation.h" - -#include -#include -#include -#include -#include - -namespace executorch::backends::coreai { -namespace { -using runtime::Error; -using runtime::Result; - -constexpr size_t kBookmarkByteLimit = 8 * 1024 * 1024; -NSString* const kBookmarksDirectory = @"bookmarks"; -NSString* const kStagingDirectory = @"staging"; -NSString* const kLocksDirectory = @"locks"; -NSString* const kBookmarkExtension = @".bookmark"; - -NSString* bookmark_name(NSString* key) { - return [key stringByAppendingString:kBookmarkExtension]; -} - -// Returns -1 when the root does not exist. -Result open_root(NSString* root) { - const int fd = retry_eintr([&] { - return open( - root.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_CLOEXEC); - }); - if (fd < 0 && errno == ENOENT) { - return -1; - } - ET_CHECK_OR_RETURN_ERROR( - fd >= 0, AccessFailed, "Cannot open Core AI assets root"); - return fd; -} - -// Returns -1 when the child does not exist in OpenExisting mode. -Result open_child(int root_fd, NSString* name, StorageCreationMode mode) { - auto opened = open_storage_directory(root_fd, name, mode); - if (!opened.ok()) { - return opened.error(); - } - FileDescriptor child(opened.get().fd); - if (opened.get().created) { - ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(root_fd)); - } - return child.release(); -} - -Result entry_exists(int root_fd, NSString* key) { - for (NSString* directory_name in - @[ kBookmarksDirectory, kStagingDirectory ]) { - auto opened = - open_child(root_fd, directory_name, StorageCreationMode::OpenExisting); - if (!opened.ok()) { - return opened.error(); - } - FileDescriptor directory(opened.get()); - if (directory.get() < 0) { - continue; - } - NSString* entry = - [directory_name isEqual:kBookmarksDirectory] ? bookmark_name(key) : key; - struct stat info; - if (lstat_at(directory.get(), entry.fileSystemRepresentation, &info) == 0) { - return true; - } - ET_CHECK_OR_RETURN_ERROR( - errno == ENOENT, AccessFailed, "Cannot inspect Core AI cache entry"); - } - return false; -} -} // namespace - -Result> acquire_cache_entry_lock( - NSString* root, - NSString* key, - StorageCreationMode mode) { - auto lock_name = cache_entry_lock_name(key); - if (!lock_name.ok()) { - return lock_name.error(); - } - auto resolved = resolve_bookmark_root(root, mode); - if (!resolved.ok()) { - return resolved.error(); - } - root = [resolved.get() copy]; - auto opened = open_root(root); - if (!opened.ok()) { - return opened.error(); - } - FileDescriptor directory(opened.get()); - const bool create = mode == StorageCreationMode::Create; - if (directory.get() < 0) { - ET_CHECK_OR_RETURN_ERROR( - !create, AccessFailed, "Core AI assets root disappeared"); - return std::unique_ptr(); - } - if (!create) { - auto exists = entry_exists(directory.get(), key); - if (!exists.ok()) { - return exists.error(); - } - if (!exists.get()) { - return std::unique_ptr(); - } - } - auto locks = - open_child(directory.get(), kLocksDirectory, StorageCreationMode::Create); - if (!locks.ok()) { - return locks.error(); - } - FileDescriptor lock_directory(locks.get()); - FileDescriptor lock_file(open_storage_shared_file( - lock_directory.get(), lock_name.get().fileSystemRepresentation)); - struct stat info; - ET_CHECK_OR_RETURN_ERROR( - lock_file.get() >= 0 && fstat(lock_file.get(), &info) == 0 && - S_ISREG(info.st_mode), - AccessFailed, - "Invalid Core AI cache entry lock"); - ET_CHECK_OR_RETURN_ERROR( - fault_point(FaultPoint::BeforeCacheEntryLock) == 0, - AccessFailed, - "Cannot begin Core AI cache entry lock acquisition"); - ET_CHECK_OR_RETURN_ERROR( - retry_eintr([&] { return flock(lock_file.get(), LOCK_EX); }) == 0, - AccessFailed, - "Cannot lock Core AI cache entry"); - if (!create) { - auto exists = entry_exists(directory.get(), key); - if (!exists.ok()) { - return exists.error(); - } - if (!exists.get()) { - return std::unique_ptr(); - } - } - auto bookmarks = open_child(directory.get(), kBookmarksDirectory, mode); - if (!bookmarks.ok()) { - return bookmarks.error(); - } - FileDescriptor bookmark_directory(bookmarks.get()); - const CacheEntryPersistence persistence = bookmark_directory.get() >= 0 - ? CacheEntryPersistence::BookmarkCapable - : CacheEntryPersistence::StagingOnly; - return std::unique_ptr(new CacheEntryLock( - directory.release(), - bookmark_directory.release(), - lock_file.release(), - root, - [key copy], - persistence)); -} - -Result resolve_bookmark_root( - NSString* path, - StorageCreationMode mode) { - ET_CHECK_OR_RETURN_ERROR( - nonempty_string(path) && [path hasPrefix:@"/"], - InvalidArgument, - "Core AI assets require an absolute root path"); - if (mode == StorageCreationMode::Create) { - return prepare_storage_root(path, mode); - } - struct stat info; - const int result = - retry_eintr([&] { return stat(path.fileSystemRepresentation, &info); }); - if (result < 0 && errno == ENOENT) { - return path; - } - ET_CHECK_OR_RETURN_ERROR( - result == 0 && S_ISDIR(info.st_mode), - AccessFailed, - "Invalid Core AI assets root"); - return path; -} - -CacheEntryLock::CacheEntryLock( - int root, - int bookmarks, - int lock_file, - NSString* root_path, - NSString* key, - CacheEntryPersistence persistence) - : root_(root), - bookmarks_(bookmarks), - lock_file_(lock_file), - root_path_(root_path), - key_(key), - persistence_(persistence) {} - -Result CacheEntryLock::read_bookmark() const { - if (persistence_ == CacheEntryPersistence::StagingOnly) { - return static_cast(nil); - } - FileDescriptor file(retry_eintr([&] { - return openat( - bookmarks_.get(), - bookmark_name(key_).fileSystemRepresentation, - O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); - })); - if (file.get() < 0 && errno == ENOENT) { - return static_cast(nil); - } - ET_CHECK_OR_RETURN_ERROR( - file.get() >= 0, AccessFailed, "Cannot open Core AI bookmark"); - struct stat info; - ET_CHECK_OR_RETURN_ERROR( - fstat(file.get(), &info) == 0 && S_ISREG(info.st_mode) && - info.st_size > 0 && - static_cast(info.st_size) <= kBookmarkByteLimit, - InvalidExternalData, - "Invalid Core AI bookmark file"); - NSMutableData* bytes = [NSMutableData dataWithLength:info.st_size]; - ET_CHECK_OR_RETURN_ERROR( - bytes != nil, MemoryAllocationFailed, "Cannot allocate Core AI bookmark"); - size_t offset = 0; - while (offset < bytes.length) { - const ssize_t count = ::read( - file.get(), - static_cast(bytes.mutableBytes) + offset, - bytes.length - offset); - if (count < 0 && errno == EINTR) { - continue; - } - ET_CHECK_OR_RETURN_ERROR( - count > 0, AccessFailed, "Cannot read Core AI bookmark"); - offset += count; - } - return bytes; -} - -Error CacheEntryLock::write_bookmark(NSData* data) const { - ET_CHECK_OR_RETURN_ERROR( - [data isKindOfClass:NSData.class] && data.length > 0 && - data.length <= kBookmarkByteLimit, - InvalidExternalData, - "Invalid Core AI bookmark data"); - ET_CHECK_OR_RETURN_ERROR( - persistence_ == CacheEntryPersistence::BookmarkCapable, - InvalidArgument, - "Cannot write through a staging-only Core AI cache entry lock"); - return publish_storage_data(bookmarks_.get(), bookmark_name(key_), data); -} - -Result CacheEntryLock::prepare_staging_root() const { - auto staging = - open_child(root_.get(), kStagingDirectory, StorageCreationMode::Create); - if (!staging.ok()) { - return staging.error(); - } - FileDescriptor directory(staging.get()); - return [root_path_ stringByAppendingPathComponent:kStagingDirectory]; -} - -Error CacheEntryLock::remove_staging() const { - auto opened = open_child( - root_.get(), kStagingDirectory, StorageCreationMode::OpenExisting); - if (!opened.ok()) { - return opened.error(); - } - FileDescriptor staging(opened.get()); - if (staging.get() < 0) { - return Error::Ok; - } - return remove_storage_directory( - staging.get(), key_, StorageRemovalPolicy::Strict); -} - -Result> lock_cache_entry( - NSString* root, - NSString* key) { - return acquire_cache_entry_lock(root, key, StorageCreationMode::Create); -} - -Result> lock_existing_cache_entry( - NSString* root, - NSString* key) { - return acquire_cache_entry_lock(root, key, StorageCreationMode::OpenExisting); -} - -} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache.h b/backends/apple/coreai/runtime/coreai_cache.h new file mode 100644 index 00000000000..0d2ea862772 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache.h @@ -0,0 +1,58 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include + +namespace executorch::runtime { +class DataLoader; +} + +namespace executorch::backends::coreai { + +/** + * Clears tracked SDK entries, per-key source bundles, and bookmarks in a + * dedicated Core AI cache directory. Null selects the same default as loading; + * supplied paths must be absolute. Missing directories are successful no-ops. + * PTEs, stable lock files, and unkeyed temporaries are not removed. + * SDK entries without saved bookmarks cannot be targeted. + * + * Callers must unload affected models and prevent concurrent loading, + * inference, and maintenance across processes until this call returns. + * Independent keys are attempted in sorted order, returning the first error; + * clearing is not a transaction. SDK failures preserve an entry's files unless + * the SDK confirms that entry is absent. + */ +[[nodiscard]] runtime::Error clear_cache( + const char* coreai_cache_dir = nullptr); + +/** + * Clears all recorded cache variants referenced by every CoreAI delegate in + * the PTE after validating its manifests. Each exported bundle's identity + * selects every device and specialization variant. Discovery does not require + * host compatibility or SDK availability; evicting an entry with a saved + * bookmark does. Does not initialize delegates or materialize Core AI assets. + * + * A null directory selects the same Caches default as backend initialization. + * Supplied paths must resolve to valid explicit cache directories; they never + * fall back to the default. The directory is never derived from the PTE's + * location. + * Copied PTEs can share cache keys. The quiescence and failure contract of + * clear_cache also applies. Keep the loader alive and its data unchanged until + * the call returns. + */ +[[nodiscard]] runtime::Error clear_cache_for_pte( + runtime::DataLoader& loader, + const char* coreai_cache_dir = nullptr); + +/** FileDataLoader convenience overload with the same contract. */ +[[nodiscard]] runtime::Error clear_cache_for_pte( + const char* pte_path, + const char* coreai_cache_dir = nullptr); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache.mm b/backends/apple/coreai/runtime/coreai_cache.mm new file mode 100644 index 00000000000..2f6c092eb81 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache.mm @@ -0,0 +1,141 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#include "coreai_cache.h" + +#import "coreai_cache_entry.h" +#import "coreai_cache_key.h" +#import "coreai_cache_root.h" +#import "coreai_load_coordinator.h" +#import "coreai_manifest.h" +#include "coreai_pte.h" + +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +Result> cache_directory(const char* path) { + auto configured = cache_directory_path(path); + if (!configured.ok()) { + return configured.error(); + } + return open_cache_directory(configured.get()); +} + +Error clear_keys(const CacheDirectory& directory, NSArray* keys) { + NSArray* ordered = [[[NSSet setWithArray:keys] allObjects] + sortedArrayUsingSelector:@selector(compare:)]; + Error first_error = Error::Ok; + for (NSString* key in ordered) { + const Error error = clear_cache_entry(directory, key, nil); + if (error != Error::Ok) { + ET_LOG( + Error, + "Core AI cache clear failed for key %s: 0x%x", + key.UTF8String, + static_cast(error)); + if (first_error == Error::Ok) { + first_error = error; + } + } + } + return first_error; +} + +Error clear_pte( + runtime::DataLoader& loader, + const std::optional& directory) { + // Keep the verified image and selected segment owners alive through eviction. + auto pte = inspect_coreai_pte(loader); + if (!pte.ok()) { + return pte.error(); + } + NSMutableSet* bundle_identities = [NSMutableSet set]; + for (const auto& buffer : pte->processed) { + auto data = buffer.data_safe(); + if (!data.ok()) { + return data.error(); + } + ET_CHECK_OR_RETURN_ERROR( + data.get() != nullptr && buffer.size() > 0, + InvalidProgram, + "Missing Core AI manifest"); + auto manifest = parse_manifest(data.get(), buffer.size()); + if (!manifest.ok()) { + return manifest.error(); + } + for (NSString* bundle_name in manifest->bundle_digests) { + auto identity = bundle_identity(manifest.get(), bundle_name); + if (!identity.ok()) { + return identity.error(); + } + [bundle_identities addObject:identity.get()]; + } + } + if (bundle_identities.count == 0 || !directory.has_value()) { + return Error::Ok; + } + auto inventory = inventory_cache_keys(*directory); + if (!inventory.ok()) { + return inventory.error(); + } + NSMutableArray* keys = [NSMutableArray array]; + for (NSString* key in inventory.get()) { + if ([bundle_identities containsObject:cache_key_bundle_identity(key)]) { + [keys addObject:key]; + } + } + return clear_keys(*directory, keys); +} +} // namespace + +Error clear_cache(const char* coreai_cache_dir) { + @autoreleasepool { + auto directory = cache_directory(coreai_cache_dir); + if (!directory.ok()) { + return directory.error(); + } + if (!directory->has_value()) { + return Error::Ok; + } + auto keys = inventory_cache_keys(directory->value()); + if (!keys.ok()) { + return keys.error(); + } + return clear_keys(directory->value(), keys.get()); + } +} + +Error clear_cache_for_pte( + runtime::DataLoader& loader, + const char* coreai_cache_dir) { + @autoreleasepool { + auto directory = cache_directory(coreai_cache_dir); + if (!directory.ok()) { + return directory.error(); + } + return clear_pte(loader, directory.get()); + } +} + +Error clear_cache_for_pte(const char* pte_path, const char* coreai_cache_dir) { + ET_CHECK_OR_RETURN_ERROR( + pte_path != nullptr && pte_path[0] != '\0', + InvalidArgument, + "Missing PTE path"); + auto loader = extension::FileDataLoader::from(pte_path); + if (!loader.ok()) { + return loader.error(); + } + return clear_cache_for_pte(loader.get(), coreai_cache_dir); +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache_entry.h b/backends/apple/coreai/runtime/coreai_cache_entry.h new file mode 100644 index 00000000000..f3b8230f5b1 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache_entry.h @@ -0,0 +1,123 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#import +#include +#include +#include +#include "coreai_cache_root.h" + +namespace executorch::backends::coreai { + +// Invalid and unowned names are omitted from the sorted inventory. +runtime::Result*> inventory_cache_keys( + const CacheDirectory& directory); + +enum class CacheBookmarkState { + Missing, + Corrupt, + Present, +}; + +struct CacheBookmarkInspection final { + CacheBookmarkState state; + NSData* data; +}; + +enum class CacheEntryPersistence { + BookmarkCapable, + // Only from acquire_existing when bookmarks/ is missing; never creates it. + SourceOnly, +}; + +struct CacheSourceDirectory final { + FileDescriptor descriptor; + NSURL* url; +}; + +class CacheEntryMaintenance; + +class LockedCacheEntry final { + public: + LockedCacheEntry(const LockedCacheEntry&) = delete; + LockedCacheEntry& operator=(const LockedCacheEntry&) = delete; + + // Each acquisition owns an independent descriptor on one stable stripe. + static runtime::Result> acquire( + const CacheDirectory& directory, + NSString* key); + // Missing entries return a null handle without changing entry storage. + static runtime::Result> acquire_existing( + const CacheDirectory& directory, + NSString* key); + + CacheEntryPersistence persistence() const { + return persistence_; + } + NSString* key() const { + return key_; + } + // Missing is nil. Empty, oversized, inaccessible, or nonregular files fail + // closed. + runtime::Result read_bookmark() const; + runtime::Error write_bookmark(NSData* data) const; + runtime::Result prepare_source_directory() const; + // A missing source bundle is a no-op; unexpected entry types fail before + // deletion. + runtime::Error remove_source() const; + + // The view must not outlive this locked entry. + CacheEntryMaintenance maintenance() const; + + private: + friend class CacheEntryMaintenance; + + LockedCacheEntry( + FileDescriptor root, + FileDescriptor bookmarks, + FileDescriptor lock_file, + NSString* root_path, + NSString* key, + CacheEntryPersistence persistence); + static runtime::Result> acquire_impl( + const CacheDirectory& directory, + NSString* key, + StorageCreationMode mode); + runtime::Result> open_source_directory( + StorageCreationMode mode) const; + // Applies a storage operation to this key's source bundle. A missing + // sources/ directory is a no-op. + runtime::Error apply_to_source( + runtime::Error (*operation)(int, NSString*, StorageRemovalPolicy), + StorageRemovalPolicy policy) const; + + FileDescriptor root_; + FileDescriptor bookmarks_; + FileDescriptor lock_file_; + NSString* root_path_; + NSString* key_; + CacheEntryPersistence persistence_; +}; + +class CacheEntryMaintenance final { + public: + runtime::Result inspect_bookmark() const; + runtime::Error preflight() const; + runtime::Error remove_source() const; + runtime::Error remove_bookmark() const; + + private: + friend class LockedCacheEntry; + explicit CacheEntryMaintenance(const LockedCacheEntry& entry) + : entry_(entry) {} + + const LockedCacheEntry& entry_; +}; + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache_entry.mm b/backends/apple/coreai/runtime/coreai_cache_entry.mm new file mode 100644 index 00000000000..ce363e04760 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_cache_entry.mm @@ -0,0 +1,370 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#import "coreai_cache_entry.h" + +#import "coreai_cache_key.h" +#import "coreai_fault_injection.h" +#import "coreai_storage.h" +#import "coreai_validation.h" + +#include +#include +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; + +constexpr size_t kBookmarkByteLimit = 8 * 1024 * 1024; +NSString* const kBookmarksDirectory = @"bookmarks"; +NSString* const kSourcesDirectory = @"sources"; +NSString* const kLocksDirectory = @"locks"; +NSString* const kBookmarkSuffix = @(kBookmarkExtension); + +NSString* bookmark_name(NSString* key) { + return [key stringByAppendingString:kBookmarkSuffix]; +} + +Result> +open_child(int root_fd, NSString* name, StorageCreationMode mode) { + auto opened = open_storage_directory(root_fd, name, mode); + if (!opened.ok()) { + return opened.error(); + } + if (opened->created) { + ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(root_fd)); + } + return std::move(opened->descriptor); +} + +Result entry_exists(int root_fd, NSString* key) { + for (NSString* directory_name in + @[ kBookmarksDirectory, kSourcesDirectory ]) { + auto opened = + open_child(root_fd, directory_name, StorageCreationMode::OpenExisting); + if (!opened.ok()) { + return opened.error(); + } + if (!opened->has_value()) { + continue; + } + NSString* entry = + [directory_name isEqual:kBookmarksDirectory] ? bookmark_name(key) : key; + struct stat info; + if (lstat_at( + opened->value().get(), entry.fileSystemRepresentation, &info) == + 0) { + return true; + } + ET_CHECK_OR_RETURN_ERROR( + errno == ENOENT, AccessFailed, "Cannot inspect Core AI cache entry"); + } + return false; +} + +Result duplicate_directory(int fd) { + FileDescriptor duplicate( + retry_eintr([&] { return fcntl(fd, F_DUPFD_CLOEXEC, 0); })); + ET_CHECK_OR_RETURN_ERROR( + duplicate.get() >= 0, + AccessFailed, + "Cannot retain Core AI cache directory"); + return duplicate; +} + +} // namespace + +Result*> inventory_cache_keys( + const CacheDirectory& directory) { + NSMutableSet* keys = [NSMutableSet set]; + for (NSString* child_name in @[ kBookmarksDirectory, kSourcesDirectory ]) { + auto child = open_child( + directory.descriptor.get(), + child_name, + StorageCreationMode::OpenExisting); + if (!child.ok()) { + return child.error(); + } + if (!child->has_value()) { + continue; + } + auto names = + storage_children(child->value().get(), StorageNamePolicy::SkipInvalid); + if (!names.ok()) { + return names.error(); + } + for (NSString* name in names.get()) { + NSString* key = name; + if ([child_name isEqual:kBookmarksDirectory]) { + if (![name hasSuffix:kBookmarkSuffix] || + name.length <= kBookmarkSuffix.length) { + continue; + } + key = [name substringToIndex:name.length - kBookmarkSuffix.length]; + } + if (is_cache_key(key)) { + [keys addObject:key]; + } + } + } + return [[keys allObjects] sortedArrayUsingSelector:@selector(compare:)]; +} + +LockedCacheEntry::LockedCacheEntry( + FileDescriptor root, + FileDescriptor bookmarks, + FileDescriptor lock_file, + NSString* root_path, + NSString* key, + CacheEntryPersistence persistence) + : root_(std::move(root)), + bookmarks_(std::move(bookmarks)), + lock_file_(std::move(lock_file)), + root_path_(root_path), + key_(key), + persistence_(persistence) {} + +Result> LockedCacheEntry::acquire( + const CacheDirectory& directory, + NSString* key) { + return acquire_impl(directory, key, StorageCreationMode::Create); +} + +Result> LockedCacheEntry::acquire_existing( + const CacheDirectory& directory, + NSString* key) { + return acquire_impl(directory, key, StorageCreationMode::OpenExisting); +} + +Result> LockedCacheEntry::acquire_impl( + const CacheDirectory& cache_directory, + NSString* key, + StorageCreationMode mode) { + auto lock_name = cache_entry_lock_name(key); + if (!lock_name.ok()) { + return lock_name.error(); + } + auto duplicated = duplicate_directory(cache_directory.descriptor.get()); + if (!duplicated.ok()) { + return duplicated.error(); + } + FileDescriptor directory = std::move(duplicated.get()); + const bool create = mode == StorageCreationMode::Create; + // Checking before locking avoids creating locks/ for a missing entry. + if (!create) { + auto exists = entry_exists(directory.get(), key); + if (!exists.ok()) { + return exists.error(); + } + if (!exists.get()) { + return std::unique_ptr(); + } + } + auto locks = + open_child(directory.get(), kLocksDirectory, StorageCreationMode::Create); + if (!locks.ok()) { + return locks.error(); + } + FileDescriptor lock_file(open_storage_shared_file( + locks->value().get(), lock_name.get().fileSystemRepresentation)); + struct stat info; + ET_CHECK_OR_RETURN_ERROR( + lock_file.get() >= 0 && fstat(lock_file.get(), &info) == 0 && + S_ISREG(info.st_mode), + AccessFailed, + "Invalid Core AI cache entry lock"); + ET_CHECK_OR_RETURN_ERROR( + fault_point(FaultPoint::BeforeCacheEntryLock) == 0, + AccessFailed, + "Cannot begin Core AI cache entry lock acquisition"); + ET_CHECK_OR_RETURN_ERROR( + retry_eintr([&] { return flock(lock_file.get(), LOCK_EX); }) == 0, + AccessFailed, + "Cannot lock Core AI cache entry"); + // Another process may have cleared the entry while this one waited. + if (!create) { + auto exists = entry_exists(directory.get(), key); + if (!exists.ok()) { + return exists.error(); + } + if (!exists.get()) { + return std::unique_ptr(); + } + } + auto bookmarks = open_child(directory.get(), kBookmarksDirectory, mode); + if (!bookmarks.ok()) { + return bookmarks.error(); + } + const CacheEntryPersistence persistence = bookmarks->has_value() + ? CacheEntryPersistence::BookmarkCapable + : CacheEntryPersistence::SourceOnly; + FileDescriptor bookmark_directory = + bookmarks->has_value() ? std::move(bookmarks->value()) : FileDescriptor(); + return std::unique_ptr(new LockedCacheEntry( + std::move(directory), + std::move(bookmark_directory), + std::move(lock_file), + [cache_directory.path copy], + [key copy], + persistence)); +} + +Result LockedCacheEntry::read_bookmark() const { + if (persistence_ == CacheEntryPersistence::SourceOnly) { + return static_cast(nil); + } + FileDescriptor file(retry_eintr([&] { + return openat( + bookmarks_.get(), + bookmark_name(key_).fileSystemRepresentation, + O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + })); + if (file.get() < 0 && errno == ENOENT) { + return static_cast(nil); + } + ET_CHECK_OR_RETURN_ERROR( + file.get() >= 0, AccessFailed, "Cannot open Core AI bookmark"); + struct stat info; + ET_CHECK_OR_RETURN_ERROR( + fstat(file.get(), &info) == 0 && S_ISREG(info.st_mode) && + info.st_size > 0 && + static_cast(info.st_size) <= kBookmarkByteLimit, + InvalidExternalData, + "Invalid Core AI bookmark file"); + NSMutableData* bytes = [NSMutableData dataWithLength:info.st_size]; + ET_CHECK_OR_RETURN_ERROR( + bytes != nil, MemoryAllocationFailed, "Cannot allocate Core AI bookmark"); + size_t offset = 0; + while (offset < bytes.length) { + const ssize_t count = retry_eintr([&] { + return ::read( + file.get(), + static_cast(bytes.mutableBytes) + offset, + bytes.length - offset); + }); + ET_CHECK_OR_RETURN_ERROR( + count > 0, AccessFailed, "Cannot read Core AI bookmark"); + offset += count; + } + return bytes; +} + +Error LockedCacheEntry::write_bookmark(NSData* data) const { + ET_CHECK_OR_RETURN_ERROR( + [data isKindOfClass:NSData.class] && data.length > 0 && + data.length <= kBookmarkByteLimit, + InvalidExternalData, + "Invalid Core AI bookmark data"); + ET_CHECK_OR_RETURN_ERROR( + persistence_ == CacheEntryPersistence::BookmarkCapable, + InvalidArgument, + "Cannot write through a source-only Core AI cache entry lock"); + return publish_storage_data(bookmarks_.get(), bookmark_name(key_), data); +} + +Result> LockedCacheEntry::open_source_directory( + StorageCreationMode mode) const { + return open_child(root_.get(), kSourcesDirectory, mode); +} + +Error LockedCacheEntry::apply_to_source( + Error (*operation)(int, NSString*, StorageRemovalPolicy), + StorageRemovalPolicy policy) const { + auto sources = open_source_directory(StorageCreationMode::OpenExisting); + if (!sources.ok()) { + return sources.error(); + } + if (!sources->has_value()) { + return Error::Ok; + } + return operation(sources->value().get(), key_, policy); +} + +Result LockedCacheEntry::prepare_source_directory() + const { + auto sources = open_source_directory(StorageCreationMode::Create); + if (!sources.ok()) { + return sources.error(); + } + NSURL* url = [[NSURL fileURLWithPath:root_path_ isDirectory:YES] + URLByAppendingPathComponent:kSourcesDirectory + isDirectory:YES]; + return CacheSourceDirectory{std::move(sources->value()), url}; +} + +Error LockedCacheEntry::remove_source() const { + return apply_to_source( + remove_storage_directory, StorageRemovalPolicy::Strict); +} + +CacheEntryMaintenance LockedCacheEntry::maintenance() const { + return CacheEntryMaintenance(*this); +} + +Result CacheEntryMaintenance::inspect_bookmark() + const { + if (entry_.persistence_ == CacheEntryPersistence::SourceOnly) { + return CacheBookmarkInspection{CacheBookmarkState::Missing, nil}; + } + struct stat info; + const int inspected = lstat_at( + entry_.bookmarks_.get(), + bookmark_name(entry_.key_).fileSystemRepresentation, + &info); + if (inspected < 0 && errno == ENOENT) { + return CacheBookmarkInspection{CacheBookmarkState::Missing, nil}; + } + ET_CHECK_OR_RETURN_ERROR( + inspected == 0, AccessFailed, "Cannot inspect Core AI bookmark"); + if (!S_ISREG(info.st_mode)) { + return CacheBookmarkInspection{CacheBookmarkState::Corrupt, nil}; + } + auto bookmark = entry_.read_bookmark(); + if (!bookmark.ok()) { + if (bookmark.error() == Error::InvalidExternalData) { + return CacheBookmarkInspection{CacheBookmarkState::Corrupt, nil}; + } + return bookmark.error(); + } + return CacheBookmarkInspection{ + bookmark.get() == nil ? CacheBookmarkState::Missing + : CacheBookmarkState::Present, + bookmark.get()}; +} + +Error CacheEntryMaintenance::preflight() const { + if (entry_.persistence_ == CacheEntryPersistence::BookmarkCapable) { + ET_CHECK_OK_OR_RETURN_ERROR(preflight_storage_directory( + entry_.bookmarks_.get(), + bookmark_name(entry_.key_), + StorageRemovalPolicy::Maintenance)); + } + return entry_.apply_to_source( + preflight_storage_directory, StorageRemovalPolicy::Maintenance); +} + +Error CacheEntryMaintenance::remove_source() const { + return entry_.apply_to_source( + remove_storage_directory, StorageRemovalPolicy::Maintenance); +} + +Error CacheEntryMaintenance::remove_bookmark() const { + if (entry_.persistence_ == CacheEntryPersistence::SourceOnly) { + return Error::Ok; + } + return remove_storage_directory( + entry_.bookmarks_.get(), + bookmark_name(entry_.key_), + StorageRemovalPolicy::Maintenance); +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache_key.h b/backends/apple/coreai/runtime/coreai_cache_key.h index 4ea30a6e1c0..b252821cbb3 100644 --- a/backends/apple/coreai/runtime/coreai_cache_key.h +++ b/backends/apple/coreai/runtime/coreai_cache_key.h @@ -17,14 +17,17 @@ class Manifest; class SelectedBundle; bool is_cache_key(NSString* key); -NSString* cache_key_model_prefix(NSString* key); +NSString* cache_key_bundle_identity(NSString* key); -// Model identity excludes device and specialization so maintenance can find all -// variants of one exported bundle. -runtime::Result cache_model_key( +// Bookmark files are named key + extension, which bounds the key length. +inline constexpr char kBookmarkExtension[] = ".bookmark"; + +// Bundle identity excludes device and specialization so maintenance can find +// every runtime variant of one exported bundle. +runtime::Result bundle_identity( const Manifest& manifest, NSString* bundle_name); -runtime::Result cache_model_key(const SelectedBundle& bundle); +runtime::Result bundle_identity(const SelectedBundle& bundle); // specialization_identity is stable, versioned plain data supplied by the SDK // boundary. This module does not depend on SDK types. runtime::Result cache_variant_key( diff --git a/backends/apple/coreai/runtime/coreai_cache_key.mm b/backends/apple/coreai/runtime/coreai_cache_key.mm index 87b0f9a9f2b..8f63ce7fd25 100644 --- a/backends/apple/coreai/runtime/coreai_cache_key.mm +++ b/backends/apple/coreai/runtime/coreai_cache_key.mm @@ -18,8 +18,8 @@ constexpr NSUInteger kHashLength = 2 * CC_SHA256_DIGEST_LENGTH; constexpr NSUInteger kFixedLength = 2 * (kHashLength + 1); -constexpr NSUInteger kBookmarkExtensionLength = sizeof(".bookmark") - 1; -constexpr NSUInteger kMaxKeyLength = NAME_MAX - kBookmarkExtensionLength; +constexpr NSUInteger kMaxKeyLength = + NAME_MAX - (sizeof(kBookmarkExtension) - 1); static_assert(kCacheEntryLockStripeCount > 0); static_assert(kCacheEntryLockStripeCount <= 256); @@ -74,13 +74,11 @@ bool valid_device(NSString* device) { return [result copy]; } -Result -model_key(NSString* digest, NSString* bundle_name, bool aot_compiled) { - ET_CHECK_OR_RETURN_ERROR( - lowercase_hex(digest, kHashLength) && relative_path(bundle_name) && - ![bundle_name containsString:@"/"], - DelegateInvalidCompatibility, - "Invalid Core AI bundle identity"); +Result compute_bundle_identity( + NSString* digest, + NSString* bundle_name, + bool aot_compiled) { + // Bumping the leading version makes every existing cache entry unreachable. auto encoded = encode_json( @[ @"1", digest, aot_compiled ? @"aot" : @"source", bundle_name ]); if (!encoded.ok()) { @@ -91,7 +89,7 @@ bool valid_device(NSString* device) { Result utf8_data(NSString* value) { ET_CHECK_OR_RETURN_ERROR( - nonempty_string(value), + is_nonempty_string(value), DelegateInvalidCompatibility, "Missing Core AI cache specialization"); NSData* data = [value dataUsingEncoding:NSUTF8StringEncoding]; @@ -113,8 +111,8 @@ bool is_cache_key(NSString* key) { } NSString* model = [key substringToIndex:kHashLength]; NSString* options = [key substringFromIndex:options_start]; - if (!lowercase_hex(model, kHashLength) || - !lowercase_hex(options, kHashLength)) { + if (!is_lowercase_hex(model, kHashLength) || + !is_lowercase_hex(options, kHashLength)) { return false; } return valid_device( @@ -122,19 +120,22 @@ bool is_cache_key(NSString* key) { kHashLength + 1, key.length - kFixedLength)]); } -NSString* cache_key_model_prefix(NSString* key) { +NSString* cache_key_bundle_identity(NSString* key) { return is_cache_key(key) ? [key substringToIndex:kHashLength] : nil; } -Result cache_model_key( +Result bundle_identity( const Manifest& manifest, NSString* bundle_name) { - return model_key( - manifest.bundle_digests[bundle_name], bundle_name, manifest.aot_compiled); + NSString* digest = manifest.bundle_digests[bundle_name]; + ET_CHECK_OR_RETURN_ERROR( + digest != nil, InvalidArgument, "Unknown Core AI bundle name"); + return compute_bundle_identity(digest, bundle_name, manifest.aot_compiled); } -Result cache_model_key(const SelectedBundle& bundle) { - return model_key(bundle.digest, bundle.bundle_name, bundle.aot_compiled); +Result bundle_identity(const SelectedBundle& bundle) { + return compute_bundle_identity( + bundle.digest, bundle.bundle_name, bundle.aot_compiled); } Result cache_variant_key( @@ -142,12 +143,12 @@ bool is_cache_key(NSString* key) { NSString* platform, NSString* architecture, NSString* specialization_identity) { - auto model = cache_model_key(bundle); + auto model = bundle_identity(bundle); if (!model.ok()) { return model.error(); } ET_CHECK_OR_RETURN_ERROR( - nonempty_string(platform) && nonempty_string(architecture) && + is_nonempty_string(platform) && is_nonempty_string(architecture) && ![platform containsString:@"_"], DelegateInvalidCompatibility, "Invalid Core AI cache context"); @@ -173,17 +174,11 @@ bool is_cache_key(NSString* key) { ET_CHECK_OR_RETURN_ERROR( is_cache_key(key), InvalidArgument, "Invalid Core AI cache key"); NSData* encoded = [key dataUsingEncoding:NSUTF8StringEncoding]; - auto hash = hash_data(encoded); - if (!hash.ok()) { - return hash.error(); - } - unsigned int first_byte = 0; - const bool parsed = - [[NSScanner scannerWithString:[hash.get() substringToIndex:2]] - scanHexInt:&first_byte]; - ET_CHECK_OR_RETURN_ERROR( - parsed, InvalidExternalData, "Cannot derive Core AI cache lock stripe"); - const size_t stripe = first_byte % kCacheEntryLockStripeCount; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(encoded.bytes, static_cast(encoded.length), digest); + const size_t stripe = digest[0] % kCacheEntryLockStripeCount; + // A different stripe scheme needs a new prefix so its files never alias + // these. return [NSString stringWithFormat:@"stripe-v1-%02zx.lock", stripe]; } diff --git a/backends/apple/coreai/runtime/coreai_cache_root.h b/backends/apple/coreai/runtime/coreai_cache_root.h index ee536e241fc..f2c3f696202 100644 --- a/backends/apple/coreai/runtime/coreai_cache_root.h +++ b/backends/apple/coreai/runtime/coreai_cache_root.h @@ -10,16 +10,30 @@ #import #include -#include "coreai_file.h" +#include +#include "coreai_storage.h" namespace executorch::backends::coreai { +struct CacheDirectory final { + NSString* path; + FileDescriptor descriptor; +}; + // Resolves the default location without creating or modifying storage. -runtime::Result default_coreai_assets_root(); -// Requires an absolute directory path. With Create, makes the directory and -// excludes it from backup; otherwise a missing directory is InvalidArgument. -runtime::Result prepare_storage_root( - NSString* path, - StorageCreationMode mode); +runtime::Result default_cache_directory(); +// Null selects the default; invalid explicit paths never fall back to it. +runtime::Result cache_directory_path(const char* configured_path); + +// Both functions resolve a symlink in the final path component once and hold +// the directory open, so later path changes cannot redirect the cache. +// +// Creates the directory if missing and excludes it from backup. A path that is +// not a directory is the caller's configuration error: InvalidArgument. +runtime::Result create_cache_directory(NSString* path); +// Returns no value when the directory is missing. A path that is not a +// directory means existing storage is unusable: AccessFailed. +runtime::Result> open_cache_directory( + NSString* path); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_cache_root.mm b/backends/apple/coreai/runtime/coreai_cache_root.mm index 18ca1b6f198..abc039bed0f 100644 --- a/backends/apple/coreai/runtime/coreai_cache_root.mm +++ b/backends/apple/coreai/runtime/coreai_cache_root.mm @@ -8,24 +8,29 @@ #import "coreai_cache_root.h" #include "coreai_fault_injection.h" -#include "coreai_file.h" +#include "coreai_validation.h" +#include #include +#include #include +#include +#include +#include namespace executorch::backends::coreai { namespace { using runtime::Error; using runtime::Result; -Error ensure_excluded_from_backup(NSURL* directory) { - NSURL* fresh = [NSURL fileURLWithPath:directory.path isDirectory:YES]; +Error ensure_excluded_from_backup(NSString* path) { + NSURL* directory = [NSURL fileURLWithPath:path isDirectory:YES]; NSNumber* excluded = nil; NSError* error = nil; ET_CHECK_OR_RETURN_ERROR( - [fresh getResourceValue:&excluded - forKey:NSURLIsExcludedFromBackupKey - error:&error], + [directory getResourceValue:&excluded + forKey:NSURLIsExcludedFromBackupKey + error:&error], AccessFailed, "Cannot read Core AI backup exclusion: %s", error.localizedDescription.UTF8String); @@ -33,71 +38,157 @@ Error ensure_excluded_from_backup(NSURL* directory) { return Error::Ok; } ET_CHECK_OR_RETURN_ERROR( - [fresh setResourceValue:@YES - forKey:NSURLIsExcludedFromBackupKey - error:&error], + [directory setResourceValue:@YES + forKey:NSURLIsExcludedFromBackupKey + error:&error], AccessFailed, - "Cannot exclude Core AI storage from backup: %s", + "Cannot exclude Core AI cache directory from backup: %s", error.localizedDescription.UTF8String); return Error::Ok; } -} // namespace +Result canonical_path(NSString* path) { + char* resolved = realpath(path.fileSystemRepresentation, nullptr); + ET_CHECK_OR_RETURN_ERROR( + resolved != nullptr, + AccessFailed, + "Cannot resolve Core AI cache directory"); + NSString* canonical = [NSFileManager.defaultManager + stringWithFileSystemRepresentation:resolved + length:std::strlen(resolved)]; + std::free(resolved); + ET_CHECK_OR_RETURN_ERROR( + canonical != nil, + AccessFailed, + "Cannot decode Core AI cache directory path"); + return canonical; +} -Result default_coreai_assets_root() { - NSURL* caches = - [NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory - inDomains:NSUserDomainMask] - .firstObject; +// isAbsolutePath accepts "~" paths, which the filesystem would treat literally. +bool is_cache_directory_path(NSString* path) { + return is_nonempty_string(path) && path.isAbsolutePath && + ![path hasPrefix:@"~"]; +} + +Error create_directory(NSString* path) { + NSDictionary* attributes = @{ + NSFileProtectionKey : NSFileProtectionCompleteUntilFirstUserAuthentication, + }; ET_CHECK_OR_RETURN_ERROR( - caches.isFileURL, AccessFailed, "Cannot resolve Core AI cache directory"); - return [caches.path stringByAppendingPathComponent:@"executorch_coreai"]; + fault_point(FaultPoint::BeforeCreateDirectory) == 0, + AccessFailed, + "Cannot begin Core AI cache directory creation"); + NSError* error = nil; + ET_CHECK_OR_RETURN_ERROR( + [NSFileManager.defaultManager createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:attributes + error:&error], + AccessFailed, + "Cannot create Core AI cache directory: %s", + error.localizedDescription.UTF8String); + return Error::Ok; } -Result prepare_storage_root( +Error not_a_directory(StorageCreationMode mode) { + ET_LOG(Error, "Core AI cache path is not a directory"); + return mode == StorageCreationMode::Create ? Error::InvalidArgument + : Error::AccessFailed; +} + +Result> resolve_cache_directory( NSString* path, StorageCreationMode mode) { const bool create = mode == StorageCreationMode::Create; ET_CHECK_OR_RETURN_ERROR( - [path isKindOfClass:NSString.class] && [path hasPrefix:@"/"] && - [path rangeOfString:@"\0"].location == NSNotFound, + is_cache_directory_path(path), InvalidArgument, - "Core AI storage requires an absolute path"); + "Core AI cache directory requires an absolute path"); struct stat info; - const int result = - retry_eintr([&] { return stat(path.fileSystemRepresentation, &info); }); - ET_CHECK_OR_RETURN_ERROR( - result == 0 || errno == ENOENT || errno == ENOTDIR, - AccessFailed, - "Cannot inspect Core AI storage directory"); - ET_CHECK_OR_RETURN_ERROR( - result == 0 ? S_ISDIR(info.st_mode) : create && errno == ENOENT, - InvalidArgument, - "Core AI storage must be a directory"); - if (create && result != 0) { - NSDictionary* attributes = @{ - NSFileProtectionKey : - NSFileProtectionCompleteUntilFirstUserAuthentication, - }; + if (retry_eintr([&] { return stat(path.fileSystemRepresentation, &info); }) != + 0) { + if (errno == ENOTDIR) { + return not_a_directory(mode); + } ET_CHECK_OR_RETURN_ERROR( - fault_point(FaultPoint::BeforeCreateDirectory) == 0, + errno == ENOENT, AccessFailed, - "Cannot begin Core AI directory creation"); - NSError* error = nil; - ET_CHECK_OR_RETURN_ERROR( - [NSFileManager.defaultManager createDirectoryAtPath:path - withIntermediateDirectories:YES - attributes:attributes - error:&error], - AccessFailed, - "Cannot create Core AI storage directory: %s", - error.localizedDescription.UTF8String); + "Cannot inspect Core AI cache directory"); + if (!create) { + return std::optional(); + } + ET_CHECK_OK_OR_RETURN_ERROR(create_directory(path)); + } else if (!S_ISDIR(info.st_mode)) { + return not_a_directory(mode); + } + NSString* resolved_path = path; + struct stat link_info; + if (retry_eintr([&] { + return lstat(path.fileSystemRepresentation, &link_info); + }) == 0 && + S_ISLNK(link_info.st_mode)) { + auto canonical = canonical_path(path); + if (!canonical.ok()) { + return canonical.error(); + } + resolved_path = canonical.get(); } + FileDescriptor directory(retry_eintr([&] { + return open( + resolved_path.fileSystemRepresentation, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + })); + if (directory.get() < 0 && errno == ENOENT && !create) { + return std::optional(); + } + ET_CHECK_OR_RETURN_ERROR( + directory.get() >= 0 && fstat(directory.get(), &info) == 0 && + S_ISDIR(info.st_mode), + AccessFailed, + "Cannot open Core AI cache directory"); if (create) { - ET_CHECK_OK_OR_RETURN_ERROR(ensure_excluded_from_backup( - [NSURL fileURLWithPath:path isDirectory:YES])); + ET_CHECK_OK_OR_RETURN_ERROR(ensure_excluded_from_backup(resolved_path)); + } + return std::optional( + std::in_place, + CacheDirectory{[resolved_path copy], std::move(directory)}); +} + +} // namespace + +Result default_cache_directory() { + NSURL* caches = + [NSFileManager.defaultManager URLsForDirectory:NSCachesDirectory + inDomains:NSUserDomainMask] + .firstObject; + ET_CHECK_OR_RETURN_ERROR( + caches.isFileURL, AccessFailed, "Cannot resolve Core AI cache directory"); + return [caches.path stringByAppendingPathComponent:@"executorch_coreai"]; +} + +Result cache_directory_path(const char* configured_path) { + if (configured_path == nullptr) { + return default_cache_directory(); } + NSString* path = [NSString stringWithUTF8String:configured_path]; + ET_CHECK_OR_RETURN_ERROR( + is_cache_directory_path(path), + InvalidArgument, + "Core AI cache directory requires an absolute UTF-8 path"); return path; } +Result create_cache_directory(NSString* path) { + auto directory = resolve_cache_directory(path, StorageCreationMode::Create); + if (!directory.ok()) { + return directory.error(); + } + // Create mode reports a missing directory as an error, never as no value. + return std::move(**directory); +} + +Result> open_cache_directory(NSString* path) { + return resolve_cache_directory(path, StorageCreationMode::OpenExisting); +} + } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_error.h b/backends/apple/coreai/runtime/coreai_error.h new file mode 100644 index 00000000000..09c276bb43b --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_error.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#import +#include + +namespace executorch::backends::coreai { + +// Binding and execution preserve bridge errors that callers can act on. +runtime::Error execution_error(NSError* error); +// Acquisition failures describe SDK/cache internals, not caller arguments. +runtime::Error acquisition_error(NSError* error); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_error.mm b/backends/apple/coreai/runtime/coreai_error.mm new file mode 100644 index 00000000000..e63e66fd477 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_error.mm @@ -0,0 +1,48 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#import "coreai_error.h" + +#import "ETCoreAIBridge.h" + +#include + +namespace executorch::backends::coreai { + +runtime::Error execution_error(NSError* error) { + ET_LOG( + Error, + "Core AI execution error (%s, %ld): %s", + error.domain.UTF8String, + static_cast(error.code), + error.description.UTF8String); + if ([error.domain isEqualToString:ETCoreAIErrorDomain]) { + switch (error.code) { + case ETCoreAIErrorInvalidModel: + return runtime::Error::InvalidProgram; + case ETCoreAIErrorUnsupported: + return runtime::Error::NotSupported; + case ETCoreAIErrorInvalidArgument: + return runtime::Error::InvalidArgument; + default: + break; + } + } + return runtime::Error::Internal; +} + +runtime::Error acquisition_error(NSError* error) { + ET_LOG( + Error, + "Core AI acquisition error (%s, %ld): %s", + error.domain.UTF8String, + static_cast(error.code), + error.description.UTF8String); + return runtime::Error::Internal; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_fault_injection.h b/backends/apple/coreai/runtime/coreai_fault_injection.h index 4981d05cdd6..79b9f38da6b 100644 --- a/backends/apple/coreai/runtime/coreai_fault_injection.h +++ b/backends/apple/coreai/runtime/coreai_fault_injection.h @@ -8,6 +8,10 @@ #pragma once +#include +#include +#include "coreai_file.h" + namespace executorch::backends::coreai { enum class FaultPoint { @@ -17,12 +21,14 @@ enum class FaultPoint { FullSync, Rename, Remove, - CreateStagingDirectory, - OpenStagingDirectory, + CreatePendingDirectory, + OpenPendingDirectory, BeforeCreateDirectory, BeforeCacheEntryLock, BeforeSDK, AfterSDK, + BeforeEvict, + AfterEvict, }; #if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING @@ -37,4 +43,16 @@ constexpr int fault_point(FaultPoint) { } #endif +template +auto fault_aware_retry(FaultPoint point, F&& call) { + return retry_eintr([&]() -> decltype(call()) { + const int fault = fault_point(point); + if (fault != 0) { + errno = fault; + return -1; + } + return call(); + }); +} + } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_file.h b/backends/apple/coreai/runtime/coreai_file.h index cbc991a5fae..473c8d5bb7d 100644 --- a/backends/apple/coreai/runtime/coreai_file.h +++ b/backends/apple/coreai/runtime/coreai_file.h @@ -15,19 +15,21 @@ namespace executorch::backends::coreai { -enum class StorageCreationMode { - OpenExisting, - Create, -}; - class FileDescriptor { public: - explicit FileDescriptor(int fd) : fd_(fd) {} + explicit FileDescriptor(int fd = -1) : fd_(fd) {} ~FileDescriptor() { reset(-1); } FileDescriptor(const FileDescriptor&) = delete; FileDescriptor& operator=(const FileDescriptor&) = delete; + FileDescriptor(FileDescriptor&& other) noexcept : fd_(other.release()) {} + FileDescriptor& operator=(FileDescriptor&& other) noexcept { + if (this != &other) { + reset(other.release()); + } + return *this; + } int get() const { return fd_; } diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.h b/backends/apple/coreai/runtime/coreai_load_coordinator.h index fa190084522..0cff9087e7d 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.h +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.h @@ -9,24 +9,32 @@ #include #import "ETCoreAIBridge.h" +#import "coreai_cache_root.h" #import "coreai_manifest.h" namespace executorch::backends::coreai { -struct RuntimeAssets final { +struct SelectedHostBundle final { SelectedBundle bundle; NSString* const platform; NSString* const architecture; }; -runtime::Result select_runtime_assets(const Manifest& manifest); +runtime::Result select_host_bundle( + const Manifest& manifest); // The cache entry lock outlives all SDK callbacks and bookmark publication. // Cleanup after a restore hit or durable publication is best-effort. -runtime::Result> acquire_bookmark_model( - const RuntimeAssets& assets, +runtime::Result> acquire_model( + const SelectedHostBundle& host_bundle, const runtime::NamedDataMap* named_data, - NSString* root, + const CacheDirectory& cache_directory, + id loader); + +// Callers must keep affected loads and sessions quiescent across processes. +runtime::Error clear_cache_entry( + const CacheDirectory& cache_directory, + NSString* key, id loader); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_load_coordinator.mm b/backends/apple/coreai/runtime/coreai_load_coordinator.mm index 5903a2393d9..bd4f45a7de1 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -7,8 +7,9 @@ */ #import "coreai_load_coordinator.h" -#import "coreai_bookmarks.h" +#import "coreai_cache_entry.h" #import "coreai_cache_key.h" +#import "coreai_error.h" #import "coreai_fault_injection.h" #import "coreai_source_bundle.h" @@ -20,14 +21,12 @@ using runtime::Error; using runtime::Result; -Error acquisition_error(NSError* error) { - ET_LOG( - Error, - "Core AI acquisition error (%s, %ld): %s", - error.domain.UTF8String, - static_cast(error.code), - error.description.UTF8String); - return Error::Internal; +Error require_coreai_available() { + ET_CHECK_OR_RETURN_ERROR( + ETCoreAIIsAvailable(), + NotSupported, + "Core AI requires macOS 27 or iOS 27"); + return Error::Ok; } Result> wait_for_acquisition( @@ -49,7 +48,7 @@ Error acquisition_error(NSError* error) { error = failure; dispatch_semaphore_signal(ready); }); - // The SDK may still be reading caller-owned source after a bounded timeout. + // Wait without a timeout; the SDK may still be reading the staged source. dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); id acquired = model; NSError* failure = error; @@ -79,27 +78,57 @@ Error acquisition_error(NSError* error) { }); } -void cleanup_source(const CacheEntryLock& cache_entry) { - const Error error = cache_entry.remove_staging(); +void cleanup_source(const LockedCacheEntry& cache_entry) { + const Error error = cache_entry.remove_source(); if (error != Error::Ok) { ET_LOG( Error, - "Cannot clean Core AI staging: error=%u", + "Cannot remove Core AI source bundle: error=%u", static_cast(error)); } } + +Error evict_one(id loader, NSData* bookmark) { + ET_CHECK_OR_RETURN_ERROR( + fault_point(FaultPoint::BeforeEvict) == 0, + AccessFailed, + "Core AI eviction interrupted"); + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block NSError* failure = nil; + [loader evictModelWithBookmark:bookmark + completion:^(NSError* error) { + failure = error; + dispatch_semaphore_signal(ready); + }]; + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + NSError* eviction_failure = failure; + // A loader may retain the completion block after invoking it. + failure = nil; + if (eviction_failure != nil) { + // A confirmed restore miss proves that a failed eviction is already done. + @autoreleasepool { + auto probe = restore(loader, bookmark); + if (probe.ok() && probe.get() == nil) { + return Error::Ok; + } + } + return acquisition_error(eviction_failure); + } + ET_CHECK_OR_RETURN_ERROR( + fault_point(FaultPoint::AfterEvict) == 0, + AccessFailed, + "Core AI eviction interrupted"); + return Error::Ok; +} } // namespace -Result select_runtime_assets(const Manifest& manifest) { +Result select_host_bundle(const Manifest& manifest) { ET_CHECK_OR_RETURN_ERROR( [NSProcessInfo.processInfo isOperatingSystemAtLeastVersion:manifest.minimum_version], DelegateInvalidCompatibility, "Core AI model requires a newer operating system"); - ET_CHECK_OR_RETURN_ERROR( - ETCoreAIIsAvailable(), - NotSupported, - "Core AI requires macOS 27 or iOS 27"); + ET_CHECK_OK_OR_RETURN_ERROR(require_coreai_available()); #if TARGET_OS_OSX NSString* platform = @"macOS"; #elif TARGET_OS_IOS @@ -108,30 +137,33 @@ void cleanup_source(const CacheEntryLock& cache_entry) { NSString* platform = @"unsupported"; #endif NSString* architecture = ETCoreAIDeviceArchitectureName(); - auto selected = select_assets(manifest, architecture, platform); + auto selected = select_bundle(manifest, architecture, platform); if (!selected.ok()) { return selected.error(); } - return RuntimeAssets{ + return SelectedHostBundle{ std::move(selected.get()), [platform copy], [architecture copy]}; } -Result> acquire_bookmark_model( - const RuntimeAssets& assets, +Result> acquire_model( + const SelectedHostBundle& host_bundle, const runtime::NamedDataMap* named_data, - NSString* root, + const CacheDirectory& cache_directory, id loader) { ET_CHECK_OR_RETURN_ERROR( loader != nil, InvalidArgument, "Missing Core AI model loader"); NSString* specialization = [loader.specializationIdentifier copy]; auto key = cache_variant_key( - assets.bundle, assets.platform, assets.architecture, specialization); + host_bundle.bundle, + host_bundle.platform, + host_bundle.architecture, + specialization); if (!key.ok()) { return key.error(); } NSString* cache_key = key.get(); ET_LOG(Info, "Core AI cache lookup: key=%s", cache_key.UTF8String); - auto locked = lock_cache_entry(root, cache_key); + auto locked = LockedCacheEntry::acquire(cache_directory, cache_key); if (!locked.ok()) { return locked.error(); } @@ -161,7 +193,10 @@ void cleanup_source(const CacheEntryLock& cache_entry) { Info, "Core AI cache miss: no bookmark, key=%s", cache_key.UTF8String); } auto source = prepare_source_bundle( - assets.bundle, named_data, *locked.get(), CorruptBundlePolicy::Replace); + host_bundle.bundle, + named_data, + *locked.get(), + CorruptBundlePolicy::Replace); if (!source.ok()) { return source.error(); } @@ -171,7 +206,7 @@ void cleanup_source(const CacheEntryLock& cache_entry) { ET_LOG( Info, "Core AI specialization started: kind=%s, key=%s", - assets.bundle.aot_compiled ? "aot" : "source", + host_bundle.bundle.aot_compiled ? "aot" : "source", cache_key.UTF8String); [loader specializeModelAtURL:url completion:completion]; }); @@ -192,4 +227,49 @@ void cleanup_source(const CacheEntryLock& cache_entry) { return model; } +Error clear_cache_entry( + const CacheDirectory& cache_directory, + NSString* key, + id loader) { + auto locked = LockedCacheEntry::acquire_existing(cache_directory, key); + if (!locked.ok()) { + return locked.error(); + } + if (locked.get() == nullptr) { + return Error::Ok; + } + ET_LOG(Info, "Core AI cache clear started: key=%s", key.UTF8String); + auto maintenance = locked.get()->maintenance(); + auto bookmark = maintenance.inspect_bookmark(); + if (!bookmark.ok()) { + return bookmark.error(); + } + ET_CHECK_OK_OR_RETURN_ERROR(maintenance.preflight()); + if (bookmark->state != CacheBookmarkState::Present) { + ET_LOG( + Info, + "Core AI cache key %s has a %s bookmark; SDK eviction could not be " + "confirmed; attempting local cleanup", + key.UTF8String, + bookmark->state == CacheBookmarkState::Missing ? "missing" : "corrupt"); + } else { + if (loader == nil) { + ET_CHECK_OK_OR_RETURN_ERROR(require_coreai_available()); + loader = ETCoreAICreateModelLoader(); + ET_CHECK_OR_RETURN_ERROR( + loader != nil, Internal, "Cannot create Core AI model loader"); + } + ET_LOG(Info, "Core AI SDK eviction requested: key=%s", key.UTF8String); + ET_CHECK_OK_OR_RETURN_ERROR(evict_one(loader, bookmark->data)); + ET_LOG( + Info, + "Core AI SDK cache entry absence confirmed: key=%s", + key.UTF8String); + } + ET_CHECK_OK_OR_RETURN_ERROR(maintenance.remove_source()); + ET_CHECK_OK_OR_RETURN_ERROR(maintenance.remove_bookmark()); + ET_LOG(Info, "Core AI local cache entry cleared: key=%s", key.UTF8String); + return Error::Ok; +} + } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_manifest.h b/backends/apple/coreai/runtime/coreai_manifest.h index c929e9cf632..9627761c0ce 100644 --- a/backends/apple/coreai/runtime/coreai_manifest.h +++ b/backends/apple/coreai/runtime/coreai_manifest.h @@ -15,7 +15,7 @@ namespace executorch::backends::coreai { class Manifest final { public: - NSString* const model_hash; + NSString* const export_hash; NSString* const function; NSArray* const inputs; NSArray* const outputs; @@ -34,7 +34,7 @@ class Manifest final { private: Manifest( - NSString* model_hash, + NSString* export_hash, NSString* function, NSArray* inputs, NSArray* outputs, @@ -50,7 +50,7 @@ class Manifest final { class SelectedBundle final { public: - NSString* const model_hash; + NSString* const export_hash; NSString* const path; NSString* const bundle_name; NSString* const digest; @@ -71,7 +71,7 @@ class SelectedBundle final { NSString* path, NSDictionary* files); - friend runtime::Result select_assets( + friend runtime::Result select_bundle( const Manifest& manifest, NSString* device_architecture, NSString* platform); @@ -79,7 +79,7 @@ class SelectedBundle final { runtime::Result parse_manifest(NSData* data); runtime::Result parse_manifest(const void* data, size_t size); -runtime::Result select_assets( +runtime::Result select_bundle( const Manifest& manifest, NSString* device_architecture, NSString* platform); diff --git a/backends/apple/coreai/runtime/coreai_manifest.mm b/backends/apple/coreai/runtime/coreai_manifest.mm index 06ffff5e832..af139c8f09d 100644 --- a/backends/apple/coreai/runtime/coreai_manifest.mm +++ b/backends/apple/coreai/runtime/coreai_manifest.mm @@ -14,7 +14,7 @@ namespace executorch::backends::coreai { Manifest::Manifest( - NSString* model_hash, + NSString* export_hash, NSString* function, NSArray* inputs, NSArray* outputs, @@ -24,7 +24,7 @@ NSString* platform, NSDictionary* architectures, NSOperatingSystemVersion minimum_version) - : model_hash(model_hash), + : export_hash(export_hash), function(function), inputs(inputs), outputs(outputs), @@ -39,10 +39,10 @@ const Manifest& manifest, NSString* path, NSDictionary* files) - : model_hash(manifest.model_hash), + : export_hash(manifest.export_hash), path(path), bundle_name(path.lastPathComponent), - digest(manifest.bundle_digests[bundle_name]), + digest(manifest.bundle_digests[path.lastPathComponent]), function(manifest.function), inputs(manifest.inputs), outputs(manifest.outputs), @@ -59,7 +59,7 @@ bool names_array(id value) { } NSMutableSet* seen = [NSMutableSet set]; for (id name in value) { - if (!nonempty_string(name) || [seen containsObject:name]) { + if (!is_nonempty_string(name) || [seen containsObject:name]) { return false; } [seen addObject:name]; @@ -91,7 +91,7 @@ Error validate_files( NSLocale* locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; for (NSString* file in filenames) { ET_CHECK_OR_RETURN_ERROR( - relative_path(file) && file_size(filenames[file]) && + is_relative_path(file) && file_size(filenames[file]) && [bundles containsObject:[file componentsSeparatedByString:@"/"] .firstObject] && [file containsString:@"/"] && @@ -132,7 +132,7 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { version = {}; return true; } - if (!nonempty_string(value)) { + if (!is_nonempty_string(value)) { return false; } NSArray* parts = [value componentsSeparatedByString:@"."]; @@ -179,21 +179,23 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { NotSupported, "Unsupported Core AI packaging; expected .aimodel or AOT .aimodelc"); const bool aot_compiled = [packaging hasPrefix:@"aot_compiled_"]; - NSString* model_hash = dict[@"hash"]; + NSString* export_hash = dict[@"export_hash"]; NSString* function = dict[@"function"]; NSArray* inputs = dict[@"input_names"]; NSArray* outputs = dict[@"output_names"]; NSString* platform = nil; NSDictionary* architectures = nil; ET_CHECK_OR_RETURN_ERROR( - lowercase_hex(model_hash), InvalidProgram, "Invalid Core AI model hash"); + is_lowercase_hex(export_hash), + InvalidProgram, + "Invalid Core AI export hash"); NSMutableSet* bundles = [NSMutableSet set]; NSString* source_path = nil; if (aot_compiled) { platform = dict[@"platform"]; architectures = dict[@"archs"]; ET_CHECK_OR_RETURN_ERROR( - nonempty_string(platform), + is_nonempty_string(platform), InvalidProgram, "Core AI AOT manifest requires a target platform"); ET_CHECK_OR_RETURN_ERROR( @@ -203,15 +205,15 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { "Core AI AOT manifest requires a nonempty architecture map"); for (id arch in architectures) { ET_CHECK_OR_RETURN_ERROR( - relative_path(arch) && ![arch containsString:@"/"] && + is_relative_path(arch) && ![arch containsString:@"/"] && [arch canBeConvertedToEncoding:NSUTF8StringEncoding], InvalidProgram, "Invalid Core AI AOT architecture name"); NSString* bundle = [NSString stringWithFormat:@"model.%@.aimodelc", arch]; id path = architectures[arch]; ET_CHECK_OR_RETURN_ERROR( - relative_path(path) && - [path isEqualToString:[model_hash + is_relative_path(path) && + [path isEqualToString:[export_hash stringByAppendingPathComponent:bundle]], InvalidProgram, "Invalid Core AI AOT bundle path for architecture %s", @@ -221,16 +223,17 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { } else { source_path = dict[@"path"]; ET_CHECK_OR_RETURN_ERROR( - relative_path(source_path) && + is_relative_path(source_path) && [source_path - isEqualToString:[model_hash + isEqualToString:[export_hash stringByAppendingString:@"/model.aimodel"]], InvalidProgram, "Invalid Core AI source bundle path"); [bundles addObject:@"model.aimodel"]; } ET_CHECK_OR_RETURN_ERROR( - nonempty_string(function) && names_array(inputs) && names_array(outputs), + is_nonempty_string(function) && names_array(inputs) && + names_array(outputs), InvalidProgram, "Core AI manifest requires ordered input/output names"); NSOperatingSystemVersion minimum_version{}; @@ -249,12 +252,12 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { for (NSString* bundle in bundles) { id digest = bundle_digests[bundle]; ET_CHECK_OR_RETURN_ERROR( - lowercase_hex(digest, 64), + is_lowercase_hex(digest, 64), InvalidProgram, "Invalid Core AI exported bundle digest"); } return Manifest( - model_hash, + export_hash, function, inputs, outputs, @@ -272,14 +275,14 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { return parse_manifest([NSData dataWithBytes:data length:size]); } -Result select_assets( +Result select_bundle( const Manifest& manifest, NSString* device_architecture, NSString* platform) { if (!manifest.aot_compiled) { return SelectedBundle( manifest, - [manifest.model_hash stringByAppendingString:@"/model.aimodel"], + [manifest.export_hash stringByAppendingString:@"/model.aimodel"], manifest.files); } ET_CHECK_OR_RETURN_ERROR( @@ -295,7 +298,7 @@ bool parse_version(id value, NSOperatingSystemVersion& version) { }]; NSString* available = [architectures componentsJoinedByString:@", "]; ET_CHECK_OR_RETURN_ERROR( - nonempty_string(device_architecture), + is_nonempty_string(device_architecture), DelegateInvalidCompatibility, "Core AI SDK device architecture is unavailable (%s); available " "architectures: [%s]; re-export the model as .aimodel or for this device", diff --git a/backends/apple/coreai/runtime/coreai_pte.cpp b/backends/apple/coreai/runtime/coreai_pte.cpp new file mode 100644 index 00000000000..512ae6c31e5 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_pte.cpp @@ -0,0 +1,256 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_pte.h" +#include "coreai_backend_id.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +namespace fb = executorch_flatbuffer; +using runtime::DataLoader; +using runtime::Error; +using runtime::ExtendedHeader; +using runtime::FreeableBuffer; +using runtime::Result; +using SegmentInfo = DataLoader::SegmentInfo; + +struct ProgramExtent { + size_t file_size; + size_t program_size; + ExtendedHeader header{}; + bool has_header = false; +}; + +struct VerifiedProgram { + FreeableBuffer storage; + const fb::Program* program; +}; + +Result cpu_data(const FreeableBuffer& buffer, size_t size) { + auto data = buffer.data_safe(); + if (!data.ok()) { + return data.error(); + } + if ((data.get() == nullptr && size != 0) || buffer.size() < size) { + return Error::InvalidProgram; + } + return data.get(); +} + +// Mirror Program::load's header and buffer checks without loading any methods. +Result parse_header(DataLoader& loader) { + auto source_size = loader.size(); + if (!source_size.ok()) { + return source_size.error(); + } + ProgramExtent extent{source_size.get(), source_size.get()}; + if (extent.file_size >= ExtendedHeader::kNumHeadBytes) { + auto prefix = loader.load( + 0, + ExtendedHeader::kNumHeadBytes, + SegmentInfo(SegmentInfo::Type::Program)); + if (!prefix.ok()) { + return prefix.error(); + } + auto data = cpu_data(prefix.get(), ExtendedHeader::kNumHeadBytes); + if (!data.ok()) { + return data.error(); + } + auto parsed = + ExtendedHeader::Parse(data.get(), ExtendedHeader::kNumHeadBytes); + if (parsed.ok()) { + extent.header = parsed.get(); + extent.has_header = true; + const auto& header = extent.header; + if (header.program_size > std::numeric_limits::max() || + header.segment_base_offset > std::numeric_limits::max() || + header.segment_data_size > std::numeric_limits::max() || + header.program_size < ExtendedHeader::kNumHeadBytes || + header.program_size > extent.file_size) { + return Error::InvalidProgram; + } + extent.program_size = static_cast(header.program_size); + // Legacy headers do not specify the segment extent. + if (header.segment_data_size != 0 && + (header.segment_base_offset < extent.program_size || + header.segment_base_offset > extent.file_size || + header.segment_data_size > + extent.file_size - header.segment_base_offset)) { + return Error::InvalidProgram; + } + } else if (parsed.error() != Error::NotFound) { + return Error::InvalidProgram; + } + } + if (extent.program_size == 0 || + extent.program_size >= FLATBUFFERS_MAX_BUFFER_SIZE) { + return Error::InvalidProgram; + } + return extent; +} + +Result load_program( + DataLoader& loader, + const ProgramExtent& extent) { + auto loaded = loader.load( + 0, extent.program_size, SegmentInfo(SegmentInfo::Type::Program)); + if (!loaded.ok()) { + return loaded.error(); + } + auto data = cpu_data(loaded.get(), extent.program_size); + if (!data.ok()) { + return data.error(); + } + if (reinterpret_cast(data.get()) % alignof(std::max_align_t) != + 0) { + return Error::InvalidArgument; + } + flatbuffers::Verifier verifier( + static_cast(data.get()), extent.program_size); + if (!fb::VerifyProgramBuffer(verifier)) { + return Error::InvalidProgram; + } + const auto* program = fb::GetProgram(data.get()); + if (program->version() > runtime::Program::kMaxSupportedSchemaVersion || + runtime::validate_program(program) != Error::Ok) { + return Error::InvalidProgram; + } + return VerifiedProgram{std::move(loaded.get()), program}; +} + +Result load_inline(const fb::Program& program, uint32_t index) { + const auto* buffers = program.backend_delegate_data(); + if (buffers == nullptr) { + return Error::InvalidProgram; + } + if (index >= buffers->size()) { + return Error::NotFound; + } + const auto* bytes = buffers->Get(index)->data(); + if (bytes == nullptr) { + return Error::InvalidProgram; + } + return FreeableBuffer(bytes->data(), bytes->size(), nullptr, nullptr); +} + +Result load_segment( + DataLoader& loader, + const fb::Program& program, + const ProgramExtent& extent, + uint32_t index, + const char* backend_id) { + const auto& header = extent.header; + const auto* segments = program.segments(); + if (!extent.has_header || header.segment_base_offset == 0) { + return Error::NotFound; + } + if (segments == nullptr) { + return Error::InvalidProgram; + } + if (index >= segments->size()) { + return Error::NotFound; + } + const auto* segment = segments->Get(index); + if (segment->offset() > std::numeric_limits::max() || + segment->size() > std::numeric_limits::max()) { + return Error::InvalidProgram; + } + const size_t base = static_cast(header.segment_base_offset); + const size_t offset = static_cast(segment->offset()); + const size_t size = static_cast(segment->size()); + if (base > extent.file_size || offset > extent.file_size - base || + size > extent.file_size - base - offset || + (header.segment_data_size != 0 && + (offset > header.segment_data_size || + size > header.segment_data_size - offset))) { + return Error::InvalidProgram; + } + auto buffer = loader.load( + base + offset, + size, + SegmentInfo(SegmentInfo::Type::Backend, index, backend_id)); + if (!buffer.ok()) { + return buffer.error(); + } + auto bytes = cpu_data(buffer.get(), size); + if (!bytes.ok()) { + return bytes.error(); + } + // Keep the loader's owner while rejecting data beyond the declared extent. + if (buffer->size() != size) { + return Error::InvalidProgram; + } + return std::move(buffer.get()); +} + +Result load_processed( + DataLoader& loader, + const fb::Program& program, + const ProgramExtent& extent, + const fb::BackendDelegate& delegate) { + const auto* reference = delegate.processed(); + if (reference == nullptr) { + return Error::InvalidProgram; + } + switch (reference->location()) { + case fb::DataLocation::INLINE: + return load_inline(program, reference->index()); + case fb::DataLocation::SEGMENT: + return load_segment( + loader, program, extent, reference->index(), delegate.id()->c_str()); + default: + return Error::InvalidProgram; + } +} +} // namespace + +Result inspect_coreai_pte(DataLoader& loader) { + auto extent = parse_header(loader); + if (!extent.ok()) { + return extent.error(); + } + auto loaded = load_program(loader, extent.get()); + if (!loaded.ok()) { + return loaded.error(); + } + const auto* program = loaded->program; + CoreAIPteData result(std::move(loaded->storage)); + for (const auto* plan : *program->execution_plan()) { + if (plan->delegates() == nullptr) { + continue; + } + for (const auto* delegate : *plan->delegates()) { + if (delegate->id() == nullptr) { + continue; + } + const std::string_view id( + delegate->id()->c_str(), delegate->id()->size()); + if (id != kCoreAIBackendId) { + continue; + } + auto buffer = load_processed(loader, *program, extent.get(), *delegate); + if (!buffer.ok()) { + return buffer.error(); + } + result.processed.emplace_back(std::move(buffer.get())); + } + } + return result; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_pte.h b/backends/apple/coreai/runtime/coreai_pte.h new file mode 100644 index 00000000000..e65bf154b84 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_pte.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +namespace executorch::backends::coreai { + +struct CoreAIPteData { + // Member order keeps program_storage alive until processed's inline borrows + // die. + runtime::FreeableBuffer program_storage; + std::vector processed; + + explicit CoreAIPteData(runtime::FreeableBuffer&& storage) + : program_storage(std::move(storage)) {} + CoreAIPteData(CoreAIPteData&&) = default; + CoreAIPteData(const CoreAIPteData&) = delete; + CoreAIPteData& operator=(const CoreAIPteData&) = delete; + CoreAIPteData& operator=(CoreAIPteData&&) = delete; +}; + +// Inspects verified CoreAIBackend processed data in method/delegate order, +// without loading methods, external constants or named assets, or calling the +// SDK. Inline data borrows program_storage; selected segments retain their +// loader callbacks and must match their declared size. Payload contents are not +// parsed. Keep the returned owner and loader alive, and the loader's data +// stable, while using any processed buffer. +runtime::Result inspect_coreai_pte(runtime::DataLoader& loader); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_source_bundle.h b/backends/apple/coreai/runtime/coreai_source_bundle.h index dabaeef5c62..6dae0d68447 100644 --- a/backends/apple/coreai/runtime/coreai_source_bundle.h +++ b/backends/apple/coreai/runtime/coreai_source_bundle.h @@ -14,7 +14,7 @@ namespace executorch::backends::coreai { -class CacheEntryLock; +class LockedCacheEntry; class SelectedBundle; enum class CorruptBundlePolicy { @@ -27,7 +27,7 @@ enum class CorruptBundlePolicy { runtime::Result prepare_source_bundle( const SelectedBundle& bundle, const runtime::NamedDataMap* named_data, - const CacheEntryLock& cache_entry, + const LockedCacheEntry& cache_entry, CorruptBundlePolicy corrupt_policy = CorruptBundlePolicy::Reject); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_source_bundle.mm b/backends/apple/coreai/runtime/coreai_source_bundle.mm index 916637e2e68..f52f4e2a38c 100644 --- a/backends/apple/coreai/runtime/coreai_source_bundle.mm +++ b/backends/apple/coreai/runtime/coreai_source_bundle.mm @@ -8,7 +8,7 @@ #import "coreai_source_bundle.h" -#import "coreai_bookmarks.h" +#import "coreai_cache_entry.h" #import "coreai_fault_injection.h" #import "coreai_manifest.h" #import "coreai_storage.h" @@ -18,6 +18,7 @@ #include #include #include +#include namespace executorch::backends::coreai { namespace { @@ -116,11 +117,11 @@ Error validate_tree( } Result inspect_source_bundle( - int root_fd, + int sources_fd, NSString* key, NSDictionary* files) { FileDescriptor entry( - open_directory_at(root_fd, key.fileSystemRepresentation)); + open_directory_at(sources_fd, key.fileSystemRepresentation)); if (entry.get() < 0) { if (errno == ENOENT) { return EntryState::Missing; @@ -141,89 +142,68 @@ FileDescriptor entry( return error; } -Error fully_sync_directory(int fd) { - ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(fd)); - ET_CHECK_OR_RETURN_ERROR( - retry_eintr([&] { - const int fault = fault_point(FaultPoint::FullSync); - if (fault != 0) { - errno = fault; - return -1; - } - return fcntl(fd, F_FULLFSYNC); - }) == 0, - AccessFailed, - "Cannot fully synchronize Core AI source directory"); - return Error::Ok; -} - -struct StagingDirectory { +// Removes the unpublished build directory on every early return. +struct PendingDirectory { int parent_fd; NSString* name; bool published = false; - StagingDirectory(int parent_fd, NSString* name) + PendingDirectory(int parent_fd, NSString* name) : parent_fd(parent_fd), name(name) {} - StagingDirectory(const StagingDirectory&) = delete; - StagingDirectory& operator=(const StagingDirectory&) = delete; - StagingDirectory(StagingDirectory&& other) + PendingDirectory(const PendingDirectory&) = delete; + PendingDirectory& operator=(const PendingDirectory&) = delete; + PendingDirectory(PendingDirectory&& other) : parent_fd(other.parent_fd), name(other.name), published(other.published) { other.published = true; } - ~StagingDirectory() { + ~PendingDirectory() { if (published) { return; } const Error error = remove_storage_directory(parent_fd, name, StorageRemovalPolicy::Strict); if (error != Error::Ok) { - ET_LOG(Error, "Cannot remove Core AI staging directory"); + ET_LOG(Error, "Cannot remove pending Core AI source directory"); } } }; -Result create_staging_directory(int root_fd) { +Result create_pending_directory(int sources_fd) { NSString* name = - [@".staging-" stringByAppendingString:NSUUID.UUID.UUIDString]; + [@".pending-" stringByAppendingString:NSUUID.UUID.UUIDString]; ET_CHECK_OR_RETURN_ERROR( - retry_eintr([&] { - const int fault = fault_point(FaultPoint::CreateStagingDirectory); - if (fault != 0) { - errno = fault; - return -1; - } - return mkdirat(root_fd, name.fileSystemRepresentation, 0777); - }) == 0, + fault_aware_retry( + FaultPoint::CreatePendingDirectory, + [&] { + return mkdirat(sources_fd, name.fileSystemRepresentation, 0777); + }) == 0, AccessFailed, - "Cannot create Core AI staging directory"); - return StagingDirectory{root_fd, [name copy]}; + "Cannot create pending Core AI source directory"); + return PendingDirectory{sources_fd, [name copy]}; } -Result open_staging_directory(int root_fd, NSString* name) { - const int fd = retry_eintr([&] { - const int fault = fault_point(FaultPoint::OpenStagingDirectory); - if (fault != 0) { - errno = fault; - return -1; - } - return open_directory_at(root_fd, name.fileSystemRepresentation); - }); +Result open_pending_directory(int sources_fd, NSString* name) { + FileDescriptor fd(fault_aware_retry(FaultPoint::OpenPendingDirectory, [&] { + return open_directory_at(sources_fd, name.fileSystemRepresentation); + })); ET_CHECK_OR_RETURN_ERROR( - fd >= 0, AccessFailed, "Cannot open Core AI staging directory"); + fd.get() >= 0, + AccessFailed, + "Cannot open pending Core AI source directory"); return fd; } -Error write_staging_bundle( - int staging_fd, +Error write_pending_bundle( + int pending_fd, const SelectedBundle& bundle, const runtime::NamedDataMap& named_data) { for (NSString* file in bundle.files) { @autoreleasepool { NSString* data_key = - [NSString stringWithFormat:@"coreai/%@/%@", bundle.model_hash, file]; + [NSString stringWithFormat:@"coreai/%@/%@", bundle.export_hash, file]; auto buffer = named_data.get_data(data_key.UTF8String); if (!buffer.ok()) { ET_CHECK_OR_RETURN_ERROR( @@ -239,33 +219,28 @@ Error write_staging_bundle( InvalidExternalData, "Invalid Core AI named data buffer"); ET_CHECK_OK_OR_RETURN_ERROR( - write_storage_file(staging_fd, file, buffer->data(), buffer->size())); + write_storage_file(pending_fd, file, buffer->data(), buffer->size())); } } - ET_CHECK_OK_OR_RETURN_ERROR(validate_tree(staging_fd, bundle.files)); - return fully_sync_directory(staging_fd); + ET_CHECK_OK_OR_RETURN_ERROR(validate_tree(pending_fd, bundle.files)); + return full_sync_storage_directory(pending_fd); } -Result publish_staging_directory( - int root_fd, - StagingDirectory& staging, +Result publish_pending_directory( + int sources_fd, + PendingDirectory& pending, NSString* key) { - const int result = retry_eintr([&] { - const int fault = fault_point(FaultPoint::Rename); - if (fault != 0) { - errno = fault; - return -1; - } + const int result = fault_aware_retry(FaultPoint::Rename, [&] { return renameatx_np( - root_fd, - staging.name.fileSystemRepresentation, - root_fd, + sources_fd, + pending.name.fileSystemRepresentation, + sources_fd, key.fileSystemRepresentation, RENAME_EXCL); }); if (result == 0) { - staging.published = true; - ET_CHECK_OK_OR_RETURN_ERROR(fully_sync_directory(root_fd)); + pending.published = true; + ET_CHECK_OK_OR_RETURN_ERROR(full_sync_storage_directory(sources_fd)); return true; } ET_CHECK_OR_RETURN_ERROR( @@ -274,10 +249,10 @@ Error write_staging_bundle( } Error recover_lost_publication_race( - int root_fd, + int sources_fd, NSString* key, NSDictionary* files) { - auto winner = inspect_source_bundle(root_fd, key, files); + auto winner = inspect_source_bundle(sources_fd, key, files); if (!winner.ok()) { return winner.error(); } @@ -285,7 +260,7 @@ Error recover_lost_publication_race( winner.get() == EntryState::Valid, InvalidExternalData, "Concurrently published Core AI source is incomplete"); - return fully_sync_directory(root_fd); + return full_sync_storage_directory(sources_fd); } } // namespace @@ -293,31 +268,25 @@ Error recover_lost_publication_race( Result prepare_source_bundle( const SelectedBundle& bundle, const runtime::NamedDataMap* named_data, - const CacheEntryLock& cache_entry, + const LockedCacheEntry& cache_entry, CorruptBundlePolicy corrupt_policy) { NSString* key = cache_entry.key(); - auto prepared = cache_entry.prepare_staging_root(); - if (!prepared.ok()) { - return prepared.error(); + auto sources = cache_entry.prepare_source_directory(); + if (!sources.ok()) { + return sources.error(); } - NSURL* root = [NSURL fileURLWithPath:prepared.get() isDirectory:YES]; - NSURL* model_url = [[root URLByAppendingPathComponent:key isDirectory:YES] + NSURL* model_url = [[sources->url URLByAppendingPathComponent:key + isDirectory:YES] URLByAppendingPathComponent:bundle.bundle_name isDirectory:YES]; - FileDescriptor root_fd(retry_eintr([&] { - return open( - root.fileSystemRepresentation, - O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - })); - ET_CHECK_OR_RETURN_ERROR( - root_fd.get() >= 0, AccessFailed, "Cannot open Core AI source root"); + FileDescriptor sources_fd = std::move(sources->descriptor); - auto state = inspect_source_bundle(root_fd.get(), key, bundle.files); + auto state = inspect_source_bundle(sources_fd.get(), key, bundle.files); if (!state.ok()) { return state.error(); } if (state.get() == EntryState::Valid) { - ET_CHECK_OK_OR_RETURN_ERROR(fully_sync_directory(root_fd.get())); + ET_CHECK_OK_OR_RETURN_ERROR(full_sync_storage_directory(sources_fd.get())); return model_url; } if (state.get() == EntryState::Corrupt) { @@ -326,33 +295,32 @@ FileDescriptor root_fd(retry_eintr([&] { InvalidExternalData, "Cannot replace a damaged Core AI stored bundle"); ET_CHECK_OK_OR_RETURN_ERROR(remove_storage_directory( - root_fd.get(), key, StorageRemovalPolicy::Maintenance)); + sources_fd.get(), key, StorageRemovalPolicy::Maintenance)); } ET_CHECK_OR_RETURN_ERROR( named_data != nullptr, InvalidProgram, "Missing NamedDataMap for Core AI source recovery"); - auto staging_result = create_staging_directory(root_fd.get()); - if (!staging_result.ok()) { - return staging_result.error(); + auto created = create_pending_directory(sources_fd.get()); + if (!created.ok()) { + return created.error(); } - StagingDirectory staging = std::move(staging_result.get()); - auto staging_fd_result = open_staging_directory(root_fd.get(), staging.name); - if (!staging_fd_result.ok()) { - return staging_fd_result.error(); + PendingDirectory pending = std::move(created.get()); + auto pending_fd = open_pending_directory(sources_fd.get(), pending.name); + if (!pending_fd.ok()) { + return pending_fd.error(); } - FileDescriptor staging_fd(staging_fd_result.get()); ET_CHECK_OK_OR_RETURN_ERROR( - write_staging_bundle(staging_fd.get(), bundle, *named_data)); + write_pending_bundle(pending_fd->get(), bundle, *named_data)); - auto published = publish_staging_directory(root_fd.get(), staging, key); + auto published = publish_pending_directory(sources_fd.get(), pending, key); if (!published.ok()) { return published.error(); } if (!published.get()) { ET_CHECK_OK_OR_RETURN_ERROR( - recover_lost_publication_race(root_fd.get(), key, bundle.files)); + recover_lost_publication_race(sources_fd.get(), key, bundle.files)); } return model_url; } diff --git a/backends/apple/coreai/runtime/coreai_storage.h b/backends/apple/coreai/runtime/coreai_storage.h index 33bd34d3272..b61699b6ff0 100644 --- a/backends/apple/coreai/runtime/coreai_storage.h +++ b/backends/apple/coreai/runtime/coreai_storage.h @@ -11,22 +11,32 @@ #import #include #include +#include #include "coreai_file.h" namespace executorch::backends::coreai { +enum class StorageCreationMode { + OpenExisting, + Create, +}; + runtime::Error sync_storage_directory(int fd); -struct StorageDirectory { - int fd; +// Also flushes the drive's write cache, which fsync alone does not on Apple +// platforms. +runtime::Error full_sync_storage_directory(int fd); + +struct StorageDirectory final { + std::optional descriptor; bool created; }; -// Opens one child component without following symlinks. The caller owns fd; -// a missing child with OpenExisting returns {-1, false}. Does not flush -// directories. +// Opens one child component without following symlinks. A missing child with +// OpenExisting has no descriptor. Does not flush directories. runtime::Result open_storage_directory(int parent_fd, NSString* name, StorageCreationMode mode); // Returns an independently owned descriptor for a stable coordination file. -int open_storage_shared_file(int parent_fd, const char* name); +// On failure the descriptor is invalid and errno is set. +FileDescriptor open_storage_shared_file(int parent_fd, const char* name); // Creates a new file and missing parents, then syncs the file and its parent. // On failure, attempts to remove the new file; created parents may remain. runtime::Error write_storage_file( @@ -52,9 +62,17 @@ enum class StorageRemovalPolicy { Maintenance, }; +// Checks one child tree without following links or changing storage. A missing +// entry counts as removed. Maintenance accepts malformed backend-owned +// non-directory entries. +runtime::Error preflight_storage_directory( + int parent_fd, + NSString* name, + StorageRemovalPolicy policy = StorageRemovalPolicy::Strict); + // Removes one child directory without following links. The complete tree is // checked before removal and every entry is checked again during removal. -// Missing entries are already removed. Maintenance also removes malformed +// A missing entry counts as removed. Maintenance also removes malformed // backend-owned non-directory entries. runtime::Error remove_storage_directory( int parent_fd, diff --git a/backends/apple/coreai/runtime/coreai_storage.mm b/backends/apple/coreai/runtime/coreai_storage.mm index f30c992081d..585152356d7 100644 --- a/backends/apple/coreai/runtime/coreai_storage.mm +++ b/backends/apple/coreai/runtime/coreai_storage.mm @@ -19,29 +19,23 @@ #include #include #include +#include namespace executorch::backends::coreai { namespace { using runtime::Error; using runtime::Result; -template -auto storage_call(FaultPoint point, F call) { - return retry_eintr([&]() -> decltype(call()) { - const int fault = fault_point(point); - if (fault != 0) { - errno = fault; - return -1; - } - return call(); - }); -} - enum class StorageSyncMode { Normal, Full, }; +bool full_fsync(int fd) { + return fault_aware_retry( + FaultPoint::FullSync, [&] { return fcntl(fd, F_FULLFSYNC); }) == 0; +} + Error write_file( int root_fd, NSString* relative_path, @@ -56,7 +50,9 @@ Error write_file( "Invalid Core AI storage write"); for (NSString* component in components) { ET_CHECK_OR_RETURN_ERROR( - storage_component(component), InvalidArgument, "Invalid storage path"); + is_storage_component(component), + InvalidArgument, + "Invalid storage path"); } FileDescriptor parent( retry_eintr([&] { return fcntl(root_fd, F_DUPFD_CLOEXEC, 0); })); @@ -65,11 +61,12 @@ FileDescriptor parent( for (NSUInteger i = 0; i + 1 < components.count; ++i) { auto opened = open_storage_directory( parent.get(), components[i], StorageCreationMode::Create); - if (!opened.ok()) + if (!opened.ok()) { return opened.error(); - FileDescriptor next(opened.get().fd); + } + FileDescriptor next = std::move(*opened->descriptor); ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(parent.get())); - parent.reset(next.release()); + parent = std::move(next); } FileDescriptor file(retry_eintr([&] { return openat( @@ -83,7 +80,7 @@ FileDescriptor file(retry_eintr([&] { const auto result = [&]() -> Error { const auto* bytes = static_cast(data); while (size > 0) { - const ssize_t count = storage_call(FaultPoint::Write, [&] { + const ssize_t count = fault_aware_retry(FaultPoint::Write, [&] { return write(file.get(), bytes, std::min(size, size_t(1024 * 1024))); }); ET_CHECK_OR_RETURN_ERROR( @@ -92,12 +89,9 @@ FileDescriptor file(retry_eintr([&] { size -= count; } ET_CHECK_OR_RETURN_ERROR( - storage_call(FaultPoint::SyncFile, [&] { return fsync(file.get()); }) == - 0 && - (sync_mode != StorageSyncMode::Full || - storage_call( - FaultPoint::FullSync, - [&] { return fcntl(file.get(), F_FULLFSYNC); }) == 0), + fault_aware_retry( + FaultPoint::SyncFile, [&] { return fsync(file.get()); }) == 0 && + (sync_mode != StorageSyncMode::Full || full_fsync(file.get())), AccessFailed, "Cannot synchronize Core AI file"); ET_CHECK_OR_RETURN_ERROR( @@ -125,6 +119,91 @@ FileDescriptor file(retry_eintr([&] { Remove, }; +enum class StorageEntryState { + Missing, + NonDirectory, + Directory, +}; + +struct InspectedStorageEntry final { + StorageEntryState state; + std::optional directory; + struct stat info; +}; + +Result inspect_storage_entry( + int parent_fd, + NSString* name, + StorageRemovalPolicy policy) { + ET_CHECK_OR_RETURN_ERROR( + is_storage_component(name), + InvalidArgument, + "Invalid Core AI entry name"); + struct stat info {}; + const int inspected = + lstat_at(parent_fd, name.fileSystemRepresentation, &info); + if (inspected < 0 && errno == ENOENT) { + return InspectedStorageEntry{ + StorageEntryState::Missing, std::nullopt, info}; + } + ET_CHECK_OR_RETURN_ERROR( + inspected == 0, AccessFailed, "Cannot inspect Core AI storage entry"); + if (!S_ISDIR(info.st_mode)) { + ET_CHECK_OR_RETURN_ERROR( + policy == StorageRemovalPolicy::Maintenance, + InvalidExternalData, + "Core AI storage entry is not a directory"); + return InspectedStorageEntry{ + StorageEntryState::NonDirectory, std::nullopt, info}; + } + FileDescriptor entry( + open_directory_at(parent_fd, name.fileSystemRepresentation)); + if (entry.get() < 0 && errno == ENOENT) { + return InspectedStorageEntry{ + StorageEntryState::Missing, std::nullopt, info}; + } + ET_CHECK_OR_RETURN_ERROR( + entry.get() >= 0, AccessFailed, "Cannot open Core AI storage directory"); + return InspectedStorageEntry{ + StorageEntryState::Directory, + std::optional(std::move(entry)), + info}; +} + +bool same_storage_entry(const struct stat& left, const struct stat& right) { + return left.st_dev == right.st_dev && left.st_ino == right.st_ino && + (left.st_mode & S_IFMT) == (right.st_mode & S_IFMT); +} + +// Removes name only while it is still the entry described by expected. A +// missing entry counts as removed. Does not sync parent_fd. +Error remove_if_unchanged( + int parent_fd, + NSString* name, + const struct stat& expected) { + struct stat current; + const int inspected = + lstat_at(parent_fd, name.fileSystemRepresentation, ¤t); + if (inspected != 0 && errno == ENOENT) { + return Error::Ok; + } + ET_CHECK_OR_RETURN_ERROR( + inspected == 0 && same_storage_entry(expected, current), + AccessFailed, + "Core AI storage entry changed during removal"); + const int removed = fault_aware_retry(FaultPoint::Remove, [&] { + return unlinkat( + parent_fd, + name.fileSystemRepresentation, + S_ISDIR(expected.st_mode) ? AT_REMOVEDIR : 0); + }); + ET_CHECK_OR_RETURN_ERROR( + removed == 0 || errno == ENOENT, + AccessFailed, + "Cannot remove Core AI storage entry"); + return Error::Ok; +} + Error walk_storage_tree( int fd, StorageTreePass pass, @@ -161,15 +240,7 @@ FileDescriptor child( ET_CHECK_OK_OR_RETURN_ERROR(walk_storage_tree(child.get(), pass, policy)); } if (pass == StorageTreePass::Remove) { - const int removed = storage_call(FaultPoint::Remove, [&] { - return unlinkat( - fd, name.fileSystemRepresentation, directory ? AT_REMOVEDIR : 0); - }); - if (removed < 0 && errno == ENOENT) { - continue; - } - ET_CHECK_OR_RETURN_ERROR( - removed == 0, AccessFailed, "Cannot remove Core AI storage entry"); + ET_CHECK_OK_OR_RETURN_ERROR(remove_if_unchanged(fd, name, info)); } } return pass == StorageTreePass::Remove ? sync_storage_directory(fd) @@ -182,7 +253,7 @@ FileDescriptor child( NSString* name, StorageCreationMode mode) { ET_CHECK_OR_RETURN_ERROR( - storage_component(name), + is_storage_component(name), InvalidArgument, "Invalid storage directory name"); const auto open_directory = [&] { @@ -206,11 +277,12 @@ FileDescriptor child( created = made == 0; fd = open_directory(); } - if (fd < 0 && errno == ENOENT && !create) - return StorageDirectory{-1, false}; + if (fd < 0 && errno == ENOENT && !create) { + return StorageDirectory{std::nullopt, false}; + } ET_CHECK_OR_RETURN_ERROR( fd >= 0, AccessFailed, "Cannot open Core AI storage directory"); - return StorageDirectory{fd, created}; + return StorageDirectory{FileDescriptor(fd), created}; } Result*> storage_children(int fd, StorageNamePolicy policy) { @@ -231,10 +303,12 @@ FileDescriptor copy(retry_eintr( errno = 0; dirent* entry = readdir(directory); if (entry == nullptr) { - if (errno == EINTR) + if (errno == EINTR) { continue; - if (errno != 0) + } + if (errno != 0) { result = Error::AccessFailed; + } break; } if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { @@ -259,56 +333,43 @@ FileDescriptor copy(retry_eintr( return [names sortedArrayUsingSelector:@selector(compare:)]; } +Error preflight_storage_directory( + int parent_fd, + NSString* name, + StorageRemovalPolicy policy) { + auto entry = inspect_storage_entry(parent_fd, name, policy); + if (!entry.ok()) { + return entry.error(); + } + return entry->state == StorageEntryState::Directory + ? walk_storage_tree( + entry->directory->get(), StorageTreePass::Preflight, policy) + : Error::Ok; +} + Error remove_storage_directory( int parent_fd, NSString* name, StorageRemovalPolicy policy) { - ET_CHECK_OR_RETURN_ERROR( - storage_component(name), InvalidArgument, "Invalid Core AI entry name"); - struct stat info; - const int inspected = - lstat_at(parent_fd, name.fileSystemRepresentation, &info); - if (inspected < 0 && errno == ENOENT) { - return Error::Ok; + auto entry = inspect_storage_entry(parent_fd, name, policy); + if (!entry.ok()) { + return entry.error(); } - ET_CHECK_OR_RETURN_ERROR( - inspected == 0, AccessFailed, "Cannot inspect Core AI storage entry"); - if (!S_ISDIR(info.st_mode)) { - ET_CHECK_OR_RETURN_ERROR( - policy == StorageRemovalPolicy::Maintenance, - InvalidExternalData, - "Core AI storage entry is not a directory"); - const int removed = storage_call(FaultPoint::Remove, [&] { - return unlinkat(parent_fd, name.fileSystemRepresentation, 0); - }); - ET_CHECK_OR_RETURN_ERROR( - removed == 0 || (removed < 0 && errno == ENOENT), - AccessFailed, - "Cannot remove Core AI storage entry"); - return sync_storage_directory(parent_fd); + if (entry->state == StorageEntryState::Missing) { + return Error::Ok; } - FileDescriptor entry( - open_directory_at(parent_fd, name.fileSystemRepresentation)); - if (entry.get() < 0 && errno == ENOENT) { - return sync_storage_directory(parent_fd); + if (entry->state == StorageEntryState::Directory) { + ET_CHECK_OK_OR_RETURN_ERROR(walk_storage_tree( + entry->directory->get(), StorageTreePass::Preflight, policy)); + ET_CHECK_OK_OR_RETURN_ERROR(walk_storage_tree( + entry->directory->get(), StorageTreePass::Remove, policy)); } - ET_CHECK_OR_RETURN_ERROR( - entry.get() >= 0, AccessFailed, "Cannot open Core AI storage directory"); ET_CHECK_OK_OR_RETURN_ERROR( - walk_storage_tree(entry.get(), StorageTreePass::Preflight, policy)); - ET_CHECK_OK_OR_RETURN_ERROR( - walk_storage_tree(entry.get(), StorageTreePass::Remove, policy)); - const int removed = storage_call(FaultPoint::Remove, [&] { - return unlinkat(parent_fd, name.fileSystemRepresentation, AT_REMOVEDIR); - }); - ET_CHECK_OR_RETURN_ERROR( - removed == 0 || (removed < 0 && errno == ENOENT), - AccessFailed, - "Cannot remove Core AI storage directory"); + remove_if_unchanged(parent_fd, name, entry->info)); return sync_storage_directory(parent_fd); } -int open_storage_shared_file(int parent_fd, const char* name) { +FileDescriptor open_storage_shared_file(int parent_fd, const char* name) { // APFS can return ENOENT to a concurrent O_CREAT loser without O_EXCL. int fd = retry_eintr([&] { return openat( @@ -322,17 +383,27 @@ int open_storage_shared_file(int parent_fd, const char* name) { return openat(parent_fd, name, O_RDWR | O_NOFOLLOW | O_CLOEXEC); }); } - return fd; + return FileDescriptor(fd); } Error sync_storage_directory(int fd) { ET_CHECK_OR_RETURN_ERROR( - storage_call(FaultPoint::SyncDirectory, [&] { return fsync(fd); }) == 0, + fault_aware_retry(FaultPoint::SyncDirectory, [&] { return fsync(fd); }) == + 0, AccessFailed, "Cannot synchronize Core AI directory"); return Error::Ok; } +Error full_sync_storage_directory(int fd) { + ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(fd)); + ET_CHECK_OR_RETURN_ERROR( + full_fsync(fd), + AccessFailed, + "Cannot fully synchronize Core AI directory"); + return Error::Ok; +} + Error write_storage_file( int root_fd, NSString* relative_path, @@ -344,7 +415,7 @@ Error write_storage_file( Error publish_storage_data(int root_fd, NSString* name, NSData* data) { ET_CHECK_OR_RETURN_ERROR( - storage_component(name) && data != nil, + is_storage_component(name) && data != nil, InvalidArgument, "Invalid Core AI storage publication"); NSString* temporary = @@ -352,7 +423,7 @@ Error publish_storage_data(int root_fd, NSString* name, NSData* data) { // Bookmarks are small and costly to lose, so they get a full device flush. auto result = write_file( root_fd, temporary, data.bytes, data.length, StorageSyncMode::Full); - if (result == Error::Ok && storage_call(FaultPoint::Rename, [&] { + if (result == Error::Ok && fault_aware_retry(FaultPoint::Rename, [&] { return renameat( root_fd, temporary.fileSystemRepresentation, @@ -365,9 +436,7 @@ Error publish_storage_data(int root_fd, NSString* name, NSData* data) { if (result == Error::Ok) { // Flush the renamed entry as well as the file's pre-rename contents. ET_CHECK_OR_RETURN_ERROR( - storage_call( - FaultPoint::FullSync, - [&] { return fcntl(root_fd, F_FULLFSYNC); }) == 0, + full_fsync(root_fd), AccessFailed, "Cannot fully synchronize Core AI publication directory"); return Error::Ok; diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.h b/backends/apple/coreai/runtime/coreai_tensor_io.h new file mode 100644 index 00000000000..1a49564face --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_tensor_io.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include +#include +#import "ETCoreAIBridge.h" + +namespace executorch::backends::coreai { + +runtime::Error execute_session( + id session, + runtime::Span args, + size_t input_count, + size_t output_count); + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_tensor_io.mm b/backends/apple/coreai/runtime/coreai_tensor_io.mm new file mode 100644 index 00000000000..d7971c8fc2d --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_tensor_io.mm @@ -0,0 +1,403 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#import "coreai_tensor_io.h" + +#import "coreai_error.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using namespace executorch::runtime; +using executorch::aten::ScalarType; +using executorch::aten::SizesType; +using executorch::aten::Tensor; + +struct TensorMetadata final { + ETCoreAIScalarType scalar_type; + size_t bytes; + size_t rank; +}; + +struct ValidatedArguments final { + std::vector tensors; +}; + +struct OutputPlan final { + std::vector shape; + size_t bytes = 0; + void* destination = nullptr; +}; + +Result scalar_type(ScalarType type) { + switch (type) { + case ScalarType::Half: + return ETCoreAIScalarTypeFloat16; + case ScalarType::Float: + return ETCoreAIScalarTypeFloat32; + default: + ET_LOG(Error, "Core AI runtime supports FP16 and FP32 tensors only"); + return Error::NotSupported; + } +} + +Result bridge_scalar_width(ETCoreAIScalarType type) { + switch (type) { + case ETCoreAIScalarTypeFloat16: + return size_t(2); + case ETCoreAIScalarTypeFloat32: + return size_t(4); + default: + return Error::Internal; + } +} + +Result validate_tensor(const Tensor& tensor) { + auto type = scalar_type(tensor.scalar_type()); + if (!type.ok()) { + return type.error(); + } + ET_CHECK_OR_RETURN_ERROR( + tensor.device_type() == executorch::aten::DeviceType::CPU, + NotSupported, + "Core AI requires CPU-accessible tensor storage"); + ET_CHECK_OR_RETURN_ERROR( + tensor.dim() >= 0 && + static_cast(tensor.dim()) <= kTensorDimensionLimit, + InvalidArgument, + "Invalid Core AI tensor rank"); + ET_CHECK_OR_RETURN_ERROR( + tensor_is_default_dim_order(tensor), + NotSupported, + "Core AI requires default tensor dimension order"); + size_t elements = 1; + size_t expected_stride = 1; + for (size_t i = tensor.sizes().size(); i > 0; --i) { + const auto size = tensor.sizes()[i - 1]; + ET_CHECK_OR_RETURN_ERROR( + size >= 0, InvalidArgument, "Negative Core AI tensor dimension"); + ET_CHECK_OR_RETURN_ERROR( + tensor.strides()[i - 1] >= 0 && + static_cast(tensor.strides()[i - 1]) == expected_stride, + NotSupported, + "Core AI requires contiguous tensor storage"); + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows(elements, static_cast(size), &elements) && + !c10::mul_overflows( + expected_stride, + std::max(static_cast(size), size_t(1)), + &expected_stride), + InvalidArgument, + "Core AI tensor size overflow"); + } + size_t bytes = 0; + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows( + elements, static_cast(tensor.element_size()), &bytes) && + bytes <= static_cast(std::numeric_limits::max()) && + bytes == tensor.nbytes(), + InvalidArgument, + "Invalid Core AI tensor byte count"); + return TensorMetadata{type.get(), bytes, tensor.sizes().size()}; +} + +Result validate_arguments( + Span args, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + args.size() >= input_count && args.size() - input_count == output_count, + InvalidArgument, + "Core AI argument count mismatch"); + ValidatedArguments validated; + validated.tensors.reserve(args.size()); + for (EValue* arg : args) { + ET_CHECK_OR_RETURN_ERROR( + arg && arg->isTensor(), + InvalidArgument, + "Core AI arguments must be tensors"); + auto metadata = validate_tensor(arg->toTensor()); + if (!metadata.ok()) { + return metadata.error(); + } + validated.tensors.push_back(metadata.get()); + } + return validated; +} + +Result*> borrow_inputs( + Span args, + const ValidatedArguments& validated, + size_t input_count) { + NSMutableArray* inputs = + [NSMutableArray arrayWithCapacity:input_count]; + for (size_t i = 0; i < input_count; ++i) { + const auto& tensor = args[i]->toTensor(); + const auto& metadata = validated.tensors[i]; + ET_CHECK_OR_RETURN_ERROR( + metadata.bytes == 0 || tensor.const_data_ptr() != nullptr, + InvalidArgument, + "Core AI input has no storage"); + NSMutableArray* shape = + [NSMutableArray arrayWithCapacity:tensor.dim()]; + for (auto size : tensor.sizes()) { + [shape addObject:@(size)]; + } + [inputs addObject:[[ETCoreAIInputTensor alloc] + initWithBytes:tensor.const_data_ptr() + byteCount:metadata.bytes + shape:shape + scalarType:metadata.scalar_type]]; + } + return inputs; +} + +Result*> wait_for_outputs( + id session, + NSArray* inputs) { + dispatch_semaphore_t ready = dispatch_semaphore_create(0); + __block NSArray* outputs = nil; + __block NSError* error = nil; + [session executeInputs:inputs + completion:^(NSArray* result, NSError* failure) { + outputs = result; + error = failure; + dispatch_semaphore_signal(ready); + }]; + // Returning earlier would let the SDK outlive borrowed input storage. + dispatch_semaphore_wait(ready, DISPATCH_TIME_FOREVER); + NSArray* completed = outputs; + NSError* failure = error; + // The SDK may retain the completion block after invoking it. + outputs = nil; + error = nil; + if (failure != nil) { + return execution_error(failure); + } + return completed; +} + +Result> validate_bridge_outputs( + NSArray* outputs, + const ValidatedArguments& validated, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + outputs != nil && outputs.count == output_count, + Internal, + "Core AI output count mismatch"); + std::vector plans(output_count); + for (size_t i = 0; i < output_count; ++i) { + ETCoreAITensor* output = outputs[i]; + ET_CHECK_OR_RETURN_ERROR( + [output isKindOfClass:ETCoreAITensor.class], + Internal, + "Invalid Core AI output tensor"); + auto width = bridge_scalar_width(output.scalarType); + if (!width.ok()) { + return width.error(); + } + const auto& expected = validated.tensors[input_count + i]; + ET_CHECK_OR_RETURN_ERROR( + output.scalarType == expected.scalar_type && + output.shape.count == expected.rank, + InvalidArgument, + "Core AI output type or rank mismatch"); + OutputPlan& plan = plans[i]; + plan.bytes = width.get(); + plan.shape.reserve(output.shape.count); + for (id size in output.shape) { + ET_CHECK_OR_RETURN_ERROR( + [size isKindOfClass:NSNumber.class] && [size longLongValue] >= 0 && + [size longLongValue] <= std::numeric_limits::max() && + [size isEqualToNumber:@([size longLongValue])], + Internal, + "Invalid Core AI output dimension"); + const auto dimension = static_cast([size longLongValue]); + plan.shape.push_back(dimension); + ET_CHECK_OR_RETURN_ERROR( + !c10::mul_overflows( + plan.bytes, static_cast(dimension), &plan.bytes), + Internal, + "Core AI output size overflow"); + } + ET_CHECK_OR_RETURN_ERROR( + plan.bytes == output.byteCount, + Internal, + "Core AI output byte count mismatch"); + } + return plans; +} + +Error resize_outputs( + std::vector& plans, + Span args, + size_t input_count) { + for (size_t i = 0; i < plans.size(); ++i) { + auto& tensor = args[input_count + i]->toTensor(); + auto& shape = plans[i].shape; + const Error resized = + resize_tensor(tensor, ArrayRef(shape.data(), shape.size())); + if (resized != Error::Ok) { + return resized == Error::NotSupported ? Error::InvalidArgument : resized; + } + } + return Error::Ok; +} + +Error validate_resized_outputs( + std::vector& plans, + Span args, + const ValidatedArguments& validated, + size_t input_count) { + for (size_t i = 0; i < plans.size(); ++i) { + auto& tensor = args[input_count + i]->toTensor(); + auto& plan = plans[i]; + ET_CHECK_OR_RETURN_ERROR( + tensor.dim() >= 0 && + static_cast(tensor.dim()) == plan.shape.size() && + std::equal( + plan.shape.begin(), plan.shape.end(), tensor.sizes().begin()), + InvalidArgument, + "Core AI output shape changed during resize"); + auto metadata = validate_tensor(tensor); + if (!metadata.ok()) { + return metadata.error(); + } + ET_CHECK_OR_RETURN_ERROR( + metadata->scalar_type == + validated.tensors[input_count + i].scalar_type && + metadata->bytes == plan.bytes && + (plan.bytes == 0 || tensor.mutable_data_ptr() != nullptr), + InvalidArgument, + "Core AI output has insufficient storage"); + plan.destination = tensor.mutable_data_ptr(); + } + return Error::Ok; +} + +bool storage_overlaps( + const void* left, + size_t left_size, + const void* right, + size_t right_size) { + if (left_size == 0 || right_size == 0) { + return false; + } + const auto a = reinterpret_cast(left); + const auto b = reinterpret_cast(right); + return a <= b ? b - a < left_size : a - b < right_size; +} + +bool outputs_need_staging( + const std::vector& plans, + NSArray* inputs) { + for (size_t i = 0; i < plans.size(); ++i) { + const auto& plan = plans[i]; + for (ETCoreAIInputTensor* input in inputs) { + if (storage_overlaps( + plan.destination, plan.bytes, input.bytes, input.byteCount)) { + return true; + } + } + for (size_t j = 0; j < i; ++j) { + if (storage_overlaps( + plan.destination, + plan.bytes, + plans[j].destination, + plans[j].bytes)) { + return true; + } + } + } + return false; +} + +Error copy_direct( + NSArray* outputs, + const std::vector& plans) { + for (size_t i = 0; i < plans.size(); ++i) { + [outputs[i] copyBytesTo:plans[i].destination]; + } + return Error::Ok; +} + +Error copy_staged( + NSArray* outputs, + const std::vector& plans) { + size_t total_bytes = 0; + for (const auto& plan : plans) { + ET_CHECK_OR_RETURN_ERROR( + plan.bytes <= std::numeric_limits::max() - total_bytes, + MemoryAllocationFailed, + "Core AI output staging size overflow"); + total_bytes += plan.bytes; + } + std::unique_ptr staging(new (std::nothrow) uint8_t[total_bytes]); + ET_CHECK_OR_RETURN_ERROR( + staging != nullptr, + MemoryAllocationFailed, + "Cannot stage Core AI outputs"); + size_t offset = 0; + for (size_t i = 0; i < plans.size(); ++i) { + [outputs[i] copyBytesTo:staging.get() + offset]; + offset += plans[i].bytes; + } + offset = 0; + for (const auto& plan : plans) { + if (plan.bytes > 0) { + memcpy(plan.destination, staging.get() + offset, plan.bytes); + } + offset += plan.bytes; + } + return Error::Ok; +} + +} // namespace + +Error execute_session( + id session, + Span args, + size_t input_count, + size_t output_count) { + ET_CHECK_OR_RETURN_ERROR( + session != nil, DelegateInvalidHandle, "Missing Core AI session"); + auto validated = validate_arguments(args, input_count, output_count); + if (!validated.ok()) { + return validated.error(); + } + auto inputs = borrow_inputs(args, validated.get(), input_count); + if (!inputs.ok()) { + return inputs.error(); + } + auto outputs = wait_for_outputs(session, inputs.get()); + if (!outputs.ok()) { + return outputs.error(); + } + auto plans = validate_bridge_outputs( + outputs.get(), validated.get(), input_count, output_count); + if (!plans.ok()) { + return plans.error(); + } + ET_CHECK_OK_OR_RETURN_ERROR(resize_outputs(plans.get(), args, input_count)); + // Finish every resize before copying so a late failure cannot write results. + ET_CHECK_OK_OR_RETURN_ERROR(validate_resized_outputs( + plans.get(), args, validated.get(), input_count)); + return outputs_need_staging(plans.get(), inputs.get()) + ? copy_staged(outputs.get(), plans.get()) + : copy_direct(outputs.get(), plans.get()); +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_validation.h b/backends/apple/coreai/runtime/coreai_validation.h index ea21885b19d..14f5e9fd63e 100644 --- a/backends/apple/coreai/runtime/coreai_validation.h +++ b/backends/apple/coreai/runtime/coreai_validation.h @@ -13,15 +13,15 @@ namespace executorch::backends::coreai { // Nonempty text without embedded NULs. -bool nonempty_string(id value); +bool is_nonempty_string(id value); // Relative bundle path without escapes, backslashes, or empty components. -bool relative_path(NSString* path); +bool is_relative_path(NSString* path); // Single path component for an fd-relative open: no slashes, ".", or "..". -// Unlike relative_path, backslashes are ordinary bytes on this filesystem. -bool storage_component(NSString* name); -// Lowercase hex of any positive length, as in the exporter model hash. -bool lowercase_hex(NSString* value); +// Unlike is_relative_path, backslashes are ordinary bytes on this filesystem. +bool is_storage_component(NSString* name); +// Lowercase hex of any positive length, as in the export hash. +bool is_lowercase_hex(NSString* value); // Lowercase hex of exactly length characters, as in bundle digests. -bool lowercase_hex(NSString* value, NSUInteger length); +bool is_lowercase_hex(NSString* value, NSUInteger length); } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_validation.mm b/backends/apple/coreai/runtime/coreai_validation.mm index 54c611b81bc..4b635772561 100644 --- a/backends/apple/coreai/runtime/coreai_validation.mm +++ b/backends/apple/coreai/runtime/coreai_validation.mm @@ -19,13 +19,13 @@ } // namespace -bool nonempty_string(id value) { +bool is_nonempty_string(id value) { return [value isKindOfClass:NSString.class] && [value length] > 0 && [value rangeOfString:@"\0"].location == NSNotFound; } -bool relative_path(NSString* path) { - if (!nonempty_string(path) || path.isAbsolutePath || +bool is_relative_path(NSString* path) { + if (!is_nonempty_string(path) || path.isAbsolutePath || [path containsString:@"\\"]) { return false; } @@ -38,19 +38,19 @@ bool relative_path(NSString* path) { return true; } -bool storage_component(NSString* name) { - return nonempty_string(name) && ![name isEqualToString:@"."] && +bool is_storage_component(NSString* name) { + return is_nonempty_string(name) && ![name isEqualToString:@"."] && ![name isEqualToString:@".."] && ![name containsString:@"/"]; } -bool lowercase_hex(NSString* value) { - return nonempty_string(value) && +bool is_lowercase_hex(NSString* value) { + return is_nonempty_string(value) && [value rangeOfCharacterFromSet:invalid_hex_characters()].location == NSNotFound; } -bool lowercase_hex(NSString* value, NSUInteger length) { - return lowercase_hex(value) && [value length] == length; +bool is_lowercase_hex(NSString* value, NSUInteger length) { + return is_lowercase_hex(value) && [value length] == length; } } // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h index edd5eea7f53..c0ac4d43281 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.h @@ -8,9 +8,10 @@ #pragma once -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" #include "coreai_fake_loader.h" #include "coreai_fault_injection.h" +#include "coreai_load_coordinator.h" #include "coreai_source_fixture.h" namespace executorch::backends::coreai::testing { @@ -18,6 +19,15 @@ namespace executorch::backends::coreai::testing { runtime::Result acquisition_staged_url( NSURL* root, const SelectedBundle& bundle); +runtime::Result> acquire_test_model( + const SelectedHostBundle& bundle, + const runtime::NamedDataMap* data, + NSString* cache_path, + id loader); +runtime::Error clear_test_cache_entry( + NSString* cache_path, + NSString* key, + id loader); struct ParallelAcquisitionObservation { int creation[2] = {-1, -1}; diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm index 2d951f0c168..bb2c5708d35 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_fixture.mm @@ -20,16 +20,13 @@ #include #include "coreai_fault_scope.h" #include "coreai_load_coordinator.h" +#include "coreai_test_utils.h" namespace executorch::backends::coreai::testing { namespace { using runtime::Error; using runtime::Result; -dispatch_time_t deadline() { - return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); -} - struct AcquisitionWorker { const SelectedBundle* bundle = nullptr; TestData* data = nullptr; @@ -44,8 +41,8 @@ dispatch_time_t deadline() { auto* worker = static_cast(opaque); dispatch_semaphore_signal(worker->started); @autoreleasepool { - RuntimeAssets assets{*worker->bundle, @"macOS", @"arch_b"}; - auto acquired = acquire_bookmark_model( + SelectedHostBundle assets{*worker->bundle, @"macOS", @"arch_b"}; + auto acquired = acquire_test_model( assets, worker->data, worker->root, worker->loader); worker->error = acquired.ok() ? Error::Ok : acquired.error(); worker->model = acquired.ok() && acquired.get() != nil; @@ -80,12 +77,12 @@ void finish() { if (!started[i]) continue; observation.completed[i] = - dispatch_semaphore_wait(workers[i].done, deadline()) == 0; + dispatch_semaphore_wait(workers[i].done, test_deadline()) == 0; observation.joined[i] = pthread_join(threads[i], nullptr); observation.errors[i] = workers[i].error; observation.models[i] = workers[i].model; } - observation.callbacks_drained = bridge.wait_for_callbacks(deadline()); + observation.callbacks_drained = bridge.wait_for_callbacks(test_deadline()); if (observation.callbacks_drained) loader.onSpecialize = nil; finished = true; @@ -95,30 +92,31 @@ void finish() { } }; -bool send_child_byte(int fd, char value) { - const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(10); - while (std::chrono::steady_clock::now() < end) { - const auto remaining = - std::chrono::duration_cast( - end - std::chrono::steady_clock::now()) - .count(); - pollfd entry{fd, POLLOUT, 0}; - const int ready = - poll(&entry, 1, static_cast(remaining > 0 ? remaining : 0)); - if (ready < 0 && errno == EINTR) - continue; - if (ready <= 0 || !(entry.revents & POLLOUT)) - return false; - const ssize_t written = write(fd, &value, 1); - if (written == 1) - return true; - if (written < 0 && (errno == EINTR || errno == EAGAIN)) - continue; - return false; +} // namespace + +Result> acquire_test_model( + const SelectedHostBundle& bundle, + const runtime::NamedDataMap* data, + NSString* cache_path, + id loader) { + auto directory = test_cache_directory(cache_path); + if (!directory.ok()) { + return directory.error(); } - return false; + return acquire_model(bundle, data, directory.get(), loader); +} + +Error clear_test_cache_entry( + NSString* cache_path, + NSString* key, + id loader) { + auto directory = + test_cache_directory(cache_path, StorageCreationMode::OpenExisting); + if (!directory.ok()) { + return directory.error() == Error::NotFound ? Error::Ok : directory.error(); + } + return clear_cache_entry(directory.get(), key, loader); } -} // namespace Result acquisition_staged_url( NSURL* root, @@ -126,7 +124,7 @@ bool send_child_byte(int fd, char value) { auto key = test_cache_key(bundle); if (!key.ok()) return key.error(); - return [[[root URLByAppendingPathComponent:@"staging"] + return [[[root URLByAppendingPathComponent:@"sources"] URLByAppendingPathComponent:key.get()] URLByAppendingPathComponent:bundle.bundle_name]; } @@ -146,7 +144,7 @@ ParallelAcquisitionObservation parallel_acquisitions( dispatch_semaphore_t proceed = group.proceed; loader.onSpecialize = ^(NSURL*) { dispatch_semaphore_signal(entered); - if (dispatch_semaphore_wait(proceed, deadline()) != 0) + if (dispatch_semaphore_wait(proceed, test_deadline()) != 0) ++*timeouts; }; for (int i = 0; i < 2; ++i) { @@ -161,17 +159,18 @@ ParallelAcquisitionObservation parallel_acquisitions( group.started[0] = observation.creation[0] == 0; if (group.started[0]) { observation.first_entered = - dispatch_semaphore_wait(entered, deadline()) == 0; + dispatch_semaphore_wait(entered, test_deadline()) == 0; } if (observation.first_entered) { observation.creation[1] = pthread_create( &group.threads[1], nullptr, AcquisitionWorker::run, &group.workers[1]); group.started[1] = observation.creation[1] == 0; if (group.started[1] && - dispatch_semaphore_wait(group.workers[1].started, deadline()) == 0) { + dispatch_semaphore_wait(group.workers[1].started, test_deadline()) == + 0) { const auto second_deadline = same_key ? dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC) - : deadline(); + : test_deadline(); const bool second_entered = dispatch_semaphore_wait(entered, second_deadline) == 0; observation.second_observed = same_key ? !second_entered : second_entered; @@ -268,12 +267,14 @@ ParallelAcquisitionObservation parallel_acquisitions( loader = nil; } auto& state = bridge.state(); - const bool none_live = state.prepared_models.load() == 0 && - state.loaders.load() == 0 && state.missing_bundles.load() == 0; + const bool none_live = state.sessions.load() == 0 && + state.prepared_models.load() == 0 && state.loaders.load() == 0 && + state.binding_mismatches.load() == 0 && + state.missing_bundles.load() == 0 && + state.input_wait_timeouts.load() == 0; if (!send_child_byte(fd, static_cast(none_live))) _exit(12); - // No GTest-bearing destructors run before InitGoogleTest, even on a failed - // child. + // Avoid GTest-bearing destructors before InitGoogleTest. _exit(close(fd) == 0 ? 0 : 13); } } // namespace @@ -312,17 +313,11 @@ int acquisition_child_mode(int argc, char** argv) { auto parsed = parse_manifest(encode(dictionary)); if (!parsed.ok()) return 5; - auto selected = select_assets(parsed.get(), @"arch_b", @"macOS"); + auto selected = select_bundle(parsed.get(), @"arch_b", @"macOS"); if (!selected.ok()) return 5; - char* end = nullptr; - errno = 0; - const long descriptor = std::strtol(argv[4], &end, 10); - if (errno != 0 || end == argv[4] || *end != '\0' || descriptor < 0 || - descriptor > INT_MAX) - return 6; - const int fd = static_cast(descriptor); - if (fcntl(fd, F_GETFD) < 0) + const int fd = parse_child_fd(argv[4]); + if (fd < 0 || fcntl(fd, F_GETFD) < 0) return 6; signal(SIGPIPE, SIG_IGN); if (!send_child_byte(fd, 'C')) @@ -336,7 +331,7 @@ int acquisition_child_mode(int argc, char** argv) { auto second_parsed = parse_manifest(encode(second_dictionary)); if (!second_parsed.ok()) return 5; - auto second = select_assets(second_parsed.get(), @"arch_b", @"macOS"); + auto second = select_bundle(second_parsed.get(), @"arch_b", @"macOS"); if (!second.ok()) return 5; parallel_child( @@ -360,8 +355,8 @@ int acquisition_child_mode(int argc, char** argv) { if (event == boundary) _exit(73); }; - RuntimeAssets assets{selected.get(), @"macOS", @"arch_b"}; - auto result = acquire_bookmark_model(assets, &data, root, loader); + SelectedHostBundle assets{selected.get(), @"macOS", @"arch_b"}; + auto result = acquire_test_model(assets, &data, root, loader); _exit(result.ok() ? 9 : 10); } } diff --git a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm index e2673f38e70..5b1df356756 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -43,15 +43,16 @@ - (void)specializeModelAtURL:(NSURL*)url namespace { -TEST(CoreAIRuntimeAssetsTest, ProbesHostAndSelectsImmutableBundle) { +TEST(CoreAISelectedHostBundleTest, ProbesHostAndSelectsImmutableBundle) { + ScopedFakeBridgeState bridge; auto parsed = parse_manifest(encode(aot_manifest_dict())); ASSERT_TRUE(parsed.ok()); - auto assets = select_runtime_assets(parsed.get()); + auto assets = select_host_bundle(parsed.get()); ASSERT_TRUE(assets.ok()); EXPECT_TRUE([assets->platform isEqual:@"macOS"]); EXPECT_TRUE([assets->architecture isEqual:@"arch_b"]); EXPECT_TRUE([assets->bundle.bundle_name isEqual:@"model.arch_b.aimodelc"]); - EXPECT_TRUE([assets->bundle.model_hash isEqual:parsed->model_hash]); + EXPECT_TRUE([assets->bundle.export_hash isEqual:parsed->export_hash]); } class CoreAIAcquisitionTest : public ::testing::Test { @@ -60,14 +61,14 @@ - (void)specializeModelAtURL:(NSURL*)url ScopedFakeBridgeState bridge; TestDirectory root; TestData data; - std::optional assets; + std::optional assets; NSString* key = nil; void SetUp() override { ASSERT_NE(root.url, nil); - auto selected = bookmark_bundle(); + auto selected = selected_bundle(manifest_dict()); ASSERT_TRUE(selected.ok()); - assets.emplace(RuntimeAssets{selected.get(), @"macOS", @"arch_b"}); + assets.emplace(SelectedHostBundle{selected.get(), @"macOS", @"arch_b"}); auto identity = test_cache_key(assets->bundle); ASSERT_TRUE(identity.ok()); key = identity.get(); @@ -76,7 +77,7 @@ void SetUp() override { Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { @autoreleasepool { auto model = - acquire_bookmark_model(*assets, named_data, root.url.path, loader); + acquire_test_model(*assets, named_data, root.url.path, loader); if (!model.ok()) return model.error(); return model.get() != nil ? Error::Ok : Error::Internal; @@ -89,14 +90,14 @@ Error acquire(BookmarkFakeLoader* loader, const NamedDataMap* named_data) { return bookmark.ok() ? bookmark.get() : nil; } - NSURL* staging_key_url() { - return [[[root.url URLByAppendingPathComponent:@"staging"] + NSURL* source_key_url() { + return [[[root.url URLByAppendingPathComponent:@"sources"] URLByAppendingPathComponent:key] copy]; } - BOOL staging_key_exists() { + BOOL source_key_exists() { return - [NSFileManager.defaultManager fileExistsAtPath:staging_key_url().path]; + [NSFileManager.defaultManager fileExistsAtPath:source_key_url().path]; } NSURL* lock_url() { @@ -121,7 +122,7 @@ ino_t lock_inode() { ASSERT_TRUE(staged.ok()); EXPECT_TRUE([fake.loader.lastSpecializeURL isEqual:staged.get().path]); EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); EXPECT_FALSE( [NSFileManager.defaultManager fileExistsAtPath:staged.get().path]); } @@ -130,14 +131,14 @@ ino_t lock_inode() { BookmarkLoaderScope fake; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); NSData* original = saved(); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); const int attempts = data.attempts.load(); ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), 1); EXPECT_EQ(fake.loader->restores.load(), 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:original]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F(CoreAIAcquisitionTest, RestoreErrorFailsClosedWithoutSourceFallback) { @@ -169,7 +170,7 @@ ino_t lock_inode() { NSData* replaced = saved(); EXPECT_NE(replaced, nil); EXPECT_FALSE([replaced isEqual:original]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F( @@ -215,14 +216,14 @@ ino_t lock_inode() { ASSERT_TRUE(asset_tree_snapshot(root.url, before)); Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model(*assets, &data, root.url.path, nil); + auto result = acquire_test_model(*assets, &data, root.url.path, nil); ASSERT_FALSE(result.ok()); error = result.error(); } EXPECT_EQ(error, Error::InvalidArgument); EXPECT_TRUE(snapshot_matches(root.url, before)); EXPECT_EQ(saved(), nil); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F(CoreAIAcquisitionTest, RestoreHitWithoutModelFailsClosed) { @@ -238,7 +239,7 @@ ino_t lock_inode() { loader.restoreStatus = ETCoreAIAcquisitionStatusHit; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); + auto result = acquire_test_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -263,7 +264,7 @@ ino_t lock_inode() { loader.restoreModel = model; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); + auto result = acquire_test_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -286,7 +287,7 @@ ino_t lock_inode() { loader.restoreStatus = ETCoreAIAcquisitionStatusError; Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); + auto result = acquire_test_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -300,7 +301,7 @@ ino_t lock_inode() { ASSERT_NE(loader, nil); Error error = Error::Ok; @autoreleasepool { - auto result = acquire_bookmark_model(*assets, &data, root.url.path, loader); + auto result = acquire_test_model(*assets, &data, root.url.path, loader); ASSERT_FALSE(result.ok()); error = result.error(); } @@ -311,9 +312,7 @@ ino_t lock_inode() { EXPECT_EQ(saved(), nil); } -TEST_F( - CoreAIAcquisitionTest, - ColdSuccessRemovesWholeStagingAfterBookmarkFlush) { +TEST_F(CoreAIAcquisitionTest, ColdSuccessRemovesWholeSourceAfterBookmarkFlush) { BookmarkLoaderScope fake; __block std::vector ops; Error error = Error::Internal; @@ -327,7 +326,7 @@ ino_t lock_inode() { } ASSERT_EQ(error, Error::Ok); EXPECT_TRUE([saved() isEqual:fake.loader.lastAcquired.bookmark]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); auto staged = acquisition_staged_url(root.url, assets->bundle); ASSERT_TRUE(staged.ok()); EXPECT_FALSE( @@ -420,13 +419,13 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->specializations.load(), specializations); const int attempts = data.attempts.load(); const int restores = fake.loader->restores.load(); - EXPECT_TRUE(staging_key_exists()); + EXPECT_TRUE(source_key_exists()); ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), specializations); EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F( @@ -450,13 +449,13 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->specializations.load(), specializations); const int attempts = data.attempts.load(); const int restores = fake.loader->restores.load(); - EXPECT_TRUE(staging_key_exists()); + EXPECT_TRUE(source_key_exists()); ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), specializations); EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F( @@ -484,13 +483,13 @@ ino_t lock_inode() { } const int attempts = data.attempts.load(); const int restores = fake.loader->restores.load(); - EXPECT_TRUE(staging_key_exists()); + EXPECT_TRUE(source_key_exists()); ASSERT_EQ(acquire(fake.loader, nullptr), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), specializations); EXPECT_EQ(fake.loader->restores.load(), restores + 1); EXPECT_EQ(data.attempts.load(), attempts); EXPECT_TRUE([saved() isEqual:bookmark]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F(CoreAIAcquisitionTest, WarmHitCleanupFailureStillReturnsModel) { @@ -518,7 +517,7 @@ ino_t lock_inode() { ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fault.hits, 1); } - ASSERT_TRUE(staging_key_exists()); + ASSERT_TRUE(source_key_exists()); NSData* original = saved(); fake.loader.restoreMiss = YES; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); @@ -527,7 +526,7 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->evictions.load(), 0); EXPECT_EQ(data.attempts.load(), 2); EXPECT_FALSE([saved() isEqual:original]); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F( @@ -551,7 +550,7 @@ ino_t lock_inode() { EXPECT_EQ(fake.loader->evictions.load(), 0); EXPECT_TRUE([saved() isEqual:original]); ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } TEST_F(CoreAIAcquisitionTest, MissingNamedDataIsNotRetried) { @@ -593,7 +592,7 @@ ino_t lock_inode() { ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); original = saved(); ASSERT_NE(original, nil); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); fake.loader.restoreMiss = YES; } { @@ -612,7 +611,7 @@ ino_t lock_inode() { fake.loader.restoreMiss = NO; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), calls + (prior ? 0 : 1)); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } INSTANTIATE_TEST_SUITE_P( Boundaries, @@ -653,22 +652,22 @@ void expect_parallel(bool same_key) { expect_parallel(false); } -TEST_F(CoreAIAcquisitionProcessTest, CrashBeforeSdkRetainsStagingAndRetries) { +TEST_F(CoreAIAcquisitionProcessTest, CrashBeforeSdkRetainsSourceAndRetries) { BookmarkChild child; ASSERT_TRUE(spawn_acquisition_crash( child, root.url, FaultPoint::BeforeSDK, manifest_dict())); ASSERT_TRUE(child.receive('C')); ASSERT_TRUE(child.expect_exit(73)); EXPECT_EQ(saved(), nil); - EXPECT_TRUE(staging_key_exists()); + EXPECT_TRUE(source_key_exists()); BookmarkLoaderScope fake; ASSERT_EQ(acquire(fake.loader, &data), Error::Ok); EXPECT_EQ(fake.loader->specializations.load(), 1); EXPECT_EQ(data.attempts.load(), 0); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } -TEST_F(CoreAIAcquisitionProcessTest, CrashAfterSdkRetainsStagingAndRetries) { +TEST_F(CoreAIAcquisitionProcessTest, CrashAfterSdkRetainsSourceAndRetries) { BookmarkChild child; ASSERT_TRUE(spawn_acquisition_crash( child, root.url, FaultPoint::AfterSDK, manifest_dict())); @@ -685,7 +684,7 @@ void expect_parallel(bool same_key) { EXPECT_EQ(fake.loader->restores.load(), 0); EXPECT_EQ(data.attempts.load(), 0); EXPECT_NE(saved(), nil); - EXPECT_FALSE(staging_key_exists()); + EXPECT_FALSE(source_key_exists()); } class CoreAILifecycleLogTest : public CoreAIAcquisitionTest { diff --git a/backends/apple/coreai/runtime/test/coreai_bookmark_contention_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_entry_contention_test.mm similarity index 91% rename from backends/apple/coreai/runtime/test/coreai_bookmark_contention_test.mm rename to backends/apple/coreai/runtime/test/coreai_cache_entry_contention_test.mm index d45614c2f38..cd0220a1b71 100644 --- a/backends/apple/coreai/runtime/test/coreai_bookmark_contention_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_cache_entry_contention_test.mm @@ -11,7 +11,7 @@ #include #include #include -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" #include "coreai_fault_scope.h" #include "coreai_filesystem_fixture.h" @@ -24,7 +24,7 @@ struct CacheEntryWaiter { std::future attempting; - std::future>> result; + std::future>> result; }; CacheEntryWaiter start_cache_entry_waiter( @@ -32,7 +32,7 @@ CacheEntryWaiter start_cache_entry_waiter( NSString* key, bool existing_only = false) { std::promise attempting; - std::promise>> result; + std::promise>> result; CacheEntryWaiter waiter{attempting.get_future(), result.get_future()}; // Detached promise-backed work cannot block test teardown if acquisition // stalls after a failed assertion. @@ -51,8 +51,8 @@ CacheEntryWaiter start_cache_entry_waiter( } }; result.set_value( - existing_only ? lock_existing_cache_entry(path, key) - : lock_cache_entry(path, key)); + existing_only ? acquire_existing_test_cache_entry(path, key) + : acquire_test_cache_entry(path, key)); } }).detach(); return waiter; @@ -66,7 +66,7 @@ CacheEntryWaiter start_cache_entry_waiter( void SetUp() override { ASSERT_NE(root.url, nil); - auto selected = bookmark_bundle(); + auto selected = selected_bundle(manifest_dict()); ASSERT_TRUE(selected.ok()); bundle = std::make_unique(selected.get()); auto result = test_cache_key(*bundle); @@ -82,7 +82,7 @@ void SetUp() override { TEST_F( CoreAIBookmarkContentionTest, ThreadContentionSerializesSameKeyButNotDifferentStripes) { - auto holder = lock_cache_entry(root.url.path, key); + auto holder = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(holder.ok()); ASSERT_NE(holder.get(), nullptr); auto waiter = start_cache_entry_waiter(root.url.path, key); @@ -112,7 +112,7 @@ void SetUp() override { TEST_F(CoreAIBookmarkContentionTest, CollidingKeysSerializeAcrossThreads) { auto collision = different_cache_key(*bundle, key, true); ASSERT_TRUE(collision.ok()); - auto holder = lock_cache_entry(root.url.path, key); + auto holder = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(holder.ok()); auto waiter = start_cache_entry_waiter(root.url.path, collision.get()); ASSERT_EQ( @@ -132,7 +132,7 @@ void SetUp() override { MissingCollidingLookupDoesNotWaitOrCreateAnotherLock) { auto collision = different_cache_key(*bundle, key, true); ASSERT_TRUE(collision.ok()); - auto holder = lock_cache_entry(root.url.path, key); + auto holder = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(holder.ok()); auto waiter = start_cache_entry_waiter(root.url.path, collision.get(), true); EXPECT_EQ(waiter.result.wait_for(kWaitTimeout), std::future_status::ready); @@ -149,7 +149,7 @@ void SetUp() override { } TEST_F(CoreAIBookmarkContentionTest, ExistingWaiterRechecksEvictedEntry) { - auto holder = lock_cache_entry(root.url.path, key); + auto holder = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(holder.ok()); NSData* bytes = [@"bookmark" dataUsingEncoding:NSUTF8StringEncoding]; ASSERT_EQ(holder.get()->write_bookmark(bytes), Error::Ok); @@ -171,7 +171,7 @@ void SetUp() override { TEST_F( CoreAIBookmarkContentionTest, WaiterOpensBookmarkDirectoryAfterAcquiringLock) { - auto holder = lock_cache_entry(root.url.path, key); + auto holder = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(holder.ok()); auto waiter = start_cache_entry_waiter(root.url.path, key); ASSERT_EQ( @@ -200,7 +200,7 @@ void SetUp() override { ASSERT_TRUE(holder.spawn(@"--bookmark-hold", root.url.path, @"same")); ASSERT_TRUE(holder.receive('L')); NSURL* file = lock_url(); - struct stat before {}; + struct stat before{}; ASSERT_EQ(stat(file.fileSystemRepresentation, &before), 0); BookmarkChild waiter; ASSERT_TRUE(waiter.spawn(@"--bookmark-lock", root.url.path, @"same")); @@ -213,7 +213,7 @@ void SetUp() override { ASSERT_TRUE(holder.kill_and_reap()); ASSERT_TRUE(waiter.receive('L')); ASSERT_TRUE(waiter.expect_exit(0)); - struct stat after {}; + struct stat after{}; ASSERT_EQ(stat(file.fileSystemRepresentation, &after), 0); EXPECT_EQ(before.st_dev, after.st_dev); EXPECT_EQ(before.st_ino, after.st_ino); @@ -225,7 +225,7 @@ void SetUp() override { BookmarkChild holder; ASSERT_TRUE(holder.spawn(@"--bookmark-hold", root.url.path, @"same")); ASSERT_TRUE(holder.receive('L')); - struct stat before {}; + struct stat before{}; ASSERT_EQ(lstat(lock_url().fileSystemRepresentation, &before), 0); BookmarkChild waiter; ASSERT_TRUE(waiter.spawn(@"--bookmark-lock", root.url.path, @"collision")); @@ -234,7 +234,7 @@ void SetUp() override { ASSERT_TRUE(holder.kill_and_reap()); ASSERT_TRUE(waiter.receive('L')); ASSERT_TRUE(waiter.expect_exit(0)); - struct stat after {}; + struct stat after{}; ASSERT_EQ(lstat(lock_url().fileSystemRepresentation, &after), 0); EXPECT_EQ(before.st_dev, after.st_dev); EXPECT_EQ(before.st_ino, after.st_ino); @@ -258,7 +258,8 @@ void SetUp() override { NSMutableDictionary* inodes = [NSMutableDictionary dictionary]; for (NSString* name in representatives) { - auto locked = lock_cache_entry(root.url.path, representatives[name]); + auto locked = + acquire_test_cache_entry(root.url.path, representatives[name]); ASSERT_TRUE(locked.ok()); NSURL* file = [[root.url URLByAppendingPathComponent:@"locks"] URLByAppendingPathComponent:name]; @@ -275,7 +276,8 @@ void SetUp() override { ASSERT_EQ(children.count, kCacheEntryLockStripeCount); for (NSString* name in representatives) { - auto locked = lock_cache_entry(root.url.path, representatives[name]); + auto locked = + acquire_test_cache_entry(root.url.path, representatives[name]); ASSERT_TRUE(locked.ok()); struct stat info {}; NSURL* file = [locks URLByAppendingPathComponent:name]; diff --git a/backends/apple/coreai/runtime/test/coreai_bookmark_fixture.h b/backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.h similarity index 75% rename from backends/apple/coreai/runtime/test/coreai_bookmark_fixture.h rename to backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.h index 90e43b5b617..40dee635fdb 100644 --- a/backends/apple/coreai/runtime/test/coreai_bookmark_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.h @@ -12,13 +12,21 @@ #include #include #include -#include "coreai_bookmarks.h" +#include "coreai_cache_entry.h" #include "coreai_cache_key.h" #include "coreai_manifest.h" +#include "coreai_manifest_fixture.h" namespace executorch::backends::coreai::testing { -runtime::Result bookmark_bundle(bool aot = false); +runtime::Result test_cache_directory( + NSString* path, + StorageCreationMode mode = StorageCreationMode::Create); +runtime::Result> acquire_test_cache_entry( + NSString* path, + NSString* key); +runtime::Result> +acquire_existing_test_cache_entry(NSString* path, NSString* key); runtime::Result test_cache_key(const SelectedBundle& bundle); runtime::Result test_cache_key( const SelectedBundle& bundle, @@ -32,6 +40,9 @@ NSURL* test_cache_lock_url(NSURL* root, NSString* key); runtime::Result saved_bookmark(NSURL* root, NSString* key); runtime::Result sized_bookmark(size_t size, uint32_t tag = 1); +// Returns -1 unless argument is a nonnegative int in decimal. +int parse_child_fd(const char* argument); +bool send_child_byte(int fd, char byte); void set_bookmark_test_executable(const char* path); // Returns -1 for normal test invocations, otherwise a child-mode exit code. int bookmark_child_mode(int argc, char** argv); diff --git a/backends/apple/coreai/runtime/test/coreai_bookmark_fixture.mm b/backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.mm similarity index 85% rename from backends/apple/coreai/runtime/test/coreai_bookmark_fixture.mm rename to backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.mm index d40351abeb8..f5d6b82a661 100644 --- a/backends/apple/coreai/runtime/test/coreai_bookmark_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_cache_entry_fixture.mm @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" #include #include @@ -22,6 +22,7 @@ #include #include #include "coreai_fault_scope.h" +#include "coreai_filesystem_fixture.h" #include "coreai_manifest_fixture.h" extern char** environ; @@ -44,7 +45,7 @@ int remaining_ms(Clock::time_point deadline) { return remaining.count() <= 0 ? 0 : static_cast(remaining.count()); } -bool send_signal_byte(int fd, char byte) { +bool send_child_byte_impl(int fd, char byte) { const auto deadline = Clock::now() + kChildTimeout; while (const int remaining = remaining_ms(deadline)) { pollfd entry{fd, POLLOUT, 0}; @@ -68,16 +69,56 @@ bool send_signal_byte(int fd, char byte) { } } // namespace -Result bookmark_bundle(bool aot) { - NSData* encoded = encode(aot ? aot_manifest_dict() : manifest_dict()); - if (encoded == nil) { - return Error::InvalidExternalData; +Result test_cache_directory( + NSString* path, + StorageCreationMode mode) { + auto directory = resolve_test_cache_directory(path, mode); + if (!directory.ok()) { + return directory.error(); } - auto parsed = parse_manifest(encoded); - if (!parsed.ok()) { - return parsed.error(); + if (!directory->has_value()) { + return Error::NotFound; } - return select_assets(parsed.get(), @"arch_b", @"macOS"); + return std::move(directory->value()); +} + +Result> acquire_test_cache_entry( + NSString* path, + NSString* key) { + auto directory = test_cache_directory(path); + if (!directory.ok()) { + return directory.error(); + } + return LockedCacheEntry::acquire(directory.get(), key); +} + +Result> acquire_existing_test_cache_entry( + NSString* path, + NSString* key) { + auto directory = + resolve_test_cache_directory(path, StorageCreationMode::OpenExisting); + if (!directory.ok()) { + return directory.error(); + } + if (!directory->has_value()) { + return std::unique_ptr(); + } + return LockedCacheEntry::acquire_existing(directory->value(), key); +} + +int parse_child_fd(const char* argument) { + char* end = nullptr; + errno = 0; + const long value = std::strtol(argument, &end, 10); + if (errno != 0 || end == argument || *end != '\0' || value < 0 || + value > INT_MAX) { + return -1; + } + return static_cast(value); +} + +bool send_child_byte(int fd, char byte) { + return send_child_byte_impl(fd, byte); } Result test_cache_key(const SelectedBundle& bundle) { @@ -136,7 +177,7 @@ bool send_signal_byte(int fd, char byte) { } Result saved_bookmark(NSURL* root, NSString* key) { - auto locked = lock_cache_entry(root.path, key); + auto locked = acquire_test_cache_entry(root.path, key); if (!locked.ok()) { return locked.error(); } @@ -174,14 +215,11 @@ int bookmark_child_mode(int argc, char** argv) { if ((!hold && !lock) || (!other && !same && !collision)) { return 2; } - char* end = nullptr; - errno = 0; - const long value = std::strtol(argv[4], &end, 10); - if (errno != 0 || end == argv[4] || *end != '\0' || value < 0 || - value > INT_MAX) { + const int parsed_fd = parse_child_fd(argv[4]); + if (parsed_fd < 0) { return 3; } - FileDescriptor signal_fd(static_cast(value)); + FileDescriptor signal_fd(parsed_fd); if (fcntl(signal_fd.get(), F_GETFD) < 0) { return 4; } @@ -190,7 +228,7 @@ int bookmark_child_mode(int argc, char** argv) { if (root == nil) { return 5; } - auto bundle = bookmark_bundle(); + auto bundle = selected_bundle(manifest_dict()); if (!bundle.ok()) { return 6; } @@ -212,15 +250,15 @@ int bookmark_child_mode(int argc, char** argv) { scope.observe = ^(FaultPoint point) { if (lock && (same || collision) && point == FaultPoint::BeforeCacheEntryLock && - !send_signal_byte(notify_fd, 'B')) { + !send_child_byte_impl(notify_fd, 'B')) { _exit(12); } }; - auto locked = lock_cache_entry(root, selected_key); + auto locked = acquire_test_cache_entry(root, selected_key); if (!locked.ok()) { return 9; } - if (!send_signal_byte(signal_fd.get(), 'L')) { + if (!send_child_byte_impl(signal_fd.get(), 'L')) { return 10; } if (close(signal_fd.release()) != 0) { diff --git a/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_entry_test.mm similarity index 73% rename from backends/apple/coreai/runtime/test/coreai_bookmark_test.mm rename to backends/apple/coreai/runtime/test/coreai_cache_entry_test.mm index 32c002d1b5e..b277344b27a 100644 --- a/backends/apple/coreai/runtime/test/coreai_bookmark_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_cache_entry_test.mm @@ -9,7 +9,7 @@ #include #include #include -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" #include "coreai_fault_scope.h" #include "coreai_filesystem_fixture.h" #include "coreai_storage.h" @@ -29,7 +29,7 @@ void SetUp() override { ASSERT_NE(root.url, nil); - auto selected = bookmark_bundle(); + auto selected = selected_bundle(manifest_dict()); ASSERT_TRUE(selected.ok()); bundle = std::make_unique(selected.get()); auto result = test_cache_key(*bundle); @@ -44,7 +44,7 @@ void SetUp() override { class CoreAIBookmarkIOTest : public CoreAIBookmarkPathTest { protected: - std::unique_ptr locked; + std::unique_ptr locked; NSURL* url = nil; void SetUp() override { @@ -52,7 +52,7 @@ void SetUp() override { if (HasFatalFailure()) { return; } - auto result = lock_cache_entry(root.url.path, key); + auto result = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(result.ok()); locked = std::move(result.get()); ASSERT_NE(locked, nullptr); @@ -62,7 +62,7 @@ void SetUp() override { }; ::testing::AssertionResult bookmark_bytes_equal( - const CacheEntryLock& lock, + const LockedCacheEntry& lock, NSData* expected) { auto result = lock.read_bookmark(); if (!result.ok()) { @@ -183,49 +183,51 @@ FileDescriptor file(open( EXPECT_EQ(info.st_size, static_cast(8 * kMiB + 1)); } -TEST_F(CoreAIBookmarkIOTest, ExistingLookupReadsWithoutPreparingStaging) { +TEST_F(CoreAIBookmarkIOTest, ExistingLookupReadsWithoutPreparingSources) { NSData* bytes = [@"bookmark" dataUsingEncoding:NSUTF8StringEncoding]; ASSERT_EQ(locked->write_bookmark(bytes), Error::Ok); locked.reset(); - auto existing = lock_existing_cache_entry(root.url.path, key); + auto existing = acquire_existing_test_cache_entry(root.url.path, key); ASSERT_TRUE(existing.ok()); ASSERT_NE(existing.get(), nullptr); EXPECT_EQ( existing.get()->persistence(), CacheEntryPersistence::BookmarkCapable); EXPECT_TRUE(bookmark_bytes_equal(*existing.get(), bytes)); EXPECT_FALSE([NSFileManager.defaultManager - fileExistsAtPath:[root.url URLByAppendingPathComponent:@"staging"].path]); + fileExistsAtPath:[root.url URLByAppendingPathComponent:@"sources"].path]); } -TEST_F(CoreAIBookmarkIOTest, PreparesSharedStagingParentOnDemand) { +TEST_F(CoreAIBookmarkIOTest, PreparesSharedSourcesParentOnDemand) { EXPECT_TRUE([locked->key() isEqual:key]); - NSString* expected = [root.url URLByAppendingPathComponent:@"staging"].path; + NSString* expected = [root.url.URLByResolvingSymlinksInPath + URLByAppendingPathComponent:@"sources"] + .path; EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:expected]); - auto staging = locked->prepare_staging_root(); - ASSERT_TRUE(staging.ok()); - EXPECT_TRUE([staging.get() isEqual:expected]); + auto sources = locked->prepare_source_directory(); + ASSERT_TRUE(sources.ok()); + EXPECT_TRUE([sources->url.path isEqual:expected]); struct stat info {}; - ASSERT_EQ(lstat(staging.get().fileSystemRepresentation, &info), 0); + ASSERT_EQ(lstat(sources->url.fileSystemRepresentation, &info), 0); EXPECT_TRUE(S_ISDIR(info.st_mode)); - auto repeated = locked->prepare_staging_root(); + auto repeated = locked->prepare_source_directory(); ASSERT_TRUE(repeated.ok()); - EXPECT_TRUE([repeated.get() isEqual:expected]); + EXPECT_TRUE([repeated->url.path isEqual:expected]); EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:[expected stringByAppendingPathComponent:key]]); } -TEST_F(CoreAIBookmarkIOTest, CleanupDoesNotCreateMissingStaging) { +TEST_F(CoreAIBookmarkIOTest, CleanupDoesNotCreateMissingSources) { StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); - EXPECT_EQ(locked->remove_staging(), Error::Ok); + EXPECT_EQ(locked->remove_source(), Error::Ok); EXPECT_EQ(fault.matches, 0); EXPECT_FALSE([NSFileManager.defaultManager - fileExistsAtPath:[root.url URLByAppendingPathComponent:@"staging"].path]); + fileExistsAtPath:[root.url URLByAppendingPathComponent:@"sources"].path]); } TEST_F(CoreAIBookmarkIOTest, CleanupRejectsUnexpectedTypesBeforeDeletingFiles) { - auto staging = locked->prepare_staging_root(); - ASSERT_TRUE(staging.ok()); - NSString* entry = [staging.get() stringByAppendingPathComponent:key]; + auto sources = locked->prepare_source_directory(); + ASSERT_TRUE(sources.ok()); + NSString* entry = [sources->url.path stringByAppendingPathComponent:key]; ASSERT_EQ(mkdir(entry.fileSystemRepresentation, 0700), 0); NSString* file = [entry stringByAppendingPathComponent:@"data"]; NSData* bytes = [@"preserved" dataUsingEncoding:NSUTF8StringEncoding]; @@ -233,7 +235,7 @@ FileDescriptor file(open( NSString* pipe = [entry stringByAppendingPathComponent:@"pipe"]; ASSERT_EQ(mkfifo(pipe.fileSystemRepresentation, 0600), 0); - EXPECT_EQ(locked->remove_staging(), Error::InvalidExternalData); + EXPECT_EQ(locked->remove_source(), Error::InvalidExternalData); EXPECT_TRUE([[NSData dataWithContentsOfFile:file] isEqual:bytes]); struct stat info; ASSERT_EQ(lstat(pipe.fileSystemRepresentation, &info), 0); @@ -241,9 +243,9 @@ FileDescriptor file(open( } TEST_F(CoreAIBookmarkIOTest, CleanupUsesLockedRootAfterPathReplacement) { - auto staging = locked->prepare_staging_root(); - ASSERT_TRUE(staging.ok()); - NSString* entry = [staging.get() stringByAppendingPathComponent:key]; + auto sources = locked->prepare_source_directory(); + ASSERT_TRUE(sources.ok()); + NSString* entry = [sources->url.path stringByAppendingPathComponent:key]; ASSERT_EQ(mkdir(entry.fileSystemRepresentation, 0700), 0); TestDirectory moved; ASSERT_NE(moved.url, nil); @@ -252,15 +254,15 @@ FileDescriptor file(open( root.url.fileSystemRepresentation, moved.url.fileSystemRepresentation), 0); - NSURL* replacement = [[root.url URLByAppendingPathComponent:@"staging"] + NSURL* replacement = [[root.url URLByAppendingPathComponent:@"sources"] URLByAppendingPathComponent:key]; ASSERT_TRUE([NSFileManager.defaultManager createDirectoryAtURL:replacement withIntermediateDirectories:YES attributes:nil error:nil]); - EXPECT_EQ(locked->remove_staging(), Error::Ok); - NSURL* original = [[moved.url URLByAppendingPathComponent:@"staging"] + EXPECT_EQ(locked->remove_source(), Error::Ok); + NSURL* original = [[moved.url URLByAppendingPathComponent:@"sources"] URLByAppendingPathComponent:key]; EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:original.path]); EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:replacement.path]); @@ -271,8 +273,8 @@ FileDescriptor file(open( ASSERT_EQ(mkdir(reference.fileSystemRepresentation, 0777), 0); struct stat expected; ASSERT_EQ(stat(reference.fileSystemRepresentation, &expected), 0); - ASSERT_TRUE(locked->prepare_staging_root().ok()); - for (NSString* name in @[ @"locks", @"bookmarks", @"staging" ]) { + ASSERT_TRUE(locked->prepare_source_directory().ok()); + for (NSString* name in @[ @"locks", @"bookmarks", @"sources" ]) { NSURL* directory = [root.url URLByAppendingPathComponent:name]; struct stat actual; ASSERT_EQ(stat(directory.fileSystemRepresentation, &actual), 0); @@ -281,102 +283,104 @@ FileDescriptor file(open( } TEST_F(CoreAIBookmarkIOTest, ReusesDirectoriesWithoutCreation) { - ASSERT_TRUE(locked->prepare_staging_root().ok()); + ASSERT_TRUE(locked->prepare_source_directory().ok()); ASSERT_EQ( locked->write_bookmark( [@"bookmark" dataUsingEncoding:NSUTF8StringEncoding]), Error::Ok); locked.reset(); StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); - auto created = lock_cache_entry(root.url.path, key); + auto created = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(created.ok()); ASSERT_NE(created.get(), nullptr); - EXPECT_TRUE(created.get()->prepare_staging_root().ok()); + EXPECT_TRUE(created.get()->prepare_source_directory().ok()); created.get().reset(); - auto existing = lock_existing_cache_entry(root.url.path, key); + auto existing = acquire_existing_test_cache_entry(root.url.path, key); ASSERT_TRUE(existing.ok()); ASSERT_NE(existing.get(), nullptr); - EXPECT_TRUE(existing.get()->prepare_staging_root().ok()); + EXPECT_TRUE(existing.get()->prepare_source_directory().ok()); EXPECT_EQ(fault.matches, 0); } -TEST_F(CoreAIBookmarkIOTest, RecreatesMissingStagingParent) { - auto staging = locked->prepare_staging_root(); - ASSERT_TRUE(staging.ok()); - ASSERT_EQ(rmdir(staging.get().fileSystemRepresentation), 0); +TEST_F(CoreAIBookmarkIOTest, RecreatesMissingSourcesParent) { + auto sources = locked->prepare_source_directory(); + ASSERT_TRUE(sources.ok()); + ASSERT_EQ(rmdir(sources->url.fileSystemRepresentation), 0); StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); - EXPECT_EQ(locked->prepare_staging_root().error(), Error::AccessFailed); + EXPECT_EQ(locked->prepare_source_directory().error(), Error::AccessFailed); EXPECT_EQ(fault.hits, 1); - auto recreated = locked->prepare_staging_root(); + auto recreated = locked->prepare_source_directory(); ASSERT_TRUE(recreated.ok()); - EXPECT_TRUE([recreated.get() isEqual:staging.get()]); + EXPECT_TRUE([recreated->url isEqual:sources->url]); } -TEST_F(CoreAIBookmarkIOTest, AcceptsConcurrentStagingCreation) { - NSURL* staging = [root.url URLByAppendingPathComponent:@"staging"]; +TEST_F(CoreAIBookmarkIOTest, AcceptsConcurrentSourcesCreation) { + NSURL* sources = [root.url URLByAppendingPathComponent:@"sources"]; StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); fault.armed = false; __block int creations = 0; fault.observe = ^(FaultPoint point) { if (point == FaultPoint::BeforeCreateDirectory) { ++creations; - EXPECT_EQ(mkdir(staging.fileSystemRepresentation, 0700), 0); + EXPECT_EQ(mkdir(sources.fileSystemRepresentation, 0700), 0); } }; - EXPECT_TRUE(locked->prepare_staging_root().ok()); + EXPECT_TRUE(locked->prepare_source_directory().ok()); EXPECT_EQ(creations, 1); } -TEST_F(CoreAIBookmarkIOTest, RejectsInvalidStagingParent) { +TEST_F(CoreAIBookmarkIOTest, RejectsInvalidSourcesParent) { TestDirectory target; ASSERT_NE(target.url, nil); - NSURL* staging = [root.url URLByAppendingPathComponent:@"staging"]; + NSURL* sources = [root.url URLByAppendingPathComponent:@"sources"]; ASSERT_EQ( symlink( target.url.fileSystemRepresentation, - staging.fileSystemRepresentation), + sources.fileSystemRepresentation), 0); - EXPECT_EQ(locked->prepare_staging_root().error(), Error::AccessFailed); - ASSERT_EQ(unlink(staging.fileSystemRepresentation), 0); + EXPECT_EQ(locked->prepare_source_directory().error(), Error::AccessFailed); + ASSERT_EQ(unlink(sources.fileSystemRepresentation), 0); NSData* bytes = [@"not a directory" dataUsingEncoding:NSUTF8StringEncoding]; - ASSERT_TRUE([bytes writeToURL:staging atomically:NO]); - EXPECT_EQ(locked->prepare_staging_root().error(), Error::AccessFailed); - EXPECT_TRUE([[NSData dataWithContentsOfURL:staging] isEqual:bytes]); + ASSERT_TRUE([bytes writeToURL:sources atomically:NO]); + EXPECT_EQ(locked->prepare_source_directory().error(), Error::AccessFailed); + EXPECT_TRUE([[NSData dataWithContentsOfURL:sources] isEqual:bytes]); } TEST_F(CoreAIBookmarkPathTest, AllowsTemporaryRootsAndRejectsInvalidKeys) { - auto allowed = lock_cache_entry(root.url.path, key); + auto allowed = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(allowed.ok()); ASSERT_NE(allowed.get(), nullptr); - EXPECT_FALSE(lock_cache_entry(root.url.path, @"../escape").ok()); + EXPECT_FALSE(acquire_test_cache_entry(root.url.path, @"../escape").ok()); } TEST_F(CoreAIBookmarkPathTest, SnapshotsMutableRootAtAcquisition) { TestDirectory other; ASSERT_NE(other.url, nil); NSMutableString* path = [root.url.path mutableCopy]; - auto locked = lock_cache_entry(path, key); + auto locked = acquire_test_cache_entry(path, key); ASSERT_TRUE(locked.ok()); ASSERT_NE(locked.get(), nullptr); [path setString:other.url.path]; - auto staging = locked.get()->prepare_staging_root(); - ASSERT_TRUE(staging.ok()); - NSString* expected = [root.url URLByAppendingPathComponent:@"staging"].path; - NSString* unrelated = [other.url URLByAppendingPathComponent:@"staging"].path; + auto sources = locked.get()->prepare_source_directory(); + ASSERT_TRUE(sources.ok()); + NSString* expected = [root.url.URLByResolvingSymlinksInPath + URLByAppendingPathComponent:@"sources"] + .path; + NSString* unrelated = [other.url URLByAppendingPathComponent:@"sources"].path; EXPECT_TRUE([NSFileManager.defaultManager fileExistsAtPath:expected]); EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:unrelated]); - EXPECT_TRUE([staging.get() isEqual:expected]) - << "Returned staging path: " << staging.get().UTF8String - << "; descriptor-pinned staging path: " << expected.UTF8String; + EXPECT_TRUE([sources->url.path isEqual:expected]) + << "Returned sources path: " << sources->url.path.UTF8String + << "; descriptor-pinned sources path: " << expected.UTF8String; } -TEST_F(CoreAIBookmarkPathTest, ExcludesRootWithoutCreatingStaging) { - auto locked = lock_cache_entry(root.url.path, key); +TEST_F(CoreAIBookmarkPathTest, ExcludesRootWithoutCreatingSources) { + auto locked = acquire_test_cache_entry(root.url.path, key); ASSERT_TRUE(locked.ok()); EXPECT_TRUE(backup_excluded(root.url)); EXPECT_FALSE([NSFileManager.defaultManager - fileExistsAtPath:[root.url URLByAppendingPathComponent:@"staging"].path]); + fileExistsAtPath:[root.url URLByAppendingPathComponent:@"sources"].path]); } TEST_F(CoreAIBookmarkPathTest, MissingLookupLeavesRootAndEntryUntouched) { @@ -385,10 +389,16 @@ FileDescriptor file(open( for (NSString* path in @[ root.url.path, missing ]) { SCOPED_TRACE(path.UTF8String); auto resolved = - resolve_bookmark_root(path, StorageCreationMode::OpenExisting); + resolve_test_cache_directory(path, StorageCreationMode::OpenExisting); ASSERT_TRUE(resolved.ok()); - EXPECT_TRUE([resolved.get() isEqual:path]); - auto absent = lock_existing_cache_entry(path, key); + const bool root_exists = [path isEqual:root.url.path]; + EXPECT_EQ(resolved->has_value(), root_exists); + if (resolved->has_value()) { + EXPECT_TRUE([resolved->value().path + isEqual:[NSURL fileURLWithPath:path] + .URLByResolvingSymlinksInPath.path]); + } + auto absent = acquire_existing_test_cache_entry(path, key); ASSERT_TRUE(absent.ok()); EXPECT_EQ(absent.get(), nullptr); } @@ -403,28 +413,28 @@ FileDescriptor file(open( TEST_F(CoreAIBookmarkPathTest, MissingLookupIgnoresOtherEntries) { auto other_key = different_cache_key(*bundle, key, false); ASSERT_TRUE(other_key.ok()); - auto other = lock_cache_entry(root.url.path, other_key.get()); + auto other = acquire_test_cache_entry(root.url.path, other_key.get()); ASSERT_TRUE(other.ok()); NSData* bytes = [@"other" dataUsingEncoding:NSUTF8StringEncoding]; ASSERT_EQ(other.get()->write_bookmark(bytes), Error::Ok); - auto absent = lock_existing_cache_entry(root.url.path, key); + auto absent = acquire_existing_test_cache_entry(root.url.path, key); ASSERT_TRUE(absent.ok()); EXPECT_EQ(absent.get(), nullptr); EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:lock_url().path]); EXPECT_TRUE(bookmark_bytes_equal(*other.get(), bytes)); } -TEST_F(CoreAIBookmarkPathTest, StagingOnlyLookupHasExplicitPersistence) { - NSURL* staged = [[root.url URLByAppendingPathComponent:@"staging"] +TEST_F(CoreAIBookmarkPathTest, SourceOnlyLookupHasExplicitPersistence) { + NSURL* staged = [[root.url URLByAppendingPathComponent:@"sources"] URLByAppendingPathComponent:key]; ASSERT_TRUE([NSFileManager.defaultManager createDirectoryAtURL:staged withIntermediateDirectories:YES attributes:nil error:nil]); - auto existing = lock_existing_cache_entry(root.url.path, key); + auto existing = acquire_existing_test_cache_entry(root.url.path, key); ASSERT_TRUE(existing.ok()); ASSERT_NE(existing.get(), nullptr); - EXPECT_EQ(existing.get()->persistence(), CacheEntryPersistence::StagingOnly); + EXPECT_EQ(existing.get()->persistence(), CacheEntryPersistence::SourceOnly); EXPECT_TRUE(bookmark_bytes_equal(*existing.get(), nil)); NSData* bytes = [@"bookmark" dataUsingEncoding:NSUTF8StringEncoding]; EXPECT_EQ(existing.get()->write_bookmark(bytes), Error::InvalidArgument); @@ -436,16 +446,21 @@ FileDescriptor file(open( TEST_F(CoreAIBookmarkPathTest, ResolvesAndCreatesRootWithBackupExclusion) { ASSERT_TRUE(set_backup_excluded(root.url, false)); - NSString* path = [root.url URLByAppendingPathComponent:@"nested/assets"].path; - auto resolved = resolve_bookmark_root(path, StorageCreationMode::Create); + NSString* path = [root.url URLByAppendingPathComponent:@"nested/cache"].path; + auto resolved = + resolve_test_cache_directory(path, StorageCreationMode::Create); ASSERT_TRUE(resolved.ok()); - EXPECT_TRUE([resolved.get() isEqual:path]); - EXPECT_TRUE(backup_excluded([NSURL fileURLWithPath:resolved.get()])); + ASSERT_TRUE(resolved->has_value()); + EXPECT_TRUE([resolved->value().path + isEqual:[NSURL fileURLWithPath:path].URLByResolvingSymlinksInPath.path]); + EXPECT_TRUE(backup_excluded([NSURL fileURLWithPath:resolved->value().path])); EXPECT_FALSE(backup_excluded(root.url)); auto existing = - resolve_bookmark_root(path, StorageCreationMode::OpenExisting); + resolve_test_cache_directory(path, StorageCreationMode::OpenExisting); ASSERT_TRUE(existing.ok()); - EXPECT_TRUE([existing.get() isEqual:path]); + ASSERT_TRUE(existing->has_value()); + EXPECT_TRUE([existing->value().path + isEqual:[NSURL fileURLWithPath:path].URLByResolvingSymlinksInPath.path]); } TEST_F(CoreAIBookmarkPathTest, RejectsInvalidResolvedRoots) { @@ -455,18 +470,21 @@ FileDescriptor file(open( @[ @"relative", @"~", @"~/cache", @"~nobody/cache" ]) { SCOPED_TRACE(path.UTF8String); EXPECT_EQ( - resolve_bookmark_root(path, mode).error(), Error::InvalidArgument); + resolve_test_cache_directory(path, mode).error(), + Error::InvalidArgument); } - EXPECT_EQ(resolve_bookmark_root(nil, mode).error(), Error::InvalidArgument); + EXPECT_EQ( + resolve_test_cache_directory(nil, mode).error(), + Error::InvalidArgument); } NSURL* file = [root.url URLByAppendingPathComponent:@"file"]; ASSERT_TRUE([[NSData data] writeToURL:file atomically:NO]); EXPECT_EQ( - resolve_bookmark_root(file.path, StorageCreationMode::OpenExisting) + resolve_test_cache_directory(file.path, StorageCreationMode::OpenExisting) .error(), Error::AccessFailed); EXPECT_EQ( - resolve_bookmark_root( + resolve_test_cache_directory( [file.path stringByAppendingPathComponent:@"child"], StorageCreationMode::OpenExisting) .error(), @@ -494,7 +512,8 @@ FileDescriptor file(open( ASSERT_EQ(mkfifo(stripe.fileSystemRepresentation, 0600), 0); } EXPECT_EQ( - lock_cache_entry(isolated.url.path, key).error(), Error::AccessFailed); + acquire_test_cache_entry(isolated.url.path, key).error(), + Error::AccessFailed); EXPECT_TRUE([[NSData dataWithContentsOfURL:target] isEqual:bytes]); } } diff --git a/backends/apple/coreai/runtime/test/coreai_cache_key_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_key_test.mm index 368e0fc6fa4..03fe45ca4a0 100644 --- a/backends/apple/coreai/runtime/test/coreai_cache_key_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_cache_key_test.mm @@ -10,7 +10,7 @@ #include #include -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" #include "coreai_manifest_fixture.h" using namespace executorch::runtime; @@ -19,14 +19,6 @@ namespace { -Result selected_bundle(NSMutableDictionary* dictionary) { - auto manifest = parse_manifest(encode(dictionary)); - if (!manifest.ok()) { - return manifest.error(); - } - return select_assets(manifest.get(), @"arch_b", @"macOS"); -} - NSString* key_for_device(NSString* device) { NSString* model = [@"" stringByPaddingToLength:64 withString:@"a" @@ -38,13 +30,13 @@ } TEST(CoreAICacheKeyIdentityTest, IgnoresBindingsAndDeliveryLocation) { - auto baseline = bookmark_bundle(); + auto baseline = selected_bundle(manifest_dict()); ASSERT_TRUE(baseline.ok()); auto expected = test_cache_key(baseline.get()); ASSERT_TRUE(expected.ok()); auto dictionary = manifest_dict(); - dictionary[@"hash"] = @"cd"; + dictionary[@"export_hash"] = @"cd"; dictionary[@"path"] = @"cd/model.aimodel"; dictionary[@"function"] = @"another_function"; dictionary[@"input_names"] = @[ @"different_input" ]; @@ -57,7 +49,7 @@ } TEST(CoreAICacheKeyIdentityTest, SeparatesExportedBundleIdentity) { - auto baseline = bookmark_bundle(); + auto baseline = selected_bundle(manifest_dict()); ASSERT_TRUE(baseline.ok()); auto expected = test_cache_key(baseline.get()); ASSERT_TRUE(expected.ok()); @@ -68,7 +60,7 @@ auto reweighted = selected_bundle(digest_changed); ASSERT_TRUE(reweighted.ok()); - auto aot = bookmark_bundle(true); + auto aot = selected_bundle(aot_manifest_dict()); ASSERT_TRUE(aot.ok()); for (const SelectedBundle* bundle : {&reweighted.get(), &aot.get()}) { auto actual = test_cache_key(*bundle); @@ -85,19 +77,27 @@ }; auto manifest = parse_manifest(encode(dictionary)); ASSERT_TRUE(manifest.ok()); - auto first = select_assets(manifest.get(), @"arch_a", @"macOS"); - auto second = select_assets(manifest.get(), @"arch_b", @"macOS"); + auto first = select_bundle(manifest.get(), @"arch_a", @"macOS"); + auto second = select_bundle(manifest.get(), @"arch_b", @"macOS"); ASSERT_TRUE(first.ok()); ASSERT_TRUE(second.ok()); - auto first_key = cache_model_key(first.get()); - auto second_key = cache_model_key(second.get()); + auto first_key = bundle_identity(first.get()); + auto second_key = bundle_identity(second.get()); ASSERT_TRUE(first_key.ok()); ASSERT_TRUE(second_key.ok()); EXPECT_FALSE([first_key.get() isEqual:second_key.get()]); } +TEST(CoreAICacheKeyIdentityTest, RejectsUnknownBundleName) { + auto manifest = parse_manifest(encode(manifest_dict())); + ASSERT_TRUE(manifest.ok()); + EXPECT_EQ( + bundle_identity(manifest.get(), @"unknown.aimodel").error(), + Error::InvalidArgument); +} + TEST(CoreAICacheKeyIdentityTest, SeparatesDeviceAndSpecializationIdentity) { - auto bundle = bookmark_bundle(); + auto bundle = selected_bundle(manifest_dict()); ASSERT_TRUE(bundle.ok()); auto baseline = test_cache_key(bundle.get()); auto platform = @@ -113,13 +113,13 @@ for (auto* changed : {&platform, &architecture, &specialization}) { ASSERT_TRUE(changed->ok()); EXPECT_FALSE([changed->get() isEqual:baseline.get()]); - EXPECT_TRUE([cache_key_model_prefix(changed->get()) - isEqual:cache_key_model_prefix(baseline.get())]); + EXPECT_TRUE([cache_key_bundle_identity(changed->get()) + isEqual:cache_key_bundle_identity(baseline.get())]); } } TEST(CoreAICacheKeyIdentityTest, RejectsIncompletePlainIdentity) { - auto bundle = bookmark_bundle(); + auto bundle = selected_bundle(manifest_dict()); ASSERT_TRUE(bundle.ok()); EXPECT_FALSE( cache_variant_key(bundle.get(), nil, @"arch_b", @"default").ok()); @@ -135,7 +135,7 @@ NSString* key = key_for_device(device); EXPECT_TRUE(is_cache_key(key)); EXPECT_TRUE( - [cache_key_model_prefix(key) isEqual:[key substringToIndex:64]]); + [cache_key_bundle_identity(key) isEqual:[key substringToIndex:64]]); } } @@ -153,7 +153,7 @@ ]) { NSString* key = key_for_device(device); EXPECT_FALSE(is_cache_key(key)); - EXPECT_EQ(cache_key_model_prefix(key), nil); + EXPECT_EQ(cache_key_bundle_identity(key), nil); } } @@ -184,7 +184,7 @@ } TEST(CoreAICacheKeyFormatTest, LockStripesStayWithinConfiguredPool) { - auto bundle = bookmark_bundle(); + auto bundle = selected_bundle(manifest_dict()); ASSERT_TRUE(bundle.ok()); NSMutableSet* names = [NSMutableSet set]; for (NSUInteger index = 0; index < 4096; ++index) { diff --git a/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm index b6cf8141065..5fffbce9a6e 100644 --- a/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_cache_root_test.mm @@ -18,6 +18,27 @@ using namespace executorch::backends::coreai; using namespace executorch::backends::coreai::testing; +TEST( + CoreAICacheRootTest, + ResolvesDefaultAndExplicitPathsWithoutCreatingStorage) { + auto default_path = cache_directory_path(nullptr); + ASSERT_TRUE(default_path.ok()); + EXPECT_TRUE(default_path.get().isAbsolutePath); + EXPECT_TRUE( + [default_path.get().lastPathComponent isEqual:@"executorch_coreai"]); + + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* missing = [storage.url URLByAppendingPathComponent:@"missing"]; + auto explicit_path = cache_directory_path(missing.path.UTF8String); + ASSERT_TRUE(explicit_path.ok()); + EXPECT_TRUE([explicit_path.get() isEqual:missing.path]); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:missing.path]); + for (const char* invalid : {"", "relative", "~", "~/cache", "\xff"}) { + EXPECT_EQ(cache_directory_path(invalid).error(), Error::InvalidArgument); + } +} + TEST(CoreAICacheRootTest, PreparesRootWithRootOnlyBackupPolicy) { NSFileManager* manager = NSFileManager.defaultManager; TestDirectory sandbox; @@ -30,29 +51,38 @@ ASSERT_TRUE(set_backup_excluded(sandbox.url, false)); ASSERT_TRUE(set_backup_excluded(parent, false)); NSURL* root = [parent URLByAppendingPathComponent:@"nested/models"]; - auto prepared = prepare_storage_root(root.path, StorageCreationMode::Create); - ASSERT_TRUE((prepared.ok() && [prepared.get() isEqualToString:root.path])); + auto prepared = + resolve_test_cache_directory(root.path, StorageCreationMode::Create); + ASSERT_TRUE(prepared.ok()); + ASSERT_TRUE(prepared->has_value()); + EXPECT_TRUE([prepared->value().path + isEqualToString:root.URLByResolvingSymlinksInPath.path]); ASSERT_TRUE((backup_excluded(root))); ASSERT_TRUE((!backup_excluded(parent) && !backup_excluded(sandbox.url))); ASSERT_TRUE((!backup_excluded(root.URLByDeletingLastPathComponent))); ASSERT_TRUE(set_backup_excluded(root, false)); - auto inspected = - prepare_storage_root(root.path, StorageCreationMode::OpenExisting); - ASSERT_TRUE((inspected.ok() && [inspected.get() isEqualToString:root.path])); + auto inspected = resolve_test_cache_directory( + root.path, StorageCreationMode::OpenExisting); + ASSERT_TRUE(inspected.ok()); + ASSERT_TRUE(inspected->has_value()); + EXPECT_TRUE([inspected->value().path + isEqualToString:root.URLByResolvingSymlinksInPath.path]); ASSERT_TRUE((!backup_excluded(root))); ASSERT_TRUE( - (prepare_storage_root(root.path, StorageCreationMode::Create).ok() && + (resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok() && backup_excluded(root))); NSURL* missing = [sandbox.url URLByAppendingPathComponent:@"missing"]; - ASSERT_EQ( - prepare_storage_root(missing.path, StorageCreationMode::OpenExisting) - .error(), - Error::InvalidArgument); + auto absent = resolve_test_cache_directory( + missing.path, StorageCreationMode::OpenExisting); + ASSERT_TRUE(absent.ok()); + EXPECT_FALSE(absent->has_value()); EXPECT_FALSE([manager fileExistsAtPath:missing.path]); EXPECT_EQ( - prepare_storage_root(@"relative", StorageCreationMode::Create).error(), + resolve_test_cache_directory(@"relative", StorageCreationMode::Create) + .error(), Error::InvalidArgument); EXPECT_TRUE((!backup_excluded(parent) && !backup_excluded(sandbox.url))); } @@ -62,28 +92,33 @@ ASSERT_NE(storage.url, nil); NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; ASSERT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); StorageFaultScope fault(FaultPoint::BeforeCreateDirectory); EXPECT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); EXPECT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::OpenExisting).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::OpenExisting) + .ok()); EXPECT_EQ(fault.matches, 0); EXPECT_TRUE(backup_excluded(root)); NSURL* missing = [storage.url URLByAppendingPathComponent:@"missing"]; - EXPECT_EQ( - prepare_storage_root(missing.path, StorageCreationMode::OpenExisting) - .error(), - Error::InvalidArgument); + auto absent = resolve_test_cache_directory( + missing.path, StorageCreationMode::OpenExisting); + ASSERT_TRUE(absent.ok()); + EXPECT_FALSE(absent->has_value()); EXPECT_EQ(fault.matches, 0); EXPECT_EQ( - prepare_storage_root(missing.path, StorageCreationMode::Create).error(), + resolve_test_cache_directory(missing.path, StorageCreationMode::Create) + .error(), Error::AccessFailed); EXPECT_EQ(fault.hits, 1); EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:missing.path]); EXPECT_TRUE( - prepare_storage_root(missing.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(missing.path, StorageCreationMode::Create) + .ok()); } TEST(CoreAICacheRootTest, CreatesDirectoriesWithDefaultPermissions) { @@ -95,7 +130,8 @@ ASSERT_EQ(stat(reference.fileSystemRepresentation, &expected), 0); NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; ASSERT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); FileDescriptor directory( open(root.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_CLOEXEC)); ASSERT_GE(directory.get(), 0); @@ -114,10 +150,12 @@ FileDescriptor directory( ASSERT_NE(storage.url, nil); NSURL* root = [storage.url URLByAppendingPathComponent:@"cache"]; ASSERT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); ASSERT_EQ(rmdir(root.fileSystemRepresentation), 0); ASSERT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); EXPECT_TRUE(backup_excluded(root)); } @@ -126,7 +164,8 @@ FileDescriptor directory( ASSERT_NE(sandbox.url, nil); NSURL* root = [sandbox.url URLByAppendingPathComponent:@"cache"]; ASSERT_TRUE( - prepare_storage_root(root.path, StorageCreationMode::Create).ok()); + resolve_test_cache_directory(root.path, StorageCreationMode::Create) + .ok()); FileDescriptor directory( open_directory_at(AT_FDCWD, root.fileSystemRepresentation)); ASSERT_GE(directory.get(), 0); @@ -171,7 +210,7 @@ FileDescriptor directory( ASSERT_TRUE([before[NSFileProtectionKey] isEqual:NSFileProtectionComplete]); for (StorageCreationMode mode : {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { - ASSERT_TRUE(prepare_storage_root(root.path, mode).ok()); + ASSERT_TRUE(resolve_test_cache_directory(root.path, mode).ok()); NSDictionary* after = [manager attributesOfItemAtPath:root.path error:&error]; ASSERT_NE(after, nil) << error.description.UTF8String; @@ -179,6 +218,44 @@ FileDescriptor directory( } } +TEST(CoreAICacheRootTest, SupportsSymlinkAndAnchorsResolvedDirectory) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* first = [storage.url URLByAppendingPathComponent:@"first"]; + NSURL* second = [storage.url URLByAppendingPathComponent:@"second"]; + NSURL* configured = [storage.url URLByAppendingPathComponent:@"configured"]; + ASSERT_EQ(mkdir(first.fileSystemRepresentation, 0777), 0); + ASSERT_EQ(mkdir(second.fileSystemRepresentation, 0777), 0); + ASSERT_EQ( + symlink( + first.fileSystemRepresentation, configured.fileSystemRepresentation), + 0); + + auto resolved = resolve_test_cache_directory( + configured.path, StorageCreationMode::Create); + ASSERT_TRUE(resolved.ok()); + ASSERT_TRUE(resolved->has_value()); + struct stat target_info; + struct stat resolved_info; + ASSERT_EQ(stat(first.fileSystemRepresentation, &target_info), 0); + ASSERT_EQ(fstat(resolved->value().descriptor.get(), &resolved_info), 0); + EXPECT_EQ(resolved_info.st_ino, target_info.st_ino); + + ASSERT_EQ(unlink(configured.fileSystemRepresentation), 0); + ASSERT_EQ( + symlink( + second.fileSystemRepresentation, configured.fileSystemRepresentation), + 0); + ASSERT_EQ( + write_storage_file( + resolved->value().descriptor.get(), @"anchored", "x", 1), + Error::Ok); + EXPECT_TRUE([NSFileManager.defaultManager + fileExistsAtPath:[first URLByAppendingPathComponent:@"anchored"].path]); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:[second URLByAppendingPathComponent:@"anchored"].path]); +} + TEST(CoreAICacheRootTest, RejectsInvalidRootsConsistently) { TestDirectory storage; ASSERT_NE(storage.url, nil); @@ -188,19 +265,24 @@ FileDescriptor directory( for (StorageCreationMode mode : {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { SCOPED_TRACE(static_cast(mode)); - EXPECT_EQ(prepare_storage_root(nil, mode).error(), Error::InvalidArgument); + EXPECT_EQ( + resolve_test_cache_directory(nil, mode).error(), + Error::InvalidArgument); + for (NSString* path in + @[ @"", @"relative", @"~", @"~/cache", @"~nobody/cache" ]) { + SCOPED_TRACE(path.UTF8String); + EXPECT_EQ( + resolve_test_cache_directory(path, mode).error(), + Error::InvalidArgument); + } for (NSString* path in @[ - @"", - @"relative", - @"~", - @"~/cache", - @"~nobody/cache", regular.path, [regular.path stringByAppendingPathComponent:@"child"] ]) { - SCOPED_TRACE(path.UTF8String); EXPECT_EQ( - prepare_storage_root(path, mode).error(), Error::InvalidArgument); + resolve_test_cache_directory(path, mode).error(), + mode == StorageCreationMode::Create ? Error::InvalidArgument + : Error::AccessFailed); } } EXPECT_TRUE([[NSData dataWithContentsOfURL:regular] isEqual:bytes]); diff --git a/backends/apple/coreai/runtime/test/coreai_cache_test.mm b/backends/apple/coreai/runtime/test/coreai_cache_test.mm new file mode 100644 index 00000000000..a04fa9e0e96 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_cache_test.mm @@ -0,0 +1,841 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "coreai_cache.h" +#include "coreai_cache_entry_fixture.h" +#include "coreai_fake_loader.h" +#include "coreai_fault_scope.h" +#include "coreai_filesystem_fixture.h" +#include "coreai_pte_fixture.h" +#include "coreai_source_fixture.h" +#include "coreai_test_utils.h" + +namespace executorch::backends::coreai::testing { +namespace { + +using runtime::Error; +using SegmentType = runtime::DataLoader::SegmentInfo::Type; +using ::testing::AssertionFailure; +using ::testing::AssertionResult; +using ::testing::AssertionSuccess; + +class CoreAICacheTest : public ::testing::Test { + protected: + void SetUp() override { + runtime::runtime_init(); + } + + void drain_callbacks() { + auto drained = bridge.wait_for_callbacks(test_deadline()); + if (!drained) { + ADD_FAILURE() << drained.message(); + // Timed-out callbacks may still borrow this test's PTE or observations. + std::_Exit(EXIT_FAILURE); + } + } + + ScopedFakeBridgeState bridge; +}; + +struct CacheEntry { + NSString* key = nil; + NSData* bookmark = nil; + NSURL* stage = nil; +}; + +AssertionResult seed_cache( + NSURL* root, + NSDictionary* dict, + BookmarkFakeLoader* loader, + CacheEntry& output, + bool bookmark = true, + bool stage = true, + NSString* architecture = @"arch_b", + NSString* platform = @"macOS", + NSString* specialization = @"default") { + if (root == nil || loader == nil) { + return AssertionFailure() << "Cache seed requires a root and fake loader"; + } + auto parsed = parse_manifest(encode(dict)); + if (!parsed.ok()) { + return AssertionFailure() << "Cannot parse cache seed manifest"; + } + auto selected = select_bundle(parsed.get(), architecture, platform); + if (!selected.ok()) { + return AssertionFailure() << "Cannot select cache seed assets"; + } + auto key = + cache_variant_key(selected.get(), platform, architecture, specialization); + if (!key.ok()) { + return AssertionFailure() << "Cannot compute cache seed key"; + } + CacheEntry entry{ + key.get(), + nil, + [[root URLByAppendingPathComponent:@"sources"] + URLByAppendingPathComponent:key.get()]}; + auto lock = acquire_test_cache_entry(root.path, entry.key); + if (!lock.ok()) { + return AssertionFailure() << "Cannot lock cache seed bookmark"; + } + if (stage) { + TestData data; + if (selected->aot_compiled) { + aot_data(data, architecture); + } + auto staged = executorch::backends::coreai::prepare_source_bundle( + selected.get(), &data, *lock.get(), CorruptBundlePolicy::Reject); + if (data.requests != data.releases) { + return AssertionFailure() + << "Cache seed source buffers were not released"; + } + if (!staged.ok()) { + return AssertionFailure() << "Cannot stage cache seed source bundle"; + } + } + if (bookmark) { + entry.bookmark = + [NSUUID.UUID.UUIDString dataUsingEncoding:NSUTF8StringEncoding]; + BookmarkFakeModel* model = [[BookmarkFakeModel alloc] init]; + if (model == nil || entry.bookmark == nil) { + return AssertionFailure() << "Cannot create cache seed model/bookmark"; + } + model.owner = loader; + model.bookmark = entry.bookmark; + loader.models[entry.bookmark] = model; + if (lock.get()->write_bookmark(entry.bookmark) != Error::Ok) { + return AssertionFailure() << "Cannot write cache seed bookmark"; + } + } + output = entry; + return AssertionSuccess(); +} + +bool cache_exists(NSURL* url) { + return [NSFileManager.defaultManager fileExistsAtPath:url.path]; +} + +void cache_entry_removed(NSURL* root, const CacheEntry& entry) { + EXPECT_FALSE(cache_exists(test_bookmark_url(root, entry.key))); + EXPECT_FALSE(cache_exists(entry.stage)); + EXPECT_TRUE(cache_exists(test_cache_lock_url(root, entry.key))); +} + +NSMutableDictionary* cache_manifest(char identity) { + auto dict = manifest_dict(); + dict[@"bundle_digests"] = @{@"model.aimodel" : fixture_identity(identity)}; + return dict; +} + +TEST_F(CoreAICacheTest, EmptyAndMissingRootsAreNoncreatingNoops) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + BookmarkLoaderScope fake; + ASSERT_NE(fake.loader, nil); + FBSyntheticPTE empty({{"forward", {}}}); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(root.url, snapshot)); + const int queries = bridge.state().architecture_queries; + bridge.state().available = false; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(clear_cache_for_pte(empty, root.url.path.UTF8String), Error::Ok); + NSURL* missing = + [root.url URLByAppendingPathComponent:@"absent/nested/models"]; + EXPECT_EQ(clear_cache(missing.path.UTF8String), Error::Ok); + EXPECT_EQ(clear_cache_for_pte(empty, missing.path.UTF8String), Error::Ok); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", encode(manifest_dict())}}}}); + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(clear_cache_for_pte(pte, missing.path.UTF8String), Error::Ok); + EXPECT_TRUE(pte.check_released()); + EXPECT_FALSE(cache_exists(missing)); + EXPECT_TRUE(snapshot_matches(root.url, snapshot)); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(bridge.state().architecture_queries.load(), queries); + EXPECT_TRUE(empty.check_released()); + } +} + +TEST_F(CoreAICacheTest, ValidatesExplicitRootsAndPtePaths) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + BookmarkLoaderScope fake; + ASSERT_NE(fake.loader, nil); + FBSyntheticPTE empty({{"forward", {}}}); + EXPECT_EQ(clear_cache("relative"), Error::InvalidArgument); + EXPECT_EQ(clear_cache_for_pte(empty, "relative"), Error::InvalidArgument); + EXPECT_EQ(clear_cache(""), Error::InvalidArgument); + EXPECT_EQ(clear_cache("\xff"), Error::InvalidArgument); + EXPECT_EQ( + clear_cache_for_pte( + static_cast(nullptr), root.url.path.UTF8String), + Error::InvalidArgument); + EXPECT_TRUE(empty.check_released()); + } +} + +TEST_F(CoreAICacheTest, SelectsAllMethodsDeduplicatesAndEvictsInKeyOrder) { + @autoreleasepool { + TestDirectory root; + TestDirectory isolated; + BookmarkLoaderScope fake; + auto source_dict = manifest_dict(); + auto second_dict = cache_manifest('2'); + auto aot_dict = aot_manifest_dict(); + CacheEntry source, second, aot, other_arch, unrelated, other_root; + ASSERT_TRUE(seed_cache(root.url, source_dict, fake.loader, source)); + ASSERT_TRUE(seed_cache(root.url, second_dict, fake.loader, second)); + ASSERT_TRUE(seed_cache(root.url, aot_dict, fake.loader, aot)); + ASSERT_TRUE(seed_cache( + root.url, aot_dict, fake.loader, other_arch, true, true, @"arch_a")); + ASSERT_TRUE( + seed_cache(root.url, cache_manifest('3'), fake.loader, unrelated)); + ASSERT_TRUE(seed_cache(isolated.url, source_dict, fake.loader, other_root)); + NSDictionary* isolation = nil; + NSDictionary* unrelated_stage = nil; + ASSERT_TRUE(asset_tree_snapshot(isolated.url, isolation)); + ASSERT_TRUE(asset_tree_snapshot(unrelated.stage, unrelated_stage)); + FBSyntheticPTE pte( + {{"empty", {}}, + {"first", + {{"UnregisteredBackend", + [@"not JSON" dataUsingEncoding:NSUTF8StringEncoding], + true}, + {"CoreAIBackend", encode(source_dict)}, + {"CoreAIBackend", encode(second_dict), true}}}, + {"last", + {{"CoreAIBackend", encode(source_dict), true}, + {"CoreAIBackend", encode(aot_manifest_dict()), true}}}}); + NSMutableArray* evictions = [NSMutableArray array]; + fake.loader.onEvict = ^(NSData* bookmark) { + [evictions addObject:bookmark]; + }; + const int queries = bridge.state().architecture_queries; + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + drain_callbacks(); + EXPECT_EQ(bridge.state().architecture_queries.load(), queries); + EXPECT_EQ(fake.loader->evictions.load(), 4); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(fake.loader->bindings.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + NSArray* ordered = + [@[ source.key, second.key, aot.key, other_arch.key ] + sortedArrayUsingSelector:@selector(compare:)]; + NSDictionary* bookmarks = @{ + source.key : source.bookmark, + second.key : second.bookmark, + aot.key : aot.bookmark, + other_arch.key : other_arch.bookmark + }; + ASSERT_EQ(evictions.count, ordered.count); + for (NSUInteger i = 0; i < ordered.count; ++i) { + SCOPED_TRACE(::testing::Message() << "sorted eviction index " << i); + EXPECT_TRUE([evictions[i] isEqual:bookmarks[ordered[i]]]); + } + cache_entry_removed(root.url, source); + cache_entry_removed(root.url, second); + cache_entry_removed(root.url, aot); + EXPECT_NE(fake.loader.models[other_root.bookmark], nil); + EXPECT_EQ(fake.loader.models[other_arch.bookmark], nil); + cache_entry_removed(root.url, other_arch); + EXPECT_TRUE(snapshot_matches(isolated.url, isolation)); + EXPECT_TRUE(snapshot_matches(unrelated.stage, unrelated_stage)); + EXPECT_EQ(pte.backend_indices, (std::vector{1, 2, 3})); + EXPECT_EQ( + pte.backend_descriptors, + (std::vector{ + "CoreAIBackend", "CoreAIBackend", "CoreAIBackend"})); + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAICacheTest, FilePathOverloadClearsReferencedEntry) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + TestDirectory distribution; + ASSERT_NE(distribution.url, nil); + CacheEntry entry; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, entry)); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", encode(manifest_dict())}}}}); + NSURL* path = [distribution.url URLByAppendingPathComponent:@"model.pte"]; + ASSERT_TRUE([[NSData dataWithBytes:pte.bytes.data() + length:pte.bytes.size()] writeToURL:path + options:0 + error:nil]); + EXPECT_EQ( + clear_cache_for_pte(path.path.UTF8String, root.url.path.UTF8String), + Error::Ok); + drain_callbacks(); + EXPECT_EQ(fake.loader->evictions.load(), 1); + cache_entry_removed(root.url, entry); + } +} + +TEST_F(CoreAICacheTest, RejectsInvalidLaterDelegatesBeforeMutation) { + @autoreleasepool { + const char* rows[] = {"malformed manifest", "backend load failure"}; + for (int row = 0; row < 2; ++row) { + SCOPED_TRACE(rows[row]); + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry source; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, source)); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(root.url, snapshot)); + PteDelegateSpec later{"CoreAIBackend", encode(aot_manifest_dict()), true}; + if (row == 0) + later.data = [@"malformed" dataUsingEncoding:NSUTF8StringEncoding]; + FBSyntheticPTE pte( + {{"first", {{"CoreAIBackend", encode(manifest_dict())}}}, + {"later", {later}}}); + if (row == 1) { + pte.fault_call = 2; + pte.load_fault = FBSyntheticPTE::LoadFault::Error; + pte.load_error = Error::AccessFailed; + } + NSDictionary* models = [fake.loader.models copy]; + const auto error = clear_cache_for_pte(pte, root.url.path.UTF8String); + drain_callbacks(); + EXPECT_NE(error, Error::Ok); + if (row == 1) + EXPECT_EQ(error, Error::AccessFailed); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(fake.loader->bindings.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_TRUE(snapshot_matches(root.url, snapshot)); + EXPECT_TRUE([fake.loader.models isEqual:models]); + EXPECT_TRUE(pte.check_released()); + } + } +} + +TEST_F(CoreAICacheTest, ClearsWarmVariantsWithoutHostCompatibility) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + auto source = manifest_dict(); + auto aot = aot_manifest_dict(); + aot[@"platform"] = @"iOS"; + CacheEntry mac, ios, compiled; + ASSERT_TRUE(seed_cache( + root.url, source, fake.loader, mac, true, false, @"unknown_mac_arch")); + ASSERT_TRUE(seed_cache( + root.url, + source, + fake.loader, + ios, + true, + false, + @"unknown_ios_arch", + @"iOS")); + ASSERT_TRUE(seed_cache( + root.url, aot, fake.loader, compiled, true, false, @"arch_a", @"iOS")); + source[@"min_deployment_version"] = @"999999.0"; + aot[@"min_deployment_version"] = @"999999.0"; + bridge.state().device_architecture = nil; + const int queries = bridge.state().architecture_queries; + FBSyntheticPTE pte( + {{"forward", + {{"CoreAIBackend", encode(source)}, + {"CoreAIBackend", encode(aot), true}}}}); + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 3); + EXPECT_EQ(bridge.state().architecture_queries.load(), queries); + cache_entry_removed(root.url, mac); + cache_entry_removed(root.url, ios); + cache_entry_removed(root.url, compiled); + } +} + +TEST_F(CoreAICacheTest, ClearsAotBundles) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry first, second; + auto dict = aot_manifest_dict(); + ASSERT_TRUE(seed_cache( + root.url, + dict, + fake.loader, + first, + true, + false, + @"arch_a", + @"macOS", + @"default")); + ASSERT_TRUE(seed_cache( + root.url, + dict, + fake.loader, + second, + true, + false, + @"arch_b", + @"macOS", + @"default")); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", encode(dict)}}}}); + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 2); + cache_entry_removed(root.url, first); + cache_entry_removed(root.url, second); + } +} + +TEST_F(CoreAICacheTest, ClearsOptionAndDeviceVariantsPreservingUnrelatedModel) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry base, specialized, device, unrelated; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, base)); + ASSERT_TRUE(seed_cache( + root.url, + manifest_dict(), + fake.loader, + specialized, + true, + true, + @"arch_b", + @"macOS", + @"tuned")); + ASSERT_TRUE(seed_cache( + root.url, + manifest_dict(), + fake.loader, + device, + true, + true, + @"other_arch")); + ASSERT_TRUE( + seed_cache(root.url, cache_manifest('3'), fake.loader, unrelated)); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", encode(manifest_dict())}}}}); + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + drain_callbacks(); + EXPECT_EQ(fake.loader->evictions.load(), 3); + EXPECT_EQ(fake.loader->specializationQueries.load(), 0); + cache_entry_removed(root.url, base); + cache_entry_removed(root.url, specialized); + cache_entry_removed(root.url, device); + EXPECT_TRUE(cache_exists(test_bookmark_url(root.url, unrelated.key))); + EXPECT_TRUE(cache_exists(unrelated.stage)); + EXPECT_NE(fake.loader.models[unrelated.bookmark], nil); + EXPECT_EQ(fake.loader.models[base.bookmark], nil); + EXPECT_EQ(fake.loader.models[specialized.bookmark], nil); + EXPECT_EQ(fake.loader.models[device.bookmark], nil); + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAICacheTest, ClearsSourceOnlyEntriesWithoutSdk) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry staged; + ASSERT_TRUE(seed_cache( + root.url, manifest_dict(), fake.loader, staged, false, true)); + bridge.state().available = false; + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", encode(manifest_dict())}}}}); + EXPECT_EQ(clear_cache_for_pte(pte, root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 0); + cache_entry_removed(root.url, staged); + } +} + +TEST_F(CoreAICacheTest, MaintenanceUnlinksSourcesRootWithoutFollowingIt) { + @autoreleasepool { + TestDirectory root, outside; + BookmarkLoaderScope fake; + CacheEntry entry; + ASSERT_TRUE( + seed_cache(root.url, manifest_dict(), fake.loader, entry, true, false)); + NSURL* sentinel = [outside.url URLByAppendingPathComponent:@"sentinel"]; + NSData* contents = [@"untouched" dataUsingEncoding:NSUTF8StringEncoding]; + ASSERT_TRUE([contents writeToURL:sentinel options:0 error:nil]); + ASSERT_TRUE([NSFileManager.defaultManager + createDirectoryAtURL:[entry.stage URLByDeletingLastPathComponent] + withIntermediateDirectories:NO + attributes:nil + error:nil]); + ASSERT_EQ( + symlink( + outside.url.fileSystemRepresentation, + entry.stage.fileSystemRepresentation), + 0); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + EXPECT_TRUE([[NSData dataWithContentsOfURL:sentinel] isEqual:contents]); + EXPECT_EQ(fake.loader->evictions.load(), 1); + cache_entry_removed(root.url, entry); + } +} + +TEST_F(CoreAICacheTest, ProcessedBuffersRemainAliveThroughEviction) { + @autoreleasepool { + TestDirectory root; + CacheEntry source; + NSData* manifest = encode(manifest_dict()); + FBSyntheticPTE valid( + {{"forward", + {{"CoreAIBackend", manifest, true}, + {"CoreAIBackend", manifest, true}, + {"CoreAIBackend", manifest}}}}); + // Destroy the loader scope before the PTE captured by its callback. + BookmarkLoaderScope fake; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, source)); + __block int observations = 0; + __block bool program_alive = false; + __block int backend_releases = -1; + FBSyntheticPTE* observed = &valid; + fake.loader.onEvict = ^(NSData*) { + ++observations; + program_alive = observed->program_loads.load() == + observed->program_releases.load() + 1; + backend_releases = observed->backend_releases.load(); + }; + EXPECT_EQ(clear_cache_for_pte(valid, root.url.path.UTF8String), Error::Ok); + drain_callbacks(); + EXPECT_EQ(observations, 1); + EXPECT_TRUE(program_alive); + EXPECT_EQ(backend_releases, 0); + EXPECT_EQ(valid.backend_loads.load(), 2); + EXPECT_EQ(valid.backend_indices, (std::vector{0, 1})); + EXPECT_TRUE(valid.program_alive_at_backend_release); + EXPECT_TRUE(valid.check_released()); + ASSERT_FALSE(valid.release_order.empty()); + EXPECT_EQ(valid.release_order.back(), SegmentType::Program); + } +} + +TEST_F( + CoreAICacheTest, + InventoriesBookmarkAndSourceUnionPreservingUnownedFiles) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry both, bookmark_only, stage_only; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, both)); + ASSERT_TRUE(seed_cache( + root.url, + cache_manifest('2'), + fake.loader, + bookmark_only, + true, + false)); + ASSERT_TRUE(seed_cache( + root.url, cache_manifest('3'), fake.loader, stage_only, false, true)); + NSURL* temporary = + [root.url URLByAppendingPathComponent:@"sources/.pending-unkeyed"]; + ASSERT_TRUE([NSFileManager.defaultManager createDirectoryAtURL:temporary + withIntermediateDirectories:NO + attributes:nil + error:nil]); + NSURL* untouched = [temporary URLByAppendingPathComponent:@"partial"]; + ASSERT_TRUE([[@"do not infer ownership" + dataUsingEncoding:NSUTF8StringEncoding] writeToURL:untouched + options:0 + error:nil]); + NSURL* external_sentinel = + [root.url URLByAppendingPathComponent:@"caller.pte"]; + ASSERT_TRUE([[@"PTE" dataUsingEncoding:NSUTF8StringEncoding] + writeToURL:external_sentinel + options:0 + error:nil]); + NSDictionary* temp_snapshot = nil; + NSDictionary* locks = nil; + ASSERT_TRUE(asset_tree_snapshot(temporary, temp_snapshot)); + ASSERT_TRUE(asset_tree_snapshot( + [root.url URLByAppendingPathComponent:@"locks"], locks)); + const int queries = bridge.state().architecture_queries; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 2); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(bridge.state().architecture_queries.load(), queries); + cache_entry_removed(root.url, both); + cache_entry_removed(root.url, bookmark_only); + cache_entry_removed(root.url, stage_only); + EXPECT_TRUE(snapshot_matches( + [root.url URLByAppendingPathComponent:@"locks"], locks)); + EXPECT_TRUE(snapshot_matches(temporary, temp_snapshot)); + EXPECT_TRUE(cache_exists(external_sentinel)); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 2); + } +} + +TEST_F(CoreAICacheTest, RemovesSourceOnlyEntriesWithoutSdkAvailability) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry stage_only; + ASSERT_TRUE(seed_cache( + root.url, manifest_dict(), fake.loader, stage_only, false, true)); + bridge.state().available = false; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + cache_entry_removed(root.url, stage_only); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + } +} + +TEST_F(CoreAICacheTest, WarnsAndContinuesPastMissingOrCorruptBookmarks) { + for (bool pte_clear : {false, true}) { + for (int corruption = 0; corruption < 6; ++corruption) { + SCOPED_TRACE(pte_clear ? "PTE" : "root"); + SCOPED_TRACE(corruption); + @autoreleasepool { + TestDirectory root, outside; + NSURL* sentinel = [outside.url URLByAppendingPathComponent:@"sentinel"]; + NSData* contents = + [@"untouched" dataUsingEncoding:NSUTF8StringEncoding]; + ASSERT_TRUE([contents writeToURL:sentinel options:0 error:nil]); + BookmarkLoaderScope fake; + CacheEntry damaged, healthy; + ASSERT_TRUE( + seed_cache(root.url, manifest_dict(), fake.loader, damaged)); + ASSERT_TRUE( + seed_cache(root.url, cache_manifest('2'), fake.loader, healthy)); + if ([damaged.key compare:healthy.key] == NSOrderedDescending) { + std::swap(damaged, healthy); + } + NSURL* path = test_bookmark_url(root.url, damaged.key); + if (corruption == 0 || corruption >= 3) { + ASSERT_EQ(unlink(path.fileSystemRepresentation), 0); + if (corruption == 3) { + ASSERT_EQ(mkfifo(path.fileSystemRepresentation, 0600), 0); + } else if (corruption == 4) { + ASSERT_EQ( + symlink( + sentinel.fileSystemRepresentation, + path.fileSystemRepresentation), + 0); + } else if (corruption == 5) { + ASSERT_EQ(mkdir(path.fileSystemRepresentation, 0700), 0); + ASSERT_TRUE([contents + writeToURL:[path URLByAppendingPathComponent:@"invalid"] + options:0 + error:nil]); + } + } else { + FileDescriptor file( + open(path.fileSystemRepresentation, O_WRONLY | O_NOFOLLOW)); + ASSERT_GE(file.get(), 0); + ASSERT_EQ( + ftruncate(file.get(), corruption == 1 ? 0 : 8 * 1024 * 1024 + 1), + 0); + } + FBSyntheticPTE pte( + {{"forward", + {{"CoreAIBackend", encode(manifest_dict())}, + {"CoreAIBackend", encode(cache_manifest('2')), true}}}}); + auto clear = [&] { + return pte_clear ? clear_cache_for_pte(pte, root.url.path.UTF8String) + : clear_cache(root.url.path.UTF8String); + }; + ::testing::internal::CaptureStderr(); + const auto error = clear(); + const std::string logs = ::testing::internal::GetCapturedStderr(); + EXPECT_EQ(error, Error::Ok); + if constexpr ( + ET_LOG_ENABLED && + runtime::LogLevel::Info >= runtime::LogLevel::ET_MIN_LOG_LEVEL) { + EXPECT_NE(logs.find(damaged.key.UTF8String), std::string::npos); + EXPECT_NE( + logs.find( + corruption == 0 ? "has a missing bookmark" + : "has a corrupt bookmark"), + std::string::npos); + EXPECT_NE( + logs.find("SDK eviction could not be confirmed"), + std::string::npos); + EXPECT_EQ( + logs.find( + std::string( + static_cast(damaged.bookmark.bytes), + damaged.bookmark.length)), + std::string::npos); + } + cache_entry_removed(root.url, damaged); + cache_entry_removed(root.url, healthy); + EXPECT_TRUE([[NSData dataWithContentsOfURL:sentinel] isEqual:contents]); + EXPECT_EQ(fake.loader->evictions.load(), 1); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_NE(fake.loader.models[damaged.bookmark], nil); + EXPECT_EQ(fake.loader.models[healthy.bookmark], nil); + ::testing::internal::CaptureStderr(); + const auto repeated = clear(); + const std::string repeated_logs = + ::testing::internal::GetCapturedStderr(); + EXPECT_EQ(repeated, Error::Ok); + EXPECT_TRUE(repeated_logs.empty()); + EXPECT_EQ(fake.loader->evictions.load(), 1); + EXPECT_TRUE(pte.check_released()); + } + } + } +} + +TEST_F(CoreAICacheTest, CorruptBookmarkCleanupDoesNotRequireSdkAndCanRetry) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry damaged; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, damaged)); + NSURL* path = test_bookmark_url(root.url, damaged.key); + ASSERT_TRUE([NSData.data writeToURL:path options:0 error:nil]); + bridge.state().available = false; + { + StorageFaultScope fault(FaultPoint::Remove); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_TRUE(cache_exists(path)); + EXPECT_TRUE(cache_exists(damaged.stage)); + } + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + cache_entry_removed(root.url, damaged); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_NE(fake.loader.models[damaged.bookmark], nil); + } +} + +TEST_F(CoreAICacheTest, NilLoaderFactoryPreservesEntriesAndAllowsRetry) { + @autoreleasepool { + ASSERT_TRUE(bridge.state().available); + ASSERT_FALSE(bridge.state().fail_loader_factory); + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry entry; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, entry)); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(root.url, snapshot)); + bridge.state().fail_loader_factory = true; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Internal); + bridge.state().fail_loader_factory = false; + EXPECT_TRUE(snapshot_matches(root.url, snapshot)); + EXPECT_NE(fake.loader.models[entry.bookmark], nil); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_EQ(fake.loader->restores.load(), 0); + EXPECT_EQ(fake.loader->specializations.load(), 0); + EXPECT_EQ(fake.loader->bindings.load(), 0); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + cache_entry_removed(root.url, entry); + } +} + +TEST_F(CoreAICacheTest, UnavailableSdkPreservesTrackedEntriesAndAllowsRetry) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry entry; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, entry)); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(root.url, snapshot)); + bridge.state().available = false; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::NotSupported); + bridge.state().available = true; + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_TRUE(snapshot_matches(root.url, snapshot)); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + cache_entry_removed(root.url, entry); + } +} + +TEST_F(CoreAICacheTest, SourceRemovalFailuresKeepBookmarkForRetry) { + @autoreleasepool { + const struct { + int match_index; + bool bookmark_removal; + const char* name; + } rows[] = { + {2, false, "staged-tree removal"}, {6, true, "bookmark removal"}}; + for (const auto& row : rows) { + SCOPED_TRACE(row.name); + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry entry; + ASSERT_TRUE( + seed_cache(root.url, aot_manifest_dict(), fake.loader, entry)); + { + StorageFaultScope fault(FaultPoint::Remove); + fault.match_index = row.match_index; + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + } + drain_callbacks(); + auto saved = saved_bookmark(root.url, entry.key); + ASSERT_TRUE(saved.ok()); + EXPECT_TRUE([saved.get() isEqual:entry.bookmark]); + EXPECT_EQ(cache_exists(entry.stage), !row.bookmark_removal); + if (fake.loader.models[entry.bookmark] == nil) { + fake.loader.evictError = [NSError errorWithDomain:NSPOSIXErrorDomain + code:ENOENT + userInfo:nil]; + } + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::Ok); + cache_entry_removed(root.url, entry); + } + } +} + +TEST_F(CoreAICacheTest, IndependentKeysContinueInSortedOrderAfterFailures) { + @autoreleasepool { + TestDirectory root; + BookmarkLoaderScope fake; + CacheEntry first, second, third; + ASSERT_TRUE(seed_cache(root.url, manifest_dict(), fake.loader, first)); + ASSERT_TRUE(seed_cache(root.url, cache_manifest('2'), fake.loader, second)); + ASSERT_TRUE(seed_cache(root.url, cache_manifest('3'), fake.loader, third)); + NSArray* ordered = [@[ first.key, second.key, third.key ] + sortedArrayUsingSelector:@selector(compare:)]; + NSDictionary* bookmarks = @{ + first.key : first.bookmark, + second.key : second.bookmark, + third.key : third.bookmark + }; + NSMutableArray* evicted = [NSMutableArray array]; + BookmarkFakeLoader* loader = fake.loader; + fake.loader.onEvict = ^(NSData* bookmark) { + [evicted addObject:bookmark]; + loader.evictError = [bookmark isEqual:bookmarks[ordered[1]]] + ? [NSError errorWithDomain:NSPOSIXErrorDomain code:EBUSY userInfo:nil] + : nil; + }; + StorageFaultScope fault(FaultPoint::BeforeEvict); + EXPECT_EQ(clear_cache(root.url.path.UTF8String), Error::AccessFailed); + drain_callbacks(); + EXPECT_EQ(fault.hits, 1); + ASSERT_EQ(evicted.count, 2u); + EXPECT_TRUE([evicted[0] isEqual:bookmarks[ordered[1]]]); + EXPECT_TRUE([evicted[1] isEqual:bookmarks[ordered[2]]]); + EXPECT_TRUE(cache_exists(test_bookmark_url(root.url, ordered[0]))); + EXPECT_TRUE(cache_exists(test_bookmark_url(root.url, ordered[1]))); + EXPECT_FALSE(cache_exists(test_bookmark_url(root.url, ordered[2]))); + } +} + +} // namespace +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h new file mode 100644 index 00000000000..7d9a9b64fa1 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.h @@ -0,0 +1,123 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#pragma once + +#include +#include +#include +#include + +#import "coreai_fake_loader.h" +#include "coreai_source_fixture.h" + +namespace executorch::backends::coreai::testing { + +class DelegateFixture final { + public: + DelegateFixture( + const runtime::BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest = manifest_dict()); + ~DelegateFixture(); + DelegateFixture(const DelegateFixture&) = delete; + DelegateFixture& operator=(const DelegateFixture&) = delete; + + runtime::Error init(NSString* root = nil); + runtime::Error init_with_options( + runtime::Span options, + runtime::MemoryAllocator* allocator = nullptr); + void reset(); + runtime::Error execute(runtime::Span args); + + TestData data; + TestDirectory storage; + int freed = 0; + NSData* json; + runtime::FreeableBuffer processed; + runtime::DelegateHandle* handle = nullptr; + + private: + const runtime::BackendInterface* backend_; + ScopedFakeBridgeState& bridge_; +}; + +struct FloatTensors final { + float input[2] = {3, 7}; + float output[2] = {-1, -1}; + executorch::aten::TensorImpl::SizesType input_size[1] = {2}; + executorch::aten::TensorImpl::SizesType output_size[1] = {2}; + executorch::aten::TensorImpl::DimOrderType order[1] = {0}; + executorch::aten::TensorImpl::StridesType input_stride[1] = {1}; + executorch::aten::TensorImpl::StridesType output_stride[1] = {1}; + executorch::aten::TensorImpl input_impl{ + executorch::aten::ScalarType::Float, + 1, + input_size, + input, + order, + input_stride}; + executorch::aten::TensorImpl output_impl{ + executorch::aten::ScalarType::Float, + 1, + output_size, + output, + order, + output_stride, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + runtime::EValue in{executorch::aten::Tensor(&input_impl)}; + runtime::EValue out{executorch::aten::Tensor(&output_impl)}; + runtime::EValue* args[2] = {&in, &out}; +}; + +NSMutableDictionary* multiple_io_manifest(); + +template +struct RangeTensors final { + static constexpr executorch::aten::ScalarType dtype = std::is_same_v + ? executorch::aten::ScalarType::Float + : executorch::aten::ScalarType::Half; + T input[12] = {}; + T output[12] = {}; + executorch::aten::TensorImpl::SizesType input_shape[2] = {4, 2}; + executorch::aten::TensorImpl::SizesType output_shape[2] = {4, 2}; + executorch::aten::TensorImpl::DimOrderType order[2] = {0, 1}; + executorch::aten::TensorImpl::StridesType input_strides[2] = {2, 1}; + executorch::aten::TensorImpl::StridesType output_strides[2] = {2, 1}; + executorch::aten::TensorImpl input_impl{ + dtype, + 2, + input_shape, + input + 2, + order, + input_strides, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + executorch::aten::TensorImpl output_impl{ + dtype, + 2, + output_shape, + output + 2, + order, + output_strides, + runtime::TensorShapeDynamism::DYNAMIC_BOUND}; + runtime::EValue in{executorch::aten::Tensor(&input_impl)}; + runtime::EValue out{executorch::aten::Tensor(&output_impl)}; + runtime::EValue* args[2] = {&in, &out}; + + RangeTensors() { + for (size_t i = 0; i < 12; ++i) { + input[i] = static_cast(i + 1); + } + fill_output(); + } + + void fill_output() { + std::fill_n(output, 12, static_cast(99)); + } +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm new file mode 100644 index 00000000000..3d450ee4d24 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm @@ -0,0 +1,87 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +#import "coreai_delegate_fixture.h" +#include "coreai_test_utils.h" + +namespace executorch::backends::coreai::testing { +using namespace executorch::runtime; + +DelegateFixture::DelegateFixture( + const BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest) + : json(encode(manifest)), + processed( + json.bytes, + json.length, + [](void* context, void*, size_t) { ++*static_cast(context); }, + &freed), + backend_(backend), + bridge_(bridge) {} + +DelegateFixture::~DelegateFixture() { + reset(); + EXPECT_TRUE(bridge_.wait_for_callbacks(test_deadline())); + processed.Free(); + if (json != nil) { + EXPECT_EQ(freed, 1); + } + EXPECT_EQ(data.requests.load(), data.releases.load()); +} + +Error DelegateFixture::init(NSString* root) { + if (storage.url == nil || json == nil) { + return Error::Internal; + } + BackendOptions<1> options; + const Error error = options.set_option( + "coreai_cache_dir", (root ?: storage.url.path).UTF8String); + if (error != Error::Ok) { + return error; + } + return init_with_options({options.view().data(), options.view().size()}); +} + +Error DelegateFixture::init_with_options( + Span options, + MemoryAllocator* allocator) { + reset(); + BackendInitContext context(allocator, nullptr, "forward", &data, options); + auto result = backend_->init(context, &processed, {}); + if (result.ok()) { + handle = result.get(); + } + auto drained = bridge_.wait_for_callbacks(test_deadline()); + if (!drained) { + ADD_FAILURE() << drained.message(); + reset(); + return Error::Internal; + } + return result.ok() ? Error::Ok : result.error(); +} + +void DelegateFixture::reset() { + if (handle != nullptr) { + backend_->destroy(handle); + } + handle = nullptr; +} + +Error DelegateFixture::execute(Span args) { + BackendExecutionContext context; + return backend_->execute(context, handle, args); +} + +NSMutableDictionary* multiple_io_manifest() { + NSMutableDictionary* manifest = manifest_dict(); + manifest[@"input_names"] = @[ @"input_1", @"input_0" ]; + manifest[@"output_names"] = @[ @"output_1", @"output_0" ]; + return manifest; +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm new file mode 100644 index 00000000000..1321bed50f0 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm @@ -0,0 +1,912 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "coreai_cache.h" +#include "coreai_cache_entry_fixture.h" +#import "coreai_delegate_fixture.h" +#include "coreai_test_utils.h" + +namespace executorch::backends::coreai::testing { +namespace { +using namespace executorch::runtime; +using executorch::aten::ScalarType; +using executorch::aten::Tensor; +using executorch::aten::TensorImpl; + +class CoreAIDelegateTest : public ::testing::Test { + protected: + void SetUp() override { + backend = get_backend_class("CoreAIBackend"); + ASSERT_NE(backend, nullptr); + } + ScopedFakeBridgeState bridge; + const BackendInterface* backend = nullptr; +}; + +// All preflight rows must fail before reading payloads or creating storage. +void expect_preflight_rejection( + const BackendInterface* backend, + ScopedFakeBridgeState& bridge, + NSDictionary* manifest, + Error expected) { + DelegateFixture fixture(backend, bridge, manifest); + ASSERT_NE(fixture.storage.url, nil); + NSURL* absent = + [fixture.storage.url URLByAppendingPathComponent:@"uncreated/models"]; + NSString* previous_bundle = bridge.state().last_bundle; + EXPECT_EQ(fixture.init(absent.path), expected); + EXPECT_EQ(fixture.data.attempts.load(), 0); + EXPECT_EQ(fixture.data.metadata_requests.load(), 0); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(bridge.state().last_bundle, previous_bundle); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:absent.path]); + NSError* error = nil; + NSArray* children = [NSFileManager.defaultManager + contentsOfDirectoryAtPath:fixture.storage.url.path + error:&error]; + EXPECT_NE(children, nil); + EXPECT_EQ(error, nil); + EXPECT_EQ(children.count, 0u); +} + +TEST_F(CoreAIDelegateTest, AvailabilityRejectsWithoutTakingManifestOwnership) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + bridge.state().available = false; + EXPECT_FALSE(backend->is_available()); + EXPECT_EQ(fixture.init(), Error::NotSupported); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(fixture.data.attempts.load(), 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + bridge.state().available = true; + EXPECT_TRUE(backend->is_available()); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(fixture.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + } +} + +TEST_F( + CoreAIDelegateTest, + LoaderFactoryFailureLeavesManifestAndStorageUntouched) { + @autoreleasepool { + bridge.state().fail_loader_factory = true; + expect_preflight_rejection( + backend, bridge, manifest_dict(), Error::Internal); + EXPECT_EQ(bridge.state().loaders.load(), 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + } +} + +TEST_F( + CoreAIDelegateTest, + PreflightRejectsBeforePayloadReadsOrStorageCreation) { + @autoreleasepool { + auto missing_function = aot_manifest_dict(); + [missing_function removeObjectForKey:@"function"]; + auto numeric_platform = aot_manifest_dict(); + numeric_platform[@"platform"] = @1; + auto escaping_arch = aot_manifest_dict(); + NSMutableDictionary* archs = [escaping_arch[@"archs"] mutableCopy]; + archs[@"arch_a"] = @"../model.arch_a.aimodelc"; + escaping_arch[@"archs"] = archs; + auto escaping_file = aot_manifest_dict(); + escaping_file[@"files"] = @{@"model.arch_a.aimodelc/../escape" : @1}; + auto ios = aot_manifest_dict(); + ios[@"platform"] = @"iOS"; + const struct { + const char* name; + NSMutableDictionary* manifest; + NSString* device_architecture; + Error expected; + } rows[] = { + {"missing function", + missing_function, + @"arch_b", + Error::InvalidProgram}, + {"non-string platform", + numeric_platform, + @"arch_b", + Error::InvalidProgram}, + {"escaping architecture path", + escaping_arch, + @"arch_b", + Error::InvalidProgram}, + {"escaping file entry", + escaping_file, + @"arch_b", + Error::InvalidProgram}, + {"incompatible platform", + ios, + @"arch_b", + Error::DelegateInvalidCompatibility}, + {"missing device architecture", + aot_manifest_dict(), + nil, + Error::DelegateInvalidCompatibility}, + }; + for (const auto& row : rows) { + SCOPED_TRACE(row.name); + bridge.state().device_architecture = row.device_architecture; + ASSERT_NO_FATAL_FAILURE(expect_preflight_rejection( + backend, bridge, row.manifest, row.expected)); + } + bridge.state().device_architecture = @"arch_b"; + } +} + +TEST_F(CoreAIDelegateTest, PreflightRejectsFutureDeploymentForSourceAndAot) { + @autoreleasepool { + for (bool aot : {false, true}) { + SCOPED_TRACE(aot ? "AOT" : "source"); + auto dict = aot ? aot_manifest_dict() : manifest_dict(); + dict[@"min_deployment_version"] = @"999999.0"; + expect_preflight_rejection( + backend, bridge, dict, Error::DelegateInvalidCompatibility); + } + } +} + +TEST_F(CoreAIDelegateTest, AotInitPassesOnlySelectedArchitectureBundleToSdk) { + @autoreleasepool { + NSString* arch = @"arch_a"; + DelegateFixture fixture(backend, bridge, aot_manifest_dict()); + aot_data(fixture.data, arch); + bridge.state().device_architecture = arch; + auto parsed = parse_manifest(fixture.json); + ASSERT_TRUE(parsed.ok()); + auto selected = select_bundle(parsed.get(), arch, @"macOS"); + ASSERT_TRUE(selected.ok()); + ASSERT_EQ(fixture.init(), Error::Ok); + auto key = cache_variant_key(selected.get(), @"macOS", arch, @"default"); + ASSERT_TRUE(key.ok()); + NSString* expected = [fixture.storage.url.path + stringByAppendingPathComponent: + [NSString stringWithFormat:@"sources/%@/%@", + key.get(), + selected->path.lastPathComponent]]; + EXPECT_TRUE([bridge.state().last_bundle isEqualToString:expected]); + fixture.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(fixture.data.attempts.load(), 2); + EXPECT_EQ(fixture.data.releases.load(), fixture.data.requests.load()); + } +} + +TEST_F( + CoreAIDelegateTest, + InitBindingFailurePreservesManifestAndAllowsWarmRetry) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + bridge.state().fail_load = true; + EXPECT_EQ(fixture.init(), Error::InvalidProgram); + EXPECT_EQ(fixture.freed, 0); + EXPECT_EQ(bridge.state().sessions.load(), 0); + ASSERT_NE(bridge.state().last_bundle, nil); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:bridge.state().last_bundle]); + const int attempts = fixture.data.attempts.load(); + bridge.state().fail_load = false; + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(fixture.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + EXPECT_EQ(fixture.data.attempts.load(), attempts); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:bridge.state().last_bundle]); + EXPECT_TRUE([bridge.state().last_bundle + hasPrefix:[fixture.storage.url.URLByResolvingSymlinksInPath.path + stringByAppendingString:@"/"]]); + } + EXPECT_EQ(bridge.state().sessions.load(), 0); +} + +TEST_F(CoreAIDelegateTest, RetainedBindingCompletionDoesNotPinSession) { + @autoreleasepool { + bridge.state().retain_binding_completion = true; + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(bridge.state().sessions.load(), 1); + fixture.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, OrderedIoBindingUsesManifestFunctionAndNames) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + EXPECT_EQ(bridge.state().binding_mismatches.load(), 0); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.output[0], 3); + EXPECT_EQ(tensors.output[1], 7); + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input); + EXPECT_EQ(bridge.state().executions.load(), 1); + EXPECT_EQ(bridge.state().output_copies.load(), 1); + EXPECT_EQ(bridge.state().last_output_destination.load(), tensors.output); + } +} + +TEST_F(CoreAIDelegateTest, AdjacentInputAndOutputStorageCopiesDirectly) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + float storage[] = {3, 7, -1, -1}; + tensors.input_impl.set_data(storage); + tensors.output_impl.set_data(storage + 2); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(storage[2], 3); + EXPECT_EQ(storage[3], 7); + EXPECT_EQ(bridge.state().output_copies.load(), 1); + EXPECT_EQ(bridge.state().last_output_destination.load(), storage + 2); + } +} + +TEST_F(CoreAIDelegateTest, PartialInputOutputOverlapStagesBeforeWriting) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + for (bool destination_first : {false, true}) { + SCOPED_TRACE(destination_first); + FloatTensors tensors; + float storage[] = {3, 7, 11}; + float* source = storage + (destination_first ? 1 : 0); + float* destination = storage + (destination_first ? 0 : 1); + const float expected[] = {source[0], source[1]}; + tensors.input_impl.set_data(source); + tensors.output_impl.set_data(destination); + const int copies = bridge.state().output_copies.load(); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(destination[0], expected[0]); + EXPECT_EQ(destination[1], expected[1]); + EXPECT_EQ(bridge.state().output_copies.load(), copies + 1); + EXPECT_NE(bridge.state().last_output_destination.load(), destination); + } + } +} + +TEST_F(CoreAIDelegateTest, CrossOutputInputAliasesPreserveEverySource) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + first.output_impl.set_data(second.input); + second.output_impl.set_data(first.input); + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(first.input[0], 11); + EXPECT_EQ(first.input[1], 13); + EXPECT_EQ(second.input[0], 3); + EXPECT_EQ(second.input[1], 7); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_NE(bridge.state().last_output_destination.load(), first.input); + } +} + +TEST_F(CoreAIDelegateTest, OverlappingOutputDestinationsPreserveWriteOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + float storage[] = {-1, -1, -1}; + first.output_impl.set_data(storage); + second.output_impl.set_data(storage + 1); + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(storage[0], 3); + EXPECT_EQ(storage[1], 11); + EXPECT_EQ(storage[2], 13); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_NE(bridge.state().last_output_destination.load(), storage + 1); + } +} + +TEST_F(CoreAIDelegateTest, MultipleInputsAndOutputsPreserveManifestOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(first.output[0], 3); + EXPECT_EQ(first.output[1], 7); + EXPECT_EQ(second.output[0], 11); + EXPECT_EQ(second.output[1], 13); + EXPECT_EQ(bridge.state().binding_mismatches.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, LaterOutputResizeFailureLeavesResultBytesUntouched) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + float larger[] = {11, 13, 17}; + TensorImpl::SizesType one[] = {1}, three[] = {3}; + TensorImpl first_input( + ScalarType::Float, + 1, + one, + first.input, + first.order, + first.input_stride); + TensorImpl second_input( + ScalarType::Float, 1, three, larger, second.order, second.input_stride); + EValue first_in{Tensor(&first_input)}, second_in{Tensor(&second_input)}; + EValue* args[] = {&first_in, &second_in, &first.out, &second.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + EXPECT_EQ(first.out.toTensor().size(0), 1); + EXPECT_EQ(second.out.toTensor().size(0), 2); + for (size_t i = 0; i < 2; ++i) { + EXPECT_EQ(first.output[i], -1); + EXPECT_EQ(second.output[i], -1); + } + } +} + +TEST_F(CoreAIDelegateTest, ConflictingSharedOutputShapesDoNotCopyBytes) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + const struct { + const char* name; + TensorImpl::SizesType shape[2]; + } cases[] = { + {"different byte counts", {2, 2}}, + {"equal byte counts", {2, 1}}, + }; + for (const auto& row : cases) { + SCOPED_TRACE(row.name); + for (bool separate_value : {false, true}) { + SCOPED_TRACE(separate_value); + RangeTensors first, second; + const TensorImpl::SizesType first_shape[] = {1, 2}; + ASSERT_EQ(resize_tensor(first.in.toTensor(), first_shape), Error::Ok); + ASSERT_EQ(resize_tensor(second.in.toTensor(), row.shape), Error::Ok); + EValue alias{Tensor(&first.output_impl)}; + EValue* args[] = { + &first.in, + &second.in, + &first.out, + separate_value ? &alias : &first.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + for (float value : first.output) { + EXPECT_EQ(value, 99); + } + } + } + } +} + +TEST_F(CoreAIDelegateTest, MatchingSharedOutputShapesPreserveCopyOrder) { + @autoreleasepool { + bridge.state().multiple_io = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + second.input[0] = 11; + second.input[1] = 13; + EValue alias{Tensor(&first.output_impl)}; + EValue* args[] = {&first.in, &second.in, &first.out, &alias}; + ASSERT_EQ(fixture.execute({args, 4}), Error::Ok); + EXPECT_EQ(bridge.state().output_copies.load(), 2); + EXPECT_EQ(first.out.toTensor().size(0), 2); + EXPECT_EQ(first.output[0], 11); + EXPECT_EQ(first.output[1], 13); + } +} + +TEST_F(CoreAIDelegateTest, WrongOutputCountDoesNotWriteCallerStorage) { + @autoreleasepool { + bridge.state().multiple_io = true; + bridge.state().bad_output = true; + DelegateFixture fixture(backend, bridge, multiple_io_manifest()); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors first, second; + EValue* args[] = {&first.in, &second.in, &first.out, &second.out}; + EXPECT_EQ(fixture.execute({args, 4}), Error::Internal); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + EXPECT_EQ(first.out.toTensor().size(0), 2); + EXPECT_EQ(second.out.toTensor().size(0), 2); + for (size_t i = 0; i < 2; ++i) { + EXPECT_EQ(first.output[i], -1); + EXPECT_EQ(second.output[i], -1); + } + } +} + +TEST_F(CoreAIDelegateTest, ArgumentValidationRejectsBeforeSdkExecution) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + EXPECT_EQ(fixture.execute({tensors.args, 1}), Error::InvalidArgument); + EValue scalar(int64_t(3)); + EValue* args[] = {&scalar, &tensors.out}; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(bridge.state().executions.load(), 0); + } +} + +// No assertion may leave a caller thread blocked behind its input-read gate. +class BorrowedInputWorker final { + public: + BorrowedInputWorker( + DelegateFixture& fixture, + FloatTensors& tensors, + FakeBridgeState& state) + : fixture_(fixture), tensors_(tensors), state_(state) { + state_.input_ready = dispatch_semaphore_create(0); + state_.allow_input_read = dispatch_semaphore_create(0); + } + ~BorrowedInputWorker() { + release_and_join(); + state_.input_ready = nil; + state_.allow_input_read = nil; + } + BorrowedInputWorker(const BorrowedInputWorker&) = delete; + BorrowedInputWorker& operator=(const BorrowedInputWorker&) = delete; + int start() { + const int error = pthread_create(&thread_, nullptr, run, this); + started_ = error == 0; + return error; + } + long wait_ready() { + return dispatch_semaphore_wait(state_.input_ready, test_deadline(5)); + } + long wait_finished(int64_t timeout) { + return dispatch_semaphore_wait( + finished_, dispatch_time(DISPATCH_TIME_NOW, timeout)); + } + void release() { + dispatch_semaphore_signal(state_.allow_input_read); + } + void release_and_join() { + release(); + if (started_) { + const long returned = dispatch_semaphore_wait(returned_, test_deadline()); + if (returned != 0) { + ADD_FAILURE() + << "Delegate worker did not return after releasing its input gate"; + // A stuck worker still borrows this stack; unwinding would be unsafe. + std::_Exit(EXIT_FAILURE); + } + EXPECT_EQ(pthread_join(thread_, nullptr), 0); + started_ = false; + } + } + Error status = Error::Internal; + + private: + static void* run(void* context) { + auto& worker = *static_cast(context); + @autoreleasepool { + worker.status = worker.fixture_.execute({worker.tensors_.args, 2}); + } + dispatch_semaphore_signal(worker.finished_); + dispatch_semaphore_signal(worker.returned_); + return nullptr; + } + DelegateFixture& fixture_; + FloatTensors& tensors_; + FakeBridgeState& state_; + dispatch_semaphore_t finished_ = dispatch_semaphore_create(0); + dispatch_semaphore_t returned_ = dispatch_semaphore_create(0); + pthread_t thread_{}; + bool started_ = false; +}; + +TEST_F(CoreAIDelegateTest, BorrowedInputStorageStaysLiveUntilSuccessOrError) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + for (bool failure : {false, true}) { + SCOPED_TRACE(failure ? "runtime error" : "success"); + bridge.state().fail_execute = failure; + bridge.state().last_input_bytes = nullptr; + const auto first_byte = + reinterpret_cast(tensors.input)[0]; + bridge.state().last_input_first_byte = + static_cast(first_byte ^ 0xff); + const int executions = bridge.state().executions.load(); + BorrowedInputWorker worker(fixture, tensors, bridge.state()); + const int started = worker.start(); + if (started != 0) { + worker.release_and_join(); + FAIL() << "pthread_create failed: " << started; + } + const long ready = worker.wait_ready(); + const void* observed = bridge.state().last_input_bytes.load(); + const long premature = worker.wait_finished(50 * NSEC_PER_MSEC); + worker.release(); + const long finished = worker.wait_finished(5 * NSEC_PER_SEC); + worker.release_and_join(); + ASSERT_TRUE(bridge.wait_for_callbacks(test_deadline())); + EXPECT_EQ(ready, 0); + EXPECT_EQ(observed, tensors.input); + EXPECT_NE(premature, 0); + EXPECT_EQ(finished, 0); + EXPECT_EQ(worker.status, failure ? Error::Internal : Error::Ok); + EXPECT_EQ(bridge.state().executions.load(), executions + 1); + EXPECT_EQ( + bridge.state().last_input_first_byte.load(), + reinterpret_cast(tensors.input)[0]); + } + } +} + +TEST_F(CoreAIDelegateTest, RetainedExecutionCompletionReleasesResults) { + @autoreleasepool { + bridge.state().retain_execution_completion = true; + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().output_owners.load(), 0); + bridge.state().fail_execute = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + EXPECT_EQ(bridge.state().output_owners.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, ExecuteMapsRuntimeErrors) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + bridge.state().fail_execute = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + } +} + +TEST_F(CoreAIDelegateTest, MalformedOutputDoesNotWriteCallerStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + bridge.state().bad_output = true; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::Internal); + EXPECT_EQ(tensors.output[0], -1); + EXPECT_EQ(tensors.output[1], -1); + } +} + +TEST_F(CoreAIDelegateTest, NoncontiguousInputIsNotSupported) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.input_stride[0] = 2; + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::NotSupported); + EXPECT_EQ(bridge.state().executions.load(), 0); + } +} + +TEST_F(CoreAIDelegateTest, NonemptyOutputRequiresStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.output_impl.set_data(nullptr); + EXPECT_EQ(fixture.execute({tensors.args, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, OutputResizesDownAndBackWithinCapacity) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType one[] = {1}; + TensorImpl smaller( + ScalarType::Float, + 1, + one, + tensors.input, + tensors.order, + tensors.input_stride); + EValue small{Tensor(&smaller)}; + EValue* args[] = {&small, &tensors.out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(tensors.out.toTensor().size(0), 1); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.out.toTensor().size(0), 2); + } +} + +TEST_F(CoreAIDelegateTest, OutputResizeFailurePreservesStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType capacity[] = {1}; + TensorImpl output( + ScalarType::Float, + 1, + capacity, + tensors.output, + tensors.order, + tensors.output_stride, + TensorShapeDynamism::DYNAMIC_BOUND); + EValue too_small{Tensor(&output)}; + EValue* args[] = {&tensors.in, &too_small}; + tensors.output[0] = -2; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(tensors.output[0], -2); + } +} + +TEST_F(CoreAIDelegateTest, StaticOutputRejectsShapeChangeWithinCapacity) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl::SizesType shape[] = {1}; + TensorImpl input( + ScalarType::Float, + 1, + shape, + tensors.input, + tensors.order, + tensors.input_stride); + TensorImpl output( + ScalarType::Float, + 1, + tensors.output_size, + tensors.output, + tensors.order, + tensors.output_stride, + TensorShapeDynamism::STATIC); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + EXPECT_EQ(fixture.execute({args, 2}), Error::InvalidArgument); + EXPECT_EQ(out.toTensor().size(0), 2); + EXPECT_EQ(tensors.output[0], -1); + EXPECT_EQ(tensors.output[1], -1); + } +} + +TEST_F(CoreAIDelegateTest, HalfDtypePreservesBitsAndRejectsMixedOutputDtype) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + uint16_t input[] = {0x3c00, 0x4000}, output[] = {0, 0}; + TensorImpl input_impl( + ScalarType::Half, + 1, + tensors.input_size, + input, + tensors.order, + tensors.input_stride); + TensorImpl output_impl( + ScalarType::Half, + 1, + tensors.output_size, + output, + tensors.order, + tensors.output_stride); + EValue in{Tensor(&input_impl)}, out{Tensor(&output_impl)}; + EValue* args[] = {&in, &out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(output[0], input[0]); + EXPECT_EQ(output[1], input[1]); + EXPECT_EQ(bridge.state().last_input_bytes.load(), input); + EValue* mixed[] = {&in, &tensors.out}; + EXPECT_EQ(fixture.execute({mixed, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, ScalarTensorsExecuteAndRejectOutputRankMismatch) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + TensorImpl input(ScalarType::Float, 0, nullptr, tensors.input); + TensorImpl output(ScalarType::Float, 0, nullptr, tensors.output); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(tensors.output[0], tensors.input[0]); + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input); + EValue* wrong_rank[] = {&tensors.in, &out}; + EXPECT_EQ(fixture.execute({wrong_rank, 2}), Error::InvalidArgument); + } +} + +TEST_F(CoreAIDelegateTest, EmptyVectorAndMatrixBorrowNullStorage) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + for (int rank : {1, 2}) { + SCOPED_TRACE(rank); + TensorImpl::SizesType shape[] = {2, 0}; + TensorImpl::DimOrderType order[] = {0, 1}; + TensorImpl::StridesType strides[] = {1, 1}; + auto* sizes = rank == 1 ? shape + 1 : shape; + TensorImpl input(ScalarType::Float, rank, sizes, nullptr, order, strides); + TensorImpl output( + ScalarType::Float, rank, sizes, nullptr, order, strides); + EValue in{Tensor(&input)}, out{Tensor(&output)}; + EValue* args[] = {&in, &out}; + const int executions = bridge.state().executions.load(); + bridge.state().last_input_bytes = &input; + bridge.state().last_input_byte_count = 1; + ASSERT_EQ(fixture.execute({args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().executions.load(), executions + 1); + EXPECT_EQ(bridge.state().last_input_bytes.load(), nullptr); + EXPECT_EQ(bridge.state().last_input_byte_count.load(), 0); + EXPECT_EQ(bridge.state().output_copies.load(), 0); + } + } +} + +TEST_F(CoreAIDelegateTest, BorrowedStorageMayAliasOutputAfterExecution) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + tensors.output_impl.set_data(tensors.input); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(tensors.input[0], 3); + EXPECT_EQ(tensors.input[1], 7); + } +} + +TEST_F(CoreAIDelegateTest, BorrowedStorageUsesCurrentInputPointer) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + FloatTensors tensors; + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + float next_input[] = {11, 13}; + tensors.input_impl.set_data(next_input); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + EXPECT_EQ(bridge.state().last_input_bytes.load(), next_input); + EXPECT_EQ(tensors.output[0], 11); + EXPECT_EQ(tensors.output[1], 13); + } +} + +TEST_F(CoreAIDelegateTest, DynamicShapesBorrowCurrentSubrange) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + ASSERT_EQ(fixture.init(), Error::Ok); + RangeTensors tensors; + for (TensorImpl::SizesType rows : {3, 0}) { + SCOPED_TRACE(rows); + TensorImpl::SizesType shape[] = {rows, 2}; + ASSERT_EQ(resize_tensor(tensors.in.toTensor(), {shape, 2}), Error::Ok); + tensors.fill_output(); + ASSERT_EQ(fixture.execute({tensors.args, 2}), Error::Ok); + const size_t elements = static_cast(rows) * 2; + EXPECT_EQ(bridge.state().last_input_bytes.load(), tensors.input + 2); + EXPECT_EQ( + bridge.state().last_input_byte_count.load(), + elements * sizeof(float)); + EXPECT_EQ(tensors.out.toTensor().size(0), rows); + EXPECT_EQ(tensors.out.toTensor().const_data_ptr(), tensors.output + 2); + for (size_t i = 0; i < 12; ++i) { + SCOPED_TRACE(i); + EXPECT_EQ( + tensors.output[i], + i >= 2 && i < 2 + elements ? tensors.input[i] : 99.0f); + } + } + } +} + +TEST_F(CoreAIDelegateTest, StorageOptionsRejectWrongTypeAndRelativePaths) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + { + SCOPED_TRACE("integer cache directory"); + BackendOption wrong_type{"coreai_cache_dir", 1}; + EXPECT_EQ( + fixture.init_with_options({&wrong_type, 1}), Error::InvalidArgument); + } + { + SCOPED_TRACE("relative cache directory"); + BackendOptions<1> relative; + ASSERT_EQ(relative.set_option("coreai_cache_dir", "relative"), Error::Ok); + EXPECT_EQ( + fixture.init_with_options( + {relative.view().data(), relative.view().size()}), + Error::InvalidArgument); + } + EXPECT_EQ(fixture.freed, 0); + } +} + +TEST_F(CoreAIDelegateTest, ConfiguredSymlinkCacheDirectoryLoadsAndClears) { + @autoreleasepool { + DelegateFixture fixture(backend, bridge); + NSURL* target = + [fixture.storage.url URLByAppendingPathComponent:@"cache-target"]; + NSURL* configured = + [fixture.storage.url URLByAppendingPathComponent:@"cache-link"]; + ASSERT_EQ(mkdir(target.fileSystemRepresentation, 0777), 0); + ASSERT_EQ( + symlink( + target.fileSystemRepresentation, + configured.fileSystemRepresentation), + 0); + ASSERT_EQ(fixture.init(configured.path), Error::Ok); + EXPECT_TRUE([bridge.state().last_bundle containsString:@"/cache-target/"]); + EXPECT_FALSE([bridge.state().last_bundle containsString:@"/cache-link/"]); + fixture.reset(); + EXPECT_EQ(clear_cache(configured.path.UTF8String), Error::Ok); + } +} + +TEST_F( + CoreAIDelegateTest, + LifetimeDestroyAndWarmReloadIsolateRootsWithoutSource) { + @autoreleasepool { + DelegateFixture first(backend, bridge); + ASSERT_EQ(first.init(), Error::Ok); + EXPECT_EQ(first.freed, 1); + EXPECT_EQ(bridge.state().sessions.load(), 1); + NSString* first_bundle = bridge.state().last_bundle; + ASSERT_NE(first_bundle, nil); + first.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:first_bundle]); + backend->destroy(nullptr); + + DelegateFixture reloaded(backend, bridge); + ASSERT_EQ(reloaded.init(first.storage.url.path), Error::Ok); + EXPECT_EQ(reloaded.data.attempts.load(), 0); + EXPECT_TRUE([bridge.state().last_bundle isEqualToString:first_bundle]); + DelegateFixture other(backend, bridge); + NSURL* nested = + [other.storage.url URLByAppendingPathComponent:@"nested/models"]; + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:nested.path]); + ASSERT_EQ(other.init(nested.path), Error::Ok); + EXPECT_EQ(bridge.state().sessions.load(), 2); + EXPECT_TRUE([bridge.state().last_bundle + hasPrefix:[other.storage.url.URLByResolvingSymlinksInPath.path + stringByAppendingString:@"/"]]); + EXPECT_FALSE([bridge.state().last_bundle isEqualToString:first_bundle]); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:first_bundle]); + other.reset(); + reloaded.reset(); + EXPECT_EQ(bridge.state().sessions.load(), 0); + } + EXPECT_EQ(bridge.state().sessions.load(), 0); + EXPECT_EQ(bridge.state().prepared_models.load(), 0); + EXPECT_EQ(bridge.state().loaders.load(), 0); +} + +} // namespace +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_eviction_test.mm b/backends/apple/coreai/runtime/test/coreai_eviction_test.mm new file mode 100644 index 00000000000..6a45af50a37 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_eviction_test.mm @@ -0,0 +1,470 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include "coreai_acquisition_fixture.h" +#include "coreai_cache_entry_fixture.h" +#include "coreai_fake_loader.h" +#include "coreai_fault_scope.h" +#include "coreai_load_coordinator.h" +#include "coreai_source_fixture.h" +#include "coreai_test_utils.h" + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +namespace { +NSError* bookmark_test_error(NSInteger code = EIO) { + return [NSError errorWithDomain:NSPOSIXErrorDomain code:code userInfo:nil]; +} + +// These cases never retain a delegate handle beyond initialization. +Error initialize_and_destroy( + NSDictionary* dict, + const NamedDataMap* data, + NSString* root) { + if (root == nil) + return Error::InvalidArgument; + BackendOptions<1> options; + auto error = options.set_option("coreai_cache_dir", root.UTF8String); + if (error != Error::Ok) + return error; + auto* backend = get_backend_class("CoreAIBackend"); + if (backend == nullptr) + return Error::Internal; + BackendInitContext context( + nullptr, + nullptr, + "forward", + data, + {options.view().data(), options.view().size()}); + NSData* json = encode(dict); + if (json == nil) + return Error::InvalidArgument; + FreeableBuffer processed(json.bytes, json.length, nullptr); + auto handle = backend->init(context, &processed, {}); + if (!handle.ok()) + return handle.error(); + backend->destroy(handle.get()); + return Error::Ok; +} + +class CoreAIEvictionTest : public ::testing::Test { + protected: + ScopedFakeBridgeState bridge; + void SetUp() override { + runtime_init(); + } +}; + +// Release the callback before waiting; never unwind storage borrowed by a hang. +struct ReleaseAndJoin final { + dispatch_semaphore_t gate; + dispatch_semaphore_t eviction_done; + dispatch_semaphore_t loading_done; + ScopedFakeBridgeState& bridge; + pthread_t eviction{}; + pthread_t loading{}; + bool eviction_started = false; + bool loading_started = false; + bool released = false; + bool joined = false; + long eviction_wait = -1; + long loading_wait = -1; + + void release() { + if (!released) { + dispatch_semaphore_signal(gate); + released = true; + } + } + void join() { + release(); + if (joined) + return; + const auto completion_deadline = test_deadline(); + if (eviction_started) + eviction_wait = + dispatch_semaphore_wait(eviction_done, completion_deadline); + if (loading_started) + loading_wait = dispatch_semaphore_wait(loading_done, completion_deadline); + if ((eviction_started && eviction_wait != 0) || + (loading_started && loading_wait != 0)) { + ADD_FAILURE() + << "Eviction/load workers did not return after releasing the gate"; + std::_Exit(EXIT_FAILURE); + } + const int eviction_join = + eviction_started ? pthread_join(eviction, nullptr) : 0; + const int loading_join = + loading_started ? pthread_join(loading, nullptr) : 0; + if (eviction_join != 0 || loading_join != 0) { + ADD_FAILURE() << "Cannot join eviction/load workers: " << eviction_join + << ", " << loading_join; + std::_Exit(EXIT_FAILURE); + } + auto drained = bridge.wait_for_callbacks(test_deadline()); + if (!drained) { + ADD_FAILURE() << drained.message(); + std::_Exit(EXIT_FAILURE); + } + joined = true; + } + ~ReleaseAndJoin() { + join(); + } +}; +} // namespace + +@interface PinBookmarkLoader : BookmarkFakeLoader +@property(nonatomic, weak) BookmarkFakeModel* temporary; +@property(nonatomic, copy) ETCoreAIAcquisitionCompletion retainedCompletion; +@end + +@implementation PinBookmarkLoader +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion { + ++specializations; + self.retainedCompletion = completion; + @autoreleasepool { + BookmarkFakeModel* model = [[BookmarkFakeModel alloc] init]; + model.owner = self; + model.bookmark = + [@"pinned-bookmark" dataUsingEncoding:NSUTF8StringEncoding]; + self.temporary = model; + completion(ETCoreAIAcquisitionStatusHit, model, nil); + model = nil; + } +} +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion { + ++restores; + self.retainedCompletion = completion; + @autoreleasepool { + BookmarkFakeModel* model = self.temporary; + completion( + model == nil ? ETCoreAIAcquisitionStatusMiss + : ETCoreAIAcquisitionStatusHit, + model, + nil); + model = nil; + } +} +- (void)evictModelWithBookmark:(NSData*)bookmark + completion:(void (^)(NSError*))completion { + ++evictions; + bool busy; + @autoreleasepool { + busy = self.temporary != nil; + } + completion(busy ? bookmark_test_error(EBUSY) : nil); +} +- (void)clearRetainedState { + self.retainedCompletion = nil; + [super clearRetainedState]; +} +@end + +namespace { +TEST_F(CoreAIEvictionTest, ConfirmedMissRecoveryEvictsOnlyCurrentBookmark) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + BookmarkLoaderScope fake; + ASSERT_NE(fake.loader, nil); + TestData data; + auto selected = selected_bundle(manifest_dict()); + ASSERT_TRUE(selected.ok()); + auto key_result = test_cache_key(selected.get()); + ASSERT_TRUE(key_result.ok()); + NSString* key = key_result.get(); + NSMutableDictionary* dict = manifest_dict(); + ASSERT_EQ(initialize_and_destroy(dict, &data, root.url.path), Error::Ok); + auto original_result = saved_bookmark(root.url, key); + ASSERT_TRUE(original_result.ok()); + NSData* original = original_result.get(); + ASSERT_NE(original, nil); + NSString* source_path = fake.loader.lastSpecializeURL; + ASSERT_NE(source_path, nil); + NSURL* source_url = [NSURL fileURLWithPath:source_path]; + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + + fake.loader.restoreMiss = YES; + fake.loader.specializeError = bookmark_test_error(); + EXPECT_NE(initialize_and_destroy(dict, &data, root.url.path), Error::Ok); + auto after_failure = saved_bookmark(root.url, key); + ASSERT_TRUE(after_failure.ok()); + EXPECT_TRUE([after_failure.get() isEqual:original]); + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + fake.loader.specializeError = nil; + ASSERT_EQ(initialize_and_destroy(dict, &data, root.url.path), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 3); + EXPECT_EQ(fake.loader->evictions.load(), 0); + EXPECT_TRUE([fake.loader.lastSpecializeURL isEqual:source_path]); + auto saved_result = saved_bookmark(root.url, key); + ASSERT_TRUE(saved_result.ok()); + NSData* saved = saved_result.get(); + ASSERT_NE(saved, nil); + EXPECT_FALSE([saved isEqual:original]); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + ASSERT_EQ( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + EXPECT_NE(fake.loader.models[original], nil); + EXPECT_EQ(fake.loader.models[saved], nil); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + fake.loader.restoreMiss = NO; + fake.loader.emptyBookmark = YES; + EXPECT_NE(initialize_and_destroy(dict, &data, root.url.path), Error::Ok); + auto empty = saved_bookmark(root.url, key); + ASSERT_TRUE(empty.ok()); + EXPECT_EQ(empty.get(), nil); + fake.loader.emptyBookmark = NO; + ASSERT_EQ(initialize_and_destroy(dict, &data, root.url.path), Error::Ok); + EXPECT_EQ(fake.loader->specializations.load(), 5); + EXPECT_EQ(data.requests.load(), data.releases.load()); + } +} + +TEST_F( + CoreAIEvictionTest, + BusyAndUnknownEvictionRetainBookmarkUntilConfirmedAbsent) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + BookmarkLoaderScope fake; + ASSERT_NE(fake.loader, nil); + TestData data; + auto selected = selected_bundle(manifest_dict()); + ASSERT_TRUE(selected.ok()); + auto key_result = test_cache_key(selected.get()); + ASSERT_TRUE(key_result.ok()); + NSString* key = key_result.get(); + EXPECT_EQ( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + EXPECT_EQ(fake.loader->evictions.load(), 0); + // Retain the source bundle after publication to exercise clearing's failure + // paths. + { + StorageFaultScope fault(FaultPoint::Remove); + ASSERT_EQ( + initialize_and_destroy(manifest_dict(), &data, root.url.path), + Error::Ok); + EXPECT_EQ(fault.hits, 1); + } + auto saved_result = saved_bookmark(root.url, key); + ASSERT_TRUE(saved_result.ok()); + NSData* saved = saved_result.get(); + ASSERT_NE(saved, nil); + NSURL* source_url = [[[root.url URLByAppendingPathComponent:@"sources"] + URLByAppendingPathComponent:key] + URLByAppendingPathComponent:selected->path.lastPathComponent]; + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + for (bool probe_error : {false, true}) { + SCOPED_TRACE( + probe_error ? "unknown restore failure" : "restore hit/busy"); + fake.loader.evictError = bookmark_test_error(EBUSY); + fake.loader.restoreError = probe_error ? bookmark_test_error() : nil; + EXPECT_NE( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + auto retained = saved_bookmark(root.url, key); + ASSERT_TRUE(retained.ok()); + EXPECT_TRUE([retained.get() isEqual:saved]); + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + } + fake.loader.restoreError = nil; + fake.loader.evictError = nil; + { + StorageFaultScope fault(FaultPoint::AfterEvict); + EXPECT_NE( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + EXPECT_EQ(fault.hits, 1); + } + auto retained = saved_bookmark(root.url, key); + ASSERT_TRUE(retained.ok()); + EXPECT_TRUE([retained.get() isEqual:saved]); + fake.loader.evictError = bookmark_test_error(ENOENT); + ASSERT_EQ( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + auto absent = saved_bookmark(root.url, key); + ASSERT_TRUE(absent.ok()); + EXPECT_EQ(absent.get(), nil); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + fake.loader.evictError = nil; + ASSERT_EQ( + initialize_and_destroy(manifest_dict(), &data, root.url.path), + Error::Ok); + { + StorageFaultScope fault(FaultPoint::Remove); + EXPECT_NE( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + EXPECT_EQ(fault.hits, 1); + auto before_remove = saved_bookmark(root.url, key); + ASSERT_TRUE(before_remove.ok()); + EXPECT_NE(before_remove.get(), nil); + } + ASSERT_EQ( + clear_test_cache_entry(root.url.path, key, fake.loader), Error::Ok); + auto removed = saved_bookmark(root.url, key); + ASSERT_TRUE(removed.ok()); + EXPECT_EQ(removed.get(), nil); + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + EXPECT_EQ(data.requests.load(), data.releases.load()); + } +} + +TEST_F( + CoreAIEvictionTest, + PreparedPinsBlockEvictionWithoutRetainedCompletionLeak) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + TestData data; + auto selected = selected_bundle(manifest_dict()); + ASSERT_TRUE(selected.ok()); + auto key_result = test_cache_key(selected.get()); + ASSERT_TRUE(key_result.ok()); + NSString* key = key_result.get(); + PinBookmarkLoader* loader = [[PinBookmarkLoader alloc] init]; + ASSERT_NE(loader, nil); + // Scope teardown clears retained completions even after fatal test + // failures. + bridge.state().bookmark_loader = loader; + SelectedHostBundle assets{selected.get(), @"macOS", @"arch_b"}; + @autoreleasepool { + auto pinned = acquire_test_model(assets, &data, root.url.path, loader); + ASSERT_TRUE(pinned.ok()); + EXPECT_NE(loader.temporary, nil); + EXPECT_NE(clear_test_cache_entry(root.url.path, key, loader), Error::Ok); + auto saved = saved_bookmark(root.url, key); + ASSERT_TRUE(saved.ok()); + EXPECT_NE(saved.get(), nil); + } + EXPECT_EQ(loader.temporary, nil); + EXPECT_NE(loader.retainedCompletion, nil); + ASSERT_EQ(clear_test_cache_entry(root.url.path, key, loader), Error::Ok); + auto removed = saved_bookmark(root.url, key); + ASSERT_TRUE(removed.ok()); + EXPECT_EQ(removed.get(), nil); + EXPECT_EQ(loader->evictions.load(), 2); + NSURL* source_url = [[[root.url URLByAppendingPathComponent:@"sources"] + URLByAppendingPathComponent:key] + URLByAppendingPathComponent:selected->path.lastPathComponent]; + EXPECT_FALSE( + [NSFileManager.defaultManager fileExistsAtPath:source_url.path]); + [loader clearRetainedState]; + EXPECT_EQ(data.requests.load(), data.releases.load()); + } +} + +TEST_F(CoreAIEvictionTest, LoadContendsOnActualKeyLockUntilEvictionFinishes) { + @autoreleasepool { + TestDirectory root; + ASSERT_NE(root.url, nil); + BookmarkLoaderScope fake; + ASSERT_NE(fake.loader, nil); + TestData data; + auto selected = selected_bundle(manifest_dict()); + ASSERT_TRUE(selected.ok()); + auto key_result = test_cache_key(selected.get()); + ASSERT_TRUE(key_result.ok()); + NSString* key = key_result.get(); + ASSERT_EQ( + initialize_and_destroy(manifest_dict(), &data, root.url.path), + Error::Ok); + dispatch_semaphore_t deleting = dispatch_semaphore_create(0); + dispatch_semaphore_t proceed = dispatch_semaphore_create(0); + dispatch_semaphore_t loading_observed = dispatch_semaphore_create(0); + dispatch_semaphore_t eviction_done = dispatch_semaphore_create(0); + dispatch_semaphore_t loading_done = dispatch_semaphore_create(0); + __block long gate_wait = -1; + fake.loader.onEvict = ^(NSData*) { + dispatch_semaphore_signal(deleting); + gate_wait = dispatch_semaphore_wait(proceed, test_deadline()); + }; + Error eviction_error = Error::Internal; + bool load_ok = false; + auto evict = [&] { + @autoreleasepool { + eviction_error = + clear_test_cache_entry(root.url.path, key, fake.loader); + } + dispatch_semaphore_signal(eviction_done); + }; + SelectedHostBundle assets{selected.get(), @"macOS", @"arch_b"}; + auto load = [&] { + @autoreleasepool { + auto acquired = + acquire_test_model(assets, &data, root.url.path, fake.loader); + load_ok = acquired.ok() && acquired.get() != nil; + } + dispatch_semaphore_signal(loading_observed); + dispatch_semaphore_signal(loading_done); + }; + ReleaseAndJoin cleanup{proceed, eviction_done, loading_done, bridge}; + const int eviction_start = pthread_create( + &cleanup.eviction, + nullptr, + [](void* context) -> void* { + (*static_cast(context))(); + return nullptr; + }, + &evict); + cleanup.eviction_started = eviction_start == 0; + const long entered = cleanup.eviction_started + ? dispatch_semaphore_wait(deleting, test_deadline()) + : -1; + int loading_start = -1; + long premature_completion = -1; + if (entered == 0) { + loading_start = pthread_create( + &cleanup.loading, + nullptr, + [](void* context) -> void* { + (*static_cast(context))(); + return nullptr; + }, + &load); + cleanup.loading_started = loading_start == 0; + if (cleanup.loading_started) { + premature_completion = dispatch_semaphore_wait( + loading_observed, + dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC)); + } + } + cleanup.join(); + fake.loader.onEvict = nil; + + EXPECT_EQ(eviction_start, 0); + EXPECT_EQ(loading_start, 0); + EXPECT_EQ(entered, 0); + EXPECT_NE(premature_completion, 0); + EXPECT_EQ(gate_wait, 0); + EXPECT_EQ(cleanup.eviction_wait, 0); + EXPECT_EQ(cleanup.loading_wait, 0); + EXPECT_EQ(eviction_error, Error::Ok); + EXPECT_TRUE(load_ok); + EXPECT_EQ(fake.loader->specializations.load(), 2); + auto saved = saved_bookmark(root.url, key); + ASSERT_TRUE(saved.ok()); + EXPECT_NE(saved.get(), nil); + EXPECT_EQ(data.requests.load(), data.releases.load()); + } +} +} // namespace diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.h b/backends/apple/coreai/runtime/test/coreai_fake_loader.h index cf8f70a6a6b..5fb287ed79a 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.h +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -20,11 +20,34 @@ namespace executorch::backends::coreai::testing { // Configure on the test thread before starting workers; join before changing // it. struct FakeBridgeState { + bool available = true; + bool fail_loader_factory = false; + NSString* device_architecture = @"arch_b"; id bookmark_loader = nil; + bool fail_load = false; + bool fail_execute = false; + bool bad_output = false; + bool multiple_io = false; + bool retain_binding_completion = false; + bool retain_execution_completion = false; + NSString* last_bundle = nil; + dispatch_semaphore_t input_ready = nil; + dispatch_semaphore_t allow_input_read = nil; + std::atomic architecture_queries{0}; + std::atomic executions{0}; + std::atomic output_copies{0}; + std::atomic output_owners{0}; + std::atomic last_output_destination{nullptr}; + std::atomic sessions{0}; std::atomic prepared_models{0}; std::atomic loaders{0}; + std::atomic last_input_bytes{nullptr}; + std::atomic last_input_byte_count{0}; + std::atomic last_input_first_byte{0}; // Worker-side contract failures are checked by the scope on the test thread. + std::atomic binding_mismatches{0}; std::atomic missing_bundles{0}; + std::atomic input_wait_timeouts{0}; }; // Process-visible, not thread-local. Scopes and configuration changes are @@ -34,7 +57,7 @@ class ScopedFakeBridgeState final { ScopedFakeBridgeState(); ~ScopedFakeBridgeState(); FakeBridgeState& state(); - // Join caller threads before draining callbacks. + // Release input gates and join caller threads before draining callbacks. ::testing::AssertionResult wait_for_callbacks(dispatch_time_t deadline); ScopedFakeBridgeState(const ScopedFakeBridgeState&) = delete; ScopedFakeBridgeState& operator=(const ScopedFakeBridgeState&) = delete; @@ -46,6 +69,17 @@ class ScopedFakeBridgeState final { } // namespace executorch::backends::coreai::testing +@interface FakeSession : NSObject +@property(nonatomic, strong) id preparedPin; +@end + +@interface FakePreparedModel : NSObject +@property(nonatomic, copy) NSData* bookmark; +@end + +@interface FakeLoader : NSObject +@end + @interface BookmarkFakeModel : NSObject @property(nonatomic, weak) BookmarkFakeLoader* owner; @property(nonatomic, copy) NSData* bookmark; @@ -55,16 +89,22 @@ class ScopedFakeBridgeState final { @public std::atomic restores; std::atomic specializations; + std::atomic bindings; std::atomic evictions; + std::atomic specializationQueries; } @property(nonatomic, strong) NSMutableDictionary* models; @property(nonatomic, strong) NSError* restoreError; @property(nonatomic, strong) NSError* specializeError; +@property(nonatomic, strong) NSError* bindError; +@property(nonatomic, strong) NSError* evictError; @property(nonatomic) BOOL restoreMiss; @property(nonatomic) BOOL emptyBookmark; @property(nonatomic, copy) void (^onSpecialize)(NSURL*); +@property(nonatomic, copy) void (^onEvict)(NSData*); @property(nonatomic, strong) BookmarkFakeModel* lastAcquired; +@property(nonatomic, strong) BookmarkFakeModel* lastBound; @property(nonatomic, copy) NSString* lastSpecializeURL; // Call only after workers/callbacks finish and caller-owned handles are // released. diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index e708d5dbd92..5eefda12f9d 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -7,8 +7,11 @@ */ #import "coreai_fake_loader.h" +#include +#include "coreai_test_utils.h" using executorch::backends::coreai::testing::FakeBridgeState; +using executorch::backends::coreai::testing::test_deadline; // ARC keeps failed asynchronous work from outliving its state storage. @interface CoreAIFakeBridgeContext : NSObject { @@ -16,6 +19,10 @@ @interface CoreAIFakeBridgeContext : NSObject { FakeBridgeState state; } @property(nonatomic, strong) dispatch_group_t callbacks; +@property(nonatomic, copy) void (^retainedBindingCompletion) + (id, NSError*); +@property(nonatomic, copy) void (^retainedExecutionCompletion) + (NSArray*, NSError*); @end @implementation CoreAIFakeBridgeContext @@ -34,6 +41,27 @@ - (instancetype)init { return [NSError errorWithDomain:ETCoreAIErrorDomain code:code userInfo:nil]; } +bool valid_binding( + CoreAIFakeBridgeContext* context, + NSString* function, + NSArray* inputs, + NSArray* outputs) { + // Reversed names make accidental sorting observable in multi-I/O tests. + NSArray* expected_inputs = context->state.multiple_io + ? @[ @"input_1", @"input_0" ] + : @[ @"input_0" ]; + NSArray* expected_outputs = context->state.multiple_io + ? @[ @"output_1", @"output_0" ] + : @[ @"output_0" ]; + const bool valid = [function isEqualToString:@"main"] && + [inputs isEqualToArray:expected_inputs] && + [outputs isEqualToArray:expected_outputs]; + if (!valid) { + ++context->state.binding_mismatches; + } + return valid; +} + // Release callback captures before publishing that all fake work has drained. void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { dispatch_group_t group = context.callbacks; @@ -56,11 +84,219 @@ void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { } return ::testing::AssertionSuccess(); } +} // namespace -dispatch_time_t cleanup_deadline() { - return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); +@interface FakeOutputOwner : NSObject +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakeOutputOwner +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if ((self = [super init])) { + self.context = context; + ++context->state.output_owners; + } + return self; } -} // namespace +- (void)dealloc { + --_context->state.output_owners; +} +@end + +@interface FakeSession () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakeSession +- (instancetype)init { + return [self initWithContext:current_context]; +} +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if (context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = context; + ++context->state.sessions; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.sessions; + } +} +- (void)executeInputs:(NSArray*)inputs + completion: + (void (^)(NSArray*, NSError*))completion { + CoreAIFakeBridgeContext* context = self.context; + ++context->state.executions; + const bool should_fail = context->state.fail_execute; + const bool malformed = context->state.bad_output; + dispatch_semaphore_t ready = context->state.input_ready; + dispatch_semaphore_t proceed = context->state.allow_input_read; + if (context->state.retain_execution_completion) { + context.retainedExecutionCompletion = completion; + } + run_async(context, ^{ + ETCoreAIInputTensor* input = inputs[0]; + context->state.last_input_bytes = input.bytes; + context->state.last_input_byte_count = input.byteCount; + if (ready != nil) { + dispatch_semaphore_signal(ready); + if (proceed == nil || + dispatch_semaphore_wait(proceed, test_deadline()) != 0) { + ++context->state.input_wait_timeouts; + completion(nil, fake_error(ETCoreAIErrorRuntime)); + return; + } + } + if (input.byteCount > 0) { + context->state.last_input_first_byte = + static_cast(input.bytes)[0]; + } + if (should_fail) { + completion(nil, fake_error(ETCoreAIErrorRuntime)); + return; + } + if (malformed) { + completion( + @[ [[ETCoreAITensor alloc] initWithData:[NSData data] + shape:@[ @2 ] + scalarType:ETCoreAIScalarTypeFloat32] ], + nil); + return; + } + NSMutableArray* outputs = + [NSMutableArray arrayWithCapacity:inputs.count]; + for (ETCoreAIInputTensor* value in inputs) { + FakeOutputOwner* owner = + [[FakeOutputOwner alloc] initWithContext:context]; + [outputs addObject:[[ETCoreAITensor alloc] + initWithByteCount:value.byteCount + shape:value.shape + scalarType:value.scalarType + copyBytes:^(void* destination) { + (void)owner; + ++context->state.output_copies; + context->state.last_output_destination = + destination; + std::memcpy( + destination, + value.bytes, + value.byteCount); + }]]; + } + completion(outputs, nil); + }); +} +@end + +@interface FakePreparedModel () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context; +@end + +@implementation FakePreparedModel +- (instancetype)init { + return [self initWithContext:current_context]; +} +- (instancetype)initWithContext:(CoreAIFakeBridgeContext*)context { + if (context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = context; + ++context->state.prepared_models; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.prepared_models; + } +} +- (NSData*)copyBookmarkData { + return self.bookmark; +} +- (void)loadFunctionNamed:(NSString*)functionName + inputNames:(NSArray*)inputNames + outputNames:(NSArray*)outputNames + completion:(void (^)(id, NSError*))completion { + CoreAIFakeBridgeContext* context = self.context; + const bool valid = + valid_binding(context, functionName, inputNames, outputNames); + const bool should_fail = context->state.fail_load; + if (context->state.retain_binding_completion) { + context.retainedBindingCompletion = completion; + } + run_async(context, ^{ + if (!valid || should_fail) { + completion(nil, fake_error(ETCoreAIErrorInvalidModel)); + return; + } + FakeSession* session = [[FakeSession alloc] initWithContext:context]; + session.preparedPin = self; + completion(session, nil); + }); +} +@end + +@interface FakeLoader () +@property(nonatomic, strong) CoreAIFakeBridgeContext* context; +@end + +@implementation FakeLoader +- (instancetype)init { + if (current_context == nil) { + return nil; + } + if ((self = [super init])) { + self.context = current_context; + ++_context->state.loaders; + } + return self; +} +- (void)dealloc { + if (_context != nil) { + --_context->state.loaders; + } +} +- (NSString*)specializationIdentifier { + return @"default"; +} +- (void)restoreModelFromBookmark:(NSData*)bookmark + completion:(ETCoreAIAcquisitionCompletion)completion { + FakePreparedModel* model = + [[FakePreparedModel alloc] initWithContext:self.context]; + model.bookmark = bookmark; + _context->state.last_bundle = + [[NSString alloc] initWithData:bookmark encoding:NSUTF8StringEncoding]; + completion(ETCoreAIAcquisitionStatusHit, model, nil); +} +- (void)specializeModelAtURL:(NSURL*)url + completion:(ETCoreAIAcquisitionCompletion)completion { + if (![NSFileManager.defaultManager fileExistsAtPath:url.path]) { + ++_context->state.missing_bundles; + completion( + ETCoreAIAcquisitionStatusError, + nil, + fake_error(ETCoreAIErrorInvalidModel)); + return; + } + _context->state.last_bundle = url.path; + FakePreparedModel* model = + [[FakePreparedModel alloc] initWithContext:self.context]; + model.bookmark = [url.path dataUsingEncoding:NSUTF8StringEncoding]; + completion(ETCoreAIAcquisitionStatusHit, model, nil); +} +- (void)evictModelWithBookmark:(NSData*)bookmark + completion:(void (^)(NSError*))completion { + completion(nil); +} +@end @interface BookmarkFakeModel () @property(nonatomic, strong) CoreAIFakeBridgeContext* context; @@ -97,8 +333,24 @@ - (void)loadFunctionNamed:(NSString*)functionName inputNames:(NSArray*)inputNames outputNames:(NSArray*)outputNames completion:(void (^)(id, NSError*))completion { - // Acquisition tests never bind functions. - completion(nil, fake_error(ETCoreAIErrorUnsupported)); + CoreAIFakeBridgeContext* context = self.context; + BookmarkFakeLoader* owner = self.owner; + if (!valid_binding(context, functionName, inputNames, outputNames) || + owner == nil) { + completion(nil, fake_error(ETCoreAIErrorInvalidModel)); + return; + } + ++owner->bindings; + owner.lastBound = self; + run_async(context, ^{ + if (owner.bindError != nil) { + completion(nil, owner.bindError); + return; + } + FakeSession* session = [[FakeSession alloc] initWithContext:context]; + session.preparedPin = self; + completion(session, nil); + }); } @end @@ -112,7 +364,9 @@ - (instancetype)init { self.models = [NSMutableDictionary dictionary]; restores = 0; specializations = 0; + bindings = 0; evictions = 0; + specializationQueries = 0; ++_context->state.loaders; } return self; @@ -123,6 +377,7 @@ - (void)dealloc { } } - (NSString*)specializationIdentifier { + ++specializationQueries; return @"default"; } - (void)restoreModelFromBookmark:(NSData*)bookmark @@ -180,28 +435,26 @@ - (void)evictModelWithBookmark:(NSData*)bookmark completion:(void (^)(NSError*))completion { ++evictions; run_async(self.context, ^{ - completion(fake_error(ETCoreAIErrorUnsupported)); + if (self.onEvict != nil) { + self.onEvict(bookmark); + } + @synchronized(self) { + if (self.evictError == nil) { + [self.models removeObjectForKey:bookmark]; + } + completion(self.evictError); + } }); } - (void)clearRetainedState { self.onSpecialize = nil; + self.onEvict = nil; self.lastAcquired = nil; + self.lastBound = nil; [self.models removeAllObjects]; } @end -BOOL ETCoreAIIsAvailable(void) { - return YES; -} - -NSString* ETCoreAIDeviceArchitectureName(void) { - return @"arch_b"; -} - -id ETCoreAICreateModelLoader(void) { - return current_context == nil ? nil : current_context->state.bookmark_loader; -} - namespace executorch::backends::coreai::testing { ScopedFakeBridgeState::ScopedFakeBridgeState() @@ -211,7 +464,7 @@ BOOL ETCoreAIIsAvailable(void) { } ScopedFakeBridgeState::~ScopedFakeBridgeState() { - auto drained = wait_for_callbacks(cleanup_deadline()); + auto drained = wait_for_callbacks(test_deadline()); EXPECT_TRUE(drained); if (drained) { id loader = context_->state.bookmark_loader; @@ -220,9 +473,17 @@ BOOL ETCoreAIIsAvailable(void) { } context_->state.bookmark_loader = nil; loader = nil; + context_.retainedBindingCompletion = nil; + context_.retainedExecutionCompletion = nil; + context_->state.input_ready = nil; + context_->state.allow_input_read = nil; + EXPECT_EQ(context_->state.sessions.load(), 0); EXPECT_EQ(context_->state.prepared_models.load(), 0); EXPECT_EQ(context_->state.loaders.load(), 0); + EXPECT_EQ(context_->state.output_owners.load(), 0); + EXPECT_EQ(context_->state.binding_mismatches.load(), 0); EXPECT_EQ(context_->state.missing_bundles.load(), 0); + EXPECT_EQ(context_->state.input_wait_timeouts.load(), 0); } EXPECT_EQ(current_context, context_); current_context = previous_; @@ -252,7 +513,7 @@ BOOL ETCoreAIIsAvailable(void) { if (context_ == nil) { return; } - auto drained = ::wait_for_callbacks(context_, cleanup_deadline()); + auto drained = ::wait_for_callbacks(context_, test_deadline()); EXPECT_TRUE(drained); if (drained) { [loader clearRetainedState]; @@ -263,3 +524,22 @@ BOOL ETCoreAIIsAvailable(void) { } } // namespace executorch::backends::coreai::testing + +BOOL ETCoreAIIsAvailable(void) { + return current_context != nil && current_context->state.available; +} + +NSString* ETCoreAIDeviceArchitectureName(void) { + if (current_context == nil) { + return nil; + } + ++current_context->state.architecture_queries; + return current_context->state.device_architecture; +} + +id ETCoreAICreateModelLoader(void) { + if (current_context == nil || current_context->state.fail_loader_factory) { + return nil; + } + return current_context->state.bookmark_loader ?: [[FakeLoader alloc] init]; +} diff --git a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h index d15d865020c..7379eff4267 100644 --- a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h @@ -10,6 +10,9 @@ #import #include +#include +#include +#include "coreai_cache_root.h" namespace executorch::backends::coreai::testing { @@ -29,6 +32,19 @@ ::testing::AssertionResult asset_tree_snapshot( ::testing::AssertionResult snapshot_matches( NSURL* root, NSDictionary* expected); -NSArray* staging_directories(NSURL* root); +NSArray* pending_directories(NSURL* root); + +// Lets one test body cover both cache directory entry points. +inline runtime::Result> +resolve_test_cache_directory(NSString* path, StorageCreationMode mode) { + if (mode == StorageCreationMode::OpenExisting) { + return open_cache_directory(path); + } + auto created = create_cache_directory(path); + if (!created.ok()) { + return created.error(); + } + return std::optional(std::move(created.get())); +} } // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm index 827a228eb8e..1e5a7bdcb9a 100644 --- a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm @@ -137,15 +137,15 @@ << "Filesystem snapshot changed: " << root.path.UTF8String; } -NSArray* staging_directories(NSURL* root) { +NSArray* pending_directories(NSURL* root) { if (root == nil) { - ADD_FAILURE() << "Missing staging root"; + ADD_FAILURE() << "Missing cache root"; return nil; } - NSURL* cache_staging = [root URLByAppendingPathComponent:@"staging" + NSURL* cache_sources = [root URLByAppendingPathComponent:@"sources" isDirectory:YES]; - if ([NSFileManager.defaultManager fileExistsAtPath:cache_staging.path]) { - root = cache_staging; + if ([NSFileManager.defaultManager fileExistsAtPath:cache_sources.path]) { + root = cache_sources; } NSError* error = nil; NSArray* children = @@ -154,16 +154,16 @@ options:0 error:&error]; if (children == nil || error != nil) { - ADD_FAILURE() << "Cannot list staging directories: " + ADD_FAILURE() << "Cannot list pending directories: " << root.path.UTF8String; return nil; } - NSMutableArray* staging = [NSMutableArray array]; + NSMutableArray* pending = [NSMutableArray array]; for (NSURL* child in children) { - if ([child.lastPathComponent hasPrefix:@".staging-"]) - [staging addObject:child]; + if ([child.lastPathComponent hasPrefix:@".pending-"]) + [pending addObject:child]; } - return staging; + return pending; } } // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_host_test.mm b/backends/apple/coreai/runtime/test/coreai_host_test.mm index d87eefdbbba..3702c812e0d 100644 --- a/backends/apple/coreai/runtime/test/coreai_host_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_host_test.mm @@ -10,7 +10,7 @@ #include #include #include "coreai_acquisition_fixture.h" -#include "coreai_bookmark_fixture.h" +#include "coreai_cache_entry_fixture.h" int main(int argc, char** argv) { executorch::runtime::runtime_init(); diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h index 68223c980a9..bf9af55c0bb 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_manifest_fixture.h @@ -23,7 +23,7 @@ inline NSString* fixture_identity(char digit) { inline NSMutableDictionary* manifest_dict() { return [@{ @"packaging" : @"inline", - @"hash" : @"ab", + @"export_hash" : @"ab", @"path" : @"ab/model.aimodel", @"function" : @"main", @"input_names" : @[ @"input_0" ], @@ -60,4 +60,19 @@ inline NSData* encode(NSDictionary* dict) { return [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; } +inline runtime::Result selected_bundle( + NSDictionary* dict, + NSString* architecture = @"arch_b", + NSString* platform = @"macOS") { + NSData* encoded = encode(dict); + if (encoded == nil) { + return runtime::Error::InvalidExternalData; + } + auto manifest = parse_manifest(encoded); + if (!manifest.ok()) { + return manifest.error(); + } + return select_bundle(manifest.get(), architecture, platform); +} + } // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm index cd7d2d22f65..2e0fa5393e7 100644 --- a/backends/apple/coreai/runtime/test/coreai_manifest_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_manifest_test.mm @@ -170,7 +170,7 @@ auto parsed = parse_manifest(encode(dict)); ASSERT_TRUE(parsed.ok()); EXPECT_EQ(parsed->aot_compiled, GetParam()); - EXPECT_TRUE([parsed->model_hash isEqual:dict[@"hash"]]); + EXPECT_TRUE([parsed->export_hash isEqual:dict[@"export_hash"]]); EXPECT_TRUE([parsed->function isEqual:dict[@"function"]]); EXPECT_TRUE([parsed->inputs isEqual:dict[@"input_names"]]); EXPECT_TRUE([parsed->outputs isEqual:dict[@"output_names"]]); @@ -246,12 +246,12 @@ TEST_P(CoreAIManifestMetadataTest, RejectsInvalidHashes) { auto missing = manifest(); - [missing removeObjectForKey:@"hash"]; + [missing removeObjectForKey:@"export_hash"]; EXPECT_EQ(parse_manifest(encode(missing)).error(), Error::InvalidProgram); for (id hash in @[ @"", @"g", @"AB", @"../x", @"a/b", @1, NSNull.null ]) { SCOPED_TRACE([hash description].UTF8String); auto dict = manifest(); - dict[@"hash"] = hash; + dict[@"export_hash"] = hash; if (GetParam()) { dict[@"archs"] = @{ @"arch_a" : @@ -391,12 +391,12 @@ [parsed->architectures copy]; for (NSString* arch in @[ @"arch_a", @"arch_b" ]) { SCOPED_TRACE(arch.UTF8String); - auto selected = select_assets(parsed.get(), arch, @"macOS"); + auto selected = select_bundle(parsed.get(), arch, @"macOS"); ASSERT_TRUE(selected.ok()); EXPECT_TRUE([selected->path isEqualToString:dict[@"archs"][arch]]); EXPECT_EQ(selected->files.count, 2); NSString* bundle = selected->path.lastPathComponent; - EXPECT_TRUE([selected->model_hash isEqual:parsed->model_hash]); + EXPECT_TRUE([selected->export_hash isEqual:parsed->export_hash]); EXPECT_TRUE([selected->function isEqual:parsed->function]); EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); @@ -420,10 +420,10 @@ ASSERT_TRUE(parsed.ok()); ASSERT_FALSE(parsed->aot_compiled); // Selection ignores the architecture and platform for source bundles. - auto selected = select_assets(parsed.get(), @"anything", @"elsewhere"); + auto selected = select_bundle(parsed.get(), @"anything", @"elsewhere"); ASSERT_TRUE(selected.ok()); EXPECT_TRUE([selected->path isEqualToString:dict[@"path"]]); - EXPECT_TRUE([selected->model_hash isEqual:parsed->model_hash]); + EXPECT_TRUE([selected->export_hash isEqual:parsed->export_hash]); EXPECT_TRUE([selected->function isEqual:parsed->function]); EXPECT_TRUE([selected->inputs isEqual:parsed->inputs]); EXPECT_TRUE([selected->outputs isEqual:parsed->outputs]); @@ -441,7 +441,7 @@ ASSERT_TRUE(parsed.ok()); [bytes setLength:0]; [original removeAllObjects]; - auto selected = select_assets(parsed.get(), @"arch_b", @"macOS"); + auto selected = select_bundle(parsed.get(), @"arch_b", @"macOS"); ASSERT_TRUE(selected.ok()); EXPECT_TRUE([selected->path isEqual:@"ab/model.arch_b.aimodelc"]); EXPECT_EQ(selected->files.count, 2); @@ -470,30 +470,31 @@ } TEST(CoreAIManifestTest, ValidationHelpersMatchParserRules) { - EXPECT_FALSE(nonempty_string(nil)); - EXPECT_FALSE(nonempty_string(@"")); - EXPECT_TRUE(nonempty_string(@"ab")); + EXPECT_FALSE(is_nonempty_string(nil)); + EXPECT_FALSE(is_nonempty_string(@"")); + EXPECT_TRUE(is_nonempty_string(@"ab")); const unichar characters[] = {'a', 0, 'b'}; NSString* embedded_nul = [NSString stringWithCharacters:characters length:3]; ASSERT_EQ(embedded_nul.length, 3); - EXPECT_FALSE(nonempty_string(embedded_nul)); - EXPECT_FALSE(relative_path(embedded_nul)); - EXPECT_FALSE(lowercase_hex(embedded_nul)); - EXPECT_FALSE(nonempty_string(@1)); - EXPECT_FALSE(lowercase_hex((id) @1, 64)); - EXPECT_FALSE(lowercase_hex((id)NSNull.null, 64)); - EXPECT_FALSE(relative_path(@"/ab/model.aimodel")); - EXPECT_FALSE(relative_path(@"ab/../model.aimodel")); - EXPECT_FALSE(relative_path(@"a\\b")); - EXPECT_FALSE(relative_path(@"a//b")); - EXPECT_TRUE(relative_path(@"ab/model.aimodel")); - EXPECT_TRUE(lowercase_hex(@"ab")); - EXPECT_FALSE(lowercase_hex(@"")); - EXPECT_FALSE(lowercase_hex(@"AB")); - EXPECT_FALSE(lowercase_hex(@"ag")); - EXPECT_TRUE(lowercase_hex(fixture_identity('a'), 64)); - EXPECT_FALSE(lowercase_hex([fixture_identity('a') substringFromIndex:1], 64)); - EXPECT_FALSE(lowercase_hex(fixture_identity('a').uppercaseString, 64)); + EXPECT_FALSE(is_nonempty_string(embedded_nul)); + EXPECT_FALSE(is_relative_path(embedded_nul)); + EXPECT_FALSE(is_lowercase_hex(embedded_nul)); + EXPECT_FALSE(is_nonempty_string(@1)); + EXPECT_FALSE(is_lowercase_hex((id) @1, 64)); + EXPECT_FALSE(is_lowercase_hex((id)NSNull.null, 64)); + EXPECT_FALSE(is_relative_path(@"/ab/model.aimodel")); + EXPECT_FALSE(is_relative_path(@"ab/../model.aimodel")); + EXPECT_FALSE(is_relative_path(@"a\\b")); + EXPECT_FALSE(is_relative_path(@"a//b")); + EXPECT_TRUE(is_relative_path(@"ab/model.aimodel")); + EXPECT_TRUE(is_lowercase_hex(@"ab")); + EXPECT_FALSE(is_lowercase_hex(@"")); + EXPECT_FALSE(is_lowercase_hex(@"AB")); + EXPECT_FALSE(is_lowercase_hex(@"ag")); + EXPECT_TRUE(is_lowercase_hex(fixture_identity('a'), 64)); + EXPECT_FALSE( + is_lowercase_hex([fixture_identity('a') substringFromIndex:1], 64)); + EXPECT_FALSE(is_lowercase_hex(fixture_identity('a').uppercaseString, 64)); } TEST(CoreAIManifestTest, RejectsMismatchedAotPlatformAndArchitecture) { @@ -501,19 +502,19 @@ ios[@"platform"] = @"iOS"; auto parsed_ios = parse_manifest(encode(ios)); ASSERT_TRUE(parsed_ios.ok()); - EXPECT_TRUE(select_assets(parsed_ios.get(), @"arch_b", @"iOS").ok()); + EXPECT_TRUE(select_bundle(parsed_ios.get(), @"arch_b", @"iOS").ok()); auto parsed = parse_manifest(encode(aot_manifest_dict())); ASSERT_TRUE(parsed.ok()); EXPECT_EQ( - select_assets(parsed.get(), @"arch_b", @"iOS").error(), + select_bundle(parsed.get(), @"arch_b", @"iOS").error(), Error::DelegateInvalidCompatibility); EXPECT_EQ( - select_assets(parsed.get(), @"arch_b", nil).error(), + select_bundle(parsed.get(), @"arch_b", nil).error(), Error::DelegateInvalidCompatibility); for (id arch in @[ @"missing", @"ARCH_B", @"", NSNull.null ]) { SCOPED_TRACE([arch description].UTF8String); EXPECT_EQ( - select_assets(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS") + select_bundle(parsed.get(), arch == NSNull.null ? nil : arch, @"macOS") .error(), Error::DelegateInvalidCompatibility); } diff --git a/backends/apple/coreai/runtime/test/coreai_pte_export_test.cpp b/backends/apple/coreai/runtime/test/coreai_pte_export_test.cpp new file mode 100644 index 00000000000..b78362f989a --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_pte_export_test.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_pte.h" + +#include +#include + +#include +#include + +namespace { +using executorch::extension::FileDataLoader; +using executorch::runtime::DataLoader; + +bool matches_export(const char* pte_path, const char* manifest_path) { + auto loader = FileDataLoader::from(pte_path); + auto manifest_loader = FileDataLoader::from(manifest_path); + if (!loader.ok() || !manifest_loader.ok()) { + return false; + } + auto result = executorch::backends::coreai::inspect_coreai_pte(loader.get()); + if (!result.ok() || result->processed.size() != 1) { + return false; + } + auto size = manifest_loader->size(); + if (!size.ok() || size.get() == 0) { + return false; + } + auto expected = manifest_loader->load( + 0, + size.get(), + DataLoader::SegmentInfo(DataLoader::SegmentInfo::Type::Program)); + return expected.ok() && result->processed[0].size() == size.get() && + std::memcmp(result->processed[0].data(), expected->data(), size.get()) == + 0; +} +} // namespace + +int main(int argc, char** argv) { + executorch::runtime::runtime_init(); + if (argc != 3 || !matches_export(argv[1], argv[2])) { + std::fputs( + "PTE inspection did not reproduce the exported Core AI manifest\n", + stderr); + return 1; + } + std::puts("PTE inspection matches the exported Core AI manifest"); + return 0; +} diff --git a/backends/apple/coreai/runtime/test/coreai_pte_fixture.h b/backends/apple/coreai/runtime/test/coreai_pte_fixture.h new file mode 100644 index 00000000000..6274ce64ecb --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_pte_fixture.h @@ -0,0 +1,76 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import + +#include +#include +#include + +#include +#include +#include +#include + +namespace executorch::backends::coreai::testing { + +struct PteDelegateSpec { + const char* backend; + NSData* data; + bool segmented = false; + int index_override = -1; + bool missing_processed = false; + std::optional backend_length = std::nullopt; +}; + +struct PteMethodSpec { + const char* name; + std::vector delegates; +}; + +class FBSyntheticPTE final : public executorch::runtime::DataLoader { + public: + enum class LoadFault { None, Error, Misaligned, Short, Oversized }; + struct Request { + size_t offset; + size_t size; + SegmentInfo::Type type; + size_t index; + std::string descriptor; + const void* data = nullptr; + }; + + std::vector bytes; + mutable std::vector requests; + mutable std::vector release_order; + mutable bool program_alive_at_backend_release = true; + executorch::runtime::Error size_error = executorch::runtime::Error::Ok; + executorch::runtime::Error load_error = + executorch::runtime::Error::NotSupported; + int fault_call = -1; + LoadFault load_fault = LoadFault::None; + mutable std::atomic program_loads{0}; + mutable std::atomic program_releases{0}; + mutable std::atomic backend_loads{0}; + mutable std::atomic backend_releases{0}; + mutable std::vector backend_indices; + mutable std::vector backend_descriptors; + + explicit FBSyntheticPTE( + const std::vector& methods, + bool extended_header = true); + + executorch::runtime::Result size() const override; + executorch::runtime::Result + load(size_t offset, size_t size, const SegmentInfo& info) const override; + ::testing::AssertionResult check_released() const; +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_pte_fixture.mm b/backends/apple/coreai/runtime/test/coreai_pte_fixture.mm new file mode 100644 index 00000000000..cf463cfaac0 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_pte_fixture.mm @@ -0,0 +1,254 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_pte_fixture.h" + +#include +#include + +#include +#include +#include + +namespace executorch::backends::coreai::testing { +namespace fb = executorch_flatbuffer; +using executorch::runtime::Error; +using executorch::runtime::ExtendedHeader; +using executorch::runtime::FreeableBuffer; +using executorch::runtime::Result; + +FBSyntheticPTE::FBSyntheticPTE( + const std::vector& methods, + bool extended_header) { + flatbuffers::FlatBufferBuilder builder; + builder.ForceDefaults(true); + // Malformed-field tests must mutate only the selected table's vtable. + builder.DedupVtables(false); + std::vector> plans; + std::vector> inline_data; + std::vector> segments; + std::vector segment_bytes; + auto append_segment = [&](NSData* data) { + while (segment_bytes.size() % 16 != 0) { + segment_bytes.push_back(0); + } + const size_t index = segments.size(); + segments.push_back( + fb::CreateDataSegment(builder, segment_bytes.size(), data.length)); + const auto* begin = static_cast(data.bytes); + if (data.length != 0) { + segment_bytes.insert(segment_bytes.end(), begin, begin + data.length); + } + return static_cast(index); + }; + for (const auto& method : methods) { + std::vector> delegates; + for (const auto& delegate : method.delegates) { + uint32_t index; + auto location = fb::DataLocation::INLINE; + if (delegate.segmented) { + index = append_segment(delegate.data); + location = fb::DataLocation::SEGMENT; + } else { + index = static_cast(inline_data.size()); + inline_data.push_back(fb::CreateBackendDelegateInlineData( + builder, + builder.CreateVector( + static_cast(delegate.data.bytes), + delegate.data.length))); + } + if (delegate.index_override >= 0) { + index = static_cast(delegate.index_override); + } + auto reference = delegate.missing_processed + ? flatbuffers::Offset{} + : fb::CreateBackendDelegateDataReference(builder, location, index); + auto name = delegate.backend == nullptr + ? flatbuffers::Offset{} + : builder.CreateString( + delegate.backend, + delegate.backend_length.value_or( + std::strlen(delegate.backend))); + delegates.push_back(fb::CreateBackendDelegate(builder, name, reference)); + } + auto name = method.name == nullptr + ? flatbuffers::Offset{} + : builder.CreateString(method.name); + auto values = + builder.CreateVector(std::vector>{}); + auto inputs = builder.CreateVector(std::vector{}); + auto outputs = builder.CreateVector(std::vector{}); + auto backends = builder.CreateVector(delegates); + auto sizes = builder.CreateVector(std::vector{0}); + fb::ExecutionPlanBuilder plan(builder); + plan.add_name(name); + plan.add_values(values); + plan.add_inputs(inputs); + plan.add_outputs(outputs); + plan.add_delegates(backends); + plan.add_non_const_buffer_sizes(sizes); + plans.push_back(plan.Finish()); + } + // Delegate metadata access must not request named asset payloads. + uint32_t asset_segment = append_segment( + [@"unread named asset" dataUsingEncoding:NSUTF8StringEncoding]); + auto named = fb::CreateNamedData( + builder, + builder.CreateString("coreai/ab/model.aimodel/graph.bin"), + asset_segment); + auto names = builder.CreateVector( + std::vector>{named}); + auto constants = fb::CreateSubsegmentOffsets( + builder, 0, builder.CreateVector(std::vector{0})); + auto plan_vector = builder.CreateVector(plans); + auto inline_vector = builder.CreateVector(inline_data); + auto segment_vector = builder.CreateVector(segments); + fb::ProgramBuilder program(builder); + program.add_version(executorch::runtime::Program::kMaxSupportedSchemaVersion); + program.add_execution_plan(plan_vector); + program.add_backend_delegate_data(inline_vector); + program.add_segments(segment_vector); + program.add_constant_segment(constants); + program.add_named_data(names); + fb::FinishProgramBuffer(builder, program.Finish()); + if (!extended_header) { + bytes.assign( + builder.GetBufferPointer(), + builder.GetBufferPointer() + builder.GetSize()); + return; + } + // Preserve flatbuffer alignment when inserting the extended header. + constexpr size_t extra = 32; + const size_t program_size = builder.GetSize() + extra; + const size_t segment_base = (program_size + 15) & ~size_t(15); + bytes.resize(segment_base + segment_bytes.size()); + std::memcpy( + bytes.data(), builder.GetBufferPointer(), ExtendedHeader::kHeaderOffset); + std::memcpy( + bytes.data() + ExtendedHeader::kHeaderOffset + extra, + builder.GetBufferPointer() + ExtendedHeader::kHeaderOffset, + builder.GetSize() - ExtendedHeader::kHeaderOffset); + auto root_offset = + flatbuffers::ReadScalar(bytes.data()); + flatbuffers::WriteScalar( + bytes.data(), root_offset + extra); + std::memcpy( + bytes.data() + 8, ExtendedHeader::kMagic, ExtendedHeader::kMagicSize); + flatbuffers::WriteScalar(bytes.data() + 12, extra); + flatbuffers::WriteScalar(bytes.data() + 16, program_size); + flatbuffers::WriteScalar(bytes.data() + 24, segment_base); + flatbuffers::WriteScalar(bytes.data() + 32, segment_bytes.size()); + std::memcpy( + bytes.data() + segment_base, segment_bytes.data(), segment_bytes.size()); +} + +Result FBSyntheticPTE::size() const { + if (size_error != Error::Ok) { + return size_error; + } + return bytes.size(); +} + +Result FBSyntheticPTE::load( + size_t offset, + size_t size, + const SegmentInfo& info) const { + const size_t call = requests.size(); + requests.push_back( + {offset, + size, + info.segment_type, + info.segment_index, + info.descriptor == nullptr ? "" : info.descriptor}); + const bool fault = static_cast(call) == fault_call; + if (fault && load_fault == LoadFault::Error) { + return load_error; + } + if (offset > bytes.size() || size > bytes.size() - offset) { + return Error::AccessFailed; + } + if (info.segment_type != SegmentInfo::Type::Program && + info.segment_type != SegmentInfo::Type::Backend) { + ADD_FAILURE() << "Unexpected segment type: " + << static_cast(info.segment_type); + return Error::InvalidArgument; + } + const bool backend = info.segment_type == SegmentInfo::Type::Backend; + if (backend) { + if (info.descriptor == nullptr) { + ADD_FAILURE() << "Backend segment has no descriptor"; + return Error::InvalidArgument; + } + backend_indices.push_back(info.segment_index); + backend_descriptors.emplace_back(info.descriptor); + } else if (info.descriptor != nullptr) { + ADD_FAILURE() << "Program segment has a backend descriptor"; + return Error::InvalidArgument; + } + auto* loads = backend ? &backend_loads : &program_loads; + const bool misaligned = fault && load_fault == LoadFault::Misaligned; + size_t loaded_size = size; + if (fault && load_fault == LoadFault::Short && size != 0) { + --loaded_size; + } else if (fault && load_fault == LoadFault::Oversized) { + ++loaded_size; + } + void* allocation = std::malloc(std::max(loaded_size, size_t(1)) + misaligned); + if (allocation == nullptr) { + ADD_FAILURE() << "Could not allocate " << size << " bytes"; + return Error::MemoryAllocationFailed; + } + auto* data = static_cast(allocation) + misaligned; + if (loaded_size != 0) { + std::memcpy(data, bytes.data() + offset, std::min(size, loaded_size)); + if (loaded_size > size) { + data[size] = 0; + } + } + struct Allocation { + const FBSyntheticPTE* loader; + void* base; + bool backend; + }; + auto* context = new Allocation{this, allocation, backend}; + requests.back().data = data; + ++*loads; + return FreeableBuffer( + data, + loaded_size, + [](void* opaque, void*, size_t) { + auto* context = static_cast(opaque); + const auto* loader = context->loader; + if (context->backend) { + loader->program_alive_at_backend_release &= + loader->program_loads > loader->program_releases; + ++loader->backend_releases; + } else { + ++loader->program_releases; + } + loader->release_order.push_back( + context->backend ? SegmentInfo::Type::Backend + : SegmentInfo::Type::Program); + std::free(context->base); + delete context; + }, + context); +} + +::testing::AssertionResult FBSyntheticPTE::check_released() const { + if (program_loads == program_releases && backend_loads == backend_releases) { + return ::testing::AssertionSuccess(); + } + return ::testing::AssertionFailure() + << "Program loads/releases: " << program_loads.load() << "/" + << program_releases.load() + << "; backend loads/releases: " << backend_loads.load() << "/" + << backend_releases.load(); +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_pte_test.mm b/backends/apple/coreai/runtime/test/coreai_pte_test.mm new file mode 100644 index 00000000000..b9839952fce --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_pte_test.mm @@ -0,0 +1,538 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_pte_fixture.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace executorch::backends::coreai::testing { +namespace { +namespace fb = executorch_flatbuffer; +using executorch::runtime::DataLoader; +using executorch::runtime::Error; +using executorch::runtime::ExtendedHeader; +using executorch::runtime::FreeableBuffer; +using executorch::runtime::Program; +using SegmentType = DataLoader::SegmentInfo::Type; +using LoadFault = FBSyntheticPTE::LoadFault; + +class CoreAIPteTest : public ::testing::Test { + protected: + void SetUp() override { + executorch::runtime::runtime_init(); + } +}; + +NSData* payload(const char* value = "abc") { + return [NSData dataWithBytes:value length:std::strlen(value)]; +} + +template +void set_field(const void* table, flatbuffers::voffset_t field, T value) { + ASSERT_NE(table, nullptr); + auto* fields = static_cast(const_cast(table)); + ASSERT_TRUE(fields->SetField(field, value)); +} + +void clear_field(const void* table, flatbuffers::voffset_t field) { + ASSERT_NE(table, nullptr); + auto* fields = static_cast(table); + ASSERT_NE(fields->GetOptionalFieldOffset(field), 0); + auto* vtable = const_cast(fields->GetVTable()); + flatbuffers::WriteScalar(vtable + field, 0); +} + +void expect_payload(const FreeableBuffer& buffer, NSData* expected) { + ASSERT_EQ(buffer.size(), expected.length); + ASSERT_NE(buffer.data(), nullptr); + EXPECT_EQ(std::memcmp(buffer.data(), expected.bytes, expected.length), 0); +} + +void expect_invalid(FBSyntheticPTE& pte, Error error = Error::InvalidProgram) { + { + auto result = inspect_coreai_pte(pte); + ASSERT_FALSE(result.ok()); + EXPECT_EQ(result.error(), error); + } + EXPECT_TRUE(pte.check_released()); +} + +TEST_F(CoreAIPteTest, BorrowsInlineAndOwnsSegmentsAcrossMethods) { + NSData* first = payload("abc"); + NSData* second = payload("xyz"); + FBSyntheticPTE pte( + {{"forward", + {{"CoreAIBackend", first}, {"UnregisteredBackend", first, true}}}, + {"other", {{"CoreAIBackend", second, true}}}}); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 2); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], first)); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[1], second)); + const auto* program = fb::GetProgram(result->program_storage.data()); + const auto* inline_data = + program->backend_delegate_data()->Get(0)->data()->data(); + EXPECT_EQ(result->processed[0].data(), inline_data); + EXPECT_NE(result->program_storage.data(), pte.bytes.data()); + ASSERT_EQ(pte.requests.size(), 3); + EXPECT_EQ(result->program_storage.data(), pte.requests[1].data); + EXPECT_EQ(result->processed[1].data(), pte.requests[2].data); + EXPECT_EQ(pte.backend_indices, (std::vector{1})); + EXPECT_EQ( + pte.backend_descriptors, (std::vector{"CoreAIBackend"})); + EXPECT_EQ(pte.program_loads, 2); + EXPECT_EQ(pte.program_releases, 1); + EXPECT_EQ(pte.backend_releases, 0); + const int releases = pte.program_releases; + result->processed[0].Free(); + EXPECT_EQ(pte.program_releases, releases); + EXPECT_EQ(std::memcmp(inline_data, first.bytes, first.length), 0); + FreeableBuffer moved(std::move(result->processed[1])); + result->processed[1].Free(); + EXPECT_EQ(pte.backend_releases, 0); + ASSERT_NO_FATAL_FAILURE(expect_payload(moved, second)); + moved.Free(); + moved.Free(); + EXPECT_EQ(pte.backend_releases, 1); + } + EXPECT_TRUE(pte.program_alive_at_backend_release); + EXPECT_TRUE(pte.check_released()); +} + +TEST_F(CoreAIPteTest, RoutesNonidentityGlobalReferencesInMethodOrder) { + for (bool segmented : {false, true}) { + SCOPED_TRACE(segmented ? "segmented" : "inline"); + auto row = [&] { + NSData* first = payload("abc"); + NSData* second = payload("xyz"); + ASSERT_EQ(first.length, second.length); + ASSERT_NE(std::memcmp(first.bytes, second.bytes, first.length), 0); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload("---"), segmented, 2}}}, + {"other", + {{"CoreAIBackend", second, segmented, 1}, + {"UnregisteredBackend", first, segmented}}}}); + const auto* source = fb::GetProgram(pte.bytes.data()); + const auto header = + ExtendedHeader::Parse(pte.bytes.data(), pte.bytes.size()); + ASSERT_TRUE(header.ok()); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 2); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], first)); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[1], second)); + ASSERT_GE(pte.requests.size(), 2); + for (size_t i = 0; i < 2; ++i) { + EXPECT_EQ(pte.requests[i].offset, 0); + EXPECT_EQ(pte.requests[i].type, SegmentType::Program); + EXPECT_EQ(pte.requests[i].index, 0); + EXPECT_TRUE(pte.requests[i].descriptor.empty()); + } + EXPECT_EQ(pte.requests[0].size, ExtendedHeader::kNumHeadBytes); + EXPECT_EQ(pte.requests[1].size, header->program_size); + if (segmented) { + ASSERT_EQ(pte.requests.size(), 4); + EXPECT_EQ(pte.backend_indices, (std::vector{2, 1})); + for (size_t i = 0; i < 2; ++i) { + const size_t index = i == 0 ? 2 : 1; + const auto* segment = source->segments()->Get(index); + const auto& request = pte.requests[i + 2]; + EXPECT_EQ(request.type, SegmentType::Backend); + EXPECT_EQ(request.index, index); + EXPECT_EQ(request.descriptor, "CoreAIBackend"); + EXPECT_EQ( + request.offset, + header->segment_base_offset + segment->offset()); + EXPECT_EQ(request.size, segment->size()); + EXPECT_EQ(request.data, result->processed[i].data()); + } + } else { + ASSERT_EQ(pte.requests.size(), 2); + const auto* program = fb::GetProgram(result->program_storage.data()); + EXPECT_EQ( + result->processed[0].data(), + program->backend_delegate_data()->Get(2)->data()->data()); + EXPECT_EQ( + result->processed[1].data(), + program->backend_delegate_data()->Get(1)->data()->data()); + } + } + EXPECT_TRUE(pte.check_released()); + EXPECT_TRUE(pte.program_alive_at_backend_release); + }; + ASSERT_NO_FATAL_FAILURE(row()); + } +} + +TEST_F(CoreAIPteTest, MatchesCompleteBackendIdentifier) { + const char embedded[] = "CoreAIBackend\0suffix"; + PteDelegateSpec nul_id{embedded, payload(), true}; + nul_id.backend_length = sizeof(embedded) - 1; + nul_id.missing_processed = true; + FBSyntheticPTE pte( + {{"forward", + {{"CoreAIBackendSuffix", payload(), true, 999}, + {"CoreAIBacken", payload(), true, 999}, + {"", payload(), true, 999}, + {nullptr, payload(), true, 999, true}, + nul_id, + {"CoreAIBackend", payload("yes")}}}}); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 1); + ASSERT_NO_FATAL_FAILURE( + expect_payload(result->processed[0], payload("yes"))); + EXPECT_EQ(pte.backend_loads, 0); + EXPECT_EQ(pte.requests.size(), 2); + } + EXPECT_TRUE(pte.check_released()); +} + +TEST_F(CoreAIPteTest, AcceptsEmptyAndUnselectedPrograms) { + for (bool other_backend : {false, true}) { + SCOPED_TRACE(other_backend ? "no CoreAI delegate" : "no plans"); + std::vector methods; + if (other_backend) { + methods.push_back( + {"forward", {{"UnregisteredBackend", payload(), true, 999}}}); + } + FBSyntheticPTE pte(methods); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + EXPECT_TRUE(result->processed.empty()); + EXPECT_EQ(pte.backend_loads, 0); + } + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAIPteTest, AcceptsHeaderlessAndExtendedPrograms) { + for (bool extended : {false, true}) { + SCOPED_TRACE(extended ? "extended" : "headerless"); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload(), extended}}}}, extended); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 1); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], payload())); + ASSERT_GE(pte.requests.size(), 2); + EXPECT_EQ(pte.requests[0].size, ExtendedHeader::kNumHeadBytes); + if (!extended) + EXPECT_EQ(pte.requests[1].size, pte.bytes.size()); + } + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAIPteTest, AcceptsUnusedMethodMetadata) { + for (auto field : + {fb::ExecutionPlan::VT_NAME, + fb::ExecutionPlan::VT_NON_CONST_BUFFER_SIZES, + fb::ExecutionPlan::VT_INPUTS, + fb::ExecutionPlan::VT_OUTPUTS}) { + SCOPED_TRACE(field); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload()}}}, + {"other", {{"CoreAIBackend", payload("xyz")}}}}); + const auto* plan = + fb::GetProgram(pte.bytes.data())->execution_plan()->Get(0); + ASSERT_NO_FATAL_FAILURE(clear_field(plan, field)); + { + auto program = + Program::load(&pte, Program::Verification::InternalConsistency); + ASSERT_TRUE(program.ok()); + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 2); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], payload())); + ASSERT_NO_FATAL_FAILURE( + expect_payload(result->processed[1], payload("xyz"))); + } + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAIPteTest, AcceptsDuplicateMethodNames) { + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload()}}}, + {"forward", {{"CoreAIBackend", payload("xyz")}}}}); + { + auto program = + Program::load(&pte, Program::Verification::InternalConsistency); + ASSERT_TRUE(program.ok()); + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 2); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], payload())); + ASSERT_NO_FATAL_FAILURE( + expect_payload(result->processed[1], payload("xyz"))); + } + EXPECT_TRUE(pte.check_released()); +} + +TEST_F(CoreAIPteTest, AcceptsEmptyProcessedBuffers) { + for (bool segmented : {false, true}) { + SCOPED_TRACE(segmented); + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload(""), segmented}}}}); + { + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 1); + EXPECT_EQ(result->processed[0].size(), 0); + } + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAIPteTest, RejectsMissingAndOutOfRangeCoreAIReferences) { + for (bool segmented : {false, true}) { + for (bool missing : {false, true}) { + SCOPED_TRACE(segmented); + SCOPED_TRACE(missing); + PteDelegateSpec delegate{"CoreAIBackend", payload(), segmented}; + delegate.missing_processed = missing; + delegate.index_override = 999; + FBSyntheticPTE pte({{"forward", {delegate}}}); + ASSERT_NO_FATAL_FAILURE(expect_invalid( + pte, missing ? Error::InvalidProgram : Error::NotFound)); + EXPECT_EQ(pte.backend_loads, 0); + EXPECT_EQ(pte.requests.size(), 2); + } + } +} + +TEST_F(CoreAIPteTest, RejectsSegmentsWithoutExtendedHeader) { + FBSyntheticPTE pte( + {{"forward", {{"CoreAIBackend", payload(), true}}}}, false); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte, Error::NotFound)); + EXPECT_EQ(pte.backend_loads, 0); + EXPECT_EQ(pte.requests.size(), 2); +} + +TEST_F(CoreAIPteTest, RejectsInvalidExtendedHeaderBounds) { + FBSyntheticPTE baseline({{"forward", {{"CoreAIBackend", payload(), true}}}}); + const auto header = + ExtendedHeader::Parse(baseline.bytes.data(), baseline.bytes.size()); + ASSERT_TRUE(header.ok()); + struct Case { + const char* name; + size_t offset; + uint64_t value; + }; + for (const auto& test : + {Case{"short program", 16, ExtendedHeader::kNumHeadBytes - 1}, + Case{"program past EOF", 16, baseline.bytes.size() + 1}, + Case{"segments overlap program", 24, header->program_size - 1}, + Case{"segment base past EOF", 24, baseline.bytes.size() + 1}, + Case{"segment bytes without a base", 24, 0}, + Case{"segment bytes past EOF", 32, header->segment_data_size + 1}, + Case{ + "segment extent overflow", + 32, + std::numeric_limits::max()}}) { + SCOPED_TRACE(test.name); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + flatbuffers::WriteScalar( + pte.bytes.data() + test.offset, test.value); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.requests.size(), 1); + EXPECT_EQ(pte.backend_loads, 0); + } +} + +TEST_F(CoreAIPteTest, RejectsInvalidSelectedSegmentBounds) { + for (int fault = 0; fault < 4; ++fault) { + SCOPED_TRACE(fault); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + const auto header = + ExtendedHeader::Parse(pte.bytes.data(), pte.bytes.size()); + ASSERT_TRUE(header.ok()); + const auto* segment = fb::GetProgram(pte.bytes.data())->segments()->Get(0); + if (fault == 0) { + ASSERT_NO_FATAL_FAILURE(set_field( + segment, + fb::DataSegment::VT_OFFSET, + std::numeric_limits::max())); + } else if (fault == 1) { + ASSERT_NO_FATAL_FAILURE(set_field( + segment, + fb::DataSegment::VT_SIZE, + std::numeric_limits::max())); + } else if (fault == 2) { + ASSERT_NO_FATAL_FAILURE(set_field( + segment, fb::DataSegment::VT_OFFSET, header->segment_data_size)); + } else { + flatbuffers::WriteScalar( + pte.bytes.data() + 32, segment->size() - 1); + } + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.requests.size(), 2); + EXPECT_EQ(pte.backend_loads, 0); + } +} + +TEST_F(CoreAIPteTest, RejectsShortLoadsFromEveryPhase) { + for (int phase = 0; phase < 3; ++phase) { + SCOPED_TRACE(phase); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + pte.fault_call = phase; + pte.load_fault = LoadFault::Short; + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.requests.size(), phase + 1); + EXPECT_TRUE(pte.program_alive_at_backend_release); + } +} + +TEST_F(CoreAIPteTest, RejectsMisalignedProgramImages) { + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload()}}}}); + pte.fault_call = 1; + pte.load_fault = LoadFault::Misaligned; + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte, Error::InvalidArgument)); + EXPECT_EQ(pte.requests.size(), 2); + EXPECT_EQ(pte.backend_loads, 0); +} + +TEST_F(CoreAIPteTest, RejectsMalformedFlatbufferAndUnsupportedVersion) { + for (bool version : {false, true}) { + SCOPED_TRACE(version ? "unsupported version" : "bad identifier"); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload()}}}}, false); + if (version) { + ASSERT_NO_FATAL_FAILURE(set_field( + fb::GetProgram(pte.bytes.data()), + fb::Program::VT_VERSION, + Program::kMaxSupportedSchemaVersion + 1)); + } else { + pte.bytes[4] = 'X'; + } + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.backend_loads, 0); + } +} + +TEST_F(CoreAIPteTest, RejectsSemanticallyInvalidVerifiedProgram) { + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload()}}}}); + const auto* program = fb::GetProgram(pte.bytes.data()); + ASSERT_NO_FATAL_FAILURE(clear_field( + program->execution_plan()->Get(0), fb::ExecutionPlan::VT_VALUES)); + flatbuffers::Verifier verifier(pte.bytes.data(), pte.bytes.size()); + ASSERT_TRUE(fb::VerifyProgramBuffer(verifier)); + EXPECT_EQ(runtime::validate_program(program), Error::InvalidProgram); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.backend_loads, 0); +} + +TEST_F(CoreAIPteTest, RejectsUnknownProcessedDataLocation) { + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload()}}}}); + const auto* reference = fb::GetProgram(pte.bytes.data()) + ->execution_plan() + ->Get(0) + ->delegates() + ->Get(0) + ->processed(); + ASSERT_NO_FATAL_FAILURE(set_field( + reference, fb::BackendDelegateDataReference::VT_LOCATION, 255)); + flatbuffers::Verifier verifier(pte.bytes.data(), pte.bytes.size()); + ASSERT_TRUE(fb::VerifyProgramBuffer(verifier)); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.backend_loads, 0); +} + +TEST_F(CoreAIPteTest, AcceptsLegacyHeaderWithUnusedSegmentBase) { + for (uint64_t base : {uint64_t(1), std::numeric_limits::max()}) { + SCOPED_TRACE(base); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload()}}}}); + flatbuffers::WriteScalar(pte.bytes.data() + 24, base); + flatbuffers::WriteScalar(pte.bytes.data() + 32, 0); + { + auto program = + Program::load(&pte, Program::Verification::InternalConsistency); + ASSERT_TRUE(program.ok()); + auto result = inspect_coreai_pte(pte); + ASSERT_TRUE(result.ok()); + ASSERT_EQ(result->processed.size(), 1); + ASSERT_NO_FATAL_FAILURE(expect_payload(result->processed[0], payload())); + EXPECT_EQ(pte.backend_loads, 0); + } + EXPECT_TRUE(pte.check_released()); + } +} + +TEST_F(CoreAIPteTest, ChecksSelectedLegacySegmentBaseBeforeSubtracting) { + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + flatbuffers::WriteScalar( + pte.bytes.data() + 24, std::numeric_limits::max()); + flatbuffers::WriteScalar(pte.bytes.data() + 32, 0); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte)); + EXPECT_EQ(pte.requests.size(), 2); + EXPECT_EQ(pte.backend_loads, 0); +} + +TEST_F(CoreAIPteTest, RejectsSegmentsWithZeroBase) { + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + flatbuffers::WriteScalar(pte.bytes.data() + 24, 0); + flatbuffers::WriteScalar(pte.bytes.data() + 32, 0); + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte, Error::NotFound)); + EXPECT_EQ(pte.backend_loads, 0); +} + +TEST_F(CoreAIPteTest, PropagatesLoadErrorsFromEveryPhase) { + const char* phases[] = { + "size", "prefix", "retained program", "selected segment"}; + for (int phase = 0; phase < 4; ++phase) { + SCOPED_TRACE(phases[phase]); + FBSyntheticPTE pte({{"forward", {{"CoreAIBackend", payload(), true}}}}); + if (phase == 0) { + pte.size_error = Error::AccessFailed; + } else { + pte.fault_call = phase - 1; + pte.load_fault = LoadFault::Error; + pte.load_error = Error::AccessFailed; + } + ASSERT_NO_FATAL_FAILURE(expect_invalid(pte, Error::AccessFailed)); + EXPECT_EQ(pte.requests.size(), phase); + } +} + +TEST_F(CoreAIPteTest, ReleasesEarlierBuffersOnLateFailure) { + for (auto fault : + {LoadFault::Error, LoadFault::Short, LoadFault::Oversized}) { + SCOPED_TRACE(static_cast(fault)); + FBSyntheticPTE pte( + {{"forward", + {{"CoreAIBackend", payload()}, + {"CoreAIBackend", payload("one"), true}, + {"CoreAIBackend", payload("two"), true}}}}); + pte.fault_call = 3; + pte.load_fault = fault; + const bool load_error = fault == LoadFault::Error; + ASSERT_NO_FATAL_FAILURE(expect_invalid( + pte, load_error ? Error::NotSupported : Error::InvalidProgram)); + EXPECT_EQ(pte.backend_loads, load_error ? 1 : 2); + EXPECT_TRUE(pte.program_alive_at_backend_release); + ASSERT_FALSE(pte.release_order.empty()); + EXPECT_EQ(pte.release_order.back(), SegmentType::Program); + } +} + +} // namespace +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_source_fixture.h b/backends/apple/coreai/runtime/test/coreai_source_fixture.h index 561466eb208..a5377e4f93d 100644 --- a/backends/apple/coreai/runtime/test/coreai_source_fixture.h +++ b/backends/apple/coreai/runtime/test/coreai_source_fixture.h @@ -11,6 +11,7 @@ #include #include #include +#include "coreai_cache_entry_fixture.h" #include "coreai_filesystem_fixture.h" #include "coreai_manifest_fixture.h" #include "coreai_source_bundle.h" @@ -38,6 +39,9 @@ class TestData final : public runtime::NamedDataMap { }; void aot_data(TestData& data, NSString* arch); +NSString* source_key(const SelectedBundle& bundle); +NSURL* sources_root(NSURL* cache_root); +NSURL* source_entry(NSURL* cache_root, const SelectedBundle& bundle); class CoreAISourceTest : public ::testing::Test { protected: diff --git a/backends/apple/coreai/runtime/test/coreai_source_fixture.mm b/backends/apple/coreai/runtime/test/coreai_source_fixture.mm index fbed96d3fa4..c06cef50cf2 100644 --- a/backends/apple/coreai/runtime/test/coreai_source_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_source_fixture.mm @@ -9,7 +9,7 @@ #include "coreai_source_fixture.h" #include -#include "coreai_bookmarks.h" +#include "coreai_cache_entry.h" #include "coreai_cache_key.h" namespace executorch::backends::coreai::testing { @@ -74,6 +74,22 @@ void aot_data(TestData& data, NSString* arch) { data.files[data.required_prefix + "nested/weights.bin"] = "weights"; } +NSString* source_key(const SelectedBundle& bundle) { + auto key = cache_variant_key(bundle, @"macOS", @"arch_b", @"default"); + EXPECT_TRUE(key.ok()); + return key.ok() ? key.get() : nil; +} + +NSURL* sources_root(NSURL* cache_root) { + return [cache_root URLByAppendingPathComponent:@"sources" isDirectory:YES]; +} + +NSURL* source_entry(NSURL* cache_root, const SelectedBundle& bundle) { + return + [sources_root(cache_root) URLByAppendingPathComponent:source_key(bundle) + isDirectory:YES]; +} + Result CoreAISourceTest::prepare_source_bundle( const SelectedBundle& bundle, const runtime::NamedDataMap* data, @@ -84,7 +100,7 @@ void aot_data(TestData& data, NSString* arch) { ADD_FAILURE() << "Cannot compute source fixture key"; return key.error(); } - auto lock = lock_cache_entry(cache_root, key.get()); + auto lock = acquire_test_cache_entry(cache_root, key.get()); if (!lock.ok()) { ADD_FAILURE() << "Cannot acquire source fixture lock"; return lock.error(); diff --git a/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm b/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm new file mode 100644 index 00000000000..264042988e7 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_source_integrity_test.mm @@ -0,0 +1,304 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include "coreai_cache_entry.h" +#include "coreai_cache_key.h" +#include "coreai_fault_scope.h" +#include "coreai_source_fixture.h" +namespace executorch::backends::coreai::testing { +using runtime::Error; +using runtime::Result; +TEST_F(CoreAISourceTest, SourcesRemainAnchoredWhenConfiguredSymlinkChanges) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* target = [storage.url URLByAppendingPathComponent:@"target"]; + NSURL* replacement = [storage.url URLByAppendingPathComponent:@"replacement"]; + NSURL* configured = [storage.url URLByAppendingPathComponent:@"configured"]; + ASSERT_EQ(mkdir(target.fileSystemRepresentation, 0777), 0); + ASSERT_EQ(mkdir(replacement.fileSystemRepresentation, 0777), 0); + ASSERT_EQ( + symlink( + target.fileSystemRepresentation, configured.fileSystemRepresentation), + 0); + auto bundle = selected_bundle(manifest_dict()); + ASSERT_TRUE(bundle.ok()); + auto key = test_cache_key(bundle.get()); + ASSERT_TRUE(key.ok()); + auto directory = test_cache_directory(configured.path); + ASSERT_TRUE(directory.ok()); + NSString* canonical_target = [directory->path copy]; + auto locked = LockedCacheEntry::acquire(directory.get(), key.get()); + ASSERT_TRUE(locked.ok()); + + ASSERT_EQ(unlink(configured.fileSystemRepresentation), 0); + ASSERT_EQ( + symlink( + replacement.fileSystemRepresentation, + configured.fileSystemRepresentation), + 0); + TestData data; + auto prepared = executorch::backends::coreai::prepare_source_bundle( + bundle.get(), &data, *locked.get()); + ASSERT_TRUE(prepared.ok()); + EXPECT_TRUE([prepared.get().path hasPrefix:canonical_target]); + EXPECT_FALSE([prepared.get().path + hasPrefix:replacement.URLByResolvingSymlinksInPath.path]); + EXPECT_TRUE( + [NSFileManager.defaultManager fileExistsAtPath:prepared.get().path]); +} + +TEST_F(CoreAISourceTest, RejectsMalformedStoredBundlesWithoutReplacement) { + for (bool aot : {false, true}) { + SCOPED_TRACE(aot ? "aot" : "source"); + for (NSString* damage in @[ + @"missing-file", + @"extra-file", + @"wrong-size", + @"symlink-file", + @"file-at-key", + @"extra-directory" + ]) { + SCOPED_TRACE(damage.UTF8String); + TestDirectory storage; + TestDirectory target; + ASSERT_NE(storage.url, nil); + ASSERT_NE(target.url, nil); + TestData data; + if (aot) + aot_data(data, @"arch_b"); + auto manifest = + selected_bundle(aot ? aot_manifest_dict() : manifest_dict()); + ASSERT_TRUE(manifest.ok()); + auto source = + prepare_source_bundle(manifest.get(), &data, storage.url.path); + ASSERT_TRUE(source.ok()); + NSURL* entry = source.get().URLByDeletingLastPathComponent; + NSURL* graph = [source.get() URLByAppendingPathComponent:@"graph.bin"]; + NSData* bytes = [NSData dataWithContentsOfURL:graph]; + ASSERT_NE(bytes, nil); + NSFileManager* manager = NSFileManager.defaultManager; + if ([damage isEqual:@"missing-file"]) { + ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); + } else if ([damage isEqual:@"extra-file"]) { + ASSERT_TRUE([[NSData data] + writeToURL:[source.get() URLByAppendingPathComponent:@"extra"] + atomically:NO]); + } else if ([damage isEqual:@"wrong-size"]) { + ASSERT_TRUE([[NSData data] writeToURL:graph atomically:NO]); + } else if ([damage isEqual:@"symlink-file"]) { + NSURL* destination = + [target.url URLByAppendingPathComponent:@"graph.bin"]; + ASSERT_TRUE([bytes writeToURL:destination atomically:NO]); + ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); + ASSERT_TRUE([manager createSymbolicLinkAtPath:graph.path + withDestinationPath:destination.path + error:nil]); + } else if ([damage isEqual:@"file-at-key"]) { + ASSERT_TRUE([manager removeItemAtURL:entry error:nil]); + ASSERT_TRUE([bytes writeToURL:entry atomically:NO]); + } else { + ASSERT_TRUE([manager + createDirectoryAtURL: + [source.get() URLByAppendingPathComponent:@"extra"] + withIntermediateDirectories:NO + attributes:nil + error:nil]); + } + NSDictionary* snapshot = nil; + NSDictionary* target_snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); + ASSERT_TRUE(asset_tree_snapshot(target.url, target_snapshot)); + const int attempts = data.attempts; + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path) + .error(), + Error::InvalidExternalData); + EXPECT_EQ(data.attempts, attempts); + EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); + EXPECT_TRUE(snapshot_matches(target.url, target_snapshot)); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + } + } +} + +TEST_F(CoreAISourceTest, ExplicitReplacementRepairsMalformedStoredBundles) { + for (NSString* damage in @[ + @"missing-file", + @"extra-file", + @"wrong-size", + @"symlink-file", + @"fifo-file", + @"file-at-key", + @"symlink-at-key", + @"extra-directory" + ]) { + SCOPED_TRACE(damage.UTF8String); + TestDirectory storage; + TestDirectory target; + ASSERT_NE(storage.url, nil); + ASSERT_NE(target.url, nil); + TestData data; + auto bundle = selected_bundle(manifest_dict()); + ASSERT_TRUE(bundle.ok()); + auto source = prepare_source_bundle(bundle.get(), &data, storage.url.path); + ASSERT_TRUE(source.ok()); + NSURL* entry = source.get().URLByDeletingLastPathComponent; + NSURL* graph = [source.get() URLByAppendingPathComponent:@"graph.bin"]; + NSFileManager* manager = NSFileManager.defaultManager; + if ([damage isEqual:@"file-at-key"] || [damage isEqual:@"symlink-at-key"]) { + ASSERT_TRUE([manager removeItemAtURL:entry error:nil]); + if ([damage isEqual:@"file-at-key"]) { + ASSERT_TRUE([[NSData data] writeToURL:entry atomically:NO]); + } else { + ASSERT_EQ( + symlink( + target.url.fileSystemRepresentation, + entry.fileSystemRepresentation), + 0); + } + } else if ([damage isEqual:@"extra-file"]) { + ASSERT_TRUE([[NSData data] + writeToURL:[source.get() URLByAppendingPathComponent:@"extra"] + atomically:NO]); + } else if ([damage isEqual:@"extra-directory"]) { + ASSERT_TRUE([manager + createDirectoryAtURL:[source.get() + URLByAppendingPathComponent:@"extra"] + withIntermediateDirectories:NO + attributes:nil + error:nil]); + } else { + ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); + if ([damage isEqual:@"wrong-size"]) { + ASSERT_TRUE([[NSData data] writeToURL:graph atomically:NO]); + } else if ([damage isEqual:@"symlink-file"]) { + NSURL* sentinel = [target.url URLByAppendingPathComponent:@"sentinel"]; + ASSERT_TRUE([@"external" writeToURL:sentinel + atomically:NO + encoding:NSUTF8StringEncoding + error:nil]); + ASSERT_EQ( + symlink( + sentinel.fileSystemRepresentation, + graph.fileSystemRepresentation), + 0); + } else if ([damage isEqual:@"fifo-file"]) { + ASSERT_EQ(mkfifo(graph.fileSystemRepresentation, 0600), 0); + } + } + NSDictionary* target_snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(target.url, target_snapshot)); + auto repaired = prepare_source_bundle( + bundle.get(), &data, storage.url.path, CorruptBundlePolicy::Replace); + ASSERT_TRUE(repaired.ok()); + EXPECT_TRUE([repaired.get() isEqual:source.get()]); + EXPECT_TRUE(([[NSData + dataWithContentsOfURL:[repaired.get() + URLByAppendingPathComponent:@"graph.bin"]] + isEqual:[NSData dataWithBytes:"model data" length:11]])); + EXPECT_EQ(data.attempts, 2); + EXPECT_EQ(data.requests, data.releases); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + EXPECT_TRUE(snapshot_matches(target.url, target_snapshot)); + } +} + +TEST_F(CoreAISourceTest, PublishedDirectoriesUseDefaultPermissions) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + NSURL* reference = [storage.url URLByAppendingPathComponent:@"reference"]; + ASSERT_EQ(mkdir(reference.fileSystemRepresentation, 0777), 0); + struct stat expected; + ASSERT_EQ(stat(reference.fileSystemRepresentation, &expected), 0); + TestData data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); + ASSERT_TRUE(source.ok()); + for (NSURL* directory in + @[ source.get().URLByDeletingLastPathComponent, source.get() ]) { + SCOPED_TRACE(directory.path.UTF8String); + struct stat actual; + ASSERT_EQ(stat(directory.fileSystemRepresentation, &actual), 0); + EXPECT_EQ(actual.st_mode & 0777, expected.st_mode & 0777); + } +} + +TEST_F(CoreAISourceTest, PermissionErrorsRemainDistinctFromCorruptEntries) { + if (geteuid() == 0) { + GTEST_SKIP() << "Root can bypass directory read permissions"; + } + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); + ASSERT_TRUE(source.ok()); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); + for (NSURL* directory in + @[ source.get().URLByDeletingLastPathComponent, source.get() ]) { + SCOPED_TRACE(directory.path.UTF8String); + struct stat original; + ASSERT_EQ(stat(directory.fileSystemRepresentation, &original), 0); + ASSERT_EQ(chmod(directory.fileSystemRepresentation, 0000), 0); + auto denied = + prepare_source_bundle(manifest.get(), &data, storage.url.path); + EXPECT_EQ( + chmod(directory.fileSystemRepresentation, original.st_mode & 07777), 0); + EXPECT_EQ(denied.error(), Error::AccessFailed); + EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); + EXPECT_EQ(data.attempts, 1); + EXPECT_TRUE( + prepare_source_bundle(manifest.get(), nullptr, storage.url.path).ok()); + } +} + +TEST_F(CoreAISourceTest, PreservesNamedDataErrorsWithoutRetrying) { + for (Error error : {Error::AccessFailed, Error::MemoryAllocationFailed}) { + SCOPED_TRACE(static_cast(error)); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + data.fail_at = 1; + data.failure_error = error; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), + error); + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, 0); + EXPECT_EQ(data.releases, 0); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:source_entry(storage.url, manifest.get()).path]); + } +} + +TEST_F(CoreAISourceTest, MissingNamedDataIsInvalidExternalData) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + data.files.clear(); + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), + Error::InvalidExternalData); + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, 0); + EXPECT_EQ(pending_directories(storage.url).count, 0u); +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_source_publication_test.mm b/backends/apple/coreai/runtime/test/coreai_source_publication_test.mm new file mode 100644 index 00000000000..5179f875826 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_source_publication_test.mm @@ -0,0 +1,295 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include +#include +#include "coreai_cache_entry.h" +#include "coreai_cache_key.h" +#include "coreai_fault_scope.h" +#include "coreai_source_fixture.h" +namespace executorch::backends::coreai::testing { +using runtime::Error; +using runtime::Result; +TEST_F(CoreAISourceTest, RejectsMismatchedBufferSizesAndReleasesPayload) { + for (size_t size : {0u, 10u, 12u}) { + SCOPED_TRACE(size); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + data.files["coreai/ab/model.aimodel/graph.bin"] = std::string(size, 'x'); + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), + Error::InvalidExternalData); + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, 1); + EXPECT_EQ(data.releases, 1); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + EXPECT_FALSE([NSFileManager.defaultManager + fileExistsAtPath:source_entry(storage.url, manifest.get()).path]); + } +} + +TEST_F(CoreAISourceTest, FullyFlushesSourceBeforeAndAfterPublication) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + aot_data(data, @"arch_b"); + auto manifest = selected_bundle(aot_manifest_dict()); + ASSERT_TRUE(manifest.ok()); + NSURL* final = source_entry(storage.url, manifest.get()); + NSURL* root = sources_root(storage.url); + const NSUInteger file_count = manifest->files.count; + __block int synced_files = 0; + __block int full_syncs = 0; + __block int publications = 0; + __block bool ordered = true; + StorageFaultScope fault(FaultPoint::FullSync); + fault.armed = false; + fault.observe = ^(FaultPoint operation) { + if (operation == FaultPoint::SyncFile) + ++synced_files; + if (operation == FaultPoint::FullSync) { + ++full_syncs; + ordered &= synced_files == file_count; + const bool published = + [NSFileManager.defaultManager fileExistsAtPath:final.path]; + ordered &= full_syncs == 1 ? !published : published; + ordered &= pending_directories(root).count == (full_syncs == 1 ? 1u : 0u); + } + if (operation == FaultPoint::Rename) { + ++publications; + ordered &= full_syncs == 1; + } + }; + auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); + ASSERT_TRUE(source.ok()); + EXPECT_TRUE(ordered); + EXPECT_EQ(full_syncs, 2); + EXPECT_EQ(publications, 1); + EXPECT_EQ(data.attempts, file_count); + EXPECT_EQ(data.requests, data.releases); +} + +TEST_F(CoreAISourceTest, PublicationFailuresCleanPendingWithoutAutomaticRetry) { + for (auto operation : + {FaultPoint::CreatePendingDirectory, + FaultPoint::OpenPendingDirectory, + FaultPoint::Write, + FaultPoint::SyncFile, + FaultPoint::SyncDirectory, + FaultPoint::FullSync, + FaultPoint::Rename}) { + SCOPED_TRACE(static_cast(operation)); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + NSString* key = source_key(manifest.get()); + auto lock = acquire_test_cache_entry(storage.url.path, key); + ASSERT_TRUE(lock.ok()); + NSURL* final = source_entry(storage.url, manifest.get()); + { + StorageFaultScope fault(operation); + EXPECT_EQ( + executorch::backends::coreai::prepare_source_bundle( + manifest.get(), &data, *lock.get()) + .error(), + Error::AccessFailed); + EXPECT_EQ(fault.matches, 1); + EXPECT_EQ(fault.hits, 1); + } + const int attempts = operation == FaultPoint::CreatePendingDirectory || + operation == FaultPoint::OpenPendingDirectory || + operation == FaultPoint::SyncDirectory + ? 0 + : 1; + EXPECT_EQ(data.attempts, attempts); + EXPECT_EQ(data.requests, data.releases); + EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:final.path]); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + auto retried = executorch::backends::coreai::prepare_source_bundle( + manifest.get(), &data, *lock.get()); + ASSERT_TRUE(retried.ok()); + EXPECT_EQ(data.attempts, attempts + 1); + } +} + +TEST_F(CoreAISourceTest, RetriesInterruptedPendingAndPublicationCalls) { + for (auto operation : + {FaultPoint::CreatePendingDirectory, + FaultPoint::OpenPendingDirectory, + FaultPoint::SyncDirectory, + FaultPoint::Rename}) { + SCOPED_TRACE(static_cast(operation)); + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + NSString* key = source_key(manifest.get()); + auto lock = acquire_test_cache_entry(storage.url.path, key); + ASSERT_TRUE(lock.ok()); + StorageFaultScope fault(operation); + fault.error = EINTR; + auto source = executorch::backends::coreai::prepare_source_bundle( + manifest.get(), &data, *lock.get()); + ASSERT_TRUE(source.ok()); + EXPECT_EQ(fault.hits, 1); + EXPECT_GE(fault.matches, 2); + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, data.releases); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + } +} + +TEST_F(CoreAISourceTest, PostRenameFlushFailureLeavesCompleteSourceVisible) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + TestData data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + NSURL* final = source_entry(storage.url, manifest.get()); + { + StorageFaultScope fault(FaultPoint::FullSync); + fault.match_index = 2; + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), + Error::AccessFailed); + EXPECT_EQ(fault.matches, 2); + EXPECT_EQ(fault.hits, 1); + } + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, data.releases); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + NSURL* graph = [final URLByAppendingPathComponent:@"model.aimodel/graph.bin"]; + EXPECT_TRUE(([[NSData dataWithContentsOfURL:graph] + isEqual:[NSData dataWithBytes:"model data" length:11]])); + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); + { + StorageFaultScope fault(FaultPoint::FullSync); + EXPECT_EQ( + prepare_source_bundle(manifest.get(), nullptr, storage.url.path) + .error(), + Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); + } + StorageFaultScope flush(FaultPoint::FullSync); + flush.error = EINTR; + auto reused = + prepare_source_bundle(manifest.get(), nullptr, storage.url.path); + ASSERT_TRUE(reused.ok()); + EXPECT_EQ(flush.hits, 1); + EXPECT_EQ(flush.matches, 2); + EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); + EXPECT_EQ(data.attempts, 1); +} + +TEST_F(CoreAISourceTest, IncompleteConcurrentWinnerFailsWithoutReplacement) { + TestDirectory storage; + TestDirectory winner_storage; + ASSERT_NE(storage.url, nil); + ASSERT_NE(winner_storage.url, nil); + TestData data; + TestData winner_data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + auto winner = prepare_source_bundle( + manifest.get(), &winner_data, winner_storage.url.path); + ASSERT_TRUE(winner.ok()); + ASSERT_TRUE([NSFileManager.defaultManager + removeItemAtURL:[winner.get() URLByAppendingPathComponent:@"graph.bin"] + error:nil]); + NSURL* winner_entry = winner.get().URLByDeletingLastPathComponent; + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(winner_entry, snapshot)); + NSURL* root = sources_root(storage.url); + NSURL* final = source_entry(storage.url, manifest.get()); + __block int publications = 0; + __block bool callback_ok = true; + { + StorageFaultScope fault(FaultPoint::Rename); + fault.armed = false; + fault.observe = ^(FaultPoint operation) { + if (operation != FaultPoint::Rename) + return; + ++publications; + callback_ok &= pending_directories(root).count == 1; + callback_ok &= [NSFileManager.defaultManager moveItemAtURL:winner_entry + toURL:final + error:nil]; + }; + EXPECT_EQ( + prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), + Error::InvalidExternalData); + EXPECT_TRUE(callback_ok); + EXPECT_EQ(publications, 1); + } + EXPECT_EQ(data.attempts, 1); + EXPECT_EQ(data.requests, data.releases); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + EXPECT_TRUE(snapshot_matches(final, snapshot)); +} + +TEST_F( + CoreAISourceTest, + ExclusivePublicationValidatesWinnerAndRemovesLosingPending) { + TestDirectory storage; + TestDirectory winner_storage; + ASSERT_NE(storage.url, nil); + ASSERT_NE(winner_storage.url, nil); + TestData data; + TestData winner_data; + auto manifest = selected_bundle(manifest_dict()); + ASSERT_TRUE(manifest.ok()); + auto winner = prepare_source_bundle( + manifest.get(), &winner_data, winner_storage.url.path); + ASSERT_TRUE(winner.ok()); + NSURL* winner_entry = winner.get().URLByDeletingLastPathComponent; + NSDictionary* snapshot = nil; + ASSERT_TRUE(asset_tree_snapshot(winner_entry, snapshot)); + NSURL* root = sources_root(storage.url); + NSURL* final = source_entry(storage.url, manifest.get()); + __block int publications = 0; + __block bool callback_ok = true; + { + StorageFaultScope fault(FaultPoint::FullSync); + fault.match_index = 2; + fault.error = EINTR; + fault.observe = ^(FaultPoint operation) { + if (operation != FaultPoint::Rename) + return; + ++publications; + callback_ok &= pending_directories(root).count == 1; + callback_ok &= [NSFileManager.defaultManager moveItemAtURL:winner_entry + toURL:final + error:nil]; + }; + auto result = + prepare_source_bundle(manifest.get(), &data, storage.url.path); + EXPECT_TRUE(callback_ok); + ASSERT_TRUE(result.ok()); + EXPECT_TRUE([result.get() + isEqual:[final URLByAppendingPathComponent:manifest->bundle_name]]); + EXPECT_EQ(publications, 1); + EXPECT_EQ(fault.hits, 1); + EXPECT_EQ(fault.matches, 3); + } + EXPECT_EQ(data.attempts, manifest->files.count); + EXPECT_EQ(data.requests, data.releases); + EXPECT_EQ(pending_directories(storage.url).count, 0u); + EXPECT_TRUE(snapshot_matches(final, snapshot)); +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_source_test.mm b/backends/apple/coreai/runtime/test/coreai_source_test.mm index 301dafad56e..67ec531c6a0 100644 --- a/backends/apple/coreai/runtime/test/coreai_source_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_source_test.mm @@ -9,47 +9,17 @@ #include #include #include -#include "coreai_bookmarks.h" +#include +#include "coreai_cache_entry.h" #include "coreai_cache_key.h" #include "coreai_fault_scope.h" #include "coreai_source_fixture.h" +#include "coreai_test_utils.h" namespace executorch::backends::coreai::testing { using runtime::Error; using runtime::Result; -namespace { -Result selected_bundle( - NSDictionary* dictionary, - NSString* architecture = @"arch_b") { - auto parsed = parse_manifest(encode(dictionary)); - if (!parsed.ok()) { - return parsed.error(); - } - return select_assets(parsed.get(), architecture, @"macOS"); -} - -Result source_manifest(bool aot = false) { - return selected_bundle(aot ? aot_manifest_dict() : manifest_dict()); -} - -NSString* source_key(const SelectedBundle& bundle) { - auto key = cache_variant_key(bundle, @"macOS", @"arch_b", @"default"); - EXPECT_TRUE(key.ok()); - return key.ok() ? key.get() : nil; -} - -NSURL* staging_root(NSURL* cache_root) { - return [cache_root URLByAppendingPathComponent:@"staging" isDirectory:YES]; -} - -NSURL* source_entry(NSURL* cache_root, const SelectedBundle& bundle) { - return - [staging_root(cache_root) URLByAppendingPathComponent:source_key(bundle) - isDirectory:YES]; -} -} // namespace - TEST_F(CoreAISourceTest, UnicodePathsMaterializeAndReuse) { NSString* composed = [NSString stringWithFormat:@"caf%C", 0x00e9]; for (NSString* name in @@ -118,7 +88,7 @@ SCOPED_TRACE(arch.UTF8String); TestData data; aot_data(data, arch); - auto selected = select_assets(parsed.get(), arch, @"macOS"); + auto selected = select_bundle(parsed.get(), arch, @"macOS"); ASSERT_TRUE(selected.ok()); auto first = prepare_source_bundle(selected.get(), &data, storage.url.path); ASSERT_TRUE(first.ok()); @@ -155,7 +125,7 @@ unselected_changed[@"files"] = files; auto changed_parsed = parse_manifest(encode(unselected_changed)); ASSERT_TRUE(changed_parsed.ok()); - auto unchanged = select_assets(changed_parsed.get(), arch, @"macOS"); + auto unchanged = select_bundle(changed_parsed.get(), arch, @"macOS"); ASSERT_TRUE(unchanged.ok()); auto unchanged_source = prepare_source_bundle(unchanged.get(), &data, storage.url.path); @@ -170,7 +140,7 @@ TestDirectory storage; ASSERT_NE(storage.url, nil); TestData data; - auto selected = source_manifest(); + auto selected = selected_bundle(manifest_dict()); ASSERT_TRUE(selected.ok()); for (int pass = 0; pass < 2; ++pass) { SCOPED_TRACE(pass == 0 ? "cold" : "warm"); @@ -203,9 +173,7 @@ auto& worker = *static_cast(context); @autoreleasepool { worker.started = - dispatch_semaphore_wait( - worker.start, - dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) == 0; + dispatch_semaphore_wait(worker.start, test_deadline()) == 0; if (!worker.started) return nullptr; auto assets = worker.fixture->prepare_source_bundle( @@ -239,7 +207,7 @@ EXPECT_EQ(data.metadata_requests, 0); EXPECT_EQ( [NSFileManager.defaultManager - contentsOfDirectoryAtPath:staging_root(storage.url).path + contentsOfDirectoryAtPath:sources_root(storage.url).path error:nil] .count, 1u); @@ -251,7 +219,7 @@ } TEST_F(CoreAISourceTest, MissingSourceRequiresNamedData) { - auto bundle = source_manifest(); + auto bundle = selected_bundle(manifest_dict()); ASSERT_TRUE(bundle.ok()); TestData data; TestDirectory storage; @@ -274,7 +242,7 @@ TestData data; TestDirectory storage; ASSERT_NE(storage.url, nil); - auto manifest = source_manifest(); + auto manifest = selected_bundle(manifest_dict()); ASSERT_TRUE(manifest.ok()); NSURL* first_url = nil; { @@ -338,7 +306,7 @@ EXPECT_EQ(data.releases, data.requests); } -TEST_F(CoreAISourceTest, PartialMaterializationCleansStagingAndCanRetry) { +TEST_F(CoreAISourceTest, PartialMaterializationCleansPendingAndCanRetry) { TestDirectory storage; ASSERT_NE(storage.url, nil); TestData data; @@ -358,7 +326,7 @@ EXPECT_EQ(data.attempts, 2); EXPECT_EQ( [NSFileManager.defaultManager - contentsOfDirectoryAtPath:staging_root(storage.url).path + contentsOfDirectoryAtPath:sources_root(storage.url).path error:nil] .count, 0u); @@ -368,7 +336,7 @@ prepare_source_bundle(manifest.get(), &data, storage.url.path).ok()); EXPECT_EQ( [NSFileManager.defaultManager - contentsOfDirectoryAtPath:staging_root(storage.url).path + contentsOfDirectoryAtPath:sources_root(storage.url).path error:nil] .count, 1u); @@ -422,24 +390,24 @@ TEST_F( CoreAISourceTest, - ExplicitStagingKeyReusesSourceWithoutWritesOrRepublication) { + ExplicitSourceKeyReusesSourceWithoutWritesOrRepublication) { TestDirectory directory; ASSERT_NE(directory.url, nil); TestData data; - auto bundle = source_manifest(); + auto bundle = selected_bundle(manifest_dict()); ASSERT_TRUE(bundle.ok()); auto key = cache_variant_key( bundle.get(), @"macOS", @"arch_b", @"explicit-test-options"); ASSERT_TRUE(key.ok()); - auto lock = lock_cache_entry(directory.url.path, key.get()); + auto lock = acquire_test_cache_entry(directory.url.path, key.get()); ASSERT_TRUE(lock.ok()); auto first = executorch::backends::coreai::prepare_source_bundle( bundle.get(), &data, *lock.get()); ASSERT_TRUE(first.ok()); EXPECT_EQ(data.attempts, 1); - NSString* expected_path = [directory.url.path + NSString* expected_path = [directory.url.URLByResolvingSymlinksInPath.path stringByAppendingPathComponent: - [NSString stringWithFormat:@"staging/%@/model.aimodel", key.get()]]; + [NSString stringWithFormat:@"sources/%@/model.aimodel", key.get()]]; EXPECT_TRUE([first.get().path isEqualToString:expected_path]); NSDictionary* snapshot = nil; ASSERT_TRUE(asset_tree_snapshot(directory.url, snapshot)); @@ -457,523 +425,4 @@ EXPECT_TRUE(snapshot_matches(directory.url, snapshot)); } -TEST_F(CoreAISourceTest, RejectsMalformedStoredBundlesWithoutReplacement) { - for (bool aot : {false, true}) { - SCOPED_TRACE(aot ? "aot" : "source"); - for (NSString* damage in @[ - @"missing-file", - @"extra-file", - @"wrong-size", - @"symlink-file", - @"file-at-key", - @"extra-directory" - ]) { - SCOPED_TRACE(damage.UTF8String); - TestDirectory storage; - TestDirectory target; - ASSERT_NE(storage.url, nil); - ASSERT_NE(target.url, nil); - TestData data; - if (aot) - aot_data(data, @"arch_b"); - auto manifest = source_manifest(aot); - ASSERT_TRUE(manifest.ok()); - auto source = - prepare_source_bundle(manifest.get(), &data, storage.url.path); - ASSERT_TRUE(source.ok()); - NSURL* entry = source.get().URLByDeletingLastPathComponent; - NSURL* graph = [source.get() URLByAppendingPathComponent:@"graph.bin"]; - NSData* bytes = [NSData dataWithContentsOfURL:graph]; - ASSERT_NE(bytes, nil); - NSFileManager* manager = NSFileManager.defaultManager; - if ([damage isEqual:@"missing-file"]) { - ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); - } else if ([damage isEqual:@"extra-file"]) { - ASSERT_TRUE([[NSData data] - writeToURL:[source.get() URLByAppendingPathComponent:@"extra"] - atomically:NO]); - } else if ([damage isEqual:@"wrong-size"]) { - ASSERT_TRUE([[NSData data] writeToURL:graph atomically:NO]); - } else if ([damage isEqual:@"symlink-file"]) { - NSURL* destination = - [target.url URLByAppendingPathComponent:@"graph.bin"]; - ASSERT_TRUE([bytes writeToURL:destination atomically:NO]); - ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); - ASSERT_TRUE([manager createSymbolicLinkAtPath:graph.path - withDestinationPath:destination.path - error:nil]); - } else if ([damage isEqual:@"file-at-key"]) { - ASSERT_TRUE([manager removeItemAtURL:entry error:nil]); - ASSERT_TRUE([bytes writeToURL:entry atomically:NO]); - } else { - ASSERT_TRUE([manager - createDirectoryAtURL: - [source.get() URLByAppendingPathComponent:@"extra"] - withIntermediateDirectories:NO - attributes:nil - error:nil]); - } - NSDictionary* snapshot = nil; - NSDictionary* target_snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); - ASSERT_TRUE(asset_tree_snapshot(target.url, target_snapshot)); - const int attempts = data.attempts; - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path) - .error(), - Error::InvalidExternalData); - EXPECT_EQ(data.attempts, attempts); - EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); - EXPECT_TRUE(snapshot_matches(target.url, target_snapshot)); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - } - } -} - -TEST_F(CoreAISourceTest, ExplicitReplacementRepairsMalformedStoredBundles) { - for (NSString* damage in @[ - @"missing-file", - @"extra-file", - @"wrong-size", - @"symlink-file", - @"fifo-file", - @"file-at-key", - @"symlink-at-key", - @"extra-directory" - ]) { - SCOPED_TRACE(damage.UTF8String); - TestDirectory storage; - TestDirectory target; - ASSERT_NE(storage.url, nil); - ASSERT_NE(target.url, nil); - TestData data; - auto bundle = source_manifest(); - ASSERT_TRUE(bundle.ok()); - auto source = prepare_source_bundle(bundle.get(), &data, storage.url.path); - ASSERT_TRUE(source.ok()); - NSURL* entry = source.get().URLByDeletingLastPathComponent; - NSURL* graph = [source.get() URLByAppendingPathComponent:@"graph.bin"]; - NSFileManager* manager = NSFileManager.defaultManager; - if ([damage isEqual:@"file-at-key"] || [damage isEqual:@"symlink-at-key"]) { - ASSERT_TRUE([manager removeItemAtURL:entry error:nil]); - if ([damage isEqual:@"file-at-key"]) { - ASSERT_TRUE([[NSData data] writeToURL:entry atomically:NO]); - } else { - ASSERT_EQ( - symlink( - target.url.fileSystemRepresentation, - entry.fileSystemRepresentation), - 0); - } - } else if ([damage isEqual:@"extra-file"]) { - ASSERT_TRUE([[NSData data] - writeToURL:[source.get() URLByAppendingPathComponent:@"extra"] - atomically:NO]); - } else if ([damage isEqual:@"extra-directory"]) { - ASSERT_TRUE([manager - createDirectoryAtURL:[source.get() - URLByAppendingPathComponent:@"extra"] - withIntermediateDirectories:NO - attributes:nil - error:nil]); - } else { - ASSERT_TRUE([manager removeItemAtURL:graph error:nil]); - if ([damage isEqual:@"wrong-size"]) { - ASSERT_TRUE([[NSData data] writeToURL:graph atomically:NO]); - } else if ([damage isEqual:@"symlink-file"]) { - NSURL* sentinel = [target.url URLByAppendingPathComponent:@"sentinel"]; - ASSERT_TRUE([@"external" writeToURL:sentinel - atomically:NO - encoding:NSUTF8StringEncoding - error:nil]); - ASSERT_EQ( - symlink( - sentinel.fileSystemRepresentation, - graph.fileSystemRepresentation), - 0); - } else if ([damage isEqual:@"fifo-file"]) { - ASSERT_EQ(mkfifo(graph.fileSystemRepresentation, 0600), 0); - } - } - NSDictionary* target_snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(target.url, target_snapshot)); - auto repaired = prepare_source_bundle( - bundle.get(), &data, storage.url.path, CorruptBundlePolicy::Replace); - ASSERT_TRUE(repaired.ok()); - EXPECT_TRUE([repaired.get() isEqual:source.get()]); - EXPECT_TRUE(([[NSData - dataWithContentsOfURL:[repaired.get() - URLByAppendingPathComponent:@"graph.bin"]] - isEqual:[NSData dataWithBytes:"model data" length:11]])); - EXPECT_EQ(data.attempts, 2); - EXPECT_EQ(data.requests, data.releases); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - EXPECT_TRUE(snapshot_matches(target.url, target_snapshot)); - } -} - -TEST_F(CoreAISourceTest, PublishedDirectoriesUseDefaultPermissions) { - TestDirectory storage; - ASSERT_NE(storage.url, nil); - NSURL* reference = [storage.url URLByAppendingPathComponent:@"reference"]; - ASSERT_EQ(mkdir(reference.fileSystemRepresentation, 0777), 0); - struct stat expected; - ASSERT_EQ(stat(reference.fileSystemRepresentation, &expected), 0); - TestData data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); - ASSERT_TRUE(source.ok()); - for (NSURL* directory in - @[ source.get().URLByDeletingLastPathComponent, source.get() ]) { - SCOPED_TRACE(directory.path.UTF8String); - struct stat actual; - ASSERT_EQ(stat(directory.fileSystemRepresentation, &actual), 0); - EXPECT_EQ(actual.st_mode & 0777, expected.st_mode & 0777); - } -} - -TEST_F(CoreAISourceTest, PermissionErrorsRemainDistinctFromCorruptEntries) { - if (geteuid() == 0) { - GTEST_SKIP() << "Root can bypass directory read permissions"; - } - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); - ASSERT_TRUE(source.ok()); - NSDictionary* snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); - for (NSURL* directory in - @[ source.get().URLByDeletingLastPathComponent, source.get() ]) { - SCOPED_TRACE(directory.path.UTF8String); - struct stat original; - ASSERT_EQ(stat(directory.fileSystemRepresentation, &original), 0); - ASSERT_EQ(chmod(directory.fileSystemRepresentation, 0000), 0); - auto denied = - prepare_source_bundle(manifest.get(), &data, storage.url.path); - EXPECT_EQ( - chmod(directory.fileSystemRepresentation, original.st_mode & 07777), 0); - EXPECT_EQ(denied.error(), Error::AccessFailed); - EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); - EXPECT_EQ(data.attempts, 1); - EXPECT_TRUE( - prepare_source_bundle(manifest.get(), nullptr, storage.url.path).ok()); - } -} - -TEST_F(CoreAISourceTest, PreservesNamedDataErrorsWithoutRetrying) { - for (Error error : {Error::AccessFailed, Error::MemoryAllocationFailed}) { - SCOPED_TRACE(static_cast(error)); - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - data.fail_at = 1; - data.failure_error = error; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), - error); - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, 0); - EXPECT_EQ(data.releases, 0); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - EXPECT_FALSE([NSFileManager.defaultManager - fileExistsAtPath:source_entry(storage.url, manifest.get()).path]); - } -} - -TEST_F(CoreAISourceTest, MissingNamedDataIsInvalidExternalData) { - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - data.files.clear(); - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), - Error::InvalidExternalData); - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, 0); - EXPECT_EQ(staging_directories(storage.url).count, 0u); -} - -TEST_F(CoreAISourceTest, RejectsMismatchedBufferSizesAndReleasesPayload) { - for (size_t size : {0u, 10u, 12u}) { - SCOPED_TRACE(size); - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - data.files["coreai/ab/model.aimodel/graph.bin"] = std::string(size, 'x'); - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), - Error::InvalidExternalData); - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, 1); - EXPECT_EQ(data.releases, 1); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - EXPECT_FALSE([NSFileManager.defaultManager - fileExistsAtPath:source_entry(storage.url, manifest.get()).path]); - } -} - -TEST_F(CoreAISourceTest, FullyFlushesSourceBeforeAndAfterPublication) { - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - aot_data(data, @"arch_b"); - auto manifest = source_manifest(true); - ASSERT_TRUE(manifest.ok()); - NSURL* final = source_entry(storage.url, manifest.get()); - NSURL* root = staging_root(storage.url); - const NSUInteger file_count = manifest->files.count; - __block int synced_files = 0; - __block int full_syncs = 0; - __block int publications = 0; - __block bool ordered = true; - StorageFaultScope fault(FaultPoint::FullSync); - fault.armed = false; - fault.observe = ^(FaultPoint operation) { - if (operation == FaultPoint::SyncFile) - ++synced_files; - if (operation == FaultPoint::FullSync) { - ++full_syncs; - ordered &= synced_files == file_count; - const bool published = - [NSFileManager.defaultManager fileExistsAtPath:final.path]; - ordered &= full_syncs == 1 ? !published : published; - ordered &= staging_directories(root).count == (full_syncs == 1 ? 1u : 0u); - } - if (operation == FaultPoint::Rename) { - ++publications; - ordered &= full_syncs == 1; - } - }; - auto source = prepare_source_bundle(manifest.get(), &data, storage.url.path); - ASSERT_TRUE(source.ok()); - EXPECT_TRUE(ordered); - EXPECT_EQ(full_syncs, 2); - EXPECT_EQ(publications, 1); - EXPECT_EQ(data.attempts, file_count); - EXPECT_EQ(data.requests, data.releases); -} - -TEST_F(CoreAISourceTest, PublicationFailuresCleanStagingWithoutAutomaticRetry) { - for (auto operation : - {FaultPoint::CreateStagingDirectory, - FaultPoint::OpenStagingDirectory, - FaultPoint::Write, - FaultPoint::SyncFile, - FaultPoint::SyncDirectory, - FaultPoint::FullSync, - FaultPoint::Rename}) { - SCOPED_TRACE(static_cast(operation)); - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - NSString* key = source_key(manifest.get()); - auto lock = lock_cache_entry(storage.url.path, key); - ASSERT_TRUE(lock.ok()); - NSURL* final = source_entry(storage.url, manifest.get()); - { - StorageFaultScope fault(operation); - EXPECT_EQ( - executorch::backends::coreai::prepare_source_bundle( - manifest.get(), &data, *lock.get()) - .error(), - Error::AccessFailed); - EXPECT_EQ(fault.matches, 1); - EXPECT_EQ(fault.hits, 1); - } - const int attempts = operation == FaultPoint::CreateStagingDirectory || - operation == FaultPoint::OpenStagingDirectory || - operation == FaultPoint::SyncDirectory - ? 0 - : 1; - EXPECT_EQ(data.attempts, attempts); - EXPECT_EQ(data.requests, data.releases); - EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:final.path]); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - auto retried = executorch::backends::coreai::prepare_source_bundle( - manifest.get(), &data, *lock.get()); - ASSERT_TRUE(retried.ok()); - EXPECT_EQ(data.attempts, attempts + 1); - } -} - -TEST_F(CoreAISourceTest, RetriesInterruptedStagingAndPublicationCalls) { - for (auto operation : - {FaultPoint::CreateStagingDirectory, - FaultPoint::OpenStagingDirectory, - FaultPoint::SyncDirectory, - FaultPoint::Rename}) { - SCOPED_TRACE(static_cast(operation)); - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - NSString* key = source_key(manifest.get()); - auto lock = lock_cache_entry(storage.url.path, key); - ASSERT_TRUE(lock.ok()); - StorageFaultScope fault(operation); - fault.error = EINTR; - auto source = executorch::backends::coreai::prepare_source_bundle( - manifest.get(), &data, *lock.get()); - ASSERT_TRUE(source.ok()); - EXPECT_EQ(fault.hits, 1); - EXPECT_GE(fault.matches, 2); - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, data.releases); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - } -} - -TEST_F(CoreAISourceTest, PostRenameFlushFailureLeavesCompleteSourceVisible) { - TestDirectory storage; - ASSERT_NE(storage.url, nil); - TestData data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - NSURL* final = source_entry(storage.url, manifest.get()); - { - StorageFaultScope fault(FaultPoint::FullSync); - fault.match_index = 2; - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), - Error::AccessFailed); - EXPECT_EQ(fault.matches, 2); - EXPECT_EQ(fault.hits, 1); - } - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, data.releases); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - NSURL* graph = [final URLByAppendingPathComponent:@"model.aimodel/graph.bin"]; - EXPECT_TRUE(([[NSData dataWithContentsOfURL:graph] - isEqual:[NSData dataWithBytes:"model data" length:11]])); - NSDictionary* snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(storage.url, snapshot)); - { - StorageFaultScope fault(FaultPoint::FullSync); - EXPECT_EQ( - prepare_source_bundle(manifest.get(), nullptr, storage.url.path) - .error(), - Error::AccessFailed); - EXPECT_EQ(fault.hits, 1); - EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); - } - StorageFaultScope flush(FaultPoint::FullSync); - flush.error = EINTR; - auto reused = - prepare_source_bundle(manifest.get(), nullptr, storage.url.path); - ASSERT_TRUE(reused.ok()); - EXPECT_EQ(flush.hits, 1); - EXPECT_EQ(flush.matches, 2); - EXPECT_TRUE(snapshot_matches(storage.url, snapshot)); - EXPECT_EQ(data.attempts, 1); -} - -TEST_F(CoreAISourceTest, IncompleteConcurrentWinnerFailsWithoutReplacement) { - TestDirectory storage; - TestDirectory winner_storage; - ASSERT_NE(storage.url, nil); - ASSERT_NE(winner_storage.url, nil); - TestData data; - TestData winner_data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - auto winner = prepare_source_bundle( - manifest.get(), &winner_data, winner_storage.url.path); - ASSERT_TRUE(winner.ok()); - ASSERT_TRUE([NSFileManager.defaultManager - removeItemAtURL:[winner.get() URLByAppendingPathComponent:@"graph.bin"] - error:nil]); - NSURL* winner_entry = winner.get().URLByDeletingLastPathComponent; - NSDictionary* snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(winner_entry, snapshot)); - NSURL* root = staging_root(storage.url); - NSURL* final = source_entry(storage.url, manifest.get()); - __block int publications = 0; - __block bool callback_ok = true; - { - StorageFaultScope fault(FaultPoint::Rename); - fault.armed = false; - fault.observe = ^(FaultPoint operation) { - if (operation != FaultPoint::Rename) - return; - ++publications; - callback_ok &= staging_directories(root).count == 1; - callback_ok &= [NSFileManager.defaultManager moveItemAtURL:winner_entry - toURL:final - error:nil]; - }; - EXPECT_EQ( - prepare_source_bundle(manifest.get(), &data, storage.url.path).error(), - Error::InvalidExternalData); - EXPECT_TRUE(callback_ok); - EXPECT_EQ(publications, 1); - } - EXPECT_EQ(data.attempts, 1); - EXPECT_EQ(data.requests, data.releases); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - EXPECT_TRUE(snapshot_matches(final, snapshot)); -} - -TEST_F( - CoreAISourceTest, - ExclusivePublicationValidatesWinnerAndRemovesLosingStaging) { - TestDirectory storage; - TestDirectory winner_storage; - ASSERT_NE(storage.url, nil); - ASSERT_NE(winner_storage.url, nil); - TestData data; - TestData winner_data; - auto manifest = source_manifest(); - ASSERT_TRUE(manifest.ok()); - auto winner = prepare_source_bundle( - manifest.get(), &winner_data, winner_storage.url.path); - ASSERT_TRUE(winner.ok()); - NSURL* winner_entry = winner.get().URLByDeletingLastPathComponent; - NSDictionary* snapshot = nil; - ASSERT_TRUE(asset_tree_snapshot(winner_entry, snapshot)); - NSURL* root = staging_root(storage.url); - NSURL* final = source_entry(storage.url, manifest.get()); - __block int publications = 0; - __block bool callback_ok = true; - { - StorageFaultScope fault(FaultPoint::FullSync); - fault.match_index = 2; - fault.error = EINTR; - fault.observe = ^(FaultPoint operation) { - if (operation != FaultPoint::Rename) - return; - ++publications; - callback_ok &= staging_directories(root).count == 1; - callback_ok &= [NSFileManager.defaultManager moveItemAtURL:winner_entry - toURL:final - error:nil]; - }; - auto result = - prepare_source_bundle(manifest.get(), &data, storage.url.path); - EXPECT_TRUE(callback_ok); - ASSERT_TRUE(result.ok()); - EXPECT_TRUE([result.get() - isEqual:[final URLByAppendingPathComponent:manifest->bundle_name]]); - EXPECT_EQ(publications, 1); - EXPECT_EQ(fault.hits, 1); - EXPECT_EQ(fault.matches, 3); - } - EXPECT_EQ(data.attempts, manifest->files.count); - EXPECT_EQ(data.requests, data.releases); - EXPECT_EQ(staging_directories(storage.url).count, 0u); - EXPECT_TRUE(snapshot_matches(final, snapshot)); -} - } // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_storage_test.mm b/backends/apple/coreai/runtime/test/coreai_storage_test.mm index c169197fc01..e5f135724f0 100644 --- a/backends/apple/coreai/runtime/test/coreai_storage_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_storage_test.mm @@ -10,6 +10,7 @@ #include #include #include +#include #include #include "coreai_fault_scope.h" #include "coreai_file.h" @@ -35,12 +36,38 @@ FileDescriptor root( ASSERT_EQ(lstat_at(root.get(), "directory", &info), 0); EXPECT_TRUE(S_ISDIR(info.st_mode)); EXPECT_EQ(open_directory_at(root.get(), "link"), -1); + EXPECT_EQ( + open_storage_directory( + root.get(), @"link", StorageCreationMode::OpenExisting) + .error(), + Error::AccessFailed); EXPECT_EQ(open_directory_at(root.get(), "missing"), -1); EXPECT_EQ(errno, ENOENT); EXPECT_EQ(lstat_at(root.get(), "missing", &info), -1); EXPECT_EQ(errno, ENOENT); } +TEST(CoreAIStorageTest, FileDescriptorMovesTransferSingleOwnership) { + FileDescriptor source(open("/dev/null", O_RDONLY | O_CLOEXEC)); + ASSERT_GE(source.get(), 0); + const int source_fd = source.get(); + FileDescriptor moved(std::move(source)); + EXPECT_EQ(source.get(), -1); + EXPECT_EQ(moved.get(), source_fd); + + FileDescriptor destination(open("/dev/null", O_RDONLY | O_CLOEXEC)); + ASSERT_GE(destination.get(), 0); + const int replaced_fd = destination.get(); + destination = std::move(moved); + EXPECT_EQ(moved.get(), -1); + EXPECT_EQ(destination.get(), source_fd); + EXPECT_EQ(fcntl(replaced_fd, F_GETFD), -1); + EXPECT_EQ(errno, EBADF); + + destination = std::move(destination); + EXPECT_EQ(destination.get(), source_fd); +} + TEST(CoreAIStorageTest, OpensDirectoriesAndReportsCreation) { TestDirectory storage; ASSERT_NE(storage.url, nil); @@ -51,16 +78,17 @@ FileDescriptor root(open( auto missing = open_storage_directory( root.get(), @"child", StorageCreationMode::OpenExisting); ASSERT_TRUE(missing.ok()); - EXPECT_EQ(missing.get().fd, -1); - EXPECT_FALSE(missing.get().created); + EXPECT_FALSE(missing->descriptor.has_value()); + EXPECT_FALSE(missing->created); StorageFaultScope sync_fault(FaultPoint::SyncDirectory); auto created = open_storage_directory(root.get(), @"child", StorageCreationMode::Create); ASSERT_TRUE(created.ok()); - FileDescriptor first(created.get().fd); + ASSERT_TRUE(created->descriptor.has_value()); + FileDescriptor first = std::move(*created->descriptor); ASSERT_GE(first.get(), 0); - EXPECT_TRUE(created.get().created); + EXPECT_TRUE(created->created); EXPECT_NE(fcntl(first.get(), F_GETFD) & FD_CLOEXEC, 0); EXPECT_EQ(sync_fault.matches, 0); struct stat original; @@ -72,9 +100,10 @@ FileDescriptor root(open( {StorageCreationMode::OpenExisting, StorageCreationMode::Create}) { auto reopened = open_storage_directory(root.get(), @"child", mode); ASSERT_TRUE(reopened.ok()); - FileDescriptor next(reopened.get().fd); + ASSERT_TRUE(reopened->descriptor.has_value()); + FileDescriptor next = std::move(*reopened->descriptor); ASSERT_GE(next.get(), 0); - EXPECT_FALSE(reopened.get().created); + EXPECT_FALSE(reopened->created); struct stat current; ASSERT_EQ(fstat(next.get(), ¤t), 0); EXPECT_EQ(current.st_ino, original.st_ino); @@ -99,9 +128,10 @@ FileDescriptor root(open( auto opened = open_storage_directory(root.get(), @"child", StorageCreationMode::Create); ASSERT_TRUE(opened.ok()); - FileDescriptor directory(opened.get().fd); + ASSERT_TRUE(opened->descriptor.has_value()); + FileDescriptor directory = std::move(*opened->descriptor); EXPECT_GE(directory.get(), 0); - EXPECT_FALSE(opened.get().created); + EXPECT_FALSE(opened->created); } TEST(CoreAIStorageTest, RejectsInvalidDirectoryComponents) { diff --git a/backends/apple/coreai/runtime/test/coreai_test_utils.h b/backends/apple/coreai/runtime/test/coreai_test_utils.h new file mode 100644 index 00000000000..88c4bf35ec0 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_test_utils.h @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include + +namespace executorch::backends::coreai::testing { + +inline dispatch_time_t test_deadline(uint64_t seconds = 10) { + return dispatch_time(DISPATCH_TIME_NOW, seconds * NSEC_PER_SEC); +} + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/export_bridge_fixture.py b/backends/apple/coreai/runtime/test/export_bridge_fixture.py index 947c47cc86e..2d92aa8025d 100644 --- a/backends/apple/coreai/runtime/test/export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -49,7 +49,9 @@ def forward(self, left, right): ) -def _export_model(model, inputs, output_dir: Path, *, dynamic_shapes=None) -> dict: +def _export_model( + model, inputs, output_dir: Path, *, dynamic_shapes=None, include_pte=False +) -> dict: exported = torch.export.export(model, inputs, dynamic_shapes=dynamic_shapes) lowered = to_edge_transform_and_lower( exported, @@ -66,15 +68,19 @@ def _export_model(model, inputs, output_dir: Path, *, dynamic_shapes=None) -> di if output_dir.exists(): shutil.rmtree(output_dir) for relative_path in manifest["files"]: - entry = store.pte_data[f"coreai/{manifest['hash']}/{relative_path}"] + entry = store.pte_data[f"coreai/{manifest['export_hash']}/{relative_path}"] destination = output_dir / relative_path destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(store.buffers[entry.buffer_index]) (output_dir / "manifest.json").write_bytes(manifest_bytes) + if include_pte: + (output_dir / "model.pte").write_bytes(lowered.to_executorch().buffer) return manifest -def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: +def export_fixture( + output_dir: Path, *, dynamic: bool = False, include_pte: bool = False +) -> None: """Export into a dedicated fixture directory, replacing its contents.""" size = torch.export.Dim("size", min=1, max=8) _export_model( @@ -82,6 +88,7 @@ def export_fixture(output_dir: Path, *, dynamic: bool = False) -> None: (torch.ones(2), torch.ones(2)), output_dir, dynamic_shapes=({0: size}, {0: size}) if dynamic else None, + include_pte=include_pte, ) @@ -114,5 +121,5 @@ def export_weighted_fixture(output_dir: Path) -> None: "output_dir", type=Path, help="Dedicated fixture directory, replaced on export" ) output_dir = parser.parse_args().output_dir - export_fixture(output_dir) + export_fixture(output_dir, include_pte=True) export_fixture(output_dir / "dynamic", dynamic=True) diff --git a/backends/apple/coreai/runtime/test/runtime_smoke.cpp b/backends/apple/coreai/runtime/test/runtime_smoke.cpp new file mode 100644 index 00000000000..a136dceb1df --- /dev/null +++ b/backends/apple/coreai/runtime/test/runtime_smoke.cpp @@ -0,0 +1,48 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include +#include + +#include + +namespace { +using executorch::runtime::DataLoader; +using executorch::runtime::Error; +using RootClear = Error (*)(const char*); +using FileClear = Error (*)(const char*, const char*); +using LoaderClear = Error (*)(DataLoader&, const char*); + +// Keep every overload in the link check without touching cache storage. +RootClear volatile clear_root = &executorch::backends::coreai::clear_cache; +FileClear volatile clear_file = + &executorch::backends::coreai::clear_cache_for_pte; +LoaderClear volatile clear_loader = + &executorch::backends::coreai::clear_cache_for_pte; +} // namespace + +int main() { + executorch::runtime::runtime_init(); + if (clear_root == nullptr || clear_file == nullptr || + clear_loader == nullptr) { + std::fputs("Core AI cache APIs were not linked\n", stderr); + return 1; + } + const auto* backend = executorch::runtime::get_backend_class("CoreAIBackend"); + if (backend == nullptr) { + std::fputs("CoreAIBackend was not retained by the linker\n", stderr); + return 1; + } + if (!backend->is_available()) { + std::fputs("CoreAIBackend requires OS 27 or newer\n", stderr); + return 1; + } + std::puts("CoreAIBackend is registered and available"); + return 0; +} diff --git a/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py b/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py index 1efe401fc3e..c1f4b34a812 100644 --- a/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py @@ -16,14 +16,15 @@ @pytest.mark.parametrize("dynamic", [False, True]) -def test_export_replaces_stale_fixture_files(tmp_path, dynamic): +@pytest.mark.parametrize("include_pte", [False, True]) +def test_export_replaces_stale_fixture_files(tmp_path, dynamic, include_pte): """Re-exports must not retain stale assets or freeze dynamic extents.""" output = tmp_path / "fixture" bundle = output / "model.aimodel" bundle.mkdir(parents=True) (bundle / "obsolete.bin").write_bytes(b"obsolete") (output / "stale.json").write_text("{}") - manifest = {"hash": "ab", "files": {"model.aimodel/graph.bin": 3}} + manifest = {"export_hash": "ab", "files": {"model.aimodel/graph.bin": 3}} manifest_bytes = json.dumps(manifest).encode() delegate = SimpleNamespace( processed_bytes=manifest_bytes, @@ -35,7 +36,7 @@ def test_export_replaces_stale_fixture_files(tmp_path, dynamic): ), ) with ( - patch.object(fixture, "to_edge_transform_and_lower"), + patch.object(fixture, "to_edge_transform_and_lower") as lower, patch.object(fixture, "get_lowered_backend_modules", return_value=[delegate]), patch.object(fixture, "get_default_passes", return_value=[]), patch.object(fixture, "get_default_compile_config"), @@ -44,7 +45,13 @@ def test_export_replaces_stale_fixture_files(tmp_path, dynamic): fixture.torch.export, "export", wraps=fixture.torch.export.export ) as export, ): - fixture.export_fixture(output, dynamic=dynamic) + lower.return_value.to_executorch.return_value.buffer = b"serialized-pte" + fixture.export_fixture(output, dynamic=dynamic, include_pte=include_pte) + if include_pte: + lower.return_value.to_executorch.assert_called_once_with() + assert (output / "model.pte").read_bytes() == b"serialized-pte" + else: + lower.return_value.to_executorch.assert_not_called() assert (output / "manifest.json").read_bytes() == manifest_bytes assert (bundle / "graph.bin").read_bytes() == b"abc" @@ -53,7 +60,9 @@ def test_export_replaces_stale_fixture_files(tmp_path, dynamic): ) == [ "manifest.json", "model.aimodel/graph.bin", - ] + ] + ( + ["model.pte"] if include_pte else [] + ) shapes = export.call_args.kwargs["dynamic_shapes"] if dynamic: assert shapes[0][0] is shapes[1][0] diff --git a/backends/apple/coreai/test/test_preprocess.py b/backends/apple/coreai/test/test_preprocess.py index 6570dac278b..5e5bd4b162f 100644 --- a/backends/apple/coreai/test/test_preprocess.py +++ b/backends/apple/coreai/test/test_preprocess.py @@ -144,7 +144,7 @@ def test_inline_embeds_files_in_nds(self): out = result.data_store_output self.assertIsNotNone(out) for rel in manifest["files"]: - self.assertIn(f"coreai/{manifest['hash']}/{rel}", out.pte_data) + self.assertIn(f"coreai/{manifest['export_hash']}/{rel}", out.pte_data) self.assertEqual(out.external_data, {}) # nothing external for inline def test_inline_keys_keep_the_bundle_directory(self): @@ -188,7 +188,7 @@ def test_sizes_and_digests_cover_every_delivered_file(self, _build): result = CoreAIBackend.preprocess(_edge_program(), specs) manifest = json.loads(result.processed_bytes) out = result.data_store_output - prefix = f"coreai/{manifest['hash']}/" + prefix = f"coreai/{manifest['export_hash']}/" embedded = { key[len(prefix) :]: len(out.buffers[entry.buffer_index]) for key, entry in out.pte_data.items() @@ -324,7 +324,7 @@ def test_archs_map_points_at_embedded_bundles(self, _build): result = CoreAIBackend.preprocess(_edge_program(), [_aot_spec(config)]) manifest = json.loads(result.processed_bytes) for arch, rel in manifest["archs"].items(): - self.assertEqual(rel, f"{manifest['hash']}/model.{arch}.aimodelc") + self.assertEqual(rel, f"{manifest['export_hash']}/model.{arch}.aimodelc") self.assertIn(f"coreai/{rel}/model.mil", result.data_store_output.pte_data) @mock.patch(_MOCK_BUILD, side_effect=_fake_run_coreai_build) @@ -348,7 +348,7 @@ def test_two_delegates_get_separate_asset_keys(self, _build): ) lbms = get_lowered_backend_modules(lowered.exported_program().graph_module) self.assertEqual(len(lbms), 2) - hashes = {json.loads(bytes(lbm.processed_bytes))["hash"] for lbm in lbms} + hashes = {json.loads(bytes(lbm.processed_bytes))["export_hash"] for lbm in lbms} self.assertEqual(len(hashes), 2) diff --git a/tools/cmake/executorch-config.cmake b/tools/cmake/executorch-config.cmake index 3d3b650f33e..6e468b1da0c 100644 --- a/tools/cmake/executorch-config.cmake +++ b/tools/cmake/executorch-config.cmake @@ -105,6 +105,7 @@ set(optional_lib_list coreml_util coreml_inmemoryfs coremldelegate + coreaidelegate mlxdelegate mlx metal_backend diff --git a/tools/cmake/preset/default.cmake b/tools/cmake/preset/default.cmake index 7b017cf36b9..cf3a335dd84 100644 --- a/tools/cmake/preset/default.cmake +++ b/tools/cmake/preset/default.cmake @@ -440,6 +440,10 @@ check_required_options_on( EXECUTORCH_BUILD_EXTENSION_LLM ) +check_required_options_on( + IF_ON EXECUTORCH_BUILD_COREAI REQUIRES EXECUTORCH_BUILD_EXTENSION_DATA_LOADER +) + check_required_options_on( IF_ON EXECUTORCH_BUILD_EXTENSION_MODULE REQUIRES EXECUTORCH_BUILD_EXTENSION_DATA_LOADER EXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR