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