diff --git a/.gitignore b/.gitignore index 96cf1f0..abc3bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ termix-plugin-signing.key .idea/ .vscode/ .claude/ +.serena/ .DS_Store Thumbs.db diff --git a/docs/directory-prefetch-benchmark.md b/docs/directory-prefetch-benchmark.md new file mode 100644 index 0000000..71e6c41 --- /dev/null +++ b/docs/directory-prefetch-benchmark.md @@ -0,0 +1,53 @@ +# Directory prefetch benchmark + +Run from the repository root with Node 24: + +```sh +node scripts/benchmark-directory-prefetch.mjs +``` + +This controlled client benchmark uses the actual directory-prefetch scheduler +and keyed request cache with a Promise-based loader delayed by 120 ms. It is +not an SSH throughput or browser-rendering benchmark. + +## Inputs + +- Windows, Node v24.15.0; five independent runs. +- Each listing returns 484 entries; cache freshness is five seconds. +- Three candidate child directories; at most two are predicted, one at a time. +- Prediction starts after 200 ms; navigation occurs 500 ms after scheduling. +- Hover sweep crosses ten rows, spending 5 ms over each before leaving. + +## Observed results + +| Run | Cold navigation (ms) | Ready-prefetched navigation (ms) | +| ------ | -------------------: | -------------------------------: | +| 1 | 126.44 | 0.0210 | +| 2 | 122.64 | 0.0197 | +| 3 | 124.93 | 0.0554 | +| 4 | 127.26 | 0.0235 | +| 5 | 124.03 | 0.0704 | +| Median | **124.93** | **0.0235** | + +Cold navigation makes one loader request. Prediction makes two requests ahead +of navigation, including one folder that is not opened. Navigation to the ready +folder makes no additional request. Returned entries are asserted equal. + +The hover sweep made **zero** requests with the 100 ms dwell gate. A separate +immediate-request fixture made ten; that comparison deliberately omits SDK +resource budgeting and is not a measurement of the previous app. Starting +foreground navigation before the prediction timer fired also made **zero** +speculative requests. + +## Limits and trade-offs + +Prefetch moves work ahead of the click, not out of the system. A folder that was +not predicted, a click before prefetch completes, or SDK budget denial still +requires waiting for the listing. The benchmark deliberately provides enough +lead time for both predicted folders to finish; it does not estimate prediction +hit rate, remote-host latency or rendering time. Extra reads are bounded but can +still consume remote resources. Live Termix navigation remains a separate check. + +Streaming and stored backend snapshots are deferred: current evidence does not +justify sorted-row churn or a second mutation-sensitive cache. Existing client +cache entries are shared, and backend sharing covers in-flight reads only. diff --git a/docs/file-manager-loading-benchmark.md b/docs/file-manager-loading-benchmark.md new file mode 100644 index 0000000..fef177f --- /dev/null +++ b/docs/file-manager-loading-benchmark.md @@ -0,0 +1,75 @@ +# File Manager loading benchmark + +## Scope + +Compared unmodified upstream `d6e2526` with the current working tree, **not HEAD**, which already contains some fixes. Actual listing routes and actual frontend `listSSHFiles`/cache code run against HTTP and an encrypted loopback SSH/SFTP server. The server returns synthetic files; it never connects to your hosts, reads your files, or changes the installed plugin. This is protocol-level controlled evidence, **not live remote-host or whole-app click-to-paint timing**. + +## Method and retained evidence + +- Node v24.15.0, win32, AMD Ryzen 9 9900X 12-Core Processor, ssh2 1.17.0; same installed dependencies and input data for both versions. +- 15 measured rounds plus two warmups per variant, size and delay; baseline/current order alternates. Nearest-rank median and p95; with 15 samples p95 is the maximum, not a stable population-tail estimate. +- 0, 500 and 10,000 regular files, deterministic names and metadata, 100 entries per SFTP READDIR reply. Every successful response is checked for exact count, uniqueness and ordered filenames. No directories, symlinks, sudo or ls fallback in this fixture. +- Cold listing means a new uncached path on an **already established SSH/SFTP connection**. It includes HTTP, encrypted SFTP read, real route mapping/JSON serialization and client response parsing; excludes connection/auth setup, browser rendering and FileManager's own navigation debounce. Baseline `force` was not forwarded to the backend, but unique paths make every measured cold read uncached for both versions. +- Zero-delay replies are synchronous: **no setTimeout(0)**. An exploratory run with zero-delay timers was discarded, because Windows timer overhead would falsely inflate a LAN fixture. 20ms is a requested per-response service delay, not measured/calibrated network RTT. Windows timer quantization makes actual delays longer. +- The baseline ssh2 path-based read waits for CLOSE acknowledgement; the new helper returns after scheduling CLOSE. That real behavioral difference contributes to measured latency, especially for empty folders; it is not entirely pipelining. Both use the same real SFTP protocol and server. +- Browser benchmarking was also running on this machine for part of collection. No CPU isolation, controlled power profile or utilization trace; alternating versions limits order bias, but small differences are not statistically established. +- Production source SHA-256 before and after: `af527640033b77ac2c6fd9f298b8516e73ce5eac9c551ca9e4cf7cd84bab4396`. All three collected runs checked the source tree stayed unchanged. No checkout, stash, reset, commit, push or deployment. +- Raw per-round measurements, inputs/formula, environment and summaries: `scripts/benchmark-file-loading.mjs` regenerates them (raw data is not committed). Generated baseline snapshots and built bundles retained under temporary `fm-loading-bench-*` directories. The 0ms and 20ms rows come from separate runs; preliminary timer-backed 0ms rows were excluded. + +## Uncached listing results + +Times in milliseconds, median / p95. Positive change means slower. + +| Requested reply delay | Files | Upstream | Current | Median change | +| --------------------- | -----: | --------------: | --------------: | ------------: | +| 0ms | 0 | 1.0 / 2.2 | 1.0 / 2.3 | 2.4% | +| 0ms | 500 | 3.4 / 5.2 | 3.5 / 6.5 | 0.2% | +| 0ms | 10,000 | 48.9 / 74.1 | 45.9 / 66.8 | -6.2% | +| 20ms | 0 | 93.2 / 93.8 | 62.5 / 63.9 | -33.0% | +| 20ms | 500 | 248.4 / 251.4 | 154.1 / 156.1 | -38.0% | +| 20ms | 10,000 | 3211.4 / 3248.7 | 1633.5 / 1658.3 | -49.1% | + +**Interpretation:** roughly 38% lower uncached listing latency for 500 files and 49% lower for 10,000 files in the delayed fixture. With no injected latency, 500 files are effectively unchanged and the 10k median improves only about 6%; this is not a universal 2x throughput claim. Small zero-delay regressions/tail changes are present in raw results, not hidden. + +## Immediate reopen + +Both upstream and current already have a short-lived cache. Reopen after each completed cold listing returned entirely from that cache, with no extra API request: medians were about 0.004–0.020ms across these cases. That is **data availability only, not display time**, and is not a new all-or-nothing cache benefit introduced by this PR. No cache-expiry or idle reconnect timing was measured. + +## Hover prefetch over the real protocol fixture + +500 files, 20ms requested per SFTP response. Both versions get the same time between hover start and click. Current uses the actual 100ms hover-dwell scheduler; the SDK admission callback is allowed immediately (resource-budget denial/defer is not benchmarked). Baseline makes no hover read. + +| Hover before click | Variant | Click-to-data median / p95 | Hover-to-data median | Ready when clicked | HTTP reads | +| ------------------ | -------- | -------------------------: | -------------------: | -----------------: | ---------: | +| 50ms | Upstream | 253.6 / 267.4 | 312.1 | 0/15 | 1 | +| 50ms | Current | 159.0 / 166.9 | 217.2 | 0/15 | 1 | +| 500ms | Upstream | 251.9 / 264.8 | 759.0 | 0/15 | 1 | +| 500ms | Current | 0.036 / 0.069 | 512.4 | 15/15 | 1 | + +The 500ms lead case removes the wait **after clicking** because the read finished beforehand; it does not make SSH free or eliminate remote work. At 50ms the dwell never fires before navigation, so the remaining improvement is the faster cold listing, not a cache hit. All 15 measured reads per variant/lead returned the exact 500 filenames. This successful-target hover fixture does not measure automatic two-child prediction hit rate, wasted reads, browsing habits or SDK budget denial. + +## Concurrent requests + +Two direct HTTP requests for the same uncached session/path produced `200 + 409` upstream and `200 + 200` currently, consistently in the measured fixture; current shares one listing. Both variants opened one SFTP directory per duplicate pair. Completion time is recorded, but it is **not an equal-success performance comparison**: upstream rejects one consumer, while current also serializes and transfers its response. In the zero-delay 10k fixture, pair completion is about 50ms upstream versus 72ms currently; that extra work buys a second successful response and should not be disguised as a speedup. FileManager's old 409 retry delay is outside this benchmark. + +## Reproduce + +From the repository root with the existing dependencies and Windows tar: + +```powershell +# A fresh temporary baseline, current bundles and results.json are generated. +node scripts/benchmark-file-loading.mjs + +# Hover cases only. Each shell/process should use its own environment. +$env:FM_BENCH_PREDICT_ONLY = '1' +node scripts/benchmark-file-loading.mjs +Remove-Item Env:FM_BENCH_PREDICT_ONLY +``` + +Optional `FM_BENCH_RUNS`, `FM_BENCH_SIZES` and `FM_BENCH_DELAYS` control measured count and comma-separated fixture values. `FM_BENCH_OUTPUT` selects the output directory. The runner emits raw JSON, verifies every returned filename/count and source preservation, then closes the HTTP/SSH connections. The fixture host key is generated in memory and never saved. + +## Evidence limits + +Verified: comparable original/current code paths, real HTTP/encrypted SFTP exchange, successful response completeness, empty/500/10k listings, cold/reopen/hover/concurrent cases, all expected measurements retained, production source unchanged. + +Not run: real user remote host/connection startup; installed Termix end-to-end navigation or tab/reconnect timings; sudo, symlink-heavy folders, mutations/cache invalidation, non-ssh2 servers, actual storage I/O and CPU/memory profiling. See [file-manager-render-benchmark.md](file-manager-render-benchmark.md) for separately measured Chromium rendering. **Do not add the separately measured loading and browser medians together as if they were a single observed end-to-end run.** diff --git a/docs/file-manager-render-benchmark.md b/docs/file-manager-render-benchmark.md new file mode 100644 index 0000000..b14f52d --- /dev/null +++ b/docs/file-manager-render-benchmark.md @@ -0,0 +1,123 @@ +# File Manager rendering benchmark + +## Scope and result + +**Controlled production-browser component harness, not live Termix or SSH.** Baseline: `d6e2526a8c0d800085e771a410412038e73776f6`. Current: snapshotted **working-tree files**, not HEAD (HEAD was `e0cbccdd43aac094145a91ea4932d406d16db9d8`). Source snapshot taken 2026-10-28T02:53:07.042Z; finished 2026-10-28T02:55:35.754Z. The current grid source still matches its snapshot after sampling: **true**. + +At 10,000 input entries, initial/update medians changed as follows: + +- list initial: **314.1 → 41.2 ms** (-86.9%). +- list update: **298.6 → 37.5 ms** (-87.4%). +- grid initial: **302.7 → 43.7 ms** (-85.6%). +- grid update: **296.2 → 34.9 ms** (-88.2%). + +These timings include the actual filtering/sorting code as well as React updates/layout. They do **not** isolate a pure grid-render speedup. The original uses per-comparison localeCompare options; current uses its real shared Intl.Collator helper. This is a plausible contributor, not separately profiled causal attribution. + +## Reproduction and artifacts + +Run from the repository with already installed dependencies (no install step): + +```powershell +node scripts/benchmark-file-rendering.cjs "$PWD" "$env:TEMP\fm-render-repeat-unique" +``` + +Use a new output directory. The runner archives the upstream commit into temp, copies current frontend sources, builds two minified production esbuild bundles against the same node_modules, starts a loopback HTTP server, launches the installed Playwright CLI package's Chromium, and closes browser/server afterward. No checkout/stash/reset, deployed plugin changes, or remote access. It writes raw JSONL, exact generated inputs, source snapshots, entry/filter harness source, compiled CSS/bundles, esbuild metafiles, metadata and summary into that output directory. + +Retained run directory: `C:\Users\deanm\AppData\Local\Temp\fm-render-benchmark-20261028-025055`. Primary script: `scripts/benchmark-file-rendering.cjs`. Raw samples are not committed; rerun the script to regenerate them. Source/filter SHA-256 hashes and initial dirty-tree status are in metadata. Full original source snapshot and entry are retained in temp; repeat runs intentionally sample whatever working-tree state then exists. + +## Method + +- AMD Ryzen 9 9900X 12-Core Processor, 24 logical CPUs; win32 10.0.26300; Node v24.15.0; Chromium 155.0.8059.12; React 19.3.1; react-virtual 3.14.21; esbuild 0.28.2; Tailwind 4.3.3. No throttling. +- Viewport 1200×850, component pane 1000×640, comfortable density. Actual scroller geometry recorded per sample. Inputs: 0, 500, 10,000 seeded-shuffled entries, 10% directories, fixed metadata; both list and grid. Full inputs retained. +- Actual FileManagerGrid and local dependencies are bundled without source modifications. Baseline filter/sort expression is extracted verbatim from baseline FileManager.tsx; current imports actual file-list.ts. Only host SDK cn and translation boundaries are stubbed. **No react-virtual, ResizeObserver, RAF or DOM-geometry mocks.** +- Production CSS: installed SDK `theme.css` plus actual Tailwind theme/preflight/utilities, scanned from both source snapshots and shared by both variants. SDK thin-scrollbar utility is included. A tiny wrapper supplies pane dimensions, ordinary Arial font and neutral CSS palette variables; **not host-perfect fonts, theme/chrome or application CSS**. An earlier exploratory simplified-theme run was discarded and is not used in this report. +- Two unmeasured warmup pairs per size/mode, then **15 measured samples per variant/size/mode/action**. Baseline/current order alternates each round. Fresh page per variant; script/CSS already loaded before initial timer starts. No navigation, network listing, build or Playwright protocol time is included. +- Initial: root.render to two requestAnimationFrame callbacks. Update/filter/sort/show: real DOM button click handler to two RAF callbacks, then filename/count/geometry inspection. Clicks are programmatic HTMLElement.click(), not trusted hardware input. This is an action-to-two-frame paint-opportunity proxy, **not GPU presentation instrumentation**. Checks run after the timer. +- Update changes every path/name with the same count. fullShow hides the full listing after a real middle scroll with display:none, waits four RAFs, then measures show. Hidden display and zero height are recorded. Filter uses substring "7" (0/95/3439 resulting entries); sort then reverses name order with directories first. show repeats hide/show on that filtered listing. Scroll-to-end additionally checks the last expected filename. +- Median is nearest-rank p50; p95 is nearest rank ceil(0.95×15), hence the maximum of 15. No significance intervals or claim that small differences are reliable. +- **Potential concurrent CPU contention:** parent ran its independent backend benchmark on this machine. No CPU isolation or utilization recording was used. Alternation limits order bias but cannot remove changing background load. Interpret small deltas/tails cautiously. + +## Observed timings + +All times in ms; columns are median / p95. Positive delta means current was slower. + +| View | Input entries | Action | Baseline median / p95 | Current median / p95 | Current median delta | +| ---- | ------------: | -------- | --------------------: | -------------------: | -------------------: | +| list | 0 | initial | 19.9 / 33.1 | 28.3 / 33.2 | 8.4 (42.2%) | +| list | 0 | update | 31.5 / 33.3 | 31.2 / 32.3 | -0.3 (-1.0%) | +| list | 0 | fullShow | 30.6 / 32.6 | 30.5 / 31.9 | -0.1 (-0.3%) | +| list | 0 | filter | 31.9 / 33.4 | 32.0 / 32.7 | 0.1 (0.3%) | +| list | 0 | sort | 31.4 / 33.1 | 31.3 / 33.2 | -0.1 (-0.3%) | +| list | 0 | show | 32.5 / 33.5 | 32.0 / 33.2 | -0.5 (-1.5%) | +| list | 500 | initial | 28.5 / 33.2 | 29.3 / 33.0 | 0.8 (2.8%) | +| list | 500 | update | 30.1 / 32.6 | 30.9 / 32.3 | 0.8 (2.7%) | +| list | 500 | fullShow | 31.1 / 32.6 | 30.9 / 31.9 | -0.2 (-0.6%) | +| list | 500 | filter | 31.9 / 33.0 | 31.6 / 32.8 | -0.3 (-0.9%) | +| list | 500 | sort | 30.2 / 31.4 | 30.6 / 32.8 | 0.4 (1.3%) | +| list | 500 | show | 31.9 / 33.1 | 31.8 / 33.3 | -0.1 (-0.3%) | +| list | 10,000 | initial | 314.1 / 344.7 | 41.2 / 48.4 | -272.9 (-86.9%) | +| list | 10,000 | update | 298.6 / 322.5 | 37.5 / 47.0 | -261.1 (-87.4%) | +| list | 10,000 | fullShow | 31.2 / 33.0 | 30.2 / 32.5 | -1.0 (-3.2%) | +| list | 10,000 | filter | 97.1 / 108.5 | 31.9 / 37.9 | -65.2 (-67.1%) | +| list | 10,000 | sort | 92.4 / 104.9 | 29.4 / 31.8 | -63.0 (-68.2%) | +| list | 10,000 | show | 31.9 / 33.2 | 31.5 / 33.6 | -0.4 (-1.3%) | +| grid | 0 | initial | 28.8 / 34.1 | 19.4 / 32.1 | -9.4 (-32.6%) | +| grid | 0 | update | 31.1 / 33.3 | 31.1 / 32.3 | 0.0 (0.0%) | +| grid | 0 | fullShow | 30.6 / 31.7 | 31.4 / 32.6 | 0.8 (2.6%) | +| grid | 0 | filter | 32.1 / 33.0 | 31.9 / 32.9 | -0.2 (-0.6%) | +| grid | 0 | sort | 31.4 / 32.7 | 32.0 / 32.8 | 0.6 (1.9%) | +| grid | 0 | show | 32.0 / 33.2 | 31.9 / 32.9 | -0.1 (-0.3%) | +| grid | 500 | initial | 28.9 / 30.8 | 28.6 / 30.6 | -0.3 (-1.0%) | +| grid | 500 | update | 27.8 / 30.3 | 28.1 / 29.7 | 0.3 (1.1%) | +| grid | 500 | fullShow | 30.6 / 32.4 | 30.8 / 32.4 | 0.2 (0.7%) | +| grid | 500 | filter | 31.8 / 32.7 | 31.8 / 32.7 | 0.0 (0.0%) | +| grid | 500 | sort | 29.0 / 30.0 | 29.1 / 30.4 | 0.1 (0.3%) | +| grid | 500 | show | 31.8 / 33.5 | 32.4 / 33.2 | 0.6 (1.9%) | +| grid | 10,000 | initial | 302.7 / 366.9 | 43.7 / 53.9 | -259.0 (-85.6%) | +| grid | 10,000 | update | 296.2 / 371.2 | 34.9 / 46.7 | -261.3 (-88.2%) | +| grid | 10,000 | fullShow | 31.1 / 32.8 | 30.8 / 33.1 | -0.3 (-1.0%) | +| grid | 10,000 | filter | 96.6 / 112.4 | 31.9 / 33.2 | -64.7 (-67.0%) | +| grid | 10,000 | sort | 89.6 / 108.3 | 28.7 / 30.3 | -60.9 (-68.0%) | +| grid | 10,000 | show | 32.2 / 34.2 | 32.2 / 34.9 | 0.0 (0.0%) | + +Observed median regressions (including tiny frame-noise-scale changes, not hidden): list/0/initial: +8.4 ms (+42.2%); list/0/filter: +0.1 ms (+0.3%); list/500/initial: +0.8 ms (+2.8%); list/500/update: +0.8 ms (+2.7%); list/500/sort: +0.4 ms (+1.3%); grid/0/fullShow: +0.8 ms (+2.6%); grid/0/sort: +0.6 ms (+1.9%); grid/500/update: +0.3 ms (+1.1%); grid/500/fullShow: +0.2 ms (+0.7%); grid/500/sort: +0.1 ms (+0.3%); grid/500/show: +0.6 ms (+1.9%); grid/10000/show: +0.0 ms (+0.0%). These are descriptive, not proof of a significant regression. + +## Correctness and geometry evidence + +180 measured variant runs, 1080 action checks. Browser page errors: 0. Per-action checks and scroll-end failures: 0; independent numeric-filename/directory-first oracle failures: 0. The independent oracle checks matching counts, prefixes, contiguous visible filename order and top filename for initial/update/filter/sort. It does not rely solely on the same helper supplying expected files. + +Below ranges are from initial/update/fullShow only (unfiltered input counts); rendered includes overscan, visible means actual rectangle intersection with scroller. These bounded counts disprove fake all-row rendering for large cases. List baseline uses measured content heights; current uses fixed 41px stride. Geometry differences are reported rather than calling both layouts pixel-identical. + +| View / input / variant | DOM file nodes | Visible file nodes | Observed row strides (px) | +| ---------------------- | -------------: | -----------------: | ------------------------- | +| list/0/baseline | 0–0 | 0–0 | n/a | +| list/0/current | 0–0 | 0–0 | n/a | +| list/500/baseline | 32–48 | 15–17 | 40 | +| list/500/current | 32–48 | 14–16 | 41 | +| list/10000/baseline | 32–48 | 15–17 | 40 | +| list/10000/current | 32–48 | 14–16 | 41 | +| grid/0/baseline | 0–0 | 0–0 | n/a | +| grid/0/current | 0–0 | 0–0 | n/a | +| grid/500/baseline | 96–152 | 48–48 | 112 | +| grid/500/current | 96–152 | 48–48 | 112 | +| grid/10000/baseline | 96–144 | 48–48 | 112 | +| grid/10000/current | 96–144 | 48–48 | 112 | + +Both versions recovered visible rows in the tested display:none→shown scenario; this run does **not** reproduce the original live-host blank-tab failure and must not be cited as its definitive fix verification. + +## Acceptance evidence matrix + +| Criterion | Status | Exact check and observed outcome | +| --------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Exact upstream versus current working tree | verified | git archive of d6e2526…; copied live frontend; source hashes retained; current grid unchanged after samples: true | +| Actual production grid and shared dependency versions | verified | Two esbuild minified NODE_ENV=production bundles; metafiles retained; same installed React/react-virtual/esbuild; SDK-only boundaries stubbed | +| Actual browser, utilities and DOM geometry | verified | Installed Playwright package Chromium 155.0.8059.12; installed SDK theme and Tailwind-generated CSS; no virtualizer/geometry mocks | +| Repeated initial/update/list/grid/empty/500/10k timings | verified | 15 measured + 2 warmup samples for each variant/size/mode/action; alternating pair order; raw JSONL retained | +| Full and filtered tab hide/show | verified | fullShow and show each timed; fullHidden records display:none and height=0; visible filenames/geometries recovered | +| Filename/count/order/geometry/virtualization checks | verified | 1080 independent action oracle checks; 0 failures; raw visible names/rectangles, bounded nodes, footer counts and final filename checks | +| Median/p95, raw inputs/results, regressions | verified | Complete table above; summary.json + gzipped raw samples/input JSON; positive median deltas listed | +| Live Termix/SSH, actual reported blank-tab bug | not_run | Isolated component shell only; both versions recover in this controlled scenario | +| Hardware click-to-display/GPU paint and isolated CPU timings | not_run | Programmatic DOM events/two RAF paint-opportunity proxy; concurrent workload not isolated | +| Production code, installed plugin and unrelated edits preserved | verified | No production edits; only benchmark script/report/evidence added; other dirty-tree changes retained | + +Skipped live host/SSH, compact density, varied viewport/font/theme, drag/edit/selection stress and GPU presentation timing. Small timing deltas and p95 tails remain sensitive to frame phase and concurrent CPU load. diff --git a/scripts/benchmark-directory-prefetch.mjs b/scripts/benchmark-directory-prefetch.mjs new file mode 100644 index 0000000..b2eb56c --- /dev/null +++ b/scripts/benchmark-directory-prefetch.mjs @@ -0,0 +1,127 @@ +// Controlled client benchmark, not a remote-host or browser-rendering benchmark. +// Run with Node 24: node scripts/benchmark-directory-prefetch.mjs +import assert from "node:assert/strict"; +import { performance } from "node:perf_hooks"; +import { createDirectoryPrefetch } from "../src/frontend/lib/directory-prefetch.ts"; +import { createKeyedRequestCache } from "../src/frontend/lib/keyed-request-cache.ts"; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const inputs = { + runs: 5, + listingDelayMs: 120, + entries: 484, + predictionLeadMs: 500, + sweepRows: 10, + sweepDwellMs: 5, +}; +const entries = Array.from({ length: inputs.entries }, (_, index) => ({ + name: `file-${index}`, + path: `/parent/a/file-${index}`, + type: "file", + size: index, +})); +const children = ["a", "b", "c"].map((name) => ({ + name, + path: `/parent/${name}`, + type: "directory", +})); + +function fixture() { + const cache = createKeyedRequestCache(5_000, 150); + let calls = 0; + const read = (path) => + cache.get(path, async () => { + calls++; + await delay(inputs.listingDelayMs); + return entries; + }); + const scheduler = createDirectoryPrefetch({ + read, + isCached: (path) => cache.peek(path, 5_000) !== null, + run: (task) => { + void task().catch(() => {}); + return true; + }, + }); + return { read, scheduler, calls: () => calls }; +} + +const runs = []; +for (let run = 0; run < inputs.runs; run++) { + const cold = fixture(); + let start = performance.now(); + assert.deepEqual(await cold.read("/parent/a"), entries); + const coldMs = performance.now() - start; + cold.scheduler.dispose(); + + const predicted = fixture(); + predicted.scheduler.predict("/parent", children); + await delay(inputs.predictionLeadMs); + predicted.scheduler.setForeground(true); + start = performance.now(); + assert.deepEqual(await predicted.read("/parent/a"), entries); + const predictedMs = performance.now() - start; + assert.equal( + predicted.calls(), + 2, + "Only two direct children should be prefetched", + ); + predicted.scheduler.dispose(); + runs.push({ + run, + coldMs, + predictedMs, + coldRequests: cold.calls(), + predictedRequests: predicted.calls(), + }); +} + +const immediateSweep = fixture(); +await Promise.all( + Array.from({ length: inputs.sweepRows }, (_, index) => + immediateSweep.read(`/parent/sweep-${index}`), + ), +); +immediateSweep.scheduler.dispose(); +const sweep = fixture(); +for (let index = 0; index < inputs.sweepRows; index++) { + const path = `/parent/sweep-${index}`; + sweep.scheduler.hover(path); + await delay(inputs.sweepDwellMs); + sweep.scheduler.cancelIntent(path); +} +await delay(150); +assert.equal(sweep.calls(), 0); +sweep.scheduler.dispose(); + +const cancelled = fixture(); +cancelled.scheduler.predict("/parent", children); +cancelled.scheduler.setForeground(true); +await delay(250); +assert.equal(cancelled.calls(), 0); +cancelled.scheduler.dispose(); + +const median = (key) => + [...runs].sort((a, b) => a[key] - b[key])[Math.floor(runs.length / 2)][key]; +console.log( + JSON.stringify( + { + environment: { node: process.version, platform: process.platform }, + fixture: + "Controlled 120ms Promise-based listing; actual scheduler/cache; immediate-hover comparison omits SDK budget; no SSH/browser", + inputs, + medians: { + coldMs: +median("coldMs").toFixed(2), + prefetchedNavigationMs: +median("predictedMs").toFixed(2), + }, + sweep: { + immediateHoverFixtureRequests: immediateSweep.calls(), + deliberateHoverRequests: sweep.calls(), + }, + foregroundCancelledPredictionRequests: cancelled.calls(), + runs, + }, + null, + 2, + ), +); diff --git a/scripts/benchmark-file-loading.mjs b/scripts/benchmark-file-loading.mjs new file mode 100644 index 0000000..779611d --- /dev/null +++ b/scripts/benchmark-file-loading.mjs @@ -0,0 +1,435 @@ +// Actual listing routes + client API over HTTP and encrypted loopback SSH/SFTP. +// No user hosts, credentials, installed plugins or working-tree checkouts are used. +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, +} from "node:fs"; +import { tmpdir, platform, cpus } from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { generateKeyPairSync, createHash } from "node:crypto"; +import { once } from "node:events"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { performance } from "node:perf_hooks"; +import { build } from "esbuild"; +import express from "express"; +import ssh2 from "ssh2"; +import { createDirectoryPrefetch } from "../src/frontend/lib/directory-prefetch.ts"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const baselineRef = "d6e2526a8c0d800085e771a410412038e73776f6"; +const out = + process.env.FM_BENCH_OUTPUT || + mkdtempSync(path.join(tmpdir(), "fm-loading-bench-")); +mkdirSync(out, { recursive: true }); +const before = path.join(out, "before"); +mkdirSync(before, { recursive: true }); +execFileSync("tar", ["-xf", "-", "-C", before], { + input: execFileSync("git", ["archive", baselineRef], { + cwd: root, + maxBuffer: 30_000_000, + }), +}); +const sourceHash = () => { + const hash = createHash("sha256"); + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else { + hash.update(path.relative(root, file)); + hash.update(readFileSync(file)); + } + } + }; + walk(path.join(root, "src")); + return hash.digest("hex"); +}; +const originalHash = sourceHash(); +const variants = []; +for (const [name, src] of [ + ["before", before], + ["after", root], +]) { + const backendFile = path.join(out, `${name}-backend.mjs`); + const apiFile = path.join(out, `${name}-api.mjs`); + const common = { + bundle: true, + platform: "node", + format: "esm", + nodePaths: [path.join(root, "node_modules")], + logLevel: "silent", + }; + await build({ + ...common, + entryPoints: [path.join(src, "src/backend/list-routes.ts")], + outfile: backendFile, + plugins: [ + { + name: "session-boundary", + setup(b) { + b.onResolve({ filter: /^\.\/session\.js$/ }, () => ({ + path: "session", + namespace: "bench", + })); + b.onLoad({ filter: /.*/, namespace: "bench" }, () => ({ + contents: `export const getSessionSftp = () => Promise.resolve(globalThis.__fmSftp); export const execChannel = () => { throw Error('Unexpected fallback'); };`, + })); + }, + }, + ], + }); + await build({ + ...common, + entryPoints: [ + path.join(src, "src/frontend/api/ssh-file-operations-api.ts"), + ], + outfile: apiFile, + plugins: [ + { + name: "http-boundary", + setup(b) { + b.onResolve({ filter: /^\.\/client$/ }, () => ({ + path: "client", + namespace: "bench", + })); + b.onResolve({ filter: /^@termix-ssh\/plugin-sdk\/ui$/ }, () => ({ + path: "sdk", + namespace: "bench", + })); + b.onResolve({ filter: /^axios$/ }, () => ({ + path: "axios", + namespace: "bench", + })); + b.onLoad({ filter: /.*/, namespace: "bench" }, ({ path: p }) => ({ + contents: + p === "client" + ? `export const getFileManagerApiForSession=()=>globalThis.__fmHttp; export const handleApiError=e=>{throw e}; export const setSessionOrigin=()=>{}; export const clearSessionOrigin=()=>{};` + : p === "sdk" + ? `export const createFrontendLogger=()=>({info(){},warn(){},error(){},success(){}}); export const resolveConnectionOrigin=()=> 'local';` + : `export default {};`, + })); + }, + }, + ], + }); + variants.push({ + name, + routes: await import(pathToFileURL(backendFile)), + api: await import(pathToFileURL(apiFile)), + }); +} + +let latencyMs = 0; +let operations = { opendir: 0, readdir: 0, close: 0 }; +const batchSize = 100; +const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const server = new ssh2.Server({ hostKeys: [privateKey] }, (connection) => { + connection.setNoDelay(true); + connection.on("authentication", (ctx) => ctx.accept()); // Loopback-only, ephemeral synthetic data. + connection.on("ready", () => + connection.on("session", (accept) => + accept().on("sftp", (accept) => { + const sftp = accept(); + const handles = new Map(); + let next = 0; + const reply = (fn) => + latencyMs > 0 ? setTimeout(fn, latencyMs) : fn(); + sftp.on("OPENDIR", (id, p) => { + operations.opendir++; + const count = Number(p.split("/").at(-1)); + assert(Number.isSafeInteger(count) && count >= 0); + const key = String(++next); + handles.set(key, { count, index: 0 }); + reply(() => sftp.handle(id, Buffer.from(key))); + }); + sftp.on("READDIR", (id, handle) => { + operations.readdir++; + const state = handles.get(handle.toString()); + assert(state); + const start = state.index; + state.index += batchSize; + const entries = Array.from( + { length: Math.max(0, Math.min(batchSize, state.count - start)) }, + (_, offset) => { + const index = start + offset; + const filename = `file-${String(index).padStart(5, "0")}.txt`; + return { + filename, + longname: `-rw-r--r-- 1 1000 1000 ${index} Oct 28 2026 ${filename}`, + attrs: { + mode: 0o100644, + uid: 1000, + gid: 1000, + size: index, + atime: 1793145600, + mtime: 1793145600, + }, + }; + }, + ); + reply(() => + entries.length + ? sftp.name(id, entries) + : sftp.status(id, ssh2.utils.sftp.STATUS_CODE.EOF), + ); + }); + sftp.on("CLOSE", (id, handle) => { + operations.close++; + handles.delete(handle.toString()); + reply(() => sftp.status(id, ssh2.utils.sftp.STATUS_CODE.OK)); + }); + }), + ), + ); +}); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const client = new ssh2.Client(); +client.connect({ + host: "127.0.0.1", + port: server.address().port, + username: "fixture", + readyTimeout: 10000, +}); +await once(client, "ready"); +client.setNoDelay(true); +globalThis.__fmSftp = await new Promise((resolve, reject) => + client.sftp((e, s) => (e ? reject(e) : resolve(s))), +); +const app = express(); +for (const variant of variants) { + const router = express.Router(); + variant.routes.registerFileListingRoutes(router, { + ctx: { + currentActor: () => "fixture", + log: { info() {}, warn() {}, error() {} }, + }, + sshSessions: { s: { isConnected: true, activeOperations: 0 } }, + activeListRequests: {}, + verifySessionOwnership: () => true, + }); + app.use("/" + variant.name, router); +} +const http = app.listen(0, "127.0.0.1"); +await once(http, "listening"); +const base = `http://127.0.0.1:${http.address().port}`; +let requests = 0; +const setApi = (name) => { + globalThis.__fmHttp = { + get: async (url, { params }) => { + requests++; + const response = await fetch( + `${base}/${name}${url}?${new URLSearchParams(params)}`, + ); + const data = await response.json(); + if (!response.ok) throw { response: { data, status: response.status } }; + return { data }; + }, + }; +}; +const verify = (value, count) => { + assert.equal(value.files.length, count); + assert.equal(new Set(value.files.map((f) => f.name)).size, count); + for (let i = 0; i < count; i++) + assert.equal(value.files[i].name, `file-${String(i).padStart(5, "0")}.txt`); +}; +const samples = []; +const runs = Number(process.env.FM_BENCH_RUNS || 15); +const delays = (process.env.FM_BENCH_DELAYS || "0,20").split(",").map(Number); +const sizes = (process.env.FM_BENCH_SIZES || "0,500,10000") + .split(",") + .map(Number); +if (process.env.FM_BENCH_PREDICT_ONLY === "1") sizes.length = 0; +const time = async (fn) => { + const start = performance.now(); + const result = await fn(); + return { ms: performance.now() - start, result }; +}; +try { + for (const delay of delays) + for (const count of sizes) { + latencyMs = delay; + for (let run = -2; run < runs; run++) { + for (const v of run % 2 === 0 ? variants : [...variants].reverse()) { + setApi(v.name); + const dir = `/fixture/${run}/${v.name}/${count}`; + const startOps = { ...operations }; + const startRequests = requests; + const cold = await time(() => + v.api.listSSHFiles("s", dir, { force: true }), + ); + verify(cold.result, count); + const repeat = await time(() => v.api.listSSHFiles("s", dir)); + verify(repeat.result, count); + // Same two HTTP requests issued concurrently; bypass frontend cache to + // measure the route's sharing/conflict policy directly. + const duplicatePath = `${dir}/duplicate/${count}`; + const duplicates = await time(() => + Promise.all( + [0, 1].map(async () => { + const r = await fetch( + `${base}/${v.name}/listFiles?sessionId=s&path=${encodeURIComponent(duplicatePath)}`, + ); + const body = await r.json(); + if (r.ok) verify(body, count); + return r.status; + }), + ), + ); + if (run >= 0) + samples.push({ + variant: v.name, + delayMs: delay, + count, + run, + coldMs: cold.ms, + repeatMs: repeat.ms, + duplicateMs: duplicates.ms, + duplicateStatuses: duplicates.result, + httpRequests: requests - startRequests, + operations: Object.fromEntries( + Object.keys(operations).map((k) => [ + k, + operations[k] - startOps[k], + ]), + ), + }); + } + } + console.log(`Completed delay=${delay}ms entries=${count}`); + } + const intentSamples = []; + if (process.env.FM_BENCH_PREDICT_ONLY === "1") { + latencyMs = 20; + for (const leadMs of [50, 500]) { + for (let run = -2; run < runs; run++) { + for (const v of run % 2 === 0 ? variants : [...variants].reverse()) { + setApi(v.name); + const dir = `/hover/${leadMs}/${run}/${v.name}/500`; + const startRequests = requests; + const complete = new Set(); + let background; + const scheduler = createDirectoryPrefetch({ + read: (p) => + (background = v.api + .listSSHFiles("s", p, { prefetch: true }) + .then((r) => { + verify(r, 500); + complete.add(p); + })), + isCached: (p) => complete.has(p), + run: (task) => { + void task(); + return true; + }, + }); + const interactionStart = performance.now(); + if (v.name === "after") scheduler.hover(dir); + await new Promise((resolve) => setTimeout(resolve, leadMs)); + const readyAtClick = complete.has(dir); + scheduler.setForeground(true); + const clicked = await time(() => v.api.listSSHFiles("s", dir)); + verify(clicked.result, 500); + await background; + scheduler.dispose(); + if (run >= 0) + intentSamples.push({ + variant: v.name, + leadMs, + run, + clickToLoadedMs: clicked.ms, + hoverToLoadedMs: performance.now() - interactionStart, + readyAtClick, + httpRequests: requests - startRequests, + }); + } + } + } + } + assert.equal(sourceHash(), originalHash, "Benchmark changed source files"); + const percentile = (values, p) => + [...values].sort((a, b) => a - b)[Math.ceil(values.length * p) - 1]; + const summary = []; + for (const delay of delays) + for (const count of sizes) + for (const v of variants) { + const group = samples.filter( + (s) => + s.delayMs === delay && s.count === count && s.variant === v.name, + ); + summary.push({ + variant: v.name, + delayMs: delay, + count, + samples: group.length, + ...Object.fromEntries( + ["coldMs", "repeatMs", "duplicateMs"].flatMap((k) => [ + [ + k + "Median", + percentile( + group.map((s) => s[k]), + 0.5, + ), + ], + [ + k + "P95", + percentile( + group.map((s) => s[k]), + 0.95, + ), + ], + ]), + ), + duplicateStatuses: group[0].duplicateStatuses, + }); + } + writeFileSync( + path.join(out, "results.json"), + JSON.stringify( + { + baselineRef, + currentSourceHash: originalHash, + sourceUnchanged: true, + environment: { + node: process.version, + platform: platform(), + cpu: cpus()[0]?.model, + ssh2: "1.17.0", + }, + method: { + runs, + warmups: 2, + batchSize, + delays, + sizes, + order: "alternating", + fixture: + "real encrypted loopback SSH/SFTP with synthetic data and per-response delay; actual routes/API; existing SSH/SFTP connection; no real user remote or browser", + }, + summary, + samples, + intentSamples, + }, + null, + 2, + ), + ); + console.log(JSON.stringify({ out, summary }, null, 2)); +} finally { + http.closeAllConnections(); + await new Promise((resolve) => http.close(resolve)); + globalThis.__fmSftp.end(); + client.end(); + server.close(); +} diff --git a/scripts/benchmark-file-rendering.cjs b/scripts/benchmark-file-rendering.cjs new file mode 100644 index 0000000..9c50369 --- /dev/null +++ b/scripts/benchmark-file-rendering.cjs @@ -0,0 +1,452 @@ +// Controlled browser component benchmark; no live host/SSH access. Windows: node scripts/benchmark-file-rendering.cjs [repository] [new-output-directory] +const fs = require("node:fs"), + path = require("node:path"), + os = require("node:os"), + cp = require("node:child_process"), + crypto = require("node:crypto"), + http = require("node:http"); +const repo = path.resolve(process.argv[2] || process.cwd()), + dir = process.argv[3] + ? path.resolve(process.argv[3]) + : fs.mkdtempSync(path.join(os.tmpdir(), "fm-render-benchmark-")), + base = "d6e2526a8c0d800085e771a410412038e73776f6"; +const req = require("node:module").createRequire( + path.join(repo, "package.json"), +); +const esbuild = req("esbuild"), + { compile } = req("@tailwindcss/node"), + { Scanner } = req("@tailwindcss/oxide"); +const { chromium } = require( + path.join( + process.env.APPDATA, + "npm/node_modules/@playwright/cli/node_modules/playwright", + ), +); +const hash = (s) => crypto.createHash("sha256").update(s).digest("hex"); +(async () => { + fs.mkdirSync(dir, { recursive: true }); + if (path.resolve(__filename) !== path.join(dir, "benchmark.cjs")) + fs.copyFileSync(__filename, path.join(dir, "benchmark.cjs")); + const status = cp.execFileSync("git", ["status", "--short"], { + cwd: repo, + encoding: "utf8", + }); + for (const variant of ["baseline", "current"]) + fs.mkdirSync(path.join(dir, variant), { recursive: true }); + cp.execFileSync( + "git", + [ + "archive", + base, + "--format=tar", + "--output=" + path.join(dir, "baseline.tar"), + "src/frontend", + ], + { cwd: repo }, + ); + cp.execFileSync("tar", [ + "-xf", + path.join(dir, "baseline.tar"), + "-C", + path.join(dir, "baseline"), + ]); + fs.cpSync( + path.join(repo, "src/frontend"), + path.join(dir, "current/src/frontend"), + { recursive: true }, + ); + const baselineManager = fs.readFileSync( + path.join(dir, "baseline/src/frontend/FileManager.tsx"), + "utf8", + ); + const start = baselineManager.indexOf(" const filteredFiles = useMemo("), + end = baselineManager.indexOf( + " [files, searchQuery, sortBy, sortOrder],", + start, + ); + if (start < 0 || end < 0) + throw Error("Cannot extract exact baseline filter expression"); + const body = baselineManager + .slice(start, end) + .split("() =>")[1] + .trim() + .replace(/,\s*$/, ""); + fs.writeFileSync( + path.join(dir, "baseline/filter.ts"), + "export const filterAndSortFiles = (files, searchQuery, sortBy, sortOrder) => " + + body + + ";\n", + ); + fs.writeFileSync( + path.join(dir, "current/filter.ts"), + 'export {filterAndSortFiles} from "./src/frontend/file-list.ts";\n', + ); + const entry = String.raw` +import React,{useState,useMemo} from 'react'; +import {createRoot} from 'react-dom/client'; +import {FileManagerGrid} from './src/frontend/FileManagerGrid.tsx'; +import {filterAndSortFiles} from './filter.ts'; +const params=new URLSearchParams(location.search),n=Number(params.get('n')),mode=params.get('mode'); +let seed=123456789; const raw=Array.from({length:n},(_,i)=>({name:'file-'+String(i).padStart(5,'0')+'.txt',path:'/a/file-'+String(i).padStart(5,'0')+'.txt',type:i%10===0?'directory':'file',size:i*137,owner:'bench',group:'bench',permissions:'-rw-r--r--',modified:'2026-10-01',modifiedTimestamp:1790812800+i})); +for(let i=raw.length-1;i>0;i--){seed=(Math.imul(seed,1664525)+1013904223)>>>0;const j=seed%(i+1);[raw[i],raw[j]]=[raw[j],raw[i]];} +window.input={n,mode,seed:123456789};window.dataset=raw; +const frames=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));window.frames2=frames; +const root=createRoot(document.getElementById('root')); +function App(){const [folder,setFolder]=useState('a'),[query,setQuery]=useState(''),[order,setOrder]=useState('asc'),[hidden,setHidden]=useState(false),[selected,setSelected]=useState([]); +const files=useMemo(()=>raw.map(f=>({...f,name:(folder==='b'?'updated-':'')+f.name,path:'/'+folder+'/'+f.name})),[folder]); +const filtered=useMemo(()=>filterAndSortFiles(files,query,'name',order),[files,query,order]);window.expected=filtered;window.state={folder,query,order,hidden}; +return <>
{}} onRefresh={()=>{}} viewMode={mode} density="comfortable" sortBy="name" sortOrder={order}/>
} +window.mount=()=>root.render(); +window.check=()=>{const pane=document.querySelector('#pane'),scroller=pane?.querySelector('.overflow-y-auto'),sr=scroller?.getBoundingClientRect();const expected=window.expected||[];const allowed=new Set(expected.map(f=>f.path));const rows=[...(pane?.querySelectorAll('[data-file-path]')||[])].map(el=>{const r=el.getBoundingClientRect();return{path:el.dataset.filePath,name:el.querySelector('[title]')?.getAttribute('title')||el.title,x:r.x,y:r.y,width:r.width,height:r.height,visible:!!sr&&r.bottom>sr.top&&r.topsr.left&&r.leftr.visible);const ys=[...new Set(visible.map(r=>r.y))].sort((a,b)=>a-b);const gaps=ys.slice(1).map((y,i)=>y-ys[i]);const errors=[]; +if(!scroller||!sr||sr.height<100||sr.width<500)errors.push('missing/collapsed scroller'); +if(expected.length&&!visible.length)errors.push('no visible rows');if(!expected.length&&rows.length)errors.push('rows in empty listing'); +if(rows.some(r=>!allowed.has(r.path)))errors.push('unexpected filename/path'); +if(rows.some(r=>r.name!==expected.find(f=>f.path===r.path)?.name))errors.push('filename mismatch'); +if(rows.some(r=>r.width<=0||r.height<=0))errors.push('zero geometry'); +if(expected.length>500&&rows.length>=expected.length)errors.push('all rows rendered, not virtualized'); +if(mode==='list'&&gaps.some(g=>g<20||g>43))errors.push('list spacing outside 20..43px');if(mode==='grid'&&gaps.some(g=>Math.abs(g-112)>1))errors.push('grid row stride is not 112px'); +if(!expected.length&&!pane?.textContent.includes('fileManager.emptyFolder'))errors.push('missing empty state'); +if(!pane?.textContent.includes(expected.length+' fileManager.items'))errors.push('footer count mismatch'); +return {errors,expectedCount:expected.length,renderedCount:rows.length,visibleCount:visible.length,visibleNames:visible.map(r=>r.name),visibleRows:visible,firstExpected:expected[0]?.name,lastExpected:expected.at(-1)?.name,scrollTop:scroller?.scrollTop,scrollHeight:scroller?.scrollHeight,scroller:sr?{width:sr.width,height:sr.height,x:sr.x,y:sr.y}:null,gaps};}; +window.perform=async(action)=>{const start=performance.now();if(action==='initial')window.mount();else document.getElementById(action).click();await frames();const elapsed=performance.now()-start;return{ms:elapsed,check:window.check(),state:window.state};}; +`; + fs.writeFileSync(path.join(dir, "entry-template.tsx"), entry); + // shortcut: only SDK cn/i18n are stubbed, use the host application for integration coverage. + const sdkStub = `export const cn=(...xs)=>xs.filter(Boolean).join(' ');export const useTranslation=()=>({t:key=>key});`; + const metadata = { + created: new Date().toISOString(), + baseline: base, + currentHead: cp + .execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repo, + encoding: "utf8", + }) + .trim(), + currentSource: "working-tree snapshot, not HEAD", + status, + os: os.platform() + " " + os.release(), + cpu: os.cpus()[0].model, + cores: os.cpus().length, + node: process.version, + versions: {}, + inputs: { + sizes: [0, 500, 10000], + modes: ["list", "grid"], + samples: 15, + warmups: 2, + viewport: { width: 1200, height: 850 }, + pane: { width: 1000, height: 640 }, + density: "comfortable", + seed: 123456789, + }, + hashes: {}, + }; + for (const mod of [ + "react", + "react-dom", + "@tanstack/react-virtual", + "esbuild", + "tailwindcss", + ]) + metadata.versions[mod] = req(mod + "/package.json").version; + let contents = []; + for (const variant of ["baseline", "current"]) { + const rootDir = path.join(dir, variant); + fs.writeFileSync(path.join(rootDir, "entry.tsx"), entry); + const walk = (p) => { + for (const e of fs.readdirSync(p, { withFileTypes: true })) { + const q = path.join(p, e.name); + if (e.isDirectory()) walk(q); + else if (/\.(tsx?|css)$/.test(q)) + contents.push({ + content: fs.readFileSync(q, "utf8"), + extension: path.extname(q).slice(1), + }); + } + }; + walk(path.join(rootDir, "src/frontend")); + metadata.hashes[variant] = { + grid: hash( + fs.readFileSync(path.join(rootDir, "src/frontend/FileManagerGrid.tsx")), + ), + filter: hash(fs.readFileSync(path.join(rootDir, "filter.ts"))), + }; + const result = await esbuild.build({ + entryPoints: [path.join(rootDir, "entry.tsx")], + outfile: path.join(rootDir, "bundle.js"), + bundle: true, + minify: true, + define: { "process.env.NODE_ENV": '"production"' }, + nodePaths: [path.join(repo, "node_modules")], + jsx: "automatic", + metafile: true, + plugins: [ + { + name: "minimal-host-boundary", + setup(build) { + build.onResolve( + { filter: /^@termix-ssh\/plugin-sdk\/(ui|frontend)$/ }, + (args) => ({ path: args.path, namespace: "sdk" }), + ); + build.onLoad({ filter: /.*/, namespace: "sdk" }, () => ({ + contents: sdkStub, + loader: "js", + })); + }, + }, + ], + }); + fs.writeFileSync( + path.join(rootDir, "metafile.json"), + JSON.stringify(result.metafile, null, 2), + ); + fs.writeFileSync( + path.join(rootDir, "index.html"), + '
', + ); + } + const themePath = req.resolve("@termix-ssh/plugin-sdk/theme.css"); + const theme = fs.readFileSync(themePath, "utf8"); + metadata.css = { + themePath, + themeSha256: hash(theme), + kind: "Installed SDK theme + real Tailwind theme/preflight/utilities; isolated shell palette variables", + }; + const compiler = await compile('@import "tailwindcss";\n' + theme, { + base: path.join(repo, "node_modules"), + onDependency: () => {}, + }); + const candidates = new Scanner({}).scanFiles(contents); + fs.writeFileSync( + path.join(dir, "style.css"), + compiler.build(candidates) + + "\n:root{--border:#444;--background:#171717;--foreground:#eee;--card:#222;--muted:#333;--muted-foreground:#aaa;--accent-brand:#68aaff;--font-ui:Arial}body{margin:0;background:var(--background);color:var(--foreground);font-family:Arial,sans-serif}nav{height:40px}button{padding:4px 10px;border:1px solid #555}", + ); + fs.writeFileSync( + path.join(dir, "metadata.json"), + JSON.stringify(metadata, null, 2), + ); + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://localhost"), + p = path.join(dir, url.pathname); + if (!p.startsWith(dir)) { + response.writeHead(403).end(); + return; + } + try { + const buf = fs.readFileSync(p); + response.setHeader( + "Content-Type", + p.endsWith(".js") + ? "text/javascript" + : p.endsWith(".css") + ? "text/css" + : "text/html", + ); + response.end(buf); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const browser = await chromium.launch({ headless: true }); + metadata.browser = browser.version(); + fs.writeFileSync( + path.join(dir, "metadata.json"), + JSON.stringify(metadata, null, 2), + ); + const samples = [], + errors = []; + const context = await browser.newContext({ + viewport: metadata.inputs.viewport, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + page.on("pageerror", (e) => errors.push(e.message)); + const output = path.join(dir, "samples.jsonl"); + fs.writeFileSync(output, ""); + try { + for (const mode of metadata.inputs.modes) + for (const n of metadata.inputs.sizes) + for (let round = -2; round < 15; round++) { + const order = + round % 2 === 0 ? ["baseline", "current"] : ["current", "baseline"]; + for (const variant of order) { + await page.goto( + "http://127.0.0.1:" + + server.address().port + + "/" + + variant + + "/index.html?n=" + + n + + "&mode=" + + mode, + ); + await page.waitForFunction(() => window.perform); + const record = { + variant, + mode, + n, + round, + warmup: round < 0, + started: new Date().toISOString(), + actions: {}, + }; + for (const action of ["initial", "update"]) + record.actions[action] = await page.evaluate( + (a) => window.perform(a), + action, + ); + record.fullPreHide = await page.evaluate(async () => { + const el = document.querySelector(".overflow-y-auto"); + el.scrollTop = el.scrollHeight / 2; + await window.frames2(); + await window.frames2(); + return window.check(); + }); + await page.evaluate(async () => { + document.getElementById("hide").click(); + await window.frames2(); + await window.frames2(); + }); + record.fullHidden = await page.evaluate(() => ({ + display: getComputedStyle(document.querySelector("#pane")) + .display, + height: document.querySelector(".overflow-y-auto").clientHeight, + })); + record.actions.fullShow = await page.evaluate(() => + window.perform("show"), + ); + await page.evaluate(async () => { + document.querySelector(".overflow-y-auto").scrollTop = 0; + await window.frames2(); + await window.frames2(); + }); + for (const action of ["filter", "sort"]) + record.actions[action] = await page.evaluate( + (a) => window.perform(a), + action, + ); + // A real scroll plus a display:none tab transition; never override geometry/virtualizer. + record.preHide = await page.evaluate(async () => { + const el = document.querySelector(".overflow-y-auto"); + el.scrollTop = el.scrollHeight / 2; + await window.frames2(); + return window.check(); + }); + await page.evaluate(async () => { + document.getElementById("hide").click(); + await window.frames2(); + await window.frames2(); + }); + record.actions.show = await page.evaluate(() => + window.perform("show"), + ); + record.scrollEnd = await page.evaluate(async () => { + const el = document.querySelector(".overflow-y-auto"); + el.scrollTop = el.scrollHeight; + await window.frames2(); + await window.frames2(); + const check = window.check(); + if ( + window.expected.length && + !check.visibleNames.includes(check.lastExpected) + ) + check.errors.push("last filename not visible at scroll end"); + return check; + }); + if (round === -2 && variant === "current") + fs.writeFileSync( + path.join(dir, "inputs-" + n + ".json"), + JSON.stringify(await page.evaluate(() => window.dataset)), + ); + fs.appendFileSync(output, JSON.stringify(record) + "\n"); + samples.push(record); + } + console.log( + JSON.stringify({ + mode, + n, + round, + complete: samples.length, + failed: samples.filter( + (r) => + Object.values(r.actions).some((a) => a.check.errors.length) || + r.scrollEnd.errors.length, + ).length, + }), + ); + } + } finally { + await browser.close(); + await new Promise((r) => server.close(r)); + } + metadata.finished = new Date().toISOString(); + fs.writeFileSync( + path.join(dir, "metadata.json"), + JSON.stringify(metadata, null, 2), + ); + fs.writeFileSync( + path.join(dir, "browser-errors.json"), + JSON.stringify(errors, null, 2), + ); + const groups = []; + const pct = (arr, p) => + arr.slice().sort((a, b) => a - b)[Math.ceil(arr.length * p) - 1]; + for (const mode of metadata.inputs.modes) + for (const n of metadata.inputs.sizes) + for (const action of [ + "initial", + "update", + "fullShow", + "filter", + "sort", + "show", + ]) { + const row = { mode, n, action }; + for (const variant of ["baseline", "current"]) { + const records = samples.filter( + (s) => + !s.warmup && + s.mode === mode && + s.n === n && + s.variant === variant, + ), + values = records.map((r) => r.actions[action].ms); + row[variant] = { + samples: values.length, + median: pct(values, 0.5), + p95: pct(values, 0.95), + failures: records.filter( + (r) => r.actions[action].check.errors.length, + ).length, + }; + } + row.deltaMedian = row.current.median - row.baseline.median; + row.deltaPercent = 100 * (row.current.median / row.baseline.median - 1); + groups.push(row); + } + fs.writeFileSync( + path.join(dir, "summary.json"), + JSON.stringify( + { + metadata, + groups, + browserErrors: errors, + measuredRecords: samples.filter((s) => !s.warmup).length, + failedRecords: samples.filter( + (s) => + !s.warmup && + (Object.values(s.actions).some((a) => a.check.errors.length) || + s.scrollEnd.errors.length), + ).length, + }, + null, + 2, + ), + ); + console.log("DONE " + dir); +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/src/backend/index.ts b/src/backend/index.ts index d637c26..5da8a97 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -169,7 +169,6 @@ export async function activate(ctx: PluginContext) { const sshSessions: Record = {}; const pendingTOTPSessions: Record = {}; - const activeListRequests: Record = {}; function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; @@ -861,6 +860,8 @@ export async function activate(ctx: PluginContext) { scpLegacy: resolvedScpLegacy, }; scheduleSessionCleanup(sessionId); + // Open SFTP now so the first listing does not pay for it; fallbacks stay lazy. + void getSessionSftp(sshSessions[sessionId]).catch(() => {}); if (userId) { void (async () => { @@ -1362,6 +1363,8 @@ export async function activate(ctx: PluginContext) { scpLegacy: session.scpLegacy, }; scheduleSessionCleanup(sessionId); + // Open SFTP now so the first listing does not pay for it; fallbacks stay lazy. + void getSessionSftp(sshSessions[sessionId]).catch(() => {}); res.json({ status: "success", @@ -1512,6 +1515,8 @@ export async function activate(ctx: PluginContext) { scpLegacy: session.scpLegacy, }; scheduleSessionCleanup(sessionId); + // Open SFTP now so the first listing does not pay for it; fallbacks stay lazy. + void getSessionSftp(sshSessions[sessionId]).catch(() => {}); res.json({ status: "success", @@ -1673,7 +1678,6 @@ export async function activate(ctx: PluginContext) { registerFileListingRoutes(app, { ctx, sshSessions, - activeListRequests, verifySessionOwnership, }); registerFileContentRoutes(app, { ctx, sshSessions, verifySessionOwnership }); diff --git a/src/backend/list-routes.ts b/src/backend/list-routes.ts index 670c959..91b0cf9 100644 --- a/src/backend/list-routes.ts +++ b/src/backend/list-routes.ts @@ -1,4 +1,5 @@ import { resolveHomeDirectory } from "./home-directory.js"; +import { readDirectory } from "./read-directory.js"; import { getErrorMessage } from "./error-message.js"; import type { Express } from "express"; import type { PluginContext } from "@termix-ssh/plugin-sdk/backend"; @@ -13,19 +14,25 @@ import { type FileListingRoutesDeps = { ctx: PluginContext; sshSessions: Record; - activeListRequests: Record; verifySessionOwnership: (session: SSHSession, userId: string) => boolean; }; +type ListingResult = { status: number; body: unknown }; + export function registerFileListingRoutes( app: Express, - { - ctx, - sshSessions, - activeListRequests, - verifySessionOwnership, - }: FileListingRoutesDeps, + { ctx, sshSessions, verifySessionOwnership }: FileListingRoutesDeps, ): void { + const pendingListings = new WeakMap< + SSHSession, + Map< + string, + { + sudoPassword: SSHSession["sudoPassword"]; + promise: Promise; + } + > + >(); /** * @openapi * /plugin-api/file-manager/listFiles: @@ -58,138 +65,225 @@ export function registerFileListingRoutes( const sshConn = sshSessions[sessionId]; let sshPath = (req.query.path as string) || "."; const userId = ctx.currentActor()!; + let closed = false; + res.on("close", () => { + closed = true; + }); + const sendJson = ({ status, body }: ListingResult) => { + if (closed || res.destroyed || res.writableEnded || res.headersSent) + return; + return res.status(status).json(body); + }; if (!sessionId) { - return res.status(400).json({ error: "Session ID is required" }); + return sendJson({ + status: 400, + body: { error: "Session ID is required" }, + }); } if (!sshConn?.isConnected) { - return res.status(400).json({ error: "SSH connection not established" }); + return sendJson({ + status: 400, + body: { error: "SSH connection not established" }, + }); } if (!verifySessionOwnership(sshConn, userId)) { - return res.status(403).json({ error: "Session access denied" }); + return sendJson({ + status: 403, + body: { error: "Session access denied" }, + }); } if (sshPath === ".") { try { sshPath = await resolveHomeDirectory(sshConn); } catch (error) { - return res.status(500).json({ - error: getErrorMessage(error, "Could not resolve home directory"), + return sendJson({ + status: 500, + body: { + error: getErrorMessage(error, "Could not resolve home directory"), + }, + }); + } + // Home discovery yields; the session or its ownership may have changed. + if (sshSessions[sessionId] !== sshConn || !sshConn.isConnected) { + return sendJson({ + status: 400, + body: { error: "SSH connection not established" }, + }); + } + if (!verifySessionOwnership(sshConn, userId)) { + return sendJson({ + status: 403, + body: { error: "Session access denied" }, }); } } - // Drop concurrent requests for the same session+path, each would open - // a new SSH channel and can exceed the server's per-connection channel limit. - const listKey = `${sessionId}:${sshPath}`; - if (activeListRequests[listKey]) { - return res - .status(409) - .json({ error: "List request already in progress" }); + const sudoPassword = sshConn.sudoPassword; + const force = req.query.force === "true"; + const epoch = + typeof req.query.epoch === "string" && /^\d+$/.test(req.query.epoch) + ? req.query.epoch + : "0"; + const prefetch = req.query.prefetch === "true"; + const listKey = JSON.stringify([userId, sshPath, epoch, prefetch]); + let listings = pendingListings.get(sshConn); + if (!listings) { + listings = new Map(); + pendingListings.set(sshConn, listings); } - activeListRequests[listKey] = true; - // "close" also fires when the client goes away before the answer. - res.on("close", () => { - delete activeListRequests[listKey]; + const inFlight = listings.get(listKey); + if (!force && inFlight && inFlight.sudoPassword === sudoPassword) { + return sendJson(await inFlight.promise); + } + let settle!: (result: ListingResult) => void; + const promise = new Promise((resolve) => { + settle = resolve; }); + if (!force) listings.set(listKey, { sudoPassword, promise }); + let settled = false; + let released = false; + // Once-only so the watchdog and a late callback never double-decrement. + const release = () => { + if (released) return; + released = true; + sshConn.activeOperations--; + }; + // Joiners wait on this promise; a hung SFTP/exec callback must not strand them. + const watchdog = setTimeout(() => { + release(); + finish(504, { error: "Directory listing timed out" }); + }, 30_000); + const finish = (status: number, body: unknown) => { + if (settled) return; + settled = true; + clearTimeout(watchdog); + if (!force && listings.get(listKey)?.promise === promise) { + listings.delete(listKey); + } + settle({ status, body }); + }; sshConn.lastActive = Date.now(); sshConn.activeOperations++; const trySFTP = () => { try { - ctx.log.info( - `Opening SFTP channel for listFiles: ${sessionId} (${sshPath})`, - ); + ctx.log.info(`Opening SFTP channel for listFiles: ${sessionId}`); getSessionSftp(sshConn) .then((sftp) => { - sftp.readdir(sshPath, (readdirErr, list) => { - if (readdirErr) { - ctx.log.warn( - `SFTP readdir failed, trying fallback: ${readdirErr.message}`, - ); - tryFallbackMethod(); - return; - } - - const symlinks: Array<{ index: number; path: string }> = []; - const files: Array<{ - name: string; - type: string; - size: number | undefined; - modified: string; - modifiedTimestamp: number; - permissions: string; - owner: string; - group: string; - linkTarget: string | undefined; - path: string; - executable: boolean; - }> = []; - - for (const entry of list) { - if (entry.filename === "." || entry.filename === "..") continue; - - const attrs = entry.attrs; - const permissions = modeToPermissions(attrs.mode); - const isDirectory = attrs.isDirectory(); - const isLink = attrs.isSymbolicLink(); - - const fileEntry = { - name: entry.filename, - type: isDirectory ? "directory" : isLink ? "link" : "file", - size: isDirectory ? undefined : attrs.size, - modified: formatMtime(attrs.mtime), - modifiedTimestamp: attrs.mtime, - permissions, - owner: String(attrs.uid), - group: String(attrs.gid), - linkTarget: undefined as string | undefined, - path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`, - executable: - !isDirectory && !isLink - ? isExecutableFile(permissions, entry.filename) - : false, - }; - - if (isLink) { - symlinks.push({ index: files.length, path: fileEntry.path }); + readDirectory( + sftp, + sshPath, + (readdirErr, list) => { + if (readdirErr) { + if ( + prefetch && + ((readdirErr as Error & { code?: number }).code === 3 || + /permission denied|access denied/i.test( + readdirErr.message, + )) + ) { + release(); + return finish(403, { + needsSudo: true, + prefetchSkippedSudo: true, + path: sshPath, + }); + } + ctx.log.warn( + `SFTP readdir failed, trying fallback: ${readdirErr.message}`, + ); + tryFallbackMethod(); + return; } - files.push(fileEntry); - } + const symlinks: Array<{ index: number; path: string }> = []; + const files: Array<{ + name: string; + type: string; + size: number | undefined; + modified: string; + modifiedTimestamp: number; + permissions: string; + owner: string; + group: string; + linkTarget: string | undefined; + path: string; + executable: boolean; + }> = []; + + for (const entry of list) { + if (entry.filename === "." || entry.filename === "..") + continue; + + const attrs = entry.attrs; + const permissions = modeToPermissions(attrs.mode); + const isDirectory = attrs.isDirectory(); + const isLink = attrs.isSymbolicLink(); + + const fileEntry = { + name: entry.filename, + type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : attrs.size, + modified: formatMtime(attrs.mtime), + modifiedTimestamp: attrs.mtime, + permissions, + owner: String(attrs.uid), + group: String(attrs.gid), + linkTarget: undefined as string | undefined, + path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`, + executable: + !isDirectory && !isLink + ? isExecutableFile(permissions, entry.filename) + : false, + }; - if (symlinks.length === 0) { - sshConn.activeOperations--; - return res.json({ files, path: sshPath }); - } + if (isLink) { + symlinks.push({ + index: files.length, + path: fileEntry.path, + }); + } - let resolved = 0; - let responded = false; + files.push(fileEntry); + } - const sendResponse = () => { - if (responded) return; - responded = true; - sshConn.activeOperations--; - res.json({ files, path: sshPath }); - }; + if (symlinks.length === 0) { + release(); + return finish(200, { files, path: sshPath }); + } - const readlinkTimeout = setTimeout(sendResponse, 5000); + let resolved = 0; + let responded = false; - for (const link of symlinks) { - sftp.readlink(link.path, (linkErr, target) => { - resolved++; - if (!linkErr && target) { - files[link.index].linkTarget = target; - } - if (resolved === symlinks.length) { - clearTimeout(readlinkTimeout); - sendResponse(); - } - }); - } - }); + const sendResponse = () => { + if (responded) return; + responded = true; + release(); + finish(200, { files, path: sshPath }); + }; + + const readlinkTimeout = setTimeout(sendResponse, 5000); + + for (const link of symlinks) { + sftp.readlink(link.path, (linkErr, target) => { + resolved++; + if (!linkErr && target) { + files[link.index].linkTarget = target; + } + if (resolved === symlinks.length) { + clearTimeout(readlinkTimeout); + sendResponse(); + } + }); + } + }, + (error) => + ctx.log.warn(`SFTP directory close failed: ${error.message}`), + ); }) .catch((err: Error) => { ctx.log.warn( @@ -213,10 +307,11 @@ export function registerFileListingRoutes( const tryFallbackMethod = () => { if (!sshConn?.isConnected) { - sshConn.activeOperations--; - return res - .status(503) - .json({ error: "SSH session disconnected", disconnected: true }); + release(); + return finish(503, { + error: "SSH session disconnected", + disconnected: true, + }); } try { const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); @@ -225,9 +320,9 @@ export function registerFileListingRoutes( `command ls -la --color=never '${escapedPath}'`, (err, stream) => { if (err) { - sshConn.activeOperations--; + release(); ctx.log.error("SSH listFiles error:", err); - return res.status(500).json({ error: err.message }); + return finish(500, { error: err.message }); } let data = ""; @@ -248,34 +343,40 @@ export function registerFileListingRoutes( errorData.toLowerCase().includes("access denied"); if (isPermissionDenied) { - if (sshConn.sudoPassword) { + if (prefetch) { + release(); + return finish(403, { + needsSudo: true, + prefetchSkippedSudo: true, + path: sshPath, + }); + } + if (sudoPassword) { ctx.log.info( - `Permission denied for listFiles, retrying with sudo: ${sshPath}`, + "Permission denied for listFiles, retrying with sudo", ); tryWithSudo(); return; } - sshConn.activeOperations--; + release(); ctx.log.warn( `Permission denied for listFiles, sudo required: ${sshPath}`, ); - return res.status(403).json({ + return finish(403, { error: `Permission denied: Cannot access ${sshPath}`, needsSudo: true, path: sshPath, }); } - sshConn.activeOperations--; + release(); ctx.log.error( `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); - return res - .status(500) - .json({ error: `Command failed: ${errorData}` }); + return finish(500, { error: `Command failed: ${errorData}` }); } - sshConn.activeOperations--; + release(); const lines = data.split("\n").filter((line) => line.trim()); const files = []; @@ -329,36 +430,36 @@ export function registerFileListingRoutes( } } - res.json({ files, path: sshPath }); + finish(200, { files, path: sshPath }); }); }, ); } catch (execErr: unknown) { - sshConn.activeOperations--; + release(); const errMsg = getErrorMessage(execErr); ctx.log.error(`Fallback listFiles exec failed: ${errMsg}`); - if (!res.headersSent) { - return res.status(500).json({ error: errMsg }); - } + return finish(500, { error: errMsg }); } }; const tryWithSudo = () => { try { const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); - const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`; + const sudoCommand = `sudo -S -p '' /bin/ls -la --color=never '${escapedPath}' 2>&1`; execChannel(sshConn, sudoCommand, (err, stream) => { if (err) { - sshConn.activeOperations--; + release(); ctx.log.error("SSH sudo listFiles error:", err); - return res.status(500).json({ error: err.message }); + return finish(500, { error: err.message }); } let data = ""; let errorData = ""; + // stdin, not argv: keeps the password out of the process list + stream.write(`${sudoPassword}\n`); + stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); @@ -368,7 +469,7 @@ export function registerFileListingRoutes( }); stream.on("close", (code) => { - sshConn.activeOperations--; + release(); data = data.replace(/\[sudo\] password for .+?:\s*/g, ""); @@ -377,8 +478,10 @@ export function registerFileListingRoutes( data.toLowerCase().includes("incorrect password") || errorData.toLowerCase().includes("sorry, try again") ) { - sshConn.sudoPassword = undefined; - return res.status(403).json({ + if (sshConn.sudoPassword === sudoPassword) { + sshConn.sudoPassword = undefined; + } + return finish(403, { error: "Sudo authentication failed. Please try again.", needsSudo: true, sudoFailed: true, @@ -390,9 +493,9 @@ export function registerFileListingRoutes( ctx.log.error( `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); - return res - .status(500) - .json({ error: `Sudo command failed: ${errorData || data}` }); + return finish(500, { + error: `Sudo command failed: ${errorData || data}`, + }); } const lines = data.split("\n").filter((line) => line.trim()); @@ -459,19 +562,18 @@ export function registerFileListingRoutes( } } - res.json({ files, path: sshPath }); + finish(200, { files, path: sshPath }); }); }); } catch (execErr: unknown) { - sshConn.activeOperations--; + release(); const errMsg = getErrorMessage(execErr); ctx.log.error(`Sudo listFiles exec failed: ${errMsg}`); - if (!res.headersSent) { - return res.status(500).json({ error: errMsg }); - } + return finish(500, { error: errMsg }); } }; trySFTP(); + return sendJson(await promise); }); } diff --git a/src/backend/read-directory.ts b/src/backend/read-directory.ts new file mode 100644 index 0000000..6480c32 --- /dev/null +++ b/src/backend/read-directory.ts @@ -0,0 +1,73 @@ +import type { FileEntry, SFTPWrapper } from "ssh2"; + +export function readDirectory( + sftp: SFTPWrapper, + path: string, + done: (error: Error | null, entries: FileEntry[]) => void, + onCloseError: (error: Error) => void, +): void { + const asError = (error: unknown): Error => + error instanceof Error ? error : new Error(String(error)); + + const collect = async (): Promise => { + const handle = await new Promise((resolve, reject) => { + sftp.opendir(path, (error, handle) => + error ? reject(error) : resolve(handle), + ); + }); + const batches: FileEntry[][] = []; + let nextBatch = 0; + let stopped = false; + let failure: Error | undefined; + + const read = async () => { + while (!stopped) { + const index = nextBatch++; + try { + const entries = await new Promise( + (resolve, reject) => { + // Keep dot-only batches nonempty so [] really indicates no progress. + sftp.readdir(handle, { full: true }, (error, entries) => + error ? reject(error) : resolve(entries), + ); + }, + ); + if (entries === false) { + stopped = true; + } else if (entries.length === 0) { + throw new Error("SFTP readdir returned an empty batch without EOF"); + } else { + batches[index] = entries; + } + } catch (error) { + stopped = true; + if ((error as { code?: number })?.code !== 1) { + failure ??= asError(error); + } + } + } + }; + + // EOF can overtake an earlier data/error reply; both readers must drain. + await Promise.all([read(), read()]); + try { + sftp.close(handle, (error) => { + if (error) onCloseError(error); + }); + } catch (error) { + onCloseError(asError(error)); + } + if (failure) throw failure; + const seen = new Set([".", ".."]); + return batches.flat().filter((entry) => { + if (seen.has(entry.filename)) return false; + seen.add(entry.filename); + return true; + }); + }; + + void collect().then( + (entries) => done(null, entries), + (error: unknown) => done(asError(error), []), + ); +} diff --git a/src/frontend/FileManager.tsx b/src/frontend/FileManager.tsx index 3ed87df..0bd3730 100644 --- a/src/frontend/FileManager.tsx +++ b/src/frontend/FileManager.tsx @@ -14,6 +14,7 @@ import React, { import { asHttpError } from "./lib/http-error"; import { cn, PanelShell } from "@termix-ssh/plugin-sdk/ui"; import { FileManagerGrid } from "./FileManagerGrid.tsx"; +import { filterAndSortFiles } from "./file-list.ts"; import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx"; import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx"; import { useFileSelection } from "./hooks/useFileSelection.ts"; @@ -103,6 +104,7 @@ import { runAdaptiveBackgroundTask, } from "@termix-ssh/plugin-sdk/ui"; import { shouldPrefetchFileContent } from "./lib/file-content-request-cache"; +import { createDirectoryPrefetch } from "./lib/directory-prefetch"; import { markFilePreviewUsed, preloadFilePreview, @@ -199,6 +201,78 @@ function FileManagerContent({ const [sshSessionId, setSshSessionId] = useState(null); const sshSessionIdRef = useRef(sshSessionId); sshSessionIdRef.current = sshSessionId; + const paneVisibleRef = useRef(isVisible); + paneVisibleRef.current = isVisible; + const directoryPrefetchRef = useRef | null>(null); + const foregroundDirectoryLoadsRef = useRef(0); + const [prefetchListing, setPrefetchListing] = useState<{ + sessionId: string; + path: string; + files: FileItem[]; + } | null>(null); + + function publishConnectedListing( + sessionId: string, + path: string, + response: Awaited>, + requestId: number, + ) { + // A newer navigation owns the pane; don't let a slow auth listing win. + if (directoryRequestRef.current !== requestId) return; + const files = Array.isArray(response) ? response : response?.files || []; + const listedPath = response.path || path; + setFiles(files); + setPrefetchListing({ sessionId, path: listedPath, files }); + setCurrentPath(listedPath); + setNavHistory((history) => + history.map((entry) => (entry === path ? listedPath : entry)), + ); + lastSuccessfulPathRef.current = listedPath; + } + + useEffect(() => { + const scheduler = createDirectoryPrefetch({ + read: (path) => { + const sessionId = sshSessionIdRef.current; + return sessionId && paneVisibleRef.current + ? listSSHFiles(sessionId, path, { prefetch: true }) + : Promise.resolve(); + }, + isCached: (path) => { + const sessionId = sshSessionIdRef.current; + return ( + !sessionId || peekCachedFileList(sessionId, path, 5_000) !== null + ); + }, + run: (task) => + paneVisibleRef.current && + runAdaptiveBackgroundTask("network", "directory-list", task), + }); + scheduler.setForeground(true); + directoryPrefetchRef.current = scheduler; + return () => { + scheduler.dispose(); + directoryPrefetchRef.current = null; + }; + }, []); + + useEffect(() => { + directoryPrefetchRef.current?.setForeground(true); + if ( + !isVisible || + prefetchListing?.sessionId !== sshSessionId || + prefetchListing?.path !== currentPathRef.current || + foregroundDirectoryLoadsRef.current > 0 + ) + return; + directoryPrefetchRef.current?.setForeground(false); + directoryPrefetchRef.current?.predict( + prefetchListing.path, + prefetchListing.files, + ); + }, [prefetchListing, sshSessionId, isVisible]); const [isReconnecting, setIsReconnecting] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [lastRefreshTime, setLastRefreshTime] = useState(0); @@ -701,17 +775,14 @@ function FileManagerContent({ connectRetryRef.current.markConnected(); try { + const authRequestId = ++directoryRequestRef.current; const response = await listSSHFiles(sessionId, currentPath); - const files = Array.isArray(response) - ? response - : response?.files || []; - setFiles(files); - const listedPath = response.path || currentPath; - setCurrentPath(listedPath); - setNavHistory((history) => - history.map((path) => (path === "." ? listedPath : path)), + publishConnectedListing( + sessionId, + currentPath, + response, + authRequestId, ); - lastSuccessfulPathRef.current = listedPath; clearSelection(); initialLoadDoneRef.current = true; @@ -780,33 +851,45 @@ function FileManagerContent({ } const loadDirectory = useCallback( - async (path: string, conflictAttempt = 0): Promise => { + async ( + path: string, + conflictAttempt = 0, + forceRefresh = false, + ): Promise => { if (!sshSessionId) { return false; } const requestId = ++directoryRequestRef.current; + const scheduler = directoryPrefetchRef.current; + foregroundDirectoryLoadsRef.current += 1; + scheduler?.setForeground(true); let resolvedPath = path; - if (path.includes("$") || path.startsWith("~")) { - resolvedPath = await resolveSSHPath(sshSessionId, path); - if (resolvedPath !== path) { - setCurrentPath(resolvedPath); - lastPathChangeRef.current = resolvedPath; + try { + if (path.includes("$") || path.startsWith("~")) { + resolvedPath = await resolveSSHPath(sshSessionId, path); + if (resolvedPath !== path) { + setCurrentPath(resolvedPath); + lastPathChangeRef.current = resolvedPath; + } } - } - currentLoadingPathRef.current = resolvedPath; - const cached = peekCachedFileList(sshSessionId, resolvedPath); - if (cached) { - setFiles(cached.files); - clearSelection(); - } - setIsLoading(!cached); + currentLoadingPathRef.current = resolvedPath; + const cached = peekCachedFileList(sshSessionId, resolvedPath); + if (cached) { + setFiles(cached.files); + clearSelection(); + } + setIsLoading(!cached); - try { - const response = await listSSHFiles(sshSessionId, resolvedPath, { - force: cached !== null, - }); + const fresh = forceRefresh + ? null + : peekCachedFileList(sshSessionId, resolvedPath, 5_000); + const response = + fresh ?? + (await listSSHFiles(sshSessionId, resolvedPath, { + force: forceRefresh || cached !== null, + })); if (directoryRequestRef.current !== requestId) { return false; @@ -818,6 +901,11 @@ function FileManagerContent({ setFiles(files); const listedPath = response.path || resolvedPath; + setPrefetchListing({ + sessionId: sshSessionId, + path: listedPath, + files, + }); if (listedPath !== resolvedPath) { setCurrentPath(listedPath); setNavHistory((history) => @@ -847,21 +935,6 @@ function FileManagerContent({ const httpStatus = apiError.status ?? apiError.response?.status; - // The sidebar may be listing the same path to populate its tree. - // Retry instead of leaving the breadcrumb and visible files out of sync. - if (httpStatus === 409) { - if (conflictAttempt < 3) { - await new Promise((resolve) => setTimeout(resolve, 250)); - return loadDirectory(resolvedPath, conflictAttempt + 1); - } - const previousPath = lastSuccessfulPathRef.current; - lastPathChangeRef.current = previousPath; - setCurrentPath((current) => - current === resolvedPath ? previousPath : current, - ); - return false; - } - if (apiError.response?.data?.needsSudo) { const previousPath = lastSuccessfulPathRef.current; lastPathChangeRef.current = previousPath; @@ -908,7 +981,7 @@ function FileManagerContent({ try { await ensureSSHConnection(); setIsReconnecting(false); - loadDirectory(resolvedPath); + loadDirectory(resolvedPath, 0, forceRefresh); return; } catch { // keep retrying @@ -937,6 +1010,10 @@ function FileManagerContent({ } return false; } finally { + foregroundDirectoryLoadsRef.current -= 1; + scheduler?.setForeground( + foregroundDirectoryLoadsRef.current > 0 || !paneVisibleRef.current, + ); if (directoryRequestRef.current === requestId) { setIsLoading(false); currentLoadingPathRef.current = ""; @@ -957,13 +1034,16 @@ function FileManagerContent({ lastPathChangeRef.current = path; loadDirectory(path); } - }, 150); + }, 0); }, [sshSessionId, loadDirectory], ); const navigateTo = useCallback( (path: string) => { + directoryPrefetchRef.current?.setForeground( + path !== currentPathRef.current, + ); directoryRequestRef.current += 1; const cached = sshSessionId ? peekCachedFileList(sshSessionId, path) @@ -995,6 +1075,7 @@ function FileManagerContent({ const goBack = useCallback(() => { if (navIndex > 0) { + directoryPrefetchRef.current?.setForeground(true); directoryRequestRef.current += 1; const newIndex = navIndex - 1; const path = navHistory[newIndex]; @@ -1010,6 +1091,7 @@ function FileManagerContent({ const goForward = useCallback(() => { if (navIndex < navHistory.length - 1) { + directoryPrefetchRef.current?.setForeground(true); directoryRequestRef.current += 1; const newIndex = navIndex + 1; const path = navHistory[newIndex]; @@ -1067,22 +1149,52 @@ function FileManagerContent({ }; }, [sshSessionId, currentPath, debouncedLoadDirectory]); + const refreshTrailingTimerRef = useRef | null>( + null, + ); const handleRefreshDirectory = useCallback(() => { const now = Date.now(); const DEBOUNCE_MS = 500; + // Mutations must always drop stale snapshots, even when the read is throttled. + if (sshSessionId) invalidateCachedFileList(sshSessionId, currentPath); + const sessionAtSchedule = sshSessionId; + const run = () => { + refreshTrailingTimerRef.current = null; + // A navigation or session change since scheduling makes this read stale. + if ( + currentPathRef.current !== currentPath || + sshSessionIdRef.current !== sessionAtSchedule + ) + return; + setLastRefreshTime(Date.now()); + // Force reset loading state to ensure refresh is not blocked + setIsLoading(false); + currentLoadingPathRef.current = ""; + loadDirectory(currentPath, 0, true); + }; + if (now - lastRefreshTime < DEBOUNCE_MS) { + // Trailing forced read so the post-mutation listing is never skipped. + if (refreshTrailingTimerRef.current) + clearTimeout(refreshTrailingTimerRef.current); + refreshTrailingTimerRef.current = setTimeout( + run, + DEBOUNCE_MS - (now - lastRefreshTime), + ); return; } - - setLastRefreshTime(now); - if (sshSessionId) invalidateCachedFileList(sshSessionId, currentPath); - // Force reset loading state to ensure refresh is not blocked - setIsLoading(false); - currentLoadingPathRef.current = ""; - loadDirectory(currentPath); + run(); }, [currentPath, lastRefreshTime, loadDirectory, sshSessionId]); + useEffect( + () => () => { + if (refreshTrailingTimerRef.current) + clearTimeout(refreshTrailingTimerRef.current); + }, + [], + ); + useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ hostId: number; path: string }>) @@ -1774,7 +1886,7 @@ function FileManagerContent({ } return; } else if (pendingSudoOperation.type === "navigate") { - const success = await loadDirectory(pendingSudoOperation.path); + const success = await loadDirectory(pendingSudoOperation.path, 0, true); if (success) { setCurrentPath(pendingSudoOperation.path); dismissSudo(); @@ -2072,6 +2184,9 @@ function FileManagerContent({ } async function handleFileOpen(file: FileItem) { if (file.type === "directory") { + directoryPrefetchRef.current?.setForeground( + file.path !== currentPathRef.current, + ); markAdaptiveResourceUsed("network", "directory-list"); directoryRequestRef.current += 1; const cached = sshSessionId @@ -2108,11 +2223,9 @@ function FileManagerContent({ const prefetchFileIntent = useCallback( (file: FileItem) => { - if (!sshSessionId) return; + if (!sshSessionId || !isVisible) return; if (file.type === "directory") { - runAdaptiveBackgroundTask("network", "directory-list", () => - listSSHFiles(sshSessionId, file.path), - ); + directoryPrefetchRef.current?.intent(file.path); return; } const budget = getAdaptiveResourceBudget("network"); @@ -2128,7 +2241,7 @@ function FileManagerContent({ ); } }, - [sshSessionId], + [sshSessionId, isVisible], ); function handleContextMenu(event: React.MouseEvent, file?: FileItem) { @@ -2936,11 +3049,14 @@ function FileManagerContent({ connectRetryRef.current.markConnected(); try { + const authRequestId = ++directoryRequestRef.current; const response = await listSSHFiles(totpSessionId, currentPath); - const files = Array.isArray(response) - ? response - : response?.files || []; - setFiles(files); + publishConnectedListing( + totpSessionId, + currentPath, + response, + authRequestId, + ); clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); @@ -2978,14 +3094,17 @@ function FileManagerContent({ connectRetryRef.current.markConnected(); try { + const authRequestId = ++directoryRequestRef.current; const response = await listSSHFiles( browserSignInSessionId, currentPath, ); - const files = Array.isArray(response) - ? response - : response?.files || []; - setFiles(files); + publishConnectedListing( + browserSignInSessionId, + currentPath, + response, + authRequestId, + ); clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); @@ -3079,11 +3198,14 @@ function FileManagerContent({ connectRetryRef.current.markConnected(); try { + const authRequestId = ++directoryRequestRef.current; const response = await listSSHFiles(sessionId, currentPath); - const files = Array.isArray(response) - ? response - : response?.files || []; - setFiles(files); + publishConnectedListing( + sessionId, + currentPath, + response, + authRequestId, + ); clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); @@ -3163,11 +3285,14 @@ function FileManagerContent({ connectRetryRef.current.markConnected(); try { + const authRequestId = ++directoryRequestRef.current; const response = await listSSHFiles(sessionId, currentPath); - const files = Array.isArray(response) - ? response - : response?.files || []; - setFiles(files); + publishConnectedListing( + sessionId, + currentPath, + response, + authRequestId, + ); clearSelection(); initialLoadDoneRef.current = true; toast.success(t("fileManager.connectedSuccessfully")); @@ -3591,32 +3716,7 @@ function FileManagerContent({ }, [sortBy, sortOrder]); const filteredFiles = useMemo( - () => - files - .filter((file) => - file.name.toLowerCase().includes(searchQuery.toLowerCase()), - ) - .sort((a, b) => { - if (a.type === "directory" && b.type !== "directory") return -1; - if (a.type !== "directory" && b.type === "directory") return 1; - - let result = 0; - switch (sortBy) { - case "name": - result = a.name.localeCompare(b.name, undefined, { - numeric: true, - sensitivity: "base", - }); - break; - case "modified": - result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0); - break; - case "size": - result = (a.size || 0) - (b.size || 0); - break; - } - return sortOrder === "desc" ? -result : result; - }), + () => filterAndSortFiles(files, searchQuery, sortBy, sortOrder), [files, searchQuery, sortBy, sortOrder], ); @@ -3796,10 +3896,17 @@ function FileManagerContent({
+ isVisible && directoryPrefetchRef.current?.hover(file.path) + } + onDirectoryLeave={(file) => + directoryPrefetchRef.current?.cancelIntent(file.path) + } onSelectionChange={setSelection} onRefresh={handleRefreshDirectory} onUpload={handleFilesDropped} diff --git a/src/frontend/FileManagerGrid.tsx b/src/frontend/FileManagerGrid.tsx index 4e7fd45..44ede6a 100644 --- a/src/frontend/FileManagerGrid.tsx +++ b/src/frontend/FileManagerGrid.tsx @@ -28,6 +28,7 @@ import { FileSymlink, Move, GitCompare, + Loader2, } from "lucide-react"; import { useTranslation } from "@termix-ssh/plugin-sdk/frontend"; import type { FileItem } from "./host-types"; @@ -85,11 +86,15 @@ interface DragState { interface FileManagerGridProps { files: FileItem[]; + /** Show a spinner instead of the empty-folder text while a listing is in flight. */ + isLoading?: boolean; selectedFiles: FileItem[]; /** Paths being moved to the trash: shown dimmed and not interactive. */ deletingPaths?: ReadonlySet; onFileOpen: (file: FileItem) => void; onFileIntent?: (file: FileItem) => void; + onDirectoryHover?: (file: FileItem) => void; + onDirectoryLeave?: (file: FileItem) => void; onSelectionChange: (files: FileItem[]) => void; onRefresh: () => void; onUpload?: (files: FileList) => void; @@ -226,10 +231,13 @@ const getFileIcon = ( export function FileManagerGrid({ files, + isLoading, selectedFiles, deletingPaths, onFileOpen, onFileIntent, + onDirectoryHover, + onDirectoryLeave, onSelectionChange, onRefresh, onUpload, @@ -265,6 +273,16 @@ export function FileManagerGrid({ }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); + const clickingFileRef = useRef(false); + const selectedDirectory = + selectedFiles.length === 1 && selectedFiles[0].type === "directory" + ? selectedFiles[0] + : undefined; + useEffect(() => { + if (gridRef.current === document.activeElement && selectedDirectory) { + onFileIntent?.(selectedDirectory); + } + }, [selectedDirectory?.path, onFileIntent]); const [editingName, setEditingName] = useState(""); const [gridCols, setGridCols] = useState(4); @@ -304,6 +322,14 @@ export function FileManagerGrid({ return () => ro.disconnect(); }, [viewMode, CONTENT_PAD, GRID_GAP, GRID_CELL]); + const selectedPaths = useMemo( + () => new Set(selectedFiles.map((f) => f.path)), + [selectedFiles], + ); + const dragPaths = useMemo( + () => new Set(dragState.files.map((f) => f.path)), + [dragState.files], + ); const gridRowCount = useMemo( () => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0), [viewMode, files.length, gridCols], @@ -348,6 +374,27 @@ export function FileManagerGrid({ // entries would otherwise keep this folder's measured row heights. }, [viewMode, density, files, editingFile?.path, createIntent, gridCols]); + // A hidden tab (display:none) collapses the scroller and the browser resets + // its scrollTop without a scroll event, leaving the virtualizers with a stale + // offset and rect. Re-sync them when the scroller becomes visible again. + useEffect(() => { + const el = gridRef.current; + if (!el) return; + let wasHidden = el.clientHeight === 0; + const ro = new ResizeObserver(() => { + const hidden = el.clientHeight === 0; + if (wasHidden && !hidden) { + for (const v of [listVirtualizer, gridVirtualizer]) { + v.scrollOffset = el.scrollTop; + v.measure(); + } + } + wasHidden = hidden; + }); + ro.observe(el); + return () => ro.disconnect(); + }, [listVirtualizer, gridVirtualizer]); + useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { if (dragState.type === "internal" && dragState.files.length > 0) { @@ -879,7 +926,9 @@ export function FileManagerGrid({ onFileIntent?.(file); if (gridRef.current && !createIntent) { + clickingFileRef.current = true; gridRef.current.focus(); + clickingFileRef.current = false; } if (event.detail === 2) { @@ -900,7 +949,7 @@ export function FileManagerGrid({ onSelectionChange(rangeFiles); } } else if (multiSelect) { - const isSelected = selectedFiles.some((f) => f.path === file.path); + const isSelected = selectedPaths.has(file.path); if (isSelected) { onSelectionChange(selectedFiles.filter((f) => f.path !== file.path)); } else { @@ -1166,14 +1215,30 @@ export function FileManagerGrid({ onDrop={handleDrop} onWheel={handleWheel} onContextMenu={(e) => onContextMenu?.(e)} + onFocus={(e) => { + if ( + e.target === e.currentTarget && + !clickingFileRef.current && + selectedDirectory + ) + onFileIntent?.(selectedDirectory); + }} tabIndex={0} > {files.length === 0 && !createIntent ? (
{viewMode === "grid" ? parentGridStrip : parentListRow}
- - {t("fileManager.emptyFolder")} + {isLoading ? ( + + ) : ( + <> + + + {t("fileManager.emptyFolder")} + + + )}
) : viewMode === "grid" ? ( @@ -1218,9 +1283,7 @@ export function FileManagerGrid({ }} > {rowFiles.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); + const isSelected = selectedPaths.has(file.path); return (
f.path === file.path, - ) && "opacity-50", + dragPaths.has(file.path) && "opacity-50", deletingPaths?.has(file.path) && "opacity-40 pointer-events-none", )} title={file.name} onClick={(e) => handleFileClick(file, e)} + onMouseEnter={() => + file.type === "directory" && + onDirectoryHover?.(file) + } + onMouseLeave={() => + file.type === "directory" && + onDirectoryLeave?.(file) + } onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); @@ -1413,18 +1482,16 @@ export function FileManagerGrid({ {listVirtualizer.getVirtualItems().map((vItem) => { const file = files[vItem.index]; if (!file) return null; - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); + const isSelected = selectedPaths.has(file.path); const isEditing = editingFile?.path === file.path; return (
f.path === file.path) && - "opacity-50", + dragPaths.has(file.path) && "opacity-50", deletingPaths?.has(file.path) && "opacity-40 pointer-events-none", )} onClick={(e) => handleFileClick(file, e)} + onMouseEnter={() => + file.type === "directory" && onDirectoryHover?.(file) + } + onMouseLeave={() => + file.type === "directory" && onDirectoryLeave?.(file) + } onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); diff --git a/src/frontend/api/ssh-file-operations-api.ts b/src/frontend/api/ssh-file-operations-api.ts index 63035de..02b9a72 100644 --- a/src/frontend/api/ssh-file-operations-api.ts +++ b/src/frontend/api/ssh-file-operations-api.ts @@ -12,7 +12,10 @@ import { resolveConnectionOrigin, } from "@termix-ssh/plugin-sdk/ui"; import type { FileItem, SSHHost } from "../host-types"; -import { getCachedFileList } from "../lib/file-list-request-cache"; +import { + getCachedFileList, + getFileListEpoch, +} from "../lib/file-list-request-cache"; import { getCachedFileContent, invalidateCachedFileContent, @@ -234,16 +237,25 @@ export async function keepSSHAlive( export async function listSSHFiles( sessionId: string, path: string, - options: { force?: boolean } = {}, + options: { force?: boolean; prefetch?: boolean } = {}, ): Promise<{ files: FileItem[]; path: string }> { - return getCachedFileList( + const epoch = getFileListEpoch(sessionId); + const result = getCachedFileList( sessionId, path, async () => { try { const response = await getFileManagerApiForSession(sessionId).get( "/listFiles", - { params: { sessionId, path } }, + { + params: { + sessionId, + path, + epoch: String(epoch), + ...(options.force ? { force: true } : {}), + ...(options.prefetch ? { prefetch: "true" } : {}), + }, + }, ); return response.data || { files: [], path }; } catch (error) { @@ -252,6 +264,15 @@ export async function listSSHFiles( }, options.force, ); + if (options.prefetch || options.force) return result; + // A caller may have joined a speculative read that deliberately skipped sudo. + return result.catch((error: unknown) => { + const data = ( + error as { response?: { data?: { prefetchSkippedSudo?: boolean } } } + ).response?.data; + if (!data?.prefetchSkippedSudo) throw error; + return listSSHFiles(sessionId, path, { force: true }); + }); } export async function identifySSHSymlink( diff --git a/src/frontend/file-list.ts b/src/frontend/file-list.ts new file mode 100644 index 0000000..14c2edc --- /dev/null +++ b/src/frontend/file-list.ts @@ -0,0 +1,36 @@ +import type { FileItem } from "./host-types"; + +export type SortBy = "name" | "modified" | "size"; + +/** Filters by name substring, directories first, then sorts. Returns a new array. */ +export function filterAndSortFiles( + files: FileItem[], + searchQuery: string, + sortBy: SortBy, + sortOrder: "asc" | "desc", +): FileItem[] { + const q = searchQuery.toLowerCase(); + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + return files + .filter((file) => !q || file.name.toLowerCase().includes(q)) + .sort((a, b) => { + if (a.type === "directory" && b.type !== "directory") return -1; + if (a.type !== "directory" && b.type === "directory") return 1; + let result = 0; + switch (sortBy) { + case "name": + result = collator.compare(a.name, b.name); + break; + case "modified": + result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0); + break; + case "size": + result = (a.size || 0) - (b.size || 0); + break; + } + return sortOrder === "desc" ? -result : result; + }); +} diff --git a/src/frontend/lib/directory-prefetch.ts b/src/frontend/lib/directory-prefetch.ts new file mode 100644 index 0000000..17c721f --- /dev/null +++ b/src/frontend/lib/directory-prefetch.ts @@ -0,0 +1,125 @@ +import type { FileItem } from "../host-types"; + +export function createDirectoryPrefetch({ + read, + isCached, + run, +}: { + read: (path: string) => Promise; + isCached: (path: string) => boolean; + run: (task: () => Promise) => boolean; +}) { + let disposed = false; + let foreground = false; + let active: string | undefined; + let queue: string[] = []; + let hoverPath: string | undefined; + let hoverTimer: ReturnType | undefined; + let predictionTimer: ReturnType | undefined; + let drainTimer: ReturnType | undefined; + + function drain() { + if (disposed || foreground || active) return; + const path = queue.shift(); + if (!path) return; + if (isCached(path)) { + drain(); + return; + } + active = path; + // Admission can defer the callback; navigation must still be able to veto it. + const admitted = run(async () => { + try { + if (!disposed && !foreground && active === path && !isCached(path)) { + await read(path); + } + } finally { + active = undefined; + if (!disposed && !foreground) drainTimer = setTimeout(drain, 0); + } + }); + if (!admitted) { + active = undefined; + queue = []; + } + } + + function enqueue(path: string, priority: boolean) { + if (disposed || foreground || active === path || isCached(path)) return; + queue = queue.filter((entry) => entry !== path); + if (priority) queue.unshift(path); + else queue.push(path); + queue = queue.slice(0, 2); + drain(); + } + + function cancelIntent(path: string) { + if (hoverPath === path) { + clearTimeout(hoverTimer); + hoverTimer = undefined; + hoverPath = undefined; + } + queue = queue.filter((entry) => entry !== path); + } + + function clear() { + clearTimeout(hoverTimer); + clearTimeout(predictionTimer); + clearTimeout(drainTimer); + hoverTimer = predictionTimer = undefined; + drainTimer = undefined; + hoverPath = undefined; + queue = []; + } + + return { + intent(path: string) { + if (hoverPath) cancelIntent(hoverPath); + enqueue(path, true); + }, + hover(path: string) { + if (hoverPath) cancelIntent(hoverPath); + if (disposed || foreground) return; + hoverPath = path; + hoverTimer = setTimeout(() => { + hoverTimer = undefined; + enqueue(path, true); + }, 100); + }, + cancelIntent, + predict(parent: string, entries: readonly FileItem[]) { + clearTimeout(predictionTimer); + if (disposed || foreground) return; + const prefix = parent === "/" ? "/" : `${parent.replace(/\/$/, "")}/`; + const paths: string[] = []; + for (const entry of entries) { + const name = entry.path.slice(prefix.length); + if ( + entry.type === "directory" && + entry.path.startsWith(prefix) && + name && + name !== "." && + name !== ".." && + !name.includes("/") && + !paths.includes(entry.path) && + !isCached(entry.path) + ) { + paths.push(entry.path); + if (paths.length === 2) break; + } + } + predictionTimer = setTimeout(() => { + predictionTimer = undefined; + for (const path of paths) enqueue(path, false); + }, 200); + }, + setForeground(value: boolean) { + foreground = value; + if (value) clear(); + }, + dispose() { + disposed = true; + clear(); + }, + }; +} diff --git a/src/frontend/lib/file-list-request-cache.ts b/src/frontend/lib/file-list-request-cache.ts index 711b832..6bee675 100644 --- a/src/frontend/lib/file-list-request-cache.ts +++ b/src/frontend/lib/file-list-request-cache.ts @@ -7,6 +7,13 @@ export interface FileListResult { } const cache = createKeyedRequestCache(5_000, 150); +const epochs = new Map(); +let epochCounter = 0; +const MAX_EPOCH_SESSIONS = 200; + +export function getFileListEpoch(sessionId: string): number { + return epochs.get(sessionId) ?? 0; +} const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`; @@ -22,16 +29,24 @@ export function getCachedFileList( export function peekCachedFileList( sessionId: string, path: string, - maxAgeMs = 30_000, + // Stale entries are fine: callers repaint instantly, then force a refresh. + maxAgeMs = 300_000, ): FileListResult | null { return cache.peek(keyFor(sessionId, path), maxAgeMs); } export function invalidateCachedFileList( sessionId: string, - path: string, + _path: string, ): void { - cache.invalidate(keyFor(sessionId, path)); + // Globally monotonic, so evicting an old session's entry can never reuse a value. + epochs.delete(sessionId); + epochs.set(sessionId, ++epochCounter); + if (epochs.size > MAX_EPOCH_SESSIONS) { + epochs.delete(epochs.keys().next().value as string); + } + // A move or directory deletion can invalidate prefetched descendants too. + cache.invalidateMatching((key) => key.startsWith(`${sessionId}\0`)); } export function updateCachedFileList( diff --git a/src/frontend/lib/keyed-request-cache.ts b/src/frontend/lib/keyed-request-cache.ts index 80dd2db..dc232b8 100644 --- a/src/frontend/lib/keyed-request-cache.ts +++ b/src/frontend/lib/keyed-request-cache.ts @@ -7,6 +7,7 @@ export interface KeyedRequestCache { peek(key: string, maxAgeMs?: number): T | null; set(key: string, value: T): void; invalidate(key?: string): void; + invalidateMatching(matches: (key: string) => boolean): void; } /** Bounded per-key cache with in-flight request coalescing. */ @@ -15,8 +16,7 @@ export function createKeyedRequestCache( maxEntries = 100, ): KeyedRequestCache { const values = new Map(); - const inflight = new Map>(); - const versions = new Map(); + const inflight = new Map; forced: boolean }>(); const store = (key: string, value: T) => { values.delete(key); @@ -36,18 +36,24 @@ export function createKeyedRequestCache( } const pending = inflight.get(key); - if (pending) return pending; + if (pending && (!options.force || pending.forced)) return pending.promise; - const version = versions.get(key) ?? 0; + // Only the request still registered for this key may write the cache, so + // invalidate/set/forced replacement make older reads stale without metadata. + const entry = { + promise: undefined as unknown as Promise, + forced: !!options.force, + }; const request = loader() .then((value) => { - if ((versions.get(key) ?? 0) === version) store(key, value); + if (inflight.get(key) === entry) store(key, value); return value; }) .finally(() => { - if (inflight.get(key) === request) inflight.delete(key); + if (inflight.get(key) === entry) inflight.delete(key); }); - inflight.set(key, request); + entry.promise = request; + inflight.set(key, entry); return request; }, @@ -60,20 +66,23 @@ export function createKeyedRequestCache( }, set(key, value) { - versions.set(key, (versions.get(key) ?? 0) + 1); inflight.delete(key); store(key, value); }, invalidate(key) { if (key === undefined) { - for (const activeKey of inflight.keys()) { - versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1); - } values.clear(); inflight.clear(); } else { - versions.set(key, (versions.get(key) ?? 0) + 1); + values.delete(key); + inflight.delete(key); + } + }, + + invalidateMatching(matches) { + for (const key of new Set([...values.keys(), ...inflight.keys()])) { + if (!matches(key)) continue; values.delete(key); inflight.delete(key); } diff --git a/tests/backend/home-directory.test.ts b/tests/backend/home-directory.test.ts index 5c013c2..da93c54 100644 --- a/tests/backend/home-directory.test.ts +++ b/tests/backend/home-directory.test.ts @@ -19,7 +19,11 @@ beforeEach(() => vi.resetAllMocks()); function setup(home: string, owned = true) { const sftp = { realpath: vi.fn((_path, done) => done(null, home)), - readdir: vi.fn((_path, done) => done(null, [])), + opendir: vi.fn((_path, done) => done(null, Buffer.from("directory"))), + readdir: vi.fn((_handle, _options, done) => + done(Object.assign(new Error("EOF"), { code: 1 })), + ), + close: vi.fn((_handle, done) => done(null)), }; vi.mocked(getSessionSftp).mockResolvedValue(sftp as never); let handler!: (req: Request, res: Response) => Promise; @@ -37,18 +41,19 @@ function setup(home: string, owned = true) { sshSessions: { s: { isConnected: true, activeOperations: 0 } as SSHSession, }, - activeListRequests: {}, verifySessionOwnership: () => owned, }, ); - const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn() }; + const json = vi.fn(); + const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json }; return { sftp, res, - list: (path?: string) => + json, + list: (path?: string, response = res) => handler( { query: { sessionId: "s", path } } as unknown as Request, - res as unknown as Response, + response as unknown as Response, ), }; } @@ -57,30 +62,58 @@ describe("remote login directory", () => { it.each(["/root", "/srv/users/alice", "/"])( "lists the actual SFTP directory %s", async (home) => { - const { sftp, res, list } = setup(home); + const { sftp, res, json, list } = setup(home); await list("."); await vi.waitFor(() => - expect(res.json).toHaveBeenCalledWith({ files: [], path: home }), + expect(json).toHaveBeenCalledWith({ files: [], path: home }), ); expect(sftp.realpath).toHaveBeenCalledWith(".", expect.any(Function)); - expect(sftp.readdir).toHaveBeenCalledWith(home, expect.any(Function)); + expect(sftp.opendir).toHaveBeenCalledWith(home, expect.any(Function)); expect(execChannel).not.toHaveBeenCalled(); }, ); it("preserves an explicit root instead of replacing it with home", async () => { - const { sftp, res, list } = setup("/home/alice"); + const { sftp, res, json, list } = setup("/home/alice"); await list("/"); await vi.waitFor(() => - expect(res.json).toHaveBeenCalledWith({ files: [], path: "/" }), + expect(json).toHaveBeenCalledWith({ files: [], path: "/" }), ); expect(sftp.realpath).not.toHaveBeenCalled(); }); + it("shares one listing between concurrent requests for the same path", async () => { + const { sftp, res, json, list } = setup("/home/alice"); + const second = { ...res, json: vi.fn() }; + await Promise.all([list("/data"), list("/data", second)]); + expect(sftp.opendir).toHaveBeenCalledTimes(1); + expect(json).toHaveBeenCalledExactlyOnceWith({ files: [], path: "/data" }); + expect(second.json).toHaveBeenCalledExactlyOnceWith({ + files: [], + path: "/data", + }); + }); it("checks ownership before discovering the home directory", async () => { + // placeholder-anchor const { res, list } = setup("/home/alice", false); await list("."); expect(res.status).toHaveBeenCalledWith(403); expect(getSessionSftp).not.toHaveBeenCalled(); }); + it("uses the existing shell fallback when a handle read fails", async () => { + const { sftp, json, list } = setup("/home/alice"); + sftp.readdir.mockImplementation((_handle, _options, done) => + done(new Error("read failed")), + ); + vi.mocked(execChannel).mockImplementation((_session, command, done) => { + expect(command).toBe("command ls -la --color=never '/data'"); + done(new Error("fallback reached"), undefined as never); + }); + await list("/data"); + await vi.waitFor(() => + expect(json).toHaveBeenCalledWith({ error: "fallback reached" }), + ); + expect(execChannel).toHaveBeenCalledTimes(1); + expect(sftp.close).toHaveBeenCalledTimes(1); + }); it("falls back to the shell login directory when SFTP is unavailable", async () => { vi.mocked(getSessionSftp).mockRejectedValue(new Error("SFTP unavailable")); vi.mocked(execChannel).mockImplementation((_session, command, done) => { diff --git a/tests/backend/list-request-sharing.test.ts b/tests/backend/list-request-sharing.test.ts new file mode 100644 index 0000000..a0e1fd9 --- /dev/null +++ b/tests/backend/list-request-sharing.test.ts @@ -0,0 +1,615 @@ +import { EventEmitter } from "node:events"; +import type { Express, Request, Response } from "express"; +import type { PluginContext } from "@termix-ssh/plugin-sdk/backend"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { registerFileListingRoutes } from "../../src/backend/list-routes.js"; +import { readDirectory } from "../../src/backend/read-directory.js"; +import { resolveHomeDirectory } from "../../src/backend/home-directory.js"; +import { + execChannel, + getSessionSftp, + type SSHSession, +} from "../../src/backend/session.js"; + +vi.mock("../../src/backend/read-directory.js", () => ({ + readDirectory: vi.fn(), +})); +vi.mock("../../src/backend/home-directory.js", () => ({ + resolveHomeDirectory: vi.fn(), +})); +vi.mock("../../src/backend/session.js", () => ({ + getSessionSftp: vi.fn(), + execChannel: vi.fn(), +})); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSessionSftp).mockResolvedValue({} as never); + vi.mocked(resolveHomeDirectory).mockResolvedValue("/home/user"); +}); + +const session = () => + ({ isConnected: true, activeOperations: 0 }) as SSHSession; + +function setup(sshSessions = { s: session() }) { + let actor = "user"; + let handler!: (req: Request, res: Response) => Promise; + const verifySessionOwnership = vi.fn(() => true); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + registerFileListingRoutes( + { + get: (_path, cb) => { + handler = cb; + }, + } as Express, + { + ctx: { + currentActor: () => actor, + log, + } as unknown as PluginContext, + sshSessions, + verifySessionOwnership, + }, + ); + return { + sshSessions, + log, + verifySessionOwnership, + setActor: (next: string) => { + actor = next; + }, + request( + path = "/data", + force?: unknown, + query: { epoch?: unknown; prefetch?: unknown } = {}, + ) { + const res = Object.assign(new EventEmitter(), { + statusCode: 200, + headersSent: false, + writableEnded: false, + destroyed: false, + status: vi.fn(function (code: number) { + this.statusCode = code; + return this; + }), + json: vi.fn(function () { + this.headersSent = true; + this.writableEnded = true; + return this; + }), + }); + const done = handler( + { + query: { sessionId: "s", path, force, ...query }, + } as unknown as Request, + res as unknown as Response, + ); + return { res, done }; + }, + }; +} + +const complete = (index: number, error: Error | null = null) => { + vi.mocked(readDirectory).mock.calls[index][2](error, []); +}; +const reads = async (count: number) => { + await vi.waitFor(() => expect(readDirectory).toHaveBeenCalledTimes(count)); +}; + +describe("list request sharing", () => { + it("isolates epochs while sharing requests in the same epoch", async () => { + const { request, sshSessions } = setup(); + const old = request("/data", undefined, { epoch: "1" }); + const fresh = request("/data", undefined, { epoch: "2" }); + const waiter = request("/data", undefined, { epoch: "2" }); + await reads(2); + complete(1); + await Promise.all([fresh.done, waiter.done]); + expect(old.res.json).not.toHaveBeenCalled(); + complete(0); + await old.done; + expect(sshSessions.s.activeOperations).toBe(0); + }); + + it("times out a hung read so joined requests are released", async () => { + vi.useFakeTimers(); + try { + const { request, sshSessions } = setup(); + const first = request("/data"); + const joined = request("/data"); + await reads(1); + await vi.advanceTimersByTimeAsync(30_000); + await Promise.all([first.done, joined.done]); + expect(first.res.status).toHaveBeenCalledWith(504); + expect(joined.res.status).toHaveBeenCalledWith(504); + expect(sshSessions.s.activeOperations).toBe(0); + complete(0); + expect(sshSessions.s.activeOperations).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it.each([undefined, "", "-1", "1.5", "1e2", " 1", "1\n", true, 1, ["1"], {}])( + "treats invalid or missing epoch %j as zero", + async (epoch) => { + const { request } = setup(); + const first = request("/data", undefined, { epoch: "0" }); + const second = request("/data", undefined, { epoch }); + await reads(1); + complete(0); + await Promise.all([first.done, second.done]); + expect(second.res.json).toHaveBeenCalledTimes(1); + }, + ); + + it.each([true, false])( + "isolates speculative and navigation reads (prefetch first: %s)", + async (prefetchFirst) => { + const { request, sshSessions } = setup(); + sshSessions.s.sudoPassword = "stored-password"; + const first = request("/data", undefined, { + prefetch: prefetchFirst ? "true" : undefined, + }); + const second = request("/data", undefined, { + prefetch: prefetchFirst ? undefined : "true", + }); + const speculative = prefetchFirst ? first : second; + const navigation = prefetchFirst ? second : first; + await reads(2); + complete( + prefetchFirst ? 0 : 1, + Object.assign(new Error("Permission denied"), { code: 3 }), + ); + await speculative.done; + expect(speculative.res.statusCode).toBe(403); + expect(speculative.res.json).toHaveBeenCalledExactlyOnceWith({ + needsSudo: true, + prefetchSkippedSudo: true, + path: "/data", + }); + expect(execChannel).not.toHaveBeenCalled(); + expect(navigation.res.json).not.toHaveBeenCalled(); + complete(prefetchFirst ? 1 : 0); + await navigation.done; + expect(navigation.res.statusCode).toBe(200); + expect(sshSessions.s.activeOperations).toBe(0); + expect(sshSessions.s.sudoPassword).toBe("stored-password"); + }, + ); + + it.each(["false", "TRUE", true, ["true"]])( + "only the exact string true marks prefetch (%j is navigation)", + async (prefetch) => { + const { request } = setup(); + const first = request(); + const second = request("/data", undefined, { prefetch }); + await reads(1); + complete(0); + await Promise.all([first.done, second.done]); + expect(second.res.json).toHaveBeenCalledTimes(1); + }, + ); + + it("shares prefetch reads and skips sudo after shell permission denial too", async () => { + const { request, sshSessions } = setup(); + sshSessions.s.sudoPassword = "stored-password"; + const first = request("/data", undefined, { prefetch: "true" }); + const waiter = request("/data", undefined, { prefetch: "true" }); + const stream = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + write: vi.fn(), + }); + vi.mocked(execChannel).mockImplementation((_session, _command, done) => { + done(null, stream as never); + }); + await reads(1); + complete(0, new Error("SFTP unavailable")); + stream.stderr.emit("data", Buffer.from("Permission denied")); + stream.emit("close", 1); + await Promise.all([first.done, waiter.done]); + expect(execChannel).toHaveBeenCalledExactlyOnceWith( + sshSessions.s, + "command ls -la --color=never '/data'", + expect.any(Function), + ); + expect(stream.write).not.toHaveBeenCalled(); + for (const { res } of [first, waiter]) { + expect(res.statusCode).toBe(403); + expect(res.json).toHaveBeenCalledExactlyOnceWith({ + needsSudo: true, + prefetchSkippedSudo: true, + path: "/data", + }); + } + expect(sshSessions.s.activeOperations).toBe(0); + }); + + it("retains original info events without paths or per-listing timing", async () => { + const { request, log } = setup(); + const listing = request("/private/path"); + await reads(1); + complete(0); + await listing.done; + expect(log.info).toHaveBeenCalledExactlyOnceWith( + "Opening SFTP channel for listFiles: s", + ); + }); + + it.each(["timeout", "resolved"])( + "waits up to 5000ms for symlink targets (%s)", + async (kind) => { + vi.useFakeTimers(); + try { + const { request, sshSessions } = setup(); + const readlink = vi.fn(); + vi.mocked(getSessionSftp).mockResolvedValue({ readlink } as never); + const listing = request(); + await Promise.resolve(); + vi.mocked(readDirectory).mock.calls[0][2](null, [ + { + filename: "link", + attrs: { + mode: 0o120777, + size: 1, + mtime: 1, + uid: 1, + gid: 1, + isDirectory: () => false, + isSymbolicLink: () => true, + }, + } as never, + ]); + await vi.advanceTimersByTimeAsync(kind === "timeout" ? 4999 : 1000); + expect(listing.res.json).not.toHaveBeenCalled(); + if (kind === "timeout") await vi.advanceTimersByTimeAsync(1); + else readlink.mock.calls[0][1](null, "/target"); + await listing.done; + expect(listing.res.json).toHaveBeenCalledExactlyOnceWith({ + files: [ + expect.objectContaining({ + linkTarget: kind === "timeout" ? undefined : "/target", + }), + ], + path: "/data", + }); + readlink.mock.calls[0][1](null, "/late-target"); + await vi.advanceTimersByTimeAsync(5000); + expect(listing.res.json).toHaveBeenCalledTimes(1); + expect(sshSessions.s.activeOperations).toBe(0); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("shares concurrent reads, but never stores a settled result", async () => { + const { request, sshSessions } = setup(); + const first = request(); + const second = request(); + await reads(1); + expect(sshSessions.s.activeOperations).toBe(1); + complete(0); + await Promise.all([first.done, second.done]); + for (const { res } of [first, second]) { + expect(res.json).toHaveBeenCalledExactlyOnceWith({ + files: [], + path: "/data", + }); + expect(res.statusCode).toBe(200); + } + expect(sshSessions.s.activeOperations).toBe(0); + const next = request(); + await reads(2); + complete(1); + await next.done; + }); + + it("keeps the real result for waiters after the first client disconnects", async () => { + const { request, sshSessions } = setup(); + const first = request(); + const waiter = request(); + await reads(1); + first.res.destroyed = true; + first.res.emit("close"); + await Promise.resolve(); + expect(waiter.res.json).not.toHaveBeenCalled(); + const laterWaiter = request(); + vi.mocked(readDirectory).mock.calls[0][2](null, [ + { + filename: "visible.txt", + attrs: { + mode: 0o100644, + size: 7, + mtime: 1, + uid: 1, + gid: 1, + isDirectory: () => false, + isSymbolicLink: () => false, + }, + } as never, + ]); + await Promise.all([first.done, waiter.done, laterWaiter.done]); + expect(first.res.json).not.toHaveBeenCalled(); + for (const { res } of [waiter, laterWaiter]) { + expect(res.statusCode).toBe(200); + expect(res.json).toHaveBeenCalledExactlyOnceWith({ + files: [ + expect.objectContaining({ + name: "visible.txt", + path: "/data/visible.txt", + }), + ], + path: "/data", + }); + } + expect(readDirectory).toHaveBeenCalledTimes(1); + expect(sshSessions.s.activeOperations).toBe(0); + }); + + it("does not write to a closed waiter or an already ended response", async () => { + const { request } = setup(); + const first = request(); + const closed = request(); + const ended = request(); + closed.res.emit("close"); + ended.res.writableEnded = true; + await reads(1); + complete(0); + await Promise.all([first.done, closed.done, ended.done]); + expect(first.res.json).toHaveBeenCalledTimes(1); + expect(closed.res.json).not.toHaveBeenCalled(); + expect(ended.res.json).not.toHaveBeenCalled(); + }); + + it("isolates a replacement session under the same session id", async () => { + const { request, sshSessions } = setup(); + const oldSession = sshSessions.s; + const first = request(); + await reads(1); + sshSessions.s = session(); + const second = request(); + await reads(2); + complete(1); + await second.done; + expect(first.res.json).not.toHaveBeenCalled(); + complete(0); + await first.done; + expect(getSessionSftp).toHaveBeenNthCalledWith(1, oldSession); + expect(getSessionSftp).toHaveBeenNthCalledWith(2, sshSessions.s); + }); + + it("isolates route registrations even with the same actual session", async () => { + const sharedSessions = { s: session() }; + const first = setup(sharedSessions).request(); + const second = setup(sharedSessions).request(); + await reads(2); + complete(0); + complete(1); + await Promise.all([first.done, second.done]); + }); + + it("isolates actors and exact paths without delimiter collisions", async () => { + const { request, setActor, verifySessionOwnership } = setup(); + setActor("user:/a"); + const first = request("/data"); + setActor("user"); + const second = request("/a:/data"); + const third = request("/data"); + const fourth = request("/data/"); + await reads(4); + expect(verifySessionOwnership).toHaveBeenCalledTimes(4); + for (let index = 0; index < 4; index++) complete(index); + await Promise.all([first.done, second.done, third.done, fourth.done]); + }); + + it.each(["ownership", "connection"])( + "validates each waiter's %s before joining", + async (kind) => { + const { request, verifySessionOwnership, sshSessions } = setup(); + const first = request(); + await reads(1); + if (kind === "ownership") verifySessionOwnership.mockReturnValue(false); + else sshSessions.s.isConnected = false; + const denied = request(); + await denied.done; + expect(denied.res.statusCode).toBe(kind === "ownership" ? 403 : 400); + expect(readDirectory).toHaveBeenCalledTimes(1); + complete(0); + await first.done; + }, + ); + + it.each(["ownership", "connection", "session"])( + "rechecks %s after home resolution before joining", + async (kind) => { + const { request, verifySessionOwnership, sshSessions } = setup(); + const first = request("/home/user"); + await reads(1); + let resolve!: (path: string) => void; + vi.mocked(resolveHomeDirectory).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const waiter = request("."); + if (kind === "ownership") verifySessionOwnership.mockReturnValue(false); + else if (kind === "connection") sshSessions.s.isConnected = false; + else sshSessions.s = session(); + resolve("/home/user"); + await waiter.done; + expect(waiter.res.statusCode).toBe(kind === "ownership" ? 403 : 400); + expect(readDirectory).toHaveBeenCalledTimes(1); + complete(0); + await first.done; + }, + ); + + it("shares home and explicit paths after resolution", async () => { + const { request } = setup(); + const first = request("/home/user"); + const second = request("."); + await reads(1); + // Let home resolution finish before completing the shared listing. + await Promise.resolve(); + complete(0); + await Promise.all([first.done, second.done]); + expect(readDirectory).toHaveBeenCalledTimes(1); + expect(second.res.json).toHaveBeenCalledWith({ + files: [], + path: "/home/user", + }); + }); + + it.each([true, false])( + "force bypass never replaces or deletes normal pending work (force finishes first: %s)", + async (forceFirst) => { + const { request } = setup(); + const normal = request(); + const forced = request("/data", "true"); + const normalWaiter = request(); + await reads(2); + if (forceFirst) { + complete(1); + await forced.done; + expect(normal.res.json).not.toHaveBeenCalled(); + const laterWaiter = request(); + expect(getSessionSftp).toHaveBeenCalledTimes(2); + complete(0); + await Promise.all([normal.done, normalWaiter.done, laterWaiter.done]); + } else { + complete(0); + await Promise.all([normal.done, normalWaiter.done]); + const newer = request(); + await reads(3); + complete(1); + await forced.done; + const newerWaiter = request(); + expect(getSessionSftp).toHaveBeenCalledTimes(3); + complete(2); + await Promise.all([newer.done, newerWaiter.done]); + } + expect(forced.res.json).toHaveBeenCalledTimes(1); + }, + ); + + it("only the exact string true forces a separate read", async () => { + const { request } = setup(); + const requests = [ + request(), + request("/data", "false"), + request("/data", true), + request("/data", ["true"]), + ]; + await reads(1); + complete(0); + await Promise.all(requests.map(({ done }) => done)); + }); + + it("isolates privilege changes and old settlement/close cannot delete newer work", async () => { + const { request, sshSessions } = setup(); + const first = request(); + await reads(1); + sshSessions.s.sudoPassword = "first-password"; + const second = request(); + await reads(2); + sshSessions.s.sudoPassword = "replacement-password"; + const third = request(); + await reads(3); + complete(0); + complete(1); + await Promise.all([first.done, second.done]); + first.res.emit("close"); + second.res.emit("close"); + const waiter = request(); + expect(getSessionSftp).toHaveBeenCalledTimes(3); + complete(2); + await Promise.all([third.done, waiter.done]); + expect(waiter.res.json).toHaveBeenCalledExactlyOnceWith({ + files: [], + path: "/data", + }); + }); + + it("uses captured sudo credentials and does not clear newer credentials on old auth failure", async () => { + const { request, sshSessions } = setup(); + sshSessions.s.sudoPassword = "captured-password"; + const first = request(); + const waiter = request(); + await reads(1); + sshSessions.s.sudoPassword = "replacement-password"; + const streams: Array< + EventEmitter & { stderr: EventEmitter; write: ReturnType } + > = []; + vi.mocked(execChannel).mockImplementation((_session, _command, done) => { + const stream = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + write: vi.fn(), + }); + streams.push(stream); + done(null, stream as never); + }); + complete(0, new Error("Permission denied")); + streams[0].stderr.emit("data", Buffer.from("Permission denied")); + streams[0].emit("close", 1); + expect(streams[1].write).toHaveBeenCalledExactlyOnceWith( + "captured-password\n", + ); + streams[1].emit("data", Buffer.from("Sorry, try again")); + streams[1].emit("close", 1); + await Promise.all([first.done, waiter.done]); + expect(sshSessions.s.sudoPassword).toBe("replacement-password"); + expect(first.res.statusCode).toBe(403); + expect(waiter.res.statusCode).toBe(403); + expect(waiter.res.json).toHaveBeenCalledWith( + expect.objectContaining({ sudoFailed: true }), + ); + }); + + it("does not adopt sudo credentials enabled while an unprivileged listing is pending", async () => { + const { request, sshSessions } = setup(); + const first = request(); + await reads(1); + sshSessions.s.sudoPassword = "new-password"; + vi.mocked(execChannel).mockImplementation((_session, _command, done) => { + const stream = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + }); + done(null, stream as never); + queueMicrotask(() => { + stream.stderr.emit("data", Buffer.from("Permission denied")); + stream.emit("close", 1); + }); + }); + complete(0, new Error("Permission denied")); + await first.done; + expect(execChannel).toHaveBeenCalledTimes(1); + expect(first.res.statusCode).toBe(403); + expect(first.res.json).toHaveBeenCalledWith( + expect.objectContaining({ needsSudo: true }), + ); + }); + + it("settles a shared fallback error once and releases its pending entry", async () => { + const { request, sshSessions } = setup(); + const first = request(); + const waiter = request(); + await reads(1); + vi.mocked(execChannel).mockImplementation((_session, _command, done) => { + done(new Error("fallback failed"), undefined as never); + }); + complete(0, new Error("SFTP failed")); + await Promise.all([first.done, waiter.done]); + for (const { res } of [first, waiter]) { + expect(res.statusCode).toBe(500); + expect(res.json).toHaveBeenCalledExactlyOnceWith({ + error: "fallback failed", + }); + } + expect(sshSessions.s.activeOperations).toBe(0); + const next = request(); + await reads(2); + complete(1); + await next.done; + }); +}); diff --git a/tests/backend/read-directory.test.ts b/tests/backend/read-directory.test.ts new file mode 100644 index 0000000..ebf59d2 --- /dev/null +++ b/tests/backend/read-directory.test.ts @@ -0,0 +1,307 @@ +import type { FileEntry, SFTPWrapper } from "ssh2"; +import { describe, expect, it, vi } from "vitest"; +import { readDirectory } from "../../src/backend/read-directory.js"; + +type ReadCallback = ( + error: Error | null, + entries?: FileEntry[] | false, +) => void; +const eof = () => Object.assign(new Error("EOF"), { code: 1 }); +const entry = (index: number): FileEntry => ({ + filename: `file-${index}`, + longname: "", + attrs: {} as FileEntry["attrs"], +}); +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function setup() { + const handle = Buffer.from("directory"); + const replies: ReadCallback[] = []; + let active = 0; + let maximum = 0; + const sftp = { + opendir: vi.fn( + (_path: string, done: (error: Error | null, handle?: Buffer) => void) => + done(null, handle), + ), + readdir: vi.fn( + (_handle: Buffer, _options: { full: boolean }, done: ReadCallback) => { + active++; + maximum = Math.max(maximum, active); + replies.push((error, entries) => { + active--; + done(error, entries); + }); + }, + ), + // Deliberately leave CLOSE unanswered: successful data must not wait for it. + close: vi.fn((_handle: Buffer, _done: (error?: Error) => void) => { + expect(active).toBe(0); + }), + }; + const done = vi.fn(); + const onCloseError = vi.fn(); + return { + sftp, + handle, + replies, + done, + onCloseError, + maximum: () => maximum, + start: async () => { + readDirectory( + sftp as unknown as SFTPWrapper, + "/data", + done, + onCloseError, + ); + await flush(); + }, + }; +} + +describe("pipelined SFTP directory reads", () => { + it("deduplicates filenames across concurrent replies, keeping the first issued entry", async () => { + const f = setup(); + const first = entry(0); + const duplicate = { ...first, longname: "later version" }; + await f.start(); + f.replies[1](null, [duplicate, entry(1), duplicate]); + await flush(); + f.replies[0](null, [first]); + await flush(); + f.replies[2](eof()); + f.replies[3](eof()); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [first, entry(1)]); + expect(f.done.mock.calls[0][1][0]).toBe(first); + expect(f.maximum()).toBe(2); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }); + + it("collects all 484 entries in issued batch order with at most two pending reads", async () => { + const f = setup(); + const entries = Array.from({ length: 484 }, (_, index) => entry(index)); + await f.start(); + expect(f.replies).toHaveLength(2); + f.replies[1](null, entries.slice(120, 240)); + await flush(); + f.replies[0](null, entries.slice(0, 120)); + await flush(); + f.replies[3](null, entries.slice(360)); + await flush(); + f.replies[2](null, entries.slice(240, 360)); + await flush(); + f.replies[5](eof()); + await flush(); + expect(f.done).not.toHaveBeenCalled(); + expect(f.sftp.close).not.toHaveBeenCalled(); + f.replies[4](eof()); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(null, entries); + expect(f.maximum()).toBe(2); + expect(f.sftp.readdir).toHaveBeenCalledTimes(6); + expect(f.sftp.readdir).toHaveBeenCalledWith( + f.handle, + { full: true }, + expect.any(Function), + ); + expect(f.sftp.close).toHaveBeenCalledExactlyOnceWith( + f.handle, + expect.any(Function), + ); + }); + + it.each(["status", "false"])( + "drains late data after %s EOF without issuing more reads", + async (kind) => { + const f = setup(); + await f.start(); + if (kind === "status") f.replies[1](eof()); + else f.replies[1](null, false); + await flush(); + expect(f.done).not.toHaveBeenCalled(); + expect(f.sftp.close).not.toHaveBeenCalled(); + expect(f.replies).toHaveLength(2); + f.replies[0](null, [entry(0)]); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [entry(0)]); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + expect(f.replies).toHaveLength(2); + }, + ); + + it.each(["status", "false"])( + "lists an empty directory terminated by %s EOF", + async (kind) => { + const f = setup(); + await f.start(); + for (const reply of f.replies) { + if (kind === "status") reply(eof()); + else reply(null, false); + } + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(null, []); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["status", "false"])( + "does not swallow an error arriving after %s EOF", + async (kind) => { + const f = setup(); + const error = new Error("late read failure"); + await f.start(); + if (kind === "status") f.replies[1](eof()); + else f.replies[1](null, false); + await flush(); + f.replies[0](error); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }, + ); + + it("discards partial data on error and drains the outstanding read before closing", async () => { + const f = setup(); + const error = new Error("read failure"); + await f.start(); + f.replies[0](null, [entry(0)]); + await flush(); + f.replies[2](error); + await flush(); + expect(f.done).not.toHaveBeenCalled(); + expect(f.sftp.close).not.toHaveBeenCalled(); + f.replies[1](null, [entry(1)]); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []); + expect(f.replies).toHaveLength(3); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }); + + it.each(["callback", "throw"])( + "reports opendir %s failure without reading or closing", + async (kind) => { + const f = setup(); + const error = new Error("open failure"); + f.sftp.opendir.mockImplementation((_path, done) => { + if (kind === "throw") throw error; + done(error); + }); + await f.start(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []); + expect(f.sftp.readdir).not.toHaveBeenCalled(); + expect(f.sftp.close).not.toHaveBeenCalled(); + }, + ); + + it.each([0, 1])( + "drains the other read when read %s throws synchronously", + async (throwIndex) => { + const f = setup(); + const error = new Error("synchronous read failure"); + const queueRead = f.sftp.readdir.getMockImplementation()!; + if (throwIndex === 1) f.sftp.readdir.mockImplementationOnce(queueRead); + f.sftp.readdir.mockImplementationOnce(() => { + throw error; + }); + await f.start(); + expect(f.replies).toHaveLength(1); + expect(f.sftp.close).not.toHaveBeenCalled(); + f.replies[0](eof()); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(error, []); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["success", "read failure"])( + "logs an asynchronous close failure after %s without delaying the result", + async (kind) => { + const f = setup(); + const readError = new Error("read failure"); + const closeError = new Error("close failure"); + await f.start(); + f.replies[0](kind === "success" ? eof() : readError); + f.replies[1](eof()); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith( + kind === "success" ? null : readError, + [], + ); + f.sftp.close.mock.calls[0][1](closeError); + expect(f.onCloseError).toHaveBeenCalledExactlyOnceWith(closeError); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + expect(f.done).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["success", "read failure"])( + "logs a synchronous close throw after %s without replacing the result", + async (kind) => { + const f = setup(); + const readError = new Error("read failure"); + const closeError = new Error("close throw"); + f.sftp.close.mockImplementation(() => { + throw closeError; + }); + await f.start(); + f.replies[0](kind === "success" ? eof() : readError); + f.replies[1](eof()); + await flush(); + expect(f.onCloseError).toHaveBeenCalledExactlyOnceWith(closeError); + expect(f.done).toHaveBeenCalledExactlyOnceWith( + kind === "success" ? null : readError, + [], + ); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }, + ); + + it("fails an empty successful batch instead of spinning or treating it as EOF", async () => { + const f = setup(); + await f.start(); + f.replies[0](null, []); + await flush(); + expect(f.replies).toHaveLength(2); + expect(f.sftp.close).not.toHaveBeenCalled(); + f.replies[1](null, [entry(0)]); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith( + new Error("SFTP readdir returned an empty batch without EOF"), + [], + ); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }); + + it("continues through dot-only batches and filters them from the result", async () => { + const f = setup(); + await f.start(); + f.replies[0](null, [ + { ...entry(0), filename: "." }, + { ...entry(0), filename: ".." }, + ]); + await flush(); + f.replies[2](eof()); + await flush(); + f.replies[1](null, [entry(0)]); + await flush(); + expect(f.done).toHaveBeenCalledExactlyOnceWith(null, [entry(0)]); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }); + + it("handles synchronously delivered batches without recursive callbacks", async () => { + const f = setup(); + let batch = 0; + f.sftp.readdir.mockImplementation((_handle, _options, done) => { + if (batch < 484) done(null, [entry(batch++)]); + else done(eof()); + }); + await f.start(); + expect(f.done).toHaveBeenCalledExactlyOnceWith( + null, + Array.from({ length: 484 }, (_, index) => entry(index)), + ); + expect(f.sftp.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/frontend/FileManager-prefetch-auth.test.tsx b/tests/frontend/FileManager-prefetch-auth.test.tsx new file mode 100644 index 0000000..f77047b --- /dev/null +++ b/tests/frontend/FileManager-prefetch-auth.test.tsx @@ -0,0 +1,383 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import type { FileItem, SSHHost } from "../../src/frontend/host-types"; + +const mocks = vi.hoisted(() => ({ + t: (key: string) => key, + get: vi.fn(), + connect: vi.fn(), + verifyTotp: vi.fn(), + verifyBrowser: vi.fn(), + grid: vi.fn( + ( + _props: React.ComponentProps< + typeof import("../../src/frontend/FileManagerGrid").FileManagerGrid + >, + ) => null, + ), + toolbar: vi.fn( + ( + _props: React.ComponentProps< + typeof import("../../src/frontend/FileManagerToolbar").FileManagerToolbar + >, + ) => null, + ), + dialogs: vi.fn( + ( + _props: React.ComponentProps< + typeof import("../../src/frontend/FileManagerDialogs").FileManagerDialogs + >, + ) => null, + ), + passphrase: vi.fn( + (_props: { onSubmit: (value: string) => Promise }) => null, + ), +})); + +vi.mock("../../src/frontend/api/client", () => ({ + fileManagerApi: () => ({ get: mocks.get }), + getFileManagerApiForSession: () => ({ get: mocks.get }), + handleApiError: (error: unknown) => { + throw error; + }, +})); +vi.mock( + "../../src/frontend/api/ssh-file-operations-api", + async (importOriginal) => ({ + ...(await importOriginal()), + connectSSH: mocks.connect, + verifySSHTOTP: mocks.verifyTotp, + verifySSHBrowserSignIn: mocks.verifyBrowser, + }), +); +vi.mock("../../src/frontend/api/file-manager-data-api", () => ({ + getPinnedFiles: async () => [], +})); +vi.mock("@termix-ssh/plugin-sdk/frontend", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: mocks.t }), + usePluginUiPreferences: () => ({ values: {}, set: vi.fn() }), + useConnectionRetry: ({ connect }: { connect: () => void }) => ({ + retryNow: connect, + reset: vi.fn(), + markConnected: vi.fn(), + }), + invokeAction: async () => undefined, + logActivity: vi.fn(), +})); +vi.mock("@termix-ssh/plugin-sdk/ui", async (importOriginal) => ({ + ...(await importOriginal()), + PanelShell: ({ children }: React.PropsWithChildren) => children, + ConnectionLogProvider: ({ children }: React.PropsWithChildren) => children, + ConnectionLogPanel: () => null, + ConnectionScreen: () => null, + useConfirm: () => vi.fn(), + useConnectionLog: () => ({ addLog: vi.fn(), clearLogs: vi.fn() }), + PassphraseDialog: mocks.passphrase, + runAdaptiveBackgroundTask: ( + _kind: string, + _key: string, + task: () => Promise, + ) => { + void Promise.resolve() + .then(task) + .catch(() => {}); + return true; + }, +})); +vi.mock("../../src/frontend/components/WindowManager", () => ({ + WindowManager: ({ children }: React.PropsWithChildren) => children, + useWindowManager: () => ({ openWindow: vi.fn() }), +})); +vi.mock("../../src/frontend/FileManagerGrid", () => ({ + FileManagerGrid: mocks.grid, +})); +vi.mock("../../src/frontend/FileManagerToolbar", () => ({ + FileManagerToolbar: mocks.toolbar, +})); +vi.mock("../../src/frontend/FileManagerDialogs", () => ({ + FileManagerDialogs: mocks.dialogs, +})); +vi.mock("../../src/frontend/FileManagerSidebar", () => ({ + FileManagerSidebar: () => null, +})); +vi.mock("../../src/frontend/FileManagerContextMenu", () => ({ + FileManagerContextMenu: () => null, +})); +vi.mock("../../src/frontend/LocalFilePane", () => ({ + LocalFilePane: () => null, +})); +vi.mock("../../src/frontend/FileManagerTrashDialog", () => ({ + FileManagerTrashDialog: () => null, +})); +vi.mock("../../src/frontend/components/TransferToHostDialog", () => ({ + TransferToHostDialog: () => null, +})); +vi.mock("../../src/frontend/components/FileWindow", () => ({ + FileWindow: () => null, +})); +vi.mock("../../src/frontend/components/DiffWindow", () => ({ + DiffWindow: () => null, +})); +vi.mock("../../src/frontend/components/TerminalWindow", () => ({ + TerminalWindow: () => null, +})); +vi.mock("../../src/frontend/hooks/useLocalTransfers", () => ({ + useLocalTransfers: () => ({}), +})); + +import { FileManager } from "../../src/frontend/FileManager"; +import { invalidateCachedFileList } from "../../src/frontend/lib/file-list-request-cache"; + +const host = { + id: 901, + ip: "test-host", + port: 22, + username: "alice", +} as SSHHost; +const child: FileItem = { + name: "child", + path: "/home/alice/child", + type: "directory", +}; +const grid = () => mocks.grid.mock.calls.at(-1)![0]; +const dialogs = () => mocks.dialogs.mock.calls.at(-1)![0]; +const toolbar = () => mocks.toolbar.mock.calls.at(-1)![0]; +const listCalls = (path: string) => + mocks.get.mock.calls.filter( + ([url, config]) => url === "/listFiles" && config.params.path === path, + ); +const tick = (ms = 0) => + act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + localStorage.clear(); + invalidateCachedFileList(String(host.id), "/"); + mocks.connect.mockResolvedValue({ status: "success" }); + mocks.verifyTotp.mockResolvedValue({ status: "success" }); + mocks.verifyBrowser.mockResolvedValue({ status: "success" }); + mocks.get.mockImplementation(async (_url, { params }) => ({ + data: + params.path === "." || params.path === "/home/alice" + ? { path: "/home/alice", files: [child] } + : { path: params.path, files: [] }, + })); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("FileManager post-auth directory prefetch", () => { + it.each(["initial", "totp", "browser", "password", "key", "passphrase"])( + "resumes prefetch with the resolved listing after %s authentication", + async (mode) => { + if (mode === "totp") + mocks.connect.mockResolvedValueOnce({ requires_totp: true }); + if (mode === "browser") + mocks.connect.mockResolvedValueOnce({ requires_browser_sign_in: true }); + if (mode === "password" || mode === "key") + mocks.connect.mockResolvedValueOnce({ status: "auth_required" }); + if (mode === "passphrase") + mocks.connect.mockResolvedValueOnce({ status: "passphrase_required" }); + render(); + await tick(); + if (mode !== "initial") { + expect(listCalls(".")).toHaveLength(0); + await act(async () => { + if (mode === "totp") await dialogs().handleTotpSubmit("123456"); + if (mode === "browser") await dialogs().handleBrowserSignInContinue(); + if (mode === "password") + await dialogs().handleAuthDialogSubmit({ + password: "test-password", + }); + if (mode === "key") + await dialogs().handleAuthDialogSubmit({ sshKey: "test-key" }); + if (mode === "passphrase") + await mocks.passphrase.mock.calls + .at(-1)![0] + .onSubmit("test-passphrase"); + }); + } + await tick(); + await tick(201); + expect(grid().files).toEqual([child]); + expect(toolbar().currentPath).toBe("/home/alice"); + expect(listCalls(child.path)).toHaveLength(1); + expect(listCalls(child.path)[0][1].params).toMatchObject({ + sessionId: String(host.id), + prefetch: "true", + }); + expect(dialogs().sudoDialogOpen).toBe(false); + }, + ); + + it("keeps prefetch paused while the pane is hidden, then resumes on visibility", async () => { + const { rerender } = render( + , + ); + await tick(300); + await tick(); + expect(listCalls(child.path)).toHaveLength(0); + rerender(); + await tick(201); + expect(listCalls(child.path)).toHaveLength(1); + }); +}); + +describe("FileManager navigation joining a speculative listing", () => { + async function joinPrefetch() { + let reject!: (error: unknown) => void; + mocks.get.mockImplementation(async (_url, { params }) => { + if (params.path === child.path && params.prefetch) { + return new Promise((_resolve, fail) => { + reject = fail; + }); + } + return { + data: { + path: params.path, + files: params.path === child.path ? [] : [child], + }, + }; + }); + render( + , + ); + await tick(); + await tick(); + await tick(201); + expect(listCalls(child.path)[0][1].params.prefetch).toBe("true"); + await act(async () => { + await grid().onFileOpen(child); + }); + await tick(); + expect(listCalls(child.path)).toHaveLength(1); + return { reject }; + } + + const skippedSudo = { + response: { + status: 403, + data: { needsSudo: true, prefetchSkippedSudo: true }, + }, + }; + + it("retries a skipped speculative sudo read once as forced normal navigation", async () => { + const { reject } = await joinPrefetch(); + await act(async () => { + reject(skippedSudo); + }); + expect(listCalls(child.path)).toHaveLength(2); + expect(listCalls(child.path)[1][1].params).toMatchObject({ force: true }); + expect(listCalls(child.path)[1][1].params).not.toHaveProperty("prefetch"); + expect(toolbar().currentPath).toBe(child.path); + expect(grid().files).toEqual([]); + expect(dialogs().sudoDialogOpen).toBe(false); + }); + + it("opens sudo only after the normal retry still requires it, without looping", async () => { + const { reject } = await joinPrefetch(); + mocks.get.mockRejectedValueOnce(skippedSudo); + await act(async () => { + reject(skippedSudo); + }); + expect(listCalls(child.path)).toHaveLength(2); + expect(dialogs().sudoDialogOpen).toBe(true); + expect(toolbar().currentPath).toBe("/home/alice"); + }); + + it("does not retry an ordinary sudo error as a speculative skip", async () => { + const { reject } = await joinPrefetch(); + await act(async () => { + reject({ response: { status: 403, data: { needsSudo: true } } }); + }); + expect(listCalls(child.path)).toHaveLength(1); + expect(dialogs().sudoDialogOpen).toBe(true); + }); + + it("does not retry or publish a speculative failure after navigation moves on", async () => { + const { reject } = await joinPrefetch(); + await act(async () => { + await grid().onFileOpen({ ...child, path: "/elsewhere" }); + }); + await tick(); + await act(async () => { + reject(skippedSudo); + }); + // The API layer makes its one forced retry; the stale result is still ignored. + expect(toolbar().currentPath).toBe("/elsewhere"); + expect(dialogs().sudoDialogOpen).toBe(false); + }); + + it("keeps prefetch paused until every overlapping foreground read finishes", async () => { + const finish = new Map void>(); + const next = { ...child, name: "next", path: "/next" }; + const predicted = { ...child, name: "predicted", path: "/next/predicted" }; + mocks.get.mockImplementation(async (_url, { params }) => { + if (params.path === child.path || params.path === next.path) { + return new Promise((resolve) => { + finish.set(params.path, resolve); + }); + } + return { + data: { + path: params.path, + files: params.path === "/home/alice" ? [child] : [], + }, + }; + }); + render( + , + ); + await tick(); + await tick(); + await act(async () => { + await grid().onFileOpen(child); + }); + await tick(); + await act(async () => { + await grid().onFileOpen(next); + }); + await tick(); + expect(finish.size).toBe(2); + await act(async () => { + finish.get(child.path)!({ data: { path: child.path, files: [] } }); + }); + expect(grid().onFileIntent).toBeTypeOf("function"); + act(() => { + grid().onFileIntent?.(predicted); + }); + await tick(201); + expect(listCalls(predicted.path)).toHaveLength(0); + await act(async () => { + finish.get(next.path)!({ data: { path: next.path, files: [predicted] } }); + }); + await tick(201); + expect(listCalls(predicted.path)).toHaveLength(1); + expect(listCalls(predicted.path)[0][1].params.prefetch).toBe("true"); + }); + + it("explicit refresh bypasses a fresh snapshot and still forces the backend", async () => { + render( + , + ); + await tick(); + await act(async () => { + toolbar().handleRefreshDirectory(); + }); + expect(listCalls("/home/alice")).toHaveLength(2); + expect(listCalls("/home/alice")[1][1].params).toMatchObject({ + force: true, + }); + expect(listCalls("/home/alice")[1][1].params).not.toHaveProperty( + "prefetch", + ); + }); +}); diff --git a/tests/frontend/FileManagerGrid.test.tsx b/tests/frontend/FileManagerGrid.test.tsx index cedef26..5e7bbc5 100644 --- a/tests/frontend/FileManagerGrid.test.tsx +++ b/tests/frontend/FileManagerGrid.test.tsx @@ -1,8 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; import { FileManagerGrid } from "../../src/frontend/FileManagerGrid"; import type { FileItem } from "../../src/frontend/host-types"; +import { createDirectoryPrefetch } from "../../src/frontend/lib/directory-prefetch"; vi.mock("@termix-ssh/plugin-sdk/frontend", async (importOriginal) => ({ ...(await importOriginal()), @@ -160,3 +167,119 @@ describe("external file drops", () => { expect(onUpload).toHaveBeenCalledExactlyOnceWith(files); }); }); + +describe("directory prefetch intent", () => { + const folder: FileItem = { + name: "folder", + path: "/folder", + type: "directory", + }; + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + }); + afterEach(() => vi.useRealTimers()); + + it.each(["list", "grid"] as const)( + "waits for deliberate directory hover and cancels on leave in %s view", + async (viewMode) => { + const read = vi.fn(async (_path: string) => undefined); + const scheduler = createDirectoryPrefetch({ + read, + isCached: () => false, + run: (task) => { + void task(); + return true; + }, + }); + const onFileIntent = vi.fn(); + const { container } = render( + scheduler.hover(entry.path)} + onDirectoryLeave={(entry) => scheduler.cancelIntent(entry.path)} + />, + ); + const row = container.querySelector('[data-file-path="/folder"]')!; + fireEvent.mouseEnter(row); + await vi.advanceTimersByTimeAsync(50); + fireEvent.mouseLeave(row); + await vi.advanceTimersByTimeAsync(200); + expect(read).not.toHaveBeenCalled(); + fireEvent.mouseEnter(row); + await vi.advanceTimersByTimeAsync(99); + expect(read).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(read).toHaveBeenCalledExactlyOnceWith(folder.path); + expect(onFileIntent).not.toHaveBeenCalled(); + fireEvent.mouseEnter( + container.querySelector('[data-file-path="/notes.txt"]')!, + ); + await vi.advanceTimersByTimeAsync(200); + expect(read).toHaveBeenCalledTimes(1); + scheduler.dispose(); + }, + ); + + it("prefetches a keyboard-focused selection, keeps Enter-to-open, and adds no arrow navigation", () => { + vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue( + document.body, + ); + const onFileIntent = vi.fn(); + const onFileOpen = vi.fn(); + const onSelectionChange = vi.fn(); + const { container, rerender } = render( + , + ); + const grid = container.querySelector('[tabindex="0"]')!; + act(() => grid.focus()); + expect(onFileIntent).toHaveBeenCalledExactlyOnceWith(folder); + const next = { ...folder, path: "/next", name: "next" }; + rerender( + , + ); + expect(onFileIntent).toHaveBeenLastCalledWith(next); + fireEvent.keyDown(grid, { key: "ArrowDown" }); + expect(onSelectionChange).not.toHaveBeenCalled(); + fireEvent.keyDown(grid, { key: "Enter" }); + expect(onFileOpen).toHaveBeenCalledExactlyOnceWith(next); + }); + + it("keeps click content prefetch and does not prefetch an old selection during pointer focus", () => { + const onFileIntent = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText(file.name)); + expect(onFileIntent).toHaveBeenCalledExactlyOnceWith(file); + }); +}); diff --git a/tests/frontend/api/ssh-file-operations-api.test.ts b/tests/frontend/api/ssh-file-operations-api.test.ts index afca554..7c81d75 100644 --- a/tests/frontend/api/ssh-file-operations-api.test.ts +++ b/tests/frontend/api/ssh-file-operations-api.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const fileManagerApiMock = vi.hoisted(() => ({ + get: vi.fn(async () => ({ data: { files: [], path: "/data" } })), post: vi.fn(async () => ({ data: { complete: false } })), postForm: vi.fn(async () => ({ data: {} })), delete: vi.fn(async () => ({ data: {} })), @@ -27,6 +28,7 @@ vi.mock("@termix-ssh/plugin-sdk/ui", async (importOriginal) => ({ })); vi.mock("../../../src/frontend/lib/file-list-request-cache", () => ({ getCachedFileList: vi.fn(), + getFileListEpoch: vi.fn(() => 0), })); vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({ getCachedFileContent: vi.fn(), @@ -37,10 +39,79 @@ import { connectSSH, downloadSSHFileStream, uploadSSHFile, + listSSHFiles, } from "../../../src/frontend/api/ssh-file-operations-api"; +import { + getCachedFileList, + getFileListEpoch, +} from "../../../src/frontend/lib/file-list-request-cache"; import { setSessionOrigin } from "../../../src/frontend/api/client"; import { resolveConnectionOrigin } from "@termix-ssh/plugin-sdk/ui"; +it("passes an explicit refresh through to the backend", async () => { + vi.mocked(getCachedFileList).mockImplementationOnce( + (_session, _path, loader) => loader(), + ); + await listSSHFiles("session-refresh", "/data", { force: true }); + expect(getCachedFileList).toHaveBeenCalledWith( + "session-refresh", + "/data", + expect.any(Function), + true, + ); + expect(fileManagerApiMock.get).toHaveBeenCalledWith("/listFiles", { + params: { + sessionId: "session-refresh", + path: "/data", + epoch: "0", + force: true, + }, + }); +}); + +it("forwards the current session epoch and marks only speculative reads", async () => { + vi.mocked(getFileListEpoch).mockReturnValueOnce(3); + vi.mocked(getCachedFileList).mockImplementationOnce( + (_session, _path, loader) => loader(), + ); + await listSSHFiles("session-prefetch", "/data", { prefetch: true }); + expect(getFileListEpoch).toHaveBeenLastCalledWith("session-prefetch"); + expect(fileManagerApiMock.get).toHaveBeenLastCalledWith("/listFiles", { + params: { + sessionId: "session-prefetch", + path: "/data", + epoch: "3", + prefetch: "true", + }, + }); + + vi.mocked(getCachedFileList).mockImplementationOnce( + (_session, _path, loader) => loader(), + ); + await listSSHFiles("session-navigation", "/data", { prefetch: false }); + expect(fileManagerApiMock.get).toHaveBeenLastCalledWith("/listFiles", { + params: { sessionId: "session-navigation", path: "/data", epoch: "0" }, + }); +}); + +it("retries once as a forced read when a joined prefetch skipped sudo", async () => { + const skipped = { response: { data: { prefetchSkippedSudo: true } } }; + vi.mocked(getCachedFileList) + .mockRejectedValueOnce(skipped) + .mockImplementationOnce((_s, _p, loader) => loader()); + await listSSHFiles("session-sudo", "/root"); + expect(getCachedFileList).toHaveBeenLastCalledWith( + "session-sudo", + "/root", + expect.any(Function), + true, + ); + vi.mocked(getCachedFileList).mockRejectedValueOnce(skipped); + await expect( + listSSHFiles("session-sudo", "/root", { prefetch: true }), + ).rejects.toBe(skipped); +}); + describe("connectSSH origin", () => { it("connects where the host or the desktop default says", async () => { vi.mocked(resolveConnectionOrigin).mockResolvedValueOnce("remote"); diff --git a/tests/frontend/file-list.test.ts b/tests/frontend/file-list.test.ts new file mode 100644 index 0000000..b81ed77 --- /dev/null +++ b/tests/frontend/file-list.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { filterAndSortFiles } from "../../src/frontend/file-list"; +import type { FileItem } from "../../src/frontend/host-types"; + +const f = ( + name: string, + type: FileItem["type"] = "file", + size = 0, +): FileItem => ({ + name, + type, + size, + path: "/" + name, +}); + +describe("filterAndSortFiles", () => { + it("puts directories first and sorts names naturally", () => { + const out = filterAndSortFiles( + [f("a10"), f("a2"), f("z", "directory")], + "", + "name", + "asc", + ); + expect(out.map((x) => x.name)).toEqual(["z", "a2", "a10"]); + }); + it("filters case-insensitively and reverses", () => { + const out = filterAndSortFiles( + [f("Ab", "file", 1), f("ab2", "file", 2), f("c")], + "AB", + "size", + "desc", + ); + expect(out.map((x) => x.name)).toEqual(["ab2", "Ab"]); + }); +}); diff --git a/tests/frontend/lib/directory-prefetch.test.ts b/tests/frontend/lib/directory-prefetch.test.ts new file mode 100644 index 0000000..7cf9145 --- /dev/null +++ b/tests/frontend/lib/directory-prefetch.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDirectoryPrefetch } from "../../../src/frontend/lib/directory-prefetch"; +import { + peekCachedFileList, + updateCachedFileList, +} from "../../../src/frontend/lib/file-list-request-cache"; +import type { FileItem } from "../../../src/frontend/host-types"; + +const directory = (path: string): FileItem => ({ + path, + name: path.split("/").pop()!, + type: "directory", +}); + +function setup() { + const pending: { + resolve: (value?: unknown) => void; + reject: (reason: Error) => void; + }[] = []; + const read = vi.fn( + (_path: string): Promise => + new Promise((resolve, reject) => pending.push({ resolve, reject })), + ); + const run = vi.fn((task: () => Promise) => { + void Promise.resolve() + .then(task) + .catch(() => {}); + return true; + }); + const isCached = vi.fn((_path: string) => false); + const scheduler = createDirectoryPrefetch({ read, run, isCached }); + return { scheduler, read, run, isCached, pending }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("bounded directory prefetch", () => { + it("cancels a fast hover sweep and admits deliberate hover after 100 ms", async () => { + const { scheduler, read } = setup(); + for (const path of ["/a", "/b", "/c"]) { + scheduler.hover(path); + await vi.advanceTimersByTimeAsync(50); + scheduler.cancelIntent(path); + } + await vi.advanceTimersByTimeAsync(200); + expect(read).not.toHaveBeenCalled(); + scheduler.hover("/deliberate"); + await vi.advanceTimersByTimeAsync(99); + expect(read).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(read).toHaveBeenCalledExactlyOnceWith("/deliberate"); + scheduler.dispose(); + }); + + it("predicts at most two uncached direct children, never recurses into results", async () => { + const { scheduler, read, pending, isCached } = setup(); + isCached.mockImplementation((path) => path === "/parent/cached"); + scheduler.predict("/parent", [ + directory("/other/not-a-child"), + directory("/parent/a/grandchild"), + directory("/parent/.."), + { ...directory("/parent/link"), type: "link" }, + directory("/parent/cached"), + directory("/parent/a"), + directory("/parent/a"), + directory("/parent/b"), + directory("/parent/c"), + ]); + await vi.advanceTimersByTimeAsync(199); + expect(read).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(read.mock.calls).toEqual([["/parent/a"]]); + pending[0].resolve({ files: [directory("/parent/a/nested")] }); + await vi.runAllTimersAsync(); + expect(read.mock.calls).toEqual([["/parent/a"], ["/parent/b"]]); + pending[1].resolve(); + await vi.runAllTimersAsync(); + expect(read).toHaveBeenCalledTimes(2); + scheduler.dispose(); + }); + + it("keeps one read active and at most two queued, with intent ahead of predictions", async () => { + const { scheduler, read, pending } = setup(); + scheduler.predict("/", [directory("/a"), directory("/b")]); + await vi.advanceTimersByTimeAsync(200); + scheduler.intent("/c"); + scheduler.intent("/d"); + expect(read.mock.calls).toEqual([["/a"]]); + pending[0].resolve(); + await vi.runAllTimersAsync(); + expect(read.mock.calls).toEqual([["/a"], ["/d"]]); + pending[1].resolve(); + await vi.runAllTimersAsync(); + expect(read.mock.calls).toEqual([["/a"], ["/d"], ["/c"]]); + pending[2].resolve(); + await vi.runAllTimersAsync(); + expect(read).toHaveBeenCalledTimes(3); + scheduler.dispose(); + }); + + it("foreground clears pending work, blocks admission, and leaves the shared active read intact", async () => { + const { scheduler, read, pending } = setup(); + scheduler.intent("/active"); + await vi.advanceTimersByTimeAsync(0); + scheduler.intent("/queued"); + scheduler.hover("/hover"); + scheduler.predict("/", [directory("/predicted")]); + scheduler.setForeground(true); + scheduler.intent("/blocked"); + scheduler.hover("/blocked-hover"); + scheduler.predict("/", [directory("/blocked-prediction")]); + await vi.runAllTimersAsync(); + expect(read.mock.calls).toEqual([["/active"]]); + pending[0].resolve(); + await vi.runAllTimersAsync(); + scheduler.setForeground(false); + await vi.runAllTimersAsync(); + expect(read).toHaveBeenCalledTimes(1); + scheduler.intent("/next"); + await vi.advanceTimersByTimeAsync(0); + expect(read).toHaveBeenLastCalledWith("/next"); + scheduler.dispose(); + }); + + it("does not get stuck when the adaptive budget denies admission", async () => { + const { scheduler, run, read } = setup(); + run.mockReturnValueOnce(false); + scheduler.intent("/denied"); + scheduler.intent("/allowed"); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(2); + expect(read).toHaveBeenCalledExactlyOnceWith("/allowed"); + scheduler.dispose(); + }); + + it("rechecks foreground priority after SDK admission but before the deferred task runs", async () => { + const { scheduler, read } = setup(); + scheduler.intent("/deferred"); + scheduler.setForeground(true); + await vi.runAllTimersAsync(); + expect(read).not.toHaveBeenCalled(); + scheduler.setForeground(false); + scheduler.intent("/allowed"); + await vi.advanceTimersByTimeAsync(0); + expect(read).toHaveBeenCalledExactlyOnceWith("/allowed"); + scheduler.dispose(); + }); + + it("releases the slot after failures and permits a later retry", async () => { + const { scheduler, read, pending } = setup(); + scheduler.intent("/failure"); + scheduler.intent("/next"); + await vi.advanceTimersByTimeAsync(0); + pending[0].reject(new Error("offline")); + await vi.runAllTimersAsync(); + expect(read).toHaveBeenLastCalledWith("/next"); + pending[1].resolve(); + await vi.runAllTimersAsync(); + scheduler.intent("/failure"); + await vi.advanceTimersByTimeAsync(0); + expect(read.mock.calls).toEqual([["/failure"], ["/next"], ["/failure"]]); + scheduler.dispose(); + }); + + it("disposes timers and queued work for a previous session without aborting its read", async () => { + const { scheduler, read, pending } = setup(); + scheduler.intent("/active"); + await vi.advanceTimersByTimeAsync(0); + scheduler.intent("/queued"); + scheduler.hover("/hover"); + scheduler.predict("/", [directory("/predicted")]); + scheduler.dispose(); + expect(vi.getTimerCount()).toBe(0); + pending[0].resolve(); + scheduler.intent("/after-dispose"); + scheduler.hover("/after-dispose"); + await vi.runAllTimersAsync(); + expect(read).toHaveBeenCalledExactlyOnceWith("/active"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses 5-second freshness rather than the cache's 5-minute stale paint window", async () => { + const read = vi.fn(async (_path: string) => undefined); + const sessionId = "prefetch-freshness"; + updateCachedFileList(sessionId, "/cached", []); + const scheduler = createDirectoryPrefetch({ + read, + isCached: (path) => peekCachedFileList(sessionId, path, 5_000) !== null, + run: (task) => { + void task(); + return true; + }, + }); + scheduler.intent("/cached"); + expect(read).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_001); + expect(peekCachedFileList(sessionId, "/cached")).not.toBeNull(); + scheduler.intent("/cached"); + expect(read).toHaveBeenCalledExactlyOnceWith("/cached"); + scheduler.dispose(); + }); +}); diff --git a/tests/frontend/lib/file-list-request-cache.test.ts b/tests/frontend/lib/file-list-request-cache.test.ts new file mode 100644 index 0000000..1b398ff --- /dev/null +++ b/tests/frontend/lib/file-list-request-cache.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + getCachedFileList, + getFileListEpoch, + invalidateCachedFileList, + peekCachedFileList, + updateCachedFileList, +} from "../../../src/frontend/lib/file-list-request-cache"; + +describe("directory snapshot invalidation", () => { + it("increments the session epoch even when no paths have been cached", () => { + expect(getFileListEpoch("epoch-host")).toBe(0); + expect(getFileListEpoch("other-epoch-host")).toBe(0); + invalidateCachedFileList("epoch-host", "/parent"); + expect(getFileListEpoch("epoch-host")).toBe(1); + invalidateCachedFileList("epoch-host", "/unrelated"); + expect(getFileListEpoch("epoch-host")).toBe(2); + expect(getFileListEpoch("other-epoch-host")).toBe(0); + }); + + it("clears prefetched descendants and prevents old requests from restoring them", async () => { + updateCachedFileList("mutation-host", "/parent", []); + updateCachedFileList("other-host", "/parent", []); + let finish!: (value: { files: []; path: string }) => void; + const pending = getCachedFileList( + "mutation-host", + "/parent/child", + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + invalidateCachedFileList("mutation-host", "/parent"); + finish({ files: [], path: "/parent/child" }); + await pending; + expect(peekCachedFileList("mutation-host", "/parent")).toBeNull(); + expect(peekCachedFileList("mutation-host", "/parent/child")).toBeNull(); + expect(peekCachedFileList("other-host", "/parent")).not.toBeNull(); + }); + + it("does not overwrite a new epoch's listing when an old read finishes last", async () => { + let finish!: (value: { files: []; path: string }) => void; + const old = getCachedFileList( + "epoch-race-host", + "/alias", + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + invalidateCachedFileList("epoch-race-host", "/alias"); + const fresh = { files: [], path: "/new" }; + await getCachedFileList("epoch-race-host", "/alias", async () => fresh); + finish({ files: [], path: "/old" }); + await old; + expect(peekCachedFileList("epoch-race-host", "/alias", 5_000)).toBe(fresh); + }); +}); diff --git a/tests/frontend/lib/keyed-request-cache.test.ts b/tests/frontend/lib/keyed-request-cache.test.ts index fc25574..cb1eaee 100644 --- a/tests/frontend/lib/keyed-request-cache.test.ts +++ b/tests/frontend/lib/keyed-request-cache.test.ts @@ -4,6 +4,60 @@ import { createKeyedRequestCache } from "../../../src/frontend/lib/keyed-request afterEach(() => vi.useRealTimers()); describe("createKeyedRequestCache", () => { + it("forces a new read instead of joining speculative work, without old results overwriting it", async () => { + const cache = createKeyedRequestCache(5_000); + let finishOld!: (value: string) => void; + const old = cache.get( + "dir", + () => + new Promise((resolve) => { + finishOld = resolve; + }), + ); + const loader = vi.fn(async () => "fresh"); + const fresh = cache.get("dir", loader, { force: true }); + const sameRefresh = cache.get("dir", loader, { force: true }); + await expect(fresh).resolves.toBe("fresh"); + await expect(sameRefresh).resolves.toBe("fresh"); + finishOld("stale"); + await old; + expect(cache.peek("dir")).toBe("fresh"); + expect(loader).toHaveBeenCalledOnce(); + }); + + it("drops a read that was invalidated mid-flight", async () => { + const cache = createKeyedRequestCache(5_000); + let finish!: (value: string) => void; + const pending = cache.get( + "dir", + () => new Promise((resolve) => (finish = resolve)), + ); + cache.invalidate("dir"); + finish("stale"); + await pending; + expect(cache.peek("dir")).toBeNull(); + }); + + it("invalidates matching stored and pending keys without affecting another session", async () => { + const cache = createKeyedRequestCache(5_000); + cache.set("a:parent", "old"); + cache.set("b:parent", "other"); + let finish!: (value: string) => void; + const pending = cache.get( + "a:child", + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + cache.invalidateMatching((key) => key.startsWith("a:")); + finish("stale child"); + await pending; + expect(cache.peek("a:parent")).toBeNull(); + expect(cache.peek("a:child")).toBeNull(); + expect(cache.peek("b:parent")).toBe("other"); + }); + it("coalesces requests per key without mixing keys", async () => { let resolve!: (value: string) => void; const loader = vi.fn(() => new Promise((done) => (resolve = done)));