fix(pubsub): clean up failed writer creation and deferred deletion

Roll back writers whose information model representation fails. A deferred
connection or WriterGroup delete asks the lifecycle callback only once,
retries vetoed writers, rejects new ones and completes a manager stop.
This commit is contained in:
Andreas Ebner
2026-09-28 15:25:56 +02:00
committed by Andreas Ebner
parent 2810e1ec1b
commit 461e46182c
7 changed files with 298 additions and 16 deletions
+4 -3
View File
@@ -6,9 +6,10 @@ refactorings and bug fixes are not reported here.
### Removing a PublishedDataSet removes the connected DataSetWriters
`UA_Server_removePublishedDataSet` removes the connected DataSetWriters together
with the PublishedDataSet. While the WriterGroup of a connected writer is
enabled, it returns `Bad_InvalidState` and changes nothing. Before, the
PublishedDataSet was freed even when removing a connected writer failed.
with the PublishedDataSet. If a connected writer has frozen the configuration,
it returns `Bad_ConfigurationError`. Otherwise, while the WriterGroup of a
connected writer is enabled, it returns `Bad_InvalidState` and changes nothing.
Before, the PublishedDataSet was freed even when removing a connected writer failed.
### PubSub message security with OpenSSL and LibreSSL
+3
View File
@@ -631,6 +631,9 @@ UA_Server_getWriterGroupLastPublishTimestamp(UA_Server *server,
const UA_NodeId wgId,
UA_DateTime *timestamp);
/* Remove the group and its DataSetWriters. A failed child removal is returned
* and leaves the group pending deletion; retry after resolving the failure.
* New writers cannot be added once group deletion has started. */
UA_EXPORT UA_StatusCode UA_THREADSAFE
UA_Server_removeWriterGroup(UA_Server *server, const UA_NodeId wgId);
+4 -2
View File
@@ -8,6 +8,7 @@
* Copyright (c) 2021 Fraunhofer IOSB (Author: Jan Hermes)
* Copyright (c) 2022 Siemens AG (Author: Thomas Fischer)
* Copyright (c) 2022 Fraunhofer IOSB (Author: Noel Graf)
* Copyright 2026 (c) o6 Automation GmbH (Author: Andreas Ebner)
*/
#include "ua_pubsub_internal.h"
@@ -168,9 +169,10 @@ UA_StatusCode
UA_PubSubConnection_delete(UA_PubSubManager *psm, UA_PubSubConnection *c) {
UA_LOCK_ASSERT(&psm->drv.server->serviceMutex);
/* Check with the application if we can remove */
/* Check with the application if we can remove. A deferred deletion calls
* this again when the channels are closed. Ask only on the first pass. */
UA_Server *server = psm->drv.server;
if(server->config.pubSubConfig.componentLifecycleCallback) {
if(!c->deleteFlag && server->config.pubSubConfig.componentLifecycleCallback) {
UA_StatusCode res = server->config.pubSubConfig.
componentLifecycleCallback(server, c->head.identifier,
UA_PUBSUBCOMPONENT_CONNECTION, true);
+13 -2
View File
@@ -8,7 +8,7 @@
* Copyright (c) 2020 Yannick Wallerer, Siemens AG
* Copyright (c) 2020 Thomas Fischer, Siemens AG
* Copyright (c) 2021 Fraunhofer IOSB (Author: Jan Hermes)
* Copyright 2025 (c) o6 Automation GmbH (Author: Andreas Ebner)
* Copyright 2025-2026 (c) o6 Automation GmbH (Author: Andreas Ebner)
* Copyright 2025 (c) o6 Automation GmbH (Author: Julius Pfrommer)
*/
@@ -177,6 +177,11 @@ UA_DataSetWriter_create(UA_PubSubManager *psm,
if(!wg)
return UA_STATUSCODE_BADNOTFOUND;
/* A disabled group may still be waiting for its transport channel to close
* or for an existing writer's removal to be approved. */
if(wg->deleteFlag)
return UA_STATUSCODE_BADINVALIDSTATE;
/* Make checks for a heartbeat */
if(UA_NodeId_isNull(&dataSet) && dswConfig->keyFrameCount != 1) {
UA_LOG_WARNING_PUBSUB(psm->logging, wg,
@@ -331,7 +336,13 @@ UA_DataSetWriter_create(UA_PubSubManager *psm,
/* Add to the information model */
#ifdef UA_ENABLE_PUBSUB_INFORMATIONMODEL
res |= addDataSetWriterRepresentation(psm->drv.server, dsw);
res = addDataSetWriterRepresentation(psm->drv.server, dsw);
if(res != UA_STATUSCODE_GOOD) {
/* Do not leave a linked writer behind for a failed creation */
UA_PubSubComponent_freeWithoutLifecycleCallback(
psm, dsw, UA_PUBSUBCOMPONENT_DATASETWRITER);
return res;
}
#else
UA_PubSubManager_generateUniqueNodeId(psm, &dsw->head.identifier);
#endif
+19 -9
View File
@@ -9,7 +9,7 @@
* Copyright (c) 2020 Thomas Fischer, Siemens AG
* Copyright (c) 2021 Fraunhofer IOSB (Author: Jan Hermes)
* Copyright (c) 2022 Linutronix GmbH (Author: Muddasir Shakil)
* Copyright 2025 (c) o6 Automation GmbH (Author: Andreas Ebner)
* Copyright 2025-2026 (c) o6 Automation GmbH (Author: Andreas Ebner)
* Copyright 2025 (c) o6 Automation GmbH (Author: Julius Pfrommer)
*/
@@ -263,9 +263,13 @@ UA_StatusCode
UA_WriterGroup_remove(UA_PubSubManager *psm, UA_WriterGroup *wg) {
UA_LOCK_ASSERT(&psm->drv.server->serviceMutex);
/* Check with the application if we can remove */
UA_PubSubConnection *connection = wg->linkedConnection;
UA_assert(connection);
/* Ask only once for the group. Remaining writers still need to be removed
* when a channel-close callback or an explicit retry resumes deletion. */
UA_Server *server = psm->drv.server;
if(server->config.pubSubConfig.componentLifecycleCallback) {
if(!wg->deleteFlag && server->config.pubSubConfig.componentLifecycleCallback) {
UA_StatusCode res = server->config.pubSubConfig.
componentLifecycleCallback(server, wg->head.identifier,
UA_PUBSUBCOMPONENT_WRITERGROUP, true);
@@ -273,18 +277,21 @@ UA_WriterGroup_remove(UA_PubSubManager *psm, UA_WriterGroup *wg) {
return res;
}
UA_PubSubConnection *connection = wg->linkedConnection;
UA_assert(connection);
/* Disable (and disconnect) and set the deleteFlag. This prevents a
* reconnect and triggers the deletion when the last open socket is
* closed. */
wg->deleteFlag = true;
UA_WriterGroup_setPubSubState(psm, wg, UA_PUBSUBSTATE_DISABLED);
UA_StatusCode res = UA_WriterGroup_setPubSubState(psm, wg, UA_PUBSUBSTATE_DISABLED);
if(res != UA_STATUSCODE_GOOD)
return res;
/* A veto must leave the group linked so the writer remains reachable and
* can release its PublishedDataSet's configuration freeze on a later retry. */
UA_DataSetWriter *dsw, *dsw_tmp;
LIST_FOREACH_SAFE(dsw, &wg->writers, listEntry, dsw_tmp) {
UA_DataSetWriter_remove(psm, dsw);
res = UA_DataSetWriter_remove(psm, dsw);
if(res != UA_STATUSCODE_GOOD)
return res;
}
if(wg->config.securityPolicy && wg->securityPolicyContext) {
@@ -1373,6 +1380,9 @@ WriterGroupChannelCallback(UA_ConnectionManager *cm, uintptr_t connectionId,
/* PSC marked for deletion and the last EventLoop connection has closed */
if(wg->deleteFlag) {
UA_WriterGroup_remove(psm, wg);
/* The last socket is closed even if a child veto kept the
* group alive. Allow server shutdown to complete. */
UA_PubSubManager_setState(psm, psm->drv.state);
unlockServer(server);
return;
}
@@ -1639,7 +1649,7 @@ UA_Server_removeWriterGroup(UA_Server *server, const UA_NodeId writerGroup) {
UA_PubSubManager *psm = getPSM(server);
UA_WriterGroup *wg = UA_WriterGroup_find(psm, writerGroup);
if(wg)
UA_WriterGroup_remove(psm, wg);
res = UA_WriterGroup_remove(psm, wg);
else
res = UA_STATUSCODE_BADNOTFOUND;
unlockServer(server);
+210
View File
@@ -12,6 +12,7 @@
#include "ua_server_internal.h"
#include "ua_pubsub_internal.h"
#include "test_helpers.h"
#include "testing_clock.h"
#include <check.h>
#include <stdlib.h>
@@ -455,6 +456,211 @@ START_TEST(RemovePdsRemovesWritersInDisabledGroups) {
UA_PublishedDataSet_find(psm, pdsB));
} END_TEST
static UA_NodeId
addTestUnicastWriterGroup(UA_NodeId connId) {
UA_WriterGroupConfig wgc;
memset(&wgc, 0, sizeof(wgc));
wgc.name = UA_STRING("WG-Unicast-Remove");
wgc.writerGroupId = 1;
wgc.publishingInterval = 100;
wgc.encodingMimeType = UA_PUBSUB_ENCODING_UADP;
UA_DatagramWriterGroupTransport2DataType transport;
memset(&transport, 0, sizeof(transport));
UA_NetworkAddressUrlDataType address =
{UA_STRING_NULL, UA_STRING("opc.udp://127.0.0.1:4841/")};
UA_ExtensionObject_setValue(&transport.address, &address,
&UA_TYPES[UA_TYPES_NETWORKADDRESSURLDATATYPE]);
UA_ExtensionObject_setValue(&wgc.transportSettings, &transport,
&UA_TYPES[UA_TYPES_DATAGRAMWRITERGROUPTRANSPORT2DATATYPE]);
UA_NodeId wgId;
UA_StatusCode rv = UA_Server_addWriterGroup(server, connId, &wgc, &wgId);
ck_assert_int_eq(rv, UA_STATUSCODE_GOOD);
return wgId;
}
static void
iteratePubSub(void) {
for(size_t i = 0; i < 10; i++) {
UA_fakeSleep(10);
UA_Server_run_iterate(server, false);
}
}
static size_t writerRemovalVetoes;
static size_t writerRemovalNotifications;
static size_t groupRemovalNotifications;
static UA_StatusCode
countAndVetoWriterRemoval(UA_Server *s, const UA_NodeId id,
const UA_PubSubComponentType componentType,
UA_Boolean remove) {
if(!remove)
return UA_STATUSCODE_GOOD;
if(componentType == UA_PUBSUBCOMPONENT_WRITERGROUP)
groupRemovalNotifications++;
if(componentType == UA_PUBSUBCOMPONENT_DATASETWRITER) {
writerRemovalNotifications++;
if(writerRemovalVetoes > 0) {
writerRemovalVetoes--;
return UA_STATUSCODE_BADUSERACCESSDENIED;
}
}
return UA_STATUSCODE_GOOD;
}
static void
setWriterRemovalVetoes(size_t count) {
writerRemovalVetoes = count;
writerRemovalNotifications = 0;
groupRemovalNotifications = 0;
UA_Server_getConfig(server)->pubSubConfig.componentLifecycleCallback =
countAndVetoWriterRemoval;
}
/* The group has its own socket, so closing it really resumes removal. A
* previously vetoed writer must be removed without asking about the group again. */
START_TEST(DeferredWriterGroupDeleteRetriesVetoedWriter) {
UA_PubSubManager *psm = getPSM(server);
UA_NodeId pdsId = addTestPds("PDS-Deferred-Veto");
UA_NodeId connId = addTestConnection();
UA_NodeId wgId = addTestUnicastWriterGroup(connId);
UA_NodeId dswId = addTestWriter(wgId, pdsId, "DSW-Deferred-Veto", 1);
ck_assert_int_eq(UA_Server_enableAllPubSubComponents(server), UA_STATUSCODE_GOOD);
iteratePubSub();
UA_WriterGroup *wg = UA_WriterGroup_find(psm, wgId);
UA_PublishedDataSet *pds = UA_PublishedDataSet_find(psm, pdsId);
ck_assert(wg->sendChannel != 0);
ck_assert_uint_eq(pds->configurationFreezeCounter, 1);
setWriterRemovalVetoes(1);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId),
UA_STATUSCODE_BADUSERACCESSDENIED);
ck_assert(wg->deleteFlag && wg->sendChannel != 0);
ck_assert_uint_eq(wg->writersCount, 1);
iteratePubSub();
ck_assert_ptr_null(UA_WriterGroup_find(psm, wgId));
ck_assert_ptr_null(UA_DataSetWriter_find(psm, dswId));
ck_assert_uint_eq(groupRemovalNotifications, 1);
ck_assert_uint_eq(writerRemovalNotifications, 2);
ck_assert_uint_eq(pds->configurationFreezeCounter, 0);
ck_assert_int_eq(UA_Server_removePublishedDataSet(server, pdsId), UA_STATUSCODE_GOOD);
UA_Server_getConfig(server)->pubSubConfig.componentLifecycleCallback = NULL;
} END_TEST
/* A persistent child veto must retain both parent and child, with or without a
* deferred channel close. An explicit retry completes the approved deletion. */
START_TEST(WriterGroupDeletePreservesVetoedWriter) {
UA_PubSubManager *psm = getPSM(server);
UA_NodeId pdsId = addTestPds("PDS-Persistent-Veto");
UA_NodeId connId = addTestConnection();
UA_NodeId wgId = (_i == 0) ? addTestWriterGroup(connId, "WG-Veto", 1) :
addTestUnicastWriterGroup(connId);
UA_NodeId dswId = addTestWriter(wgId, pdsId, "DSW-Persistent-Veto", 1);
ck_assert_int_eq(UA_Server_enableAllPubSubComponents(server), UA_STATUSCODE_GOOD);
iteratePubSub();
UA_WriterGroup *wg = UA_WriterGroup_find(psm, wgId);
if(_i != 0)
ck_assert(wg->sendChannel != 0);
setWriterRemovalVetoes(100);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId),
UA_STATUSCODE_BADUSERACCESSDENIED);
iteratePubSub();
ck_assert_ptr_eq(UA_WriterGroup_find(psm, wgId), wg);
ck_assert(wg->deleteFlag && wg->sendChannel == 0);
ck_assert_uint_eq(wg->writersCount, 1);
UA_DataSetWriter *dsw = UA_DataSetWriter_find(psm, dswId);
ck_assert_ptr_nonnull(dsw);
ck_assert_ptr_eq(dsw->linkedWriterGroup, wg);
UA_PublishedDataSet *pds = UA_PublishedDataSet_find(psm, pdsId);
ck_assert_uint_eq(pds->configurationFreezeCounter, 1);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId),
UA_STATUSCODE_BADUSERACCESSDENIED);
ck_assert_ptr_eq(UA_WriterGroup_find(psm, wgId), wg);
writerRemovalVetoes = 0;
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId), UA_STATUSCODE_GOOD);
ck_assert_ptr_null(UA_WriterGroup_find(psm, wgId));
ck_assert_ptr_null(UA_DataSetWriter_find(psm, dswId));
ck_assert_uint_eq(groupRemovalNotifications, 1);
ck_assert_uint_eq(pds->configurationFreezeCounter, 0);
ck_assert_int_eq(UA_Server_removePublishedDataSet(server, pdsId), UA_STATUSCODE_GOOD);
UA_Server_getConfig(server)->pubSubConfig.componentLifecycleCallback = NULL;
} END_TEST
START_TEST(DeferredWriterGroupDeleteRejectsNewWriters) {
UA_PubSubManager *psm = getPSM(server);
UA_NodeId pdsId = addTestPds("PDS-Late-Writer");
UA_NodeId connId = addTestConnection();
UA_NodeId wgId = addTestUnicastWriterGroup(connId);
ck_assert_int_eq(UA_Server_enableAllPubSubComponents(server), UA_STATUSCODE_GOOD);
iteratePubSub();
UA_WriterGroup *wg = UA_WriterGroup_find(psm, wgId);
ck_assert(wg->sendChannel != 0);
setWriterRemovalVetoes(0);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId), UA_STATUSCODE_GOOD);
ck_assert(wg->deleteFlag && wg->sendChannel != 0);
UA_DataSetWriterConfig dswc;
memset(&dswc, 0, sizeof(dswc));
dswc.name = UA_STRING("DSW-Late");
dswc.dataSetWriterId = 1;
ck_assert_int_eq(UA_Server_addDataSetWriter(server, wgId, pdsId, &dswc, NULL),
UA_STATUSCODE_BADINVALIDSTATE);
ck_assert_uint_eq(wg->writersCount, 0);
ck_assert_uint_eq(UA_PublishedDataSet_find(psm, pdsId)->configurationFreezeCounter, 0);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId), UA_STATUSCODE_GOOD);
iteratePubSub();
ck_assert_ptr_null(UA_WriterGroup_find(psm, wgId));
ck_assert_uint_eq(groupRemovalNotifications, 1);
ck_assert_uint_eq(writerRemovalNotifications, 0);
ck_assert_int_eq(UA_Server_removePublishedDataSet(server, pdsId), UA_STATUSCODE_GOOD);
UA_Server_getConfig(server)->pubSubConfig.componentLifecycleCallback = NULL;
} END_TEST
/* A retained group has no open socket after its close callback. It must not
* prevent the PubSubManager from completing a stop request. */
START_TEST(DeferredWriterGroupVetoCompletesManagerStop) {
UA_PubSubManager *psm = getPSM(server);
UA_PubSubConnectionConfig cc;
memset(&cc, 0, sizeof(cc));
cc.name = UA_STRING("Conn-Unicast-Only");
cc.transportProfileUri =
UA_STRING("http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp");
UA_NetworkAddressUrlDataType address =
{UA_STRING_NULL, UA_STRING("opc.udp://localhost:4840/")};
UA_Variant_setScalar(&cc.address, &address,
&UA_TYPES[UA_TYPES_NETWORKADDRESSURLDATATYPE]);
UA_NodeId connId;
ck_assert_int_eq(UA_Server_addPubSubConnection(server, &cc, &connId), UA_STATUSCODE_GOOD);
UA_NodeId pdsId = addTestPds("PDS-Stop-Veto");
UA_NodeId wgId = addTestUnicastWriterGroup(connId);
addTestWriter(wgId, pdsId, "DSW-Stop-Veto", 1);
ck_assert_int_eq(UA_Server_enableAllPubSubComponents(server), UA_STATUSCODE_GOOD);
iteratePubSub();
UA_WriterGroup *wg = UA_WriterGroup_find(psm, wgId);
ck_assert(wg->sendChannel != 0);
UA_PubSubConnection *conn = UA_PubSubConnection_find(psm, connId);
ck_assert_uint_eq(conn->sendChannel, 0);
ck_assert_uint_eq(conn->recvChannelsSize, 0);
setWriterRemovalVetoes(100);
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId),
UA_STATUSCODE_BADUSERACCESSDENIED);
lockServer(server);
UA_PubSubManager_setState(psm, UA_LIFECYCLESTATE_STOPPED);
unlockServer(server);
ck_assert_int_eq(psm->drv.state, UA_LIFECYCLESTATE_STOPPING);
iteratePubSub();
ck_assert_ptr_eq(UA_WriterGroup_find(psm, wgId), wg);
ck_assert_uint_eq(wg->sendChannel, 0);
ck_assert_int_eq(psm->drv.state, UA_LIFECYCLESTATE_STOPPED);
writerRemovalVetoes = 0;
ck_assert_int_eq(UA_Server_removeWriterGroup(server, wgId), UA_STATUSCODE_GOOD);
ck_assert_int_eq(UA_Server_removePublishedDataSet(server, pdsId), UA_STATUSCODE_GOOD);
UA_Server_getConfig(server)->pubSubConfig.componentLifecycleCallback = NULL;
} END_TEST
START_TEST(GetPublishedDataSetConfigInvalidArgs) {
/* unknown id */
UA_PublishedDataSetConfig copy;
@@ -507,6 +713,10 @@ int main(void) {
tcase_add_test(tc_pds_extra, RemovePdsWithWriterInEnabledGroupFails);
tcase_add_test(tc_pds_extra, RemovePdsStopsOnVetoedWriterRemoval);
tcase_add_test(tc_pds_extra, RemovePdsRemovesWritersInDisabledGroups);
tcase_add_test(tc_pds_extra, DeferredWriterGroupDeleteRetriesVetoedWriter);
tcase_add_loop_test(tc_pds_extra, WriterGroupDeletePreservesVetoedWriter, 0, 2);
tcase_add_test(tc_pds_extra, DeferredWriterGroupDeleteRejectsNewWriters);
tcase_add_test(tc_pds_extra, DeferredWriterGroupVetoCompletesManagerStop);
Suite *s = suite_create("PubSub PublishedDataSets handling");
suite_add_tcase(s, tc_add_pubsub_pds_minimal_config);
+45
View File
@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) 2024 open62541 contributors.
* Copyright 2026 (c) o6 Automation GmbH (Author: Andreas Ebner)
*
* Unit tests that exercise code paths in the PubSub state machines that are
* not covered by the existing test suite. The targeted branches are the ones
@@ -21,6 +22,7 @@
* addReaderGroup/addWriterGroup.
* - Some null-argument and unknown-connection paths in the public
* addReaderGroup / addWriterGroup wrappers.
* - A deferred connection delete asks the componentLifecycleCallback once.
*/
#include <open62541/server.h>
@@ -31,6 +33,7 @@
#include "ua_server_internal.h"
#include "test_helpers.h"
#include "testing_clock.h"
#include <check.h>
#include <stdlib.h>
@@ -459,6 +462,47 @@ START_TEST(RemoveReaderGroup_UnknownReturnsBadNotFound) {
ck_assert_int_eq(res, UA_STATUSCODE_BADNOTFOUND);
} END_TEST
/* Removal notifications per component type */
static size_t removeNotifications[8];
static UA_StatusCode
removeCountingCallback(UA_Server *s, const UA_NodeId id,
const UA_PubSubComponentType componentType,
UA_Boolean remove) {
if(remove && (size_t)componentType < 8)
removeNotifications[componentType]++;
return UA_STATUSCODE_GOOD;
}
/* A deferred delete (sockets still open) completes in later EventLoop
* iterations. The application is asked only once per component. */
START_TEST(DeferredDeleteNotifiesOnce) {
UA_Server_run_startup(server);
addMinimalPubSubConnection();
addMinimalWriterGroup();
UA_StatusCode res = UA_Server_enableAllPubSubComponents(server);
ck_assert_int_eq(res, UA_STATUSCODE_GOOD);
for(size_t i = 0; i < 5; i++) {
UA_fakeSleep(50);
UA_Server_run_iterate(server, false);
}
UA_PubSubManager *psm = getPSM(server);
memset(removeNotifications, 0, sizeof(removeNotifications));
UA_ServerConfig *sc = UA_Server_getConfig(server);
sc->pubSubConfig.componentLifecycleCallback = removeCountingCallback;
res = UA_Server_removePubSubConnection(server, connectionIdent);
ck_assert_int_eq(res, UA_STATUSCODE_GOOD);
for(size_t i = 0; i < 100 && psm->connectionsSize > 0; i++) {
UA_fakeSleep(50);
UA_Server_run_iterate(server, false);
}
sc->pubSubConfig.componentLifecycleCallback = NULL;
ck_assert_uint_eq(psm->connectionsSize, 0);
ck_assert_uint_eq(removeNotifications[UA_PUBSUBCOMPONENT_CONNECTION], 1);
ck_assert_uint_eq(removeNotifications[UA_PUBSUBCOMPONENT_WRITERGROUP], 1);
} END_TEST
static Suite *
stateMachineSuite(void) {
Suite *s = suite_create("PubSub state machine edge cases");
@@ -487,6 +531,7 @@ stateMachineSuite(void) {
tcase_add_test(tc_api, RemovePubSubConnection_UnknownReturnsBadNotFound);
tcase_add_test(tc_api, RemoveWriterGroup_UnknownReturnsBadNotFound);
tcase_add_test(tc_api, RemoveReaderGroup_UnknownReturnsBadNotFound);
tcase_add_test(tc_api, DeferredDeleteNotifiesOnce);
suite_add_tcase(s, tc_wg_state);
suite_add_tcase(s, tc_rg_state);