diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 83721e35ff9..33d0d2d1e33 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -31,14 +31,17 @@ enable_language(OBJCXX) 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_backend.mm - runtime/coreai_bookmarks.mm + runtime/coreai_cache_entry.mm + runtime/coreai_cache.mm runtime/coreai_cache_key.mm runtime/coreai_cache_root.mm runtime/coreai_error.mm @@ -75,10 +78,12 @@ 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 @@ -87,6 +92,8 @@ if(EXECUTORCH_BUILD_TESTS AND CMAKE_SYSTEM_NAME STREQUAL "Darwin") 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} ) @@ -94,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 ${_coreai_runtime} program_schema 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) @@ -213,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}" @@ -251,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" @@ -304,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( @@ -361,11 +373,9 @@ if(EXECUTORCH_BUILD_TESTS ) coreai_configure_objc_target(coreai_pte_export_test) target_link_libraries( - coreai_pte_export_test PRIVATE ${_coreai_host_runtime} program_schema + coreai_pte_export_test PRIVATE ${_coreai_runtime} ${_coreai_data_loader} + program_schema ) - if(NOT EXECUTORCH_BUILD_SHARED) - target_link_libraries(coreai_pte_export_test PRIVATE extension_data_loader) - endif() add_dependencies(coreai_pte_export_test coreai_bridge_fixture) if(_coreai_run_sdk_tests) add_test(NAME coreai_pte_export_test @@ -385,7 +395,7 @@ if(EXECUTORCH_BUILD_TESTS endif() add_executable( coreai_sdk_source_lifetime_test - runtime/test/SDKSourceLifetimeTest.swift runtime/ETCoreAIModel.swift + runtime/test/SDKSourceLifetimeTest.swift ${_coreai_swift_sources} runtime/ETCoreAITensor.mm ) coreai_configure_objc_target(coreai_sdk_source_lifetime_test) @@ -419,8 +429,8 @@ 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} program_schema - executorch::coreai_dependencies + 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) @@ -445,6 +455,12 @@ 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 coreaidelegate coreai_swift EXPORT ExecuTorchTargets diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index c24c18bb9cd..e76c209991e 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -30,15 +30,17 @@ 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. @@ -105,20 +107,21 @@ 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 +### Cache directory -Backend initialization reads the string runtime spec `coreai_assets_dir`. If +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, staged bundles and lock files. +explicit directory. It contains raw bookmarks, source bundles and lock files. -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 +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 @@ -126,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 @@ -137,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 @@ -156,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. @@ -170,7 +173,7 @@ 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 @@ -182,25 +185,27 @@ 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 always uses `AIModelCache.default` with persistent retention and -stores an opaque bookmark for direct restoration. `coreai_assets_dir` does not +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 model key derived from its export digest, bundle -name, and source/AOT mode. A readable variant key combines that model key with +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. @@ -219,16 +224,98 @@ 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 @@ -236,25 +323,26 @@ loader using the `executorch::runtime` namespace: ```cpp BackendOptions<1> options; -ET_CHECK_OK_OR_RETURN_ERROR(options.set_option("coreai_assets_dir", assets_dir)); +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 assets root. `coreai_assets_dir` +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 storage roots for different models. -The path is a root directory, not the bundle itself. ExecuTorch option strings +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 @@ -302,23 +390,8 @@ 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. - -The delegate does not require `EXECUTORCH_BUILD_EXTENSION_DATA_LOADER`. -Enable it for consumers such as `Module` that load program data. +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 @@ -331,7 +404,8 @@ 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 and backend registration retention. With +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). 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.. session = nil; }; -Result path_option(BackendInitContext& context, const char* key) { - auto option = context.get_runtime_spec(key); +Result cache_directory_option(BackendInitContext& context) { + auto option = context.get_runtime_spec("coreai_cache_dir"); if (!option.ok()) { if (option.error() == Error::NotFound) { - return static_cast(nil); + return cache_directory_path(nullptr); } return option.error(); } - NSString* path = option.get() == nullptr - ? nil - : [NSString stringWithUTF8String:option.get()]; ET_CHECK_OR_RETURN_ERROR( - path != nil && path.isAbsolutePath, + option.get() != nullptr, InvalidArgument, - "Core AI %s must be an absolute UTF-8 path", - key); - return path; + "Core AI cache directory option must not be null"); + return cache_directory_path(option.get()); } Result> bind_session( @@ -93,31 +89,30 @@ bool is_available() const override { if (!manifest.ok()) { return manifest.error(); } - auto assets = select_runtime_assets(manifest.get()); - if (!assets.ok()) { - return assets.error(); + auto host_bundle = select_host_bundle(manifest.get()); + if (!host_bundle.ok()) { + return host_bundle.error(); } - auto assets_option = path_option(context, "coreai_assets_dir"); - if (!assets_option.ok()) { - return assets_option.error(); - } - NSString* root = assets_option.get(); - if (root == nil) { - auto default_root = default_coreai_assets_root(); - if (!default_root.ok()) { - return default_root.error(); - } - root = default_root.get(); + 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 prepared = acquire_bookmark_model( - assets.get(), context.get_named_data_map(), root, 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(), assets->bundle); + auto session = bind_session(prepared.get(), host_bundle->bundle); if (!session.ok()) { return session.error(); } @@ -126,8 +121,8 @@ bool is_available() const override { handle != nullptr, MemoryAllocationFailed, "Cannot allocate Core AI handle"); - handle->input_count = assets->bundle.inputs.count; - handle->output_count = assets->bundle.outputs.count; + 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()); 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_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 8d7e8987498..bd4f45a7de1 100644 --- a/backends/apple/coreai/runtime/coreai_load_coordinator.mm +++ b/backends/apple/coreai/runtime/coreai_load_coordinator.mm @@ -7,7 +7,7 @@ */ #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" @@ -21,6 +21,14 @@ using runtime::Error; using runtime::Result; +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( void (^operation)(ETCoreAIAcquisitionCompletion)) { ET_CHECK_OR_RETURN_ERROR( @@ -70,27 +78,57 @@ }); } -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 @@ -99,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(); } @@ -152,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(); } @@ -162,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]; }); @@ -183,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_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_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 935b377bea1..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; @@ -314,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')) @@ -338,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( @@ -362,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 d331194a2f3..5b1df356756 100644 --- a/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_acquisition_test.mm @@ -43,16 +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 { @@ -61,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(); @@ -77,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; @@ -90,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() { @@ -122,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]); } @@ -131,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) { @@ -170,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( @@ -216,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) { @@ -239,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(); } @@ -264,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(); } @@ -287,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(); } @@ -301,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(); } @@ -312,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; @@ -328,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( @@ -421,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( @@ -451,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( @@ -485,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) { @@ -519,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); @@ -528,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( @@ -552,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) { @@ -594,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; } { @@ -613,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, @@ -654,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())); @@ -686,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.mm b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm index 6cac71e0c4f..3d450ee4d24 100644 --- a/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm +++ b/backends/apple/coreai/runtime/test/coreai_delegate_fixture.mm @@ -6,6 +6,7 @@ * 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; @@ -25,8 +26,7 @@ DelegateFixture::~DelegateFixture() { reset(); - EXPECT_TRUE(bridge_.wait_for_callbacks( - dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC))); + EXPECT_TRUE(bridge_.wait_for_callbacks(test_deadline())); processed.Free(); if (json != nil) { EXPECT_EQ(freed, 1); @@ -40,7 +40,7 @@ } BackendOptions<1> options; const Error error = options.set_option( - "coreai_assets_dir", (root ?: storage.url.path).UTF8String); + "coreai_cache_dir", (root ?: storage.url.path).UTF8String); if (error != Error::Ok) { return error; } @@ -56,8 +56,7 @@ if (result.ok()) { handle = result.get(); } - auto drained = bridge_.wait_for_callbacks( - dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + auto drained = bridge_.wait_for_callbacks(test_deadline()); if (!drained) { ADD_FAILURE() << drained.message(); reset(); diff --git a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm index 8dcebeeafc7..1321bed50f0 100644 --- a/backends/apple/coreai/runtime/test/coreai_delegate_test.mm +++ b/backends/apple/coreai/runtime/test/coreai_delegate_test.mm @@ -10,11 +10,15 @@ #include #include #include +#include +#include #include #include -#include "coreai_bookmark_fixture.h" +#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 { @@ -165,14 +169,14 @@ void expect_preflight_rejection( bridge.state().device_architecture = arch; auto parsed = parse_manifest(fixture.json); ASSERT_TRUE(parsed.ok()); - auto selected = select_assets(parsed.get(), arch, @"macOS"); + 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:@"staging/%@/%@", + [NSString stringWithFormat:@"sources/%@/%@", key.get(), selected->path.lastPathComponent]]; EXPECT_TRUE([bridge.state().last_bundle isEqualToString:expected]); @@ -204,7 +208,8 @@ void expect_preflight_rejection( EXPECT_FALSE([NSFileManager.defaultManager fileExistsAtPath:bridge.state().last_bundle]); EXPECT_TRUE([bridge.state().last_bundle - hasPrefix:[fixture.storage.url.path stringByAppendingString:@"/"]]); + hasPrefix:[fixture.storage.url.URLByResolvingSymlinksInPath.path + stringByAppendingString:@"/"]]); } EXPECT_EQ(bridge.state().sessions.load(), 0); } @@ -476,8 +481,7 @@ int start() { return error; } long wait_ready() { - return dispatch_semaphore_wait( - state_.input_ready, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + return dispatch_semaphore_wait(state_.input_ready, test_deadline(5)); } long wait_finished(int64_t timeout) { return dispatch_semaphore_wait( @@ -489,8 +493,7 @@ void release() { void release_and_join() { release(); if (started_) { - const long returned = dispatch_semaphore_wait( - returned_, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + const long returned = dispatch_semaphore_wait(returned_, test_deadline()); if (returned != 0) { ADD_FAILURE() << "Delegate worker did not return after releasing its input gate"; @@ -548,8 +551,7 @@ void release_and_join() { worker.release(); const long finished = worker.wait_finished(5 * NSEC_PER_SEC); worker.release_and_join(); - ASSERT_TRUE(bridge.wait_for_callbacks( - dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC))); + ASSERT_TRUE(bridge.wait_for_callbacks(test_deadline())); EXPECT_EQ(ready, 0); EXPECT_EQ(observed, tensors.input); EXPECT_NE(premature, 0); @@ -828,16 +830,15 @@ TensorImpl output( @autoreleasepool { DelegateFixture fixture(backend, bridge); { - SCOPED_TRACE("integer assets directory"); - BackendOption wrong_type{"coreai_assets_dir", 1}; + 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 assets directory"); + SCOPED_TRACE("relative cache directory"); BackendOptions<1> relative; - ASSERT_EQ( - relative.set_option("coreai_assets_dir", "relative"), Error::Ok); + ASSERT_EQ(relative.set_option("coreai_cache_dir", "relative"), Error::Ok); EXPECT_EQ( fixture.init_with_options( {relative.view().data(), relative.view().size()}), @@ -847,6 +848,27 @@ TensorImpl output( } } +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) { @@ -873,7 +895,8 @@ TensorImpl output( 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.path stringByAppendingString:@"/"]]); + 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(); 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 b5194100429..5fb287ed79a 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.h +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.h @@ -91,15 +91,18 @@ class ScopedFakeBridgeState final { 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; diff --git a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm index 019552e978d..5eefda12f9d 100644 --- a/backends/apple/coreai/runtime/test/coreai_fake_loader.mm +++ b/backends/apple/coreai/runtime/test/coreai_fake_loader.mm @@ -8,8 +8,10 @@ #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 { @@ -82,10 +84,6 @@ void run_async(CoreAIFakeBridgeContext* context, void (^work)()) { } return ::testing::AssertionSuccess(); } - -dispatch_time_t cleanup_deadline() { - return dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC); -} } // namespace @interface FakeOutputOwner : NSObject @@ -149,7 +147,7 @@ - (void)executeInputs:(NSArray*)inputs if (ready != nil) { dispatch_semaphore_signal(ready); if (proceed == nil || - dispatch_semaphore_wait(proceed, cleanup_deadline()) != 0) { + dispatch_semaphore_wait(proceed, test_deadline()) != 0) { ++context->state.input_wait_timeouts; completion(nil, fake_error(ETCoreAIErrorRuntime)); return; @@ -368,6 +366,7 @@ - (instancetype)init { specializations = 0; bindings = 0; evictions = 0; + specializationQueries = 0; ++_context->state.loaders; } return self; @@ -378,6 +377,7 @@ - (void)dealloc { } } - (NSString*)specializationIdentifier { + ++specializationQueries; return @"default"; } - (void)restoreModelFromBookmark:(NSData*)bookmark @@ -435,11 +435,20 @@ - (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]; @@ -455,7 +464,7 @@ - (void)clearRetainedState { } 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; @@ -504,7 +513,7 @@ - (void)clearRetainedState { 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]; 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_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 797ddb63b6c..2d92aa8025d 100644 --- a/backends/apple/coreai/runtime/test/export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/export_bridge_fixture.py @@ -68,7 +68,7 @@ def _export_model( 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]) diff --git a/backends/apple/coreai/runtime/test/runtime_smoke.cpp b/backends/apple/coreai/runtime/test/runtime_smoke.cpp index 3d91a760b4b..a136dceb1df 100644 --- a/backends/apple/coreai/runtime/test/runtime_smoke.cpp +++ b/backends/apple/coreai/runtime/test/runtime_smoke.cpp @@ -6,13 +6,34 @@ * 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); 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 c8b90230672..c1f4b34a812 100644 --- a/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py +++ b/backends/apple/coreai/runtime/test/test_export_bridge_fixture.py @@ -24,7 +24,7 @@ def test_export_replaces_stale_fixture_files(tmp_path, dynamic, include_pte): 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, 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/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