fix: unreachable UNC paths in the vfs can cause "not found" error on the frontend #1115

Massimo Melina committed Nov 23, 2025 at 20:04 UTC 7bce0ad8ef47ea3d9b8100a520a5eeca971831cb
5 files changed +67 -41
package.json
+1
@@ -57,6 +57,7 @@
57 "pkg": {
58 "assets": [
59 "central.json",
60 + "src/statWorker.js",
61 "admin/**/*",
62 "frontend/**/*",
63 "**/node_modules/fswin/x64/*",
src/stat.ts
+35 -36
@@ -1,42 +1,41 @@
1 -import { defineConfig } from './config'
1 +import { Worker } from 'node:worker_threads'
2 import { Stats } from 'node:fs'
3 -import { haveTimeout, pendingPromise } from './cross'
4 -import { stat } from 'fs/promises'
3 +import { PendingPromise, pendingPromise } from './cross'
4
6 -const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
5 +// all stat requests for the same worker are serialized, potentially introducing extra latency
6
8 -// since nodejs' UV_THREADPOOL_SIZE is limited, avoid using multiple slots for the same UNC host, and always leave one free for local operations
9 -const poolSize = Number(process.env.UV_THREADPOOL_SIZE || 4)
10 -const previous = new Map<string, Promise<Stats>>() // wrapped promises with haveTimeout
11 -const working = new Set<Promise<Stats>>() // plain stat's promise
12 -export async function statWithTimeout(path: string) {
13 - const uncHost = /^\\\\([^\\]+)\\/.exec(path)?.[1]
14 - if (!uncHost)
15 - return haveTimeout(fileTimeout.compiled(), stat(path))
16 - const busy = process.env.HFS_PARALLEL_UNC ? null : previous.get(uncHost) // by default we serialize requests on the same UNC host, to keep threadpool usage low
17 - const ret = pendingPromise<Stats>()
18 - previous.set(uncHost, ret) // reserve the slot before starting the operation
19 - const err = await busy?.then(() => false, e => e.message === 'timeout' && e) // only timeout error is shared with pending requests
20 - if (err) {
21 - if (previous.get(uncHost) === ret) // but we don't want to block forever, only involve those that were already waiting
22 - previous.delete(uncHost)
23 - ret.reject(err)
7 +const pool = new Map<string, (path: string) => Promise<Stats>>()
8 +
9 +export function getStatWorker(key: string) {
10 + const existing = pool.get(key)
11 + if (existing)
12 + return existing
13 + const worker = new Worker(__dirname + '/statWorker.js')
14 + worker.unref()
15 + const requests = new Map<string, PendingPromise<Stats>>()
16 + worker.on('message', (msg: any) => { // request finished, good or bad
17 + const k = msg.path
18 + requests.get(k)?.resolve(msg.error ? Promise.reject(new Error(msg.error))
19 + : Object.setPrototypeOf(msg.result, Stats.prototype) )
20 + requests.delete(k)
21 + })
22 + worker.on('error', (err) => { // worker failure
23 + for (const p of requests.values())
24 + p.reject(err)
25 + requests.clear()
26 + worker.terminate().catch(() => {})
27 + pool.delete(key)
28 + })
29 + pool.set(key, query)
30 + return query
31 +
32 + function query(path: string) {
33 + const was = requests.get(path)
34 + if (was)
35 + return was
36 + const ret = pendingPromise<Stats>()
37 + requests.set(path, ret)
38 + worker.postMessage(path)
39 return ret
40 }
26 - while (working.size >= poolSize - 1) // always leave one slot free for local operations
27 - await Promise.race(working.values()).catch(() => {}) // we are assuming UV_THREADPOOL_SIZE > 1, otherwise race() will deadlock
28 - const op = stat(path)
29 - working.add(op)
30 - try {
31 - ret.resolve(await haveTimeout(fileTimeout.compiled(),
32 - op.finally(() => working.delete(op)) ))
33 - }
34 - catch (e) {
35 - ret.reject(e)
36 - }
37 - finally {
38 - if (previous.get(uncHost) === ret)
39 - previous.delete(uncHost)
40 - }
41 - return ret
41 }
src/statWorker.js new
+10
@@ -0,0 +1,10 @@
1 +const { parentPort } = require('node:worker_threads')
2 +const { statSync } = require('node:fs')
3 +
4 +parentPort.on('message', async path => {
5 + try { // we use statSync to not use (and not risk to saturate) libuv's thread pool
6 + parentPort.postMessage({ path, result: { ...statSync(path) } })
7 + } catch (err) {
8 + parentPort.postMessage({ path, error: err?.message || String(err) })
9 + }
10 +})
src/util-files.ts
+19 -4
@@ -1,18 +1,33 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { access, mkdir, readFile } from 'fs/promises'
4 -import { Promisable, try_, wait, isWindowsDrive } from './cross'
3 +import { access, mkdir, readFile, stat } from 'fs/promises'
4 +import { Promisable, try_, wait, isWindowsDrive, haveTimeout } from './cross'
5 +import { defineConfig } from './config'
6 import { createWriteStream, mkdirSync, watch, ftruncate } from 'fs'
7 import { basename, dirname } from 'path'
8 import glob from 'fast-glob'
9 import { IS_WINDOWS } from './const'
10 import { once } from 'events'
11 import { Readable } from 'stream'
11 -import { statWithTimeout } from './stat'
12 +import { getStatWorker } from './stat'
13 // @ts-ignore
14 import unzipper from 'unzip-stream'
15
15 -export { statWithTimeout }
16 +const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
17 +// a smart (and a bit arbitrary) way to decide if we need the stat-workers functionality. Without it, we may be a bit faster. We'll see with experience if we need a dedicated configuration.
18 +const disableStatWorkers = Number(process.env.UV_THREADPOOL_SIZE) >= 10
19 +
20 +// some paths (mostly offline networked ones) can take 20+ seconds to fail
21 +export async function statWithTimeout(path: string) {
22 + // with just 4 requests we saturate the default UV_THREADPOOL_SIZE, blocking even local fs operations, so we move all UNC requests to worker threads
23 + const workerKey = !disableStatWorkers && IS_WINDOWS && getUncHost(path) // pool requests by server name
24 + const op = workerKey ? getStatWorker(workerKey)(path) : stat(path)
25 + return haveTimeout(fileTimeout.compiled(), op)
26 +}
27 +
28 +function getUncHost(path: string) {
29 + return /^\\\\([^\\]+)\\/.exec(path)?.[1]
30 +}
31
32 export async function isDirectory(path: string) {
33 try { return (await statWithTimeout(path)).isDirectory() }
tsconfig.json
+2 -1
@@ -1,5 +1,6 @@
1 {
2 "exclude": ["frontend","admin","tests","dist","shared","mui-grid-form","e2e","./playwright.config.ts"],
3 + "include": ["src/**/*"], // restrict the allowJs:true
4 "compilerOptions": {
5 /* Visit https://aka.ms/tsconfig.json to read more about this file */
6
@@ -38,7 +39,7 @@
39 // "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
40
41 /* JavaScript Support */
41 - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
42 + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
43 // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
45