From 90effffe8605a0224b2d152408306f759a1da9fa Mon Sep 17 00:00:00 2001 From: Andreas Ebner Date: Wed, 14 Oct 2026 14:47:14 +0200 Subject: [PATCH] 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). --- drivers/file_transfer/file_transfer_driver.c | 29 ++++- drivers/file_transfer/file_transfer_file.c | 32 ++++++ .../file_transfer/file_transfer_internal.h | 5 + include/open62541/driver/file_transfer.h | 6 + tests/server/check_server_filetransfer.c | 108 ++++++++++++++++++ 5 files changed, 175 insertions(+), 5 deletions(-) diff --git a/drivers/file_transfer/file_transfer_driver.c b/drivers/file_transfer/file_transfer_driver.c index 33caca802..bc78645f5 100644 --- a/drivers/file_transfer/file_transfer_driver.c +++ b/drivers/file_transfer/file_transfer_driver.c @@ -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) diff --git a/drivers/file_transfer/file_transfer_file.c b/drivers/file_transfer/file_transfer_file.c index 1aae212e7..179566fc1 100644 --- a/drivers/file_transfer/file_transfer_file.c +++ b/drivers/file_transfer/file_transfer_file.c @@ -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); diff --git a/drivers/file_transfer/file_transfer_internal.h b/drivers/file_transfer/file_transfer_internal.h index fe691df15..7af2d7ff1 100644 --- a/drivers/file_transfer/file_transfer_internal.h +++ b/drivers/file_transfer/file_transfer_internal.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); diff --git a/include/open62541/driver/file_transfer.h b/include/open62541/driver/file_transfer.h index dfbe2438d..cd392366e 100644 --- a/include/open62541/driver/file_transfer.h +++ b/include/open62541/driver/file_transfer.h @@ -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; diff --git a/tests/server/check_server_filetransfer.c b/tests/server/check_server_filetransfer.c index 7913b661f..4ad3f8f52 100644 --- a/tests/server/check_server_filetransfer.c +++ b/tests/server/check_server_filetransfer.c @@ -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);