From 2768a8274fe262800ab29dec75136031a6a77006 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:57:26 +0700 Subject: [PATCH 1/3] fix(ptp-sv): bound age of phase observation used for local claim --- .../time_sync/sv_ptp_phase_authority.hpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index b04fa0fa8..c46499a53 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -235,6 +235,22 @@ class SvPtpPhaseAuthority final { SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; }; +// A stalled/late 1Hz phase observer must not leave smpSynch=1 on the 4kHz +// wire indefinitely. Called by the publisher as an O(1) atomic-read-only +// LAST-RESORT lease check; known source/fault/phase failures still revoke +// immediately. This is not a new clock or qualification authority. +struct SvPhaseObservationFreshness final { + static constexpr std::uint32_t kMaxUnobservedEpochs = 1U; + [[nodiscard]] static constexpr bool fresh( + const std::uint32_t observed_epoch, + const std::uint32_t current_epoch) noexcept { + // A zero generation is never a valid sampled PTP second. Unsigned + // subtraction naturally handles the 32-bit epoch counter rollover. + return observed_epoch != 0U && current_epoch != 0U && + current_epoch - observed_epoch <= kMaxUnobservedEpochs; + } +}; + // A control-plane gate for evidence that a canonical SV publisher has not // skipped sample slots or failed Ethernet TX since the previous observation. // A fresh session and any fault require re-acquisition; neither can inherit From 82ab7da37e3e9afdca47937029b0a0505d0195de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:57:29 +0700 Subject: [PATCH 2/3] fix(ptp-sv): revoke stale observer phase claim in canonical publisher --- embedded/esp32p4_smv_injector/main/app_main.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 99a301a3c..5fc7d4f7c 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -209,6 +209,10 @@ struct SampleClockContext final { std::atomic cumulative_slew_ticks{0}; std::atomic publisher_fault_generation{0U}; std::atomic phase_authority_valid{false}; + // Last successfully observed sample epoch (published only by the 1Hz + // control-plane observer). The 4kHz producer checks freshness without + // waiting for that observer or consulting blocking MAC/PTP APIs. + std::atomic last_phase_observed_epoch{0U}; // Immutable for one monitored timer session; read under phase mutex. SvPtpClockLease armed_ptp_clock{}; SvBoundedSlewCursor phase_slew{}; @@ -617,6 +621,7 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.publisher_fault_generation.store(0U, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); + g_sample_clock_context.last_phase_observed_epoch.store(0U, std::memory_order_relaxed); g_sample_clock_context.armed_ptp_clock = {}; g_sample_clock_context.phase_index = 0U; g_sample_clock_context.previous_phase_healthy = false; @@ -708,6 +713,8 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { 0, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + g_sample_clock_context.last_phase_observed_epoch.store( + 0U, std::memory_order_release); g_sample_clock_context.armed_ptp_clock = {}; #endif static_cast(gptimer_stop(timer)); @@ -1000,6 +1007,11 @@ void publisher_task(void* argument) { if (smp_synch_source_local_qualified() && (!g_sample_clock_context.phase_authority_valid.load( std::memory_order_acquire) || + !ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire), + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire)) || ar_ptp_lab_source_generation() != g_sample_clock_context.armed_ptp_clock.generation)) { smp_synch_set_source_local_qualified(false); @@ -1344,6 +1356,10 @@ void sv_ptp_phase_observer_task(void* argument) { // The PRE-START clock must truly steer while SV TX is OFF. // The old live_tx_running() gate invalidated its servo on every // 1Hz observation; capture verified 120s of smpSynch=0. + g_sample_clock_context.last_phase_observed_epoch.store( + decision.epoch_second_continuous + ? observation.last_sv_epoch_generation : 0U, + std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( decision.epoch_second_continuous, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( From 50c6e347a465506a96798276c64b5a8649c3d518 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:57:32 +0700 Subject: [PATCH 3/3] test(ptp-sv): regress phase-observer stall and epoch rollover --- tests/test_sv_ptp_phase_authority.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index db8b7e158..340be28b7 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -76,6 +76,27 @@ void check_auto_preflight_does_not_invent_sync_or_start() { } } +void check_phase_observer_stall_revokes_source_wire_claim() { + using Fresh = SvPhaseObservationFreshness; + static_assert(Fresh::kMaxUnobservedEpochs == 1U); + // After a qualified observation at epoch 25, the producer may continue + // into the next second while the observer runs at t+250ms. An observer + // stall cannot advertise LOCAL through the following epoch. + CHECK(!Fresh::fresh(0U, 25U)); + CHECK(!Fresh::fresh(25U, 0U)); + CHECK(Fresh::fresh(25U, 25U)); + CHECK(Fresh::fresh(25U, 26U)); + CHECK(!Fresh::fresh(25U, 27U)); + CHECK(!Fresh::fresh(25U, 100U)); + CHECK(!Fresh::fresh(26U, 25U)); // future/stale generation + // No retained qualification across a 32-bit generation wrap; only a + // genuinely adjacent observation may pass. + CHECK(Fresh::fresh(0xfffffffeU, 0xffffffffU)); + CHECK(!Fresh::fresh(0xfffffffeU, 1U)); + CHECK(!Fresh::fresh(0xffffffffU, 0U)); // zero remains invalid + CHECK(Fresh::fresh(0xffffffffU, 1U) == false); // two skipped epochs +} + void check_phase_sign_and_bounded_corrections() { SvPtpPhaseAuthority controller; const auto warm = controller.observe(snapshot(1U, 220)); @@ -397,6 +418,7 @@ void check_continuous_ptp_second_binding() { int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); + check_phase_observer_stall_revokes_source_wire_claim(); check_phase_sign_and_bounded_corrections(); check_uncertain_brackets_never_claim_software_tracking(); check_per_second_slew_distribution();