diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/Hedera.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/Hedera.java index 23ab533dd860..57a5ade1bf0e 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/Hedera.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/Hedera.java @@ -146,6 +146,7 @@ import com.swirlds.platform.system.SwirldMain; import com.swirlds.platform.system.address.AddressBook; import com.swirlds.platform.system.events.Event; +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; import com.swirlds.platform.system.state.notifications.StateHashedListener; import com.swirlds.platform.system.status.PlatformStatus; import com.swirlds.platform.system.transaction.Transaction; @@ -1003,6 +1004,7 @@ private void initializeDagger(@NonNull final State state, @NonNull final InitTri notifications.unregister(PlatformStatusChangeListener.class, this); notifications.unregister(ReconnectCompleteListener.class, daggerApp.reconnectListener()); notifications.unregister(StateWriteToDiskCompleteListener.class, daggerApp.stateWriteToDiskListener()); + notifications.unregister(AsyncFatalIssListener.class, daggerApp.fatalIssListener()); if (blockStreamEnabled) { notifications.unregister(StateHashedListener.class, daggerApp.blockStreamManager()); } @@ -1056,6 +1058,7 @@ private void initializeDagger(@NonNull final State state, @NonNull final InitTri notifications.register(PlatformStatusChangeListener.class, this); notifications.register(ReconnectCompleteListener.class, daggerApp.reconnectListener()); notifications.register(StateWriteToDiskCompleteListener.class, daggerApp.stateWriteToDiskListener()); + notifications.register(AsyncFatalIssListener.class, daggerApp.fatalIssListener()); if (blockStreamEnabled) { notifications.register(StateHashedListener.class, daggerApp.blockStreamManager()); daggerApp diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/HederaInjectionComponent.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/HederaInjectionComponent.java index ece99d0dda4e..f2b45fca06c5 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/HederaInjectionComponent.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/HederaInjectionComponent.java @@ -66,6 +66,7 @@ import com.swirlds.platform.listeners.StateWriteToDiskCompleteListener; import com.swirlds.platform.system.InitTrigger; import com.swirlds.platform.system.Platform; +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; import com.swirlds.state.State; import com.swirlds.state.lifecycle.StartupNetworks; import com.swirlds.state.lifecycle.info.NetworkInfo; @@ -146,6 +147,8 @@ public interface HederaInjectionComponent { SubmissionManager submissionManager(); + AsyncFatalIssListener fatalIssListener(); + @Component.Builder interface Builder { @BindsInstance diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/platform/PlatformModule.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/platform/PlatformModule.java index 49939859d3e2..e8e4bec810f5 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/platform/PlatformModule.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/platform/PlatformModule.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2024 Hedera Hashgraph, LLC + * Copyright (C) 2020-2025 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,12 +17,14 @@ package com.hedera.node.app.platform; import com.hedera.node.app.annotations.CommonExecutor; +import com.hedera.node.app.state.listeners.FatalIssListenerImpl; import com.hedera.node.app.state.listeners.ReconnectListener; import com.hedera.node.app.state.listeners.WriteStateToDiskListener; import com.swirlds.common.stream.Signer; import com.swirlds.platform.listeners.ReconnectCompleteListener; import com.swirlds.platform.listeners.StateWriteToDiskCompleteListener; import com.swirlds.platform.system.Platform; +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; import dagger.Binds; import dagger.Module; import dagger.Provides; @@ -68,4 +70,8 @@ static IntSupplier provideFrontendThrottleSplit(@NonNull final Platform platform @Binds @Singleton StateWriteToDiskCompleteListener bindStateWrittenToDiskListener(WriteStateToDiskListener writeStateToDiskListener); + + @Binds + @Singleton + AsyncFatalIssListener bindFatalIssListener(FatalIssListenerImpl listener); } diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/state/listeners/FatalIssListenerImpl.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/state/listeners/FatalIssListenerImpl.java new file mode 100644 index 000000000000..c9e5699cd891 --- /dev/null +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/state/listeners/FatalIssListenerImpl.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2025 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hedera.node.app.state.listeners; + +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; +import com.swirlds.platform.system.state.notifications.IssNotification; +import edu.umd.cs.findbugs.annotations.NonNull; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +@Singleton +public class FatalIssListenerImpl implements AsyncFatalIssListener { + + private static final Logger log = LogManager.getLogger(FatalIssListenerImpl.class); + + @Inject + public FatalIssListenerImpl() { + // no-op + } + + @Override + public void notify(@NonNull final IssNotification data) { + log.warn("ISS detected (type={}, round={})", data.getIssType(), data.getRound()); + } +} diff --git a/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/builder/PlatformComponentBuilder.java b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/builder/PlatformComponentBuilder.java index eefe64b3d020..2647672872eb 100644 --- a/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/builder/PlatformComponentBuilder.java +++ b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/builder/PlatformComponentBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Hedera Hashgraph, LLC + * Copyright (C) 2024-2025 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/components/DefaultAppNotifier.java b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/components/DefaultAppNotifier.java index 7665e5f88591..09d93170a91b 100644 --- a/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/components/DefaultAppNotifier.java +++ b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/components/DefaultAppNotifier.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Hedera Hashgraph, LLC + * Copyright (C) 2024-2025 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,10 @@ import com.swirlds.platform.listeners.ReconnectCompleteNotification; import com.swirlds.platform.listeners.StateWriteToDiskCompleteListener; import com.swirlds.platform.listeners.StateWriteToDiskCompleteNotification; +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; import com.swirlds.platform.system.state.notifications.IssListener; import com.swirlds.platform.system.state.notifications.IssNotification; +import com.swirlds.platform.system.state.notifications.IssNotification.IssType; import com.swirlds.platform.system.state.notifications.NewSignedStateListener; import com.swirlds.platform.system.state.notifications.StateHashedListener; import com.swirlds.platform.system.state.notifications.StateHashedNotification; @@ -84,5 +86,10 @@ public void sendLatestCompleteStateNotification( @Override public void sendIssNotification(@NonNull final IssNotification notification) { notificationEngine.dispatch(IssListener.class, notification); + + if (IssType.CATASTROPHIC_ISS == notification.getIssType() || IssType.SELF_ISS == notification.getIssType()) { + // Forward notification to application + notificationEngine.dispatch(AsyncFatalIssListener.class, notification); + } } } diff --git a/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/system/state/notifications/AsyncFatalIssListener.java b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/system/state/notifications/AsyncFatalIssListener.java new file mode 100644 index 000000000000..cc8ed60bd24f --- /dev/null +++ b/platform-sdk/swirlds-platform-core/src/main/java/com/swirlds/platform/system/state/notifications/AsyncFatalIssListener.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2025 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.swirlds.platform.system.state.notifications; + +import com.swirlds.common.notification.DispatchMode; +import com.swirlds.common.notification.DispatchModel; +import com.swirlds.common.notification.DispatchOrder; +import com.swirlds.common.notification.Listener; + +/** + * Listener for fatal ISS events (i.e. of type SELF or CATASTROPHIC). This listener is ordered and asynchronous. + * If you require ordered and synchronous dispatch that includes all ISS events, then use {@link IssListener}. + */ +@DispatchModel(mode = DispatchMode.ASYNC, order = DispatchOrder.ORDERED) +public interface AsyncFatalIssListener extends Listener {} diff --git a/platform-sdk/swirlds-platform-core/src/test/java/com/swirlds/platform/components/DefaultAppNotifierTest.java b/platform-sdk/swirlds-platform-core/src/test/java/com/swirlds/platform/components/DefaultAppNotifierTest.java new file mode 100644 index 000000000000..a036a3aa8568 --- /dev/null +++ b/platform-sdk/swirlds-platform-core/src/test/java/com/swirlds/platform/components/DefaultAppNotifierTest.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2025 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.swirlds.platform.components; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import com.swirlds.common.crypto.DigestType; +import com.swirlds.common.crypto.Hash; +import com.swirlds.common.notification.NotificationEngine; +import com.swirlds.common.notification.NotificationResult; +import com.swirlds.common.threading.futures.StandardFuture.CompletionCallback; +import com.swirlds.platform.components.appcomm.CompleteStateNotificationWithCleanup; +import com.swirlds.platform.listeners.PlatformStatusChangeListener; +import com.swirlds.platform.listeners.PlatformStatusChangeNotification; +import com.swirlds.platform.listeners.ReconnectCompleteListener; +import com.swirlds.platform.listeners.ReconnectCompleteNotification; +import com.swirlds.platform.listeners.StateWriteToDiskCompleteListener; +import com.swirlds.platform.listeners.StateWriteToDiskCompleteNotification; +import com.swirlds.platform.system.SwirldState; +import com.swirlds.platform.system.state.notifications.AsyncFatalIssListener; +import com.swirlds.platform.system.state.notifications.IssListener; +import com.swirlds.platform.system.state.notifications.IssNotification; +import com.swirlds.platform.system.state.notifications.IssNotification.IssType; +import com.swirlds.platform.system.state.notifications.NewSignedStateListener; +import com.swirlds.platform.system.state.notifications.NewSignedStateNotification; +import com.swirlds.platform.system.state.notifications.StateHashedListener; +import com.swirlds.platform.system.state.notifications.StateHashedNotification; +import com.swirlds.platform.system.status.PlatformStatus; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; + +public class DefaultAppNotifierTest { + + NotificationEngine notificationEngine; + AppNotifier notifier; + + @BeforeEach + void beforeEach() { + notificationEngine = mock(NotificationEngine.class); + notifier = new DefaultAppNotifier(notificationEngine); + } + + @Test + void testStateWrittenToDiskNotificationSent() { + final StateWriteToDiskCompleteNotification notification = + new StateWriteToDiskCompleteNotification(100, Instant.now(), false); + + assertDoesNotThrow(() -> notifier.sendStateWrittenToDiskNotification(notification)); + verify(notificationEngine, times(1)).dispatch(StateWriteToDiskCompleteListener.class, notification); + verifyNoMoreInteractions(notificationEngine); + } + + @Test + void testStateHashNotificationSent() { + final StateHashedNotification notification = new StateHashedNotification(100L, new Hash(DigestType.SHA_384)); + + assertDoesNotThrow(() -> notifier.sendStateHashedNotification(notification)); + verify(notificationEngine, times(1)).dispatch(StateHashedListener.class, notification); + verifyNoMoreInteractions(notificationEngine); + } + + @Test + void testReconnectCompleteNotificationSent() { + final SwirldState state = mock(SwirldState.class); + final ReconnectCompleteNotification notification = + new ReconnectCompleteNotification(100L, Instant.now(), state); + + assertDoesNotThrow(() -> notifier.sendReconnectCompleteNotification(notification)); + verify(notificationEngine, times(1)).dispatch(ReconnectCompleteListener.class, notification); + verifyNoMoreInteractions(notificationEngine); + } + + @Test + void testPlatformStatusChangeNotificationSent() { + final PlatformStatus status = PlatformStatus.ACTIVE; + final ArgumentCaptor captor = + ArgumentCaptor.forClass(PlatformStatusChangeNotification.class); + + assertDoesNotThrow(() -> notifier.sendPlatformStatusChangeNotification(status)); + verify(notificationEngine, times(1)).dispatch(eq(PlatformStatusChangeListener.class), captor.capture()); + verifyNoMoreInteractions(notificationEngine); + + final PlatformStatusChangeNotification notification = captor.getValue(); + assertNotNull(notification); + assertEquals(status, notification.getNewStatus()); + } + + @Test + void testLatestCompleteStateNotificationSent() { + final SwirldState state = mock(SwirldState.class); + final CompletionCallback> cleanup = + mock(CompletionCallback.class); + final NewSignedStateNotification signedStateNotification = + new NewSignedStateNotification(state, 100L, Instant.now()); + final CompleteStateNotificationWithCleanup notificationWithCleanup = + new CompleteStateNotificationWithCleanup(signedStateNotification, cleanup); + + assertDoesNotThrow(() -> notifier.sendLatestCompleteStateNotification(notificationWithCleanup)); + verify(notificationEngine, times(1)).dispatch(NewSignedStateListener.class, signedStateNotification, cleanup); + verifyNoMoreInteractions(notificationEngine); + } + + public static List issTypes() { + return List.of( + Arguments.of(IssType.CATASTROPHIC_ISS, true), + Arguments.of(IssType.SELF_ISS, true), + Arguments.of(IssType.OTHER_ISS, false)); + } + + @ParameterizedTest + @MethodSource("issTypes") + void testIssNotificationSent(final IssType type, final boolean isFatal) { + final IssNotification notification = new IssNotification(100L, type); + + assertDoesNotThrow(() -> notifier.sendIssNotification(notification)); + + // verify the ISS notification is always sent to the IssListener + verify(notificationEngine, times(1)).dispatch(IssListener.class, notification); + + if (isFatal) { + // if the ISS event is considered fatal to the local node, verify the event is also sent to the + // FatalIssListener + verify(notificationEngine, times(1)).dispatch(AsyncFatalIssListener.class, notification); + } + + verifyNoMoreInteractions(notificationEngine); + } +} diff --git a/platform-sdk/swirlds-unit-tests/core/swirlds-platform-test/src/test/java/com/swirlds/platform/test/state/IssHandlerTests.java b/platform-sdk/swirlds-unit-tests/core/swirlds-platform-test/src/test/java/com/swirlds/platform/test/state/IssHandlerTests.java index ab2034142f41..ab988216a816 100644 --- a/platform-sdk/swirlds-unit-tests/core/swirlds-platform-test/src/test/java/com/swirlds/platform/test/state/IssHandlerTests.java +++ b/platform-sdk/swirlds-unit-tests/core/swirlds-platform-test/src/test/java/com/swirlds/platform/test/state/IssHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2024 Hedera Hashgraph, LLC + * Copyright (C) 2022-2025 Hedera Hashgraph, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -135,7 +135,7 @@ void selfIssAutomatedRecovery() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } @Test @@ -167,7 +167,7 @@ void selfIssNoAction() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } @Test @@ -205,7 +205,7 @@ void selfIssAlwaysFreeze() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } @Test @@ -237,7 +237,7 @@ void catastrophicIssNoAction() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } @Test @@ -275,7 +275,7 @@ void catastrophicIssAlwaysFreeze() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } @Test @@ -313,6 +313,6 @@ void catastrophicIssFreezeOnCatastrophic() { final SerializableLong issRound = simpleScratchpad.get(IssScratchpad.LAST_ISS_ROUND); assertNotNull(issRound); - assertEquals(issRound.getValue(), 1234L); + assertEquals(1234L, issRound.getValue()); } }