Skip to content

Commit 8639c96

Browse files
authored
fix: harden render concurrency, cache lifecycle, worker limits, and benchmark portability (#64)
* fix: harden render concurrency, cache lifecycle, worker limits, and benchmark portability * chore: address review comments
1 parent b07e6f7 commit 8639c96

20 files changed

Lines changed: 482 additions & 164 deletions

cli/scripts/benchmark-render.mjs

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,22 @@
11
#!/usr/bin/env node
22
import { spawn } from 'node:child_process';
33
import { mkdir, rm } from 'node:fs/promises';
4-
import { readFileSync } from 'node:fs';
54
import { resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
66
import { performance } from 'node:perf_hooks';
7+
import { processTreeRss } from './lib/proc-rss.mjs';
78

8-
const cli = resolve('../dist/facet');
9-
const template = resolve(process.argv[2] ?? 'examples/SimpleReport.tsx');
10-
const data = resolve(process.argv[3] ?? 'examples/simple-data.json');
9+
const scriptDir = fileURLToPath(new URL('.', import.meta.url));
10+
const cli = resolve(scriptDir, '../../dist/facet');
11+
const template = process.argv[2]
12+
? resolve(process.argv[2])
13+
: resolve(scriptDir, '../examples/SimpleReport.tsx');
14+
const data = process.argv[3]
15+
? resolve(process.argv[3])
16+
: resolve(scriptDir, '../examples/simple-data.json');
1117
const output = resolve('.benchmark-output');
1218
const iterations = Math.max(1, Number(process.env.FACET_BENCH_ITERATIONS ?? 5));
1319

14-
function processTreeRss(pid, seen = new Set()) {
15-
if (process.platform !== 'linux' || seen.has(pid)) return 0;
16-
seen.add(pid);
17-
let rss = 0;
18-
try {
19-
rss = Number(readFileSync(`/proc/${pid}/statm`, 'utf8').trim().split(/\s+/)[1] ?? 0) * 4096;
20-
} catch { return 0; }
21-
try {
22-
const children = readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean);
23-
for (const child of children) rss += processTreeRss(Number(child), seen);
24-
} catch { /* process exited while sampling */ }
25-
return rss;
26-
}
27-
2820
function run(format, cold) {
2921
return new Promise((resolveRun, reject) => {
3022
const args = [cli, format, template, '--data', data, '--output', output];

cli/scripts/benchmark-server.mjs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
#!/usr/bin/env node
22
import { spawn } from 'node:child_process';
33
import { mkdtemp, rm } from 'node:fs/promises';
4-
import { readFileSync } from 'node:fs';
54
import { tmpdir } from 'node:os';
65
import { join, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
77
import { performance } from 'node:perf_hooks';
8+
import { processTreeRss } from './lib/proc-rss.mjs';
89

9-
const cli = resolve('../dist/facet');
10-
const templatesDir = resolve(process.argv[2] ?? 'examples');
10+
const scriptDir = fileURLToPath(new URL('.', import.meta.url));
11+
const cli = resolve(scriptDir, '../../dist/facet');
12+
const templatesDir = process.argv[2]
13+
? resolve(process.argv[2])
14+
: resolve(scriptDir, '../examples');
1115
const iterations = Math.max(1, Number(process.env.FACET_BENCH_ITERATIONS ?? 5));
1216
const sectionCount = Math.max(1, Number(process.env.FACET_BENCH_SECTIONS ?? 1));
1317
const templateName = process.env.FACET_BENCH_TEMPLATE ?? 'SimpleReport';
@@ -17,20 +21,6 @@ if (formats.length === 0) throw new Error('FACET_BENCH_FORMATS must contain html
1721
const port = Number(process.env.FACET_BENCH_PORT ?? 39123);
1822
const cacheDir = await mkdtemp(join(tmpdir(), 'facet-server-bench-'));
1923

20-
function processTreeRss(pid, seen = new Set()) {
21-
if (process.platform !== 'linux' || seen.has(pid)) return 0;
22-
seen.add(pid);
23-
let rss = 0;
24-
try {
25-
rss = Number(readFileSync(`/proc/${pid}/statm`, 'utf8').trim().split(/\s+/)[1] ?? 0) * 4096;
26-
} catch { return 0; }
27-
try {
28-
const children = readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim().split(/\s+/).filter(Boolean);
29-
for (const child of children) rss += processTreeRss(Number(child), seen);
30-
} catch { /* process exited while sampling */ }
31-
return rss;
32-
}
33-
3424
const server = spawn(cli, [
3525
'serve', '--port', String(port), '--templates-dir', templatesDir,
3626
'--workers', '1', '--cache-dir', cacheDir,
@@ -45,6 +35,7 @@ const sampler = setInterval(() => {
4535
peakRss = Math.max(peakRss, rss);
4636
requestPeakRss = Math.max(requestPeakRss, rss);
4737
}, 25);
38+
sampler.unref();
4839

4940
async function waitForServer() {
5041
const deadline = Date.now() + 30_000;

cli/scripts/lib/proc-rss.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { readFileSync } from 'node:fs';
2+
3+
export function processTreeRss(pid, seen = new Set()) {
4+
if (process.platform !== 'linux' || seen.has(pid)) return 0;
5+
seen.add(pid);
6+
let rss = 0;
7+
try {
8+
rss = Number(readFileSync(`/proc/${pid}/statm`, 'utf8').trim().split(/\s+/)[1] ?? 0) * 4096;
9+
} catch { return 0; }
10+
try {
11+
const children = readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8')
12+
.trim().split(/\s+/).filter(Boolean);
13+
for (const child of children) rss += processTreeRss(Number(child), seen);
14+
} catch { /* process exited while sampling */ }
15+
return rss;
16+
}

cli/src/builders/facet-directory.test.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ describe('FacetDirectory.generateViteConfig remark plugins', () => {
229229
describe('FacetDirectory.generatePackageJson .npmrc', () => {
230230
it('disables modules-purge confirmation so non-interactive pnpm runs do not abort', async () => {
231231
await writeFile(join(consumerRoot, 'package.json'), JSON.stringify({ name: 'consumer', version: '0.0.0' }));
232-
newFacetDir().generatePackageJson();
232+
await newFacetDir().generatePackageJson();
233233
const npmrc = await readFile(join(facetRoot, '.npmrc'), 'utf-8');
234234
expect(npmrc).toContain('confirm-modules-purge=false');
235235
});
@@ -260,7 +260,7 @@ describe('FACET_PACKAGE_PATH local directory override', () => {
260260
const localRoot = await writeLocalFacetPackage();
261261
process.env.FACET_PACKAGE_PATH = localRoot;
262262

263-
newFacetDir().generatePackageJson();
263+
await newFacetDir().generatePackageJson();
264264

265265
const generated = JSON.parse(await readFile(join(facetRoot, 'package.json'), 'utf-8'));
266266
expect(generated.dependencies['@flanksource/facet']).toBe(`file:${localRoot}`);
@@ -277,7 +277,7 @@ describe('FACET_PACKAGE_PATH local directory override', () => {
277277
await writeFile(tarball, 'fake tarball');
278278
process.env.FACET_PACKAGE_PATH = tarball;
279279

280-
newFacetDir().generatePackageJson();
280+
await newFacetDir().generatePackageJson();
281281

282282
const generated = JSON.parse(await readFile(join(facetRoot, 'package.json'), 'utf-8'));
283283
expect(generated.dependencies['@flanksource/facet']).toBe(`file:${tarball}`);
@@ -322,20 +322,43 @@ describe('FACET_PACKAGE_PATH local directory override', () => {
322322
expect(needsLocalFacetCssBuild(localRoot)).toBe(true);
323323
});
324324

325+
it('waits for a local build lock without blocking the event loop', async () => {
326+
const localRoot = await writeLocalFacetPackage();
327+
process.env.FACET_PACKAGE_PATH = localRoot;
328+
const future = new Date(Date.now() + 120_000);
329+
utimesSync(join(localRoot, 'src/components/index.tsx'), future, future);
330+
const lockPath = join(localRoot, '.facet-local-build.lock');
331+
await writeFile(lockPath, 'other-process\n');
332+
let timerRan = false;
333+
const timer = setTimeout(() => {
334+
timerRan = true;
335+
void rm(lockPath, { force: true });
336+
}, 25);
337+
338+
try {
339+
await newFacetDir().generatePackageJson();
340+
} finally {
341+
clearTimeout(timer);
342+
}
343+
344+
expect(timerRan).toBe(true);
345+
expect(existsSync(lockPath)).toBe(false);
346+
});
347+
325348
it('removes a stale installed local package copy when package.json is unchanged', async () => {
326349
const localRoot = await writeLocalFacetPackage();
327350
process.env.FACET_PACKAGE_PATH = localRoot;
328351
const facetDir = newFacetDir();
329352

330-
facetDir.generatePackageJson();
353+
await facetDir.generatePackageJson();
331354

332355
const installedRoot = join(facetRoot, 'node_modules/@flanksource/facet');
333356
await mkdir(join(installedRoot, 'dist/components'), { recursive: true });
334357
await writeFile(join(installedRoot, 'package.json'), await readFile(join(localRoot, 'package.json'), 'utf-8'));
335358
await writeFile(join(installedRoot, 'dist/components/index.js'), 'export const Marker = "stale";\n');
336359
await writeFile(join(facetRoot, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n');
337360

338-
facetDir.generatePackageJson();
361+
await facetDir.generatePackageJson();
339362

340363
expect(existsSync(installedRoot)).toBe(false);
341364
expect(existsSync(join(facetRoot, 'pnpm-lock.yaml'))).toBe(false);

cli/src/builders/facet-directory.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -690,12 +690,12 @@ export default defineConfig({
690690
* Generate package.json with all required build dependencies
691691
* Reads versions from embedded root-package.json and merges with consumer's dependencies
692692
*/
693-
generatePackageJson(): void {
693+
async generatePackageJson(): Promise<void> {
694694
this.logger.debug('Generating package.json');
695695

696696
const facetOverride = resolveFacetPackageOverride();
697697
if (facetOverride?.kind === 'directory') {
698-
this.ensureLocalFacetPackageBuilt(facetOverride.path);
698+
await this.ensureLocalFacetPackageBuilt(facetOverride.path);
699699
}
700700

701701
let dependencies: Record<string, string> = {};
@@ -1003,15 +1003,15 @@ export default defineConfig({
10031003
return readFileSync(embeddedPath, 'utf-8');
10041004
}
10051005

1006-
private ensureLocalFacetPackageBuilt(packageRoot: string): void {
1006+
private async ensureLocalFacetPackageBuilt(packageRoot: string): Promise<void> {
10071007
const isCurrent = (): boolean =>
10081008
!needsLocalFacetComponentsBuild(packageRoot) && !needsLocalFacetCssBuild(packageRoot);
10091009
if (isCurrent()) {
10101010
this.logger.debug(`FACET_PACKAGE_PATH build outputs are current: ${packageRoot}`);
10111011
return;
10121012
}
10131013

1014-
this.withLocalFacetBuildLock(packageRoot, () => {
1014+
await this.withLocalFacetBuildLock(packageRoot, () => {
10151015
// Another process may have completed the build while this process waited.
10161016
if (isCurrent()) return;
10171017
const shouldBuildCss = needsLocalFacetCssBuild(packageRoot);
@@ -1026,14 +1026,17 @@ export default defineConfig({
10261026
});
10271027
}
10281028

1029-
private withLocalFacetBuildLock(packageRoot: string, action: () => void): void {
1029+
private async withLocalFacetBuildLock(
1030+
packageRoot: string,
1031+
action: () => void | Promise<void>,
1032+
): Promise<void> {
10301033
const lockPath = join(packageRoot, '.facet-local-build.lock');
10311034
const deadline = Date.now() + LOCAL_BUILD_TIMEOUT_MS;
10321035
let fd: number | undefined;
10331036
while (fd === undefined) {
1037+
let acquiredFd: number;
10341038
try {
1035-
fd = openSync(lockPath, 'wx');
1036-
writeFileSync(fd, `${process.pid}\n`);
1039+
acquiredFd = openSync(lockPath, 'wx');
10371040
} catch (error) {
10381041
const code = (error as NodeJS.ErrnoException).code;
10391042
if (code !== 'EEXIST') throw error;
@@ -1042,15 +1045,31 @@ export default defineConfig({
10421045
unlinkSync(lockPath);
10431046
continue;
10441047
}
1045-
} catch { continue; }
1048+
} catch {
1049+
if (Date.now() >= deadline) {
1050+
throw new Error(`Timed out waiting for local Facet build lock: ${lockPath}`);
1051+
}
1052+
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 100));
1053+
continue;
1054+
}
10461055
if (Date.now() >= deadline) {
10471056
throw new Error(`Timed out waiting for local Facet build lock: ${lockPath}`);
10481057
}
1049-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
1058+
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 100));
1059+
continue;
1060+
}
1061+
1062+
try {
1063+
writeFileSync(acquiredFd, `${process.pid}\n`);
1064+
fd = acquiredFd;
1065+
} catch (error) {
1066+
try { closeSync(acquiredFd); } catch { /* preserve the write error */ }
1067+
try { unlinkSync(lockPath); } catch { /* preserve the write error */ }
1068+
throw error;
10501069
}
10511070
}
10521071
try {
1053-
action();
1072+
await action();
10541073
} finally {
10551074
closeSync(fd);
10561075
try { unlinkSync(lockPath); } catch { /* already cleaned up */ }

cli/src/bundler/build-cache.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,48 +19,77 @@ function extension(name: string): string {
1919
return index < 0 ? '' : name.slice(index).toLowerCase();
2020
}
2121

22+
interface TreeDigestCacheEntry {
23+
metadataDigest: string;
24+
contentDigest: string;
25+
}
26+
27+
const treeDigestCache = new Map<string, TreeDigestCacheEntry>();
28+
2229
/** Hash template sources and build metadata, intentionally excluding render data. */
2330
export function computeTemplateBuildKey(
2431
consumerRoot: string,
2532
facetVersion: string,
2633
templatePath?: string,
2734
): string {
28-
const hash = createHash('sha256');
29-
hash.update(`facet:${facetVersion}\0`);
30-
if (templatePath) {
31-
hash.update(`entry:${templatePath}\0`);
32-
// Content-addressed generated fragments are excluded from the general tree
33-
// to avoid invalidating the main template, so hash the selected entry here.
34-
try {
35-
hash.update(readFileSync(join(consumerRoot, templatePath)));
36-
hash.update('\0');
37-
} catch { /* the normal source traversal will report/build missing entries */ }
38-
}
3935
const files: string[] = [];
40-
4136
const visit = (dir: string): void => {
4237
for (const entry of readdirSync(dir, { withFileTypes: true })) {
4338
if (entry.isSymbolicLink()) continue;
4439
if (entry.isDirectory()) {
4540
if (!EXCLUDED_DIRS.has(entry.name)) visit(join(dir, entry.name));
4641
continue;
4742
}
48-
if (!entry.isFile()) continue;
49-
if (INCLUDED_METADATA.has(entry.name) || SOURCE_EXTENSIONS.has(extension(entry.name))) {
43+
if (entry.isFile() && (INCLUDED_METADATA.has(entry.name) || SOURCE_EXTENSIONS.has(extension(entry.name)))) {
5044
files.push(join(dir, entry.name));
5145
}
5246
}
5347
};
5448

5549
visit(consumerRoot);
5650
files.sort();
51+
const selectedEntry = templatePath ? join(consumerRoot, templatePath) : undefined;
52+
const metadataHash = createHash('sha256');
53+
if (selectedEntry) {
54+
try {
55+
const stats = statSync(selectedEntry, { bigint: true });
56+
metadataHash.update(`entry:${templatePath}\0${stats.size}:${stats.mtimeNs}\0`);
57+
} catch { /* the normal source traversal will report/build missing entries */ }
58+
}
5759
for (const file of files) {
58-
hash.update(relative(consumerRoot, file));
59-
hash.update('\0');
60-
hash.update(readFileSync(file));
61-
hash.update('\0');
60+
const stats = statSync(file, { bigint: true });
61+
metadataHash.update(relative(consumerRoot, file));
62+
metadataHash.update(`\0${stats.size}:${stats.mtimeNs}\0`);
6263
}
63-
return hash.digest('hex').slice(0, 24);
64+
65+
const cacheKey = `${consumerRoot}\0${templatePath ?? ''}`;
66+
const metadataDigest = metadataHash.digest('hex');
67+
let contentDigest = treeDigestCache.get(cacheKey)?.metadataDigest === metadataDigest
68+
? treeDigestCache.get(cacheKey)!.contentDigest
69+
: undefined;
70+
if (!contentDigest) {
71+
const contentHash = createHash('sha256');
72+
if (selectedEntry) {
73+
try {
74+
contentHash.update(`entry:${templatePath}\0`);
75+
contentHash.update(readFileSync(selectedEntry));
76+
contentHash.update('\0');
77+
} catch { /* the normal source traversal will report/build missing entries */ }
78+
}
79+
for (const file of files) {
80+
contentHash.update(relative(consumerRoot, file));
81+
contentHash.update('\0');
82+
contentHash.update(readFileSync(file));
83+
contentHash.update('\0');
84+
}
85+
contentDigest = contentHash.digest('hex');
86+
treeDigestCache.set(cacheKey, { metadataDigest, contentDigest });
87+
if (treeDigestCache.size > 100) treeDigestCache.delete(treeDigestCache.keys().next().value!);
88+
}
89+
90+
return createHash('sha256')
91+
.update(`facet:${facetVersion}\0entry:${templatePath ?? ''}\0${contentDigest}`)
92+
.digest('hex').slice(0, 24);
6493
}
6594

6695
/** Keep the newest cache entries within a configurable count. */

0 commit comments

Comments
 (0)