fix: symlink 探测有界并发 + UNC 标记平台中性化(回应 CR/自审)

- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一
  探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变
- html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/...
  前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号
  与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点
- 测试/文档同步;移除未使用的 isPosix
This commit is contained in:
Menghuan1918
2026-08-16 23:35:57 +08:00
parent 9652bbda59
commit be95787c00
10 changed files with 104 additions and 114 deletions
+5 -21
View File
@@ -8,7 +8,6 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
import { isWindowsStylePath } from './paths.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -212,29 +211,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}`
}
/** A forward-slash UNC share (`//server/share/...`) — ambiguous with a POSIX
* absolute path, so it only counts as Windows-style when the session cwd is
* itself a forward-slash UNC (see htmlUrl). */
function isForwardSlashUnc(path: string): boolean {
return /^\/\/[^/]/.test(path)
}
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The platform guard for the
* UNC marker comes from the session cwd's shape (the browser OS may differ
* from the host OS, so no client-side platform detection is used), extended
* by the file path itself: an unambiguous backslash UNC path keeps its '//'
* marker even while the cwd is still hydrating or arrives in forward-slash
* form. A forward-slash `//` path is only marked when the cwd is itself a
* forward-slash UNC — a POSIX session keeps `//server/share/...` as an
* ordinary absolute path.
* the same route with the session scope intact. The UNC marker is
* platform-neutral — the host's requireAbsolute resolves the decoded
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
const cwd = scope.cwd ?? ''
const windowsPathStyle = isWindowsStylePath(cwd)
|| isWindowsStylePath(path)
|| (isForwardSlashUnc(path) && isForwardSlashUnc(cwd))
return encodeHtmlUrl(scope.sessionId, path, windowsPathStyle)
return encodeHtmlUrl(scope.sessionId, path)
}
+2 -13
View File
@@ -5,25 +5,14 @@
* to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
* host is the authority for path semantics, so these mirrors deliberately
* accept a SUPERSET of absolute forms — anything a Windows host would emit
* host is the authority for path semantics, so this mirror deliberately
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/
/**
* Whether a path looks like it belongs to a Windows-style session: a drive
* letter or any backslash (UNC shares, and drive paths in general).
* Used as the platform signal for the html route's UNC marker — the client
* cannot read process.platform (the browser OS may differ from the host),
* but the session cwd shape is authoritative.
*/
export function isWindowsStylePath(path: string): boolean {
return path.includes('\\') || /^[A-Za-z]:[\\/]/.test(path)
}
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
+33 -12
View File
@@ -60,30 +60,51 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
}
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
const fullPath = join(path, dirent.name)
const isSymlink = dirent.isSymbolicLink()
// Probe the link's target once so a symlinked directory renders as an
// expandable directory row. stat follows the chain; any failure
// (missing target, ELOOP, permission) leaves the row as a broken
// file-shaped link the editor will refuse to read. Non-symlink entries
// skip the syscall entirely — dirent already classified them.
const info = isSymlink ? await stat(fullPath).catch(() => undefined) : undefined
rows.push({
name: dirent.name,
path: fullPath,
isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(),
isSymlink,
broken: isSymlink && info === undefined,
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
isSymlink: dirent.isSymbolicLink(),
broken: false,
hidden: dirent.name.startsWith('.'),
})
}
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
// Probe symlink targets AFTER the readdir stream closes, with bounded
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
// rows are skipped by the probe, so levels without links stay as cheap as
// before.
await probeSymlinkTargets(rows)
rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 }
}
/** How many symlink target stats run in flight during one level listing. */
const SYMLINK_PROBE_CONCURRENCY = 32
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
let next = 0
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
for (;;) {
const index = next
next += 1
if (index >= rows.length) return
const row = rows[index]!
if (!row.isSymlink) continue
// stat follows the chain; any failure (missing target, ELOOP, permission)
// leaves the row as a broken file-shaped link the editor refuses to read.
const info = await stat(row.path).catch(() => undefined)
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
row.broken = info === undefined
}
})
await Promise.all(workers)
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string {
const base = basename(path)
+16 -21
View File
@@ -13,16 +13,17 @@
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
* Windows UNC (windowsPathStyle): \\server\share\proj\a.html
* UNC (\\server\share\... or //server/share/...):
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
* The `windowsPathStyle` flag is the platform guard: a leading `//` is a
* legitimate POSIX absolute path, so the UNC marker is only emitted for
* sessions whose cwd shape identifies them as Windows (see the client's
* isWindowsStylePath) — otherwise a POSIX `//server/share/...` would decode
* back to a backslash UNC form the host then rejects.
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
* path. That form is intentionally platform-neutral: `node:path` resolves it
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
* host's existing requireAbsolute + isWithin fence needs no platform signal
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
* either platform).
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -45,15 +46,9 @@ export type HtmlDecodeResult =
/** The route prefix both encoders/decoders agree on. */
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/**
* Build the route URL for one absolute file path (client + tests).
* @param windowsPathStyle - whether the path belongs to a Windows-style
* session (drive or UNC). Only then is a `\\`/`//`-prefixed UNC path marked
* with the '//' marker; POSIX sessions keep `//` as an ordinary absolute
* path (see the module comment for the platform guard rationale).
*/
export function encodeHtmlUrl(sessionId: string, path: string, windowsPathStyle = false): string {
const unc = windowsPathStyle && /^[\\/]{2}[^\\/]/.test(path)
/** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
const unc = /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
@@ -84,9 +79,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
// '<sid>//server/share/...' for Windows UNC paths); the encoder filters
// empty segments everywhere else, so an empty segment can only be the
// marker or a malformed URL — both handled here.
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
// segments everywhere else, so an empty segment can only be the marker or
// a malformed URL — both handled here.
const unc = pathSegments[0] === ''
const tail = unc ? pathSegments.slice(1) : pathSegments
if (tail.length === 0 || tail.some(segment => segment === '')) {
@@ -94,9 +89,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
}
let path: string
if (unc) {
// Rebuild the UNC form \\server\share\... so requireAbsolute() +
// isWithin(cwd) see the same path the explorer works with.
path = `\\\\${tail.join('\\')}`
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
path = `//${tail.join('/')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'