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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions ASFW.xcodeproj/xcshareddata/xcschemes/ASFWDriver.xcscheme
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
Expand All @@ -16,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EB9A8DA75D08971084A440A"
BuildableName = "ASFWDriver.dext"
BuildableName = "net.mrmidi.ASFW.ASFWDriver.dext"
BlueprintName = "ASFWDriver"
ReferencedContainer = "container:ASFW.xcodeproj">
</BuildableReference>
Expand All @@ -27,21 +26,18 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EB9A8DA75D08971084A440A"
BuildableName = "ASFWDriver.dext"
BuildableName = "net.mrmidi.ASFW.ASFWDriver.dext"
BlueprintName = "ASFWDriver"
ReferencedContainer = "container:ASFW.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
Expand All @@ -58,13 +54,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EB9A8DA75D08971084A440A"
BuildableName = "ASFWDriver.dext"
BuildableName = "net.mrmidi.ASFW.ASFWDriver.dext"
BlueprintName = "ASFWDriver"
ReferencedContainer = "container:ASFW.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand All @@ -77,13 +71,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0EB9A8DA75D08971084A440A"
BuildableName = "ASFWDriver.dext"
BuildableName = "net.mrmidi.ASFW.ASFWDriver.dext"
BlueprintName = "ASFWDriver"
ReferencedContainer = "container:ASFW.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
Expand Down
89 changes: 64 additions & 25 deletions ASFWDriver/Audio/DriverKit/ASFWAudioDriverZts.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,21 +228,50 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars,
// can momentarily outrun the producer. Killing TX here would leave the
// stream permanently silent -- the timing-loss recovery is health-gated
// when the device clock is fine (see DiceAudioBackend), and even ungated
// a coordinator restart cannot re-prime TX. Instead, re-sync the reader
// to RX's current epoch and ship a NO-DATA (silence) packet for this
// slot; DATA replay resumes as soon as RX republishes kReadDelay
// entries. Persistent unavailability degrades to silence, which is the
// correct "nothing to send yet" state, not a stream death.
// a coordinator restart cannot re-prime TX. Persistent unavailability
// degrades to silence, which is the correct "nothing to send yet"
// state, not a stream death.
if (!ivars.runtime.txReplayReader.IsActive()) {
(void)ivars.runtime.txReplayReader.Begin(
directControl->rxSequenceReplay);
}

ASFW::Audio::Runtime::RxSequenceEntry replay{};
ASFW::Audio::Runtime::RxSequenceReplayReadDiagnostic replayDiagnostic{};
if (ivars.runtime.txReplayReader.TryRead(
directControl->rxSequenceReplay, replay,
&replayDiagnostic)) {
bool replayReadable = ivars.runtime.txReplayReader.TryRead(
directControl->rxSequenceReplay, replay,
&replayDiagnostic);
// A reader that fell out of the bounded 512-entry RX history is
// repositionable, not faulted: Begin() re-anchors kReadDelay
// behind the live producer and the skipped entries only shift
// NODATA placement, which IEC 61883-6 blocking permits (DBC
// continuity is packetizer-owned; the SYT offset drifts sub-tick
// across the skipped span). Frame-cursor alignment must NOT
// re-arm for this: re-projecting abandons the established
// host-frame mapping and orphans every host frame behind the new
// cursor (the all-zero-payload Duet zombie of 2026-07-19).
if (!replayReadable &&
replayDiagnostic.failure ==
ASFW::Audio::Runtime::RxSequenceReplayReadFailure::
kHistoryOverwritten) {
if (ivars.runtime.txReplayReader.Begin(
directControl->rxSequenceReplay)) {
replayReadable = ivars.runtime.txReplayReader.TryRead(
directControl->rxSequenceReplay, replay,
&replayDiagnostic);
}
ASFW_LOG_RING_ONLY_RL(
DirectAudio,
"tx-replay-reclamp",
1000u,
::ASFW::Logging::LogLevel::Warning,
"[TxReplay] reclamped pkt=%llu cur=%llu prod=%llu ok=%u",
nextPacketToPrepare,
replayDiagnostic.readerCursor,
replayDiagnostic.producerCursor,
replayReadable ? 1u : 0u);
}
if (replayReadable) {
directControl->txReplayEntries.fetch_add(
1, std::memory_order_relaxed);
timing.replayDataBlocks = replay.dataBlocks;
Expand Down Expand Up @@ -273,26 +302,36 @@ uint32_t PrepareTransmitSlots(ASFWAudioDriver_IVars& ivars,
replayDiagnostic.slotSequence,
replayDiagnostic.slotEpoch,
replayDiagnostic.replayEstablished ? 1u : 0u);
// Reader stale (epoch moved) or ahead of the producer: count it,
// drop the reader so the next packet re-Begins on the live epoch,
// and fall through with the NO-DATA disposition set above. Also
// re-arm the frame-cursor alignment: while stalled we emit NO-DATA
// packets, which do NOT advance the content-frame cursor, so it
// freezes at its pre-stall frame while CoreAudio keeps writing. If
// the stall outlasts one playback ring the cursor is stranded on
// overwritten (silent) frames forever. Re-arming makes the first
// DATA packet after replay recovers re-project the cursor to the
// live frame, closing the gap. Only reached on a genuine replay
// stall (churn), never during normal cadence NO-DATA.
directControl->txReplayUnderflows.fetch_add(
1, std::memory_order_relaxed);
ivars.runtime.txReplayReader.Reset();
ivars.runtime.txStreamEngine.ReArmFrameCursorAlignment();
if (ivars.runtime.txSecondaryActive) {
ivars.runtime.txStreamEngineSecondary
.ReArmFrameCursorAlignment();
}
timing.replayDataBlocks = 0;
if (replayDiagnostic.failure ==
ASFW::Audio::Runtime::RxSequenceReplayReadFailure::
kAheadOfProducer) {
// RX simply has not published this entry yet (a deep
// preparation burst outran real-time RX). Hold the reader
// where it is and ship one NODATA packet; the same cursor
// reads successfully once RX catches up. Resetting the
// reader or re-arming alignment here turns a transient,
// self-resolving condition into a frame-cursor jump that
// abandons host frames.
} else {
// Epoch change, establishment loss, or a seqlock miss: the
// RX timing domain itself moved. Drop the reader so the
// next packet re-Begins on the live epoch and re-arm the
// frame-cursor alignment: while stalled we emit NO-DATA
// packets, which do NOT advance the content-frame cursor,
// so it freezes at its pre-stall frame while CoreAudio
// keeps writing. Re-arming makes the first DATA packet
// after replay recovers re-project the cursor to the live
// frame, closing the gap.
ivars.runtime.txReplayReader.Reset();
ivars.runtime.txStreamEngine.ReArmFrameCursorAlignment();
if (ivars.runtime.txSecondaryActive) {
ivars.runtime.txStreamEngineSecondary
.ReArmFrameCursorAlignment();
}
}
}

if (replay.dataBlocks != 0) {
Expand Down
44 changes: 44 additions & 0 deletions ASFWDriver/Isoch/Transmit/IsochTransmitContext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ kern_return_t IsochTransmitContext::Start() noexcept {
interruptCount_.store(0, std::memory_order_relaxed);
lastInterruptCountSeen_ = 0;
irqStallTicks_ = 0;
irqSilentKickStreak_ = 0;
refillInProgress_.clear(std::memory_order_release);

latencyBucket0_.store(0, std::memory_order_relaxed);
Expand Down Expand Up @@ -500,6 +501,48 @@ void IsochTransmitContext::Poll() noexcept {
if (irqStallTicks_ >= 5) {
irqStallTicks_ = 0;
irqWatchdogKicks_.fetch_add(1, std::memory_order_relaxed);
++irqSilentKickStreak_;
// Snapshot context + latched interrupt state on the anomaly path
// only. kIntEvent latches raised events even when masked, so a
// cycleInconsistent/cycleTooLong the dispatcher never serviced is
// still visible here (first-fault evidence for the 2026-07-19
// Saffire dual-context freeze, whose trigger left no interrupt
// record).
if (irqSilentKickStreak_ == 1 && hardware_) {
const uint32_t ctrl = hardware_->Read(static_cast<Register32>(
DMAContextHelpers::IsoXmitContextControl(contextIndex_)));
const uint32_t latchedIntEvents =
hardware_->Read(Register32::kIntEvent);
ASFW_LOG(Isoch,
"IT: refill watchdog engaged (no IT interrupts "
"observed; kicks=%llu ctrl=0x%08x intEvent=0x%08x)",
irqWatchdogKicks_.load(std::memory_order_relaxed),
ctrl,
latchedIntEvents);
}
if (irqSilentKickStreak_ >= kIrqSilentKickFatalThreshold) {
// Watchdog-carried streaming re-transmits stale descriptor
// laps between kicks (observed Duet zombie, 2026-07-19: the
// interrupt path died mid-session and the watchdog fed the
// wire for 35 minutes of corrupt audio). Sustained interrupt
// silence is a transport fault, not jitter.
const uint32_t ctrl = hardware_
? hardware_->Read(static_cast<Register32>(
DMAContextHelpers::IsoXmitContextControl(
contextIndex_)))
: 0;
const uint32_t latchedIntEvents =
hardware_ ? hardware_->Read(Register32::kIntEvent) : 0;
ASFW_LOG(Isoch,
"IT FATAL: interrupt path silent across %u "
"consecutive watchdog kicks; stopping context "
"(ctrl=0x%08x intEvent=0x%08x)",
irqSilentKickStreak_,
ctrl,
latchedIntEvents);
StopImmediatelyForTxFault();
return;
}
if (!refillInProgress_.test_and_set(std::memory_order_acq_rel)) {
// Stop() may have acquired the gate after the first state
// check. Re-check while holding it before touching the slab.
Expand All @@ -512,6 +555,7 @@ void IsochTransmitContext::Poll() noexcept {
} else {
lastInterruptCountSeen_ = currentInterrupts;
irqStallTicks_ = 0;
irqSilentKickStreak_ = 0;
}
}

Expand Down
8 changes: 8 additions & 0 deletions ASFWDriver/Isoch/Transmit/IsochTransmitContext.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ class IsochTransmitContext final {
uint64_t lastInterruptCountSeen_{0};
uint32_t irqStallTicks_{0};

// Consecutive watchdog kicks with zero interrupts observed. The watchdog
// bridges interrupt-delivery jitter only: its cadence is far coarser than
// the 48-packet descriptor ring, so a stream carried by the watchdog
// re-transmits stale ring laps between kicks. A sustained silent streak
// is a dead interrupt path and the context must stop honestly.
static constexpr uint32_t kIrqSilentKickFatalThreshold = 16;
uint32_t irqSilentKickStreak_{0};

// Refill Latency Histogram (buckets: <50us, 50-200us, 200-500us, >500us)
std::atomic<uint64_t> latencyBucket0_{0};
std::atomic<uint64_t> latencyBucket1_{0};
Expand Down
25 changes: 22 additions & 3 deletions ASFWDriver/Protocols/AVC/FCPTransport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -360,14 +360,24 @@ void FCPTransport::OnFCPResponse(uint16_t srcNodeID,
return;
}

if (!pending_->successfulWriteAttempt.has_value()) {
// AR Request and AR Response are separate DMA contexts. RxPath drains the
// request context first, so a target's FCP block write can be delivered
// before our earlier command-write acknowledgement is dispatched from AR
// Response. That ordering is normal on the wire: the FCP response itself
// is definitive proof that the target received and processed this command.
// Do not drop it merely because the local write-completion callback is
// still queued.
const bool responsePrecedesWriteCompletion = !pending_->successfulWriteAttempt.has_value();
if (responsePrecedesWriteCompletion && !pending_->activeWriteAttempt.has_value()) {
IOLockUnlock(lock_);
ASFW_LOG_V3(FCP,
"FCPTransport: Ignoring response before write completion");
"FCPTransport: Ignoring response without an active write attempt");
return;
}

const FCPWriteAttempt successfulAttempt = *pending_->successfulWriteAttempt;
const FCPWriteAttempt successfulAttempt = responsePrecedesWriteCompletion
? *pending_->activeWriteAttempt
: *pending_->successfulWriteAttempt;
const uint16_t expectedNodeID = successfulAttempt.targetNodeID;
const bool exactMatch = srcNodeID == expectedNodeID;
const bool nodeNumberMatch = (srcNodeID & 0x3F) == (expectedNodeID & 0x3F);
Expand Down Expand Up @@ -403,6 +413,15 @@ void FCPTransport::OnFCPResponse(uint16_t srcNodeID,
return;
}

if (responsePrecedesWriteCompletion) {
// Preserve the delivery proof for diagnostics and make a later async
// write completion a harmless stale callback after CompleteCommand().
pending_->successfulWriteAttempt = successfulAttempt;
ASFW_LOG_V2(FCP,
"FCPTransport: Accepting FCP response before local write completion attempt=%llu",
successfulAttempt.id);
}

FCPFrame response;
response.length = std::min(payload.size(), response.data.size());
std::copy_n(payload.begin(), response.length, response.data.begin());
Expand Down
Loading
Loading