Generics refactor#1
Draft
gistya wants to merge 174 commits into
Draft
Conversation
gistya
commented
Jun 29, 2026
Owner
- Refactors SwiftXState to take better advantage of Swift generics
- Implements changes to reflect upcoming v6 API of XState
- Adds a resultBuilder DSL compatible with SwiftUI for declarative XState goodness
- Better file system organization
Move the per-actor timer registry (scheduledTimers) + clock scheduling into a reusable DelayScheduler; Actor delegates its five timer methods. Behaviour-preserving — same names/signatures, bodies delegate. First reusable runtime component for the StateActor/MachineLogic refactor. Timer tests green; the 2 flaky fromTask/fromTaskGroup emit tests are pre-existing on main (verified via baseline stash).
Move the child-ref container (children dict + stopped-id tracking + the child-status snapshot mapping) into a reusable ChildRegistry; Actor delegates all ~15 dict touchpoints. Spawn/stop orchestration (system register, inspection, _snapshot sync) stays on Actor for now — those are the fused parts that come out with the effect-dispatch extraction. Behaviour-preserving; 262 tests green.
Move the macrostep side-effect dispatch switch out of Actor into a stateless ActionEffectRunner, reached via an EffectHost protocol seam. run() takes the host as an isolated parameter (SE-0313), so the dispatch body executes inside the host actor's isolation: every effect call stays same-actor and non-hopping, exactly as when the loop was inline -- the isProcessing reentrancy guard is unaffected. Actor.runSideEffectActions is now a thin wrapper; the seven effect methods drop private to satisfy EffectHost. Shared by the upcoming StateActor. 262 tests green (incl. the parallel-load emit canaries across 4 runs).
makeChildActorRef + resolveActorSource used no actor state (only the Context generic for implementations, plus free helpers), so move them to free generic functions in ChildActorFactory.swift, along with the ResolvedActorSource box they share. The parent is already type-erased to any ActorParentRef, so any actor that can parent children now drives identical child creation -- the seam StateActor will spawn through, rather than a duplicate factory. Pure relocation, no behavior change. 262 tests green.
The new actor runtime, assembled entirely from the extracted shared pieces -- DelayScheduler, ChildRegistry, ActionEffectRunner (via the EffectHost isolated seam), and the free makeChildActorRef factory -- over the pure engine (initialTransition / macrostep). It is the machine-specialized shape of the eventual StateActor<Logic, ID>; inspection / persistence / custom executors are deliberately omitted as orthogonal to the effect/child parity being proven. StateActorParityTests drives the SAME machine + event sequence through both the legacy Actor and StateActor and asserts identical state value / status / context across the full surface: - transition + always (eventless macrostep) - assign (context mutation) - emit -> on() listeners - after (delayed transition, deterministic SimulatedClock) - invoke child-machine onDone -> parent context - forwardTo an invoked callback child + sendToParent round-trip Spawned children still come from the shared factory (a sub-machine child is a regular Actor whose sendParent lands back via enqueueFromChild) -- the proof is that StateActor *orchestrates* spawn/invoke identically, not that children are StateActors yet. 268 tests green across 3 full runs.
Scene3DTextures.ciContext (a global static CIContext) failed Swift 6 strict concurrency on a clean rebuild of SwiftXStateGraph -- masked until now by build caching, but it breaks 'swift test' from clean. CIContext is documented thread-safe and the value is generated-once/read-only, so nonisolated(unsafe) is the correct annotation. Unrelated to the generics refactor; fixed here because it blocked the test suite.
… #5a) Introduce MachineLogic<Context> -- the pure reducer behind a state-machine StateActor (initialSnapshot + reduce/macrostep), the in-package form of the engine-wrap spike's MachineLogic and the first conformer the eventual StateActor<Logic, ID> will be parameterized over. StateActor now obtains its initial snapshot and per-event transitions THROUGH the logic instead of calling the free engine functions directly; the machine-shaped orchestration (after / invoke) and effect dispatch stay on StateActor, which owns the resources they touch. Behavior-preserving -- 268 tests green (parity suite included).
Introduce the generic-logic seam the eventual StateActor<Logic, ID> is built on:
- ActorLogic: the pure-reducer surface a conformer supplies (opaque Snapshot,
initialState, step, status) -- no machine concepts. MachineLogic conforms;
its reducer is the macrostep.
- LogicActor<L: ActorLogic>: the Context-agnostic actor core (mailbox +
run-to-completion + observers), parameterized purely by the logic. Folds
events via logic.step, gates on logic.status, notifies subscribers.
LogicActorTests proves ONE generic implementation hosts two distinct logic
kinds: a hand-written CounterLogic (RTC gate, input seeding) and MachineLogic
for effect-free machines (always/final, assign) -- the latter matching Actor's
snapshots. Effectful machines (emit/sendTo/spawn/invoke/after/raise) still need
StateActor's orchestration; folding that on top of this core as a capability is
the next step.
Additive and low-risk: does not touch Actor or StateActor. 273 tests green.
Add the second logic shape to the generic core. ActorLogic gains an optional run(scope:) (default no-op) and an ActorScope that pushes snapshots back onto the host's isolation; LogicActor launches it as a background task in start(), applies pushed snapshots under run-to-completion (dropped once not .active), and cancels on stop(). This is the shape behind callback / task / observable children. With it, one generic LogicActor<L> now provably spans BOTH logic kinds: reducers (events -> snapshots, incl. MachineLogic) and runnables (a self-driven snapshot stream). New deterministic test (signal-based, no sleeps) drives a StreamLogic to done. Still additive -- Actor and StateActor untouched. 274 tests green.
… #5d)
The frontier: the generic actor core now runs side effects, after, and invoke
-- so one LogicActor<MachineLogic<C>> reaches full parity with Actor.
- MachineHost: the Context-AGNOSTIC runtime seam (timers, child registry,
emit, parent/system wiring). LogicActor owns DelayScheduler + ChildRegistry
+ ActorSystem + parent and conforms to it (+ ActorParentRef/ActorSystemRef).
- ActorLogic gains started(host:) / handle(host:) hooks, defaulting to the
pure initialState/step so reducers and runnables are unaffected.
- MachineLogic overrides them (MachineLogic+Host.swift): the macrostep's
side-effect dispatch, after rescheduling, and invoke reconciliation run
*inside the host actor's isolation* via the `isolated` host parameter --
the Context-specific work (action switch, makeChildActorRef) lives in the
logic, the host supplies only non-generic primitives. No new hops, so the
isProcessing run-to-completion guard holds.
LogicActorTests now drives the SAME machine + events through Actor and
LogicActor<MachineLogic> with identical results across emit, after
(SimulatedClock), invoke child-machine onDone, and forwardTo + sendToParent --
on top of the existing hand-written-reducer, runnable, and effect-free-machine
cases. One generic implementation, every logic shape.
Still additive: Actor and StateActor untouched. 278 tests green across 3 runs.
…tor #6a) StateActor<Context> was the parity proof vehicle for the extracted runtime. Now that LogicActor<MachineLogic<C>> reaches the same parity with Actor (and also hosts hand-written reducers and runnables), StateActor is strictly redundant -- three machine-running actors collapse to two (Actor + LogicActor). Delete StateActor.swift and StateActorParityTests.swift (its cases are covered by LogicActorTests). Refresh MachineLogic's doc to point at LogicActor. The remaining dispatch duplication (ActionEffectRunner vs MachineLogic+Host) is removed only when Actor itself migrates -- the next consolidation step. 272 tests green.
…s-refactor #6b) De-risks the eventual Actor migration by proving the generic path matches Actor across the WHOLE machine surface, not just the effect cases: compound states + shallow history, guards (inline + named), parallel regions, immediate raise chaining within a macrostep, deferred raise via the scheduler (SimulatedClock), and spawnChild entry actions (children parity). Found and corrected one test-only wrong assumption along the way -- an unregistered numeric delay string (raise(delay:"50")) is treated as IMMEDIATE by both runtimes (old=done == new=done, verified), not deferred; switched to a registered delay key to actually exercise the scheduler. No runtime divergence. With this, LogicActor<MachineLogic> is shown to fully cover Actor's machine behavior -- the migration that removes the duplicate dispatch is now a confident mechanical step rather than a leap. 278 tests green.
First step of productionizing LogicActor toward retiring Actor: it now emits the
Stately InspectionEvent stream, matching Actor for the core lifecycle kinds.
- LogicActor owns the plumbing: inspectable flag + options.inspect wiring,
inspectionActorRef/rootId, and emitInspection(@autoclosure () -> Event?)
guarded by inspectable && system.hasInspectors -- so the Mirror-encode only
runs when an inspector is attached (Actor's perf fix, preserved).
- ActorLogic gains inspection hooks (declared as REQUIREMENTS, not just
extension defaults -- otherwise a generic LogicActor<L> statically dispatches
to the no-op instead of the conformer's override). MachineLogic builds the
@xstate.actor / .transition / .snapshot payloads from MachineSnapshot
(MachineLogic+Inspection.swift); pure logics emit nothing.
- Wired at start (registration[root] / transition / init event / snapshot),
send + enqueueFromChild (incoming event), process (transition / snapshot),
and spawn/invoke (child @xstate.actor via inspectSpawnedChild).
LogicActorInspectionTests compares LogicActor<MachineLogic> vs Actor via
InspectionCollector: identical core-kind stream + snapshot values, identical
definitionJSON on registration, child invoke registers a 2nd .actor on both,
and inspectable:false emits nothing.
Not yet ported (filtered from the comparison, noted): .microstep and .action
events, and child source attribution on cross-actor events. 282 tests green.
… #6d) Completes the inspection stream (only cross-actor source attribution remains). MachineHost gains inspection primitives (inspectionActorRef/rootId, recordsMicrosteps, and emitInspection(@autoclosure)), so MachineLogic.handle can record microsteps (gated on recordsMicrosteps = inspectable) and emit @xstate.microstep per step + @xstate.action per inspectable action as it runs; started emits entry-action events. The @autoclosure guard is preserved, so a non-inspected run does no Mirror-encode and records no microsteps. LogicActorInspectionTests now also asserts action-type multiset and microstep count match Actor (order-tolerant, since LogicActor emits actions slightly differently around startup). 284 tests green.
PersistableLogic capability: a logic whose snapshot can be serialized. MachineLogic conforms ONLY when Context: Codable (conditional conformance), mirroring Actor's `where Context: Codable`. LogicActor gains a constrained getPersistedSnapshot() (where L: PersistableLogic) that collects child snapshots from the registry and delegates the machine-specific encode to the logic. Tests: the PersistedSnapshot LogicActor<MachineLogic> produces equals Actor's, and round-trips through the existing Actor.start(from:) restore path -- i.e. the bytes are genuinely compatible. Restore on LogicActor itself is next (#6f); the context-override typing there needs Context, not the erased Snapshot, so it lands with the restore orchestration. 286 tests green.
LogicActor.start(from: PersistedSnapshot) (where L: PersistableLogic): restores state + context + children instead of running the initial transition, matching Actor.start(from:). PersistableLogic gains restoredSnapshot + restoreChildren; MachineLogic re-spawns invoke children and spawnChild entry children (MachineLogic+Host.restoreChildren), seeding each via a new MachineHost primitive pendingChildSnapshot (set from persisted.children during restore, nil otherwise -- so the normal spawn path is unchanged). Also fixes a latent gap: LogicActor.start now drains startup-enqueued child events (extracted drainLoop), as Actor.start does -- previously a child that emitted to its parent during initial entry would sit undrained until the next send. Shared emitStartupInspection between start and start(from:). Tests: restore state+context (Actor-saved -> LogicActor-restored), keeps running after restore, LogicActor save->restore round-trip, and invoke-child re-spawn matching Actor. Persistence is now complete on LogicActor. (NOTE: 2 pre-existing EmitTests flake under the now-larger parallel suite -- a register-after-start race in those Actor tests, unrelated to this change; they pass in isolation. Fixed next.)
The fromTask / fromTaskGroup "scope emit delivers to child on()" tests had a
register-after-start race: the child slept 30ms then emitted autonomously, while
the test attached on("progress") only after start(). Under parallel load the
emit fired before the listener attached, so received.wait() timed out (~5s).
The grown LogicActor test suite pushed this from intermittent to consistent.
Fix (no sleeps, completion-signal style): the child awaits a `canEmit` TestSignal
that the test fires only AFTER attaching the listener, so the emit can never race
ahead. Replaces the Task.sleep(30ms) timing assumption with a deterministic gate.
Full suite green across 4 consecutive runs (289 tests), incl. these two.
LogicActor gains Actor's custom-executor backing (Darwin-only): with ActorOptions.useMainExecutor it runs on MainActor.sharedUnownedExecutor (no thread hop from the main actor, for SwiftUI); otherwise a dedicated DispatchSerialQueue preserves off-main serialization. unownedExecutor returns the chosen executor. Test (Darwin-gated): a ThreadProbeLogic records Thread.isMainThread in step -- useMainExecutor:true runs on main, the default runs off-main. That completes the productionize checklist: LogicActor now matches Actor on behavior, inspection, persistence, AND executor. 291 tests green.
Prerequisite for the Actor swap: LogicActor must be a true behavioral superset
of Actor. Two remaining inspection deltas closed:
- Startup ordering: the root @xstate.actor registration now precedes the
startup @xstate.action events (Actor pins this — InspectionTests
"actor registration precedes inspectable spawn actions on start"). Achieved
by moving entry-action emission out of MachineLogic.started and into
LogicActor after the registration, driven by a new startupActionTypes hook
(re-derives the inspectable initial action types, pure).
- Source attribution: enqueueFromChild now attributes child→parent events
(Done/Error/Snapshot actor events) back to the originating child ref, as
Actor.inspectionSource does, instead of source: nil.
New test mirrors Actor's ordering assertion against LogicActor. With this,
LogicActor matches Actor on behavior + inspection + persistence + executor --
the swap's prerequisites are met. 292 tests green.
… #7)
The capstone: Actor<Context> is now a temporary final-class facade that forwards
to an inner LogicActor<MachineLogic<Context>>. One engine, one dispatch path --
ActionEffectRunner + EffectHost are deleted (the duplicate effect dispatch is
gone). The entire public API is preserved; every consumer (SwiftUI / SwiftData /
Graph / Inspector / WinBridge) compiles unchanged.
How the friction was handled:
- start(context:) threads through a new MachineLogic.contextOverride field
(baked into the logic the facade constructs at start), avoiding both the
LogicActor.start() overload ambiguity and the SendableValue/Equatable issue.
- The actor->class change surfaced exactly two spots that relied on Actor being
a Swift actor for synchronous same-actor access: waitFor and the test helper
waitForSnapshot, both subscribing inside sync closures. waitFor gets a
buffered-resolve WaitForState (tolerant of the now-async subscribe, no race);
the helper just needed await.
Behavioral gap found by the full suite (not the parity tests, which didn't cover
subscribe-then-stop): LogicActor.stop() never published a terminal .stopped
snapshot, so a waitFor waiting on termination hung. Fixed with a stoppedSnapshot
ActorLogic hook (the logic builds the .stopped snapshot) + notify on stop.
Full test suite green. The facade is deliberately temporary -- a later step
collapses it so Actor becomes the engine directly.
"Ref" isn't idiomatic Swift. From a parent's view the conformers ARE the child actors (you start/stop/send them), so the protocol parallels Swift's own `Actor` protocol and reads consistently with the existing `ChildActorSnapshot`. Pure token rename: `ChildActorRef` -> `ChildActor` (and the factory free function `makeChildActorRef` -> `makeChildActor` by the same token), file renamed too. No behavior change; all targets + tests compile. Note on the child registry key: kept as `String`. Child ids aren't an identity we mint -- they're author-written strings from the machine definition (invoke/spawnChild ids) and go out as string session ids on the Stately wire, so a typed ID wrapper would just be ceremony around a string here. (The generic `ID` from the Acting<ID> vision is about top-level actor identity, a separate deferrable question.)
Groundwork to host callback/task/observable children as LogicActor<XLogic>,
informed by reading XState v6 alpha.7 (whose ActorLogic maps ~1:1 to ours, and
whose effects are declarative data executed centrally for ordering).
- LogicEffect enum (emit / sendToParent / deliverToSelf) -- the declarative
effect vocabulary, modelled on XState v6's LogicEffect.
- ActorScope enriched from {update} to {input, update, receive, sendToParent,
emit} -- matching v6's CallbackLogicFunction scope.
- ActorLogic.run now returns an optional cleanup (XState's fromCallback
dispose), run on stop().
- LogicActor wires it: a lock-boxed ReceiverBox (so a background run can
register receivers without a racing hop), a single ParentDeliveryChain for
ORDERED outbound sendToParent (the v6 "central relay" lesson -- no per-child
chain), run-cleanup storage; process() routes events to receivers (no-op for
reducers), stop() runs the cleanup + clears receivers.
Additive: no production logic uses the new surface yet (CallbackChildRef
untouched), so behavior is unchanged. 17 LogicActor/waitFor tests green. Next:
LogicActor: ChildActor conformance + CallbackLogic + swap the spawn path.
The first child kind expressed as an ActorLogic (XState v6: fromCallback IS just
an ActorLogic), additively -- CallbackChildRef untouched.
- CallbackLogic: ActorLogic wrapping CallbackActorLogic; status-only snapshot;
setUp builds a CallbackActorScope from the runtime scope and runs the user's
callback, returning its dispose.
- LogicChildActor<L>: ONE generic ChildActor adapter wrapping LogicActor<L>
(XState's "an ActorRef wraps the interpreter") -- replaces the per-kind
*ChildRef classes; captures input, applies it at the no-arg ChildActor.start().
- ActorLogic.setUp: synchronous startup hook (vs the async run) so callback
receivers + dispose are in place before start() returns -- matching
CallbackChildRef.start; fixed the receiver-registration race.
LogicChildActor<CallbackLogic> proven to match CallbackChildRef: receive →
ordered sendToParent, emit → on(), stop → dispose + .stopped. Next (#8c): point
CallbackActorLogicBox.spawn at it and delete CallbackChildRef.
…rics-refactor #8c) CallbackActorLogicBox.spawn now builds a LogicChildActor<CallbackLogic> wrapping a LogicActor instead of a bespoke CallbackChildRef, and CallbackChildRef.swift is deleted. First of the seven *ChildRef classes collapsed onto the generic engine (6 → ... remaining: task / taskGroup / observable / transition / store, plus the heavier MachineChildRef). Full suite green (295 tests) -- the forwardTo / emit / invoke suites, which drive callback children via fromCallback, all pass on the new path; ordered sendToParent is preserved by LogicActor's single ParentDeliveryChain.
Generalize the child machinery for COMPLETING children (task etc.):
- ActorScope gains complete(output) / fail(error): deliver a Done/Error event
to the parent on the SAME ordered ParentDeliveryChain as sendToParent (so an
event sent just before completion lands first), and set a terminal status.
- LogicActor: terminalStatus/lastError override the snapshot-derived status;
subscribeStatus(status, error) lets the ChildActor adapter cache status/error
synchronously; notify also fires status observers.
- LogicChildActor subscribes to status before start (so a fast child's terminal
status isn't missed) and exposes errorMessage.
TaskLogic: ActorLogic wrapping TaskActorLogic -- runs the async work via the same
runAsyncChildLogic/AsyncCancelCleanup as TaskChildRef, then complete/fail.
TaskActorLogicBox.spawn now builds LogicChildActor<TaskLogic>; TaskChildRef
DELETED. (2 of 7 child kinds collapsed.)
295 tests green (TaskCancellation / fromTask emit / invoke / checkout pipeline).
…(generics-refactor #8e) TaskGroupLogic (structurally identical to TaskLogic, output is [Output]). TaskGroupActorLogicBox.spawn builds LogicChildActor<TaskGroupLogic>; TaskGroupChildRef deleted. 3 of 7 child kinds collapsed. 295 tests green.
Three more child kinds collapsed onto LogicActor; their *ChildRef classes deleted.
- ObservableLogic: setUp subscribes the stream; each value optionally pushes a
SnapshotActorEvent (syncSnapshot), completion -> complete(lastValue), error
-> fail; cleanup unsubscribes. Tracks last value in a lock-box.
- TransitionLogic: a genuine reducer -- snapshot IS the context, handle folds
via logic.transition; syncSnapshot pushes SnapshotActorEvent on start + each
event. Added MachineHost.sendToParentOrdered (sync, via the chain) for its
synchronous scope.sendToParent.
- StoreChildLogic (named to avoid the existing StoreLogic): setUp creates the
store, routes received events to store.send, bridges store emits to on(_:),
publishes snapshots.
ActorScope gains `actorId` (so a logic can build child-targeted events like
SnapshotActorEvent). 6 of 7 child kinds done; only MachineChildRef remains.
295 tests green.
- MIGRATING: new breaking-change section for the SwiftXStateCodable move, with the one-import-plus-one-conformance migration, a which-modules-need-it table, and a warning that child machine contexts need the conformance too (otherwise a child silently takes the non-persistable spawn path). Notes the on-disk format is unchanged. - MIGRATING: Diff Mode section covering the contextPublishing policy, keyframes, and the Stately caveat. - README: added SwiftXStateCodable to the library list; rewrote Dependencies since the core now has no Foundation, no Codable, and no JSON engine; corrected the source-compatibility claim in the upgrade banner. - DocC: landing-page Topics for Persistence (ContextPersistable) and Inspection (ContextDelta). - Fixed an unresolvable doc link in ContextDelta.
1.1.2 used Mirror, AnyKeyPath: Hashable and AnyCollection unconditionally, all unavailable in Embedded Swift, so it failed to compile before the core was even reached. 1.2.0 gates those behind hasFeature(Embedded).
Scripts/embedded-check.sh compiles the core in Embedded mode and reports what blocks it, grouped by cause and by file. Compile-only — Embedded produces no runnable artifact here; the point is the diagnostics. Counts only real file:line diagnostics, not caret continuation lines (counting those roughly doubled every figure and made concurrency look larger than Codable). Warns when all errors are in dependencies, since that means your own code was never type-checked. Requires a toolchain shipping an embedded stdlib. Xcode's bundled toolchain does not: it fails with 'module Swift cannot be imported in embedded Swift mode'.
Embedded Swift ships a concurrency runtime but does not implicitly import it the way full Swift does, so Task, AsyncStream, withTaskGroup and @TaskLocal failed name resolution rather than being unsupported. Adds a hasFeature(Embedded)-guarded import to the 18 core files that use them. Embedded error count drops 619 to 531; no effect on any other platform (462 tests green).
Atoms: the reactive Atom/ComputedAtom engine is excluded from Embedded builds. Its dependency graph needs weak references to let a computed atom observe atoms that may go away, and it is an additive port of v6 @xstate/store rather than core statechart machinery. The cut is smaller than expected: the subscribeToAtom ActionRef case and SubscribeAtomAction are value-type-erased and stay, so only the two atom-typed entry points (the subscribeToAtom builder and the enq.subscribeTo atom overload) are guarded. Every switch over ActionRef is untouched. Mutex: Embedded ships Atomic but not Synchronization.Mutex, so EmbeddedMutex.swift supplies a source-compatible stand-in with the same borrowing withLock shape. All 34 call sites compile unchanged. The type provides no actual mutual exclusion and is sound only under the single-threaded assumption documented in the file header. The Value == Bool lock/unlock extension needed a separate Embedded path: the spin loop cannot be used with one thread of execution, since nothing can release the flag and re-entry hangs instead of contending. The Embedded version asserts on re-entry rather than spinning. Embedded error count 531 to 508 (Mutex 34 to 0); 462 tests green.
The core declared Codable inline on 18 wire types. None of them are used by the core itself: the only encode/decode calls in Sources/SwiftXState were inside the conformances. They exist so downstream consumers can serialize, so excluding them on Embedded costs no core functionality. Each conformance is now a separate extension behind hasFeature(Embedded). Placement is forced by where Swift will synthesize: only a raw-value enum derives Codable from RawRepresentable and can be extended from another file, so those three are collected in CodableConformances.swift with the policy note, and everything else carries a guarded extension beside its own declaration. The three hand-written conformances (JSONValue, PersistedChildSnapshot, ReplayableEvent) are wrapped in place. Embedded error count 508 to 222, with the Codable family down from 248 to 4; 462 tests green.
The 38 isolated-parameter errors were the same missing import as before, in a third disguise. MachineHosting refines _Concurrency.Actor, so without the module imported the Actor protocol is invisible and every isolated H parameter reports that H does not conform to Actor. The earlier import pass only covered files matching Task or AsyncStream, which missed the seven files that use isolated parameters without spawning anything. Embedded error count 222 to 174; actor-isolation 38 to 0. 462 tests green.
String(describing:) falls back to reflection, which Embedded lacks. The 30 uses split three ways rather than one. Display-only sites (log messages, inspector payload strings, child snapshot summaries) now go through describeValue, which uses the value's own CustomStringConvertible when it has one and degrades to a placeholder otherwise. Verified that the dynamic cast to CustomStringConvertible does compile under Embedded, so most types keep a real description. Identity sites do NOT degrade. StateEvent.eventType and BasicIdentifying.name are routing keys, so a placeholder would silently collapse distinct events into one bucket rather than fail. Both defaults are now unavailable on Embedded, making an undeclared name a compile error at the conformance. String-raw-value identifiers are unaffected, since the RawRepresentable overload already supplies name. SendableValue equality was comparing String(describing:) of the two boxed values. That needed a real fix rather than degradation, since a degraded description would have made every value compare equal. It now opens the existential and compares via the concrete type's own ==, which also fixes a pre-existing case where two distinct types rendering identically compared equal. Embedded error count 174 to 138.
Embedded Swift prohibits weak, and also safe unowned; only unowned(unsafe) survives, which trades a nil check for a dangling pointer. Instead the non-owning links now go through BackRef, whose non-Embedded branch is literally a weak stored property, so Apple and Linux lifetime behaviour is unchanged by construction. Only the Embedded build takes the strong path. Reading .value yields an Optional on both platforms, so the surrounding self?.doThing() bodies are unchanged. A strong back-reference forms a cycle, so deinit cannot be the safety net. Soundness rests on top-level actors being immortal on embedded targets, and on every other child being torn down through stop(), which drops the references that would form the cycle. ParentRef is a concrete twin of BackRef for any ParentActorRepresentable: a protocol existential cannot satisfy a T: AnyObject constraint even for a class-bound protocol, though a weak stored property of existential type is fine. Embedded weak errors 62 to 30; Actor.swift now clean. 462 tests green over four consecutive runs.
Makes the BackRef contract true rather than merely documented. stop() was already releasing the run task, run cleanup, receivers, listener subscriptions, children and snapshot continuations, but left two links that matter once those references are strong on Embedded: - parent, which is the cycle itself: the parent holds this actor in its ChildRegistry while this actor points back. - pending timers, whose scheduled closures capture the actor until they fire. Adds DelayScheduler.cancelAll for this. Both are hygiene on platforms with weak, and load-bearing on Embedded. Adds two regression tests pinning down that a delay scheduled before stop() does not take effect after it. That already held, but by a second line of defence rather than the first: fireSelfEvent appends to the mailbox bypassing the isStopped check, and it is process() dropping the event against a terminal snapshot that saves it. The tests fix that behaviour in place against changes to either guard. No assertion was added to ChildRegistry.remove: its only call site marks the child stopped and stops it, so a trap there would fire on the correct path.
Finishes the pass started in Actor. Stored properties take a direct hasFeature(Embedded) guard, since that needs no call-site change: StateNode.machine, and the store back-pointer in StoreEnqueue and StoreSelector. Escaping captures go through BackRef, and the one existential parent capture in ParentDeliveryChain through ParentRef. Sites converted: Store (5), Emit, ActorSystem, InspectionRecorder, ReplayRecorder, ChildActorBox, ParentDeliveryChain. The Subscription bodies previously repeated self? on each line, which re-loaded the weak reference several times per call and left the odd index < self?.observers.count ?? 0 comparison. They now bind once with a guard let, which is both cheaper and clearer, and behaves identically: each self? line was already a no-op once the referent was gone. Embedded weak errors 30 to 0; total 106 to 76. 464 tests green over five consecutive runs.
Nothing else in CI can observe an Embedded regression. The Embedded build is selected by hasFeature(Embedded), and around 45 such branches now exist in the core; every other lane compiles the #else side, so a mistake that only affects Embedded is structurally invisible to them. Zero errors is not yet a usable pass condition, so embedded-check.sh now records the count in Scripts/embedded-baseline.txt and fails only when it rises. A drop still passes but prints a reminder to lower the baseline, so the ratchet tightens as the migration proceeds and cannot slip back. Verified all three branches by exit code: 70 fails, 80 passes with the reminder, 76 holds. Also guards against a false green: a build that produces no parseable diagnostics but never completes (missing SDK, manifest error, failed dependency fetch) is now treated as a failure rather than reported as zero errors. The workflow runs in the swift:6.3.2 container rather than on the macOS runners the other workflows use, because Xcode's bundled toolchain ships no embedded stdlib. The Wasm SDK provides it, pinned by URL and sha256. SDK, toolchain and baseline must be bumped together — a different compiler can report a different count for identical code. The script also gained SWIFTXSTATE_SWIFT so CI can drive swift directly instead of going through swiftly.
Fixes every remaining type-checker diagnostic: - Task.checkCancellation is unavailable; replaced with the equivalent isCancelled plus throw, which needs no conditional and is one code path on every platform. - unowned in ResolvedTransition, guarded like the weak sites. - The hand-written Decoder init on PersistedSnapshot, missed in the earlier Codable pass because it lives in the struct body rather than the guarded extension. - print with multiple arguments; Embedded takes a single interpolated one. - The reflection-derived CasePath convenience on XTransition. - InspectJSONEncoder's Mirror walk, which now falls back to the ContextPersistable seam. The scalar type switch above it needs no reflection and is unchanged. - Two files still missing import _Concurrency, one of them reached only through Task.value rather than by name. Task.sleep does not exist on Embedded at all — there is no clock, since time is a host concern. The waitFor timeout therefore cannot fire and asserts in debug rather than silently waiting forever; the interval on a values-source is skipped. Routing both through the engine's existing Clock abstraction is the proper fix and remains the one real feature gap. The count rises 76 to 86, which is progress rather than regression: with the type checker satisfied, the compiler now lowers code it previously never reached, and reports the SIL/IRGen restrictions behind it — existentials, dynamic casts, key paths. The baseline file records this so the rise is not mistaken for a fault later.
First step of removing existentials from the core. Clock stays the protocol you conform to; ClockHandle is how the engine stores one. A protocol existential needs a runtime witness table, which Embedded Swift does not have, whereas closures are ordinary values that specialize. TimeoutHandle no longer boxes any Sendable for each clock to recover with a conditional cast. It carries an opaque UInt64 token and each clock keeps its own id-to-timer table, which removes both the existential and the cast and is simpler regardless of platform. The Dispatch clock also drops a fired timer's bookkeeping now, so a long-lived clock stops accumulating completed work items. ActorOptions.init is generic in the clock so ActorOptions(clock: SimulatedClock()) still compiles unchanged. ClockHandle itself conforms to Clock, with a non-generic passthrough init so forwarding an already-erased handle between actors does not stack another layer of closures. Embedded errors 86 to 74; any Clock and any Sendable both to zero. 443 tests green over three runs.
Three Embedded restrictions independent of the event and SendableValue redesign: - Key paths are unsupported; ReplaySession.replayEvents uses closures. - An untyped throws boxes into any Error, and Embedded permits no value existentials. ActorAsyncCancellation.checkCancellation now uses typed throws; the concrete type was always CancellationError, so naming it costs callers nothing. - describeValue recovered a description by casting to CustomStringConvertible, which is itself a prohibited dynamic cast. On Embedded it now always returns the placeholder. Recovering a real description there would mean taking the value generically, which the call sites cannot do since they hold heterogeneous values behind Any. Also fixes a misleading count in embedded-check.sh: <unknown>:0: errors carry no source location and are whole-module diagnostics from this target, not dependency errors. Counting them as dependencies could fire the your-code-was-never-type-checked warning when the code had in fact been checked. Embedded errors 86 to 72. 443 tests green.
The count is dominated by value existentials and dynamic casts, and those are a toolchain limitation rather than a language one. Measured today: Xcode.app (Swift 6.2.4) ships no embedded stdlib at all; 6.3.2 rejects value existentials, as? and metatypes; the 2026-07-05 main snapshot accepts all three. Support lands in Swift 6.4 / Xcode 27. Apple's own documented example from docs.swift.org/embedded fails on 6.3.2 and compiles on the snapshot, so a diagnostic from 6.3.2 is not evidence about the language. Recording that because I inferred the opposite and nearly landed a 126-site event redesign on the strength of it. What 6.4 does not fix — weak, Codable, Mirror, key paths — is exactly what the work already on this branch addresses, so that stands.
Embedded Swift allows as? to a concrete type but never to an existential (as? any P). That restriction is documented and is not the toolchain gap that covers most of the current baseline, so these are worth fixing now rather than waiting for 6.4. Two capability protocols are folded into the protocols that already exist, with defaulted requirements: PersistedChildSnapshotProviding into ChildActorRepresentable, and ReplayPayloadRepresentable's replayPayload into Eventable. Both cast sites disappear. Behaviour is identical because the payload parameters already defaulted to nil, so the conforming branch and the fallthrough collapse into one call. Arguably better design too — the capability is visible on the protocol rather than discovered at runtime. SendableValue captures its comparison at init instead of opening the existential to call a generic, which Embedded also disallows. The cast inside the closure is to a concrete T, which is permitted. Same shape as CompositionalInit's PartialProperty. InspectJSONEncoder loses its Embedded structured fallback. The ContextPersistable seam is unreachable there: the value arrives as Any, and the generic entry points are called from contexts generic over an unconstrained Context, so a constrained overload would never be selected. Inspection loses context detail on Embedded only. 443 tests green.
observe() created one unstructured Task per event and let them race to enter the bridge actor, so N events could reach the transport in any of N! orders. InspectBridgeState.publish already chained deliveries, but that could not help: the race was over which task entered the actor first, so the chaining order itself was nondeterministic. Ordering has to be fixed synchronously at the call site, which is what InspectDeliveryChain now does — a lock-protected tail, mirroring ParentDeliveryChain in the core, which solves the same problem for parent deliveries. The actor's own chaining is removed as redundant, and stop() drains the chain so queued events are not dropped. This is a product bug, not just a test one. The Stately sequence view and replay both assume causal order, and Diff Mode's keyframe counter is order-dependent, so a reorder silently corrupts the stream. The test also waited on the raw message count while asserting on snapshots, which publishedSnapshots filters — so the wait could satisfy early and let stop() cut the stream short. It now counts snapshots. Verified by mutation: with the race restored the publishing tests fail 25 out of 25 runs; with the fix, 0 of 25. Full suite 464 green over three runs.
The README said the core targets Embedded Swift and that machine export/import runs there. Neither is true yet on a released toolchain, so a reader following it hits a wall. Replaced with a status section: the design is right, existential support arrives in Swift 6.4 / Xcode 27, and embedded-check.sh tracks the gap. Adds the host symbols a bare-metal or libc-less target must supply, measured from a freestanding object rather than guessed: posix_memalign, free, memcpy, memmove, memcmp, arc4random_buf, putchar, and the stack protector pair. Also records the two things you do NOT implement — the Unicode tables ship in libswiftUnicodeDataTables.a, and the clock is injected through ActorOptions.clock rather than defaulted. MIGRATING gains the breaking changes since 2.0: TimeoutHandle is now an opaque token (sharpest edge, since custom clocks are a documented extension point), ActorOptions.clock stores a ClockHandle, checkCancellation uses typed throws, and SendableValue equality is type-safe rather than comparing rendered descriptions. @inlinable on five one-line public forwarders that touch only public state. Deliberately narrow — see the notes in the commit discussion for why it is not applied more widely.
Also fixes an Embedded-related bug
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.