MobiusCore/Source/AsyncStartStopStateMachine.swift (108 lines of code) (raw):
// Copyright Spotify AB.
// SPDX-License-Identifier: Apache-2.0
import Foundation
/// Helper representing an entity that has a stopped state where it can be configured, and a running state where it
/// can’t, with all mutation happening on a designated dispatch queue.
///
/// In an ideal world this would just boil down to the enum `Snapshot` defined below, but Swift doesn’t provide the
/// tools to easily make it thread safe. In particular, we need to be able to query whether we’re currently running or
/// not from any queue, including the designated mutating queue.
final class AsyncStartStopStateMachine<StoppedState, RunningState> {
// Intermediate states are required to handle the case where `running` is queried during a state transition.
//
// In practice, this can happen in MobiusController when an event source dispatches an event immediately when
// subscribed to, which happens before the loop variable is assigned in start(). In this case, we enter
// flipEventsToLoopQueue() in the .transitioningToRunning state, but the async block cannot proceed until we
// reach the .running state (because start() is already running on the loop queue).
private enum RawState {
case stopped
case transitioningToRunning
case running
case transitioningToStopped
}
enum Error: Swift.Error {
case wrongState
}
private let rawState = Synchronized(value: RawState.stopped)
private let queue: DispatchQueue
// Invariant: only one of these optionals is meaningful at any given time. They are only read in `snapshot`.
private var stoppedState: StoppedState?
private var runningState: RunningState?
enum Snapshot {
case stopped(StoppedState)
case running(RunningState)
}
init(state: StoppedState, queue: DispatchQueue) {
stoppedState = state
self.queue = queue
}
/// Test whether we’re in a running state. Ongoing transitions are considered running states.
///
/// This is safe to invoke from any queue, including the loop queue.
var running: Bool {
switch rawState.value {
case .stopped:
return false
case .transitioningToRunning, .running, .transitioningToStopped:
return true
}
}
/// Call `closure` with the current state. It will execute on the loop queue.
func syncRead<T>(_ closure: (Snapshot) throws -> T) rethrows -> T {
dispatchPrecondition(condition: .notOnQueue(queue))
return try queue.sync {
try closure(snapshot())
}
}
/// Mutate the stopped state, assuming we’re currently stopped. If not, throw `Error.wrongState`.
func mutate(by closure: (inout StoppedState) throws -> Void) throws {
dispatchPrecondition(condition: .notOnQueue(queue))
try queue.sync {
switch snapshot() {
case .running:
throw Error.wrongState
case .stopped(var state):
try closure(&state)
stoppedState = state
}
}
}
/// Transition from a stopped state to a running state, assuming we’re currently stopped. If not, fail with the
/// provided error message.
///
/// If the `transition` closure throws an error, the state remains unchanged.
func transitionToRunning(by transition: (StoppedState) throws -> RunningState) throws {
dispatchPrecondition(condition: .notOnQueue(queue))
try queue.sync {
switch snapshot() {
case .running:
throw Error.wrongState
case .stopped(let stoppedState):
rawState.value = .transitioningToRunning
do {
let runningState = try transition(stoppedState)
become(running: runningState)
} catch {
rawState.value = .stopped
throw error
}
}
}
}
/// Transition from a running state to a stopped state, assuming we’re currently running. If not, fail with the
/// provided error message.
///
/// If the `transition` closure throws an error, the state remains unchanged.
func transitionToStopped(by transition: (RunningState) throws -> StoppedState) throws {
dispatchPrecondition(condition: .notOnQueue(queue))
try queue.sync {
switch snapshot() {
case .stopped:
throw Error.wrongState
case .running(let runningState):
rawState.value = .transitioningToStopped
do {
let stoppedState = try transition(runningState)
become(stopped: stoppedState)
} catch {
rawState.value = .running
throw error
}
}
}
}
/// Generate a `Snapshot` reflecting the current state.
///
/// This function is the only point where we deal with the two optionals.
private func snapshot() -> Snapshot {
dispatchPrecondition(condition: .onQueue(queue))
if running {
guard let runningState = runningState else { preconditionFailure("Internal invariant broken") }
return .running(runningState)
} else {
guard let stoppedState = stoppedState else { preconditionFailure("Internal invariant broken") }
return .stopped(stoppedState)
}
}
private func become(running state: RunningState) {
dispatchPrecondition(condition: .onQueue(queue))
self.runningState = state
rawState.value = .running
self.stoppedState = nil
}
private func become(stopped state: StoppedState) {
dispatchPrecondition(condition: .onQueue(queue))
self.stoppedState = state
rawState.value = .stopped
self.runningState = nil
}
}