feat(driver): close file handles without activity after a timeout

The activity-timeout parameter closes handles without a Method call like Close,
e.g. for the ActivityTimeout of a TrustList (Part 12 7.8.2.1).
This commit is contained in:
Andreas Ebner
2026-10-14 14:47:14 +02:00
committed by Andreas Ebner
parent 0c253ab297
commit 90effffe86
5 changed files with 175 additions and 5 deletions
+24 -5
View File
@@ -131,6 +131,14 @@ driverParam(FileTransferDriver *ftd, const char *key, const UA_DataType *type,
return NULL;
}
/* Zero disables a timer. Otherwise the timer needs at least one DateTime tick
* and an Int64 tick count (rejects NaN and infinity). */
static UA_Boolean
validInterval(UA_Double ms) {
return ms == 0.0 || (ms * UA_DATETIME_MSEC >= 1.0 &&
ms <= (UA_Double)UA_INT64_MAX / UA_DATETIME_MSEC);
}
static UA_StatusCode
configureDriver(FileTransferDriver *ftd) {
UA_StatusCode res = UA_STATUSCODE_GOOD;
@@ -174,13 +182,14 @@ configureDriver(FileTransferDriver *ftd) {
driverParam(ftd, "refresh-interval", &UA_TYPES[UA_TYPES_DOUBLE], &res);
if(interval)
ftd->config.refreshInterval = *interval;
const UA_Double *timeout = (const UA_Double*)
driverParam(ftd, "activity-timeout", &UA_TYPES[UA_TYPES_DOUBLE], &res);
if(timeout)
ftd->config.activityTimeout = *timeout;
if(res != UA_STATUSCODE_GOOD)
return res;
/* Zero disables the periodic refresh. Otherwise the timer needs at least
* one DateTime tick and an Int64 tick count (rejects NaN and infinity). */
UA_Double ri = ftd->config.refreshInterval;
if(ri != 0.0 && !(ri * UA_DATETIME_MSEC >= 1.0 &&
ri <= (UA_Double)UA_INT64_MAX / UA_DATETIME_MSEC))
if(!validInterval(ftd->config.refreshInterval) ||
!validInterval(ftd->config.activityTimeout))
return UA_STATUSCODE_BADINVALIDARGUMENT;
if(!backendComplete(&ftd->backend, !ftd->root->isDirectory, ftd->config.readOnly))
return UA_STATUSCODE_BADINVALIDARGUMENT;
@@ -250,6 +259,10 @@ startDriver(UA_Driver *drv) {
ftd->config.refreshInterval > 0)
res = UA_Server_addRepeatedCallback(drv->server, refreshCallback, ftd,
ftd->config.refreshInterval, &ftd->refreshCallbackId);
if(res == UA_STATUSCODE_GOOD && ftd->config.activityTimeout > 0)
res = UA_Server_addRepeatedCallback(drv->server, sweepIdleHandles, ftd,
ftd->config.activityTimeout,
&ftd->sweepCallbackId);
if(res != UA_STATUSCODE_GOOD)
FileTransferDriver_stop(drv);
return res;
@@ -283,6 +296,10 @@ FileTransferDriver_stop(UA_Driver *drv) {
UA_Server_removeCallback(drv->server, ftd->refreshCallbackId);
ftd->refreshCallbackId = 0;
}
if(ftd->sweepCallbackId) {
UA_Server_removeCallback(drv->server, ftd->sweepCallbackId);
ftd->sweepCallbackId = 0;
}
if(wasStarted)
ZIP_ITER(FTEntriesById, &ftd->entriesByNodeId, unbindEntryMethods, NULL);
@@ -528,6 +545,8 @@ UA_FileTransferDriver_getHandleInfo(UA_Driver *driver, const UA_NodeId fileNodeI
UA_EventLoop *el = UA_Server_getConfig(driver->server)->eventLoop;
el->lock(el);
FTHandle *h = findObjectHandle(driver, &fileNodeId, sessionId, fileHandle);
if(h)
touchFTHandle(driver->server, h);
if(h && mode)
*mode = h->mode;
if(h && backendHandle)
@@ -175,6 +175,13 @@ newHandleId(FileTransferDriver *ftd) {
return ftd->nextHandle;
}
/* A Method call on the handle restarts its activity-timeout */
void
touchFTHandle(UA_Server *server, FTHandle *h) {
UA_EventLoop *el = UA_Server_getConfig(server)->eventLoop;
h->lastActivity = el->dateTime_nowMonotonic(el);
}
/* Close the backend handle and release the handle. Removes zombie nodes once
* their last handle is closed. */
UA_StatusCode
@@ -225,6 +232,29 @@ closeSessionHandles(UA_Server *server, FileTransferDriver *ftd,
closeFTHandle(server, h);
}
static void *
findIdleHandle(void *context, FTHandle *h) {
return h->lastActivity <= *(UA_DateTime*)context ? h : NULL;
}
/* Close the handles without a Method call within the activity-timeout like
* Close. The search starts again after each removal. */
void
sweepIdleHandles(UA_Server *server, void *driver) {
FileTransferDriver *ftd = (FileTransferDriver*)driver;
UA_EventLoop *el = UA_Server_getConfig(server)->eventLoop;
UA_DateTime limit = el->dateTime_nowMonotonic(el) -
(UA_DateTime)(ftd->config.activityTimeout * UA_DATETIME_MSEC);
FTHandle *h;
while((h = (FTHandle*)ZIP_ITER(FTHandlesById, &ftd->handlesById,
findIdleHandle, &limit))) {
UA_LOG_INFO(UA_Server_getConfig(server)->logging, UA_LOGCATEGORY_SERVER,
"FileTransfer: Closing the idle FileHandle %u of %N",
(unsigned)h->handle, h->file->nodeId);
closeFTHandle(server, h);
}
}
/**************************************
* FileType Method Callbacks
**************************************/
@@ -242,6 +272,7 @@ resolveHandle(FileTransferDriver *ftd, const UA_NodeId *sessionId,
FTHandle *h = findFTHandle(ftd, sessionId, handleId);
if(!h || !UA_NodeId_equal(&h->file->nodeId, objectId))
return UA_STATUSCODE_BADINVALIDARGUMENT;
touchFTHandle(ftd->driver.server, h);
*outHandle = h;
return UA_STATUSCODE_GOOD;
}
@@ -310,6 +341,7 @@ openFileHandle(UA_Server *server, FTEntry *node,
h->file = node;
h->mode = mode;
h->backendHandle = backendHandle;
touchFTHandle(server, h);
ZIP_INSERT(FTHandlesById, &ftd->handlesById, h);
ZIP_INSERT(FTHandlesBySession, &ftd->handlesBySession, h);
@@ -46,6 +46,7 @@ typedef struct FTHandle {
FTEntry *file;
UA_Byte mode; /* UA_OPENFILEMODE_* bit mask, matching the Open Method Byte */
UA_UInt32 backendHandle;
UA_DateTime lastActivity; /* Monotonic, for the activity-timeout */
} FTHandle;
ZIP_HEAD(FTEntriesById, FTEntry);
@@ -87,6 +88,7 @@ typedef struct {
UA_UInt16 maxHandlesPerSession;
UA_UInt16 maxHandlesPerFile;
UA_UInt32 maxReadLength;
UA_Double activityTimeout;
} FTConfig;
/* One driver owns one backend and one root Object for its entire lifetime. */
@@ -96,6 +98,7 @@ struct FileTransferDriver {
FTConfig config;
FTEntry *root;
UA_UInt64 refreshCallbackId;
UA_UInt64 sweepCallbackId; /* Closes idle handles */
UA_StatusCode refreshResult; /* Of the last periodic refresh */
UA_Boolean scanReported; /* lastScan is set; reset on start */
FTScanSummary lastScan; /* Of the last refresh of the root */
@@ -195,6 +198,8 @@ UA_StatusCode openFileHandle(UA_Server *server, FTEntry *node,
UA_Byte mode, void *openContext,
FTHandle **outHandle);
UA_StatusCode closeFTHandle(UA_Server *server, FTHandle *handle);
void touchFTHandle(UA_Server *server, FTHandle *handle);
void sweepIdleHandles(UA_Server *server, void *driver);
void closeDriverHandles(UA_Server *server, FileTransferDriver *ftd);
void closeSessionHandles(UA_Server *server, FileTransferDriver *ftd,
const UA_NodeId *sessionId);
+6
View File
@@ -233,6 +233,12 @@ struct UA_FileTransferBackend {
* 0:max-open-handles-per-file [UInt16]
* Open-handle limit per file (default: 16). Either handle limit makes
* Open return Bad_ResourceUnavailable when reached.
* 0:activity-timeout [Double]
* Close handles without a Method call for longer than this many
* milliseconds like Close, e.g. for the ActivityTimeout of a TrustList
* (Part 12, 7.8.2.1). The FileType Methods, openHandle and getHandleInfo
* count as calls. Idle handles are closed within twice the timeout. 0
* disables it (default); otherwise at least 0.0001.
* 0:max-read-length [UInt32]
* Maximum bytes per Read or Write (default, also when zero: 1048576).
* Published as MaxByteStringLength. Reads are capped at this length;
+108
View File
@@ -5427,6 +5427,111 @@ START_TEST(directoryLifetimeAndAutomaticRefresh) {
UA_NodeId_clear(&file);
} END_TEST
static void
setActivityTimeout(UA_Driver *driver, UA_Double timeout) {
ck_assert_uint_eq(UA_KeyValueMap_setScalar(&driver->params,
UA_QUALIFIEDNAME(0, "activity-timeout"), &timeout,
&UA_TYPES[UA_TYPES_DOUBLE]), UA_STATUSCODE_GOOD);
}
static void
advanceClock(UA_UInt32 ms) {
UA_fakeSleep(ms);
UA_Server_run_iterate(server_ft, false);
}
/* Handles without a Method call for longer than the activity-timeout close
* like with Close */
START_TEST(activityTimeoutClosesIdleHandles) {
UA_Server_getConfig(server_ft)->tcpEnabled = false;
ck_assert_uint_eq(UA_Server_run_startup(server_ft), UA_STATUSCODE_GOOD);
UA_FileTransferBackend backend = memBackendWithFile("f.bin", "content");
UA_NodeId file;
UA_Driver *driver = newTestFile(server_ft, &backend.file, "Timed", &file);
setActivityTimeout(driver, 100);
ck_assert_uint_eq(UA_Server_addDriver(server_ft, driver), UA_STATUSCODE_GOOD);
/* Method calls keep the handle open */
UA_UInt32 h = callOpen(file, UA_OPENFILEMODE_READ, UA_STATUSCODE_GOOD);
for(size_t i = 0; i < 4; i++) {
advanceClock(60);
callGetPosition(file, h);
}
/* So do the calls of subtype Methods */
const UA_NodeId *session = &server_ft->adminSession.sessionId;
for(size_t i = 0; i < 3; i++) {
advanceClock(60);
ck_assert_uint_eq(UA_FileTransferDriver_getHandleInfo(driver, file, session,
h, NULL, NULL), UA_STATUSCODE_GOOD);
}
ck_assert_uint_eq(readOpenCount(file), 1);
/* An idle handle closes within twice the timeout */
advanceClock(201);
ck_assert_uint_eq(readOpenCount(file), 0);
callClose(file, h, UA_STATUSCODE_BADINVALIDARGUMENT);
/* Without the parameter, the handles stay open */
driver->stop(driver);
ck_assert_uint_eq(UA_KeyValueMap_remove(&driver->params,
UA_QUALIFIEDNAME(0, "activity-timeout")), UA_STATUSCODE_GOOD);
ck_assert_uint_eq(driver->start(driver), UA_STATUSCODE_GOOD);
h = callOpen(file, UA_OPENFILEMODE_READ, UA_STATUSCODE_GOOD);
advanceClock(10 * 60 * 1000);
ck_assert_uint_eq(readOpenCount(file), 1);
callClose(file, h, UA_STATUSCODE_GOOD);
ck_assert_uint_eq(UA_Server_run_shutdown(server_ft), UA_STATUSCODE_GOOD);
UA_NodeId_clear(&file);
} END_TEST
/* Closing an idle handle also removes a vanished file it kept */
START_TEST(activityTimeoutClosesZombies) {
UA_Server_getConfig(server_ft)->tcpEnabled = false;
ck_assert_uint_eq(UA_Server_run_startup(server_ft), UA_STATUSCODE_GOOD);
UA_FileTransferBackend backend = memBackendWithTree();
UA_NodeId root;
UA_Driver *driver = newTestDirectory(server_ft, &backend, &root);
UA_Double interval = 0;
ck_assert_uint_eq(UA_KeyValueMap_setScalar(&driver->params,
UA_QUALIFIEDNAME(0, "refresh-interval"), &interval,
&UA_TYPES[UA_TYPES_DOUBLE]), UA_STATUSCODE_GOOD);
setActivityTimeout(driver, 100);
ck_assert_uint_eq(UA_Server_addDriver(server_ft, driver), UA_STATUSCODE_GOOD);
UA_NodeId docs = resolveChild(server_ft, root, "docs");
UA_NodeId file = resolveChild(server_ft, docs, "a.txt");
callOpen(file, UA_OPENFILEMODE_READ, UA_STATUSCODE_GOOD);
ck_assert_uint_eq(backend.remove(&backend, UA_STRING("docs/a.txt")),
UA_STATUSCODE_GOOD);
ck_assert_uint_eq(testRefresh(driver, root), UA_STATUSCODE_GOOD);
ck_assert(tryResolveChild(server_ft, docs, "a.txt", NULL));
advanceClock(201);
ck_assert(!tryResolveChild(server_ft, docs, "a.txt", NULL));
ck_assert_uint_eq(UA_Server_run_shutdown(server_ft), UA_STATUSCODE_GOOD);
UA_NodeId_clear(&file);
UA_NodeId_clear(&docs);
UA_NodeId_clear(&root);
} END_TEST
START_TEST(activityTimeoutParameter) {
UA_FileTransferBackend backend = memBackendWithFile("f.bin", "content");
UA_Driver *driver = newTestFile(server_ft, &backend.file, "Timed", NULL);
ck_assert_uint_eq(UA_Server_addDriver(server_ft, driver), UA_STATUSCODE_GOOD);
/* Negative, NaN, infinite and sub-tick timeouts are rejected */
UA_Double zero = 0.0;
UA_Double invalid[4] = {-1, zero / zero, 1.0 / zero, 0.00001};
for(size_t i = 0; i < 4; i++) {
setActivityTimeout(driver, invalid[i]);
ck_assert_uint_eq(driver->start(driver), UA_STATUSCODE_BADINVALIDARGUMENT);
}
UA_UInt32 wrong = 100;
ck_assert_uint_eq(UA_KeyValueMap_setScalar(&driver->params,
UA_QUALIFIEDNAME(0, "activity-timeout"), &wrong,
&UA_TYPES[UA_TYPES_UINT32]), UA_STATUSCODE_GOOD);
ck_assert_uint_eq(driver->start(driver), UA_STATUSCODE_BADINVALIDARGUMENT);
setActivityTimeout(driver, 0);
ck_assert_uint_eq(driver->start(driver), UA_STATUSCODE_GOOD);
} END_TEST
/* A negative refresh interval is rejected. 0 disables the periodic refresh;
* the application reconciles on demand. */
/* A known parameter with the wrong type fails the start instead of being
@@ -6366,6 +6471,9 @@ int main(void) {
tcase_add_test(tc_lifecycle, failedRegistrationRetainsObjects);
tcase_add_test(tc_lifecycle, directoryLifetimeAndAutomaticRefresh);
tcase_add_test(tc_lifecycle, disabledPeriodicRefresh);
tcase_add_test(tc_lifecycle, activityTimeoutClosesIdleHandles);
tcase_add_test(tc_lifecycle, activityTimeoutClosesZombies);
tcase_add_test(tc_lifecycle, activityTimeoutParameter);
tcase_add_test(tc_lifecycle, driverParametersRequireTheirTypes);
tcase_add_test(tc_lifecycle, directoryBackendCastsFileBackend);
tcase_add_test(tc_lifecycle, defaultNodeDescriptions);