Skip to content

Generics refactor#1

Draft
gistya wants to merge 174 commits into
mainfrom
generics-refactor
Draft

Generics refactor#1
gistya wants to merge 174 commits into
mainfrom
generics-refactor

Conversation

@gistya

@gistya gistya commented Jun 29, 2026

Copy link
Copy Markdown
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

gistya added 30 commits June 23, 2026 06:26
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.
gistya added 30 commits July 19, 2026 18:45
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant