From 1980550aeb5fa5b8aac0ddc6e4a703986672e208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Tue, 29 Sep 2026 10:46:31 +0200 Subject: [PATCH 1/3] Keep key path index layouts independent of user types A Swift compiler bug (present in 6.3.3 and 6.4.0) misplaces a key path's subscript index when the path is formed in generic code, the index's layout depends on a generic parameter, and the index is aligned beyond a pointer. The caller packs it at pointer-size offset; the generated argument-init thunk reads it at the offset rounded up to its alignment. SwiftModel formed such paths for storage ([_metadata: ContextStorage]), preferences ([_preference: PreferenceStorage]) and container elements ([cursor: ContainerCursor]). On wasm32 that made LocalStorage/Int64/Double read garbage or trap in keypath_destroy under Context.willAccessStorage; on 64-bit, 16-byte-aligned types such as SIMD vectors did the same. Index the storage and preference stub paths by the non-generic storage key, box ContainerCursor's id, and drop the unused generic Model [_metadata:]/[_preference:] subscripts. OverAlignedKeyPathIndexTests reproduces all four paths natively with SIMD2 (each crashed before). scripts/wasm-smoke builds a small executable for wasm32-unknown-wasip1 and runs it under wasmtime; it trapped with the downstream stack before the fix and passes after. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 ++ CHANGELOG.md | 7 ++ Docs/Contributing/CI.md | 6 +- Sources/SwiftModel/Internal/Context.swift | 19 ++-- .../Internal/ModelContainer+Internals.swift | 15 ++- .../Internal/ModelContextStorage.swift | 27 ----- .../Internal/ModelPreferenceStorage.swift | 14 --- .../SwiftModel/Internal/ThreadLocals.swift | 2 +- Sources/SwiftModel/ModelSourceBox.swift | 16 ++- .../OverAlignedKeyPathIndexTests.swift | 101 ++++++++++++++++++ Tests/WASMSmoke/Package.swift | 27 +++++ Tests/WASMSmoke/Sources/WASMSmoke/main.swift | 96 +++++++++++++++++ scripts/wasm-smoke | 54 ++++++++++ 13 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 Tests/SwiftModelTests/OverAlignedKeyPathIndexTests.swift create mode 100644 Tests/WASMSmoke/Package.swift create mode 100644 Tests/WASMSmoke/Sources/WASMSmoke/main.swift create mode 100755 scripts/wasm-smoke diff --git a/AGENTS.md b/AGENTS.md index 55874b90..3123b304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Tests/ SwiftModelSnapshotTests/ # InlineSnapshotTesting-based output / diff tests SwiftModelBenchmarkTests/ # Performance benchmarks (skipped from regular runs) SwiftModelMacroTests/ # Macro expansion tests (MacroTesting) + WASMSmoke/ # Separate package: wasm32 runtime smoke (scripts/wasm-smoke) Examples/ # Standalone example apps (each embeds a copy of the library) Docs/ # User-facing guides (linked from README) Docs/Contributing/ # Contributor / agent deep-dives @@ -45,6 +46,9 @@ scripts/test # One test (forwards --filter to swift test). scripts/test --filter SwiftModelTests.SomeTestName +# Runtime smoke under wasmtime (CI only compiles WASM). +scripts/wasm-smoke + # Stress loop. Use after touching observation / coalescing / settling code. scripts/test --loop 100 @@ -153,6 +157,12 @@ based on evidence and progress, not wall-clock time. `CHANGELOG.md`, so a release only has to stamp that section. - Run `scripts/test` (and `--no-parallel` for anything touching observation or settling) before pushing. +- Run `scripts/wasm-smoke` before opening a PR that changes `Sources/`. CI only + compiles for WASM; this runs a small executable under wasmtime, because + wasm32's 4-byte pointers change what the compiler generates (see + `OverAlignedKeyPathIndexTests`). It needs wasmtime, a Swift WASM SDK and the + matching open-source toolchain (the script's header says how it finds them). + If they aren't installed, say so in the PR rather than skipping silently. ## Further reading diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b4444a..60a65dce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes are documented here. The format follows [Keep a Changelog](h ## [Unreleased] +### Fixed + +- **Local, environment and preference storage of an over-aligned value type, and collection elements with an over-aligned `id`, read garbage or crashed.** On wasm32 that is any 8-byte-aligned type: reading a `LocalStorage` trapped with "null function" in `keypath_destroy` under `Context.willAccessStorage`, and `Int64` or `Double` did the same. On 64-bit platforms it takes a 16-byte-aligned type, such as a SIMD vector. The cause is a Swift compiler bug, present in 6.3.3 and 6.4.0: a key path formed in generic code whose subscript index has a layout that depends on a generic parameter is packed and unpacked at different offsets when that index is aligned beyond a pointer. SwiftModel formed such key paths for storage (`[_metadata: ContextStorage]`), preferences (`[_preference: PreferenceStorage]`) and container elements (`[cursor: ContainerCursor]`). + - Storage and preference paths are now indexed by the storage's non-generic key, and `ContainerCursor` boxes its `id`, so no index layout depends on a user type. The unused generic `Model[_metadata:]` / `Model[_preference:]` subscripts are removed. + - `OverAlignedKeyPathIndexTests` covers all four paths with `SIMD2`, so the regular macOS and Linux runs catch a regression. Every one of those tests crashed before the fix. + - `scripts/wasm-smoke` builds a small executable (`Tests/WASMSmoke`) for `wasm32-unknown-wasip1` and runs it under wasmtime, covering the 8-byte-aligned wasm32 cases. Before the fix it trapped with the downstream stack. + --- ## [1.1.1] — Memoize no longer re-evaluates on torn-down models + `forEach(cancelPrevious:)` cancellation fix diff --git a/Docs/Contributing/CI.md b/Docs/Contributing/CI.md index 06a94623..ad77b529 100644 --- a/Docs/Contributing/CI.md +++ b/Docs/Contributing/CI.md @@ -23,7 +23,11 @@ GitHub Actions (`.github/workflows/ci.yml`): and `SwiftModelBenchmarks` doesn't compile for WASI (`DispatchTime`, `DispatchQueue.concurrentPerform`). Running the bundle under wasmtime is still open — `GlobalTickScheduler` is GCD-backed and would need a WASI-native path - first. + first. Until then, `scripts/wasm-smoke` is the runtime check, run locally + before a PR: it builds `Tests/WASMSmoke` (a small executable, not the suite) + and runs it under wasmtime. It exists because a Swift compiler bug with key + path indices aligned beyond a pointer made `LocalStorage` trap on + wasm32 while compiling fine. **Linux `swift test` goes through `scripts/ci-test`.** On Linux, swift-syntax's compiler-plugin message handler intermittently logs `Internal Error: diff --git a/Sources/SwiftModel/Internal/Context.swift b/Sources/SwiftModel/Internal/Context.swift index 7421e005..c004d62d 100644 --- a/Sources/SwiftModel/Internal/Context.swift +++ b/Sources/SwiftModel/Internal/Context.swift @@ -475,12 +475,13 @@ final class Context: AnyContext, @unchecked Sendable { // Shadow gap-race detector (withObservationTracking path): subscribe on the TYPED // `[_metadata:]` path — that is the key `didModifyStorage`'s post-lock callbacks // fire (`modifyCallbacks` for the untyped path never fire). See `willAccessGapShadow`. - willAccessGapShadow(at: \M._ModelState[_metadata: storage] as WritableKeyPath&Sendable) + willAccessGapShadow(at: \M._ModelState[_metadata: storage.key] as WritableKeyPath&Sendable) // Typed writable path on M._ModelState — drives TestAccess snapshot tracking so that // `model.node.local.myKey`/`model.node.environment.myKey` inside expect {} is fully assertable. - // \M._ModelState[_metadata: storage] is a WritableKeyPath because ContextStorage - // is Hashable (via its key), giving Swift what it needs to form and distinguish paths. + // \M._ModelState[_metadata: storage.key] is a WritableKeyPath<_, V> indexed by the storage's + // key alone, so distinct storages produce distinct paths. The index must not be the + // `ContextStorage` itself — see `_ModelStateType[_metadata:]`. // Tag the access as `.metadata` so TestAccess records it under the correct exhaustivity area. // // The getter on _ModelStateType._metadata stubs have fatalError — TestAccess reads the value @@ -488,7 +489,7 @@ final class Context: AnyContext, @unchecked Sendable { // The re-entry guard (isAccessingMetadataStorage) is no longer needed since calling the getter // is no longer possible from TestAccess, but we keep it for clarity of intent. guard !threadLocals.isAccessingMetadataStorage else { return } - let typedPath: WritableKeyPath&Sendable = \M._ModelState[_metadata: storage] + let typedPath: WritableKeyPath&Sendable = \M._ModelState[_metadata: storage.key] let mc = metadataModelContext() let storageArea: _ExhaustivityBits = storage.propagation == .environment ? .environment : .local // Pre-compute storage value so any code that needs to read it (TestAccess's @@ -535,7 +536,7 @@ final class Context: AnyContext, @unchecked Sendable { let untypedPath: KeyPath&Sendable = \M._ModelState[environmentKey: storage.key] // Typed writable path on M._ModelState — drives TestAccess didModify so writes are tracked. // Tag the modification as `.metadata` so TestAccess records it under the correct area. - let typedPath: WritableKeyPath&Sendable = \M._ModelState[_metadata: storage] + let typedPath: WritableKeyPath&Sendable = \M._ModelState[_metadata: storage.key] let mc = metadataModelContext() lock { self.didModify() } @@ -612,7 +613,7 @@ final class Context: AnyContext, @unchecked Sendable { // untyped path never fire). This runs per visited context during `preferenceValue`'s // subtree aggregation, so a contribution write on any visited descendant fires the // subscription registered on that same context. See `willAccessGapShadow`. - willAccessGapShadow(at: \M._ModelState[_preference: storage] as WritableKeyPath&Sendable) + willAccessGapShadow(at: \M._ModelState[_preference: storage.key] as WritableKeyPath&Sendable) // The typed writable path for TestAccess is now handled by willAccessPreferenceValue, // called after preferenceValue finishes aggregating with the computed value in hand. @@ -631,7 +632,7 @@ final class Context: AnyContext, @unchecked Sendable { // already in hand. This avoids re-entering preferenceValue (which acquires child // locks) while the caller's context lock may still be held. guard !threadLocals.isAccessingMetadataStorage else { return } - let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage] + let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage.key] let mc = metadataModelContext() // Set `precomputedPreferenceValue` both during the `willAccess` call and around // its returned closure — symmetric with the `precomputedStorageValue` pattern in @@ -672,7 +673,7 @@ final class Context: AnyContext, @unchecked Sendable { // Registrar call uses _StateObserver (no Model Observable conformance needed). let untypedPath: KeyPath&Sendable = \M._ModelState[preferenceKey: storage.key] // Typed writable path on M._ModelState — drives TestAccess didModify so writes are tracked. - let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage] + let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage.key] let mc = metadataModelContext() lock { self.didModify() } @@ -752,7 +753,7 @@ final class Context: AnyContext, @unchecked Sendable { invokeDidModifySyntheticPath(\_StateObserver[preferenceKey: storage.key, modelID: reference.modelID]) } modelContext.invokeDidModify(at: untypedPath)?() - let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage] + let typedPath: WritableKeyPath&Sendable = \M._ModelState[_preference: storage.key] #if DEBUG let prefName = storage.name let prefPropDesc: (@Sendable () -> String?)? = { "preference.\(prefName)" } diff --git a/Sources/SwiftModel/Internal/ModelContainer+Internals.swift b/Sources/SwiftModel/Internal/ModelContainer+Internals.swift index 067dd181..c417bfb9 100644 --- a/Sources/SwiftModel/Internal/ModelContainer+Internals.swift +++ b/Sources/SwiftModel/Internal/ModelContainer+Internals.swift @@ -185,16 +185,27 @@ func frozenCopy(_ value: T) -> T { } struct ContainerCursor: Hashable, @unchecked Sendable { - let id: ID + // The id is boxed so this struct's layout doesn't depend on `ID`. The cursor is a key path + // subscript index (`\C.[cursor:]`) formed in generic code, and the Swift compiler misreads + // such an index when it is aligned beyond a pointer (e.g. a `UInt64` id on wasm32). + // See `OverAlignedKeyPathIndexTests`. + private let idBox: IDBox let get: @Sendable (Root) -> Value let set: @Sendable (inout Root, Value) -> Void + private final class IDBox { + let id: ID + init(_ id: ID) { self.id = id } + } + init(id: ID, get: @escaping @Sendable (Root) -> Value, set: @escaping @Sendable (inout Root, Value) -> Void) { - self.id = id + self.idBox = IDBox(id) self.get = get self.set = set } + var id: ID { idBox.id } + static func == (lhs: ContainerCursor, rhs: ContainerCursor) -> Bool { lhs.id == rhs.id } diff --git a/Sources/SwiftModel/Internal/ModelContextStorage.swift b/Sources/SwiftModel/Internal/ModelContextStorage.swift index 6bc30e87..b6f692ba 100644 --- a/Sources/SwiftModel/Internal/ModelContextStorage.swift +++ b/Sources/SwiftModel/Internal/ModelContextStorage.swift @@ -455,30 +455,3 @@ extension AnyContext { } } } - -// MARK: - Internal Model subscript for context storage observation -// -// Provides a WritableKeyPath rooted at the Model type itself. -// Swift requires keypath subscript indices to be Hashable; AnyHashableSendable satisfies this, -// and distinct storage keys produce distinct AnyHashableSendable values, so Swift forms -// a distinct WritableKeyPath per storage key. This path composes with rootPaths in -// TestAccess exactly like a regular @Model property keypath. -// -// This subscript is internal-only — it is the bridge between the typed storage system -// and the TestAccess observation machinery. Users always use `node.context.myKey`; -// `Context` uses this subscript internally in willAccessStorage/didModifyStorage -// to produce the typed keypath needed for TestAccess snapshot tracking. -extension Model { - subscript(_metadata storage: ContextStorage) -> V { - // The subscript index must be Hashable for keypath formation. We use a wrapper - // that hashes/equals on storage.key so distinct storages produce distinct paths. - get { - guard let context = node._context else { return storage.defaultValue } - switch storage.propagation { - case .local: return context[storage] - case .environment: return context.environmentValue(for: storage) - } - } - set { node._context?[storage] = newValue } - } -} diff --git a/Sources/SwiftModel/Internal/ModelPreferenceStorage.swift b/Sources/SwiftModel/Internal/ModelPreferenceStorage.swift index aa6cf623..529a0fbb 100644 --- a/Sources/SwiftModel/Internal/ModelPreferenceStorage.swift +++ b/Sources/SwiftModel/Internal/ModelPreferenceStorage.swift @@ -312,17 +312,3 @@ extension AnyContext { } } } - -// MARK: - Internal Model subscript for preference storage observation -// -// Provides a WritableKeyPath rooted at the Model type itself, analogous to -// the `_metadata` subscript for context storage. -// This subscript is internal-only — it bridges the typed preference storage system -// and the TestAccess observation machinery. Users always use `node.preference.myKey`; -// `Context` uses this subscript internally in willAccessPreference/didModifyPreference. -extension Model { - subscript(_preference storage: PreferenceStorage) -> V { - get { node.preference[storage] } - set { node.preference[storage] = newValue } - } -} diff --git a/Sources/SwiftModel/Internal/ThreadLocals.swift b/Sources/SwiftModel/Internal/ThreadLocals.swift index 5ec32760..c2018155 100644 --- a/Sources/SwiftModel/Internal/ThreadLocals.swift +++ b/Sources/SwiftModel/Internal/ThreadLocals.swift @@ -54,7 +54,7 @@ final class ThreadLocals: @unchecked Sendable { /// the typed context storage path calls so context changes are reported under `.local`. var modificationArea: _ExhaustivityBits? = nil /// Guards against infinite recursion in `willAccessStorage`/`didModifyStorage`. - /// Reading `readModel[keyPath: \M[_metadata: storage]]` inside the TestAccess closure + /// Reading `readModel[keyPath: \M._ModelState[_metadata: storage.key]]` inside the TestAccess closure /// re-enters `willAccessStorage` through the context getter. This flag breaks that cycle. var isAccessingMetadataStorage = false /// Set while `TestAccess` is applying `Access.apply` closures to snapshot copies inside diff --git a/Sources/SwiftModel/ModelSourceBox.swift b/Sources/SwiftModel/ModelSourceBox.swift index 107eddbc..7af2409c 100644 --- a/Sources/SwiftModel/ModelSourceBox.swift +++ b/Sources/SwiftModel/ModelSourceBox.swift @@ -219,21 +219,27 @@ public extension _ModelStateType { subscript(environmentKey _: AnyHashableSendable) -> AnyHashableSendable { fatalError() } subscript(preferenceKey _: AnyHashableSendable) -> AnyHashableSendable { fatalError() } subscript(memoizeKey _: AnyHashableSendable) -> AnyHashableSendable { fatalError() } +} - // Writable stub paths for typed TestAccess snapshot tracking. +extension _ModelStateType { + // Writable stub paths for typed TestAccess snapshot tracking, indexed by the storage's key. // The getter is never called (precomputedStorageValue thread-local provides the value). // The setter is a no-op (context storage lives outside _State). - subscript(_metadata _: ContextStorage) -> V { + // + // The index is deliberately the non-generic key, not the `ContextStorage` / + // `PreferenceStorage`: those embed a `defaultValue: V`, and a key path formed in generic + // code whose index layout depends on `V` is misread by the Swift compiler when `V` is + // aligned beyond a pointer (`UInt64`/`Double` on wasm32, SIMD types on 64-bit). + // See `OverAlignedKeyPathIndexTests`. + subscript(_metadata _: AnyHashableSendable) -> V { get { fatalError() } set {} } - subscript(_preference _: PreferenceStorage) -> V { + subscript(_preference _: AnyHashableSendable) -> V { get { fatalError() } set {} } -} -extension _ModelStateType { // Internal-only: `_ParentsObservationKey` is an internal type so this subscript // cannot be public. Only accessed via `\M._ModelState[_parentsObservationKey: ...]` // inside the framework. diff --git a/Tests/SwiftModelTests/OverAlignedKeyPathIndexTests.swift b/Tests/SwiftModelTests/OverAlignedKeyPathIndexTests.swift new file mode 100644 index 00000000..836cd6f9 --- /dev/null +++ b/Tests/SwiftModelTests/OverAlignedKeyPathIndexTests.swift @@ -0,0 +1,101 @@ +import Testing +@testable import SwiftModel +import SwiftModel + +// Regression tests for a Swift compiler bug in key paths formed in a generic context whose +// subscript index has a layout that depends on a generic parameter (e.g. `\S[i: Idx(…)]` +// inside `func f`). The call site packs the index into the key path's argument buffer at +// pointer-size offset without aligning it, while the generated argument-init thunk reads it +// back at the offset rounded up to the index's alignment. Any index aligned beyond a +// pointer is therefore read as garbage — a wrong value, or a trap in `keypath_destroy`. +// +// On wasm32 (4-byte pointers) that is every 8-byte-aligned value: `LocalStorage`, +// `Int64`, `Double`. On 64-bit hosts it takes a 16-byte-aligned value, which is what these +// tests use (`SIMD2`) so the regular macOS/Linux runs catch a regression. +// `scripts/wasm-smoke` covers the 8-byte-aligned wasm32 case. +// +// SwiftModel forms such key paths for context storage (`[_metadata:]`), preferences +// (`[_preference:]`) and container elements (`[cursor:]`), so each index type must keep a +// layout that doesn't depend on the user's value type. + +private typealias Wide = SIMD2 + +private struct WideID: Hashable, Sendable { + var value: Wide +} + +private extension LocalKeys { + var wideLocal: LocalStorage { .init(defaultValue: Wide(1, 2)) } +} + +private extension EnvironmentKeys { + var wideEnvironment: EnvironmentStorage { .init(defaultValue: Wide(3, 4)) } +} + +private extension PreferenceKeys { + var wideSum: PreferenceStorage { + .init(defaultValue: Wide(0, 0), key: "wideSum") { $0 += $1 } + } +} + +@Model +private struct WideRow: Identifiable { + let id: WideID + var count: Int = 0 +} + +@Model +private struct WideRowParent { + var rows: [WideRow] = [] +} + +@Model +private struct WideStorageModel { + var child: WideRow = WideRow(id: WideID(value: Wide(7, 8))) +} + +@Suite(.modelTesting) +struct OverAlignedKeyPathIndexTests { + @Test func overAlignedLocalStorage() async { + #expect(MemoryLayout.alignment > MemoryLayout.alignment) + let model = WideStorageModel().withAnchor() + #expect(model.node.local.wideLocal == Wide(1, 2)) + + model.node.local.wideLocal = Wide(5, 6) + await expect(model.node.local.wideLocal == Wide(5, 6)) + } + + @Test func overAlignedEnvironmentStorage() async { + let model = WideStorageModel().withAnchor() + #expect(model.child.node.environment.wideEnvironment == Wide(3, 4)) + + model.node.environment.wideEnvironment = Wide(9, 10) + await expect { + model.node.environment.wideEnvironment == Wide(9, 10) + model.child.node.environment.wideEnvironment == Wide(9, 10) + } + } + + @Test func overAlignedPreference() async { + let model = WideStorageModel().withAnchor() + #expect(model.node.preference.wideSum == Wide(0, 0)) + + model.node.preference.wideSum = Wide(1, 1) + model.child.node.preference.wideSum = Wide(2, 3) + await expect { + model.node.preference.wideSum == Wide(3, 4) + model.child.node.preference.wideSum == Wide(2, 3) + } + } + + @Test func overAlignedContainerElementID() async { + let model = WideRowParent().withAnchor() + + model.rows.append(WideRow(id: WideID(value: Wide(1, 1)))) + model.rows.append(WideRow(id: WideID(value: Wide(2, 2)))) + await expect(model.rows.map(\.id.value) == [Wide(1, 1), Wide(2, 2)]) + + model.rows[1].count += 1 + await expect(model.rows[1].count == 1) + } +} diff --git a/Tests/WASMSmoke/Package.swift b/Tests/WASMSmoke/Package.swift new file mode 100644 index 00000000..3eca6f32 --- /dev/null +++ b/Tests/WASMSmoke/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:6.1 +// A tiny executable that exercises SwiftModel under wasm32 at runtime. The test suite +// itself can't run on WASI yet (see Docs/Contributing/CI.md), so this covers the code paths +// that have broken there before. Run it with `scripts/wasm-smoke`. +import PackageDescription + +// A path dependency's identity is its directory name, which isn't `swift-model` in a worktree. +// Plain string handling rather than Foundation, which doesn't load in every toolchain's manifest. +let repoComponents = #filePath.split(separator: "/").dropLast(3) +let repoPath = "/" + repoComponents.joined(separator: "/") +let repoName = String(repoComponents.last!) + +let package = Package( + name: "WASMSmoke", + dependencies: [ + .package(path: repoPath), + ], + targets: [ + .executableTarget( + name: "WASMSmoke", + dependencies: [ + .product(name: "SwiftModel", package: repoName), + ] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/Tests/WASMSmoke/Sources/WASMSmoke/main.swift b/Tests/WASMSmoke/Sources/WASMSmoke/main.swift new file mode 100644 index 00000000..1074bf37 --- /dev/null +++ b/Tests/WASMSmoke/Sources/WASMSmoke/main.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftModel + +// Each check exercises a code path that has broken on wasm32 before. It prints a line and +// exits non-zero on the first mismatch; a runtime trap fails the run too. +// +// Over-aligned key path indices: SwiftModel forms key paths in generic code whose subscript +// index used to embed the user's value type (`[_metadata: ContextStorage]`, +// `[_preference: PreferenceStorage]`, `[cursor: ContainerCursor]`). The Swift +// compiler misreads such an index when it is aligned beyond a pointer, which on wasm32 is +// any 8-byte-aligned type (`UInt64`, `Int64`, `Double`). That read back garbage or trapped +// with "null function" in `keypath_destroy`. See `OverAlignedKeyPathIndexTests`. + +// Unbuffered, so a trap still shows every check that passed before it. +setvbuf(stdout, nil, _IONBF, 0) + +nonisolated(unsafe) var failures = 0 + +func check(_ name: String, _ actual: T, _ expected: T) { + if actual == expected { + print("ok \(name)") + } else { + print("FAIL \(name): got \(actual), expected \(expected)") + failures += 1 + } +} + +extension LocalKeys { + var smokeUInt64: LocalStorage { .init(defaultValue: 1) } + var smokeInt64: LocalStorage { .init(defaultValue: -2) } + var smokeDouble: LocalStorage { .init(defaultValue: 3.5) } +} + +extension EnvironmentKeys { + var smokeEnvironmentDouble: EnvironmentStorage { .init(defaultValue: 4.5) } +} + +extension PreferenceKeys { + var smokeSum: PreferenceStorage { + .init(defaultValue: 0, key: "smokeSum") { $0 += $1 } + } +} + +@Model +struct Row: Identifiable { + let id: UInt64 + var count: Int = 0 +} + +@Model +struct Root { + var rows: [Row] = [Row(id: 10), Row(id: 20)] + var child: Row = Row(id: 99) + + var total: Int { + node.memoize(for: "total") { rows.reduce(0) { $0 + $1.count } } + } +} + +let root = Root().withAnchor() + +check("LocalStorage default", root.node.local.smokeUInt64, 1) +root.node.local.smokeUInt64 = .max +check("LocalStorage write", root.node.local.smokeUInt64, .max) + +check("LocalStorage default", root.node.local.smokeInt64, -2) +root.node.local.smokeInt64 = .min +check("LocalStorage write", root.node.local.smokeInt64, .min) + +check("LocalStorage default", root.node.local.smokeDouble, 3.5) +root.node.local.smokeDouble = 7.25 +check("LocalStorage write", root.node.local.smokeDouble, 7.25) + +check("EnvironmentStorage inherited default", root.child.node.environment.smokeEnvironmentDouble, 4.5) +root.node.environment.smokeEnvironmentDouble = 8.75 +check("EnvironmentStorage inherited write", root.child.node.environment.smokeEnvironmentDouble, 8.75) + +root.node.preference.smokeSum = 5 +root.child.node.preference.smokeSum = 6 +check("PreferenceStorage aggregate", root.node.preference.smokeSum, 11) + +check("[Row] with UInt64 ids", root.rows.map(\.id), [10, 20]) +root.rows.append(Row(id: .max)) +root.rows[2].count = 3 +root.rows[0].count = 1 +check("[Row] element write", root.rows.map(\.count), [1, 0, 3]) +check("memoize over [Row]", root.total, 4) +root.rows.removeFirst() +check("[Row] remove", root.rows.map(\.id), [20, .max]) +check("memoize after remove", root.total, 3) + +if failures > 0 { + print("wasm smoke: \(failures) check(s) failed") + exit(1) +} +print("wasm smoke: all checks passed") diff --git a/scripts/wasm-smoke b/scripts/wasm-smoke new file mode 100755 index 00000000..e4373d3f --- /dev/null +++ b/scripts/wasm-smoke @@ -0,0 +1,54 @@ +#!/bin/bash +# Build Tests/WASMSmoke for wasm32-unknown-wasip1 and run it under wasmtime. +# +# CI only *compiles* SwiftModel for WASM; the test suite can't run on WASI yet +# (see Docs/Contributing/CI.md). This smoke executable is the runtime check: +# it exercises code paths that have broken on wasm32 before, where a +# 32-bit pointer size changes what the compiler generates. Run it before +# opening a PR that changes the library. +# +# Usage: +# scripts/wasm-smoke # debug build +# scripts/wasm-smoke -c release # extra args are forwarded to `swift build` +# +# Needs: +# • a Swift WASM SDK (`swift sdk list`); override the pick with SWIFT_WASM_SDK. +# • a matching open-source toolchain — Xcode's `swift` can't target WASM. +# On macOS the script uses ~/Library/Developer/Toolchains/swift--RELEASE.xctoolchain +# for the SDK's version; elsewhere, `swift` on PATH. Override with SWIFT_BIN. +# • wasmtime (`brew install wasmtime`, or https://wasmtime.dev). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGE_DIR="$REPO_ROOT/Tests/WASMSmoke" + +if ! command -v wasmtime >/dev/null 2>&1; then + echo "error: wasmtime not found (brew install wasmtime, or see https://wasmtime.dev)" >&2 + exit 1 +fi + +SDK="${SWIFT_WASM_SDK:-}" +if [[ -z "$SDK" ]]; then + # Newest non-embedded WASM SDK, e.g. swift-6.4.0-RELEASE_wasm. + SDK="$(swift sdk list 2>/dev/null | grep -E '_wasm$' | sort -V | tail -1 || true)" +fi +if [[ -z "$SDK" ]]; then + echo "error: no Swift WASM SDK installed (swift sdk list). Install one from https://www.swift.org/install/ or set SWIFT_WASM_SDK." >&2 + exit 1 +fi + +SWIFT="${SWIFT_BIN:-}" +if [[ -z "$SWIFT" ]]; then + TOOLCHAIN="$HOME/Library/Developer/Toolchains/${SDK%_wasm}.xctoolchain/usr/bin/swift" + if [[ -x "$TOOLCHAIN" ]]; then + SWIFT="$TOOLCHAIN" + else + SWIFT="swift" + fi +fi + +echo "wasm-smoke: SDK $SDK, toolchain $SWIFT" +cd "$PACKAGE_DIR" +"$SWIFT" build --swift-sdk "$SDK" --product WASMSmoke "$@" +BIN_DIR="$("$SWIFT" build --swift-sdk "$SDK" --product WASMSmoke --show-bin-path "$@")" +wasmtime "$BIN_DIR/WASMSmoke.wasm" From 0da4ddd09d991c7ef5afd322cb778b4dea89e077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Tue, 29 Sep 2026 12:02:28 +0200 Subject: [PATCH 2/3] Run the WASM smoke under wasmtime in CI The test suite can't run on WASI yet, but a plain executable can, so the WASM job now installs wasmtime and runs scripts/wasm-smoke after the compile steps. Job timeout 10 -> 15 min for the extra build (the job took ~4 min; the smoke adds one more SwiftModel build). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 +++++++++++++++-------- AGENTS.md | 13 ++++++------- CHANGELOG.md | 2 +- Docs/Contributing/CI.md | 8 ++++---- scripts/wasm-smoke | 9 ++++----- 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 370f304c..9801de28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,7 +272,7 @@ jobs: # (ObjectiveC, AppKit, UIKit) evaluate against the container OS, which # correctly reflects the WASM target's behaviour. runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 container: image: swift:6.3.0 # Both levers are set job-wide so every `swift` invocation evaluates the @@ -329,10 +329,17 @@ jobs: swift build --build-tests \ --swift-sdk swift-6.3-RELEASE_wasm - # Not yet done: actually *running* the bundle under wasmtime. That's no - # longer blocked by the toolchain — it's a test-suite question. WASI is - # single-threaded with no `DispatchQueue`, and `GlobalTickScheduler` (the - # deadline source behind `expect` / `settle` / `waitUntil`) is GCD-backed, - # so the wait primitives need a WASI-native scheduler path before the - # suite could run rather than hang. Compile + link is the useful gate - # until someone takes that on. + - name: Install wasmtime + uses: bytecodealliance/actions/wasmtime/setup@v1 + + - name: Run WASM smoke under wasmtime + # The test bundle above can't run on WASI yet: `GlobalTickScheduler` + # (the deadline source behind `expect` / `settle` / `waitUntil`) is + # GCD-backed and would need a WASI-native path first. A plain + # executable runs fine, so `Tests/WASMSmoke` exercises at runtime the + # code paths that have broken on wasm32 while compiling cleanly — e.g. + # the over-aligned key path index bug that made + # `LocalStorage` trap (see `OverAlignedKeyPathIndexTests`). + run: scripts/wasm-smoke + env: + SWIFT_WASM_SDK: swift-6.3-RELEASE_wasm diff --git a/AGENTS.md b/AGENTS.md index 3123b304..71c81d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ their area. SwiftModel is a Swift library for composing models that drive SwiftUI views. It uses `@Model` macros, observation tracking, lifetime management (anchors), exhaustive testing tooling (`ModelTester`), dependency injection (via `swift-dependencies`), and async task management. -The library targets Apple platforms (macOS 11+, iOS 14+, tvOS 14+, watchOS 6+) and Linux. It also compiles for Android and WASM (build only; CI checks both). +The library targets Apple platforms (macOS 11+, iOS 14+, tvOS 14+, watchOS 6+) and Linux. It also compiles for Android and WASM (CI builds both, and runs a WASM smoke executable under wasmtime). ## Repository layout @@ -46,7 +46,7 @@ scripts/test # One test (forwards --filter to swift test). scripts/test --filter SwiftModelTests.SomeTestName -# Runtime smoke under wasmtime (CI only compiles WASM). +# WASM runtime smoke under wasmtime (CI runs it too). scripts/wasm-smoke # Stress loop. Use after touching observation / coalescing / settling code. @@ -157,12 +157,11 @@ based on evidence and progress, not wall-clock time. `CHANGELOG.md`, so a release only has to stamp that section. - Run `scripts/test` (and `--no-parallel` for anything touching observation or settling) before pushing. -- Run `scripts/wasm-smoke` before opening a PR that changes `Sources/`. CI only - compiles for WASM; this runs a small executable under wasmtime, because +- CI's WASM job runs `scripts/wasm-smoke`, a small executable under wasmtime. wasm32's 4-byte pointers change what the compiler generates (see - `OverAlignedKeyPathIndexTests`). It needs wasmtime, a Swift WASM SDK and the - matching open-source toolchain (the script's header says how it finds them). - If they aren't installed, say so in the PR rather than skipping silently. + `OverAlignedKeyPathIndexTests`). Run it locally to reproduce a failure there; + it needs wasmtime, a Swift WASM SDK and the matching open-source toolchain + (the script's header says how it finds them). ## Further reading diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a65dce..2c222ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes are documented here. The format follows [Keep a Changelog](h - **Local, environment and preference storage of an over-aligned value type, and collection elements with an over-aligned `id`, read garbage or crashed.** On wasm32 that is any 8-byte-aligned type: reading a `LocalStorage` trapped with "null function" in `keypath_destroy` under `Context.willAccessStorage`, and `Int64` or `Double` did the same. On 64-bit platforms it takes a 16-byte-aligned type, such as a SIMD vector. The cause is a Swift compiler bug, present in 6.3.3 and 6.4.0: a key path formed in generic code whose subscript index has a layout that depends on a generic parameter is packed and unpacked at different offsets when that index is aligned beyond a pointer. SwiftModel formed such key paths for storage (`[_metadata: ContextStorage]`), preferences (`[_preference: PreferenceStorage]`) and container elements (`[cursor: ContainerCursor]`). - Storage and preference paths are now indexed by the storage's non-generic key, and `ContainerCursor` boxes its `id`, so no index layout depends on a user type. The unused generic `Model[_metadata:]` / `Model[_preference:]` subscripts are removed. - `OverAlignedKeyPathIndexTests` covers all four paths with `SIMD2`, so the regular macOS and Linux runs catch a regression. Every one of those tests crashed before the fix. - - `scripts/wasm-smoke` builds a small executable (`Tests/WASMSmoke`) for `wasm32-unknown-wasip1` and runs it under wasmtime, covering the 8-byte-aligned wasm32 cases. Before the fix it trapped with the downstream stack. + - `scripts/wasm-smoke` builds a small executable (`Tests/WASMSmoke`) for `wasm32-unknown-wasip1` and runs it under wasmtime, covering the 8-byte-aligned wasm32 cases. Before the fix it trapped with the downstream stack. CI's WASM job now runs it, so WASM is executed in CI rather than only compiled. --- diff --git a/Docs/Contributing/CI.md b/Docs/Contributing/CI.md index ad77b529..12c37de4 100644 --- a/Docs/Contributing/CI.md +++ b/Docs/Contributing/CI.md @@ -12,7 +12,7 @@ GitHub Actions (`.github/workflows/ci.yml`): regression. - **Linux** (matrix: `parallel` | `serial`): `ubuntu-latest`, `swift:6.3.0` container, `scripts/ci-test` (wraps `swift test` — see below). - **Android**: compile-only cross-compile to `aarch64-unknown-linux-android28`. -- **WASM**: build (no run) to `wasm32-unknown-wasip1` — the library on its own, +- **WASM**: build to `wasm32-unknown-wasip1` — the library on its own, plus `--build-tests`, which links a full test executable. The link step needs `OMIT_DYNAMIC_TEST_SUPPORT=1` (xctest-dynamic-overlay ≥ 1.11.0, hence the `from: "1.11.0"` floor): WASI has no shared libraries, and without the lever @@ -23,9 +23,9 @@ GitHub Actions (`.github/workflows/ci.yml`): and `SwiftModelBenchmarks` doesn't compile for WASI (`DispatchTime`, `DispatchQueue.concurrentPerform`). Running the bundle under wasmtime is still open — `GlobalTickScheduler` is GCD-backed and would need a WASI-native path - first. Until then, `scripts/wasm-smoke` is the runtime check, run locally - before a PR: it builds `Tests/WASMSmoke` (a small executable, not the suite) - and runs it under wasmtime. It exists because a Swift compiler bug with key + first. Until then, `scripts/wasm-smoke` is the runtime check (the job's last + step, also runnable locally): it builds `Tests/WASMSmoke` (a small + executable, not the suite) and runs it under wasmtime. It exists because a Swift compiler bug with key path indices aligned beyond a pointer made `LocalStorage` trap on wasm32 while compiling fine. diff --git a/scripts/wasm-smoke b/scripts/wasm-smoke index e4373d3f..1561b317 100755 --- a/scripts/wasm-smoke +++ b/scripts/wasm-smoke @@ -1,11 +1,10 @@ #!/bin/bash # Build Tests/WASMSmoke for wasm32-unknown-wasip1 and run it under wasmtime. # -# CI only *compiles* SwiftModel for WASM; the test suite can't run on WASI yet -# (see Docs/Contributing/CI.md). This smoke executable is the runtime check: -# it exercises code paths that have broken on wasm32 before, where a -# 32-bit pointer size changes what the compiler generates. Run it before -# opening a PR that changes the library. +# The test suite can't run on WASI yet (see Docs/Contributing/CI.md), so this +# smoke executable is the runtime check: it exercises code paths that have +# broken on wasm32 before, where a 32-bit pointer size changes what the +# compiler generates. CI's WASM job runs it; run it locally to reproduce. # # Usage: # scripts/wasm-smoke # debug build From 1a1f58e31f8c556154ab421eb9728ef5a875b512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Tue, 29 Sep 2026 12:30:39 +0200 Subject: [PATCH 3/3] Install xz before the wasmtime setup action in the WASM job The swift:6.3.0 container has no xz, so the action couldn't unpack the wasmtime .tar.xz. Also pin wasmtime to 49.0.1. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9801de28..98bd517c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,8 +329,15 @@ jobs: swift build --build-tests \ --swift-sdk swift-6.3-RELEASE_wasm + - name: Install xz (unpacks the wasmtime release tarball) + # The swift:6.3.0 container image doesn't ship xz, and the setup + # action below extracts a .tar.xz. + run: apt-get update -qq && apt-get install -y -qq xz-utils + - name: Install wasmtime uses: bytecodealliance/actions/wasmtime/setup@v1 + with: + version: "49.0.1" - name: Run WASM smoke under wasmtime # The test bundle above can't run on WASI yet: `GlobalTickScheduler`