From 9d406d2b594f7384d54c13954fe751a9b0517e37 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Mon, 14 Jul 2025 19:56:42 +0300 Subject: [PATCH] [bug] Add watchWithFallback to support fs.watch fallback on NFS; For bug 75439 --- Common/sources/runtimeConfigManager.js | 32 ++++++------- Common/sources/utils.js | 64 ++++++++++++++++++++++++++ DocService/sources/DocsCoServer.js | 4 +- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/Common/sources/runtimeConfigManager.js b/Common/sources/runtimeConfigManager.js index 1d9a8282..004ecaaa 100644 --- a/Common/sources/runtimeConfigManager.js +++ b/Common/sources/runtimeConfigManager.js @@ -99,15 +99,21 @@ async function saveConfig(ctx, config) { } /** - * Handle configuration file change events - * @param {string} eventType - Type of file system event (change, rename) - * @param {string} filename - Name of the file that triggered the event + * Supports both fs.watch (eventType, filename) and fs.watchFile (current, previous) callbacks */ -function handleConfigFileChange(eventType, filename) { +function handleConfigFileChange(eventTypeOrCurrent, filenameOrPrevious) { try { - if (configFileName === filename) { + let shouldReload = false; + + if (typeof eventTypeOrCurrent === 'object' && eventTypeOrCurrent.isFile) { + shouldReload = eventTypeOrCurrent.mtime !== filenameOrPrevious.mtime; + operationContext.global.logger.info(`handleConfigFileChange reloaded=${shouldReload} watchFile: ${configFileName}`); + } else { + shouldReload = configFileName === filenameOrPrevious; + operationContext.global.logger.info(`handleConfigFileChange reloaded=${shouldReload} watch ${eventTypeOrCurrent}: ${filenameOrPrevious}`); + } + if (shouldReload) { nodeCache.del(configFileName); - operationContext.global.logger.info(`handleConfigFileChange configuration ${eventType}: ${configFileName}`); } } catch (err) { operationContext.global.logger.error(`handleConfigFileChange error: ${err.message}`); @@ -117,21 +123,13 @@ function handleConfigFileChange(eventType, filename) { /** * Initialize the configuration directory watcher */ -function initRuntimeConfigWatcher(ctx) { +async function initRuntimeConfigWatcher(ctx) { if (!configFilePath) { ctx.logger.info(`runtimeConfig.filePath is not specified`); return; } - try { - const configDir = path.dirname(configFilePath); - const watcher = fsWatch.watch(configDir, handleConfigFileChange); - watcher.on('error', (err) => { - ctx.logger.warn(`initRuntimeConfigWatcher error: ${err.message}`); - }); - ctx.logger.info(`watching for runtime config changes in: ${configDir}`); - } catch (watchErr) { - ctx.logger.warn(`initRuntimeConfigWatcher error: ${watchErr.message}`); - } + const configDir = path.dirname(configFilePath); + await utils.watchWithFallback(ctx, configDir, configFilePath, handleConfigFileChange); } module.exports = { initRuntimeConfigWatcher, diff --git a/Common/sources/utils.js b/Common/sources/utils.js index 9f0c0c4e..36191440 100644 --- a/Common/sources/utils.js +++ b/Common/sources/utils.js @@ -40,6 +40,7 @@ const { buffer } = require('node:stream/consumers'); const { Transform } = require('stream'); var config = require('config'); var fs = require('fs'); +const fsPromises = require('node:fs/promises'); var path = require('path'); const crypto = require('crypto'); var url = require('url'); @@ -1361,3 +1362,66 @@ exports.isObject = isObject; exports.deepMergeObjects = deepMergeObjects; exports.NodeCache = NodeCache;//todo via require +//like suggestion in https://github.com/paulmillr/chokidar/issues/242#issuecomment-76205459 +const UNSAFE_MAGIC = new Set([ + 0x6969, // NFS + 0xFF534D42, // CIFS/SMB1 + 0xFE534D42, // SMB2+ + 0x517B, // legacy SMB + 0x65735546, // FUSE + 0x794C7630, // overlayfs + 0x00C36400, // CephFS + 0x73757245, // Coda + 0x6B414653 // AFS +]); + +/** + * Gets the file system type for the given path + * @param {operationContext} ctx - Operation context + * @param {string} path - Path to check + * @returns {Promise} File system type + */ +async function getFsType(ctx, path) { + try { + const statfs = await fsPromises.statfs(path); + const fsType = Number(statfs.type); + ctx.logger.info(`getFsType fs type=${fsType} ${path}`); + return fsType; + } catch (err) { + ctx.logger.info(`getFsType error: ${path}: ${err.message}`); + return null; + } +} + + +/** + * File watcher with native events fallback to polling + * @param {operationContext} ctx - Operation context + * @param {string} dirPath - Directory path to watch + * @param {string} filePath - File path to watch + * @param {Function} listener - Change event callback + * @param {Object} opts - Options + * @returns {Promise} Watcher instance + */ +exports.watchWithFallback = async function watchWithFallback(ctx, dirPath, filePath, listener, opts = {}) { + const fsType = await getFsType(ctx, dirPath); + if (null === fsType || UNSAFE_MAGIC.has(fsType)) { + ctx.logger.info(`watchWithFallback fs type=${fsType} unsupport watch. fallback to watchFile ${filePath}`); + return fs.watchFile(filePath, opts, listener); + } + + //Try native watch + try { + const watcher = fs.watch(dirPath, opts, listener); + watcher.on('error', (err) => { + watcher.close(); + ctx.logger.info(`watchWithFallback error ${dirPath} fallback to watchFile ${filePath}: ${err.message}`); + fs.watchFile(filePath, opts, listener); + }); + ctx.logger.info(`watchWithFallback watch: ${dirPath}`); + return watcher; + } catch (err) { + ctx.logger.info(`watchWithFallback error ${dirPath} fallback to watchFile ${filePath}: ${err.message}`); + return fs.watchFile(filePath, opts, listener); + } +} \ No newline at end of file diff --git a/DocService/sources/DocsCoServer.js b/DocService/sources/DocsCoServer.js index d547c998..bdda5274 100644 --- a/DocService/sources/DocsCoServer.js +++ b/DocService/sources/DocsCoServer.js @@ -4011,7 +4011,9 @@ exports.install = function(server, callbackFunction) { }); //Initialize watch here to avoid circular import with operationContext - runtimeConfigManager.initRuntimeConfigWatcher(operationContext.global); + runtimeConfigManager.initRuntimeConfigWatcher(operationContext.global).catch(err => { + operationContext.global.logger.warn('initRuntimeConfigWatcher error: %s', err.stack); + }); void aiProxyHandler.getPluginSettings(operationContext.global); }; exports.setLicenseInfo = async function(globalCtx, data, original) {