From b207e9eae333207dfaef9f1d8ed08e9bc17c969d Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 10:58:05 +0200 Subject: [PATCH 1/5] Fix: Serialize JSONFileStorage writes through an ordered writer - Capture an immutable cache snapshot under the lock for every mutation and persist snapshots on one serial writer queue, so an older snapshot can never overwrite a newer mutation - Make synchronize() a true barrier that waits for every previously enqueued write before returning - Keep atomic file replacement and complete pending writes even if the storage instance is released - Add ordering, barrier, restart, corruption, and concurrency stress tests against an injectable persistence step --- Sources/TUIkit/State/AppStorage.swift | 97 ++++++-- .../AppStoragePersistenceTests.swift | 210 ++++++++++++++++++ 2 files changed, 285 insertions(+), 22 deletions(-) create mode 100644 Tests/TUIkitTests/AppStoragePersistenceTests.swift diff --git a/Sources/TUIkit/State/AppStorage.swift b/Sources/TUIkit/State/AppStorage.swift index a1d2337d..2ad0441b 100644 --- a/Sources/TUIkit/State/AppStorage.swift +++ b/Sources/TUIkit/State/AppStorage.swift @@ -76,15 +76,31 @@ private func appConfigDirectory() -> URL { /// Data is stored in `$XDG_CONFIG_HOME/[appName]/settings.json` /// or `~/.config/[appName]/settings.json` as fallback. public final class JSONFileStorage: StorageBackend, @unchecked Sendable { + /// Persists one serialized snapshot payload to its destination. + /// + /// The default handler writes atomically to the file system. Tests inject + /// recording or failing handlers to assert ordering and error behavior. + typealias PersistHandler = @Sendable (_ payload: Data, _ destination: URL) throws -> Void + /// The file URL for the storage file. private let fileURL: URL /// In-memory cache of stored values. private var cache: [String: Data] = [:] - /// Lock for thread safety. + /// Lock protecting the in-memory cache. private let lock = NSLock() + /// Serial queue establishing a total order over snapshot writes. + /// + /// Every mutation enqueues an immutable snapshot of its resulting state. + /// Because the queue is serial, an earlier (older) snapshot can never be + /// written after (and thereby overwrite) a later one. + private let writeQueue = DispatchQueue(label: "work.layered.tuikit.storage-writer") + + /// Writes one serialized payload to disk. + private let persist: PersistHandler + /// Creates a JSON file storage with default location. public init() { let configDir = appConfigDirectory() @@ -93,12 +109,26 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) self.fileURL = configDir.appendingPathComponent("settings.json") + self.persist = Self.atomicWrite loadFromDisk() } /// Creates a JSON file storage with a custom file URL. public init(fileURL: URL) { self.fileURL = fileURL + self.persist = Self.atomicWrite + loadFromDisk() + } + + /// Creates a storage with an injectable persistence step. + /// + /// - Parameters: + /// - fileURL: The destination passed to every persist invocation. + /// - persist: Handler receiving each serialized snapshot payload in + /// mutation order on the storage's writer queue. + init(fileURL: URL, persist: @escaping PersistHandler) { + self.fileURL = fileURL + self.persist = persist loadFromDisk() } } @@ -120,31 +150,23 @@ public extension JSONFileStorage { } func setValue(_ value: T, forKey key: String) { - lock.lock() - defer { lock.unlock() } - do { let data = try JSONEncoder().encode(value) - cache[key] = data - saveToDiskAsync() + applyAndEnqueueSnapshot { cache in cache[key] = data } } catch { // Encoding failed - ignore silently } } func removeValue(forKey key: String) { - lock.lock() - defer { lock.unlock() } - - cache.removeValue(forKey: key) - saveToDiskAsync() + applyAndEnqueueSnapshot { cache in cache.removeValue(forKey: key) } } func synchronize() { - lock.lock() - defer { lock.unlock() } - - saveToDiskSync() + // An empty synchronous block on the serial writer queue is a true + // barrier: it can only run after every previously enqueued snapshot + // write has completed. + writeQueue.sync {} } } @@ -169,26 +191,57 @@ private extension JSONFileStorage { } } - func saveToDiskAsync() { - Task.detached(priority: .utility) { [weak self] in - self?.saveToDiskSync() + /// Applies a mutation to the cache and enqueues the resulting snapshot. + /// + /// The mutation and the snapshot copy happen under the lock, so every + /// enqueued snapshot is an immutable, consistent image of the state the + /// mutation produced. The writer queue then persists snapshots strictly + /// in mutation order. + /// + /// - Parameter mutate: The cache mutation to apply. + func applyAndEnqueueSnapshot(_ mutate: (inout [String: Data]) -> Void) { + lock.lock() + mutate(&cache) + let snapshot = cache + lock.unlock() + + writeQueue.async { [persist, fileURL] in + Self.persistSnapshot(snapshot, to: fileURL, using: persist) } } - func saveToDiskSync() { + /// Serializes one snapshot and hands it to the persist handler. + /// + /// Runs on the writer queue. Captures only immutable values so pending + /// writes complete even if the storage instance is released. + /// + /// - Parameters: + /// - snapshot: The cache state to persist. + /// - fileURL: The destination file. + /// - persist: The handler performing the actual write. + static func persistSnapshot( + _ snapshot: [String: Data], + to fileURL: URL, + using persist: PersistHandler + ) { // Convert Data values to base64 strings for JSON compatibility var serializable: [String: String] = [:] - for (key, data) in cache { + for (key, data) in snapshot { serializable[key] = data.base64EncodedString() } do { - let data = try JSONSerialization.data(withJSONObject: serializable, options: .prettyPrinted) - try data.write(to: fileURL, options: .atomic) + let payload = try JSONSerialization.data(withJSONObject: serializable, options: .prettyPrinted) + try persist(payload, fileURL) } catch { // Failed to save - ignore silently } } + + /// Default persistence: atomic replacement of the destination file. + static let atomicWrite: PersistHandler = { payload, destination in + try payload.write(to: destination, options: .atomic) + } } // MARK: - Storage Defaults diff --git a/Tests/TUIkitTests/AppStoragePersistenceTests.swift b/Tests/TUIkitTests/AppStoragePersistenceTests.swift new file mode 100644 index 00000000..33a11bd5 --- /dev/null +++ b/Tests/TUIkitTests/AppStoragePersistenceTests.swift @@ -0,0 +1,210 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// AppStoragePersistenceTests.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation +import Testing + +@testable import TUIkit + +@Suite("AppStorage Persistence") +struct AppStoragePersistenceTests { + + // MARK: - Ordered Writer + + @Test("Each mutation persists its own snapshot in mutation order") + func mutationsPersistOrderedSnapshots() { + let recorder = PersistRecorder(delayFirstWrite: 0.05) + let storage = JSONFileStorage( + fileURL: temporaryStorageURL(), + persist: recorder.persist + ) + + storage.setValue("first", forKey: "key") + storage.setValue("second", forKey: "key") + storage.synchronize() + + let snapshots = recorder.recordedSnapshots() + #expect(snapshots.count == 2) + #expect(snapshots.first?["key"] == "first") + #expect(snapshots.last?["key"] == "second") + } + + @Test("synchronize awaits every previously issued write") + func synchronizeAwaitsAllPreviousWrites() { + let recorder = PersistRecorder(delayPerWrite: 0.05) + let storage = JSONFileStorage( + fileURL: temporaryStorageURL(), + persist: recorder.persist + ) + + storage.setValue("one", forKey: "a") + storage.setValue("two", forKey: "b") + storage.removeValue(forKey: "a") + storage.synchronize() + + let snapshots = recorder.recordedSnapshots() + #expect(snapshots.count == 3) + #expect(snapshots.last?["a"] == nil) + #expect(snapshots.last?["b"] == "two") + } + + // MARK: - Restart + + @Test("A restarted storage reads previously persisted values") + func restartReadsPersistedValues() { + let fileURL = temporaryStorageURL() + defer { removeTemporaryStorage(fileURL) } + + let first = JSONFileStorage(fileURL: fileURL) + first.setValue("persisted", forKey: "greeting") + first.setValue(42, forKey: "answer") + first.synchronize() + + let second = JSONFileStorage(fileURL: fileURL) + let greeting: String? = second.value(forKey: "greeting") + let answer: Int? = second.value(forKey: "answer") + #expect(greeting == "persisted") + #expect(answer == 42) + } + + // MARK: - Corruption + + @Test("A corrupted storage file starts fresh and recovers on write") + func corruptedFileStartsFreshAndRecovers() throws { + let fileURL = temporaryStorageURL() + defer { removeTemporaryStorage(fileURL) } + try Data("not { valid json".utf8).write(to: fileURL) + + let storage = JSONFileStorage(fileURL: fileURL) + let missing: String? = storage.value(forKey: "key") + #expect(missing == nil) + + storage.setValue("recovered", forKey: "key") + storage.synchronize() + + let reloaded = JSONFileStorage(fileURL: fileURL) + let value: String? = reloaded.value(forKey: "key") + #expect(value == "recovered") + } + + // MARK: - Concurrency Stress + + @Test("Concurrent mutations never lose the final value per key", .timeLimit(.minutes(1))) + func concurrentMutationsSurviveStress() async { + let fileURL = temporaryStorageURL() + defer { removeTemporaryStorage(fileURL) } + let storage = JSONFileStorage(fileURL: fileURL) + let writers = 16 + let mutationsPerWriter = 25 + + await withTaskGroup(of: Void.self) { group in + for writer in 0.. Void { + { [self] payload, _ in + lock.lock() + invocations += 1 + let isFirst = invocations == 1 + lock.unlock() + + if isFirst, delayFirstWrite > 0 { + Thread.sleep(forTimeInterval: delayFirstWrite) + } + if delayPerWrite > 0 { + Thread.sleep(forTimeInterval: delayPerWrite) + } + + lock.lock() + payloads.append(payload) + lock.unlock() + } + } + + /// Returns every recorded payload decoded into `[key: decoded String or Int]`. + /// + /// Values are stored as base64-encoded JSON fragments; this decodes them + /// back to their readable representation for assertions. + func recordedSnapshots() -> [[String: String]] { + lock.lock() + defer { lock.unlock() } + return payloads.map { payload in + guard + let object = try? JSONSerialization.jsonObject(with: payload), + let encoded = object as? [String: String] + else { return [:] } + + var snapshot: [String: String] = [:] + for (key, base64) in encoded { + guard + let data = Data(base64Encoded: base64), + let value = try? JSONDecoder().decode(String.self, from: data) + else { continue } + snapshot[key] = value + } + return snapshot + } + } +} + +/// Returns a unique storage file URL inside the system temporary directory. +private func temporaryStorageURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("tuikit-storage-tests-\(UUID().uuidString)") + .appendingPathExtension("json") +} + +/// Removes a temporary storage file created by a test. +private func removeTemporaryStorage(_ fileURL: URL) { + try? FileManager.default.removeItem(at: fileURL) +} From 830900361cd55dc6c7f06599fc9c3a4f509d95e1 Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 11:00:47 +0200 Subject: [PATCH 2/5] Fix: Report storage persistence failures instead of dropping them - Add StoragePersistenceError with sanitized reasons (error domain and code only, never paths or stored content) - Route encode, serialize, and write failures through an injectable handler; the default logs one line to standard error - Cover failing writes, unwritable destinations, and encoding failures with tests asserting the storage path never leaks --- Sources/TUIkit/State/AppStorage.swift | 62 ++++++++++-- .../State/StoragePersistenceError.swift | 53 ++++++++++ .../AppStoragePersistenceTests.swift | 99 +++++++++++++++++++ 3 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 Sources/TUIkit/State/StoragePersistenceError.swift diff --git a/Sources/TUIkit/State/AppStorage.swift b/Sources/TUIkit/State/AppStorage.swift index 2ad0441b..e70e57fd 100644 --- a/Sources/TUIkit/State/AppStorage.swift +++ b/Sources/TUIkit/State/AppStorage.swift @@ -101,8 +101,16 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { /// Writes one serialized payload to disk. private let persist: PersistHandler + /// Receives every persistence failure this storage encounters. + private let onPersistenceFailure: @Sendable (StoragePersistenceError) -> Void + /// Creates a JSON file storage with default location. - public init() { + /// + /// - Parameter onPersistenceFailure: Optional handler receiving sanitized + /// persistence failures. Defaults to logging one line to standard error. + public init( + onPersistenceFailure: (@Sendable (StoragePersistenceError) -> Void)? = nil + ) { let configDir = appConfigDirectory() // Create directory if needed @@ -110,13 +118,23 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { self.fileURL = configDir.appendingPathComponent("settings.json") self.persist = Self.atomicWrite + self.onPersistenceFailure = onPersistenceFailure ?? Self.logFailureToStandardError loadFromDisk() } /// Creates a JSON file storage with a custom file URL. - public init(fileURL: URL) { + /// + /// - Parameters: + /// - fileURL: The destination file for persisted values. + /// - onPersistenceFailure: Optional handler receiving sanitized + /// persistence failures. Defaults to logging one line to standard error. + public init( + fileURL: URL, + onPersistenceFailure: (@Sendable (StoragePersistenceError) -> Void)? = nil + ) { self.fileURL = fileURL self.persist = Self.atomicWrite + self.onPersistenceFailure = onPersistenceFailure ?? Self.logFailureToStandardError loadFromDisk() } @@ -126,9 +144,16 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { /// - fileURL: The destination passed to every persist invocation. /// - persist: Handler receiving each serialized snapshot payload in /// mutation order on the storage's writer queue. - init(fileURL: URL, persist: @escaping PersistHandler) { + /// - onPersistenceFailure: Optional handler receiving sanitized + /// persistence failures. Defaults to logging one line to standard error. + init( + fileURL: URL, + persist: @escaping PersistHandler, + onPersistenceFailure: (@Sendable (StoragePersistenceError) -> Void)? = nil + ) { self.fileURL = fileURL self.persist = persist + self.onPersistenceFailure = onPersistenceFailure ?? Self.logFailureToStandardError loadFromDisk() } } @@ -154,7 +179,7 @@ public extension JSONFileStorage { let data = try JSONEncoder().encode(value) applyAndEnqueueSnapshot { cache in cache[key] = data } } catch { - // Encoding failed - ignore silently + onPersistenceFailure(StoragePersistenceError(operation: .encode, underlying: error)) } } @@ -205,8 +230,13 @@ private extension JSONFileStorage { let snapshot = cache lock.unlock() - writeQueue.async { [persist, fileURL] in - Self.persistSnapshot(snapshot, to: fileURL, using: persist) + writeQueue.async { [persist, fileURL, onPersistenceFailure] in + Self.persistSnapshot( + snapshot, + to: fileURL, + using: persist, + reportingTo: onPersistenceFailure + ) } } @@ -219,10 +249,12 @@ private extension JSONFileStorage { /// - snapshot: The cache state to persist. /// - fileURL: The destination file. /// - persist: The handler performing the actual write. + /// - report: Receiver for sanitized serialization and write failures. static func persistSnapshot( _ snapshot: [String: Data], to fileURL: URL, - using persist: PersistHandler + using persist: PersistHandler, + reportingTo report: @Sendable (StoragePersistenceError) -> Void ) { // Convert Data values to base64 strings for JSON compatibility var serializable: [String: String] = [:] @@ -230,11 +262,18 @@ private extension JSONFileStorage { serializable[key] = data.base64EncodedString() } + let payload: Data + do { + payload = try JSONSerialization.data(withJSONObject: serializable, options: .prettyPrinted) + } catch { + report(StoragePersistenceError(operation: .serialize, underlying: error)) + return + } + do { - let payload = try JSONSerialization.data(withJSONObject: serializable, options: .prettyPrinted) try persist(payload, fileURL) } catch { - // Failed to save - ignore silently + report(StoragePersistenceError(operation: .write, underlying: error)) } } @@ -242,6 +281,11 @@ private extension JSONFileStorage { static let atomicWrite: PersistHandler = { payload, destination in try payload.write(to: destination, options: .atomic) } + + /// Default failure handling: one sanitized line on standard error. + static let logFailureToStandardError: @Sendable (StoragePersistenceError) -> Void = { failure in + FileHandle.standardError.write(Data("TUIkit: \(failure.description)\n".utf8)) + } } // MARK: - Storage Defaults diff --git a/Sources/TUIkit/State/StoragePersistenceError.swift b/Sources/TUIkit/State/StoragePersistenceError.swift new file mode 100644 index 00000000..1ee0e2bc --- /dev/null +++ b/Sources/TUIkit/State/StoragePersistenceError.swift @@ -0,0 +1,53 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// StoragePersistenceError.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +// MARK: - Storage Persistence Error + +/// Describes one failed attempt to persist application storage. +/// +/// Reasons are sanitized: they identify the failing step and the underlying +/// error's domain and code, but never include file paths or stored content, +/// so the error can be logged or surfaced without leaking user data. +public struct StoragePersistenceError: Error, Sendable, CustomStringConvertible { + /// The persistence step that failed. + public enum Operation: String, Sendable { + /// Encoding a value into its JSON representation failed. + case encode + + /// Serializing the snapshot dictionary into a payload failed. + case serialize + + /// Writing the serialized payload to its destination failed. + case write + } + + /// The failing persistence step. + public let operation: Operation + + /// Sanitized failure reason without paths or stored content. + public let reason: String + + /// Creates an error from a failing step and its underlying error. + /// + /// The underlying error is reduced to its domain and code. Foundation + /// errors carry file paths in their descriptions and user info, so the + /// original error is deliberately not retained. + /// + /// - Parameters: + /// - operation: The persistence step that failed. + /// - underlying: The error thrown by that step. + init(operation: Operation, underlying: any Error) { + self.operation = operation + let nsError = underlying as NSError + self.reason = "\(nsError.domain) code \(nsError.code)" + } + + public var description: String { + "Application storage \(operation.rawValue) failed: \(reason)" + } +} diff --git a/Tests/TUIkitTests/AppStoragePersistenceTests.swift b/Tests/TUIkitTests/AppStoragePersistenceTests.swift index 33a11bd5..55276160 100644 --- a/Tests/TUIkitTests/AppStoragePersistenceTests.swift +++ b/Tests/TUIkitTests/AppStoragePersistenceTests.swift @@ -90,6 +90,66 @@ struct AppStoragePersistenceTests { #expect(value == "recovered") } + // MARK: - Failure Reporting + + @Test("A failing write reports a sanitized persistence failure") + func failingWriteReportsPersistenceFailure() { + let fileURL = temporaryStorageURL() + let reported = ReportedFailures() + let storage = JSONFileStorage( + fileURL: fileURL, + persist: { _, _ in throw CocoaError(.fileWriteNoPermission) }, + onPersistenceFailure: { reported.append($0) } + ) + + storage.setValue("value", forKey: "key") + storage.synchronize() + + let failures = reported.all() + #expect(failures.count == 1) + #expect(failures.first?.operation == .write) + #expect(failures.first?.description.contains(fileURL.path) == false) + } + + @Test("The default persistence step reports unwritable destinations without leaking the path") + func unwritableDestinationReportsFailure() { + let missingDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("tuikit-missing-\(UUID().uuidString)") + let fileURL = missingDirectory.appendingPathComponent("settings.json") + let reported = ReportedFailures() + let storage = JSONFileStorage( + fileURL: fileURL, + onPersistenceFailure: { reported.append($0) } + ) + + storage.setValue("value", forKey: "key") + storage.synchronize() + + let failures = reported.all() + #expect(failures.isEmpty == false) + #expect(failures.allSatisfy { failure in + !failure.description.contains(missingDirectory.path) + && !failure.reason.contains(missingDirectory.path) + }) + } + + @Test("An encoding failure is reported instead of silently dropped") + func encodingFailureIsReported() { + let reported = ReportedFailures() + let storage = JSONFileStorage( + fileURL: temporaryStorageURL(), + persist: { _, _ in }, + onPersistenceFailure: { reported.append($0) } + ) + + storage.setValue(FailingEncodable(), forKey: "key") + storage.synchronize() + + let failures = reported.all() + #expect(failures.count == 1) + #expect(failures.first?.operation == .encode) + } + // MARK: - Concurrency Stress @Test("Concurrent mutations never lose the final value per key", .timeLimit(.minutes(1))) @@ -124,6 +184,45 @@ struct AppStoragePersistenceTests { // MARK: - Test Support +/// Collects persistence failures reported by a storage under test. +private final class ReportedFailures: @unchecked Sendable { + /// Reported failures in delivery order. + private var failures: [StoragePersistenceError] = [] + + /// Lock protecting the failure list. + private let lock = NSLock() + + /// Records one reported failure. + func append(_ failure: StoragePersistenceError) { + lock.lock() + failures.append(failure) + lock.unlock() + } + + /// Returns every failure reported so far. + func all() -> [StoragePersistenceError] { + lock.lock() + defer { lock.unlock() } + return failures + } +} + +/// A Codable value whose encoding always fails. +private struct FailingEncodable: Codable { + init() {} + + init(from decoder: Decoder) throws { + self.init() + } + + func encode(to encoder: Encoder) throws { + throw EncodingError.invalidValue( + self, + EncodingError.Context(codingPath: [], debugDescription: "always fails") + ) + } +} + /// Records every payload handed to the storage's persist step. /// /// Optional artificial delays surface ordering bugs: a delayed first write From a2bef92c1e25342553135f27da7ce949800717ba Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 11:02:27 +0200 Subject: [PATCH 3/5] Fix: Remove the global storage fallback and bind storage per runtime - Delete the deprecated mutable StorageDefaults global - Give every production runtime its own JSONFileStorage instance so two runtimes never share a backend unless explicitly injected - Route AppStorage access outside a runtime to a deliberately volatile process-local fallback that never touches the file system --- Sources/TUIkit/Environment/TUIContext.swift | 6 ++- Sources/TUIkit/State/AppStorage.swift | 38 +++++++------------ .../AppStoragePersistenceTests.swift | 15 ++++++++ 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/Sources/TUIkit/Environment/TUIContext.swift b/Sources/TUIkit/Environment/TUIContext.swift index a1493b33..2b5067ed 100644 --- a/Sources/TUIkit/Environment/TUIContext.swift +++ b/Sources/TUIkit/Environment/TUIContext.swift @@ -447,10 +447,14 @@ final class TUIContext { extension TUIContext { /// Creates a runtime backed by the user's persistent configuration. + /// + /// Each production runtime owns its own ``JSONFileStorage`` instance, so + /// two runtimes never share a storage backend unless one is injected + /// explicitly. static func production() -> TUIContext { TUIContext( runtimeDiagnostics: .standardError(), - storageBackend: StorageDefaults.runtimeBackend, + storageBackend: JSONFileStorage(), localizationService: LocalizationService() ) } diff --git a/Sources/TUIkit/State/AppStorage.swift b/Sources/TUIkit/State/AppStorage.swift index e70e57fd..fc37e4a1 100644 --- a/Sources/TUIkit/State/AppStorage.swift +++ b/Sources/TUIkit/State/AppStorage.swift @@ -288,35 +288,25 @@ private extension JSONFileStorage { } } -// MARK: - Storage Defaults +// MARK: - Unbound Fallback -/// Provides the default storage backend for ``AppStorage``. +/// Process-local fallback for ``AppStorage`` properties accessed outside a +/// runtime. /// -/// This global compatibility hook is deprecated. `@AppStorage` properties -/// rendered inside an app bind to that app's runtime backend. Pass an explicit -/// backend to the property wrapper when code outside a runtime needs one. +/// Persistent storage is always owned by an app runtime (injected through +/// `TUIContext`) or passed explicitly to the property wrapper: /// /// ```swift /// @AppStorage("token", storage: MyCustomBackend()) var token = "" /// ``` -public enum StorageDefaults { - /// Backing storage retained until issue #15 removes the global fallback. - nonisolated(unsafe) private static var configuredBackend: StorageBackend = JSONFileStorage() - - /// The default storage backend used by ``AppStorage``. - /// - /// Defaults to a ``JSONFileStorage`` instance that persists to - /// `$XDG_CONFIG_HOME/[appName]/settings.json`. - @available(*, deprecated, message: "Pass a StorageBackend to the AppStorage initializer instead") - public static var backend: StorageBackend { - get { configuredBackend } - set { configuredBackend = newValue } - } - - /// Legacy fallback used only when AppStorage is accessed outside a runtime. - static var runtimeBackend: StorageBackend { - configuredBackend - } +/// +/// Code that touches an `@AppStorage` property before any runtime hydrates it +/// therefore gets deliberately volatile in-memory semantics: nothing reaches +/// the file system, and no global mutable backend exists that two runtimes +/// could accidentally share. +enum UnboundAppStorage { + /// Shared volatile backend for unbound property access. + static let fallback: StorageBackend = VolatileStorageBackend() } // MARK: - AppStorage Property Wrapper @@ -475,7 +465,7 @@ private extension AppStorageBox { bindToActiveRuntimeIfNeeded() lock.lock() - let storage = explicitStorage ?? runtimeStorage ?? StorageDefaults.runtimeBackend + let storage = explicitStorage ?? runtimeStorage ?? UnboundAppStorage.fallback let invalidationSink = invalidationSink let identity = identity lock.unlock() diff --git a/Tests/TUIkitTests/AppStoragePersistenceTests.swift b/Tests/TUIkitTests/AppStoragePersistenceTests.swift index 55276160..18d3a04d 100644 --- a/Tests/TUIkitTests/AppStoragePersistenceTests.swift +++ b/Tests/TUIkitTests/AppStoragePersistenceTests.swift @@ -150,6 +150,21 @@ struct AppStoragePersistenceTests { #expect(failures.first?.operation == .encode) } + // MARK: - Unbound Fallback + + @Test("AppStorage outside a runtime falls back to volatile process storage") + func unboundAppStorageUsesVolatileFallback() { + #expect(UnboundAppStorage.fallback is VolatileStorageBackend) + + let key = "unbound-\(UUID().uuidString)" + let first = AppStorage(wrappedValue: "default", key) + let second = AppStorage(wrappedValue: "default", key) + + first.wrappedValue = "shared" + + #expect(second.wrappedValue == "shared") + } + // MARK: - Concurrency Stress @Test("Concurrent mutations never lose the final value per key", .timeLimit(.minutes(1))) From c5d84947e5520bd25594d173255dea3a5df1fd9f Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 11:14:35 +0200 Subject: [PATCH 4/5] Chore: Regenerate the API compatibility manifest for storage changes - Drop policy overrides for the removed StorageDefaults global and the replaced JSONFileStorage initializers - Add tuiSpecific overrides for StoragePersistenceError and the new failure-reporting initializers, copied from fresh 6.0.3 snapshots - Regenerate the manifest with TUIkitAPICheck against the assembled macOS and Linux snapshot set --- .../Configuration/compatibility-manifest.json | 65 ++++++++++---- .../Configuration/review-policy.json | 85 +++++++++++++------ 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/Tools/APICompatibility/Configuration/compatibility-manifest.json b/Tools/APICompatibility/Configuration/compatibility-manifest.json index 0d84fefc..a902b7c5 100644 --- a/Tools/APICompatibility/Configuration/compatibility-manifest.json +++ b/Tools/APICompatibility/Configuration/compatibility-manifest.json @@ -342084,27 +342084,27 @@ { "classification" : "tuiSpecific", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15JSONFileStorageC5value6forKeyxSgSS_tSeRzSERzlF" + "symbolID" : "s:6TUIkit15JSONFileStorageC20onPersistenceFailureACyAA0cE5ErrorVYbcSg_tcfc" }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15JSONFileStorageC7fileURLAC10Foundation0E0V_tcfc" + "symbolID" : "s:6TUIkit15JSONFileStorageC5value6forKeyxSgSS_tSeRzSERzlF" }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15JSONFileStorageC7fileURLAC20FoundationEssentials0E0V_tcfc" + "symbolID" : "s:6TUIkit15JSONFileStorageC7fileURL20onPersistenceFailureAC10Foundation0E0V_yAA0cG5ErrorVYbcSgtcfc" }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15JSONFileStorageC8setValue_6forKeyyx_SStSeRzSERzlF" + "symbolID" : "s:6TUIkit15JSONFileStorageC7fileURL20onPersistenceFailureAC20FoundationEssentials0E0V_yAA0cG5ErrorVYbcSgtcfc" }, { "classification" : "tuiSpecific", "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15JSONFileStorageCACycfc" + "symbolID" : "s:6TUIkit15JSONFileStorageC8setValue_6forKeyyx_SStSeRzSERzlF" }, { "classification" : "tuiSpecific", @@ -342651,16 +342651,6 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit15RadioButtonItemVyACyxGx_SStcfc" }, - { - "classification" : "tuiSpecific", - "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15StorageDefaultsO" - }, - { - "classification" : "tuiSpecific", - "ownerIssue" : "#35", - "symbolID" : "s:6TUIkit15StorageDefaultsO7backendAA0B7Backend_pvpZ" - }, { "classification" : "implementationLeak", "ownerIssue" : "#35", @@ -343556,6 +343546,51 @@ "ownerIssue" : "#35", "symbolID" : "s:6TUIkit23RadioButtonGroupBuilderO13buildOptionalySayAA0bC4ItemVyxGGAHSgFZ" }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV11descriptionSSvp" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV6reasonSSvp" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9OperationO" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9OperationO5writeyA2EmF" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9OperationO6encodeyA2EmF" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9OperationO8rawValueAESgSS_tcfc" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9OperationO9serializeyA2EmF" + }, + { + "classification" : "tuiSpecific", + "ownerIssue" : "#35", + "symbolID" : "s:6TUIkit23StoragePersistenceErrorV9operationAC9OperationOvp" + }, { "classification" : "implementationLeak", "ownerIssue" : "#35", diff --git a/Tools/APICompatibility/Configuration/review-policy.json b/Tools/APICompatibility/Configuration/review-policy.json index 0477e833..9cc6920b 100644 --- a/Tools/APICompatibility/Configuration/review-policy.json +++ b/Tools/APICompatibility/Configuration/review-policy.json @@ -22573,26 +22573,11 @@ "ownerIssue": "#35", "symbolID": "s:6TUIkit15JSONFileStorageC5value6forKeyxSgSS_tSeRzSERzlF" }, - { - "action": "tuiSpecific", - "ownerIssue": "#35", - "symbolID": "s:6TUIkit15JSONFileStorageC7fileURLAC10Foundation0E0V_tcfc" - }, - { - "action": "tuiSpecific", - "ownerIssue": "#35", - "symbolID": "s:6TUIkit15JSONFileStorageC7fileURLAC20FoundationEssentials0E0V_tcfc" - }, { "action": "tuiSpecific", "ownerIssue": "#35", "symbolID": "s:6TUIkit15JSONFileStorageC8setValue_6forKeyyx_SStSeRzSERzlF" }, - { - "action": "tuiSpecific", - "ownerIssue": "#35", - "symbolID": "s:6TUIkit15JSONFileStorageCACycfc" - }, { "action": "tuiSpecific", "ownerIssue": "#35", @@ -23138,16 +23123,6 @@ "ownerIssue": "#35", "symbolID": "s:6TUIkit15RadioButtonItemVyACyxGx_SStcfc" }, - { - "action": "tuiSpecific", - "ownerIssue": "#35", - "symbolID": "s:6TUIkit15StorageDefaultsO" - }, - { - "action": "tuiSpecific", - "ownerIssue": "#35", - "symbolID": "s:6TUIkit15StorageDefaultsO7backendAA0B7Backend_pvpZ" - }, { "action": "implementationLeak", "ownerIssue": "#35", @@ -25892,6 +25867,66 @@ "action": "implementationLeak", "ownerIssue": "#35", "symbolID": "s:10TUIkitView13RenderContextV5phaseAA0C5PhaseOvp" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit15JSONFileStorageC20onPersistenceFailureACyAA0cE5ErrorVYbcSg_tcfc" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit15JSONFileStorageC7fileURL20onPersistenceFailureAC10Foundation0E0V_yAA0cG5ErrorVYbcSgtcfc" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit15JSONFileStorageC7fileURL20onPersistenceFailureAC20FoundationEssentials0E0V_yAA0cG5ErrorVYbcSgtcfc" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV11descriptionSSvp" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV6reasonSSvp" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9OperationO" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9OperationO5writeyA2EmF" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9OperationO6encodeyA2EmF" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9OperationO8rawValueAESgSS_tcfc" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9OperationO9serializeyA2EmF" + }, + { + "action": "tuiSpecific", + "ownerIssue": "#35", + "symbolID": "s:6TUIkit23StoragePersistenceErrorV9operationAC9OperationOvp" } ] } From 370451484d9ccd3a920f9df4c49a323866b7e44e Mon Sep 17 00:00:00 2001 From: phranck Date: Wed, 22 Jul 2026 11:15:10 +0200 Subject: [PATCH 5/5] Docs: Describe AppStorage persistence guarantees - Document ordered snapshot writes, the synchronize barrier, and sanitized failure reporting in the state management article - Document the volatile fallback for properties accessed outside a runtime --- .../TUIkit/TUIkit.docc/Articles/StateManagement.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md index 371c246a..79611b66 100644 --- a/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md +++ b/Sources/TUIkit/TUIkit.docc/Articles/StateManagement.md @@ -105,6 +105,20 @@ runtime or needs dedicated storage: @AppStorage("username", storage: MyStorageBackend()) var username = "Guest" ``` +An `@AppStorage` property accessed before any runtime hydrates it falls back +to volatile in-memory storage: nothing reaches the file system without an +owning runtime or an explicit backend. + +### Persistence Guarantees + +``JSONFileStorage`` captures an immutable snapshot for every mutation and +persists snapshots through one ordered writer, so an older state can never +overwrite a newer one. `synchronize()` returns only after every previously +issued write has completed; the runtime flushes storage this way during +cleanup. Persistence failures are reported as ``StoragePersistenceError`` +values whose reasons are sanitized: they identify the failing step and error +code, but never contain file paths or stored content. + ## How State Survives Re-Rendering TUIkit re-evaluates the entire view tree on every frame. When `body` is called, views are