ucn 5.3.3 → 5.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ answer, a notice stating the full size and limit, and the accounting/contract
39
39
  lines needed to interpret what remains; the requested limit includes all
40
40
  three. The text block is the whole response on every surface.
41
41
 
42
- Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use.
42
+ Persistent indexes live in a per-user, project-keyed cache rather than the analyzed repository. Set `UCN_CACHE_DIR` to override the cache root; CLI `--no-cache` bypasses persistence and `--clear-cache` removes the current project's cache. Legacy `<project>/.ucn-cache` directories are migrated on first use. Concurrent invocations on a cold cache share one build: the first process takes a build lock, the others wait for its cache (`Waiting for another ucn process building the index...` on stderr) instead of rebuilding; a lock left by a dead process is broken automatically.
43
43
 
44
44
  Supported source families are JavaScript/TypeScript/TSX, Python, Go, Rust, Java, C, C++, C#, and HTML inline JavaScript/event handlers. C/C++ uses `compile_commands.json` when available to classify headers and resolve include paths. Recoverable preprocessor branches contribute AST-proven source facts, so a single selected configuration does not silently erase definitions or calls; disagreeing conditional macro identities stay visible as unverified. C++ resolution uses namespace ownership, static overload shape (including arrays), and macro-parameter requalification. C# resolution uses declared property/field receiver types plus overload and hiding discipline. This is portable AST analysis, not a compiler build; macros, templates, generated code, reflection, and external dependency semantics can remain unverified.
45
45
 
package/cli/index.js CHANGED
@@ -766,8 +766,18 @@ function runProjectCommand(rootDir, command, arg) {
766
766
  // If cache was loaded but stale, force rebuild to avoid duplicates
767
767
  let needsCacheSave = false;
768
768
  if (!usedCache) {
769
- index.build(null, { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles, workers: flags.workers });
770
- needsCacheSave = flags.cache;
769
+ const buildOpts = { quiet: flags.quiet, forceRebuild: cacheWasLoaded, followSymlinks: flags.followSymlinks, maxFiles: flags.maxFiles, workers: flags.workers };
770
+ if (flags.cache && !flags.maxFiles) {
771
+ // Cross-process build lock (fix #354): concurrent cold-cache
772
+ // invocations share one build instead of each rebuilding.
773
+ index.buildCached(buildOpts, {
774
+ onWait: () => { if (!flags.quiet) console.error('Waiting for another ucn process building the index...'); },
775
+ });
776
+ needsCacheSave = false; // buildCached saved (or loaded the other process's cache)
777
+ } else {
778
+ index.build(null, buildOpts);
779
+ needsCacheSave = flags.cache;
780
+ }
771
781
  }
772
782
 
773
783
  try {
@@ -986,13 +996,10 @@ function runInteractive(rootDir) {
986
996
  if (flags.cache) {
987
997
  const loaded = !flags.clearCache && index.loadCache();
988
998
  iCacheFresh = loaded && !index.isCacheStale();
989
- if (!iCacheFresh && loaded) {
990
- index.build(null, { quiet: true, forceRebuild: true, workers: flags.workers });
991
- } else if (!iCacheFresh) {
992
- index.build(null, { quiet: true, workers: flags.workers });
993
- }
994
999
  if (!iCacheFresh) {
995
- try { index.saveCache(); } catch (_) { /* best-effort */ }
1000
+ index.buildCached({ quiet: true, forceRebuild: !!loaded, workers: flags.workers }, {
1001
+ onWait: () => console.log('Waiting for another ucn process building the index...'),
1002
+ });
996
1003
  }
997
1004
  } else {
998
1005
  index.build(null, { quiet: true, workers: flags.workers });
@@ -1113,15 +1120,19 @@ Flags can be added per-command: show myFunc --sections=source,callers
1113
1120
  // appeared in neighbouring answers (UCN5-044).
1114
1121
  if (index.isCacheStale()) {
1115
1122
  console.log('Source changed; rebuilding index...');
1116
- index.build(null, {
1123
+ const rebuildOpts = {
1117
1124
  quiet: true,
1118
1125
  forceRebuild: true,
1119
1126
  followSymlinks: flags.followSymlinks,
1120
1127
  maxFiles: flags.maxFiles,
1121
1128
  workers: flags.workers,
1122
- });
1123
- if (flags.cache) {
1124
- try { index.saveCache(); } catch (_) { /* best-effort */ }
1129
+ };
1130
+ if (flags.cache && !flags.maxFiles) {
1131
+ index.buildCached(rebuildOpts, {
1132
+ onWait: () => console.log('Waiting for another ucn process building the index...'),
1133
+ });
1134
+ } else {
1135
+ index.build(null, rebuildOpts);
1125
1136
  }
1126
1137
  console.log(`Index ready: ${index.files.size} files, ${index.symbols.size} unique symbol names`);
1127
1138
  }
package/core/cache.js CHANGED
@@ -256,6 +256,14 @@ function pruneUserCache({ force = false, now = Date.now() } = {}) {
256
256
  root = data.root || null;
257
257
  touched = Math.max(touched, Number(data.timestamp) || 0);
258
258
  } catch (_) { /* malformed/incomplete cache expires below */ }
259
+ // fix #354: a project whose FIRST build is in progress has no
260
+ // index.json yet, only a live build.lock — another process's prune
261
+ // must not delete the lock (or the dir) out from under the builder.
262
+ const lockPath = path.join(dir, BUILD_LOCK_FILE);
263
+ if (!root && fs.existsSync(lockPath) && !_lockIsStale(lockPath)) {
264
+ entries.push({ dir, touched: now, bytes: null });
265
+ continue;
266
+ }
259
267
  if (!root || !fs.existsSync(root) || now - touched > CACHE_TTL_MS) {
260
268
  try {
261
269
  fs.rmSync(dir, { recursive: true, force: true });
@@ -1494,7 +1502,101 @@ function _computeReachabilityFingerprint(index) {
1494
1502
  return `${fileCount}:${symbolCount}:${sample}`;
1495
1503
  }
1496
1504
 
1505
+ const BUILD_LOCK_FILE = 'build.lock';
1506
+ const BUILD_LOCK_STALE_MS = 15 * 60 * 1000;
1507
+ const BUILD_LOCK_WAIT_MS = 15 * 60 * 1000;
1508
+ const BUILD_LOCK_POLL_MS = 150;
1509
+
1510
+ function _sleepSync(ms) {
1511
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1512
+ }
1513
+
1514
+ function _pidAlive(pid) {
1515
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1516
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
1517
+ }
1518
+
1519
+ function _readLock(lockPath) {
1520
+ try { return JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch (_) { return null; }
1521
+ }
1522
+
1523
+ function _lockIsStale(lockPath) {
1524
+ const info = _readLock(lockPath);
1525
+ if (!info) {
1526
+ // Unreadable or half-written: judge by mtime alone
1527
+ try { return Date.now() - fs.statSync(lockPath).mtimeMs > BUILD_LOCK_STALE_MS; } catch (_) { return true; }
1528
+ }
1529
+ if (info.pid && !_pidAlive(info.pid)) return true;
1530
+ return Date.now() - (info.startedAt || 0) > BUILD_LOCK_STALE_MS;
1531
+ }
1532
+
1533
+ /**
1534
+ * Cross-process build lock (fix #354): N concurrent cold-cache invocations
1535
+ * used to rebuild the whole index N times at once (6 processes on a 474k-line
1536
+ * repo: 9s each alone, 363s each together — the stampede fires after every
1537
+ * version bump, because the bump invalidates every cache). One process takes
1538
+ * the lock and builds; the others wait, then load the cache it saved. A lock
1539
+ * whose holder is dead, or older than 15 minutes, is broken. Waiters give up
1540
+ * after 15 minutes and build themselves (a wedged holder must never make the
1541
+ * tool hang forever).
1542
+ *
1543
+ * @returns {{ built: boolean, waited: boolean }}
1544
+ */
1545
+ function buildWithLock(index, buildOpts = {}, options = {}) {
1546
+ const cacheDir = getProjectCacheDir(index.root);
1547
+ const lockPath = path.join(cacheDir, BUILD_LOCK_FILE);
1548
+ fs.mkdirSync(cacheDir, { recursive: true });
1549
+ const deadline = Date.now() + BUILD_LOCK_WAIT_MS;
1550
+ let announced = false;
1551
+ for (;;) {
1552
+ let fd = null;
1553
+ try {
1554
+ fd = fs.openSync(lockPath, 'wx');
1555
+ } catch (e) {
1556
+ if (e.code !== 'EEXIST') throw e;
1557
+ }
1558
+ if (fd !== null) {
1559
+ try {
1560
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
1561
+ fs.closeSync(fd);
1562
+ index.build(null, buildOpts);
1563
+ if (options.save !== false) {
1564
+ try { saveCache(index); } catch (_) { /* best-effort */ }
1565
+ }
1566
+ return { built: true, waited: announced };
1567
+ } finally {
1568
+ try { fs.unlinkSync(lockPath); } catch (_) { /* already gone */ }
1569
+ }
1570
+ }
1571
+ if (_lockIsStale(lockPath)) {
1572
+ try { fs.unlinkSync(lockPath); } catch (_) { /* raced */ }
1573
+ continue;
1574
+ }
1575
+ if (Date.now() > deadline) {
1576
+ index.build(null, buildOpts);
1577
+ if (options.save !== false) {
1578
+ try { saveCache(index); } catch (_) { /* best-effort */ }
1579
+ }
1580
+ return { built: true, waited: true };
1581
+ }
1582
+ if (!announced) {
1583
+ announced = true;
1584
+ if (typeof options.onWait === 'function') options.onWait(_readLock(lockPath));
1585
+ }
1586
+ _sleepSync(BUILD_LOCK_POLL_MS);
1587
+ if (!fs.existsSync(lockPath)) {
1588
+ // The holder finished: its cache is the answer unless it is
1589
+ // already stale again (or it never saved one)
1590
+ if (options.reload !== false && loadCache(index) && !isCacheStale(index)) {
1591
+ return { built: false, waited: true };
1592
+ }
1593
+ // fall through: take the lock ourselves
1594
+ }
1595
+ }
1596
+ }
1597
+
1497
1598
  module.exports = {
1599
+ buildWithLock,
1498
1600
  saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
1499
1601
  getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
1500
1602
  getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
package/core/project.js CHANGED
@@ -2529,6 +2529,15 @@ class ProjectIndex {
2529
2529
  saveCache(cachePath) { return indexCache.saveCache(this, cachePath); }
2530
2530
 
2531
2531
  /** Load index from cache file */
2532
+ /**
2533
+ * Build under the cross-process build lock (fix #354) and save the cache.
2534
+ * Returns { built, waited }; when another process built meanwhile and its
2535
+ * cache is fresh, that cache is loaded instead of rebuilding.
2536
+ */
2537
+ buildCached(buildOpts = {}, options = {}) {
2538
+ return indexCache.buildWithLock(this, buildOpts, options);
2539
+ }
2540
+
2532
2541
  loadCache(cachePath) { return indexCache.loadCache(this, cachePath); }
2533
2542
 
2534
2543
  /** Persist the bounded, content-hash-keyed usage-query cache. */
package/mcp/server.js CHANGED
@@ -71,8 +71,13 @@ function getIndex(projectDir, options) {
71
71
  // Disk cache is fresh (skip when maxFiles is set — cached index may have different file count)
72
72
  } else {
73
73
  buildOpts.forceRebuild = !!loaded;
74
- index.build(null, buildOpts);
75
- if (!maxFiles) index.saveCache(); // Don't pollute disk cache with partial indexes
74
+ if (maxFiles) {
75
+ index.build(null, buildOpts); // Don't pollute disk cache with partial indexes
76
+ } else {
77
+ // Cross-process build lock (fix #354): a CLI or another MCP
78
+ // server building the same repo at the same moment shares one build
79
+ index.buildCached(buildOpts);
80
+ }
76
81
  }
77
82
 
78
83
  // LRU eviction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "5.3.3",
3
+ "version": "5.3.4",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
6
6
  "main": "index.js",