Skip to content

perf: optimize html/pdf rendering with persistent caches, pooled workers and faster multi-pass PDFs#60

Open
yashmehrotra wants to merge 8 commits into
mainfrom
speed-up
Open

perf: optimize html/pdf rendering with persistent caches, pooled workers and faster multi-pass PDFs#60
yashmehrotra wants to merge 8 commits into
mainfrom
speed-up

Conversation

@yashmehrotra

@yashmehrotra yashmehrotra commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added server controls for worker recycling, queue limits, resource thresholds, acquisition timeouts, and persistent rendering.
    • Improved HTML and PDF rendering for concurrent requests, large documents, and mixed page layouts.
    • Added benchmark commands and a mixed-layout benchmark example.
    • Improved PDF readiness handling for fonts, images, and custom page completion signals.
  • Documentation

    • Expanded performance benchmark guidance, results, measurement methodology, and server options.
  • Bug Fixes

    • Improved cache reuse and reduced temporary-file handling during rendering.
    • Prevented cross-request header and footer interference.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds benchmark tooling and documentation, content-addressed build and Tailwind caches, persistent SSR loaders, configurable worker lifecycle controls, reusable template workspaces, streamed render-cache responses, and a shared PDF rendering pipeline with readiness synchronization.

Changes

Rendering platform and benchmarks

Layer / File(s) Summary
Benchmark tooling and measurement
README.md, cli/scripts/*, cli/examples/BenchmarkMixedReport.tsx, cli/src/utils/performance.ts, cli/package.json, Taskfile.yml
Adds cold/warm HTML and PDF benchmarks, server benchmarks, profiling output, benchmark fixtures, command scripts, and updated benchmark documentation.
Build, SSR, and Tailwind caching
cli/src/builders/facet-directory.ts, cli/src/bundler/*, cli/src/loaders/ssr.ts, cli/src/utils/tailwind.ts, cli/src/generators/html.ts
Adds deterministic build keys, cache pruning, locked local builds, persistent NDJSON SSR daemons, cached Tailwind output, and cache-aware build results.
Server workers, workspaces, and streamed cache responses
cli/src/cli.ts, cli/src/server/*, cli/test/render-api.test.ts, README.md
Adds worker and queue controls, persistent SSR configuration, worker recycling and health tracking, reusable template workspaces, file-backed cache streaming, detailed health metrics, and concurrent-render coverage.
PDF readiness and multi-pass rendering
cli/src/utils/browser-readiness.ts, cli/src/utils/pdf-generator.ts, cli/src/utils/pdf-multipass.ts, cli/src/utils/*test.ts, cli/test/pdf-bleed.test.ts, examples/kitchen-sink/PageSizeTest.tsx
Adds shared page-readiness handling, browser-context PDF rendering, optimized multi-pass overlays, PDF metadata propagation, buffer release, and expanded PDF test coverage.

Sequence Diagram(s)

sequenceDiagram
  participant RenderRoute
  participant BuildTemplate
  participant SsrLoaderPool
  participant SsrDaemon
  RenderRoute->>BuildTemplate: request persistent render
  BuildTemplate->>SsrLoaderPool: runPersistentSsrLoader
  SsrLoaderPool->>SsrDaemon: send cache-keyed NDJSON request
  SsrDaemon-->>SsrLoaderPool: return HTML and CSS
  SsrLoaderPool-->>BuildTemplate: return render result
  BuildTemplate-->>RenderRoute: return cached build output
Loading

Possibly related PRs

Suggested reviewers: moshloop

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main performance-focused changes: persistent caches, pooled workers, and faster HTML/PDF rendering.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch speed-up
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch speed-up

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Add content-addressed Vite SSR and Tailwind caches so data-only renders avoid rebuilding unchanged templates and styles.

Keep bounded persistent template workspaces and SSR loader processes for warm server requests, with idle/LRU cleanup and a one-shot fallback. Serialize workspace builds and content-address generated header/footer fragments to make concurrent requests safe.

Stream cached responses from disk and harden the browser worker pool with queue limits, acquisition timeouts, age/render/RSS recycling, health metrics, and graceful shutdown.

Expose the new server controls through CLI flags and environment-backed configuration, and cover build keys, workspace reuse, worker queue policy, and concurrent fragments with regression tests.
Consolidate the duplicated PDF entry points behind one browser-backed pipeline and isolate each render in its own browser context.

Reduce multi-pass work by extracting minimal group and overlay HTML, rendering only page type/size combinations that actually occur, and releasing intermediate group and overlay buffers after embedding.

Avoid the metadata-only pdf-lib rewrite for direct Chromium PDFs while preserving Facet metadata for composited documents. Preserve pre-existing HTML output when PDF generation uses the same output name.

Re-enable the visual PDF suite, fix its large-page footer fixture and rasterization timeout, and add regression coverage for overlay type/size selection.
Add cold/warm CLI and persistent-server benchmark harnesses with stage timings, per-request process-tree, Chromium and Node memory metrics, output sizes, and configurable iteration/document sizes.

Add a deterministic mixed-page fixture that exercises typed headers and footers, three page sizes, content grouping, and pdf-lib compositing.

Document benchmark methodology and measured checkpoints, including retained improvements, memory tradeoffs, rejected overlay concurrency, and all new server tuning controls.
Allow zero-browser worker pools for health-only server tests while continuing to reject negative and non-integer worker counts.

Increase the integration render timeout and large mixed-page visual hook timeout so concurrent full-suite execution has enough time for dependency builds and ImageMagick rasterization.

Verified with task test:unit: 310 passed, 1 network-dependent test skipped.
Invoke the TypeScript CLI entry through Node and tsx when visual tests generate HTML, removing the unit suite's dependency on dist/facet being built first.

Clean up the temporary kitchen-sink node_modules symlink produced by source CLI execution without touching consumer-owned dependencies.

Verified task test:unit with dist/facet temporarily removed: 310 passed, 1 network-dependent test skipped.
Guard FACET_PACKAGE_PATH directory rebuilds with an atomic filesystem lock so concurrent render workers cannot resolve @flanksource/facet while another process empties and rebuilds dist/.

Recheck component and CSS freshness after acquiring the lock, recover stale locks, bound lock waits by the existing local build timeout, and always clean up the lock file.

Verified by touching component sources to force a stale local package and running task test:unit: 310 passed, 1 network-dependent test skipped.
Make test:unit depend on the project build because the visual PDF tests intentionally exercise dist/facet and the built local @flanksource/facet package.

Restore visual HTML generation through the release binary instead of the Node/tsx source entry, avoiding Node 22 and Vite directory-link resolution failures for the kitchen-sink file dependency.

Clean up only the test-generated kitchen-sink node_modules symlink after the suite. Verified from a missing dist/facet state: task test:unit builds first, then passes 310 tests with 1 network-dependent skip.
@yashmehrotra yashmehrotra marked this pull request as ready for review July 14, 2026 06:46
@yashmehrotra yashmehrotra requested a review from moshloop July 14, 2026 06:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
cli/src/server/worker-pool.ts (1)

145-178: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

recycling counter leaks when recycling during shutdown.

The this.shuttingDown early return (this.total--; return;) is outside the try/finally that decrements this.recycling, so any recycle triggered while shutting down leaves this.recycling permanently incremented (never reset in shutdown() either).

🔧 Proposed fix
     if (this.shuttingDown) {
       this.total--;
+      this.recycling--;
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/server/worker-pool.ts` around lines 145 - 178, Ensure recycle always
decrements this.recycling, including when this.shuttingDown is true. Move the
shutdown early return inside the existing try/finally structure in recycle,
preserving the total decrement and avoiding browser replacement during shutdown.
cli/src/bundler/vite-builder.ts (1)

187-274: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Build lock scope includes the SSR render step, potentially serializing concurrent renders of the same template.

withBuildLock wraps all of buildTemplateUnlocked, including the actual runPersistentSsrLoader/runLoaderWithRetry render call. That's necessary for the filesystem setup (symlinking, config/package.json generation, pnpm install) which can't safely run concurrently for the same .facet/ dir, but once the workspace is current, concurrent render requests for the same template (different data) are still forced to execute one-at-a-time by this lock — including inside the persistent-SSR path this PR adds to speed things up.

Worth considering whether the lock can be narrowed to just the "ensure workspace is built" phase, letting persistent-loader renders for the same template run concurrently once the build is current.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/bundler/vite-builder.ts` around lines 187 - 274, Narrow withBuildLock
in buildTemplate so it protects only the shared workspace setup and
dependency-installation phase, not the SSR rendering performed by
runPersistentSsrLoader or runLoaderWithRetry. Ensure concurrent requests can
render different data for an already-prepared facet workspace while preserving
serialized filesystem/configuration setup for the same lock key.
🧹 Nitpick comments (4)
cli/scripts/benchmark-render.mjs (2)

8-11: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Same cwd-relative path resolution fragility in both scripts. Both scripts resolve the CLI binary and default template/input paths via resolve() against process.cwd() instead of the script's own location, so they only work when invoked with cwd pinned to cli/ (e.g. via the new pnpm run bench:* scripts).

  • cli/scripts/benchmark-render.mjs#L8-L11: resolve cli, template, and data relative to import.meta.url instead of process.cwd().
  • cli/scripts/benchmark-server.mjs#L9-L18: resolve cli and templatesDir the same way for consistency.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/scripts/benchmark-render.mjs` around lines 8 - 11, Update path resolution
in cli/scripts/benchmark-render.mjs lines 8-11 and
cli/scripts/benchmark-server.mjs lines 9-18 to resolve the CLI binary and
default template/input directories relative to each script’s import.meta.url
rather than process.cwd(); preserve command-line overrides while making both
benchmark scripts runnable from any working directory.

14-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate processTreeRss implementation across both benchmark scripts. Both files define an identical /proc-based process-tree RSS sampler; extracting it to a shared module avoids drift between the two.

  • cli/scripts/benchmark-render.mjs#L14-L26: move this implementation into a small shared helper module (e.g. cli/scripts/lib/proc-rss.mjs) and import it here.
  • cli/scripts/benchmark-server.mjs#L20-L32: import the same shared helper instead of keeping a second copy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/scripts/benchmark-render.mjs` around lines 14 - 26, Extract the duplicate
processTreeRss implementation from cli/scripts/benchmark-render.mjs lines 14-26
and cli/scripts/benchmark-server.mjs lines 20-32 into a shared helper module,
such as cli/scripts/lib/proc-rss.mjs. Export and import the helper in both
scripts, removing their local copies while preserving the existing Linux,
recursion, and error-handling behavior.
cli/src/server/worker-pool.ts (1)

180-194: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

stats() recomputes Chromium RSS synchronously for every worker on every call.

stats() is called from the /healthz endpoint (per graph evidence), so every health probe walks each worker's full Chromium process tree via synchronous /proc reads (already done once per worker in release()). Under frequent probing with multiple long-lived workers/children this adds blocking I/O on a hot, frequently-polled path. Consider caching/throttling this (e.g. only refresh on a TTL, or reuse worker.lastRssMb from the last release() instead of recomputing on every stats() call).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/server/worker-pool.ts` around lines 180 - 194, Update
WorkerPool.stats() to avoid synchronously recomputing Chromium RSS for every
worker on each health check. Reuse the per-worker lastRssMb values populated by
release(), or refresh them only under a suitable TTL, while preserving the
chromiumRssMb aggregate and existing stats fields.
cli/src/bundler/build-cache.ts (1)

22-64: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Full source-tree content hash runs on every render.

computeTemplateBuildKey reads and hashes the full content of every matching file (including .json — potentially large data fixtures) under consumerRoot, and this runs on every buildTemplateUnlocked call, not just when something actually needs rebuilding. For larger consumer projects this read+hash cost is paid on every request, partially offsetting the caching benefits this PR is trying to deliver.

Consider a cheaper first-pass check (e.g. mtime+size digest) with the full content hash only as a fallback/verification step, to keep the common "nothing changed" case cheap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/bundler/build-cache.ts` around lines 22 - 64, Optimize
computeTemplateBuildKey so the routine does not read and hash every matching
file on each render. Add a cheap first-pass fingerprint based on stable metadata
such as each file’s path, size, and modification time, and reuse it for the
unchanged-tree case; retain full content hashing only as a fallback or
verification step when metadata indicates a possible change. Preserve the
existing exclusions, file selection, sorting, and selected template-entry
handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/src/builders/facet-directory.ts`:
- Around line 996-1004: Update the lock acquisition loop around openSync and
writeFileSync so any write failure after successfully opening the lock closes fd
and removes lockPath before rethrowing. Preserve the existing EEXIST retry
behavior, and ensure cleanup applies only to the newly acquired descriptor and
lock file.
- Around line 993-1022: Update withLocalFacetBuildLock and its callers
buildTemplateUnlocked/startViteServer so lock acquisition no longer uses
blocking Atomics.wait on the Node event loop. Make the lock-wait path
asynchronous, preserving timeout, stale-lock cleanup, action execution, and
finally-based descriptor/file cleanup while awaiting the lock.

In `@cli/src/bundler/ssr-pool.ts`:
- Around line 112-125: Update runPersistentSsrLoader and SsrLoaderProcess so
eviction never closes a loader with outstanding requests: expose and use a
pending-request status such as hasPending() or pendingCount, select the oldest
idle loader for eviction, and if none is idle reject the new request with a
clear pool-exhausted error instead of terminating active work.
- Around line 70-75: Validate the parsed environment values in
scheduleIdleShutdown and the loader-cap logic before applying Math.max or
comparing against the limit. If FACET_SSR_LOADER_IDLE_MS or
FACET_MAX_SSR_LOADERS is missing, non-numeric, or otherwise invalid, fall back
to the existing defaults (300000 and 4 respectively), while preserving the
existing minimum bounds.
- Around line 46-56: Update the reply handler in the SSR pool’s createInterface
callback so scheduleIdleShutdown() is invoked only after all pending requests
have completed. Check this.pending after removing the replied request,
preserving the existing resolve/reject behavior while preventing close() from
terminating an in-flight request.

In `@cli/src/cli.ts`:
- Around line 230-235: Update the Commander option declarations for
max-renders-per-worker, max-queue-depth, max-worker-age, max-worker-rss, and
worker-acquire-timeout to coerce and validate their values as numbers before
storing them in options. Reject invalid or non-numeric input at CLI parsing time
so WorkerPool receives valid numeric limits.

In `@cli/src/loaders/ssr.ts`:
- Around line 111-128: Ensure the temporary staging directory created in the
!cachedBundle build flow is removed when await build(...) throws. Wrap the build
operation and subsequent cache promotion in cleanup handling, removing buildDir
recursively with force enabled on failure while preserving the existing rename
race handling and rethrowing the original build error.

In `@cli/src/server/preview.ts`:
- Line 9: Update createServer’s stop() cleanup to avoid calling the process-wide
shutdownPersistentSsrLoaders for all loaders; track the facetRoot values used by
that server instance and shut down only those loaders, preserving loaders owned
by other live server instances.

In `@cli/src/server/routes.ts`:
- Around line 457-469: Add per-workspace pruning for `.facet-fragments` within
acquireTemplateWorkspace after writing the content-hashed fragment, using the
existing pruneWorkspaces or pruneTailwindCache pattern and an entry-count or age
limit. Keep current fragment creation and buildTemplate behavior unchanged while
ensuring stale or excess fragment files are removed.

In `@cli/src/utils/browser-readiness.ts`:
- Around line 36-53: Update the readiness flow containing document.fonts.ready,
image loading/decoding, and __FACET_READY__ so timeoutMs applies to the entire
phase through one shared deadline or remaining-time budget rather than creating
a fresh timeout for each await. Preserve the existing readiness tasks and
timeout behavior, and ensure the budget is consumed across them collectively.

In `@cli/src/utils/pdf-generator.ts`:
- Around line 485-506: Update the zero-margin fast path inside renderSinglePass
to apply the same creator/producer PDF metadata stamping performed by
compositeHeaderFooter before returning page.pdf() bytes. Preserve the existing
behavior for non-zero margins and ensure the metadata update occurs before any
subsequent debug typography processing in the caller.

In `@cli/src/utils/tailwind.ts`:
- Around line 78-88: Guard the cache-directory listing and file metadata
collection in pruneTailwindCache with a try/catch, returning without pruning
when readdir or stat encounters a concurrent deletion or other filesystem error.
Keep the existing sorting and removal behavior for successful reads, and ensure
pruning failures do not propagate through runTailwindCached.
- Around line 107-153: Update the cache-hit path in runTailwindCached to refresh
cachePath’s access and modification timestamps after successfully reading the
cached CSS, using the node:fs/promises utimes API; preserve the existing
read-and-return behavior and allow timestamp refresh errors to propagate
consistently.

---

Outside diff comments:
In `@cli/src/bundler/vite-builder.ts`:
- Around line 187-274: Narrow withBuildLock in buildTemplate so it protects only
the shared workspace setup and dependency-installation phase, not the SSR
rendering performed by runPersistentSsrLoader or runLoaderWithRetry. Ensure
concurrent requests can render different data for an already-prepared facet
workspace while preserving serialized filesystem/configuration setup for the
same lock key.

In `@cli/src/server/worker-pool.ts`:
- Around line 145-178: Ensure recycle always decrements this.recycling,
including when this.shuttingDown is true. Move the shutdown early return inside
the existing try/finally structure in recycle, preserving the total decrement
and avoiding browser replacement during shutdown.

---

Nitpick comments:
In `@cli/scripts/benchmark-render.mjs`:
- Around line 8-11: Update path resolution in cli/scripts/benchmark-render.mjs
lines 8-11 and cli/scripts/benchmark-server.mjs lines 9-18 to resolve the CLI
binary and default template/input directories relative to each script’s
import.meta.url rather than process.cwd(); preserve command-line overrides while
making both benchmark scripts runnable from any working directory.
- Around line 14-26: Extract the duplicate processTreeRss implementation from
cli/scripts/benchmark-render.mjs lines 14-26 and
cli/scripts/benchmark-server.mjs lines 20-32 into a shared helper module, such
as cli/scripts/lib/proc-rss.mjs. Export and import the helper in both scripts,
removing their local copies while preserving the existing Linux, recursion, and
error-handling behavior.

In `@cli/src/bundler/build-cache.ts`:
- Around line 22-64: Optimize computeTemplateBuildKey so the routine does not
read and hash every matching file on each render. Add a cheap first-pass
fingerprint based on stable metadata such as each file’s path, size, and
modification time, and reuse it for the unchanged-tree case; retain full content
hashing only as a fallback or verification step when metadata indicates a
possible change. Preserve the existing exclusions, file selection, sorting, and
selected template-entry handling.

In `@cli/src/server/worker-pool.ts`:
- Around line 180-194: Update WorkerPool.stats() to avoid synchronously
recomputing Chromium RSS for every worker on each health check. Reuse the
per-worker lastRssMb values populated by release(), or refresh them only under a
suitable TTL, while preserving the chromiumRssMb aggregate and existing stats
fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 22814b86-71bf-43db-ac75-1ddb3d1d96aa

📥 Commits

Reviewing files that changed from the base of the PR and between ac0d21d and 0a356ed.

📒 Files selected for processing (33)
  • README.md
  • Taskfile.yml
  • cli/examples/BenchmarkMixedReport.tsx
  • cli/package.json
  • cli/scripts/benchmark-render.mjs
  • cli/scripts/benchmark-server.mjs
  • cli/src/builders/facet-directory.ts
  • cli/src/bundler/build-cache.test.ts
  • cli/src/bundler/build-cache.ts
  • cli/src/bundler/ssr-pool.ts
  • cli/src/bundler/vite-builder.ts
  • cli/src/cli.ts
  • cli/src/generators/html.ts
  • cli/src/generators/pdf.ts
  • cli/src/loaders/ssr.ts
  • cli/src/server/config.ts
  • cli/src/server/preview.ts
  • cli/src/server/render-cache.ts
  • cli/src/server/routes.ts
  • cli/src/server/template-workspaces.test.ts
  • cli/src/server/template-workspaces.ts
  • cli/src/server/worker-pool.test.ts
  • cli/src/server/worker-pool.ts
  • cli/src/utils/browser-readiness.ts
  • cli/src/utils/pdf-generator.ts
  • cli/src/utils/pdf-multipass.test.ts
  • cli/src/utils/pdf-multipass.ts
  • cli/src/utils/performance.ts
  • cli/src/utils/tailwind.test.ts
  • cli/src/utils/tailwind.ts
  • cli/test/pdf-bleed.test.ts
  • cli/test/render-api.test.ts
  • examples/kitchen-sink/PageSizeTest.tsx

Comment on lines +993 to 1022
private withLocalFacetBuildLock(packageRoot: string, action: () => void): void {
const lockPath = join(packageRoot, '.facet-local-build.lock');
const deadline = Date.now() + LOCAL_BUILD_TIMEOUT_MS;
let fd: number | undefined;
while (fd === undefined) {
try {
fd = openSync(lockPath, 'wx');
writeFileSync(fd, `${process.pid}\n`);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') throw error;
try {
if (Date.now() - statSync(lockPath).mtimeMs > LOCAL_BUILD_TIMEOUT_MS * 2) {
unlinkSync(lockPath);
continue;
}
} catch { continue; }
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for local Facet build lock: ${lockPath}`);
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
}
}
try {
action();
} finally {
closeSync(fd);
try { unlinkSync(lockPath); } catch { /* already cleaned up */ }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether ensureLocalFacetPackageBuilt / generatePackageJson can run inside the persistent server process (not just one-shot CLI renders).
rg -n "generatePackageJson|ensureLocalFacetPackageBuilt" cli/src -A2 -B5

Repository: flanksource/facet

Length of output: 6905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- facet-directory lock implementation ---'
sed -n '970,1028p' cli/src/builders/facet-directory.ts

echo
echo '--- render/build call sites ---'
rg -n "buildTemplateUnlocked|buildTemplate\(|renderHTML(Streamed)?\(|startViteServer|generatePackageJson\(" cli/src -A4 -B4

echo
echo '--- routes around renderHTML/renderHTMLStreamed ---'
fd -a 'routes.ts' cli/src

Repository: flanksource/facet

Length of output: 17491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- buildTemplate / render call graph ---'
rg -n "buildTemplateUnlocked|buildTemplate\(|renderHTML(Streamed)?\(" cli/src -A6 -B6

printf '\n%s\n' '--- routes file presence ---'
fd -a 'routes.ts' cli/src

printf '\n%s\n' '--- vite-server path ---'
sed -n '1,120p' cli/src/bundler/vite-server.ts

printf '\n%s\n' '--- vite-builder path ---'
sed -n '200,260p' cli/src/bundler/vite-builder.ts

Repository: flanksource/facet

Length of output: 15556


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "renderHTMLStreamed|renderHTML|buildTemplateUnlocked|buildTemplate" . -A5 -B5

Repository: flanksource/facet

Length of output: 12103


Synchronous lock wait blocks the Node process

Atomics.wait blocks the main thread while waiting on .facet-local-build.lock. This runs from buildTemplateUnlocked() (used by the server routes) and startViteServer(), so a concurrent local-facet rebuild can stall the entire process for up to 5 minutes. Make the wait async or move the lock handling off the event loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/builders/facet-directory.ts` around lines 993 - 1022, Update
withLocalFacetBuildLock and its callers buildTemplateUnlocked/startViteServer so
lock acquisition no longer uses blocking Atomics.wait on the Node event loop.
Make the lock-wait path asynchronous, preserving timeout, stale-lock cleanup,
action execution, and finally-based descriptor/file cleanup while awaiting the
lock.

Comment on lines +996 to +1004
let fd: number | undefined;
while (fd === undefined) {
try {
fd = openSync(lockPath, 'wx');
writeFileSync(fd, `${process.pid}\n`);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') throw error;
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Lock fd/file leak if writeFileSync fails after openSync succeeds.

If writeFileSync(fd, ...) throws (non-EEXIST path), the function rethrows without closing fd or unlinking lockPath — the fd stays open for the process lifetime and the lock file lingers until another process's staleness check (10 minutes) removes it.

🔧 Proposed fix
       try {
         fd = openSync(lockPath, 'wx');
-        writeFileSync(fd, `${process.pid}\n`);
       } catch (error) {
+        writeFileSync(fd, `${process.pid}\n`);
+      } catch (error) {
+        if (fd !== undefined) {
+          try { closeSync(fd); } catch {}
+          try { unlinkSync(lockPath); } catch {}
+          fd = undefined;
+        }
         const code = (error as NodeJS.ErrnoException).code;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/builders/facet-directory.ts` around lines 996 - 1004, Update the lock
acquisition loop around openSync and writeFileSync so any write failure after
successfully opening the lock closes fd and removes lockPath before rethrowing.
Preserve the existing EEXIST retry behavior, and ensure cleanup applies only to
the newly acquired descriptor and lock file.

Comment on lines +46 to +56
createInterface({ input: this.child.stdout }).on('line', (line) => {
let reply: Reply;
try { reply = JSON.parse(line) as Reply; }
catch { return; }
const pending = this.pending.get(reply.id);
if (!pending) return;
this.pending.delete(reply.id);
if (reply.result) pending.resolve(reply.result);
else pending.reject(new Error(reply.error ?? 'Persistent SSR loader failed'));
this.scheduleIdleShutdown();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Idle shutdown can fire while other requests are still in flight.

scheduleIdleShutdown() runs after every reply regardless of whether this.pending still has outstanding entries. If two requests are sent back-to-back to the same loader, the first reply arms an idle timer; if it elapses before the remaining reply arrives (e.g. under a shortened FACET_SSR_LOADER_IDLE_MS), close() tears down the child and rejects the still-pending request via fail().

🔧 Proposed fix
       if (reply.result) pending.resolve(reply.result);
       else pending.reject(new Error(reply.error ?? 'Persistent SSR loader failed'));
-      this.scheduleIdleShutdown();
+      if (this.pending.size === 0) this.scheduleIdleShutdown();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
createInterface({ input: this.child.stdout }).on('line', (line) => {
let reply: Reply;
try { reply = JSON.parse(line) as Reply; }
catch { return; }
const pending = this.pending.get(reply.id);
if (!pending) return;
this.pending.delete(reply.id);
if (reply.result) pending.resolve(reply.result);
else pending.reject(new Error(reply.error ?? 'Persistent SSR loader failed'));
this.scheduleIdleShutdown();
});
createInterface({ input: this.child.stdout }).on('line', (line) => {
let reply: Reply;
try { reply = JSON.parse(line) as Reply; }
catch { return; }
const pending = this.pending.get(reply.id);
if (!pending) return;
this.pending.delete(reply.id);
if (reply.result) pending.resolve(reply.result);
else pending.reject(new Error(reply.error ?? 'Persistent SSR loader failed'));
if (this.pending.size === 0) this.scheduleIdleShutdown();
});
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/bundler/ssr-pool.ts` around lines 46 - 56, Update the reply handler
in the SSR pool’s createInterface callback so scheduleIdleShutdown() is invoked
only after all pending requests have completed. Check this.pending after
removing the replied request, preserving the existing resolve/reject behavior
while preventing close() from terminating an in-flight request.

Comment on lines +70 to +75
private scheduleIdleShutdown(): void {
if (this.idleTimer) clearTimeout(this.idleTimer);
const idleMs = Math.max(1_000, parseInt(process.env['FACET_SSR_LOADER_IDLE_MS'] ?? '300000', 10));
this.idleTimer = setTimeout(() => void this.close(), idleMs);
this.idleTimer.unref();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unvalidated parseInt on env vars can silently disable limits.

parseInt(process.env['FACET_SSR_LOADER_IDLE_MS'] ?? '300000', 10) (line 72) and parseInt(process.env['FACET_MAX_SSR_LOADERS'] ?? '4', 10) (line 118) return NaN for a malformed value, and Math.max(1, NaN)/Math.max(1_000, NaN) both evaluate to NaN. That disables the loader cap entirely (unbounded growth, since loaders.size >= NaN is always false) or makes idle shutdown fire almost immediately (NaN delay).

🔧 Proposed fix
-    const idleMs = Math.max(1_000, parseInt(process.env['FACET_SSR_LOADER_IDLE_MS'] ?? '300000', 10));
+    const parsedIdleMs = parseInt(process.env['FACET_SSR_LOADER_IDLE_MS'] ?? '300000', 10);
+    const idleMs = Math.max(1_000, Number.isNaN(parsedIdleMs) ? 300_000 : parsedIdleMs);
-    const maxLoaders = Math.max(1, parseInt(process.env['FACET_MAX_SSR_LOADERS'] ?? '4', 10));
+    const parsedMaxLoaders = parseInt(process.env['FACET_MAX_SSR_LOADERS'] ?? '4', 10);
+    const maxLoaders = Math.max(1, Number.isNaN(parsedMaxLoaders) ? 4 : parsedMaxLoaders);

Also applies to: 112-119

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/bundler/ssr-pool.ts` around lines 70 - 75, Validate the parsed
environment values in scheduleIdleShutdown and the loader-cap logic before
applying Math.max or comparing against the limit. If FACET_SSR_LOADER_IDLE_MS or
FACET_MAX_SSR_LOADERS is missing, non-numeric, or otherwise invalid, fall back
to the existing defaults (300000 and 4 respectively), while preserving the
existing minimum bounds.

Comment on lines +112 to +125
export function runPersistentSsrLoader(
request: PersistentLoaderRequest,
logger: Logger,
): Promise<PersistentLoaderResult> {
let loader = loaders.get(request.facetRoot);
if (!loader) {
const maxLoaders = Math.max(1, parseInt(process.env['FACET_MAX_SSR_LOADERS'] ?? '4', 10));
if (loaders.size >= maxLoaders) {
const oldest = loaders.entries().next().value as [string, SsrLoaderProcess] | undefined;
if (oldest) {
loaders.delete(oldest[0]);
void oldest[1].close();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

LRU eviction can kill a loader with in-flight requests.

request() picks the oldest map entry once FACET_MAX_SSR_LOADERS is reached and closes it unconditionally. If that loader is currently servicing a request, close() ends stdin/kills the process and fail() rejects the still-pending request — a legitimate in-flight render fails just because a new, unrelated facetRoot needed a slot.

Consider skipping eviction of loaders with outstanding pending requests (expose a hasPending()/pendingCount accessor on SsrLoaderProcess and prefer an idle candidate, or reject the new request with a clear "pool exhausted" error instead of killing active work).

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/bundler/ssr-pool.ts` around lines 112 - 125, Update
runPersistentSsrLoader and SsrLoaderProcess so eviction never closes a loader
with outstanding requests: expose and use a pending-request status such as
hasPending() or pendingCount, select the oldest idle loader for eviction, and if
none is idle reject the new request with a clear pool-exhausted error instead of
terminating active work.

Comment thread cli/src/server/routes.ts
Comment on lines +457 to +469
persistentLoader = true,
): Promise<string> {
await writeFile(join(consumerRoot, filename), code, 'utf-8');
const fragmentDir = join(consumerRoot, '.facet-fragments');
const contentKey = createHash('sha256').update(code).digest('hex').slice(0, 20);
const fragmentPath = join('.facet-fragments', `${filename.replace(/\.tsx$/, '')}-${contentKey}.tsx`);
await mkdir(fragmentDir, { recursive: true });
await writeFile(join(consumerRoot, fragmentPath), code, 'utf-8');
const result = await buildTemplate({
templatePath: filename,
templatePath: fragmentPath,
data,
consumerRoot,
logger,
persistentLoader,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant files and inspect the workspace/cache flow.
git ls-files | rg '(^|/)(cli/src/server/routes\.ts|.*workspace.*|.*cache.*|.*template.*)$' || true

printf '\n--- routes.ts outline ---\n'
ast-grep outline cli/src/server/routes.ts --view expanded || true

printf '\n--- search for fragment/cache helpers ---\n'
rg -n "facet-fragments|acquireTemplateWorkspace|cleanupTempDir|pruneTailwindCache|pruneWorkspaces|FACET_WORKSPACE_CACHE_ENTRIES|FACET_TAILWIND_CACHE_ENTRIES|buildFragment|resolveTemplateSource" cli/src/server/routes.ts cli/src || true

Repository: flanksource/facet

Length of output: 6891


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- template-workspaces.ts ---\n'
cat -n cli/src/server/template-workspaces.ts | sed -n '1,220p'

printf '\n--- relevant routes.ts sections ---\n'
sed -n '322,530p' cli/src/server/routes.ts | cat -n

printf '\n--- build-cache.ts ---\n'
cat -n cli/src/bundler/build-cache.ts | sed -n '1,220p'

Repository: flanksource/facet

Length of output: 15660


Add a prune step for .facet-fragments
acquireTemplateWorkspace() only bounds the number of workspace roots; a hot workspace can keep accumulating content-hashed header/footer files under .facet-fragments with no per-workspace cap. Add entry-count or age-based pruning here, similar to pruneWorkspaces()/pruneTailwindCache().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/server/routes.ts` around lines 457 - 469, Add per-workspace pruning
for `.facet-fragments` within acquireTemplateWorkspace after writing the
content-hashed fragment, using the existing pruneWorkspaces or
pruneTailwindCache pattern and an entry-count or age limit. Keep current
fragment creation and buildTemplate behavior unchanged while ensuring stale or
excess fragment files are removed.

Comment on lines +36 to +53
await withTimeout(document.fonts.ready);
await withTimeout(Promise.all(
Array.from(document.images, async (image) => {
if (!image.complete) {
await new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
});
}
if (typeof image.decode === 'function') {
await image.decode().catch(() => undefined);
}
}),
));

if (facet) {
const ready = (window as typeof window & { __FACET_READY__?: Promise<unknown> }).__FACET_READY__;
if (ready && typeof ready.then === 'function') await withTimeout(ready);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Apply timeoutMs to the entire readiness phase.

Fonts, images, and __FACET_READY__ each receive a fresh timeout, so the default 30-second setting can take 90 seconds here—or 120 seconds including setContent. Run all readiness tasks under one deadline/shared remaining budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/utils/browser-readiness.ts` around lines 36 - 53, Update the
readiness flow containing document.fonts.ready, image loading/decoding, and
__FACET_READY__ so timeoutMs applies to the entire phase through one shared
deadline or remaining-time budget rather than creating a fresh timeout for each
await. Preserve the existing readiness tasks and timeout behavior, and ensure
the budget is consumed across them collectively.

Comment on lines +485 to +506
const typeInfo = await detectPageTypes(page, options.defaultPageSize) ?? await detectMixedSizes(page);
let result: Buffer;
if (typeInfo != null) {
log.info(`Multi-pass mode: ${typeInfo.definitions.size} type(s), ${typeInfo.pageSizes.length} pages`);
await page.close();
result = await renderMultiPass(browser, html, typeInfo, log, debug, outputPath, debugTypography);
result = await renderMultiPass(
context, html, typeInfo, log, options.debug,
options.debugOutputPath, options.debugTypography,
);
} else {
log.info('Single-pass mode (no typed headers/footers)');
result = await renderSinglePass(browser, html, page, log, debug, landscape, defaultPageSize, outputPath, margins, debugTypography);
await page.close();
result = await renderSinglePass(
context, html, page, log, options.debug, options.landscape,
options.defaultPageSize, options.debugOutputPath, options.margins,
options.debugTypography,
);
}

if (debugTypography) {
result = Buffer.from(await appendDebugFontPage(result, fontSize, fontCombos));
if (options.debugTypography) {
result = Buffer.from(await appendDebugFontPage(result, options.fontSize, fontCombos));
}
result = await stampPDFMetadata(result);
writeFileSync(outputPath, result);
log.debug(`PDF saved to: ${outputPath}`);
return result;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail
git ls-files 'cli/src/utils/pdf-generator.ts' 'cli/src/utils/*' | sed -n '1,20p'
echo '--- outline ---'
ast-grep outline cli/src/utils/pdf-generator.ts --view expanded
echo '--- metadata refs ---'
rg -n "creator|producer|metadata|compositeHeaderFooter|renderSinglePass|page\.pdf|appendDebugFontPage" cli/src/utils/pdf-generator.ts

Repository: flanksource/facet

Length of output: 3310


🏁 Script executed:

set -euo pipefail
sed -n '1,260p' cli/src/utils/pdf-generator.ts | cat -n

Repository: flanksource/facet

Length of output: 11935


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('cli/src/utils/pdf-generator.ts')
text = p.read_text()
for needle in ['async function renderSinglePass', 'async function renderMultiPass', 'async function compositeHeaderFooter']:
    idx = text.find(needle)
    print(f'=== {needle} @ {idx} ===')
    if idx != -1:
        start = max(0, idx - 800)
        end = min(len(text), idx + 4000)
        snippet = text[start:end]
        for i, line in enumerate(snippet.splitlines(), 1):
            print(f'{i:4}: {line}')
        print()
PY

Repository: flanksource/facet

Length of output: 11247


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('cli/src/utils/pdf-generator.ts')
lines = p.read_text().splitlines()
targets = ['async function renderSinglePass', 'async function renderMultiPass', 'async function compositeHeaderFooter']
for t in targets:
    for i,l in enumerate(lines):
        if t in l:
            start = max(0, i-30)
            end = min(len(lines), i+140)
            print(f'\n### {t} at line {i+1}\n')
            for j in range(start, end):
                print(f'{j+1:4}: {lines[j]}')
            break
PY

Repository: flanksource/facet

Length of output: 16275


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('cli/src/utils/pdf-generator.ts')
lines = p.read_text().splitlines()
for start in [416, 460, 520]:
    end = min(len(lines), start+140)
    print(f'\n### lines {start}-{end}\n')
    for j in range(start-1, end):
        print(f'{j+1:4}: {lines[j]}')
PY

Repository: flanksource/facet

Length of output: 11888


🏁 Script executed:

set -euo pipefail
echo '--- outline pdf-multipass.ts ---'
ast-grep outline cli/src/utils/pdf-multipass.ts --view expanded
echo '--- metadata refs ---'
rg -n "creator|producer|setCreator|setProducer|PDFDocument.load|save\\(|compositeHeaderFooter" cli/src/utils/pdf-multipass.ts

Repository: flanksource/facet

Length of output: 4677


Keep PDF metadata on the zero-margin fast path.
renderSinglePass returns raw page.pdf() bytes when all margins are zero, so it skips the creator/producer stamping applied in compositeHeaderFooter. Apply the same metadata update before returning there. cli/src/utils/pdf-generator.ts:336-356

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/utils/pdf-generator.ts` around lines 485 - 506, Update the
zero-margin fast path inside renderSinglePass to apply the same creator/producer
PDF metadata stamping performed by compositeHeaderFooter before returning
page.pdf() bytes. Preserve the existing behavior for non-zero margins and ensure
the metadata update occurs before any subsequent debug typography processing in
the caller.

Comment thread cli/src/utils/tailwind.ts
Comment on lines +78 to +88
async function pruneTailwindCache(cacheDir: string): Promise<void> {
const maxEntries = Math.max(1, parseInt(process.env['FACET_TAILWIND_CACHE_ENTRIES'] ?? '50', 10));
const names = (await readdir(cacheDir)).filter((name) => name.endsWith('.css'));
if (names.length <= maxEntries) return;
const entries = await Promise.all(names.map(async (name) => {
const path = join(cacheDir, name);
return { path, mtimeMs: (await stat(path)).mtimeMs };
}));
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
await Promise.all(entries.slice(maxEntries).map((entry) => rm(entry.path, { force: true })));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded stat failure in pruneTailwindCache can discard freshly-generated CSS.

readdir/stat aren't wrapped in a try/catch, so a concurrent delete of a cache file (e.g. from a parallel prune by another process/request) throws here. Since pruneTailwindCache() is awaited before return css; in runTailwindCached (line 139), that exception propagates out and causes the just-generated CSS to be discarded — applyTailwind then silently falls back to non-cached CSS, wasting the Tailwind run. The sibling pruneWorkspaces() in cli/src/server/template-workspaces.ts guards the equivalent readdir+stat block with try { ... } catch { return; } for exactly this race.

🛠️ Proposed fix
 async function pruneTailwindCache(cacheDir: string): Promise<void> {
   const maxEntries = Math.max(1, parseInt(process.env['FACET_TAILWIND_CACHE_ENTRIES'] ?? '50', 10));
-  const names = (await readdir(cacheDir)).filter((name) => name.endsWith('.css'));
-  if (names.length <= maxEntries) return;
-  const entries = await Promise.all(names.map(async (name) => {
-    const path = join(cacheDir, name);
-    return { path, mtimeMs: (await stat(path)).mtimeMs };
-  }));
+  let entries;
+  try {
+    const names = (await readdir(cacheDir)).filter((name) => name.endsWith('.css'));
+    if (names.length <= maxEntries) return;
+    entries = await Promise.all(names.map(async (name) => {
+      const path = join(cacheDir, name);
+      return { path, mtimeMs: (await stat(path)).mtimeMs };
+    }));
+  } catch {
+    return;
+  }
   entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
   await Promise.all(entries.slice(maxEntries).map((entry) => rm(entry.path, { force: true })));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function pruneTailwindCache(cacheDir: string): Promise<void> {
const maxEntries = Math.max(1, parseInt(process.env['FACET_TAILWIND_CACHE_ENTRIES'] ?? '50', 10));
const names = (await readdir(cacheDir)).filter((name) => name.endsWith('.css'));
if (names.length <= maxEntries) return;
const entries = await Promise.all(names.map(async (name) => {
const path = join(cacheDir, name);
return { path, mtimeMs: (await stat(path)).mtimeMs };
}));
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
await Promise.all(entries.slice(maxEntries).map((entry) => rm(entry.path, { force: true })));
}
async function pruneTailwindCache(cacheDir: string): Promise<void> {
const maxEntries = Math.max(1, parseInt(process.env['FACET_TAILWIND_CACHE_ENTRIES'] ?? '50', 10));
let entries;
try {
const names = (await readdir(cacheDir)).filter((name) => name.endsWith('.css'));
if (names.length <= maxEntries) return;
entries = await Promise.all(names.map(async (name) => {
const path = join(cacheDir, name);
return { path, mtimeMs: (await stat(path)).mtimeMs };
}));
} catch {
return;
}
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
await Promise.all(entries.slice(maxEntries).map((entry) => rm(entry.path, { force: true })));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/utils/tailwind.ts` around lines 78 - 88, Guard the cache-directory
listing and file metadata collection in pruneTailwindCache with a try/catch,
returning without pruning when readdir or stat encounters a concurrent deletion
or other filesystem error. Keep the existing sorting and removal behavior for
successful reads, and ensure pruning failures do not propagate through
runTailwindCached.

Comment thread cli/src/utils/tailwind.ts
Comment on lines +107 to +153
export async function runTailwindCached(opts: CachedTailwindOptions): Promise<string> {
const classSet = renderedClassKey(opts.html);
const cacheDir = join(opts.facetRoot, 'tailwind-cache');
const cachePath = join(cacheDir, `${opts.buildCacheKey}-${classSet.key}.css`);
const existing = tailwindLocks.get(cachePath);
if (existing) return existing;

const operation = (async () => {
try {
return await readFile(cachePath, 'utf-8');
} catch { /* cache miss */ }

await mkdir(cacheDir, { recursive: true });
const nonce = `${process.pid}-${randomUUID()}`;
const contentPath = join(cacheDir, `${nonce}.html`);
const outputPath = join(cacheDir, `${nonce}.css`);
try {
await writeFile(contentPath, classSet.content, 'utf-8');
await runTailwind({
facetRoot: opts.facetRoot,
stylesInput: opts.stylesInput,
contentPath,
outputCssPath: outputPath,
verbose: opts.verbose,
});
const css = await readFile(outputPath, 'utf-8');
try {
await rename(outputPath, cachePath);
} catch {
// A separate process may have populated the same content-addressed key.
await rm(outputPath, { force: true });
}
await pruneTailwindCache(cacheDir);
return css;
} finally {
await rm(contentPath, { force: true });
await rm(outputPath, { force: true });
}
})();

tailwindLocks.set(cachePath, operation);
try {
return await operation;
} finally {
if (tailwindLocks.get(cachePath) === operation) tailwindLocks.delete(cachePath);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Cache hits don't bump mtime, so pruneTailwindCache's LRU sort is based on creation time, not last-use.

Line 116's readFile(cachePath, ...) on a cache hit never touches the file's mtime, so a frequently-reused entry can still look "old" relative to entries that were merely regenerated recently, and get evicted ahead of truly cold entries. RenderCache.lookup() (cli/src/server/render-cache.ts) and ensureWorkspace() (cli/src/server/template-workspaces.ts) both explicitly utimes/utimesSync the file on hit for this reason.

🛠️ Proposed fix
   const operation = (async () => {
     try {
-      return await readFile(cachePath, 'utf-8');
+      const css = await readFile(cachePath, 'utf-8');
+      const now = new Date();
+      await utimes(cachePath, now, now).catch(() => undefined);
+      return css;
     } catch { /* cache miss */ }

(Requires importing utimes from node:fs/promises.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/src/utils/tailwind.ts` around lines 107 - 153, Update the cache-hit path
in runTailwindCached to refresh cachePath’s access and modification timestamps
after successfully reading the cached CSS, using the node:fs/promises utimes
API; preserve the existing read-and-return behavior and allow timestamp refresh
errors to propagate consistently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant