mitigating the problem with unreachable UNC paths by leaving at least one slot available for local operations #1115
Massimo Melina committed
Nov 2, 2025 at 16:18 UTC
7f56fbbe4f99942d7a8c0b6d2e6975d7866d501a
3 files changed
+51
-18
src/stat.ts
new
+42
@@ -0,0 +1,42 @@
1
+import { defineConfig } from './config'
2
+import { Stats } from 'node:fs'
3
+import { haveTimeout, pendingPromise } from './cross'
4
+import { stat } from 'fs/promises'
5
+
6
+const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
7
+
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)
24
+ return ret
25
+ }
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
42
+}
src/util-files.ts
+5
-17
@@ -1,30 +1,18 @@
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, stat } from 'fs/promises'
4
-import { Promisable, try_, wait, isWindowsDrive, haveTimeout, splitAt } from './cross'
5
-import { defineConfig } from './config'
3
+import { access, mkdir, readFile } from 'fs/promises'
4
+import { Promisable, try_, wait, isWindowsDrive } from './cross'
5
import { createWriteStream, mkdirSync, watch, ftruncate } from 'fs'
6
import { basename, dirname } from 'path'
7
import glob from 'fast-glob'
8
import { IS_WINDOWS } from './const'
9
import { once } from 'events'
10
import { Readable } from 'stream'
11
+import { statWithTimeout } from './stat'
12
// @ts-ignore
13
import unzipper from 'unzip-stream'
14
15
-const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
16
-
17
-// since nodejs' UV_THREADPOOL_SIZE is 4 by default, avoid using multiple slots for the same UNC host. This doesn't solve the problem but at least mitigates it.
18
-const uncStatWaiting = new Map<string, Promise<any>>()
19
-export async function statWithTimeout(path: string) {
20
- const uncHost = path.startsWith('\\\\') && splitAt('\\', path.slice(2))[0]
21
- if (uncHost)
22
- await uncStatWaiting.get(uncHost)
23
- const op = stat(path)
24
- if (uncHost)
25
- uncStatWaiting.set(uncHost, op.finally(() => uncStatWaiting.delete(uncHost)).catch(() => {}))
26
- return haveTimeout(fileTimeout.compiled(), op)
27
-}
15
+export { statWithTimeout }
16
17
export async function isDirectory(path: string) {
18
try { return (await statWithTimeout(path)).isDirectory() }
@@ -174,4 +162,4 @@ export async function parseFile<T>(path: string, parse: (path: string) => T) {
162
163
export async function parseFileContent<T>(path: string, parse: (raw: Buffer) => T) {
164
return parseFile(path, () => readFile(path).then(parse))
177
-}
165
+}
\ No newline at end of file
src/vfs.ts
+4
-1
@@ -172,7 +172,10 @@ export async function getNodeByName(name: string, parent: VfsNode) {
172
}
173
174
export let vfs: VfsNode = {}
175
-defineConfig('vfs', vfs).sub(reviewVfs)
175
+defineConfig('vfs', vfs).sub(async x => {
176
+ await reviewVfs(x)
177
+ console.log('VFS ready')
178
+})
179
180
async function reviewVfs(data=vfs) {
181
await (async function recur(node) {