diff --git a/ASFWDriver/ASFWDriver.cpp b/ASFWDriver/ASFWDriver.cpp index 59cbfb73..02293a8f 100644 --- a/ASFWDriver/ASFWDriver.cpp +++ b/ASFWDriver/ASFWDriver.cpp @@ -41,6 +41,7 @@ #include "Audio/Core/AudioCoordinator.hpp" #include "Audio/Core/AudioEndpointRuntime.hpp" #include "Audio/Core/AudioRuntimeRegistry.hpp" +#include "Bus/BusResetCoordinator.hpp" #include "Bus/SelfIDCapture.hpp" #include "Common/DriverKitOwnership.hpp" #include "ConfigROM/ConfigROMStager.hpp" @@ -211,6 +212,14 @@ kern_return_t IMPL(ASFWDriver, Start) { return kr; if (!ivars || !ivars->context) return kIOReturnNoMemory; + ivars->powerProvider = provider; + return StartRuntime(provider); +} + +kern_return_t ASFWDriver::StartRuntime(IOService* provider) { + if (!ivars || !ivars->context) + return kIOReturnNoMemory; + kern_return_t kr = kIOReturnSuccess; auto& ctx = *ivars->context; ctx.stopping.store(false, std::memory_order_release); DriverWiring::EnsureDeps(this, ctx); @@ -347,11 +356,13 @@ kern_return_t IMPL(ASFWDriver, Start) { const uint32_t initialMask = IntMaskBits::kMasterIntEnable | kBaseIntMask; ctx.deps.hardware->IntMaskSet(initialMask); - // Publish the SBP-2 nub. The SCSI HBA currently co-matches the PCI device - // directly (see Info.plist ASFWSCSIControllerService), so nothing matches on - // this nub yet — it is staged for a future per-unit personality carrying - // login/unit identity, and kept published now to reserve the discovery seam. - { + // Publish once per instance: StartRuntime() is re-entered on wake, and the + // SBP-2 nub and RegisterService() must not repeat across sleep/wake cycles. + if (!ivars->serviceRegistered) { + // Publish the SBP-2 nub. The SCSI HBA currently co-matches the PCI device + // directly (see Info.plist ASFWSCSIControllerService), so nothing matches on + // this nub yet — it is staged for a future per-unit personality carrying + // login/unit identity, and kept published now to reserve the discovery seam. IOService* sbp2NubService = nullptr; kern_return_t nubKr = Create(this, "ASFWSBP2NubProperties", &sbp2NubService); if (nubKr != kIOReturnSuccess || sbp2NubService == nullptr) { @@ -360,15 +371,46 @@ kern_return_t IMPL(ASFWDriver, Start) { // IOKit retains the nub as our child; the nub's Start() calls RegisterService(). sbp2NubService->release(); } + + RegisterService(); + ivars->serviceRegistered = true; } - RegisterService(); + // NOTE: do NOT call ChangePowerState/SetPowerOverride here. The kernel + // joins a dext into the PM tree only after Start() returns + // (xnu IOUserServer.cpp serviceStarted -> serviceJoinPMTree), so PM calls + // made during Start() are dropped: powerOverrideOnPriv returns + // IOPMNotYetInitialized (surfaced as kIOReturnError) and + // ChangePowerState_Impl silently discards the same failure. The power + // desire is pinned in SetPowerState() on the first On callback instead, + // which the kernel delivers right after the PM join. + ASFW_LOG(Controller, "ASFWDriver::Start() complete"); return kIOReturnSuccess; } kern_return_t IMPL(ASFWDriver, Stop) { + QuiesceRuntime(); + if (ivars) { + if (ivars->wakeVerifyTimer) { + // Disable only, then release — Cancel() dispatches async and can + // run after the source is freed (see WatchdogCoordinator::Stop). + // Releasing the action breaks the OSAction→service retain cycle. + ivars->wakeVerifyTimer->SetEnableWithCompletion(false, nullptr); + ivars->wakeVerifyTimer->release(); + ivars->wakeVerifyTimer = nullptr; + } + if (ivars->wakeVerifyAction) { + ivars->wakeVerifyAction->release(); + ivars->wakeVerifyAction = nullptr; + } + ivars->powerProvider = nullptr; + } + return Stop(provider, SUPERDISPATCH); +} + +void ASFWDriver::QuiesceRuntime() { if (ivars && ivars->context) { auto& ctx = *ivars->context; ctx.stopping.store(true, std::memory_order_release); @@ -411,7 +453,176 @@ kern_return_t IMPL(ASFWDriver, Stop) { if (ctx.deps.configRomStager && ctx.deps.hardware) ctx.deps.configRomStager->Teardown(*ctx.deps.hardware); } - return Stop(provider, SUPERDISPATCH); +} + +// Wake verification cadence. 3s puts the first check well past the dark-wake → +// full-wake transition (~2s observed) while staying invisible to the user; 5 +// attempts bound the self-heal at ~15s. +static constexpr uint64_t kWakeVerifyDelayNs = 3'000'000'000ull; +static constexpr uint64_t kWakeVerifyMaxAttempts = 5; + +kern_return_t IMPL(ASFWDriver, SetPowerState) { + const bool poweredOn = (powerFlags & kIOServicePowerCapabilityOn) != 0; + ASFW_LOG(Controller, "SetPowerState: powerFlags=0x%08x (%{public}s)", powerFlags, + poweredOn ? "on" : "sleep/low"); + + if (ivars) { + if (!poweredOn) { + // Sleep: quiesce everything and reset the runtime while the + // controller still answers MMIO. The silicon loses its programmed + // state in low power (Linux ohci.c pci_suspend does software_reset; + // Apple gates all hardware access while asleep). + if (!ivars->runtimeSuspended && ivars->context) { + ASFW_LOG(Controller, "SetPowerState: quiescing runtime for sleep"); + QuiesceRuntime(); + ivars->context->Reset(); + ivars->runtimeSuspended = true; + } + } else { + // Pin our power desire to full-on. A bus controller must stay + // powered even with no devices attached (plug detection needs a + // programmed, interrupting controller). The audio driver matched on + // our nub is a PM-tree child; when the last nub terminates, the + // child's demand vanishes and the system sends SetPowerState(0) + // ~1ms later — which tore down the runtime, leaving the controller + // dead until the PM domain happened to repower minutes later. + // SetPowerOverride makes our power state governed solely by our own + // desire (children ignored), so capability 0 then means real system + // sleep only. These calls only work once the PM join has happened + // (after Start() returns) — this callback is the earliest reliable + // point. Idempotent, so unconditional on every On is fine. + const kern_return_t pmKr = ChangePowerState(kIOServicePowerCapabilityOn); + const kern_return_t ovKr = SetPowerOverride(true); + ASFW_LOG(Controller, + "SetPowerState: pin desire On -> 0x%08x, override -> 0x%08x", + pmKr, ovKr); + } + if (poweredOn && ivars->runtimeSuspended) { + // Wake: rebuild the runtime from scratch — full OHCI re-init ending + // in a forced bus reset, after which normal discovery re-publishes + // devices (Linux pci_resume runs the same ohci_enable as cold probe). + ivars->runtimeSuspended = false; + if (ivars->powerProvider) { + ASFW_LOG(Controller, "SetPowerState: wake - rebuilding runtime"); + const kern_return_t kr = StartRuntime(ivars->powerProvider); + if (kr != kIOReturnSuccess) { + ASFW_LOG(Controller, + "SetPowerState: ❌ wake runtime rebuild failed: 0x%08x", kr); + } else { + // The On callback can arrive during dark wake; verify the + // rebuild actually took once the platform has settled. + ScheduleWakeVerify(1); + } + } else { + ASFW_LOG(Controller, "SetPowerState: wake with no provider; skipping rebuild"); + } + } + } + + return SetPowerState(powerFlags, SUPERDISPATCH); +} + +void ASFWDriver::VerifyWakeRuntime(uint64_t attempt) { + if (!ivars || !ivars->context || ivars->runtimeSuspended) { + return; // slept again (or tearing down) before the check fired + } + auto& ctx = *ivars->context; + if (ctx.stopping.load(std::memory_order_acquire) || !ctx.deps.hardware || + !ctx.deps.busReset) { + return; + } + + // The wake rebuild always ends in a forced bus reset, and resetCount only + // advances via the full interrupt path (IRQ → Self-ID → coordinator). A + // completed reset therefore proves interrupt delivery end to end. + const uint32_t resets = ctx.deps.busReset->Metrics().resetCount; + const uint32_t hcControl = ctx.deps.hardware->Read(Register32::kHCControl); + const bool mmioAlive = (hcControl != 0xFFFFFFFFu); + const bool linkEnabled = mmioAlive && (hcControl & HCControlBits::kLinkEnable); + + if (resets > 0 && linkEnabled) { + ASFW_LOG(Controller, "Wake verify: ✅ alive (resets=%u HCControl=0x%08x attempt=%llu)", + resets, hcControl, attempt); + return; + } + + // Distinguish the failure mode for the log: busReset pending in IntEvent + // with resetCount==0 means the reset happened but the IRQ never arrived + // (interrupt path dead); linkEnable clear means the controller was reset + // under us after the rebuild; 0xFFFFFFFF means MMIO itself is gone. + const uint32_t intEvent = mmioAlive ? ctx.deps.hardware->Read(Register32::kIntEvent) : 0; + ASFW_LOG(Controller, + "Wake verify: ❌ dead controller (resets=%u HCControl=0x%08x IntEvent=0x%08x " + "attempt=%llu/%llu) - rebuilding", + resets, hcControl, intEvent, attempt, kWakeVerifyMaxAttempts); + + if (attempt >= kWakeVerifyMaxAttempts) { + ASFW_LOG(Controller, "Wake verify: ❌ giving up after %llu attempts", attempt); + return; + } + if (!ivars->powerProvider) { + ASFW_LOG(Controller, "Wake verify: no provider; cannot rebuild"); + return; + } + + QuiesceRuntime(); + ctx.Reset(); + const kern_return_t kr = StartRuntime(ivars->powerProvider); + if (kr != kIOReturnSuccess) { + ASFW_LOG(Controller, "Wake verify: ❌ rebuild failed: 0x%08x", kr); + } + ScheduleWakeVerify(attempt + 1); +} + +void ASFWDriver::ScheduleWakeVerify(uint64_t attempt) { + if (!ivars || !ivars->context) { + return; + } + if (!ivars->wakeVerifyTimer) { + // ctx.workQueue is the service's default queue (DriverWiring:: + // PrepareQueue), so the timer stays valid across runtime rebuilds and + // the verify serializes with Start/Stop/SetPowerState. + auto& queue = ivars->context->workQueue; + if (!queue) { + return; + } + IOTimerDispatchSource* timer = nullptr; + kern_return_t kr = IOTimerDispatchSource::Create(queue.get(), &timer); + if (kr != kIOReturnSuccess || !timer) { + ASFW_LOG(Controller, "Wake verify: ❌ timer create failed: 0x%08x", kr); + return; + } + OSAction* action = nullptr; + kr = CreateActionWakeVerifyTimerFired(0, &action); + if (kr != kIOReturnSuccess || !action) { + ASFW_LOG(Controller, "Wake verify: ❌ timer action create failed: 0x%08x", kr); + timer->release(); + return; + } + kr = timer->SetHandler(action); + if (kr != kIOReturnSuccess) { + ASFW_LOG(Controller, "Wake verify: ❌ timer SetHandler failed: 0x%08x", kr); + action->release(); + timer->release(); + return; + } + (void)timer->SetEnableWithCompletion(true, nullptr); + ivars->wakeVerifyTimer = timer; + ivars->wakeVerifyAction = action; + } + + ivars->wakeVerifyAttempt = attempt; + (void)ASFW::Timing::initializeHostTimebase(); + const uint64_t deadline = + mach_absolute_time() + ASFW::Timing::nanosToHostTicks(kWakeVerifyDelayNs); + (void)ivars->wakeVerifyTimer->WakeAtTime(kIOTimerClockMachAbsoluteTime, deadline, 0); +} + +void ASFWDriver::WakeVerifyTimerFired_Impl(ASFWDriver_WakeVerifyTimerFired_Args) { + if (!ivars) { + return; + } + VerifyWakeRuntime(ivars->wakeVerifyAttempt); } kern_return_t ASFWDriver::CopyControllerStatus(OSDictionary** status) { diff --git a/ASFWDriver/ASFWDriver.iig b/ASFWDriver/ASFWDriver.iig index e0cd9bd4..d08d20cd 100644 --- a/ASFWDriver/ASFWDriver.iig +++ b/ASFWDriver/ASFWDriver.iig @@ -26,6 +26,35 @@ public: kern_return_t Start(IOService* provider) override; kern_return_t Stop(IOService* provider) override; + // System sleep/wake. The OHCI controller loses its programmed state across + // a power transition; without this the driver survives sleep but the + // silicon wakes unprogrammed and deaf (no bus-reset interrupts, so a device + // plugged after wake never mounts). Sleep quiesces and resets the runtime; + // wake rebuilds it (full re-init + forced bus reset), mirroring Linux + // firewire-ohci pci_suspend/pci_resume and Apple IOFireWireController + // setPowerState. + virtual kern_return_t SetPowerState(uint32_t powerFlags) override; + + // Runtime bring-up/quiesce, shared by Start/Stop and SetPowerState. + kern_return_t StartRuntime(IOService* provider) LOCALONLY; + void QuiesceRuntime() LOCALONLY; + + // Post-wake health check. The SetPowerState(On) callback can arrive during + // dark wake, before the PCIe/Thunderbolt path is fully restored: the rebuild + // then completes cleanly over MMIO but the forced bus reset never delivers + // an interrupt (observed on HW 2026-07-05 — interrupt/bus-mastering state + // lost between dark wake and full wake). Every wake rebuild ends in a forced + // bus reset, so a completed reset must be observed shortly after; if not, + // the runtime is rebuilt again (bounded retries). + void VerifyWakeRuntime(uint64_t attempt) LOCALONLY; + + // Arms the wake-verify timer for `attempt`. A hardware timer keeps the + // settle delay off the dispatch queue — Scheduler::DispatchAsyncAfter + // sleeps *on* the bound queue, which would stall interrupt and discovery + // work for the whole verification window. The timer's OSAction retains + // this service, so a pending callback cannot outlive the driver. + void ScheduleWakeVerify(uint64_t attempt) LOCALONLY; + kern_return_t CopyControllerStatus(OSDictionary** status) LOCALONLY; kern_return_t CopyControllerSnapshot(OSDictionary** status, uint64_t* sequence, @@ -55,6 +84,10 @@ public: uint64_t time) TYPE(IOTimerDispatchSource::TimerOccurred); + virtual void WakeVerifyTimerFired(OSAction* action, + uint64_t time) + TYPE(IOTimerDispatchSource::TimerOccurred); + virtual void ProviderNotificationReady(OSAction* action) TYPE(IOServiceNotificationDispatchSource::ServiceNotificationReady); @@ -93,4 +126,14 @@ public: struct ASFWDriver_IVars { ServiceContext* context; + IOService* powerProvider; // borrowed from Start; cleared in Stop + bool runtimeSuspended; // true between sleep quiesce and wake rebuild + bool serviceRegistered; // RegisterService() must run once per instance + + // Wake-verify timer. Lives on the default queue (stable for the service + // lifetime, unlike per-rebuild runtime state); released in Stop, which also + // breaks the OSAction→service retain cycle. + IOTimerDispatchSource* wakeVerifyTimer; // retained + OSAction* wakeVerifyAction; // retained + uint64_t wakeVerifyAttempt; // attempt carried to the armed timer }; diff --git a/ASFWDriver/Audio/Protocols/Backends/ClockRequestBroker.hpp b/ASFWDriver/Audio/Protocols/Backends/ClockRequestBroker.hpp new file mode 100644 index 00000000..079022bb --- /dev/null +++ b/ASFWDriver/Audio/Protocols/Backends/ClockRequestBroker.hpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 ASFireWire Project +// +// ClockRequestBroker.hpp +// +// FW-67: clock-request token allocation, pending-request coalescing, and +// completion delivery extracted from DiceDuplexRestartCoordinator. The broker owns the pending +// and completed per-GUID maps plus the monotonic token allocator; it deliberately BORROWS the +// coordinator's existing IOLock* rather than creating a lock or queue of its own. +// +// That borrowed lock is load-bearing. RequestClockConfig and ClearSession each make one atomic +// update across this broker, RestartSessionStore, and DuplexOperationGate. Splitting those +// updates across independently locked components would permit an observer to see a pending +// request without the corresponding session flag (or vice versa). Like the other extracted +// helpers, the broker therefore provides both: +// * self-locking operations for ordinary producer/consumer/completion paths; and +// * *Locked operations for the coordinator's existing multi-domain critical sections. +// +// The DICE names are intentional for now; FW-73 performs the mechanical neutral rename after +// the protocol-neutral coordinator path is complete. + +#pragma once + +#include "RestartJournal.hpp" +#include "RestartSessionStore.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace ASFW::Audio::Backends { + +using ASFW::Audio::DICE::DiceClockRequestCompletion; +using ASFW::Audio::DICE::DiceClockRequestOutcome; +using ASFW::Audio::DICE::DiceDesiredClockConfig; +using ASFW::Audio::DICE::DiceRestartReason; + +class ClockRequestBroker { +public: + struct PendingClockRequest { + DiceDesiredClockConfig desiredClock{}; + DiceRestartReason reason{DiceRestartReason::kManualReconfigure}; + uint64_t token{0}; + }; + + // `lock` points at the coordinator's IOLock* member. `sessionStore` is the coordinator's + // lock-borrowing session store; both remain valid for the broker's lifetime. + ClockRequestBroker(IOLock** lock, RestartSessionStore& sessionStore) noexcept + : lockRef_(lock), sessionStore_(sessionStore) {} + + // --- lock-held API: caller already holds *lockRef_. These keep RequestClockConfig and + // ClearSession's cross-component state mutations atomic on the coordinator lock. + + [[nodiscard]] uint64_t AllocateTokenLocked() noexcept { + return nextClockToken_++; + } + + // Replaces the queued request for guid, returns the displaced request when one existed, and + // mirrors the new pending state into its restart session. This is the former contiguous + // pendingClockRequests_ + sessions_ mutation in RequestClockConfig. + [[nodiscard]] std::optional QueuePendingLocked( + uint64_t guid, + const PendingClockRequest& request) noexcept { + std::optional superseded{}; + const auto existingIt = pendingClockRequests_.find(guid); + if (existingIt != pendingClockRequests_.end()) { + superseded = existingIt->second; + } + pendingClockRequests_[guid] = request; + + auto* session = sessionStore_.FindSessionLocked(guid); + if (session != nullptr) { + session->pendingClock = request.desiredClock; + session->pendingReason = request.reason; + session->hasPendingClockRequest = true; + } + return superseded; + } + + void ClearLocked(uint64_t guid) noexcept { + pendingClockRequests_.erase(guid); + completedClockRequests_.erase(guid); + } + + // --- self-locking API: exact former coordinator helper behaviour. + + [[nodiscard]] bool TryConsumePending( + uint64_t guid, + PendingClockRequest& outRequest) noexcept { + IOLock* const lock = *lockRef_; + if (!lock) { + return false; + } + + IOLockLock(lock); + const bool consumed = TryConsumePendingLocked(guid, outRequest); + IOLockUnlock(lock); + return consumed; + } + + [[nodiscard]] bool TryTakeCompleted( + uint64_t guid, + uint64_t token, + DiceClockRequestCompletion& outCompletion) noexcept { + IOLock* const lock = *lockRef_; + if (!lock) { + return false; + } + + IOLockLock(lock); + auto storeIt = completedClockRequests_.find(guid); + if (storeIt == completedClockRequests_.end()) { + IOLockUnlock(lock); + return false; + } + + auto completionIt = storeIt->second.byToken.find(token); + if (completionIt == storeIt->second.byToken.end()) { + IOLockUnlock(lock); + return false; + } + + outCompletion = completionIt->second; + storeIt->second.byToken.erase(completionIt); + auto& order = storeIt->second.insertionOrder; + order.erase(std::remove(order.begin(), order.end(), token), order.end()); + if (storeIt->second.byToken.empty() && order.empty()) { + completedClockRequests_.erase(storeIt); + } + IOLockUnlock(lock); + return true; + } + + void Complete(const DiceClockRequestCompletion& completion, uint64_t guid) noexcept { + IOLock* const lock = *lockRef_; + if (!lock) { + return; + } + + IOLockLock(lock); + auto& store = completedClockRequests_[guid]; + if (store.byToken.find(completion.token) == store.byToken.end()) { + store.insertionOrder.push_back(completion.token); + } + store.byToken[completion.token] = completion; + while (store.insertionOrder.size() > kMaxCompletedClockRequestsPerGuid) { + const uint64_t evictedToken = store.insertionOrder.front(); + store.insertionOrder.pop_front(); + store.byToken.erase(evictedToken); + } + + auto* session = sessionStore_.FindSessionLocked(guid); + if (session != nullptr) { + session->lastClockCompletion = completion; + } + IOLockUnlock(lock); + + // Preserve the established [FSM] clock-completion trace verbatim; runtime consumers use + // these fields to correlate the synchronous clock request with restart progress. + ASFW_LOG_V2(DICE, + "[FSM] clock token=%llu outcome=%{public}s status=0x%08x guid=0x%llx restartId=%llu gen=%u", + completion.token, + ToString(completion.outcome), + static_cast(completion.status), + guid, + completion.restartId, + GenerationValue(completion.generation)); + } + + void FailPending( + uint64_t guid, + DiceClockRequestOutcome outcome, + IOReturn status) noexcept { + PendingClockRequest request{}; + if (!TryConsumePending(guid, request)) { + return; + } + + // Keep the original lock boundaries: consuming the pending entry, reading the restart + // session, and publishing the completion are three separate operations. In particular, + // do not fold this into one new critical section; stop/recovery races intentionally see + // the same interleavings as before the extraction. + const auto session = sessionStore_.LoadSession(guid); + Complete(DiceClockRequestCompletion{ + .token = request.token, + .desiredClock = request.desiredClock, + .reason = request.reason, + .outcome = outcome, + .status = status, + .restartId = session.restartId, + .generation = session.topologyGeneration, + }, + guid); + } + +private: + struct ClockCompletionStore { + std::unordered_map byToken{}; + std::deque insertionOrder{}; + }; + + [[nodiscard]] bool TryConsumePendingLocked( + uint64_t guid, + PendingClockRequest& outRequest) noexcept { + const auto it = pendingClockRequests_.find(guid); + if (it == pendingClockRequests_.end()) { + return false; + } + + outRequest = it->second; + pendingClockRequests_.erase(it); + auto* session = sessionStore_.FindSessionLocked(guid); + if (session != nullptr) { + session->pendingClock = {}; + session->pendingReason = DiceRestartReason::kInitialStart; + session->hasPendingClockRequest = false; + } + return true; + } + + IOLock** lockRef_; // borrowed: &coordinator.lock_ (not owned; never allocated/freed here) + RestartSessionStore& sessionStore_; + std::unordered_map pendingClockRequests_{}; + std::unordered_map completedClockRequests_{}; + uint64_t nextClockToken_{1}; + + static constexpr size_t kMaxCompletedClockRequestsPerGuid = 32; +}; + +} // namespace ASFW::Audio::Backends diff --git a/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.cpp b/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.cpp index a1fec559..814a6afe 100644 --- a/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.cpp +++ b/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.cpp @@ -3,18 +3,17 @@ #include "DiceDuplexRestartCoordinator.hpp" #include "DiceRecoveryPolicy.hpp" +#include "DuplexStreamProfile.hpp" #include "RestartJournal.hpp" #include "SyncAsyncBridge.hpp" +#include "../../../Logging/Logging.hpp" #include "../../Core/AudioRuntimeRegistry.hpp" #include "../../Model/ASFWAudioDevice.hpp" -#include "../../../Logging/Logging.hpp" #include "../DICE/Core/IDICEDuplexProtocol.hpp" -#include "../DeviceProtocolFactory.hpp" #include -#include #include #include #include @@ -36,11 +35,11 @@ using ASFW::Audio::DICE::DiceDuplexPrepareResult; using ASFW::Audio::DICE::DiceDuplexStageResult; using ASFW::Audio::DICE::DiceRestartErrorClass; using ASFW::Audio::DICE::DiceRestartFailureCause; +using ASFW::Audio::DICE::DiceRestartIssueInfo; using ASFW::Audio::DICE::DiceRestartPhase; using ASFW::Audio::DICE::DiceRestartReason; -using ASFW::Audio::DICE::DiceRestartIssueInfo; -using ASFW::Audio::DICE::DiceRestartState; using ASFW::Audio::DICE::DiceRestartSession; +using ASFW::Audio::DICE::DiceRestartState; using ASFW::Audio::DICE::HasAnyRestartState; using ASFW::Audio::DICE::HasDeviceRestartState; using ASFW::Audio::DICE::HasHostRestartState; @@ -48,125 +47,140 @@ using ASFW::Audio::DICE::HasRestartIntent; using ASFW::Audio::DICE::IsSupportedClockConfig; using ASFW::Audio::DICE::kDiceClockSelect48kInternal; -constexpr uint8_t kDefaultIrChannel = 1; -constexpr uint8_t kDefaultItChannel = 0; -constexpr uint32_t kPlaybackBandwidthUnits = 320; -constexpr uint32_t kCaptureBandwidthUnits = 576; constexpr uint32_t kClockRequestWaitTimeoutMs = 15000; [[nodiscard]] uint64_t UptimeMilliseconds() noexcept { mach_timebase_info_data_t timebase{}; - if (mach_timebase_info(&timebase) != KERN_SUCCESS || - timebase.denom == 0) { + if (mach_timebase_info(&timebase) != KERN_SUCCESS || timebase.denom == 0) { return 0; } const unsigned __int128 nanos = - static_cast(mach_absolute_time()) * - timebase.numer / timebase.denom; + static_cast(mach_absolute_time()) * timebase.numer / timebase.denom; return static_cast(nanos / 1'000'000U); } -[[nodiscard]] bool IsValidIsoChannel(uint8_t channel) noexcept { - return channel <= 0x3F; +inline uint8_t ReadLocalSid(Driver::HardwareInterface& hw) noexcept { + return static_cast(hw.ReadNodeID() & 0x3Fu); } -[[nodiscard]] AudioDuplexChannels ResolveDuplexChannelsForRecord( - const Discovery::DeviceRecord& record, - const IDeviceProtocol* protocol) noexcept { - AudioDuplexChannels channels{ - .deviceToHostIsoChannel = kDefaultIrChannel, - .hostToDeviceIsoChannel = kDefaultItChannel, - }; - - AudioStreamRuntimeCaps caps{}; - bool haveCaps = protocol && protocol->GetRuntimeAudioStreamCaps(caps); +} // namespace - // Stream counts come from the device's TX_NUMBER/RX_NUMBER (includes streams - // currently reported with iso=-1 that the host must still arm). Single-stream - // devices report 1/1 and take exactly the legacy code path below. - auto clampCount = [](uint32_t n) noexcept -> uint32_t { - if (n == 0) return 1; - return (n > kMaxAudioStreamsPerDirection) ? kMaxAudioStreamsPerDirection : n; +// FW-70: the execution body of a duplex start/stop/idle-clock transaction. The +// coordinator remains the FSM entry-point and owns the gate/store, while this +// runner owns the ordered stage sequence. FW-71 will replace the temporary +// DICE-named dependency types with their protocol-neutral seams. +// Device stream programming before GLOBAL_ENABLE is cross-validated with Linux +// dice-stream.c:326-374 and dice-interface.h:120-125; no source code is copied. +class DuplexStartTransaction final { +public: + struct Dependencies { + Discovery::DeviceRegistry& registry; + AudioRuntimeRegistry& runtime; + IDiceHostTransport& hostTransport; + Driver::HardwareInterface& hardware; + const std::atomic* cancel; + const DiceDuplexRestartCoordinator::DirectAudioBindingSourceProvider& bindingSourceProvider; + Backends::DuplexOperationGate& gate; + Backends::RestartSessionStore& sessionStore; + std::atomic& teardownAbortCount; + uint32_t syncBridgeTimeoutMs; + uint32_t globalClockLockTimeoutMs; + uint32_t globalClockLockPollMs; + uint32_t globalClockStableReads; }; - channels.captureStreamCount = - haveCaps ? clampCount(caps.deviceToHostStreamCount) : 1; - channels.playbackStreamCount = - haveCaps ? clampCount(caps.hostToDeviceStreamCount) : 1; - - // Assign distinct iso channels across both directions. stream[0] keeps the - // device-reported channel (or the legacy default) so the single-stream host - // path is byte-for-byte unchanged; additional streams get the lowest free - // channels. The host allocates these (it owns the IRM reservation) and writes - // them into the device's per-stream ISOC registers before GLOBAL_ENABLE. - uint64_t used = 0; // bitset over channels 0..63 - auto markUsed = [&](uint8_t ch) noexcept { - if (ch <= 0x3F) used |= (uint64_t{1} << ch); + + struct StartRequest { + uint64_t guid; + Discovery::DeviceRecord& record; + DICE::IDICEDuplexProtocol& deviceControl; + DiceRestartSession& session; + const DiceDesiredClockConfig& desiredClock; + DiceRestartReason reason; }; - auto nextFree = [&]() noexcept -> uint8_t { - for (uint8_t ch = 0; ch <= 0x3F; ++ch) { - if ((used & (uint64_t{1} << ch)) == 0) { - used |= (uint64_t{1} << ch); - return ch; - } - } - return AudioStreamWireInfo::kInvalidIsoChannel; + + struct StopRequest { + uint64_t guid; + Discovery::DeviceRecord& record; + DICE::IDICEDuplexProtocol& deviceControl; + DiceRestartSession& session; }; - channels.captureIsoChannels[0] = - (haveCaps && IsValidIsoChannel(caps.deviceToHostIsoChannel)) - ? caps.deviceToHostIsoChannel - : kDefaultIrChannel; - channels.playbackIsoChannels[0] = - (haveCaps && IsValidIsoChannel(caps.hostToDeviceIsoChannel)) - ? caps.hostToDeviceIsoChannel - : kDefaultItChannel; - markUsed(channels.captureIsoChannels[0]); - markUsed(channels.playbackIsoChannels[0]); + struct IdleClockApplyRequest { + uint64_t guid; + DICE::IDICEDuplexProtocol& deviceControl; + DiceRestartSession& session; + FW::Generation topologyGeneration; + const DiceDesiredClockConfig& desiredClock; + DiceRestartReason reason; + }; - for (uint32_t i = 1; i < channels.captureStreamCount; ++i) { - channels.captureIsoChannels[i] = nextFree(); - } - for (uint32_t i = 1; i < channels.playbackStreamCount; ++i) { - channels.playbackIsoChannels[i] = nextFree(); - } + explicit DuplexStartTransaction(Dependencies dependencies) noexcept : dependencies_(dependencies) {} + + [[nodiscard]] IOReturn Run(const StartRequest& request) noexcept; + [[nodiscard]] IOReturn Stop(const StopRequest& request) noexcept; + [[nodiscard]] IOReturn ApplyIdleClock(const IdleClockApplyRequest& request) noexcept; + +private: + [[nodiscard]] bool TeardownRequested() const noexcept; + void RecordTeardownAbort(const char* stage, uint64_t guid) noexcept; + [[nodiscard]] bool IsStopRequested(uint64_t guid) const noexcept; + [[nodiscard]] bool IsRestartEpochCurrent(uint64_t guid, uint64_t restartId, + FW::Generation topologyGeneration) const noexcept; + [[nodiscard]] Runtime::IDirectAudioBindingSource* GetDirectAudioBindingSource( + uint64_t guid) const noexcept; + [[nodiscard]] IOReturn WaitForStableGlobalClock( + uint64_t guid, + DICE::IDICEDuplexProtocol& deviceControl, + FW::Generation topologyGeneration, + const DiceDesiredClockConfig& desiredClock) noexcept; + + Dependencies dependencies_; +}; + +bool DuplexStartTransaction::TeardownRequested() const noexcept { + return dependencies_.cancel != nullptr && + dependencies_.cancel->load(std::memory_order_acquire); +} - // Legacy single-channel fields mirror stream[0]. - channels.deviceToHostIsoChannel = channels.captureIsoChannels[0]; - channels.hostToDeviceIsoChannel = channels.playbackIsoChannels[0]; - return channels; +void DuplexStartTransaction::RecordTeardownAbort(const char* stage, uint64_t guid) noexcept { + dependencies_.teardownAbortCount.fetch_add(1, std::memory_order_acq_rel); + ASFW_LOG(Audio, + "DiceDuplexRestartCoordinator: recovery aborted by teardown stage=%{public}s " + "GUID=%llx kr=0x%x", + stage ? stage : "unknown", guid, kIOReturnAborted); } -inline uint8_t ReadLocalSid(Driver::HardwareInterface& hw) noexcept { - return static_cast(hw.ReadNodeID() & 0x3Fu); +bool DuplexStartTransaction::IsStopRequested(uint64_t guid) const noexcept { + return dependencies_.gate.IsStopRequested(guid); } -[[nodiscard]] constexpr Encoding::AudioWireFormat ResolveDicePlaybackWireFormat( - const Discovery::DeviceRecord& record, - const AudioStreamRuntimeCaps& caps) noexcept { - if (record.vendorId == DeviceProtocolFactory::kFocusriteVendorId && - record.modelId == DeviceProtocolFactory::kSPro24DspModelId && - caps.hostOutputPcmChannels == 8 && - caps.hostToDeviceAm824Slots == 9) { - return Encoding::AudioWireFormat::kRawPcm24In32; +bool DuplexStartTransaction::IsRestartEpochCurrent( + uint64_t guid, uint64_t restartId, FW::Generation topologyGeneration) const noexcept { + if (guid == 0 || restartId == 0 || IsStopRequested(guid)) { + return false; + } + + const DiceRestartSession session = dependencies_.sessionStore.LoadSession(guid); + if (session.restartId != restartId || session.topologyGeneration != topologyGeneration) { + return false; } - return Encoding::AudioWireFormat::kAM824; + + const auto* liveRecord = dependencies_.registry.FindByGuid(guid); + return liveRecord != nullptr && liveRecord->gen == topologyGeneration; } -} // namespace +Runtime::IDirectAudioBindingSource* DuplexStartTransaction::GetDirectAudioBindingSource( + uint64_t guid) const noexcept { + return dependencies_.bindingSourceProvider ? dependencies_.bindingSourceProvider(guid) : nullptr; +} DiceDuplexRestartCoordinator::DiceDuplexRestartCoordinator( - Discovery::DeviceRegistry& registry, - AudioRuntimeRegistry& runtime, - IDiceHostTransport& hostTransport, - Driver::HardwareInterface& hardware, + Discovery::DeviceRegistry& registry, AudioRuntimeRegistry& runtime, + IDiceHostTransport& hostTransport, Driver::HardwareInterface& hardware, const std::atomic* cancel, DirectAudioBindingSourceProvider bindingSourceProvider) noexcept - : registry_(registry) - , runtime_(runtime) - , hostTransport_(hostTransport) - , hardware_(hardware) - , cancel_(cancel) - , bindingSourceProvider_(std::move(bindingSourceProvider)) { + : registry_(registry), runtime_(runtime), hostTransport_(hostTransport), hardware_(hardware), + cancel_(cancel), bindingSourceProvider_(std::move(bindingSourceProvider)) { lock_ = IOLockAlloc(); if (!lock_) { ASFW_LOG_ERROR(Audio, "DiceDuplexRestartCoordinator: failed to allocate lock"); @@ -193,13 +207,8 @@ IOReturn DiceDuplexRestartCoordinator::StartStreaming(uint64_t guid) noexcept { } const DiceRestartSession session = LoadSession(guid); - LogFsmEvent("start", - guid, - session.restartId, - session.topologyGeneration, - session.state, - session.phase, - session.reason); + LogFsmEvent("start", guid, session.restartId, session.topologyGeneration, session.state, + session.phase, session.reason); bool acquired = false; for (uint32_t waited = 0; waited < kSyncBridgeTimeoutMs; waited += kSyncBridgePollMs) { @@ -214,9 +223,9 @@ IOReturn DiceDuplexRestartCoordinator::StartStreaming(uint64_t guid) noexcept { } if (!acquired) { ASFW_LOG_ERROR(Audio, - "DiceDuplexRestartCoordinator: StartStreaming timed out waiting for GUID claim GUID=%llx timeoutMs=%u", - guid, - kSyncBridgeTimeoutMs); + "DiceDuplexRestartCoordinator: StartStreaming timed out waiting for GUID " + "claim GUID=%llx timeoutMs=%u", + guid, kSyncBridgeTimeoutMs); return kIOReturnTimeout; } @@ -230,20 +239,14 @@ IOReturn DiceDuplexRestartCoordinator::StopStreaming(uint64_t guid) noexcept { return kIOReturnBadArgument; } if (TeardownRequested()) { - ASFW_LOG(Audio, - "DiceDuplexRestartCoordinator: StopStreaming refused by teardown GUID=%llx", + ASFW_LOG(Audio, "DiceDuplexRestartCoordinator: StopStreaming refused by teardown GUID=%llx", guid); return kIOReturnAborted; } const DiceRestartSession session = LoadSession(guid); - LogFsmEvent("stop", - guid, - session.restartId, - session.topologyGeneration, - session.state, - session.phase, - session.reason); + LogFsmEvent("stop", guid, session.restartId, session.topologyGeneration, session.state, + session.phase, session.reason); RequestStopIntent(guid); FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); @@ -262,9 +265,9 @@ IOReturn DiceDuplexRestartCoordinator::StopStreaming(uint64_t guid) noexcept { } if (!acquired) { ASFW_LOG_ERROR(Audio, - "DiceDuplexRestartCoordinator: StopStreaming timed out waiting for GUID claim GUID=%llx timeoutMs=%u", - guid, - kSyncBridgeTimeoutMs); + "DiceDuplexRestartCoordinator: StopStreaming timed out waiting for GUID " + "claim GUID=%llx timeoutMs=%u", + guid, kSyncBridgeTimeoutMs); ClearStopIntent(guid); return kIOReturnTimeout; } @@ -277,9 +280,7 @@ IOReturn DiceDuplexRestartCoordinator::StopStreaming(uint64_t guid) noexcept { } IOReturn DiceDuplexRestartCoordinator::RequestClockConfig( - uint64_t guid, - const DiceDesiredClockConfig& desiredClock, - DiceRestartReason reason) noexcept { + uint64_t guid, const DiceDesiredClockConfig& desiredClock, DiceRestartReason reason) noexcept { if (guid == 0) { return kIOReturnBadArgument; } @@ -311,19 +312,9 @@ IOReturn DiceDuplexRestartCoordinator::RequestClockConfig( return kIOReturnAborted; } - request.token = nextClockToken_++; + request.token = clockRequests_.AllocateTokenLocked(); if (gate_.IsActiveLocked(guid)) { - const auto existingIt = pendingClockRequests_.find(guid); - if (existingIt != pendingClockRequests_.end()) { - supersededRequest = existingIt->second; - } - pendingClockRequests_[guid] = request; - auto* sessionPtr = store_.FindSessionLocked(guid); - if (sessionPtr != nullptr) { - sessionPtr->pendingClock = request.desiredClock; - sessionPtr->pendingReason = request.reason; - sessionPtr->hasPendingClockRequest = true; - } + supersededRequest = clockRequests_.QueuePendingLocked(guid, request); } else { gate_.AcquireLocked(guid); shouldLaunchLoop = true; @@ -332,14 +323,8 @@ IOReturn DiceDuplexRestartCoordinator::RequestClockConfig( session.pendingClock = request.desiredClock; session.pendingReason = request.reason; - LogFsmEvent("clock", - guid, - session.restartId, - session.topologyGeneration, - session.state, - session.phase, - reason, - request.token); + LogFsmEvent("clock", guid, session.restartId, session.topologyGeneration, session.state, + session.phase, reason, request.token); if (supersededRequest.has_value()) { CompleteClockRequest( @@ -388,13 +373,8 @@ IOReturn DiceDuplexRestartCoordinator::RecoverStreaming(uint64_t guid, } const DiceRestartSession session = LoadSession(guid); - LogFsmEvent("recover", - guid, - session.restartId, - session.topologyGeneration, - session.state, - session.phase, - reason); + LogFsmEvent("recover", guid, session.restartId, session.topologyGeneration, session.state, + session.phase, reason); FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); @@ -411,9 +391,9 @@ IOReturn DiceDuplexRestartCoordinator::RecoverStreaming(uint64_t guid, } if (!acquired) { ASFW_LOG_ERROR(Audio, - "DiceDuplexRestartCoordinator: RecoverStreaming timed out waiting for GUID claim GUID=%llx timeoutMs=%u", - guid, - kSyncBridgeTimeoutMs); + "DiceDuplexRestartCoordinator: RecoverStreaming timed out waiting for GUID " + "claim GUID=%llx timeoutMs=%u", + guid, kSyncBridgeTimeoutMs); return kIOReturnTimeout; } @@ -429,14 +409,14 @@ void DiceDuplexRestartCoordinator::ClearSession(uint64_t guid) noexcept { IOLockLock(lock_); store_.EraseSessionLocked(guid); - pendingClockRequests_.erase(guid); - completedClockRequests_.erase(guid); + clockRequests_.ClearLocked(guid); gate_.ReleaseLocked(guid); gate_.ClearStopLocked(guid); IOLockUnlock(lock_); } -std::optional DiceDuplexRestartCoordinator::GetSession(uint64_t guid) const noexcept { +std::optional +DiceDuplexRestartCoordinator::GetSession(uint64_t guid) const noexcept { return store_.GetSession(guid); } @@ -458,12 +438,12 @@ IOReturn DiceDuplexRestartCoordinator::RunStartStreaming(uint64_t guid) noexcept .sampleRateHz = 48000U, .clockSelect = kDiceClockSelect48kInternal, }; - const DiceRestartReason reason = - DICE::HasRestartIntent(session) - ? DICE::ClassifyRestartReason(&session, desiredClock) - : DiceRestartReason::kInitialStart; + const DiceRestartReason reason = DICE::HasRestartIntent(session) + ? DICE::ClassifyRestartReason(&session, desiredClock) + : DiceRestartReason::kInitialStart; - const IOReturn status = RunDuplexStart(guid, *record, *diceProtocol, session, desiredClock, reason); + const IOReturn status = + RunDuplexStart(guid, *record, *diceProtocol, session, desiredClock, reason); if (status != kIOReturnSuccess) { FailPendingClockRequest(guid, DiceClockRequestOutcome::kFailed, status); return status; @@ -489,7 +469,8 @@ IOReturn DiceDuplexRestartCoordinator::RunStartStreaming(uint64_t guid) noexcept .generation = completionSession.topologyGeneration, }, guid); - FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); + FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, + kIOReturnAborted); break; } @@ -507,7 +488,8 @@ IOReturn DiceDuplexRestartCoordinator::RunStartStreaming(uint64_t guid) noexcept .generation = completionSession.topologyGeneration, }, guid); - FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); + FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, + kIOReturnAborted); break; } @@ -517,9 +499,8 @@ IOReturn DiceDuplexRestartCoordinator::RunStartStreaming(uint64_t guid) noexcept .token = pending.token, .desiredClock = pending.desiredClock, .reason = pending.reason, - .outcome = (pendingStatus == kIOReturnSuccess) - ? DiceClockRequestOutcome::kApplied - : DiceClockRequestOutcome::kFailed, + .outcome = (pendingStatus == kIOReturnSuccess) ? DiceClockRequestOutcome::kApplied + : DiceClockRequestOutcome::kFailed, .status = pendingStatus, .restartId = completionSession.restartId, .generation = completionSession.topologyGeneration, @@ -563,17 +544,9 @@ IOReturn DiceDuplexRestartCoordinator::RunRecoveryStreaming(uint64_t guid, DiceRestartSession session = LoadSession(guid); session.guid = guid; if (IsRecoveryReason(reason)) { - RecordIssue(session, - session.lastInvalidation, - session.phase, - DiceRestartErrorClass::kEpochInvalidated, - FailureCauseForReason(reason), - kIOReturnAborted, - true, - false, - kIOReturnSuccess, - true, - true); + RecordIssue(session, session.lastInvalidation, session.phase, + DiceRestartErrorClass::kEpochInvalidated, FailureCauseForReason(reason), + kIOReturnAborted, true, false, kIOReturnSuccess, true, true); StoreSession(session); LogInvalidation(session); } @@ -596,27 +569,20 @@ IOReturn DiceDuplexRestartCoordinator::RunRecoveryStreaming(uint64_t guid, if (decision.disposition == DiceRecoveryDisposition::kIgnore) { return (decision.reason == DiceRecoveryPolicyReason::kSuppressedByStop || decision.reason == DiceRecoveryPolicyReason::kIdleApplyInvalidated) - ? kIOReturnAborted - : kIOReturnSuccess; + ? kIOReturnAborted + : kIOReturnSuccess; } if (decision.disposition == DiceRecoveryDisposition::kFailSession) { const bool missingDependency = (!record || !diceProtocol); session.terminalError = missingDependency - ? kIOReturnNotReady - : (session.lastFailure.has_value() ? session.lastFailure->status : kIOReturnUnsupported); + ? kIOReturnNotReady + : (session.lastFailure.has_value() ? session.lastFailure->status + : kIOReturnUnsupported); if (missingDependency) { - RecordIssue(session, - session.lastFailure, - session.phase, - DiceRestartErrorClass::kMissingDependency, - FailureCauseForReason(reason), - session.terminalError, - false, - false, - kIOReturnSuccess, - false, - false); + RecordIssue(session, session.lastFailure, session.phase, + DiceRestartErrorClass::kMissingDependency, FailureCauseForReason(reason), + session.terminalError, false, false, kIOReturnSuccess, false, false); } ApplyTerminalPhase(session, DiceRestartPhase::kFailed, ToString(decision.reason)); StoreSession(session); @@ -655,8 +621,9 @@ IOReturn DiceDuplexRestartCoordinator::RunRecoveryStreaming(uint64_t guid, return RunDuplexStart(guid, *record, *diceProtocol, session, desiredClock, reason); } -IOReturn DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, - PendingClockRequest initialRequest) noexcept { +IOReturn +DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, + PendingClockRequest initialRequest) noexcept { PendingClockRequest current = initialRequest; IOReturn lastStatus = kIOReturnSuccess; @@ -674,7 +641,8 @@ IOReturn DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, .generation = completionSession.topologyGeneration, }, guid); - FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); + FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, + kIOReturnAborted); break; } @@ -693,7 +661,8 @@ IOReturn DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, .generation = completionSession.topologyGeneration, }, guid); - FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); + FailPendingClockRequest(guid, DiceClockRequestOutcome::kAbortedByStop, + kIOReturnAborted); break; } @@ -703,9 +672,8 @@ IOReturn DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, .token = current.token, .desiredClock = current.desiredClock, .reason = current.reason, - .outcome = (lastStatus == kIOReturnSuccess) - ? DiceClockRequestOutcome::kApplied - : DiceClockRequestOutcome::kFailed, + .outcome = (lastStatus == kIOReturnSuccess) ? DiceClockRequestOutcome::kApplied + : DiceClockRequestOutcome::kFailed, .status = lastStatus, .restartId = completionSession.restartId, .generation = completionSession.topologyGeneration, @@ -722,8 +690,9 @@ IOReturn DiceDuplexRestartCoordinator::RunClockRequestLoop(uint64_t guid, return lastStatus; } -IOReturn DiceDuplexRestartCoordinator::ApplyClockRequest(uint64_t guid, - const PendingClockRequest& request) noexcept { +IOReturn +DiceDuplexRestartCoordinator::ApplyClockRequest(uint64_t guid, + const PendingClockRequest& request) noexcept { if (IsStopRequested(guid) || TeardownRequested()) { return kIOReturnAborted; } @@ -739,8 +708,7 @@ IOReturn DiceDuplexRestartCoordinator::ApplyClockRequest(uint64_t guid, } DiceRestartSession session = LoadSession(guid); - if (HasAnyRestartState(session) || - session.phase == DiceRestartPhase::kRunning || + if (HasAnyRestartState(session) || session.phase == DiceRestartPhase::kRunning || session.state == DiceRestartState::kRunning || session.state == DiceRestartState::kRecovering || session.state == DiceRestartState::kFailed) { @@ -754,28 +722,55 @@ IOReturn DiceDuplexRestartCoordinator::ApplyClockRequest(uint64_t guid, if (TeardownRequested()) { return kIOReturnAborted; } - return RunDuplexStart(guid, *record, *diceProtocol, session, request.desiredClock, request.reason); + return RunDuplexStart(guid, *record, *diceProtocol, session, request.desiredClock, + request.reason); } if (TeardownRequested()) { return kIOReturnAborted; } - return RunIdleClockApply(guid, - *diceProtocol, - session, - record->gen, - request.desiredClock, + return RunIdleClockApply(guid, *diceProtocol, session, record->gen, request.desiredClock, request.reason); } -IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( - uint64_t guid, - Discovery::DeviceRecord& record, - DICE::IDICEDuplexProtocol& diceProtocol, - DiceRestartSession& session, - const DiceDesiredClockConfig& desiredClock, - DiceRestartReason reason) noexcept { +IOReturn DuplexStartTransaction::Run(const StartRequest& request) noexcept { + const uint64_t guid = request.guid; + Discovery::DeviceRecord& record = request.record; + DICE::IDICEDuplexProtocol& diceProtocol = request.deviceControl; + DiceRestartSession& session = request.session; + const DiceDesiredClockConfig& desiredClock = request.desiredClock; + const DiceRestartReason reason = request.reason; + auto& runtime_ = dependencies_.runtime; + auto& hostTransport_ = dependencies_.hostTransport; + auto& hardware_ = dependencies_.hardware; + const std::atomic* const cancel_ = dependencies_.cancel; + const uint32_t kSyncBridgeTimeoutMs = dependencies_.syncBridgeTimeoutMs; + + auto TeardownRequested = [this]() noexcept { return this->TeardownRequested(); }; + auto RecordTeardownAbort = [this](const char* stage, uint64_t abortGuid) noexcept { + this->RecordTeardownAbort(stage, abortGuid); + }; + auto AllocateRestartId = [this]() noexcept { return dependencies_.sessionStore.AllocateRestartId(); }; + auto StoreSession = [this](const DiceRestartSession& updatedSession) noexcept { + dependencies_.sessionStore.StoreSession(updatedSession); + }; + auto IsStopRequested = [this](uint64_t requestGuid) noexcept { + return this->IsStopRequested(requestGuid); + }; + auto IsRestartEpochCurrent = [this](uint64_t requestGuid, uint64_t restartId, + FW::Generation generation) noexcept { + return this->IsRestartEpochCurrent(requestGuid, restartId, generation); + }; + auto GetDirectAudioBindingSource = [this](uint64_t requestGuid) noexcept { + return this->GetDirectAudioBindingSource(requestGuid); + }; + auto RunDuplexStop = [this](uint64_t stopGuid, Discovery::DeviceRecord& stopRecord, + DICE::IDICEDuplexProtocol& stopDevice, + DiceRestartSession& stopSession) noexcept { + return Stop(StopRequest{stopGuid, stopRecord, stopDevice, stopSession}); + }; + auto abortIfTeardown = [&](const char* stage) noexcept -> bool { if (!TeardownRequested()) { return false; @@ -793,7 +788,7 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( // Pre-read the device's static stream format (DICE TX_NUMBER/RX_NUMBER + // per-stream channels) so the channel resolution + IRM reservation below see // the real stream count. EnsureRuntimeStreamGeometry publishes the per-stream - // caps that ResolveDuplexChannelsForRecord consumes via GetRuntimeAudioStreamCaps. + // caps that DuplexStreamProfileResolver consumes via GetRuntimeAudioStreamCaps. // Non-fatal: on failure we fall back to the legacy single-stream resolution // and PrepareDuplex will surface any genuine device error. A multi-stream // device (Venice F32 = 2×16) needs this to allocate a channel per stream; @@ -803,9 +798,7 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( diceProtocol.EnsureRuntimeStreamGeometry( [callback = std::move(callback)](IOReturn st) mutable { callback(st, true); }); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); + kSyncBridgeTimeoutMs, kIOReturnTimeout, cancel_); if (geometryLoad.status != kIOReturnSuccess) { ASFW_LOG(DICE, "RunDuplexStart: stream-geometry pre-read failed (0x%x); " @@ -813,16 +806,14 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( geometryLoad.status); } - const AudioDuplexChannels channels = ResolveDuplexChannelsForRecord(record, runtimeProtocol.get()); + const DuplexStreamProfile initialProfile = + DuplexStreamProfileResolver::Resolve(record, runtimeProtocol.get()); + const AudioDuplexChannels channels = initialProfile.channels; const uint64_t restartId = AllocateRestartId(); - auto finalizeFailure = [&](IOReturn failureStatus, - DiceRestartPhase failedPhase, - DiceRestartFailureCause cause, - DiceRestartErrorClass errorClass, - bool rollbackAttempted, - IOReturn rollbackStatus, - bool hostStateKnown, + auto finalizeFailure = [&](IOReturn failureStatus, DiceRestartPhase failedPhase, + DiceRestartFailureCause cause, DiceRestartErrorClass errorClass, + bool rollbackAttempted, IOReturn rollbackStatus, bool hostStateKnown, bool deviceStateKnown) noexcept { session.guid = guid; session.restartId = restartId; @@ -832,56 +823,36 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( session.reason = reason; session.desiredClock = desiredClock; session.terminalError = failureStatus; - RecordIssue(session, - session.lastFailure, - failedPhase, - errorClass, - cause, - failureStatus, - IsRetryableStatus(failureStatus), - rollbackAttempted, - rollbackStatus, - hostStateKnown, - deviceStateKnown); + RecordIssue(session, session.lastFailure, failedPhase, errorClass, cause, failureStatus, + IsRetryableStatus(failureStatus), rollbackAttempted, rollbackStatus, + hostStateKnown, deviceStateKnown); ApplyTerminalPhase(session, DiceRestartPhase::kFailed, ToString(errorClass)); StoreSession(session); LogTerminal(session); return failureStatus; }; - auto rollbackToFailure = [&](IOReturn failureStatus, - DiceRestartPhase failedPhase, + auto rollbackToFailure = [&](IOReturn failureStatus, DiceRestartPhase failedPhase, DiceRestartFailureCause cause) noexcept { if (failureStatus == kIOReturnAborted && TeardownRequested()) { return failureStatus; } const IOReturn rollbackStatus = RunDuplexStop(guid, record, diceProtocol, session); - return finalizeFailure(failureStatus, - failedPhase, - cause, - DiceRestartErrorClass::kStageFailure, - true, - rollbackStatus, - true, + return finalizeFailure(failureStatus, failedPhase, cause, + DiceRestartErrorClass::kStageFailure, true, rollbackStatus, true, true); }; - auto rollbackToInvalidation = [&](IOReturn invalidationStatus, - DiceRestartPhase failedPhase, + auto rollbackToInvalidation = [&](IOReturn invalidationStatus, DiceRestartPhase failedPhase, DiceRestartFailureCause cause) noexcept { if (invalidationStatus == kIOReturnAborted && TeardownRequested()) { return invalidationStatus; } const IOReturn rollbackStatus = RunDuplexStop(guid, record, diceProtocol, session); if (rollbackStatus != kIOReturnSuccess) { - return finalizeFailure(rollbackStatus, - DiceRestartPhase::kStopping, - DiceRestartFailureCause::kStop, - DiceRestartErrorClass::kStageFailure, - true, - rollbackStatus, - true, - true); + return finalizeFailure( + rollbackStatus, DiceRestartPhase::kStopping, DiceRestartFailureCause::kStop, + DiceRestartErrorClass::kStageFailure, true, rollbackStatus, true, true); } session.guid = guid; @@ -892,20 +863,13 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( session.reason = reason; session.desiredClock = desiredClock; session.terminalError = kIOReturnSuccess; - RecordIssue(session, - session.lastInvalidation, - failedPhase, + RecordIssue(session, session.lastInvalidation, failedPhase, IsStopRequested(guid) ? DiceRestartErrorClass::kStopIntent : DiceRestartErrorClass::kEpochInvalidated, - cause, - invalidationStatus, - true, - true, - rollbackStatus, - true, - true); + cause, invalidationStatus, true, true, rollbackStatus, true, true); ClearFailureSnapshot(session); - ApplyTerminalPhase(session, DiceRestartPhase::kIdle, ToString(session.lastInvalidation->errorClass)); + ApplyTerminalPhase(session, DiceRestartPhase::kIdle, + ToString(session.lastInvalidation->errorClass)); StoreSession(session); LogInvalidation(session); LogTerminal(session); @@ -914,27 +878,20 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( auto* irmClient = diceProtocol.GetIRMClient(); if (irmClient == nullptr) { - ASFW_LOG_ERROR(Audio, "DiceDuplexRestartCoordinator: protocol missing IRM client GUID=%llx", guid); - return finalizeFailure(kIOReturnNotReady, - DiceRestartPhase::kPreparingDevice, + ASFW_LOG_ERROR(Audio, "DiceDuplexRestartCoordinator: protocol missing IRM client GUID=%llx", + guid); + return finalizeFailure(kIOReturnNotReady, DiceRestartPhase::kPreparingDevice, DiceRestartFailureCause::kPrepare, - DiceRestartErrorClass::kMissingDependency, - false, - kIOReturnSuccess, - false, - false); + DiceRestartErrorClass::kMissingDependency, false, kIOReturnSuccess, + false, false); } auto* bindingSource = GetDirectAudioBindingSource(guid); if (!bindingSource) { - return finalizeFailure(kIOReturnNotReady, - DiceRestartPhase::kPreparingDevice, + return finalizeFailure(kIOReturnNotReady, DiceRestartPhase::kPreparingDevice, DiceRestartFailureCause::kPrepare, - DiceRestartErrorClass::kMissingDependency, - false, - kIOReturnSuccess, - false, - true); + DiceRestartErrorClass::kMissingDependency, false, kIOReturnSuccess, + false, true); } session.guid = guid; @@ -949,32 +906,18 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( SetSessionState(session, RestartStateForStartReason(reason), ToString(reason)); SetSessionPhase(session, DiceRestartPhase::kPreparingDevice); StoreSession(session); - LogFsmEvent("start", - guid, - restartId, - topologyGeneration, - session.state, - session.phase, - reason); - ASFW_LOG(Audio, - "DiceDuplexRestartCoordinator: using DICE iso channels d2h=%u h2d=%u GUID=%llx", - channels.deviceToHostIsoChannel, - channels.hostToDeviceIsoChannel, - guid); + LogFsmEvent("start", guid, restartId, topologyGeneration, session.state, session.phase, reason); + ASFW_LOG(Audio, "DiceDuplexRestartCoordinator: using DICE iso channels d2h=%u h2d=%u GUID=%llx", + channels.deviceToHostIsoChannel, channels.hostToDeviceIsoChannel, guid); if (abortIfTeardown("BeginSplitDuplex")) { return kIOReturnAborted; } const kern_return_t claimStatus = hostTransport_.BeginSplitDuplex(guid); if (claimStatus != kIOReturnSuccess) { - return finalizeFailure(claimStatus, - DiceRestartPhase::kPreparingDevice, - DiceRestartFailureCause::kPrepare, - DiceRestartErrorClass::kStageFailure, - false, - kIOReturnSuccess, - true, - true); + return finalizeFailure( + claimStatus, DiceRestartPhase::kPreparingDevice, DiceRestartFailureCause::kPrepare, + DiceRestartErrorClass::kStageFailure, false, kIOReturnSuccess, true, true); } session.hostDuplexClaimed = true; StoreSession(session); @@ -986,20 +929,16 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( [&](auto callback) { diceProtocol.PrepareDuplex(channels, desiredClock, std::move(callback)); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); + kSyncBridgeTimeoutMs, kIOReturnTimeout, cancel_); if (prepare.status != kIOReturnSuccess) { if (prepare.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("PreparingDevice", guid); } - return rollbackToFailure(prepare.status, - DiceRestartPhase::kPreparingDevice, + return rollbackToFailure(prepare.status, DiceRestartPhase::kPreparingDevice, DiceRestartFailureCause::kPrepare); } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kPreparingDevice, + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kPreparingDevice, DiceRestartFailureCause::kPrepare); } session.ownerClaimed = true; @@ -1007,6 +946,8 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( session.generation = prepare.value.generation; session.appliedClock = prepare.value.appliedClock; session.runtimeCaps = prepare.value.runtimeCaps; + const DuplexStreamProfile streamProfile = + DuplexStreamProfileResolver::Resolve(record, session.runtimeCaps, channels); SetSessionPhase(session, DiceRestartPhase::kPrepared); StoreSession(session); @@ -1018,10 +959,7 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( } for (uint32_t i = 0; i < channels.playbackStreamCount; ++i) { const kern_return_t reservePlaybackStatus = hostTransport_.ReservePlaybackResources( - guid, - *irmClient, - channels.PlaybackChannel(i), - kPlaybackBandwidthUnits); + guid, *irmClient, channels.PlaybackChannel(i), streamProfile.playbackBandwidthUnits); if (reservePlaybackStatus != kIOReturnSuccess) { return rollbackToFailure(reservePlaybackStatus, DiceRestartPhase::kReservingPlaybackResources, @@ -1043,10 +981,7 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( } for (uint32_t i = 0; i < channels.captureStreamCount; ++i) { const kern_return_t reserveCaptureStatus = hostTransport_.ReserveCaptureResources( - guid, - *irmClient, - channels.CaptureChannel(i), - kCaptureBandwidthUnits); + guid, *irmClient, channels.CaptureChannel(i), streamProfile.captureBandwidthUnits); if (reserveCaptureStatus != kIOReturnSuccess) { return rollbackToFailure(reserveCaptureStatus, DiceRestartPhase::kReservingCaptureResources, @@ -1062,139 +997,104 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( session.hostCaptureReserved = true; StoreSession(session); - Encoding::AudioWireFormat rxWireFormat = Encoding::AudioWireFormat::kAM824; - if (record.vendorId == DeviceProtocolFactory::kFocusriteVendorId && - record.modelId == DeviceProtocolFactory::kSPro24DspModelId && - session.runtimeCaps.hostInputPcmChannels == 8 && - session.runtimeCaps.deviceToHostAm824Slots == 9) { - rxWireFormat = Encoding::AudioWireFormat::kRawPcm24In32; - } - const uint32_t rxAm824Slots = session.runtimeCaps.deviceToHostAm824Slots; - const Encoding::AudioWireFormat wireFormat = - ResolveDicePlaybackWireFormat(record, session.runtimeCaps); - ASFW_LOG(Audio, - "DICE DUPLEX START guid=0x%016llx ir=%u it=%u inCh=%u outCh=%u inSlots=%u outSlots=%u mode=blocking rxFmt=%u txFmt=%u", - guid, - channels.deviceToHostIsoChannel, - channels.hostToDeviceIsoChannel, - session.runtimeCaps.hostInputPcmChannels, - session.runtimeCaps.hostOutputPcmChannels, - session.runtimeCaps.deviceToHostAm824Slots, - session.runtimeCaps.hostToDeviceAm824Slots, - static_cast(rxWireFormat), - static_cast(wireFormat)); + "DICE DUPLEX START guid=0x%016llx ir=%u it=%u inCh=%u outCh=%u inSlots=%u outSlots=%u " + "mode=blocking rxFmt=%u txFmt=%u", + guid, channels.deviceToHostIsoChannel, channels.hostToDeviceIsoChannel, + session.runtimeCaps.hostInputPcmChannels, session.runtimeCaps.hostOutputPcmChannels, + session.runtimeCaps.deviceToHostAm824Slots, session.runtimeCaps.hostToDeviceAm824Slots, + static_cast(streamProfile.captureWireFormat), + static_cast(streamProfile.playbackWireFormat)); // Saffire.kext allocates every local/remote isoch port before it reports // the assigned channels to DICE. Keep the expensive DMA setup on the // disabled side of GLOBAL_ENABLE as well. - // Multi-stream capture (e.g. Venice F32 = 2×16): each wire stream carries - // its own 16-ch CIP (per-stream DBS), de-interleaved into the single shared - // 32-ch input buffer at a channel offset. The master (stream 0) owns the - // clock/ZTS/replay; secondary streams write PCM only. Single-stream devices - // (captureStreamCount == 1) take the legacy path with streamChannels == 0 - // (full width) and the aggregate slot count. - const bool multiCapture = channels.captureStreamCount > 1; - const uint32_t masterCaptureSlots = - multiCapture ? session.runtimeCaps.deviceToHostStreams[0].am824Slots - : rxAm824Slots; - const uint32_t masterCaptureChannels = - multiCapture ? session.runtimeCaps.deviceToHostStreams[0].pcmChannels - : 0; - - if (abortIfTeardown("PreparingHostReceive")) { - return kIOReturnAborted; - } - const kern_return_t prepareReceiveStatus = hostTransport_.PrepareReceive( - channels.CaptureChannel(0), - hardware_, - bindingSource, - rxWireFormat, - masterCaptureSlots, - masterCaptureChannels); - if (prepareReceiveStatus != kIOReturnSuccess) { - return rollbackToFailure(prepareReceiveStatus, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); - } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); - } + // Multi-stream capture (e.g. Venice F32 = 2×16) is described by the + // profile's per-stream AM824/PCM geometry. The master owns clock/ZTS/replay; + // secondaries write their PCM slice only. A single stream retains the legacy + // full-width (streamChannels == 0) host receive path. + const DuplexCaptureStreamGeometry& masterCapture = streamProfile.captureStreams[0]; + + for (const DuplexHostDirection direction : streamProfile.startOrder.prepareOrder) { + if (direction == DuplexHostDirection::kReceive) { + if (abortIfTeardown("PreparingHostReceive")) { + return kIOReturnAborted; + } + const kern_return_t prepareReceiveStatus = + hostTransport_.PrepareReceive(channels.CaptureChannel(0), hardware_, bindingSource, + streamProfile.captureWireFormat, + masterCapture.am824Slots, masterCapture.pcmChannels); + if (prepareReceiveStatus != kIOReturnSuccess) { + return rollbackToFailure(prepareReceiveStatus, + DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } + if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, + DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } - // Prepare each secondary capture stream on its own OHCI IR context, writing - // its slice at the running channel offset. - uint32_t captureChannelOffset = masterCaptureChannels; - for (uint32_t i = 1; i < channels.captureStreamCount; ++i) { - const auto& streamInfo = session.runtimeCaps.deviceToHostStreams[i]; - if (abortIfTeardown("PreparingHostReceiveStream")) { - return kIOReturnAborted; - } - const kern_return_t status = hostTransport_.PrepareReceiveStream( - i, - channels.CaptureChannel(i), - hardware_, - bindingSource, - captureChannelOffset, - streamInfo.pcmChannels, - rxWireFormat, - streamInfo.am824Slots); - if (status != kIOReturnSuccess) { - return rollbackToFailure(status, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); - } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); + // Prepare each secondary capture stream on its own OHCI IR context, writing + // its slice at the running channel offset. + for (uint32_t i = 1; i < channels.captureStreamCount; ++i) { + const DuplexCaptureStreamGeometry& captureStream = streamProfile.captureStreams[i]; + if (abortIfTeardown("PreparingHostReceiveStream")) { + return kIOReturnAborted; + } + const kern_return_t status = hostTransport_.PrepareReceiveStream( + i, channels.CaptureChannel(i), hardware_, bindingSource, + captureStream.pcmChannelOffset, captureStream.pcmChannels, + streamProfile.captureWireFormat, captureStream.am824Slots); + if (status != kIOReturnSuccess) { + return rollbackToFailure(status, DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } + if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, + DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } + } + + continue; } - captureChannelOffset += streamInfo.pcmChannels; - } - if (abortIfTeardown("PreparingHostTransmit")) { - return kIOReturnAborted; - } - const kern_return_t prepareTransmitStatus = hostTransport_.PrepareTransmit( - channels.hostToDeviceIsoChannel, - hardware_, - ReadLocalSid(hardware_)); - if (prepareTransmitStatus != kIOReturnSuccess) { - return rollbackToFailure(prepareTransmitStatus, - DiceRestartPhase::kStartingHostTransmit, - DiceRestartFailureCause::kStartTransmit); - } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostTransmit, - DiceRestartFailureCause::kStartTransmit); - } - - // Prepare each secondary playback stream on its own host IT context. It - // transmits on the iso channel that feeds the device's matching RX stream - // (PlaybackChannel(i)); the host IT ring stamps that channel into every - // packet header. The audio engine maps the secondary's shared slab (already - // allocated by StartIO) and writes its 16-ch slice. - for (uint32_t i = 1; i < channels.playbackStreamCount; ++i) { - if (abortIfTeardown("PreparingHostTransmitStream")) { + if (abortIfTeardown("PreparingHostTransmit")) { return kIOReturnAborted; } - const kern_return_t status = hostTransport_.PrepareTransmitStream( - i, - channels.PlaybackChannel(i), - hardware_, - ReadLocalSid(hardware_)); - if (status != kIOReturnSuccess) { - return rollbackToFailure(status, - DiceRestartPhase::kStartingHostTransmit, + const kern_return_t prepareTransmitStatus = hostTransport_.PrepareTransmit( + channels.hostToDeviceIsoChannel, hardware_, ReadLocalSid(hardware_)); + if (prepareTransmitStatus != kIOReturnSuccess) { + return rollbackToFailure(prepareTransmitStatus, DiceRestartPhase::kStartingHostTransmit, DiceRestartFailureCause::kStartTransmit); } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostTransmit, + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kStartingHostTransmit, DiceRestartFailureCause::kStartTransmit); } + + // Prepare each secondary playback stream on its own host IT context. It + // transmits on the iso channel that feeds the device's matching RX stream + // (PlaybackChannel(i)); the host IT ring stamps that channel into every + // packet header. The audio engine maps the secondary's shared slab (already + // allocated by StartIO) and writes its 16-ch slice. + for (uint32_t i = 1; i < channels.playbackStreamCount; ++i) { + if (abortIfTeardown("PreparingHostTransmitStream")) { + return kIOReturnAborted; + } + const kern_return_t status = hostTransport_.PrepareTransmitStream( + i, channels.PlaybackChannel(i), hardware_, ReadLocalSid(hardware_)); + if (status != kIOReturnSuccess) { + return rollbackToFailure(status, DiceRestartPhase::kStartingHostTransmit, + DiceRestartFailureCause::kStartTransmit); + } + if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, + DiceRestartPhase::kStartingHostTransmit, + DiceRestartFailureCause::kStartTransmit); + } + } } SetSessionPhase(session, DiceRestartPhase::kWaitingGlobalClock); @@ -1205,13 +1105,11 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( const IOReturn clockLockStatus = WaitForStableGlobalClock(guid, diceProtocol, topologyGeneration, desiredClock); if (clockLockStatus != kIOReturnSuccess) { - return rollbackToFailure(clockLockStatus, - DiceRestartPhase::kWaitingGlobalClock, + return rollbackToFailure(clockLockStatus, DiceRestartPhase::kWaitingGlobalClock, DiceRestartFailureCause::kGlobalClockLock); } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kWaitingGlobalClock, + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kWaitingGlobalClock, DiceRestartFailureCause::kGlobalClockLock); } @@ -1219,29 +1117,25 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( return kIOReturnAborted; } const auto programRx = WaitForAsyncResult( - [&](auto callback) { diceProtocol.ProgramRx(std::move(callback)); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); + [&](auto callback) { diceProtocol.ProgramRx(std::move(callback)); }, kSyncBridgeTimeoutMs, + kIOReturnTimeout, cancel_); if (programRx.status != kIOReturnSuccess) { if (programRx.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("ProgrammingDeviceRx", guid); } - return rollbackToFailure(programRx.status, - DiceRestartPhase::kProgrammingDeviceRx, + return rollbackToFailure(programRx.status, DiceRestartPhase::kProgrammingDeviceRx, DiceRestartFailureCause::kProgramRx); } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kProgrammingDeviceRx, + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kProgrammingDeviceRx, DiceRestartFailureCause::kProgramRx); } session.generation = programRx.value.generation; SetSessionPhase(session, DiceRestartPhase::kProgrammingDeviceRx); session.deviceRxProgrammed = true; session.runtimeCaps = programRx.value.runtimeCaps.hostInputPcmChannels != 0 - ? programRx.value.runtimeCaps - : session.runtimeCaps; + ? programRx.value.runtimeCaps + : session.runtimeCaps; SetSessionPhase(session, DiceRestartPhase::kDeviceRxProgrammed); StoreSession(session); @@ -1250,120 +1144,121 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( } const auto programTx = WaitForAsyncResult( [&](auto callback) { diceProtocol.ProgramTxAndEnableDuplex(std::move(callback)); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); + kSyncBridgeTimeoutMs, kIOReturnTimeout, cancel_); if (programTx.status != kIOReturnSuccess) { if (programTx.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("ProgrammingDeviceTx", guid); } - return rollbackToFailure(programTx.status, - DiceRestartPhase::kProgrammingDeviceTx, + return rollbackToFailure(programTx.status, DiceRestartPhase::kProgrammingDeviceTx, DiceRestartFailureCause::kProgramTx); } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kProgrammingDeviceTx, + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kProgrammingDeviceTx, DiceRestartFailureCause::kProgramTx); } session.generation = programTx.value.generation; SetSessionPhase(session, DiceRestartPhase::kProgrammingDeviceTx); session.deviceTxArmed = true; session.runtimeCaps = programTx.value.runtimeCaps.hostInputPcmChannels != 0 - ? programTx.value.runtimeCaps - : session.runtimeCaps; + ? programTx.value.runtimeCaps + : session.runtimeCaps; SetSessionPhase(session, DiceRestartPhase::kDeviceTxArmed); StoreSession(session); - // Saffire::StartStreams waits 2 ms after GLOBAL_ENABLE before starting + // The DICE recipe waits after GLOBAL_ENABLE before starting // the already-allocated host isoch channels. - IOSleep(2); + IOSleep(streamProfile.startOrder.postDeviceEnableDelayMs); if (abortIfTeardown("PostGlobalEnableDelay")) { return kIOReturnAborted; } - // RX first: the device needs to be receiving before it can lock its TX - // clock. Starting IT before IR causes the DICE PLL to see TX without - // a valid RX reference, leading to timing instability. - if (abortIfTeardown("StartingHostReceive")) { - return kIOReturnAborted; - } - const kern_return_t startReceiveStatus = - hostTransport_.StartPreparedReceive(); - if (startReceiveStatus != kIOReturnSuccess) { - return rollbackToFailure(startReceiveStatus, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); - } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostReceive, - DiceRestartFailureCause::kStartReceive); - } - SetSessionPhase(session, DiceRestartPhase::kStartingHostReceive); - session.hostReceiveStarted = true; - StoreSession(session); + // The profile's DICE recipe starts RX first: the device needs to be + // receiving before it can lock its TX clock. Starting IT before IR causes + // the DICE PLL to see TX without a valid RX reference, leading to timing + // instability. + for (const DuplexHostDirection direction : streamProfile.startOrder.startOrder) { + if (direction == DuplexHostDirection::kReceive) { + if (abortIfTeardown("StartingHostReceive")) { + return kIOReturnAborted; + } + const kern_return_t startReceiveStatus = hostTransport_.StartPreparedReceive(); + if (startReceiveStatus != kIOReturnSuccess) { + return rollbackToFailure(startReceiveStatus, DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } + if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, + DiceRestartPhase::kStartingHostReceive, + DiceRestartFailureCause::kStartReceive); + } + SetSessionPhase(session, DiceRestartPhase::kStartingHostReceive); + session.hostReceiveStarted = true; + StoreSession(session); - if (abortIfTeardown("StartingHostTransmit")) { - return kIOReturnAborted; - } - const kern_return_t startTransmitStatus = - hostTransport_.StartPreparedTransmit(); - if (startTransmitStatus != kIOReturnSuccess) { - return rollbackToFailure(startTransmitStatus, - DiceRestartPhase::kStartingHostTransmit, - DiceRestartFailureCause::kStartTransmit); - } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kStartingHostTransmit, - DiceRestartFailureCause::kStartTransmit); - } - SetSessionPhase(session, DiceRestartPhase::kStartingHostTransmit); - session.hostTransmitStarted = true; - StoreSession(session); + continue; + } - if (abortIfTeardown("ConfirmingDeviceStart")) { - return kIOReturnAborted; - } - const auto confirm = WaitForAsyncResult( - [&](auto callback) { diceProtocol.ConfirmDuplexStart(std::move(callback)); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); - if (confirm.status != kIOReturnSuccess) { - if (confirm.status == kIOReturnAborted && TeardownRequested()) { - RecordTeardownAbort("ConfirmingDeviceStart", guid); + if (abortIfTeardown("StartingHostTransmit")) { + return kIOReturnAborted; + } + const kern_return_t startTransmitStatus = hostTransport_.StartPreparedTransmit(); + if (startTransmitStatus != kIOReturnSuccess) { + return rollbackToFailure(startTransmitStatus, DiceRestartPhase::kStartingHostTransmit, + DiceRestartFailureCause::kStartTransmit); + } + if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kStartingHostTransmit, + DiceRestartFailureCause::kStartTransmit); } - return rollbackToFailure(confirm.status, - DiceRestartPhase::kConfirmingDeviceStart, - DiceRestartFailureCause::kConfirmStart); + SetSessionPhase(session, DiceRestartPhase::kStartingHostTransmit); + session.hostTransmitStarted = true; + StoreSession(session); } - if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { - return rollbackToInvalidation(kIOReturnAborted, - DiceRestartPhase::kConfirmingDeviceStart, - DiceRestartFailureCause::kConfirmStart); - } - - SetSessionPhase(session, DiceRestartPhase::kConfirmingDeviceStart); - SetSessionPhase(session, DiceRestartPhase::kRunning); - SetSessionState(session, DiceRestartState::kRunning, "confirmed_running"); - session.generation = confirm.value.generation; - session.deviceRunning = true; - session.appliedClock = confirm.value.appliedClock; - session.runtimeCaps = confirm.value.runtimeCaps; - session.terminalError = kIOReturnSuccess; - ClearFailureSnapshot(session); - StoreSession(session); - LogTerminal(session); - return kIOReturnSuccess; + + if (abortIfTeardown("ConfirmingDeviceStart")) { + return kIOReturnAborted; +} +const auto confirm = WaitForAsyncResult( + [&](auto callback) { diceProtocol.ConfirmDuplexStart(std::move(callback)); }, + kSyncBridgeTimeoutMs, kIOReturnTimeout, cancel_); +if (confirm.status != kIOReturnSuccess) { + if (confirm.status == kIOReturnAborted && TeardownRequested()) { + RecordTeardownAbort("ConfirmingDeviceStart", guid); + } + return rollbackToFailure(confirm.status, DiceRestartPhase::kConfirmingDeviceStart, + DiceRestartFailureCause::kConfirmStart); +} +if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { + return rollbackToInvalidation(kIOReturnAborted, DiceRestartPhase::kConfirmingDeviceStart, + DiceRestartFailureCause::kConfirmStart); } -IOReturn DiceDuplexRestartCoordinator::WaitForStableGlobalClock( - uint64_t guid, - DICE::IDICEDuplexProtocol& diceProtocol, - const FW::Generation topologyGeneration, +SetSessionPhase(session, DiceRestartPhase::kConfirmingDeviceStart); +SetSessionPhase(session, DiceRestartPhase::kRunning); +SetSessionState(session, DiceRestartState::kRunning, "confirmed_running"); +session.generation = confirm.value.generation; +session.deviceRunning = true; +session.appliedClock = confirm.value.appliedClock; +session.runtimeCaps = confirm.value.runtimeCaps; +session.terminalError = kIOReturnSuccess; +ClearFailureSnapshot(session); +StoreSession(session); +LogTerminal(session); +return kIOReturnSuccess; +} + +IOReturn DuplexStartTransaction::WaitForStableGlobalClock( + uint64_t guid, DICE::IDICEDuplexProtocol& diceProtocol, const FW::Generation topologyGeneration, const DiceDesiredClockConfig& desiredClock) noexcept { + const std::atomic* const cancel_ = dependencies_.cancel; + const uint32_t kGlobalClockLockTimeoutMs = dependencies_.globalClockLockTimeoutMs; + const uint32_t kGlobalClockLockPollMs = dependencies_.globalClockLockPollMs; + const uint32_t kGlobalClockStableReads = dependencies_.globalClockStableReads; + auto TeardownRequested = [this]() noexcept { return this->TeardownRequested(); }; + auto RecordTeardownAbort = [this](const char* stage, uint64_t abortGuid) noexcept { + this->RecordTeardownAbort(stage, abortGuid); + }; + uint32_t consecutiveLockedReads = 0; const uint64_t startMs = UptimeMilliseconds(); const uint64_t deadlineMs = startMs + kGlobalClockLockTimeoutMs; @@ -1378,26 +1273,21 @@ IOReturn DiceDuplexRestartCoordinator::WaitForStableGlobalClock( const uint32_t remainingMs = static_cast(deadlineMs - nowMs); const auto health = WaitForAsyncResult( [&](auto callback) { diceProtocol.ReadDuplexHealth(std::move(callback)); }, - std::max(remainingMs, 1U), - kIOReturnTimeout, - cancel_); + std::max(remainingMs, 1U), kIOReturnTimeout, cancel_); if (health.status != kIOReturnSuccess) { if (health.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("WaitingGlobalClock", guid); return health.status; } - ASFW_LOG_ERROR( - Audio, - "DICE global clock health read failed before isoch start kr=0x%x", - health.status); + ASFW_LOG_ERROR(Audio, "DICE global clock health read failed before isoch start kr=0x%x", + health.status); return health.status; } if (health.value.generation != topologyGeneration) { ASFW_LOG_ERROR( Audio, "DICE global clock generation changed before isoch start expected=%u actual=%u", - GenerationValue(topologyGeneration), - GenerationValue(health.value.generation)); + GenerationValue(topologyGeneration), GenerationValue(health.value.generation)); return kIOReturnOffline; } @@ -1409,12 +1299,9 @@ IOReturn DiceDuplexRestartCoordinator::WaitForStableGlobalClock( consecutiveLockedReads = lockedAtTarget ? consecutiveLockedReads + 1 : 0; if (consecutiveLockedReads >= kGlobalClockStableReads) { - ASFW_LOG( - Audio, - "DICE global clock stable before isoch start rate=%u status=0x%08x reads=%u", - desiredClock.sampleRateHz, - health.value.status, - consecutiveLockedReads); + ASFW_LOG(Audio, + "DICE global clock stable before isoch start rate=%u status=0x%08x reads=%u", + desiredClock.sampleRateHz, health.value.status, consecutiveLockedReads); return kIOReturnSuccess; } @@ -1426,23 +1313,29 @@ IOReturn DiceDuplexRestartCoordinator::WaitForStableGlobalClock( RecordTeardownAbort("WaitingGlobalClock", guid); return kIOReturnAborted; } - IOSleep(std::min( - kGlobalClockLockPollMs, deadlineMs - afterReadMs)); + IOSleep(std::min(kGlobalClockLockPollMs, deadlineMs - afterReadMs)); } - ASFW_LOG_ERROR( - Audio, - "DICE global clock failed to stabilize before isoch start rate=%u timeoutMs=%u", - desiredClock.sampleRateHz, - kGlobalClockLockTimeoutMs); + ASFW_LOG_ERROR(Audio, + "DICE global clock failed to stabilize before isoch start rate=%u timeoutMs=%u", + desiredClock.sampleRateHz, kGlobalClockLockTimeoutMs); return kIOReturnTimeout; } -IOReturn DiceDuplexRestartCoordinator::RunDuplexStop( - uint64_t guid, - Discovery::DeviceRecord& record, - DICE::IDICEDuplexProtocol& diceProtocol, - DiceRestartSession& session) noexcept { +IOReturn DuplexStartTransaction::Stop(const StopRequest& request) noexcept { + const uint64_t guid = request.guid; + Discovery::DeviceRecord& record = request.record; + DICE::IDICEDuplexProtocol& diceProtocol = request.deviceControl; + DiceRestartSession& session = request.session; + auto& hostTransport_ = dependencies_.hostTransport; + auto TeardownRequested = [this]() noexcept { return this->TeardownRequested(); }; + auto RecordTeardownAbort = [this](const char* stage, uint64_t abortGuid) noexcept { + this->RecordTeardownAbort(stage, abortGuid); + }; + auto StoreSession = [this](const DiceRestartSession& updatedSession) noexcept { + dependencies_.sessionStore.StoreSession(updatedSession); + }; + (void)record; if (TeardownRequested()) { @@ -1465,7 +1358,8 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStop( RecordTeardownAbort("DeviceStop", guid); return kIOReturnAborted; } - if (deviceStatus != kIOReturnSuccess && deviceStatus != kIOReturnUnsupported && result == kIOReturnSuccess) { + if (deviceStatus != kIOReturnSuccess && deviceStatus != kIOReturnUnsupported && + result == kIOReturnSuccess) { result = deviceStatus; } @@ -1475,17 +1369,9 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStop( ApplyTerminalPhase(session, DiceRestartPhase::kIdle, "stop_complete"); } else { session.terminalError = result; - RecordIssue(session, - session.lastFailure, - DiceRestartPhase::kStopping, - DiceRestartErrorClass::kStageFailure, - DiceRestartFailureCause::kStop, - result, - IsRetryableStatus(result), - false, - kIOReturnSuccess, - true, - true); + RecordIssue(session, session.lastFailure, DiceRestartPhase::kStopping, + DiceRestartErrorClass::kStageFailure, DiceRestartFailureCause::kStop, result, + IsRetryableStatus(result), false, kIOReturnSuccess, true, true); ApplyTerminalPhase(session, DiceRestartPhase::kFailed, "stop_failed"); } StoreSession(session); @@ -1493,13 +1379,31 @@ IOReturn DiceDuplexRestartCoordinator::RunDuplexStop( return result; } -IOReturn DiceDuplexRestartCoordinator::RunIdleClockApply( - uint64_t guid, - DICE::IDICEDuplexProtocol& diceProtocol, - DiceRestartSession& session, - FW::Generation topologyGeneration, - const DiceDesiredClockConfig& desiredClock, - DiceRestartReason reason) noexcept { +IOReturn DuplexStartTransaction::ApplyIdleClock(const IdleClockApplyRequest& request) noexcept { + const uint64_t guid = request.guid; + DICE::IDICEDuplexProtocol& diceProtocol = request.deviceControl; + DiceRestartSession& session = request.session; + const FW::Generation topologyGeneration = request.topologyGeneration; + const DiceDesiredClockConfig& desiredClock = request.desiredClock; + const DiceRestartReason reason = request.reason; + const std::atomic* const cancel_ = dependencies_.cancel; + const uint32_t kSyncBridgeTimeoutMs = dependencies_.syncBridgeTimeoutMs; + auto TeardownRequested = [this]() noexcept { return this->TeardownRequested(); }; + auto RecordTeardownAbort = [this](const char* stage, uint64_t abortGuid) noexcept { + this->RecordTeardownAbort(stage, abortGuid); + }; + auto AllocateRestartId = [this]() noexcept { return dependencies_.sessionStore.AllocateRestartId(); }; + auto StoreSession = [this](const DiceRestartSession& updatedSession) noexcept { + dependencies_.sessionStore.StoreSession(updatedSession); + }; + auto IsStopRequested = [this](uint64_t requestGuid) noexcept { + return this->IsStopRequested(requestGuid); + }; + auto IsRestartEpochCurrent = [this](uint64_t requestGuid, uint64_t restartId, + FW::Generation generation) noexcept { + return this->IsRestartEpochCurrent(requestGuid, restartId, generation); + }; + if (TeardownRequested()) { RecordTeardownAbort("IdleClockApply", guid); return kIOReturnAborted; @@ -1520,24 +1424,15 @@ IOReturn DiceDuplexRestartCoordinator::RunIdleClockApply( const auto apply = WaitForAsyncResult( [&](auto callback) { diceProtocol.ApplyClockConfig(desiredClock, std::move(callback)); }, - kSyncBridgeTimeoutMs, - kIOReturnTimeout, - cancel_); + kSyncBridgeTimeoutMs, kIOReturnTimeout, cancel_); if (apply.status != kIOReturnSuccess) { if (apply.status == kIOReturnAborted && TeardownRequested()) { RecordTeardownAbort("IdleClockApply", guid); } session.terminalError = apply.status; - RecordIssue(session, - session.lastFailure, - DiceRestartPhase::kPreparingDevice, - DiceRestartErrorClass::kStageFailure, - DiceRestartFailureCause::kIdleClockApply, - apply.status, - IsRetryableStatus(apply.status), - false, - kIOReturnSuccess, - true, + RecordIssue(session, session.lastFailure, DiceRestartPhase::kPreparingDevice, + DiceRestartErrorClass::kStageFailure, DiceRestartFailureCause::kIdleClockApply, + apply.status, IsRetryableStatus(apply.status), false, kIOReturnSuccess, true, true); ApplyTerminalPhase(session, DiceRestartPhase::kFailed, "idle_apply_failed"); StoreSession(session); @@ -1546,18 +1441,11 @@ IOReturn DiceDuplexRestartCoordinator::RunIdleClockApply( } if (!IsRestartEpochCurrent(guid, restartId, topologyGeneration)) { session.terminalError = kIOReturnSuccess; - RecordIssue(session, - session.lastInvalidation, - DiceRestartPhase::kPreparingDevice, + RecordIssue(session, session.lastInvalidation, DiceRestartPhase::kPreparingDevice, IsStopRequested(guid) ? DiceRestartErrorClass::kStopIntent : DiceRestartErrorClass::kEpochInvalidated, - DiceRestartFailureCause::kIdleClockApply, - kIOReturnAborted, - true, - false, - kIOReturnSuccess, - true, - true); + DiceRestartFailureCause::kIdleClockApply, kIOReturnAborted, true, false, + kIOReturnSuccess, true, true); ClearFailureSnapshot(session); ApplyTerminalPhase(session, DiceRestartPhase::kIdle, "idle_apply_invalidated"); StoreSession(session); @@ -1577,9 +1465,45 @@ IOReturn DiceDuplexRestartCoordinator::RunIdleClockApply( return kIOReturnSuccess; } +IOReturn DiceDuplexRestartCoordinator::RunDuplexStart( + uint64_t guid, Discovery::DeviceRecord& record, DICE::IDICEDuplexProtocol& diceProtocol, + DiceRestartSession& session, const DiceDesiredClockConfig& desiredClock, + DiceRestartReason reason) noexcept { + DuplexStartTransaction transaction{DuplexStartTransaction::Dependencies{ + registry_, runtime_, hostTransport_, hardware_, cancel_, bindingSourceProvider_, gate_, store_, + teardownAbortCount_, kSyncBridgeTimeoutMs, kGlobalClockLockTimeoutMs, + kGlobalClockLockPollMs, kGlobalClockStableReads}}; + return transaction.Run( + DuplexStartTransaction::StartRequest{guid, record, diceProtocol, session, desiredClock, + reason}); +} + +IOReturn DiceDuplexRestartCoordinator::RunDuplexStop( + uint64_t guid, Discovery::DeviceRecord& record, DICE::IDICEDuplexProtocol& diceProtocol, + DiceRestartSession& session) noexcept { + DuplexStartTransaction transaction{DuplexStartTransaction::Dependencies{ + registry_, runtime_, hostTransport_, hardware_, cancel_, bindingSourceProvider_, gate_, store_, + teardownAbortCount_, kSyncBridgeTimeoutMs, kGlobalClockLockTimeoutMs, + kGlobalClockLockPollMs, kGlobalClockStableReads}}; + return transaction.Stop( + DuplexStartTransaction::StopRequest{guid, record, diceProtocol, session}); +} + +IOReturn DiceDuplexRestartCoordinator::RunIdleClockApply( + uint64_t guid, DICE::IDICEDuplexProtocol& diceProtocol, DiceRestartSession& session, + FW::Generation topologyGeneration, const DiceDesiredClockConfig& desiredClock, + DiceRestartReason reason) noexcept { + DuplexStartTransaction transaction{DuplexStartTransaction::Dependencies{ + registry_, runtime_, hostTransport_, hardware_, cancel_, bindingSourceProvider_, gate_, store_, + teardownAbortCount_, kSyncBridgeTimeoutMs, kGlobalClockLockTimeoutMs, + kGlobalClockLockPollMs, kGlobalClockStableReads}}; + return transaction.ApplyIdleClock( + DuplexStartTransaction::IdleClockApplyRequest{guid, diceProtocol, session, + topologyGeneration, desiredClock, reason}); +} + Discovery::DeviceRecord* DiceDuplexRestartCoordinator::RequireDiceRecord( - uint64_t guid, - DICE::IDICEDuplexProtocol*& outDiceProtocol, + uint64_t guid, DICE::IDICEDuplexProtocol*& outDiceProtocol, std::shared_ptr& outHold) noexcept { outDiceProtocol = nullptr; outHold.reset(); @@ -1603,7 +1527,8 @@ Discovery::DeviceRecord* DiceDuplexRestartCoordinator::RequireDiceRecord( return record; } -ASFW::Audio::Runtime::IDirectAudioBindingSource* DiceDuplexRestartCoordinator::GetDirectAudioBindingSource(uint64_t guid) const noexcept { +ASFW::Audio::Runtime::IDirectAudioBindingSource* +DiceDuplexRestartCoordinator::GetDirectAudioBindingSource(uint64_t guid) const noexcept { if (!bindingSourceProvider_) { return nullptr; } @@ -1617,9 +1542,7 @@ bool DiceDuplexRestartCoordinator::TryAcquireGuid(uint64_t guid) noexcept { return gate_.Acquire(guid); } -void DiceDuplexRestartCoordinator::ReleaseGuid(uint64_t guid) noexcept { - gate_.Release(guid); -} +void DiceDuplexRestartCoordinator::ReleaseGuid(uint64_t guid) noexcept { gate_.Release(guid); } void DiceDuplexRestartCoordinator::RequestStopIntent(uint64_t guid) noexcept { gate_.RequestStop(guid); @@ -1637,9 +1560,8 @@ uint64_t DiceDuplexRestartCoordinator::AllocateRestartId() noexcept { return store_.AllocateRestartId(); } -bool DiceDuplexRestartCoordinator::IsRestartEpochCurrent(uint64_t guid, - uint64_t restartId, - FW::Generation topologyGeneration) const noexcept { +bool DiceDuplexRestartCoordinator::IsRestartEpochCurrent( + uint64_t guid, uint64_t restartId, FW::Generation topologyGeneration) const noexcept { if (guid == 0 || restartId == 0) { return false; } @@ -1653,9 +1575,8 @@ bool DiceDuplexRestartCoordinator::IsRestartEpochCurrent(uint64_t guid, IOLockLock(lock_); const auto* sessionPtr = store_.FindSessionLocked(guid); - const bool sessionMatches = (sessionPtr != nullptr) && - sessionPtr->restartId == restartId && - sessionPtr->topologyGeneration == topologyGeneration; + const bool sessionMatches = (sessionPtr != nullptr) && sessionPtr->restartId == restartId && + sessionPtr->topologyGeneration == topologyGeneration; IOLockUnlock(lock_); if (!sessionMatches) { return false; @@ -1671,123 +1592,33 @@ bool DiceDuplexRestartCoordinator::IsRestartEpochCurrent(uint64_t guid, bool DiceDuplexRestartCoordinator::TryConsumePendingClockRequest(uint64_t guid, PendingClockRequest& outRequest) noexcept { - if (!lock_) { - return false; - } - - IOLockLock(lock_); - const auto it = pendingClockRequests_.find(guid); - if (it == pendingClockRequests_.end()) { - IOLockUnlock(lock_); - return false; - } - - outRequest = it->second; - pendingClockRequests_.erase(it); - auto* sessionPtr = store_.FindSessionLocked(guid); - if (sessionPtr != nullptr) { - sessionPtr->pendingClock = {}; - sessionPtr->pendingReason = DiceRestartReason::kInitialStart; - sessionPtr->hasPendingClockRequest = false; - } - IOLockUnlock(lock_); - return true; + return clockRequests_.TryConsumePending(guid, outRequest); } bool DiceDuplexRestartCoordinator::TryTakeCompletedClockRequest( uint64_t guid, uint64_t token, DiceClockRequestCompletion& outCompletion) noexcept { - if (!lock_) { - return false; - } - - IOLockLock(lock_); - auto storeIt = completedClockRequests_.find(guid); - if (storeIt == completedClockRequests_.end()) { - IOLockUnlock(lock_); - return false; - } - - auto completionIt = storeIt->second.byToken.find(token); - if (completionIt == storeIt->second.byToken.end()) { - IOLockUnlock(lock_); - return false; - } - - outCompletion = completionIt->second; - storeIt->second.byToken.erase(completionIt); - auto& order = storeIt->second.insertionOrder; - order.erase(std::remove(order.begin(), order.end(), token), order.end()); - if (storeIt->second.byToken.empty() && order.empty()) { - completedClockRequests_.erase(storeIt); - } - IOLockUnlock(lock_); - return true; + return clockRequests_.TryTakeCompleted(guid, token, outCompletion); } void DiceDuplexRestartCoordinator::CompleteClockRequest(const DiceClockRequestCompletion& completion, uint64_t guid) noexcept { - if (!lock_) { - return; - } - - IOLockLock(lock_); - auto& store = completedClockRequests_[guid]; - if (store.byToken.find(completion.token) == store.byToken.end()) { - store.insertionOrder.push_back(completion.token); - } - store.byToken[completion.token] = completion; - while (store.insertionOrder.size() > kMaxCompletedClockRequestsPerGuid) { - const uint64_t evictedToken = store.insertionOrder.front(); - store.insertionOrder.pop_front(); - store.byToken.erase(evictedToken); - } - - auto* sessionPtr = store_.FindSessionLocked(guid); - if (sessionPtr != nullptr) { - sessionPtr->lastClockCompletion = completion; - } - IOLockUnlock(lock_); - - ASFW_LOG_V2(DICE, - "[FSM] clock token=%llu outcome=%{public}s status=0x%08x guid=0x%llx restartId=%llu gen=%u", - completion.token, - ToString(completion.outcome), - static_cast(completion.status), - guid, - completion.restartId, - GenerationValue(completion.generation)); + clockRequests_.Complete(completion, guid); } void DiceDuplexRestartCoordinator::FailPendingClockRequest(uint64_t guid, DiceClockRequestOutcome outcome, IOReturn status) noexcept { - PendingClockRequest request{}; - if (TryConsumePendingClockRequest(guid, request)) { - const DiceRestartSession session = LoadSession(guid); - CompleteClockRequest( - DiceClockRequestCompletion{ - .token = request.token, - .desiredClock = request.desiredClock, - .reason = request.reason, - .outcome = outcome, - .status = status, - .restartId = session.restartId, - .generation = session.topologyGeneration, - }, - guid); - } + clockRequests_.FailPending(guid, outcome, status); } -void DiceDuplexRestartCoordinator::RecordTeardownAbort(const char* stage, - uint64_t guid) noexcept { +void DiceDuplexRestartCoordinator::RecordTeardownAbort(const char* stage, uint64_t guid) noexcept { teardownAbortCount_.fetch_add(1, std::memory_order_acq_rel); ASFW_LOG(Audio, - "DiceDuplexRestartCoordinator: recovery aborted by teardown stage=%{public}s GUID=%llx kr=0x%x", - stage ? stage : "unknown", - guid, - kIOReturnAborted); + "DiceDuplexRestartCoordinator: recovery aborted by teardown stage=%{public}s " + "GUID=%llx kr=0x%x", + stage ? stage : "unknown", guid, kIOReturnAborted); } // FW-69b: session persistence + the restart-id allocator now live in RestartSessionStore diff --git a/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.hpp b/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.hpp index a2b617d3..c018de71 100644 --- a/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.hpp +++ b/ASFWDriver/Audio/Protocols/Backends/DiceDuplexRestartCoordinator.hpp @@ -5,6 +5,7 @@ #pragma once +#include "ClockRequestBroker.hpp" #include "DiceHostTransport.hpp" #include "DuplexOperationGate.hpp" #include "RestartSessionStore.hpp" @@ -15,12 +16,9 @@ #include "../DICE/Core/DICERestartSession.hpp" #include -#include #include #include #include -#include -#include namespace ASFW::Audio { @@ -63,16 +61,7 @@ class DiceDuplexRestartCoordinator final { } private: - struct PendingClockRequest { - DICE::DiceDesiredClockConfig desiredClock{}; - DICE::DiceRestartReason reason{DICE::DiceRestartReason::kManualReconfigure}; - uint64_t token{0}; - }; - - struct ClockCompletionStore { - std::unordered_map byToken{}; - std::deque insertionOrder{}; - }; + using PendingClockRequest = Backends::ClockRequestBroker::PendingClockRequest; [[nodiscard]] IOReturn RunStartStreaming(uint64_t guid) noexcept; [[nodiscard]] IOReturn RunStopStreaming(uint64_t guid) noexcept; @@ -97,12 +86,6 @@ class DiceDuplexRestartCoordinator final { FW::Generation topologyGeneration, const DICE::DiceDesiredClockConfig& desiredClock, DICE::DiceRestartReason reason) noexcept; - [[nodiscard]] IOReturn WaitForStableGlobalClock( - uint64_t guid, - DICE::IDICEDuplexProtocol& diceProtocol, - FW::Generation topologyGeneration, - const DICE::DiceDesiredClockConfig& desiredClock) noexcept; - // Resolves the record + its DICE duplex surface for `guid`. `outHold` receives a // shared_ptr to the owning IDeviceProtocol; callers must keep it alive for as long as // they use `outDiceProtocol` (it is a view into the held protocol). @@ -155,9 +138,9 @@ class DiceDuplexRestartCoordinator final { // FW-69b: per-GUID session persistence + restart-id allocator. Borrows &lock_ (see // RestartSessionStore) so its critical sections share the coordinator's single lock. Backends::RestartSessionStore store_{&lock_}; - std::unordered_map pendingClockRequests_{}; - std::unordered_map completedClockRequests_{}; - uint64_t nextClockToken_{1}; + // FW-67: clock token + pending/completion delivery. Borrows &lock_ and shares the store so + // pending/completion state remains atomic with the restart-session snapshot. + Backends::ClockRequestBroker clockRequests_{&lock_, store_}; std::atomic teardownAbortCount_{0}; static constexpr uint32_t kSyncBridgeTimeoutMs = 12000; @@ -165,7 +148,6 @@ class DiceDuplexRestartCoordinator final { static constexpr uint32_t kGlobalClockLockTimeoutMs = 1000; static constexpr uint32_t kGlobalClockLockPollMs = 10; static constexpr uint32_t kGlobalClockStableReads = 3; - static constexpr size_t kMaxCompletedClockRequestsPerGuid = 32; }; } // namespace ASFW::Audio diff --git a/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp b/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp new file mode 100644 index 00000000..82b6385d --- /dev/null +++ b/ASFWDriver/Audio/Protocols/Backends/DuplexStreamProfile.hpp @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 ASFireWire Project +// +// DuplexStreamProfile.hpp - Resolved stream geometry and host-start recipe for duplex audio + +#pragma once + +#include "../../../DeviceProfiles/Audio/AudioDeviceIds.hpp" +#include "../../../Discovery/DiscoveryTypes.hpp" +#include "../../Wire/AMDTP/AmdtpTypes.hpp" +#include "../AudioTypes.hpp" +#include "../IDeviceProtocol.hpp" + +#include +#include + +namespace ASFW::Audio::Backends { + +enum class DuplexHostDirection : uint8_t { + kReceive, + kTransmit, +}; + +// The device enable happens before this recipe's startOrder. DICE requires all +// host contexts to be prepared while GLOBAL_ENABLE is clear, then starts IR +// before IT after a short post-enable settling interval. +struct DuplexStartOrderRecipe { + // DICE programs device RX/TX before starting the corresponding host context. + // FW-72 can select the inverse interleave for a protocol that requires it. + bool startReceiveBeforeDeviceRx{false}; + bool startTransmitBeforeDeviceTx{false}; + std::array prepareOrder{ + DuplexHostDirection::kReceive, + DuplexHostDirection::kTransmit, + }; + std::array startOrder{ + DuplexHostDirection::kReceive, + DuplexHostDirection::kTransmit, + }; + uint32_t postDeviceEnableDelayMs{2}; +}; + +// Host receive geometry for one DICE TX stream. `pcmChannels == 0` preserves +// the legacy single-stream full-width receive path. +struct DuplexCaptureStreamGeometry { + uint8_t isoChannel{AudioStreamWireInfo::kInvalidIsoChannel}; + uint32_t pcmChannelOffset{0}; + uint32_t pcmChannels{0}; + uint32_t am824Slots{0}; +}; + +// Retained even though the current host IT seam derives its own packet geometry: +// the resolver remains the one place that owns both directions' stream facts. +struct DuplexPlaybackStreamGeometry { + uint8_t isoChannel{AudioStreamWireInfo::kInvalidIsoChannel}; + uint32_t pcmChannels{0}; + uint32_t am824Slots{0}; +}; + +struct DuplexStreamProfile { + AudioDuplexChannels channels{}; + AudioStreamRuntimeCaps runtimeCaps{}; + std::array captureStreams{}; + std::array playbackStreams{}; + Encoding::AudioWireFormat captureWireFormat{Encoding::AudioWireFormat::kAM824}; + Encoding::AudioWireFormat playbackWireFormat{Encoding::AudioWireFormat::kAM824}; + uint32_t playbackBandwidthUnits{320}; + uint32_t captureBandwidthUnits{576}; + DuplexStartOrderRecipe startOrder{}; +}; + +// The coordinator deliberately delegates all device identity checks and stream +// geometry policy to this resolver. It owns DICE's defaults, quirks, AM824 +// geometry, resource costs, and host-start ordering as immutable profile data. +class DuplexStreamProfileResolver final { + public: + [[nodiscard]] static DuplexStreamProfile Resolve(const Discovery::DeviceRecord& record, + const IDeviceProtocol* protocol) noexcept { + AudioStreamRuntimeCaps caps{}; + const bool haveCaps = protocol != nullptr && protocol->GetRuntimeAudioStreamCaps(caps); + return Resolve(record, haveCaps ? caps : AudioStreamRuntimeCaps{}); + } + + [[nodiscard]] static DuplexStreamProfile Resolve(const Discovery::DeviceRecord& record, + const AudioStreamRuntimeCaps& caps) noexcept { + return Build(record, caps, ResolveChannels(record, caps)); + } + + // Device prepare can refresh stream caps after channels have already been + // assigned and written into the pending restart session. Retain that channel + // assignment while resolving the refreshed wire geometry. + [[nodiscard]] static DuplexStreamProfile + Resolve(const Discovery::DeviceRecord& record, const AudioStreamRuntimeCaps& caps, + const AudioDuplexChannels& assignedChannels) noexcept { + return Build(record, caps, assignedChannels); + } + + private: + static constexpr uint8_t kDefaultCaptureIsoChannel = 1; + static constexpr uint8_t kDefaultPlaybackIsoChannel = 0; + + [[nodiscard]] static constexpr bool IsValidIsoChannel(uint8_t channel) noexcept { + return channel <= 0x3F; + } + + [[nodiscard]] static constexpr uint32_t ClampStreamCount(uint32_t count) noexcept { + if (count == 0) { + return 1; + } + return count > kMaxAudioStreamsPerDirection ? kMaxAudioStreamsPerDirection : count; + } + + [[nodiscard]] static constexpr bool + HasAlesisCaptureStreamQuirk(const Discovery::DeviceRecord& record) noexcept { + // FFADO's DICE discovery clamps these Alesis model IDs because they + // advertise two RX streams although only one exists. Behavioral + // cross-validation: libffado-2.5.0/src/dice/dice_avdevice.cpp:1682-1695. + return record.vendorId == DeviceProfiles::Audio::kAlesisVendorId && + (record.modelId == 0x000000 || record.modelId == 0x000001); + } + + [[nodiscard]] static constexpr bool + IsSPro24Dsp(const Discovery::DeviceRecord& record) noexcept { + return record.vendorId == DeviceProfiles::Audio::kFocusriteVendorId && + record.modelId == DeviceProfiles::Audio::kSPro24DspModelId; + } + + [[nodiscard]] static AudioDuplexChannels + ResolveChannels(const Discovery::DeviceRecord& record, + const AudioStreamRuntimeCaps& caps) noexcept { + AudioDuplexChannels channels{ + .deviceToHostIsoChannel = kDefaultCaptureIsoChannel, + .hostToDeviceIsoChannel = kDefaultPlaybackIsoChannel, + }; + + channels.captureStreamCount = ClampStreamCount(caps.deviceToHostStreamCount); + channels.playbackStreamCount = ClampStreamCount(caps.hostToDeviceStreamCount); + if (HasAlesisCaptureStreamQuirk(record)) { + channels.captureStreamCount = 1; + } + + // Stream zero retains the legacy scalar channel. Remaining streams are + // assigned the lowest channel not already used by either direction. + uint64_t usedChannels = 0; + const auto markUsed = [&usedChannels](uint8_t channel) noexcept { + if (channel <= 0x3F) { + usedChannels |= (uint64_t{1} << channel); + } + }; + const auto nextFree = [&usedChannels]() noexcept -> uint8_t { + for (uint8_t channel = 0; channel <= 0x3F; ++channel) { + if ((usedChannels & (uint64_t{1} << channel)) == 0) { + usedChannels |= (uint64_t{1} << channel); + return channel; + } + } + return AudioStreamWireInfo::kInvalidIsoChannel; + }; + + channels.captureIsoChannels[0] = IsValidIsoChannel(caps.deviceToHostIsoChannel) + ? caps.deviceToHostIsoChannel + : kDefaultCaptureIsoChannel; + channels.playbackIsoChannels[0] = IsValidIsoChannel(caps.hostToDeviceIsoChannel) + ? caps.hostToDeviceIsoChannel + : kDefaultPlaybackIsoChannel; + markUsed(channels.captureIsoChannels[0]); + markUsed(channels.playbackIsoChannels[0]); + + for (uint32_t i = 1; i < channels.captureStreamCount; ++i) { + channels.captureIsoChannels[i] = nextFree(); + } + for (uint32_t i = 1; i < channels.playbackStreamCount; ++i) { + channels.playbackIsoChannels[i] = nextFree(); + } + + channels.deviceToHostIsoChannel = channels.captureIsoChannels[0]; + channels.hostToDeviceIsoChannel = channels.playbackIsoChannels[0]; + return channels; + } + + [[nodiscard]] static DuplexStreamProfile Build(const Discovery::DeviceRecord& record, + const AudioStreamRuntimeCaps& caps, + const AudioDuplexChannels& channels) noexcept { + DuplexStreamProfile profile{ + .channels = channels, + .runtimeCaps = caps, + }; + + // AM824 uses one data-block slot per PCM channel plus any MIDI slots; + // the controller consumes the already-discovered DBS values unchanged. + // Cross-validated with Linux sound/firewire/amdtp-am824.c:42-96. + const bool multiCapture = channels.captureStreamCount > 1; + uint32_t captureChannelOffset = 0; + for (uint32_t i = 0; i < channels.captureStreamCount; ++i) { + const AudioStreamWireInfo& stream = caps.deviceToHostStreams[i]; + DuplexCaptureStreamGeometry& geometry = profile.captureStreams[i]; + geometry.isoChannel = channels.CaptureChannel(i); + geometry.pcmChannelOffset = captureChannelOffset; + geometry.pcmChannels = multiCapture ? stream.pcmChannels : 0; + geometry.am824Slots = multiCapture ? stream.am824Slots : caps.deviceToHostAm824Slots; + captureChannelOffset += geometry.pcmChannels; + } + + for (uint32_t i = 0; i < channels.playbackStreamCount; ++i) { + const AudioStreamWireInfo& stream = caps.hostToDeviceStreams[i]; + DuplexPlaybackStreamGeometry& geometry = profile.playbackStreams[i]; + geometry.isoChannel = channels.PlaybackChannel(i); + geometry.pcmChannels = stream.pcmChannels; + geometry.am824Slots = stream.am824Slots; + } + + if (IsSPro24Dsp(record) && caps.hostInputPcmChannels == 8 && + caps.deviceToHostAm824Slots == 9) { + profile.captureWireFormat = Encoding::AudioWireFormat::kRawPcm24In32; + } + if (IsSPro24Dsp(record) && caps.hostOutputPcmChannels == 8 && + caps.hostToDeviceAm824Slots == 9) { + profile.playbackWireFormat = Encoding::AudioWireFormat::kRawPcm24In32; + } + return profile; + } +}; + +} // namespace ASFW::Audio::Backends diff --git a/ASFWDriver/SCSIController/ASFWSCSIController.cpp b/ASFWDriver/SCSIController/ASFWSCSIController.cpp index 3580c7f9..a39797ab 100644 --- a/ASFWDriver/SCSIController/ASFWSCSIController.cpp +++ b/ASFWDriver/SCSIController/ASFWSCSIController.cpp @@ -8,12 +8,27 @@ // SessionRegistry/CommandExecutor command plane — ORB per command, real SCSI // status + autosense back in the response. // -// The framework auto-creates target 0 and probes it (~3 s) BEFORE the SBP-2 -// login completes. Rather than spoof a hardcoded identity, the pre-login probe -// INQUIRY is DEFERRED: its completion is held and replayed with the device's -// real INQUIRY once login is up (generic, no per-device identity). TUR/REQUEST -// SENSE complete GOOD meanwhile; everything else returns BUSY so the initiator -// retries. A held INQUIRY is flushed BUSY on teardown/abort if login never comes. +// Target lifecycle follows the SBP-2 session (pure framework hotplug model): +// UserTargetPresentForID answers false UNCONDITIONALLY, so the kernel shim's +// bring-up presence scan never creates a target — a machine booting with no +// SBP-2 device has no target whose probe could strand the registry (the +// pre-fix unconditional true held the boot probe INQUIRY forever: 60 s +// registry busy-timeout panic, IOService.cpp:5986, issue #54). Constant false +// also makes the scan's timing irrelevant: a login-driven create can never +// race the scan into a duplicate target 0 (the HW-observed v49 wedge). All +// creation is explicit: UserCreateTargetForID(0) on the SBP-2 login-up edge, +// UserDestroyTargetForID(0) on the terminal logout/login-failure edge, both on +// a dedicated lifecycle queue. Transient bus-reset suspension emits no edge +// (reconnect re-asserts login), so the target survives a bus reset mid-scan. +// +// The SAM probes the target right after creation, while the session is logged +// in — the probe INQUIRY is forwarded to the device and returns its real +// identity. In the suspended window (bus reset dropped the login; reconnect +// pending) INQUIRY answers BUSY like everything else, so the initiator's +// bounded retries either land after reconnect or fail cleanly — nothing is +// ever held without a deadline. (The previous design deferred pre-login +// INQUIRYs indefinitely; that was the #54 strand and is gone. TUR/REQUEST +// SENSE still answer GOOD to keep probes moving.) // // libc++ must precede DriverKit headers: DriverKit.h forward-declares @@ -43,36 +58,62 @@ namespace SBP2 = ASFW::Protocols::SBP2; namespace { constexpr uint8_t kOpTestUnitReady = 0x00; constexpr uint8_t kOpRequestSense = 0x03; -constexpr uint8_t kOpInquiry = 0x12; constexpr uint8_t kOpReserve6 = 0x16; constexpr uint8_t kOpRelease6 = 0x17; constexpr uint8_t kOpReserve10 = 0x56; constexpr uint8_t kOpRelease10 = 0x57; -// One deferred pre-login probe INQUIRY. The framework auto-creates target 0 and -// probes it (~3 s) BEFORE the SBP-2 login completes; INQUIRY must return data -// even when the unit is not ready (SCSI), so instead of a spoof we HOLD the -// probe's completion and replay it against the real device once login is up. -// A single slot suffices — the SAM keeps one probe INQUIRY outstanding; a second -// concurrent one falls back to BUSY. -struct HeldInquiry { - OSAction* completion; // owns one retain (taken at hold) until consumed - uint64_t targetID; - uint64_t taskID; - uint64_t dataAddress; // framework data buffer, valid until the task completes - uint64_t dataLength; - uint32_t requestedLength; - uint32_t timeoutMs; - uint8_t cdb[16]; - uint8_t cdbLen; - bool inUse; -}; - struct PendingState { IOLock* lock; - HeldInquiry inquiry; + // Target 0 exists kernel-side (created at SBP-2 login, destroyed at logout). + // Create/destroy idempotence guard; written on lifecycleQueue only. + bool targetAttached; + // Set (and never cleared) at the top of Stop: lifecycle blocks still queued + // skip create/destroy so they cannot race the framework's own child-target + // termination. Stop deliberately does NOT wait for in-flight blocks — a + // synchronous wait from the Default queue can deadlock against an in-flight + // UserCreateTargetForID (its target-init upcall is serviced on Default). + bool stopping; }; +bool IsTargetAttached(PendingState* ps) { + if (ps == nullptr || ps->lock == nullptr) { + return false; + } + IOLockLock(ps->lock); + const bool attached = ps->targetAttached; + IOLockUnlock(ps->lock); + return attached; +} + +void SetTargetAttached(PendingState* ps, bool attached) { + if (ps == nullptr || ps->lock == nullptr) { + return; + } + IOLockLock(ps->lock); + ps->targetAttached = attached; + IOLockUnlock(ps->lock); +} + +bool IsStopping(PendingState* ps) { + if (ps == nullptr || ps->lock == nullptr) { + return true; // no state → treat as tearing down, do nothing + } + IOLockLock(ps->lock); + const bool stopping = ps->stopping; + IOLockUnlock(ps->lock); + return stopping; +} + +void SetStopping(PendingState* ps) { + if (ps == nullptr || ps->lock == nullptr) { + return; + } + IOLockLock(ps->lock); + ps->stopping = true; + IOLockUnlock(ps->lock); +} + // Must match UserGetDMASpecification's maxTransferSize. Sized as a permissive // ceiling for any single-LUN SBP-2 scanner, not a per-model value: the LS-9000's // largest observed READ(10) (VueScan reads whole line groups, ~510 KB) sits well @@ -126,96 +167,77 @@ void FillResponseFromResult(SCSIUserParallelResponse& resp, } } -// Store a held INQUIRY into the single slot. Caller has already taken the -// completion + controller retains. Returns false if the slot is occupied (the -// caller then drops the retains and answers BUSY). -bool TryHoldInquiry(PendingState* ps, const HeldInquiry& held) { - if (ps == nullptr || ps->lock == nullptr) { - return false; - } - IOLockLock(ps->lock); - const bool slotFree = !ps->inquiry.inUse; - if (slotFree) { - ps->inquiry = held; - } - IOLockUnlock(ps->lock); - return slotFree; -} - -// Take ownership of the held INQUIRY (if any). Exactly one caller wins; the -// retains transfer to it. -bool ExtractHeldInquiry(PendingState* ps, HeldInquiry* out) { - if (ps == nullptr || ps->lock == nullptr) { - return false; - } - IOLockLock(ps->lock); - const bool had = ps->inquiry.inUse; - if (had) { - *out = ps->inquiry; - ps->inquiry = HeldInquiry{}; +// Runs on lifecycleQueue (never auxQueue: UserCreateTargetForID is routed +// through AuxiliaryQueue by the framework, and never the Default queue: it +// services the framework's target-init upcalls). Handles one SBP-2 login edge: +// create target 0 on login-up, destroy it on terminal logout. Caller holds a +// self retain across the call. +// +// Known race, accepted: a block that passed the stopping check can still be +// inside a create/destroy kernel call when the framework begins terminating +// the controller (Stop cannot wait for it — a synchronous wait from the +// Default queue deadlocks against the create's target-init upcall). The +// framework must tolerate hotplug create/destroy racing termination; the call +// then fails and is logged. HW validation covers the unplug paths. +void HandleLoginEdge(ASFWSCSIController* self, PendingState* ps, + uint64_t guid, bool loggedIn) +{ + if (IsStopping(ps)) { + return; } - IOLockUnlock(ps->lock); - return had; -} - -// Complete a held INQUIRY with BUSY (login never arrived / teardown / abort) and -// consume the held completion retain. `self` is always live at every call site -// (drain block retain, Stop frame, or abort frame), so it is NOT released here. -void CompleteHeldInquiryBusy(ASFWSCSIController* self, const HeldInquiry& held) { - SCSIUserParallelResponse resp{}; - resp.version = kScsiUserParallelTaskResponseCurrentVersion1; - resp.fTargetID = held.targetID; - resp.fControllerTaskIdentifier = held.taskID; - resp.fServiceResponse = kSCSIServiceResponse_TASK_COMPLETE; - resp.fCompletionStatus = kSCSITaskStatus_BUSY; - self->ParallelTaskCompletion(held.completion, resp); - held.completion->release(); -} -// Replay a held INQUIRY against the real device now that login is up. Consumes -// the held retains via the submit completion (or inline on the not-ready guard). -void SubmitHeldInquiry(ASFWSCSIController* self, const HeldInquiry& held) { - auto bridge = SBP2::SBP2BridgeHub::Get(); - if (!bridge || !bridge->IsReady()) { - CompleteHeldInquiryBusy(self, held); + if (loggedIn) { + if (IsTargetAttached(ps)) { + return; // reconnect re-assert or duplicate catch-up — already attached + } + // Re-check the session NOW (this block may run long after the edge was + // queued — a stale Start catch-up must not create a target for a + // session that has since logged out; the later down-edge found nothing + // to destroy). + auto bridge = SBP2::SBP2BridgeHub::Get(); + if (!bridge || !bridge->IsReady()) { + ASFW_LOG(Controller, "[SCSIHBA] login edge stale (session not ready) — create skipped"); + return; + } + OSDictionary* dict = OSDictionary::withCapacity(1); + if (dict == nullptr) { + // No create attempt without a properties dict; the next login edge + // (reconnect re-fires the observer) retries. + ASFW_LOG(Controller, "[SCSIHBA] target dict alloc failed — create skipped"); + return; + } + const kern_return_t kr = self->UserCreateTargetForID(0, dict); + OSSafeReleaseNULL(dict); + if (kr == kIOReturnSuccess) { + SetTargetAttached(ps, true); + ASFW_LOG(Controller, + "[SCSIHBA] target 0 created (SBP-2 login, guid=0x%016llx)", guid); + } else { + // Not retried here: a reconnect or replug re-fires the up edge. + ASFW_LOG(Controller, "[SCSIHBA] UserCreateTargetForID(0) failed: 0x%x", kr); + } return; } - SBP2::SCSI::CommandRequest request{}; - request.cdb.assign(held.cdb, held.cdb + held.cdbLen); - request.direction = SBP2::SCSI::DataDirection::FromTarget; - request.transferLength = held.requestedLength; - request.timeoutMs = held.timeoutMs; - - OSAction* completion = held.completion; // held completion retain, released in the lambda - const uint64_t targetID = held.targetID; - const uint64_t taskID = held.taskID; - const uint64_t dataAddress = held.dataAddress; - const uint64_t dataLength = held.dataLength; - const uint32_t requestedLength = held.requestedLength; - - // Keep the controller alive across the async submit gap (local retain paired - // with the lambda's release). - self->retain(); - bridge->SubmitTask(std::move(request), - [self, completion, targetID, taskID, dataAddress, dataLength, requestedLength]( - const SBP2::SCSI::CommandResult& result) { - SCSIUserParallelResponse resp{}; - resp.version = kScsiUserParallelTaskResponseCurrentVersion1; - resp.fTargetID = targetID; - resp.fControllerTaskIdentifier = taskID; - FillResponseFromResult(resp, result); - if (resp.fServiceResponse == kSCSIServiceResponse_TASK_COMPLETE && - !result.payload.empty() && dataAddress != 0) { - uint64_t n = std::min(result.payload.size(), requestedLength); - n = std::min(n, dataLength); - memcpy(reinterpret_cast(dataAddress), result.payload.data(), n); - resp.fBytesTransferred = n; - } - self->ParallelTaskCompletion(completion, resp); - completion->release(); - self->release(); - }); + // Terminal logout or login failure (a transient bus-reset suspension emits + // no event — reconnect re-asserts login instead). Outstanding bridged tasks + // complete through the registry's abort path with synthetic failures; the + // framework handles completions racing a destroyed target (standard + // hotplug). + if (IsTargetAttached(ps)) { + const kern_return_t kr = self->UserDestroyTargetForID(0); + // Clear the flag even on failure: the kernel target is terminating (or + // already gone) either way, and the next login edge recreates it. + SetTargetAttached(ps, false); + if (kr == kIOReturnSuccess) { + ASFW_LOG(Controller, + "[SCSIHBA] target 0 destroyed (SBP-2 logout, guid=0x%016llx)", guid); + } else { + ASFW_LOG(Controller, + "[SCSIHBA] UserDestroyTargetForID(0) failed: 0x%x (guid=0x%016llx)", + kr, guid); + } + } } } // namespace @@ -232,9 +254,8 @@ bool ASFWSCSIController::init() if (ivars == nullptr) { return false; } - ivars->targetCreated = false; - ivars->targetID = 0; + // IONewZero → targetAttached=false, stopping=false. PendingState* ps = IONewZero(PendingState, 1); if (ps == nullptr) { return false; @@ -250,16 +271,22 @@ bool ASFWSCSIController::init() void ASFWSCSIController::free() { - if (ivars != nullptr && ivars->pendingState != nullptr) { - // Stop flushes any held INQUIRY before free; if one somehow survived, a - // live controller retain would have kept us out of free — so only the - // lock + struct need releasing here. - auto* ps = static_cast(ivars->pendingState); - if (ps->lock != nullptr) { - IOLockFree(ps->lock); + if (ivars != nullptr) { + // Queues are released here, not in Stop: lifecycle blocks retain the + // controller, so free() only runs once every queued block has finished + // — the queues are idle by now. Releasing in Stop instead would race + // in-flight blocks, and a failed Start (which never gets a Stop) would + // leak them. + OSSafeReleaseNULL(ivars->lifecycleQueue); + OSSafeReleaseNULL(ivars->auxQueue); + if (ivars->pendingState != nullptr) { + auto* ps = static_cast(ivars->pendingState); + if (ps->lock != nullptr) { + IOLockFree(ps->lock); + } + IOSafeDeleteNULL(ps, PendingState, 1); + ivars->pendingState = nullptr; } - IOSafeDeleteNULL(ps, PendingState, 1); - ivars->pendingState = nullptr; } IOSafeDeleteNULL(ivars, ASFWSCSIController_IVars, 1); super::free(); @@ -267,7 +294,7 @@ void ASFWSCSIController::free() kern_return_t IMPL(ASFWSCSIController, Start) { - ASFW_LOG(Controller, "[SCSIHBA] Start (SBP-2 bridge + deferred-INQUIRY probe)"); + ASFW_LOG(Controller, "[SCSIHBA] Start (SBP-2 bridge, login-driven target)"); // UserCreateTargetForID is declared QUEUENAME(AuxiliaryQueue) in the SDK .iig // ("this call to the framework runs on the Auxiliary queue"), but the framework // does not create that queue — the dext must. Without it the call never @@ -283,6 +310,16 @@ kern_return_t IMPL(ASFWSCSIController, Start) ASFW_LOG(Controller, "[SCSIHBA] SetDispatchQueue(AuxiliaryQueue) failed: 0x%x", ret); return ret; } + // Login-edge work (UserCreateTargetForID/UserDestroyTargetForID) runs on its + // own serial queue: not auxQueue (the create call is routed through it — a + // call FROM it never dispatches, same wedge as above) and not the Default + // queue (it services the framework's target-init upcalls during the create). + ret = IODispatchQueue::Create("ASFWSCSIController-TargetLifecycle", 0, 0, + &ivars->lifecycleQueue); + if (ret != kIOReturnSuccess || ivars->lifecycleQueue == nullptr) { + ASFW_LOG(Controller, "[SCSIHBA] lifecycle queue create failed: 0x%x", ret); + return ret; + } ret = Start(provider, SUPERDISPATCH); if (ret != kIOReturnSuccess) { ASFW_LOG(Controller, "[SCSIHBA] super::Start failed: 0x%x", ret); @@ -290,59 +327,68 @@ kern_return_t IMPL(ASFWSCSIController, Start) } // Reverse channel from the FireWire side (a separate IOService, unreachable - // via the provider chain): fires on SBP-2 login up/down. On login up, replay - // any probe INQUIRY held during the pre-login window with the device's real - // INQUIRY (see the deferred-INQUIRY handling in UserProcessParallelTask). + // via the provider chain): fires on SBP-2 login up/down and drives the + // target lifecycle (create on login, destroy on terminal logout) — see + // HandleLoginEdge. // // Runs UNDER the hub lock (see SBP2BridgeHub::NotifyTargetState), so it only - // schedules work: retain self, hop onto auxQueue, drain there, release. - SBP2::SBP2BridgeHub::SetTargetObserver([this](uint64_t guid, bool loggedIn) { - (void)guid; - if (!loggedIn || ivars == nullptr || ivars->auxQueue == nullptr) { + // schedules work: retain self, hop onto lifecycleQueue, handle there, release. + PendingState* ps = static_cast(ivars->pendingState); + SBP2::SBP2BridgeHub::SetTargetObserver([this, ps](uint64_t guid, bool loggedIn) { + if (ivars == nullptr || ivars->lifecycleQueue == nullptr) { return; } this->retain(); - ivars->auxQueue->DispatchAsync(^{ - HeldInquiry held{}; - if (ExtractHeldInquiry(static_cast(ivars->pendingState), &held)) { - ASFW_LOG(Controller, "[SCSIHBA] replaying held INQUIRY after login"); - SubmitHeldInquiry(this, held); - } + ivars->lifecycleQueue->DispatchAsync(^{ + HandleLoginEdge(this, ps, guid, loggedIn); this->release(); }); }); + + // Catch-up: the FireWire side may already be logged in when the HBA starts + // (HBA service restart while the driver is running) — no further login + // event will fire, so synthesize the up-edge. Safe against a racing real + // edge: the lifecycle queue serializes, targetAttached dedupes a double + // create, and HandleLoginEdge re-checks IsReady() at execution time so a + // catch-up that lands after a terminal logout creates nothing. + auto bridge = SBP2::SBP2BridgeHub::Get(); + if (bridge && bridge->IsReady()) { + this->retain(); + ivars->lifecycleQueue->DispatchAsync(^{ + HandleLoginEdge(this, ps, /*guid*/ 0, /*loggedIn*/ true); + this->release(); + }); + } return kIOReturnSuccess; } kern_return_t IMPL(ASFWSCSIController, Stop) { ASFW_LOG(Controller, "[SCSIHBA] Stop"); - // Drop the observer first. ClearTargetObserver is synchronous with respect to - // an in-flight login notification (runs under the hub lock), so no new drain - // blocks are scheduled after it returns. + // Gate lifecycle blocks first: anything still queued sees stopping and + // skips create/destroy, so it cannot race the framework's own child-target + // termination (see the no-destroy comment below). + SetStopping(static_cast(ivars != nullptr ? ivars->pendingState : nullptr)); + // Drop the observer. ClearTargetObserver is synchronous with respect to + // an in-flight login notification (runs under the hub lock), so no new + // lifecycle blocks are scheduled after it returns. + // + // Deliberately NO synchronous wait on lifecycleQueue here: Stop runs on the + // Default queue, and an in-flight UserCreateTargetForID cannot return until + // its target-init upcall is serviced on that same Default queue — a sync + // wait closes a three-way deadlock (Stop → lifecycleQueue → kernel create → + // Default) that wedges termination into the 60 s registry busy-timeout + // panic. Queued blocks are gated by the stopping flag instead; queue + // objects are released in free(), which cannot run until every block (each + // holds a controller retain) has finished. SBP2::SBP2BridgeHub::ClearTargetObserver(); - if (ivars != nullptr && ivars->auxQueue != nullptr) { - // Barrier on auxQueue: runs after any queued drain block, so a held - // INQUIRY is either already replayed or still ours to flush here (backstop - // against a leaked OSAction if the SAM tears down without aborting it). - IODispatchQueue* aux = ivars->auxQueue; - aux->DispatchSync(^{ - HeldInquiry held{}; - if (ExtractHeldInquiry(static_cast(ivars->pendingState), &held)) { - CompleteHeldInquiryBusy(this, held); - } - }); - OSSafeReleaseNULL(ivars->auxQueue); - } - // No UserDestroyTargetForID: target 0 is framework-auto-created (presence - // scan, see UserStartController). On HBA teardown the framework terminates it - // as a child of the stopping controller. An explicit destroy here re-enters - // that in-flight termination on the aux queue, target 0 never quiesces, and - // the registry busy-times out at 60s → panic (IOSCSITargetDevice (1,1) + - // IOThunderboltPort, IOService.cpp:5986). Same auto-target-0 rule as create. - if (ivars != nullptr) { - ivars->targetCreated = false; - } + // No UserDestroyTargetForID here — not even for a target this HBA created + // at login: on HBA teardown the framework terminates the target as a child + // of the stopping controller, and an explicit destroy re-enters that + // in-flight termination on the aux queue, target 0 never quiesces, and the + // registry busy-times out at 60s → panic (IOSCSITargetDevice (1,1) + + // IOThunderboltPort, IOService.cpp:5986). Logout-driven destroys run on the + // lifecycle queue BEFORE Stop and are gated off by the stopping flag above. return Stop(provider, SUPERDISPATCH); } @@ -409,17 +455,16 @@ kern_return_t IMPL(ASFWSCSIController, UserInitializeController) kern_return_t IMPL(ASFWSCSIController, UserStartController) { - // No explicit UserCreateTargetForID: the kernel shim scans target IDs + // No target yet: the kernel shim scans target IDs // 0..UserReportHighestSupportedDeviceID at bring-up and creates a device for - // every ID where UserTargetPresentForID returns true, so target 0 is auto- - // created here (HW-confirmed: an explicit create for target 0 fails - // kIOReturnError because it already exists, and pairing it with a true - // presence answer spawned a DUPLICATE device that wedged teardown). The - // pre-login probe of this auto-created target is handled by deferring INQUIRY - // (see UserProcessParallelTask), not by creating the target on login. - ASFW_LOG(Controller, "[SCSIHBA] UserStartController — target 0 published via presence scan"); - ivars->targetCreated = true; - ivars->targetID = 0; + // every ID where UserTargetPresentForID returns true — which is now false + // until an SBP-2 login is up, so the scan creates nothing and controller + // registration completes immediately. A machine booting with no SBP-2 + // device on the bus therefore has no target whose probe could strand the + // registry (issue #54). Target 0 is created from the login observer + // (HandleLoginEdge); creating it here with a true presence answer spawned a + // DUPLICATE device that wedged teardown (HW-observed, v49). + ASFW_LOG(Controller, "[SCSIHBA] UserStartController — no target until SBP-2 login"); return kIOReturnSuccess; } @@ -483,7 +528,16 @@ kern_return_t IMPL(ASFWSCSIController, UserDoesHBASupportSCSIParallelFeature) kern_return_t IMPL(ASFWSCSIController, UserTargetPresentForID) { - *result = (targetID == 0); + // Constant false — the presence scan must NEVER create a target. Answering + // true unconditionally made the bring-up scan auto-create target 0 on a + // device-less boot and strand its probe INQUIRY forever (the 60 s registry + // busy-timeout boot panic of issue #54); answering true while attached + // would let a login-driven create that lands BEFORE the scan runs be + // duplicated BY the scan (the HW-observed v49 duplicate-target wedge — the + // scan's timing relative to login edges is not ordered). All target + // creation goes through UserCreateTargetForID on the login edge instead. + (void)targetID; + *result = false; return kIOReturnSuccess; } @@ -576,54 +630,16 @@ kern_return_t IMPL(ASFWSCSIController, UserProcessParallelTask) const bool ready = bridge && bridge->IsReady(); if (!ready) { - // Pre-login window (or Suspend after a bus reset). INQUIRY must return - // data even when the unit is not ready, so DEFER it: hold the completion - // and replay it with the device's real INQUIRY once login is up — no - // spoof. TUR/REQUEST SENSE complete GOOD to keep the probe moving; - // everything else returns BUSY so the initiator retries. - if (opcode == kOpInquiry) { - IOBufferMemoryDescriptor* buffer = nullptr; - IOAddressSegment seg{}; - const uint8_t cdbLen = parallelRequest.fCommandSize; - kern_return_t kr = UserGetDataBuffer(parallelRequest.fTargetID, - parallelRequest.fControllerTaskIdentifier, - &buffer); - if (kr == kIOReturnSuccess && buffer != nullptr && - buffer->GetAddressRange(&seg) == kIOReturnSuccess && seg.address != 0 && - cdbLen > 0 && cdbLen <= sizeof(HeldInquiry{}.cdb)) { - HeldInquiry held{}; - held.completion = completion; - held.targetID = parallelRequest.fTargetID; - held.taskID = parallelRequest.fControllerTaskIdentifier; - held.dataAddress = seg.address; - held.dataLength = seg.length; - held.requestedLength = static_cast(std::min( - parallelRequest.fRequestedTransferCount, kMaxTransferPerTask)); - held.timeoutMs = parallelRequest.fTimeoutInMilliSec != 0 - ? parallelRequest.fTimeoutInMilliSec - : kDefaultTaskTimeoutMs; - memcpy(held.cdb, parallelRequest.fCommandDescriptorBlock, cdbLen); - held.cdbLen = cdbLen; - held.inUse = true; - // Hold a completion retain for the async gap; consumed on drain - // (SubmitHeldInquiry) or flush (CompleteHeldInquiryBusy). The - // borrowed buffer ref stays valid until the task completes — do - // NOT release it. - completion->retain(); - if (TryHoldInquiry(static_cast(ivars->pendingState), held)) { - ASFW_LOG(Controller, "[SCSIHBA] INQUIRY deferred until SBP-2 login"); - if (response != nullptr) { - *response = kIOReturnSuccess; - } - return kIOReturnSuccess; // completion fires on drain/flush - } - // Slot already occupied — undo the retain, fall through to BUSY. - completion->release(); - } else { - ASFW_LOG(Controller, "[SCSIHBA] INQUIRY: UserGetDataBuffer failed 0x%x", kr); - } - resp.fCompletionStatus = kSCSITaskStatus_BUSY; - } else if (opcode == kOpTestUnitReady || opcode == kOpRequestSense) { + // Suspended window: the target exists (it was created at login) but the + // session dropped after a bus reset and reconnect is pending. Every + // data-carrying command — INQUIRY included — answers BUSY so the + // initiator's bounded retries either land after the reconnect or fail + // cleanly; nothing is held without a deadline (an indefinitely held + // INQUIRY was the issue-#54 strand: a device that vanishes while + // suspended emits no terminal edge, so a held completion would never + // fire and the task would pin the registry). TUR/REQUEST SENSE complete + // GOOD to keep probes moving. + if (opcode == kOpTestUnitReady || opcode == kOpRequestSense) { // GOOD, no data. } else { ASFW_LOG(Controller, "[SCSIHBA] opcode 0x%02x → BUSY (SBP-2 session not ready)", @@ -770,13 +786,9 @@ kern_return_t IMPL(ASFWSCSIController, UserProcessParallelTask) kern_return_t IMPL(ASFWSCSIController, UserAbortTaskRequest) { (void)theT; (void)theL; (void)theQ; - // If the framework times out / aborts the deferred probe INQUIRY, complete it - // so the OSAction is not leaked (single target, so match coarsely). - HeldInquiry held{}; - if (ivars != nullptr && - ExtractHeldInquiry(static_cast(ivars->pendingState), &held)) { - CompleteHeldInquiryBusy(this, held); - } + // Nothing to abort HBA-side: no task is ever held (not-ready answers BUSY + // synchronously), and bridged tasks complete through the registry's own + // abort path on teardown. if (response != nullptr) { *response = 0; } return kIOReturnSuccess; } diff --git a/ASFWDriver/SCSIController/ASFWSCSIController.iig b/ASFWDriver/SCSIController/ASFWSCSIController.iig index ad9429b2..6e88b312 100644 --- a/ASFWDriver/SCSIController/ASFWSCSIController.iig +++ b/ASFWDriver/SCSIController/ASFWSCSIController.iig @@ -15,10 +15,17 @@ // surface seen by the app is identical. // // UserProcessParallelTask forwards SCSI tasks to the real SBP-2 target via -// SBP2TargetBridge/SessionRegistry (see the .cpp). The framework auto-creates -// target 0 and probes it before the SBP-2 login completes; rather than spoof an -// identity, the pre-login probe INQUIRY is deferred and replayed with the -// device's real INQUIRY once login is up (generic, no per-device identity). +// SBP2TargetBridge/SessionRegistry (see the .cpp). Target 0 is created at SBP-2 +// login and destroyed at terminal logout (pure framework hotplug model): +// UserTargetPresentForID answers false unconditionally, so the bring-up +// presence scan never creates a target — a machine booting with no SBP-2 +// device on the bus has no target to strand (the pre-fix unconditional true +// held the boot probe INQUIRY forever and tripped watchdogd's 60 s registry +// busy-timeout — IOService.cpp:5986, issue #54), and a login-driven create can +// never be duplicated by the scan. The SAM probes the target right after the +// login-edge create, while the session is up, so the probe INQUIRY returns the +// device's real identity; in the suspended window (bus reset) commands answer +// BUSY — nothing is ever held without a deadline. // // Matching: this HBA co-matches the OHCI PCI device directly (IOProviderClass // IOPCIDevice + IOPCIMatch, distinct IOMatchCategory), alongside the main @@ -101,11 +108,14 @@ public: }; struct ASFWSCSIController_IVars { - bool targetCreated{false}; - uint64_t targetID{0}; // single target at ID 0 IODispatchQueue* auxQueue{nullptr}; // the framework's "AuxiliaryQueue" contract + // Serializes UserCreateTargetForID/UserDestroyTargetForID (login/logout + // edges). Deliberately NOT auxQueue (the create call is routed through + // AuxiliaryQueue by the framework — calling it from there wedges) and NOT + // the Default queue (which services the framework's target-init upcalls). + IODispatchQueue* lifecycleQueue{nullptr}; uint32_t nextUniqueTaskID{0}; // handed out in UserMapHBAData (must be unique per task) - void* pendingState{nullptr}; // PendingState* (deferred pre-login INQUIRY), defined in .cpp + void* pendingState{nullptr}; // PendingState* (target/stop flags), defined in .cpp }; #endif /* ASFWSCSIController_h */ diff --git a/ASFWDriver/SCSIController/SBP2BridgeHub.hpp b/ASFWDriver/SCSIController/SBP2BridgeHub.hpp index dcbb8dc3..065606a4 100644 --- a/ASFWDriver/SCSIController/SBP2BridgeHub.hpp +++ b/ASFWDriver/SCSIController/SBP2BridgeHub.hpp @@ -28,7 +28,10 @@ class SBP2BridgeHub { // provider chain) registers a target-state observer so the FireWire side can // drive SCSI target create/destroy on SBP-2 login/logout. loggedIn=true on // login up, false on logout/failure. The HBA registers on Start and clears - // on Stop; NotifyTargetState fires the observer outside the hub lock. + // on Stop. NotifyTargetState fires the observer UNDER the hub lock — the + // HBA's Stop is load-bearing on that synchrony (once ClearTargetObserver + // returns, no observer call is running or can start), so the observer must + // only schedule non-blocking work; see NotifyTargetState. using TargetStateCallback = std::function; static void SetTargetObserver(TargetStateCallback observer); static void ClearTargetObserver(); diff --git a/ASFWDriver/SCSIController/SBP2TargetBridge.cpp b/ASFWDriver/SCSIController/SBP2TargetBridge.cpp index a901d16a..31c8ab35 100644 --- a/ASFWDriver/SCSIController/SBP2TargetBridge.cpp +++ b/ASFWDriver/SCSIController/SBP2TargetBridge.cpp @@ -248,10 +248,11 @@ void SBP2TargetBridge::OnUnitPublished(const std::shared_ptr& } void SBP2TargetBridge::OnLoginStateChanged(uint64_t guid, bool loggedIn) { - // Phase 1: the guid->targetID map (WI-3) and the actual - // UserCreateTargetForID/UserDestroyTargetForID (WI-4) are not wired yet. - // Forward the raw event to the HBA, which logs it inertly — the static - // phantom target still serves SCSI probes unchanged. + // The registry emits this on TERMINAL edges only: login-up (fresh login or + // reconnect re-assert) and logout/login-failure. A transient bus-reset + // suspension emits nothing. The HBA drives target 0 create/destroy off + // these edges (ASFWSCSIController::HandleLoginEdge); a multi-target + // guid→targetID map remains future work. ASFW_LOG(Controller, "[SBP2Bridge] login %s guid=0x%016llx", loggedIn ? "up" : "down", guid); SBP2BridgeHub::NotifyTargetState(guid, loggedIn); diff --git a/ASFWDriver/Scheduling/Scheduler.hpp b/ASFWDriver/Scheduling/Scheduler.hpp index aa14ed52..31706659 100644 --- a/ASFWDriver/Scheduling/Scheduler.hpp +++ b/ASFWDriver/Scheduling/Scheduler.hpp @@ -19,6 +19,9 @@ class Scheduler { void Bind(OSSharedPtr queue); void DispatchAsync(const std::function& work); + // WARNING: implemented as IOSleep *on the bound queue* (IODispatchQueue has + // no native delayed dispatch) — the queue is blocked for the whole delay. + // Only use for short delays; long waits need an IOTimerDispatchSource. void DispatchAsyncAfter(uint64_t delayNs, const std::function& work); void DispatchSync(const std::function& work); diff --git a/README.md b/README.md index 6fa26166..9afa385d 100644 --- a/README.md +++ b/README.md @@ -385,13 +385,12 @@ To include the HBA, opt in explicitly: ./sign.sh # picks the +SCSI entitlements automatically ``` -> **Warning:** `--scsi` builds are currently **not cold-boot-safe**: cold-booting -> with the FireWire controller attached and no powered-on SBP-2 device on the bus -> can hit the 60 s boot panic even with SIP fully disabled. Power the SBP-2 device -> on before booting. A stalled probe on a running system does not panic -> immediately, but can panic later when the adapter is unplugged or the extension -> is torn down. The HBA-side fix (create the target at SBP-2 login instead of -> boot) will follow in a separate PR. +> **Warning:** this branch moves SCSI target creation to SBP-2 login (the HBA +> reports no target until a device is actually logged in), which removes the +> cold-boot panic path — **pending hardware validation**. Until that validation +> lands, keep the old precautions when running `--scsi` builds: power the SBP-2 +> device on before booting, and avoid restarting or unplugging the adapter while +> it is attached with no powered-on SBP-2 device on the bus. If a machine ever ends up in a panic loop: boot into Recovery, `csrutil disable`, boot normally, uninstall the extension diff --git a/documentation/SAMPLE_RATE_EXPANSION.md b/documentation/SAMPLE_RATE_EXPANSION.md index 6653c9af..e03bcf02 100644 --- a/documentation/SAMPLE_RATE_EXPANSION.md +++ b/documentation/SAMPLE_RATE_EXPANSION.md @@ -206,11 +206,15 @@ In rough dependency order: read) to populate the nub's supported-rate list instead of assuming. 6. **HAL rate switching is not wired.** The ADK graph already publishes multiple rates (`ASFWAudioDriverGraph.cpp:294-306`, - `SetAvailableSampleRates` from nub properties), but there is no - `PerformDeviceConfigurationChange` handler, so a HAL-initiated rate change - has no path to *stop streams → reprogram CLOCK_SELECT → restart duplex*. - `DiceDuplexRestartCoordinator` is the natural vehicle: a rate change is a - restart with new parameters. + `SetAvailableSampleRates` from nub properties), but `ASFWAudioDevice` + overrides only `StartIO`/`StopIO` (`Audio/DriverKit/ASFWAudioDevice.iig`) + and has no `HandleChangeSampleRate`, + `PerformDeviceConfigurationChange`, or + `AbortDeviceConfigurationChange` path. A HAL- or device-initiated rate + change therefore has no transaction to *stop streams → reprogram + CLOCK_SELECT → update the ADK graph → restart duplex*. The required ADK + contract is specified below. `DiceDuplexRestartCoordinator` is the natural + device-side vehicle: a rate change is a restart with new parameters. 7. **Fallback-ladder policy (explicit decision).** Keep start-up as-is (no-data until replay warms, ≈32 ms — behaviorally equivalent to Linux `on_the_fly=false`), keep FATAL on mid-stream replay underrun, and treat @@ -218,6 +222,69 @@ In rough dependency order: mid-stream hot-swap. Hot-swapping cadence sources mid-stream is exactly the double-path trap. +### AudioDriverKit sample-rate-change transaction (required) + +**This is a host-coordinated configuration transaction, not a call to +`SetSampleRate` while the stream is live.** The authoritative local API source +is the installed Xcode 27 DriverKit header +`AudioDriverKit.framework/Headers/IOUserAudioClockDevice.iig:211-246`: it says +that a device **must** begin any I/O- or structure-affecting state change with +`RequestDeviceConfigurationChange`, and explicitly lists nominal sample rate, +stream formats, ring-buffer size, presentation latency, and safety offset. +The header also says the host may defer the `Perform...` callback to another +thread. This was cross-checked with Context7's current Apple +`Creating an audio device driver` documentation on 2026-07-10. + +| Phase | AudioDriverKit contract | ASFW requirement | +|---|---|---| +| 1. Propose | The initiator calls `RequestDeviceConfigurationChange(action, info)`. The request only asks the host to begin the transaction; it does not grant permission to change hardware. | Validate the exact requested Hz against the nub's advertised rates and current device caps. Store a single pending record `{rate, clock source, reason, topology generation}` behind the audio-device work queue, then request a private `kSampleRateChange` action. A HAL property write enters through `HandleChangeSampleRate`; for ASFW it must enqueue this transaction, not inherit the default direct `SetSampleRate` behavior. An external clock/device-rate notification uses the same path. | +| 2. Quiesce | The host stops outstanding I/O, calls `StopIO`, then later calls `PerformDeviceConfigurationChange`. | `StopIO` must stop duplex/isoch, stop new payload preparation and ZTS publication, clear `isRunning`/`txActive`, and detach every transport view before freeing a mapping. It must not reprogram the DICE clock or publish a new ADK rate. Existing `ASFWAudioDevice::StopIO` is the correct teardown entry point, but its ordering must be shared with the rate transaction rather than duplicated. | +| 3. Commit | `PerformDeviceConfigurationChange(action, info)` is the only point at which the device may change the requested state. I/O is already stopped. | Match the pending action and generation; serialize with bus-reset/teardown and other clock requests. Program DICE `CLOCK_SELECT` plus rate, then wait until the device reports the requested rate and a stable lock. Do **not** return success merely because an async request was queued: once `Perform...` returns, the host is allowed to restart I/O. | +| 4. Publish | Set the device rate and update each stream's current format before returning. | After hardware commit, update `ivars.device.currentSampleRate`, direct-memory metadata, `AudioGraphBinding::sampleRateHz`, replay/cadence/DBC state, timing geometry, and transfer-delay policy as one configuration. Call `audioDevice->SetSampleRate(rate)`, then `inputStream->DeviceSampleRateChanged(rate)` and `outputStream->DeviceSampleRateChanged(rate)`. The latter chooses the available stream format matching the new rate and invokes the stream's current-format handler (`IOUserAudioStream.iig:254-266`). Call `super::PerformDeviceConfigurationChange` exactly once after the custom action has succeeded, as required by the base header. | +| 5. Resume | After `Perform...` returns, AudioDriverKit observes the changed object state and restarts active I/O with `StartIO` if required. There is no separate `CompleteDeviceConfigurationChange` API. | `StartIO` must acquire/rebuild only resources compatible with the committed rate, reset packet/replay/ZTS state, and start the duplex stream at that same rate. It must never reload the current 48 kHz defaults. Only the `kern_return_t` from `Perform...` is the completion signal to the host. | +| Abort/failure | The host may call `AbortDeviceConfigurationChange` for a request it will not perform. A non-success return from `Perform...` rejects the commit. | Abort drops the matching pending record without changing hardware and calls `super::AbortDeviceConfigurationChange`. A hardware/programming/lock failure leaves the ADK rate and stream formats at the old committed state, returns failure, and performs an explicit device recovery if a partial DICE write occurred. Never publish 44.1 to CoreAudio while the device is still running 48 kHz. | + +`HandleChangeSampleRate` is therefore **not sufficient on its own** for this +driver. The base implementation merely calls `SetSampleRate`; it is suitable +for a virtual clock whose rate has no live transport consequences. ASFW owns a +hardware clock, FireWire DMA geometry, packet cadence, and shared mappings, so +the framework's mandatory request/perform barrier applies. In particular, do +not make `SetSampleRate`, `SetCurrentStreamFormat`, or a DICE register write +from the real-time I/O handler. + +The existing direct-memory contract makes buffer ownership part of the same +transaction. `IOUserAudioStream::SetIOMemoryDescriptor` is documented as legal +only inside `PerformDeviceConfigurationChange` +(`IOUserAudioStream.iig:429-446`). If a new rate preserves PCM frame layout +and ring capacity, retain the descriptors and only rebuild FireWire-side +resources. If geometry requires replacement, install the new descriptors in +phase 4 only after `StopIO` has dropped every cross-service view; no raw +pointer into an old audio-owned mapping may survive the swap (FW-60 rule). +The graph already rejects a direct-memory sample-rate mismatch at +`ASFWAudioDriverGraph.cpp:355-367`, so the nub/control-block rate and ADK +device rate must be advanced together, never one callback apart. + +**ASFW implementation delta.** Add the three overrides to +`ASFWAudioDevice.iig` and implement a small pending-change state machine in +`ASFWAudioDevice.cpp`; keep all state transition work on the existing +`ivars.workQueue`. Teach the DICE coordinator to accept every supported +`DiceDesiredClockConfig` instead of its present 48 kHz default +(`DiceDuplexRestartCoordinator.cpp:455-460`), and make the packetizer, +replay-bootstrap step, transfer delays, and timing geometry rate-parameterized +before connecting the new action. The current `DICETcatProtocol` 48 kHz +wrappers (`DICETcatProtocol.cpp:240-249`) are explicitly not a valid +implementation of this transaction. + +**Acceptance test for rate change.** Exercise 48,000 → 44,100 → 48,000 while +an input and output client are active, and assert the ordered trace +`Request → StopIO → Perform → StartIO` for each switch. At the end of each +`Perform`, assert one committed rate across DICE global state, nub/direct +memory metadata, `IOUserAudioDevice`, both `IOUserAudioStream` current +formats, `AudioGraphBinding`, and TX/RX cadence state. Add separate cases for +an unsupported rate, host abort before `Perform`, a DICE lock timeout, device +removal, and a bus reset during the request; none may leave a live stream or a +ZTS anchor using the old rate after the new rate has been published. + The RX side needs essentially nothing for cadence: it decodes per-packet `dataBlocks` from the CIP header, and the replay ring stores whatever arrives. diff --git a/tests/devices/CMakeLists.txt b/tests/devices/CMakeLists.txt index 4a3df577..31247217 100644 --- a/tests/devices/CMakeLists.txt +++ b/tests/devices/CMakeLists.txt @@ -55,6 +55,11 @@ add_device_test(DiceDuplexRestartCoordinatorTests "${ASFW_DRIVER_DIR}/Bus/IRM/IRMClient.cpp" ) +# FW-74: resolver-owned duplex stream geometry, wire-format quirks, and host recipe. +add_device_test(DuplexStreamProfileTests + DuplexStreamProfileTests.cpp +) + # FW-65: sync/async bridge (WaitForAsyncResult) characterization tests (header-only) add_device_test(WaitForAsyncResultTests WaitForAsyncResultTests.cpp @@ -80,6 +85,11 @@ add_device_test(RestartSessionStoreTests RestartSessionStoreTests.cpp ) +# DICE clock-request broker tests (FW-67, header-only lock-borrowing broker) +add_device_test(ClockRequestBrokerTests + ClockRequestBrokerTests.cpp +) + # Apogee protocol boolean control mapping tests add_device_test(ApogeeBooleanControlMappingTests ApogeeBooleanControlMappingTests.cpp @@ -91,4 +101,3 @@ add_device_test(ApogeeDuetVendorCmdTests "${ASFW_DRIVER_DIR}/Audio/Protocols/Oxford/Apogee/ApogeeDuetProtocol.cpp" ) - diff --git a/tests/devices/ClockRequestBrokerTests.cpp b/tests/devices/ClockRequestBrokerTests.cpp new file mode 100644 index 00000000..5fe796bc --- /dev/null +++ b/tests/devices/ClockRequestBrokerTests.cpp @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 ASFireWire Project +// +// FW-67 characterization tests for ClockRequestBroker. These pin the clock-token, latest-wins +// pending queue, completion mailbox, eviction, and restart-session mirror behaviour extracted +// from DiceDuplexRestartCoordinator. Host tests, no hardware. + +#include + +#include + +#include "Audio/Protocols/Backends/ClockRequestBroker.hpp" + +namespace { + +using ASFW::Audio::Backends::ClockRequestBroker; +using ASFW::Audio::Backends::RestartSessionStore; +using ASFW::Audio::DICE::DiceClockRequestCompletion; +using ASFW::Audio::DICE::DiceClockRequestOutcome; +using ASFW::Audio::DICE::DiceRestartReason; +using ASFW::Audio::DICE::DiceRestartSession; + +constexpr uint64_t kGuid = 0x0011223344556677ULL; + +class ClockRequestBrokerTest : public ::testing::Test { +protected: + void SetUp() override { ASSERT_NE(lock_, nullptr); } + void TearDown() override { + if (lock_) { + IOLockFree(lock_); + } + } + + ClockRequestBroker::PendingClockRequest QueueLocked( + uint32_t sampleRateHz, + DiceRestartReason reason = DiceRestartReason::kManualReconfigure) { + IOLockLock(lock_); + ClockRequestBroker::PendingClockRequest request{ + .desiredClock = {.sampleRateHz = sampleRateHz, .clockSelect = sampleRateHz / 1000U}, + .reason = reason, + .token = broker_.AllocateTokenLocked(), + }; + EXPECT_FALSE(broker_.QueuePendingLocked(kGuid, request).has_value()); + IOLockUnlock(lock_); + return request; + } + + IOLock* lock_ = IOLockAlloc(); + RestartSessionStore store_{&lock_}; + ClockRequestBroker broker_{&lock_, store_}; +}; + +TEST_F(ClockRequestBrokerTest, TokenAllocationStartsAtOneAndIsMonotonic) { + IOLockLock(lock_); + EXPECT_EQ(broker_.AllocateTokenLocked(), 1u); + EXPECT_EQ(broker_.AllocateTokenLocked(), 2u); + EXPECT_EQ(broker_.AllocateTokenLocked(), 3u); + IOLockUnlock(lock_); +} + +// RequestClockConfig holds the coordinator lock across queue insertion and session mutation. +// The broker must preserve that atomic latest-wins update, including its session mirror. +TEST_F(ClockRequestBrokerTest, QueuePendingLatestWinsAndMirrorsRestartSession) { + DiceRestartSession session{}; + session.guid = kGuid; + store_.StoreSession(session); + + IOLockLock(lock_); + const ClockRequestBroker::PendingClockRequest first{ + .desiredClock = {.sampleRateHz = 44100, .clockSelect = 44}, + .reason = DiceRestartReason::kSampleRateChange, + .token = broker_.AllocateTokenLocked(), + }; + EXPECT_FALSE(broker_.QueuePendingLocked(kGuid, first).has_value()); + + const ClockRequestBroker::PendingClockRequest replacement{ + .desiredClock = {.sampleRateHz = 48000, .clockSelect = 48}, + .reason = DiceRestartReason::kClockSourceChange, + .token = broker_.AllocateTokenLocked(), + }; + const auto superseded = broker_.QueuePendingLocked(kGuid, replacement); + ASSERT_TRUE(superseded.has_value()); + EXPECT_EQ(superseded->token, first.token); + EXPECT_EQ(superseded->desiredClock.sampleRateHz, 44100u); + IOLockUnlock(lock_); + + const auto mirrored = store_.GetSession(kGuid); + ASSERT_TRUE(mirrored.has_value()); + EXPECT_TRUE(mirrored->hasPendingClockRequest); + EXPECT_EQ(mirrored->pendingClock.sampleRateHz, 48000u); + EXPECT_EQ(mirrored->pendingReason, DiceRestartReason::kClockSourceChange); + + ClockRequestBroker::PendingClockRequest consumed{}; + ASSERT_TRUE(broker_.TryConsumePending(kGuid, consumed)); + EXPECT_EQ(consumed.token, replacement.token); + EXPECT_EQ(consumed.desiredClock.sampleRateHz, 48000u); +} + +TEST_F(ClockRequestBrokerTest, ConsumeClearsPendingMirrorAndOnlyConsumesOnce) { + DiceRestartSession session{}; + session.guid = kGuid; + store_.StoreSession(session); + const auto request = QueueLocked(48000); + + ClockRequestBroker::PendingClockRequest consumed{}; + ASSERT_TRUE(broker_.TryConsumePending(kGuid, consumed)); + EXPECT_EQ(consumed.token, request.token); + EXPECT_EQ(consumed.desiredClock.sampleRateHz, request.desiredClock.sampleRateHz); + EXPECT_FALSE(broker_.TryConsumePending(kGuid, consumed)); + + const auto mirrored = store_.GetSession(kGuid); + ASSERT_TRUE(mirrored.has_value()); + EXPECT_FALSE(mirrored->hasPendingClockRequest); + EXPECT_EQ(mirrored->pendingClock.sampleRateHz, 0u); + EXPECT_EQ(mirrored->pendingReason, DiceRestartReason::kInitialStart); +} + +TEST_F(ClockRequestBrokerTest, CompleteDeliversOnceAndUpdatesLastClockCompletion) { + DiceRestartSession session{}; + session.guid = kGuid; + store_.StoreSession(session); + + const DiceClockRequestCompletion completion{ + .token = 17, + .desiredClock = {.sampleRateHz = 96000, .clockSelect = 96}, + .reason = DiceRestartReason::kManualReconfigure, + .outcome = DiceClockRequestOutcome::kApplied, + .status = kIOReturnSuccess, + .restartId = 9, + .generation = ASFW::FW::Generation{3}, + }; + broker_.Complete(completion, kGuid); + + const auto afterComplete = store_.GetSession(kGuid); + ASSERT_TRUE(afterComplete.has_value()); + ASSERT_TRUE(afterComplete->lastClockCompletion.has_value()); + EXPECT_EQ(afterComplete->lastClockCompletion->token, completion.token); + EXPECT_EQ(afterComplete->lastClockCompletion->outcome, DiceClockRequestOutcome::kApplied); + + DiceClockRequestCompletion taken{}; + ASSERT_TRUE(broker_.TryTakeCompleted(kGuid, completion.token, taken)); + EXPECT_EQ(taken.token, completion.token); + EXPECT_EQ(taken.status, kIOReturnSuccess); + EXPECT_FALSE(broker_.TryTakeCompleted(kGuid, completion.token, taken)); +} + +TEST_F(ClockRequestBrokerTest, FailPendingPublishesFailureWithCurrentSessionEpoch) { + DiceRestartSession session{}; + session.guid = kGuid; + session.restartId = 11; + session.topologyGeneration = ASFW::FW::Generation{7}; + store_.StoreSession(session); + const auto request = QueueLocked(88200, DiceRestartReason::kClockSourceChange); + + broker_.FailPending(kGuid, DiceClockRequestOutcome::kAbortedByStop, kIOReturnAborted); + + DiceClockRequestCompletion completion{}; + ASSERT_TRUE(broker_.TryTakeCompleted(kGuid, request.token, completion)); + EXPECT_EQ(completion.desiredClock.sampleRateHz, 88200u); + EXPECT_EQ(completion.reason, DiceRestartReason::kClockSourceChange); + EXPECT_EQ(completion.outcome, DiceClockRequestOutcome::kAbortedByStop); + EXPECT_EQ(completion.status, kIOReturnAborted); + EXPECT_EQ(completion.restartId, 11u); + EXPECT_EQ(completion.generation.value, 7u); +} + +// The original coordinator bounded each GUID's completion mailbox at 32 tokens. This avoids a +// waiter that never returns allowing an unbounded per-device accumulation. +TEST_F(ClockRequestBrokerTest, CompletionMailboxEvictsOldestAfterThirtyTwoTokens) { + for (uint64_t token = 1; token <= 33; ++token) { + broker_.Complete(DiceClockRequestCompletion{.token = token}, kGuid); + } + + DiceClockRequestCompletion completion{}; + EXPECT_FALSE(broker_.TryTakeCompleted(kGuid, 1, completion)); + ASSERT_TRUE(broker_.TryTakeCompleted(kGuid, 2, completion)); + EXPECT_EQ(completion.token, 2u); + ASSERT_TRUE(broker_.TryTakeCompleted(kGuid, 33, completion)); + EXPECT_EQ(completion.token, 33u); +} + +TEST_F(ClockRequestBrokerTest, ClearLockedDropsPendingAndCompletedRequests) { + const auto request = QueueLocked(48000); + broker_.Complete(DiceClockRequestCompletion{.token = 44}, kGuid); + + IOLockLock(lock_); + broker_.ClearLocked(kGuid); + IOLockUnlock(lock_); + + ClockRequestBroker::PendingClockRequest pending{}; + DiceClockRequestCompletion completion{}; + EXPECT_FALSE(broker_.TryConsumePending(kGuid, pending)); + EXPECT_FALSE(broker_.TryTakeCompleted(kGuid, 44, completion)); + EXPECT_NE(request.token, 0u); +} + +// The broker follows the same borrowed-lock lifecycle as the gate/store: self-locking paths +// short-circuit until the coordinator has allocated its IOLock. +TEST(ClockRequestBrokerNullLock, SelfLockingOperationsShortCircuitWithoutBorrowedLock) { + IOLock* lock = nullptr; + RestartSessionStore store{&lock}; + ClockRequestBroker broker{&lock, store}; + ClockRequestBroker::PendingClockRequest pending{}; + DiceClockRequestCompletion completion{}; + + EXPECT_FALSE(broker.TryConsumePending(kGuid, pending)); + EXPECT_FALSE(broker.TryTakeCompleted(kGuid, 1, completion)); + broker.Complete(DiceClockRequestCompletion{.token = 1}, kGuid); + broker.FailPending(kGuid, DiceClockRequestOutcome::kFailed, kIOReturnError); + EXPECT_FALSE(broker.TryTakeCompleted(kGuid, 1, completion)); +} + +} // namespace diff --git a/tests/devices/DuplexStreamProfileTests.cpp b/tests/devices/DuplexStreamProfileTests.cpp new file mode 100644 index 00000000..af869399 --- /dev/null +++ b/tests/devices/DuplexStreamProfileTests.cpp @@ -0,0 +1,101 @@ +#include + +#include "Audio/Protocols/Backends/DuplexStreamProfile.hpp" + +namespace { + +using ASFW::Audio::AudioStreamRuntimeCaps; +using ASFW::Audio::Backends::DuplexHostDirection; +using ASFW::Audio::Backends::DuplexStreamProfile; +using ASFW::Audio::Backends::DuplexStreamProfileResolver; +using ASFW::DeviceProfiles::Audio::kAlesisVendorId; +using ASFW::DeviceProfiles::Audio::kFocusriteVendorId; +using ASFW::DeviceProfiles::Audio::kSPro24DspModelId; +using ASFW::Discovery::DeviceRecord; +using ASFW::Encoding::AudioWireFormat; + +TEST(DuplexStreamProfileTests, OrdinaryDiceKeepsLegacyChannelsGeometryAndRecipe) { + DeviceRecord record{}; + AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 16, + .hostOutputPcmChannels = 16, + .deviceToHostAm824Slots = 17, + .hostToDeviceAm824Slots = 17, + .sampleRateHz = 48000, + .deviceToHostIsoChannel = AudioStreamRuntimeCaps::kInvalidIsoChannel, + .hostToDeviceIsoChannel = AudioStreamRuntimeCaps::kInvalidIsoChannel, + .deviceToHostStreamCount = 1, + .hostToDeviceStreamCount = 1, + }; + + const DuplexStreamProfile profile = DuplexStreamProfileResolver::Resolve(record, caps); + + EXPECT_EQ(profile.channels.captureStreamCount, 1U); + EXPECT_EQ(profile.channels.playbackStreamCount, 1U); + EXPECT_EQ(profile.channels.deviceToHostIsoChannel, 1U); + EXPECT_EQ(profile.channels.hostToDeviceIsoChannel, 0U); + EXPECT_EQ(profile.captureStreams[0].pcmChannels, 0U); + EXPECT_EQ(profile.captureStreams[0].am824Slots, 17U); + EXPECT_EQ(profile.captureWireFormat, AudioWireFormat::kAM824); + EXPECT_EQ(profile.playbackWireFormat, AudioWireFormat::kAM824); + EXPECT_EQ(profile.playbackBandwidthUnits, 320U); + EXPECT_EQ(profile.captureBandwidthUnits, 576U); + EXPECT_EQ(profile.startOrder.postDeviceEnableDelayMs, 2U); + EXPECT_FALSE(profile.startOrder.startReceiveBeforeDeviceRx); + EXPECT_FALSE(profile.startOrder.startTransmitBeforeDeviceTx); + EXPECT_EQ(profile.startOrder.prepareOrder[0], DuplexHostDirection::kReceive); + EXPECT_EQ(profile.startOrder.prepareOrder[1], DuplexHostDirection::kTransmit); + EXPECT_EQ(profile.startOrder.startOrder[0], DuplexHostDirection::kReceive); + EXPECT_EQ(profile.startOrder.startOrder[1], DuplexHostDirection::kTransmit); +} + +TEST(DuplexStreamProfileTests, SPro24DspResolvesRawPcmOnBothDirectionsWhenGeometryMatches) { + DeviceRecord record{ + .vendorId = kFocusriteVendorId, + .modelId = kSPro24DspModelId, + }; + AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 8, + .hostOutputPcmChannels = 8, + .deviceToHostAm824Slots = 9, + .hostToDeviceAm824Slots = 9, + }; + + const DuplexStreamProfile profile = DuplexStreamProfileResolver::Resolve(record, caps); + + EXPECT_EQ(profile.captureWireFormat, AudioWireFormat::kRawPcm24In32); + EXPECT_EQ(profile.playbackWireFormat, AudioWireFormat::kRawPcm24In32); + EXPECT_EQ(profile.captureStreams[0].am824Slots, 9U); +} + +TEST(DuplexStreamProfileTests, AlesisModelsClampAdvertisedCaptureStreamsToOne) { + AudioStreamRuntimeCaps caps{ + .hostInputPcmChannels = 32, + .hostOutputPcmChannels = 32, + .deviceToHostAm824Slots = 34, + .hostToDeviceAm824Slots = 34, + .deviceToHostIsoChannel = 5, + .hostToDeviceIsoChannel = 8, + .deviceToHostStreamCount = 2, + .hostToDeviceStreamCount = 2, + }; + caps.deviceToHostStreams[0] = {.isoChannel = 5, .pcmChannels = 16, .am824Slots = 17}; + caps.deviceToHostStreams[1] = {.isoChannel = 6, .pcmChannels = 16, .am824Slots = 17}; + + for (const uint32_t modelId : {0x000000U, 0x000001U}) { + DeviceRecord record{ + .vendorId = kAlesisVendorId, + .modelId = modelId, + }; + const DuplexStreamProfile profile = DuplexStreamProfileResolver::Resolve(record, caps); + + EXPECT_EQ(profile.channels.captureStreamCount, 1U) << modelId; + EXPECT_EQ(profile.channels.playbackStreamCount, 2U) << modelId; + EXPECT_EQ(profile.captureStreams[0].isoChannel, 5U) << modelId; + EXPECT_EQ(profile.captureStreams[0].pcmChannels, 0U) << modelId; + EXPECT_EQ(profile.captureStreams[0].am824Slots, 34U) << modelId; + EXPECT_EQ(profile.channels.PlaybackChannel(1), 0U) << modelId; + } +} + +} // namespace