diff --git a/backends/apple/coreai/CMakeLists.txt b/backends/apple/coreai/CMakeLists.txt index 312f30c9d0c..e96862afcb0 100644 --- a/backends/apple/coreai/CMakeLists.txt +++ b/backends/apple/coreai/CMakeLists.txt @@ -10,7 +10,7 @@ endif() enable_language(OBJCXX) -set(_coreai_runtime_sources runtime/coreai_assets.mm) +set(_coreai_runtime_sources runtime/coreai_assets.mm runtime/coreai_storage.mm) function(coreai_configure_objc_target target) target_include_directories( @@ -32,7 +32,8 @@ if(EXECUTORCH_BUILD_TESTS) add_executable( coreai_host_test runtime/test/coreai_host_test.mm runtime/test/coreai_manifest_test.mm - ${_coreai_runtime_sources} + runtime/test/coreai_storage_test.mm + runtime/test/coreai_filesystem_fixture.mm ${_coreai_runtime_sources} ) coreai_configure_objc_target(coreai_host_test) target_compile_definitions(coreai_host_test PRIVATE COREAI_ASSETS_TESTING=1) diff --git a/backends/apple/coreai/README.md b/backends/apple/coreai/README.md index 99ca602bbdd..892d1d30635 100644 --- a/backends/apple/coreai/README.md +++ b/backends/apple/coreai/README.md @@ -42,6 +42,19 @@ architectures=["h17p"])` requests that compiler architecture; omitting the list lets the compiler emit its supported architectures for the target platform. These are Core AI architecture names, not CPU names such as `arm64`. +### Asset storage + +The assets root must be an absolute path. The application chooses it, should +reserve it for this backend, and is trusted not to rename, replace or modify its +contents while the backend uses it; the backend does not defend against other +processes of the same user rebinding paths. Written files and changed directories +are synced with `fsync`; atomic publication also uses `F_FULLFSYNC` before its +rename. + +Preparing the assets root sets `NSURLIsExcludedFromBackupKey` on it, which covers +everything beneath it. Ancestors are not modified. This is backup exclusion, not +a control for iCloud Drive synchronization. + ## Host Tests `EXECUTORCH_BUILD_COREAI=ON` with `EXECUTORCH_BUILD_TESTS=ON` registers the diff --git a/backends/apple/coreai/runtime/coreai_file.h b/backends/apple/coreai/runtime/coreai_file.h new file mode 100644 index 00000000000..478f03a7b91 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_file.h @@ -0,0 +1,53 @@ +/* + * 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 { + +class FileDescriptor { + public: + explicit FileDescriptor(int fd) : fd_(fd) {} + ~FileDescriptor() { + reset(-1); + } + FileDescriptor(const FileDescriptor&) = delete; + FileDescriptor& operator=(const FileDescriptor&) = delete; + int get() const { + return fd_; + } + int release() { + const int fd = fd_; + fd_ = -1; + return fd; + } + void reset(int fd) { + if (fd_ >= 0) { + // Retrying close can close a descriptor reused by another thread. + ::close(fd_); + } + fd_ = fd; + } + + private: + int fd_; +}; + +template +auto retry_eintr(F call) { + decltype(call()) result; + do { + result = call(); + } while (result == -1 && errno == EINTR); + return result; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_storage.h b/backends/apple/coreai/runtime/coreai_storage.h new file mode 100644 index 00000000000..f5bd377dd70 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_storage.h @@ -0,0 +1,60 @@ +/* + * 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 + +namespace executorch::backends::coreai { + +// 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, bool create); +runtime::Error ensure_excluded_from_backup(NSURL* directory); + +runtime::Error sync_storage_directory(int fd); +// Returns an independently owned descriptor for a stable coordination file. +int open_storage_shared_file(int parent_fd, const char* name); +// Creates a new file and missing parents, then syncs the file and its parent. +runtime::Error write_storage_file( + int root_fd, + NSString* relative_path, + const void* bytes, + size_t size); +// Atomically replaces name with data. On error, readers see either the old or +// the new complete bytes. +runtime::Error publish_storage_data(int root_fd, NSString* name, NSData* data); +runtime::Result*> storage_children( + int fd, + bool skip_invalid_names = false); + +enum class StorageOperation { + Rename, + BeforeSDK, + AfterSDK, + BeforeEvict, + AfterEvict, + Remove, +}; +#if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING +int storage_fault(StorageOperation operation); +namespace testing { +using StorageFaultCallback = int (*)(StorageOperation); +extern thread_local StorageFaultCallback storage_fault_callback; +} // namespace testing +#else +constexpr int storage_fault(StorageOperation) { + return 0; +} +#endif + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/coreai_storage.mm b/backends/apple/coreai/runtime/coreai_storage.mm new file mode 100644 index 00000000000..66fea0e8833 --- /dev/null +++ b/backends/apple/coreai/runtime/coreai_storage.mm @@ -0,0 +1,298 @@ +/* + * 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_storage.h" +#include "coreai_file.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace executorch::backends::coreai { +namespace { +using runtime::Error; +using runtime::Result; +} // namespace + +Error ensure_excluded_from_backup(NSURL* directory) { + NSURL* fresh = [NSURL fileURLWithPath:directory.path isDirectory:YES]; + NSNumber* excluded = nil; + NSError* error = nil; + ET_CHECK_OR_RETURN_ERROR( + [fresh getResourceValue:&excluded + forKey:NSURLIsExcludedFromBackupKey + error:&error], + AccessFailed, + "Cannot read Core AI backup exclusion: %s", + error.localizedDescription.UTF8String); + if (excluded.boolValue) { + return Error::Ok; + } + ET_CHECK_OR_RETURN_ERROR( + [fresh setResourceValue:@YES + forKey:NSURLIsExcludedFromBackupKey + error:&error], + AccessFailed, + "Cannot exclude Core AI storage from backup: %s", + error.localizedDescription.UTF8String); + return Error::Ok; +} + +Result default_coreai_assets_root() { + 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 prepare_storage_root(NSString* path, bool create) { + ET_CHECK_OR_RETURN_ERROR( + [path isKindOfClass:NSString.class] && path.isAbsolutePath && + [path rangeOfString:@"\0"].location == NSNotFound, + InvalidArgument, + "Core AI storage requires an absolute path"); + if (create) { + NSError* error = nil; + ET_CHECK_OR_RETURN_ERROR( + [NSFileManager.defaultManager + createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:@{ + NSFilePosixPermissions : @0700 + } + error:&error], + AccessFailed, + "Cannot create Core AI storage directory: %s", + error.localizedDescription.UTF8String); + } + 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), + InvalidArgument, + "Core AI storage must be an existing directory"); + if (create) { + ET_CHECK_OK_OR_RETURN_ERROR(ensure_excluded_from_backup( + [NSURL fileURLWithPath:path isDirectory:YES])); + } + return path; +} + +#if defined(COREAI_ASSETS_TESTING) && COREAI_ASSETS_TESTING +namespace testing { +thread_local StorageFaultCallback storage_fault_callback = nullptr; +} + +int storage_fault(StorageOperation operation) { + return testing::storage_fault_callback == nullptr + ? 0 + : testing::storage_fault_callback(operation); +} +#endif + +namespace { +template +int storage_call(StorageOperation operation, F call) { + return retry_eintr([&] { + const int fault = storage_fault(operation); + if (fault != 0) { + errno = fault; + return -1; + } + return call(); + }); +} + +bool storage_component(NSString* name) { + return [name isKindOfClass:NSString.class] && name.length > 0 && + ![name isEqualToString:@"."] && ![name isEqualToString:@".."] && + ![name containsString:@"/"] && ![name containsString:@"\0"]; +} + +Error write_file( + int root_fd, + NSString* relative_path, + const void* data, + size_t size, + bool full_sync) { + NSArray* components = + [relative_path componentsSeparatedByString:@"/"]; + ET_CHECK_OR_RETURN_ERROR( + components.count > 0 && (data != nullptr || size == 0), + InvalidArgument, + "Invalid Core AI storage write"); + for (NSString* component in components) { + ET_CHECK_OR_RETURN_ERROR( + storage_component(component), InvalidArgument, "Invalid storage path"); + } + FileDescriptor parent(retry_eintr([&] { return dup(root_fd); })); + ET_CHECK_OR_RETURN_ERROR( + parent.get() >= 0, AccessFailed, "Cannot open storage parent"); + for (NSUInteger i = 0; i + 1 < components.count; ++i) { + const char* component = components[i].fileSystemRepresentation; + ET_CHECK_OR_RETURN_ERROR( + retry_eintr( + [&] { return mkdirat(parent.get(), component, 0700); }) == 0 || + errno == EEXIST, + AccessFailed, + "Cannot create Core AI source directory"); + FileDescriptor next(retry_eintr([&] { + return openat( + parent.get(), + component, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + })); + ET_CHECK_OR_RETURN_ERROR( + next.get() >= 0, AccessFailed, "Cannot open source directory"); + ET_CHECK_OK_OR_RETURN_ERROR(sync_storage_directory(parent.get())); + parent.reset(next.release()); + } + FileDescriptor file(retry_eintr([&] { + return openat( + parent.get(), + components.lastObject.fileSystemRepresentation, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + })); + ET_CHECK_OR_RETURN_ERROR( + file.get() >= 0, AccessFailed, "Cannot create Core AI storage file"); + const auto* bytes = static_cast(data); + while (size > 0) { + const ssize_t count = + write(file.get(), bytes, std::min(size, size_t(1024 * 1024))); + if (count < 0 && errno == EINTR) continue; + ET_CHECK_OR_RETURN_ERROR( + count > 0, AccessFailed, "Cannot write Core AI storage file"); + bytes += count; + size -= count; + } + ET_CHECK_OR_RETURN_ERROR( + retry_eintr([&] { return fsync(file.get()); }) == 0 && + (!full_sync || + retry_eintr([&] { return fcntl(file.get(), F_FULLFSYNC); }) == 0), + AccessFailed, + "Cannot synchronize Core AI file"); + ET_CHECK_OR_RETURN_ERROR(::close(file.release()) == 0, AccessFailed, + "Cannot close Core AI storage file"); + return sync_storage_directory(parent.get()); +} +} // namespace + +Result*> storage_children(int fd, bool skip_invalid_names) { + FileDescriptor copy( + retry_eintr([&] { return fcntl(fd, F_DUPFD_CLOEXEC, 0); })); + ET_CHECK_OR_RETURN_ERROR(copy.get() >= 0, AccessFailed, + "Cannot duplicate storage directory"); + DIR* directory = fdopendir(copy.get()); + ET_CHECK_OR_RETURN_ERROR(directory != nullptr, AccessFailed, + "Cannot open storage directory stream"); + copy.release(); + // dup shares the enumeration offset with the original descriptor. + rewinddir(directory); + NSMutableArray* names = [NSMutableArray array]; + Error result = Error::Ok; + while (true) { + errno = 0; + dirent* entry = readdir(directory); + if (entry == nullptr) { + if (errno == EINTR) continue; + if (errno != 0) result = Error::AccessFailed; + break; + } + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + NSString* name = [[NSString alloc] initWithBytes:entry->d_name + length:strlen(entry->d_name) + encoding:NSUTF8StringEncoding]; + if (name == nil || + strcmp(name.fileSystemRepresentation, entry->d_name) != 0) { + if (skip_invalid_names) { + continue; + } + result = Error::InvalidExternalData; + break; + } + [names addObject:name]; + } + const int closed = closedir(directory); + ET_CHECK_OK_OR_RETURN_ERROR(result); + ET_CHECK_OR_RETURN_ERROR(closed == 0, AccessFailed, + "Cannot close storage directory stream"); + return [names sortedArrayUsingSelector:@selector(compare:)]; +} + +int 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(parent_fd, name, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + }); + if (fd < 0 && errno == EEXIST) { + fd = retry_eintr([&] { + return openat(parent_fd, name, O_RDWR | O_NOFOLLOW | O_CLOEXEC); + }); + } + return fd; +} + +Error sync_storage_directory(int fd) { + ET_CHECK_OR_RETURN_ERROR( + retry_eintr([&] { return fsync(fd); }) == 0, + AccessFailed, + "Cannot synchronize Core AI directory"); + return Error::Ok; +} + +Error write_storage_file( + int root_fd, + NSString* relative_path, + const void* data, + size_t size) { + return write_file(root_fd, relative_path, data, size, false); +} + +Error publish_storage_data(int root_fd, NSString* name, NSData* data) { + ET_CHECK_OR_RETURN_ERROR( + storage_component(name) && data != nil, + InvalidArgument, + "Invalid Core AI storage publication"); + NSString* temporary = + [@".tmp-" stringByAppendingString:NSUUID.UUID.UUIDString]; + // 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, true); + if (result == Error::Ok && storage_call(StorageOperation::Rename, [&] { + return renameat( + root_fd, + temporary.fileSystemRepresentation, + root_fd, + name.fileSystemRepresentation); + }) != 0) + result = Error::AccessFailed; + if (result == Error::Ok) + return sync_storage_directory(root_fd); + if (unlinkat(root_fd, temporary.fileSystemRepresentation, 0) == 0) { + (void)sync_storage_directory(root_fd); + } + return result; +} + +} // namespace executorch::backends::coreai diff --git a/backends/apple/coreai/runtime/test/coreai_fault_scope.h b/backends/apple/coreai/runtime/test/coreai_fault_scope.h new file mode 100644 index 00000000000..e55de88579a --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_fault_scope.h @@ -0,0 +1,57 @@ +/* + * 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 "coreai_storage.h" + +namespace executorch::backends::coreai::testing { + +struct StorageFaultScope { + StorageOperation operation; + int error = EIO; + int match_index = 1; + int matches = 0; + int hits = 0; + bool armed = true; + void (^observe)(StorageOperation) = nil; + + explicit StorageFaultScope(StorageOperation op) + : operation(op), + previous_(current_), + previous_callback_(storage_fault_callback) { + current_ = this; + storage_fault_callback = inject; + } + ~StorageFaultScope() { + storage_fault_callback = previous_callback_; + current_ = previous_; + } + StorageFaultScope(const StorageFaultScope&) = delete; + StorageFaultScope& operator=(const StorageFaultScope&) = delete; + + private: + inline static thread_local StorageFaultScope* current_ = nullptr; + StorageFaultScope* previous_; + StorageFaultCallback previous_callback_; + + static int inject(StorageOperation operation) { + auto& scope = *current_; + if (scope.observe != nil) + scope.observe(operation); + if (scope.armed && scope.operation == operation && + ++scope.matches == scope.match_index) { + ++scope.hits; + return scope.error; + } + return 0; + } +}; + +} // namespace executorch::backends::coreai::testing diff --git a/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h new file mode 100644 index 00000000000..625f1f48a8a --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.h @@ -0,0 +1,27 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#import +#include + +namespace executorch::backends::coreai::testing { + +struct TestDirectory { + NSURL* url = nil; + TestDirectory(); + ~TestDirectory(); + TestDirectory(const TestDirectory&) = delete; + TestDirectory& operator=(const TestDirectory&) = delete; +}; + +::testing::AssertionResult backup_excluded(NSURL* url); +::testing::AssertionResult set_backup_excluded(NSURL* url, bool excluded); + +} // 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 new file mode 100644 index 00000000000..f9810e02aad --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_filesystem_fixture.mm @@ -0,0 +1,61 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "coreai_filesystem_fixture.h" +#include + +namespace executorch::backends::coreai::testing { + +TestDirectory::TestDirectory() { + char pattern[] = "/private/tmp/coreai-host-test-XXXXXX"; + if (mkdtemp(pattern) == nullptr) { + ADD_FAILURE() << "mkdtemp: " << strerror(errno); + return; + } + url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:pattern] isDirectory:YES] + .URLByResolvingSymlinksInPath; +} + +TestDirectory::~TestDirectory() { + if (url == nil) return; + EXPECT_TRUE([url.lastPathComponent hasPrefix:@"coreai-host-test-"]); + if (![url.lastPathComponent hasPrefix:@"coreai-host-test-"]) return; + EXPECT_TRUE([NSFileManager.defaultManager removeItemAtURL:url error:nil]); +} + +::testing::AssertionResult backup_excluded(NSURL* url) { + NSURL* fresh = [NSURL fileURLWithPath:url.path]; + [fresh removeAllCachedResourceValues]; + NSNumber* value = nil; + NSError* error = nil; + if (![fresh getResourceValue:&value forKey:NSURLIsExcludedFromBackupKey error:&error] || + error != nil || value == nil) { + ADD_FAILURE() << "Cannot read backup flag: " << url.path.UTF8String; + return ::testing::AssertionFailure() << "Backup flag unavailable"; + } + return value.boolValue ? ::testing::AssertionSuccess() + : ::testing::AssertionFailure() << "Not excluded: " << url.path.UTF8String; +} + +::testing::AssertionResult set_backup_excluded(NSURL* url, bool excluded) { + // Foundation can defer metadata writes; settle deliberate fixture changes. + for (int attempt = 0; attempt < 50; ++attempt) { + NSURL* fresh = [NSURL fileURLWithPath:url.path]; + [fresh removeAllCachedResourceValues]; + if (![fresh setResourceValue:@(excluded) forKey:NSURLIsExcludedFromBackupKey error:nil]) { + return ::testing::AssertionFailure() << "Cannot set backup flag: " << url.path.UTF8String; + } + usleep(20000); + if (static_cast(backup_excluded(url)) == excluded) { + return ::testing::AssertionSuccess(); + } + } + return ::testing::AssertionFailure() << "Backup flag did not settle: " << url.path.UTF8String; +} + +} // 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 new file mode 100644 index 00000000000..9b1ae57c5a1 --- /dev/null +++ b/backends/apple/coreai/runtime/test/coreai_storage_test.mm @@ -0,0 +1,113 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include +#include "coreai_fault_scope.h" +#include "coreai_file.h" +#include "coreai_filesystem_fixture.h" + +using namespace executorch::runtime; +using namespace executorch::backends::coreai; +using namespace executorch::backends::coreai::testing; + +TEST(CoreAIStorageTest, PreparesRootWithRootOnlyBackupPolicy) { + NSFileManager* manager = NSFileManager.defaultManager; + TestDirectory sandbox; + ASSERT_NE(sandbox.url, nil); + NSURL* parent = [sandbox.url URLByAppendingPathComponent:@"parent"]; + ASSERT_TRUE(([manager createDirectoryAtURL:parent + withIntermediateDirectories:NO + attributes:nil + error:nil])); + 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, true); + ASSERT_TRUE((prepared.ok() && [prepared.get() isEqualToString:root.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, false); + ASSERT_TRUE((inspected.ok() && [inspected.get() isEqualToString:root.path])); + ASSERT_TRUE((!backup_excluded(root))); + ASSERT_TRUE((prepare_storage_root(root.path, true).ok() && backup_excluded(root))); + + NSURL* missing = [sandbox.url URLByAppendingPathComponent:@"missing"]; + ASSERT_EQ(prepare_storage_root(missing.path, false).error(), Error::InvalidArgument); + EXPECT_FALSE([manager fileExistsAtPath:missing.path]); + EXPECT_EQ(prepare_storage_root(@"relative", true).error(), Error::InvalidArgument); + EXPECT_TRUE((!backup_excluded(parent) && !backup_excluded(sandbox.url))); +} + +TEST(CoreAIStorageTest, ListsChildrenRepeatedlyInSortedOrder) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root( + open(storage.url.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_TRUE((root.get() >= 0)); + NSMutableArray* expected = [NSMutableArray array]; + for (int i = 7; i >= 0; --i) { + NSString* name = [NSString stringWithFormat:@"entry-%d", i]; + FileDescriptor file(openat(root.get(), name.fileSystemRepresentation, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600)); + ASSERT_TRUE((file.get() >= 0)); + [expected insertObject:name atIndex:0]; + } + for (int pass = 0; pass < 3; ++pass) { + errno = EIO; + auto names = storage_children(root.get()); + ASSERT_TRUE((names.ok() && [names.get() isEqual:expected])); + ASSERT_TRUE((fcntl(root.get(), F_GETFD) >= 0)); + } +} + +TEST(CoreAIStorageTest, RetriesEintr) { + int calls = 0; + const ssize_t count = retry_eintr([&]() -> ssize_t { + if (++calls < 3) { + errno = EINTR; + return -1; + } + return 7; + }); + ASSERT_TRUE((calls == 3 && count == 7)); +} + +static ::testing::AssertionResult no_temporary_files(int root) { + auto children = storage_children(root); + if (!children.ok()) return ::testing::AssertionFailure() << "Cannot list storage"; + for (NSString* child in children.get()) { + if ([child hasPrefix:@".tmp-"]) return ::testing::AssertionFailure() << child.UTF8String; + } + return ::testing::AssertionSuccess(); +} + +TEST(CoreAIStorageTest, AtomicPublicationPreservesOldBytesOnFailure) { + TestDirectory storage; + ASSERT_NE(storage.url, nil); + FileDescriptor root( + open(storage.url.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_CLOEXEC)); + ASSERT_TRUE((root.get() >= 0)); + NSData* old = [@"old" dataUsingEncoding:NSUTF8StringEncoding]; + NSData* next = [@"next" dataUsingEncoding:NSUTF8StringEncoding]; + NSURL* published = [storage.url URLByAppendingPathComponent:@"published"]; + ASSERT_EQ(publish_storage_data(root.get(), @"published", old), Error::Ok); + { + StorageFaultScope fault(StorageOperation::Rename); + EXPECT_EQ(publish_storage_data(root.get(), @"published", next), Error::AccessFailed); + EXPECT_EQ(fault.hits, 1); + } + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:old])); + EXPECT_TRUE(no_temporary_files(root.get())); + + ASSERT_EQ(publish_storage_data(root.get(), @"published", next), Error::Ok); + EXPECT_TRUE(([[NSData dataWithContentsOfURL:published] isEqual:next])); + EXPECT_TRUE(no_temporary_files(root.get())); +}