Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -329,10 +329,24 @@ 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 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`
# (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<UInt64>` trap (see `OverAlignedKeyPathIndexTests`).
run: scripts/wasm-smoke
env:
SWIFT_WASM_SDK: swift-6.3-RELEASE_wasm
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -45,6 +46,9 @@ scripts/test
# One test (forwards --filter to swift test).
scripts/test --filter SwiftModelTests.SomeTestName

# WASM runtime smoke under wasmtime (CI runs it too).
scripts/wasm-smoke

# Stress loop. Use after touching observation / coalescing / settling code.
scripts/test --loop 100

Expand Down Expand Up @@ -153,6 +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.
- 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`). 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

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt64>` 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<V>]`), preferences (`[_preference: PreferenceStorage<V>]`) and container elements (`[cursor: ContainerCursor<ID, …>]`).
- 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<Double>`, 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. CI's WASM job now runs it, so WASM is executed in CI rather than only compiled.

---

## [1.1.1] — Memoize no longer re-evaluates on torn-down models + `forEach(cancelPrevious:)` cancellation fix
Expand Down
8 changes: 6 additions & 2 deletions Docs/Contributing/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (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<UInt64>` 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:
Expand Down
19 changes: 10 additions & 9 deletions Sources/SwiftModel/Internal/Context.swift
Original file line number Diff line number Diff line change
Expand Up @@ -475,20 +475,21 @@ final class Context<M: Model>: 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<M._ModelState, V>&Sendable)
willAccessGapShadow(at: \M._ModelState[_metadata: storage.key] as WritableKeyPath<M._ModelState, V>&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<V>
// 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<V>` 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
// via the precomputedStorageValue thread-local instead of calling the getter.
// 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<M._ModelState, V>&Sendable = \M._ModelState[_metadata: storage]
let typedPath: WritableKeyPath<M._ModelState, V>&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
Expand Down Expand Up @@ -535,7 +536,7 @@ final class Context<M: Model>: AnyContext, @unchecked Sendable {
let untypedPath: KeyPath<M._ModelState, AnyHashableSendable>&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<M._ModelState, V>&Sendable = \M._ModelState[_metadata: storage]
let typedPath: WritableKeyPath<M._ModelState, V>&Sendable = \M._ModelState[_metadata: storage.key]
let mc = metadataModelContext()

lock { self.didModify() }
Expand Down Expand Up @@ -612,7 +613,7 @@ final class Context<M: Model>: 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<M._ModelState, V>&Sendable)
willAccessGapShadow(at: \M._ModelState[_preference: storage.key] as WritableKeyPath<M._ModelState, V>&Sendable)

// The typed writable path for TestAccess is now handled by willAccessPreferenceValue,
// called after preferenceValue finishes aggregating with the computed value in hand.
Expand All @@ -631,7 +632,7 @@ final class Context<M: Model>: 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<M._ModelState, V>&Sendable = \M._ModelState[_preference: storage]
let typedPath: WritableKeyPath<M._ModelState, V>&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
Expand Down Expand Up @@ -672,7 +673,7 @@ final class Context<M: Model>: AnyContext, @unchecked Sendable {
// Registrar call uses _StateObserver (no Model Observable conformance needed).
let untypedPath: KeyPath<M._ModelState, AnyHashableSendable>&Sendable = \M._ModelState[preferenceKey: storage.key]
// Typed writable path on M._ModelState — drives TestAccess didModify so writes are tracked.
let typedPath: WritableKeyPath<M._ModelState, V>&Sendable = \M._ModelState[_preference: storage]
let typedPath: WritableKeyPath<M._ModelState, V>&Sendable = \M._ModelState[_preference: storage.key]
let mc = metadataModelContext()

lock { self.didModify() }
Expand Down Expand Up @@ -752,7 +753,7 @@ final class Context<M: Model>: AnyContext, @unchecked Sendable {
invokeDidModifySyntheticPath(\_StateObserver<M._ModelState>[preferenceKey: storage.key, modelID: reference.modelID])
}
modelContext.invokeDidModify(at: untypedPath)?()
let typedPath: WritableKeyPath<M._ModelState, V>&Sendable = \M._ModelState[_preference: storage]
let typedPath: WritableKeyPath<M._ModelState, V>&Sendable = \M._ModelState[_preference: storage.key]
#if DEBUG
let prefName = storage.name
let prefPropDesc: (@Sendable () -> String?)? = { "preference.\(prefName)" }
Expand Down
15 changes: 13 additions & 2 deletions Sources/SwiftModel/Internal/ModelContainer+Internals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,16 +185,27 @@ func frozenCopy<T>(_ value: T) -> T {
}

struct ContainerCursor<ID: Hashable, Root, Value>: 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
}
Expand Down
27 changes: 0 additions & 27 deletions Sources/SwiftModel/Internal/ModelContextStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -455,30 +455,3 @@ extension AnyContext {
}
}
}

// MARK: - Internal Model subscript for context storage observation
//
// Provides a WritableKeyPath<M, V> 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<M, V> 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<M>` uses this subscript internally in willAccessStorage/didModifyStorage
// to produce the typed keypath needed for TestAccess snapshot tracking.
extension Model {
subscript<V>(_metadata storage: ContextStorage<V>) -> 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 }
}
}
14 changes: 0 additions & 14 deletions Sources/SwiftModel/Internal/ModelPreferenceStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,17 +312,3 @@ extension AnyContext {
}
}
}

// MARK: - Internal Model subscript for preference storage observation
//
// Provides a WritableKeyPath<M, V> 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<M>` uses this subscript internally in willAccessPreference/didModifyPreference.
extension Model {
subscript<V>(_preference storage: PreferenceStorage<V>) -> V {
get { node.preference[storage] }
set { node.preference[storage] = newValue }
}
}
2 changes: 1 addition & 1 deletion Sources/SwiftModel/Internal/ThreadLocals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions Sources/SwiftModel/ModelSourceBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<V>(_metadata _: ContextStorage<V>) -> V {
//
// The index is deliberately the non-generic key, not the `ContextStorage<V>` /
// `PreferenceStorage<V>`: 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<V>(_metadata _: AnyHashableSendable) -> V {
get { fatalError() }
set {}
}
subscript<V>(_preference _: PreferenceStorage<V>) -> V {
subscript<V>(_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.
Expand Down
Loading
Loading