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) {