From f243930b47dfaa3ca5672c869fcc4ab01a91324b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 22 May 2026 15:16:52 +0300 Subject: [PATCH 001/114] feat: Clean cache command --- packages/cli/lib/cli/commands/cache.js | 78 +++++++++ packages/cli/test/lib/cli/commands/cache.js | 81 +++++++++ packages/project/lib/cache/CacheCleanup.js | 165 ++++++++++++++++++ packages/project/package.json | 1 + .../project/test/lib/cache/CacheCleanup.js | 109 ++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 packages/cli/lib/cli/commands/cache.js create mode 100644 packages/cli/test/lib/cli/commands/cache.js create mode 100644 packages/project/lib/cache/CacheCleanup.js create mode 100644 packages/project/test/lib/cache/CacheCleanup.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js new file mode 100644 index 00000000000..ae4ca03f61c --- /dev/null +++ b/packages/cli/lib/cli/commands/cache.js @@ -0,0 +1,78 @@ +import chalk from "chalk"; +import path from "node:path"; +import os from "node:os"; +import process from "node:process"; +import baseMiddleware from "../middlewares/base.js"; +import Configuration from "@ui5/project/config/Configuration"; +import {cleanCache} from "@ui5/project/cache/CacheCleanup"; + +const cacheCommand = { + command: "cache", + describe: "Manage UI5 CLI cache", + middlewares: [baseMiddleware], + handler: handleCache +}; + +cacheCommand.builder = function(cli) { + return cli + .demandCommand(1, "Command required. Available command is 'clean'") + .command("clean", "Remove all cached UI5 data", { + handler: handleCache, + builder: noop, + middlewares: [baseMiddleware], + }) + .example("$0 cache clean", + "Remove all cached UI5 data"); +}; + +function noop() {} + +/** + * Format a byte size as a human-readable string. + * + * @param {number} bytes Size in bytes + * @returns {string} Formatted size string + */ +function formatSize(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +async function handleCache() { + // Resolve UI5 data directory + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + ui5DataDir = path.resolve(process.cwd(), ui5DataDir); + } else { + ui5DataDir = path.join(os.homedir(), ".ui5"); + } + + const result = await cleanCache({ui5DataDir}); + + if (result.totalCount === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } + + for (const entry of result.entries) { + const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; + process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + } + + process.stderr.write( + `\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + + (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" + ); +} + +export default cacheCommand; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js new file mode 100644 index 00000000000..53fb40d1a22 --- /dev/null +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -0,0 +1,81 @@ +import test from "ava"; +import sinon from "sinon"; +import esmock from "esmock"; +import Configuration from "@ui5/project/config/Configuration"; + +function getDefaultArgv() { + return { + "_": ["cache", "clean"], + "loglevel": "info", + "log-level": "info", + "logLevel": "info", + "perf": false, + "silent": false, + "$0": "ui5" + }; +} + +test.beforeEach(async (t) => { + t.context.argv = getDefaultArgv(); + + t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); + + t.context.Configuration = Configuration; + sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); + + t.context.cleanCacheStub = sinon.stub(); + + t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { + "@ui5/project/config/Configuration": t.context.Configuration, + "@ui5/project/cache/CacheCleanup": { + cleanCache: t.context.cleanCacheStub, + }, + }); +}); + +test.afterEach.always((t) => { + sinon.restore(); + esmock.purge(t.context.cache); +}); + +test.serial("ui5 cache clean: nothing to clean", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + + cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); +}); + +test.serial("ui5 cache clean: removes entries and reports", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + + cleanCacheStub.resolves({ + entries: [ + {path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024}, + {path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024}, + ], + totalSize: 23 * 1024 * 1024, + totalCount: 2, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // Should have 4 writes: 2 entries + 1 newline + summary + t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr"); + // Check that summary mentions entries count + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); + t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size"); +}); + +test("Command definition is correct", (t) => { + // Import without esmock for structure check + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..63b243c5535 --- /dev/null +++ b/packages/project/lib/cache/CacheCleanup.js @@ -0,0 +1,165 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {DatabaseSync} from "node:sqlite"; + +/** + * Get the size of a directory tree recursively. + * + * @param {string} dirPath Absolute path to directory + * @returns {Promise} Total size in bytes + */ +async function getDirectorySize(dirPath) { + let total = 0; + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return 0; + } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + total += await getDirectorySize(entryPath); + } else { + try { + const stat = await fs.stat(entryPath); + total += stat.size; + } catch { + // Skip inaccessible files + } + } + } + return total; +} + +/** + * Clean a single directory by removing it entirely. + * + * @param {string} dirPath Absolute path to directory + * @param {string} displayPath Path to display in results + * @param {string} type Type of cache entry + * @returns {Promise>} Removed entries + */ +async function cleanDirectory(dirPath, displayPath, type) { + const removed = []; + try { + await fs.access(dirPath); + } catch { + return removed; + } + + const size = await getDirectorySize(dirPath); + try { + await fs.rm(dirPath, {recursive: true, force: true}); + removed.push({path: displayPath, type, size}); + } catch { + // Skip on failure + } + return removed; +} + +/** + * Clean build cache directory by clearing all records from the SQLite database. + * + * @param {string} buildCacheDir Path to buildCache/ + * @returns {Promise>} Removed entries + */ +async function cleanBuildCache(buildCacheDir) { + const removed = []; + try { + await fs.access(buildCacheDir); + } catch { + return removed; + } + + let versionDirs; + try { + versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + } catch { + return removed; + } + + const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db"); + try { + await fs.access(dbPath); + } catch { + continue; + } + + const statBefore = await fs.stat(dbPath); + const sizeBefore = statBefore.size; + + const db = new DatabaseSync(dbPath); + db.exec("BEGIN"); + for (const table of tables) { + db.exec(`DELETE FROM ${table}`); + } + db.exec("COMMIT"); + db.exec("VACUUM"); + db.close(); + + const statAfter = await fs.stat(dbPath); + const freedSize = sizeBefore - statAfter.size; + + removed.push({ + path: `buildCache/${versionDir.name}`, + type: "buildCache", + size: freedSize, + }); + } + + return removed; +} + +/** + * Scans the UI5 data directory and removes all cache entries. + * + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{entries: Array<{path: string, type: string, size: number}>, + * totalSize: number, totalCount: number}>} + */ +export async function cleanCache({ui5DataDir}) { + const allRemoved = []; + + // Clean framework packages + allRemoved.push(...await cleanDirectory( + path.join(ui5DataDir, "framework", "packages"), + "framework/packages", + "framework" + )); + + // Clean cacache + allRemoved.push(...await cleanDirectory( + path.join(ui5DataDir, "framework", "cacache"), + "framework/cacache", + "cacache" + )); + + // Clean build cache (special: clears DB records, not files) + allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + + // Clean misc dirs + const miscDirs = [ + ["framework/staging", "staging"], + ["framework/locks", "locks"], + ["server", "server"], + ]; + for (const [rel, type] of miscDirs) { + allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type)); + } + + const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); + return { + entries: allRemoved, + totalSize, + totalCount: allRemoved.length, + }; +} diff --git a/packages/project/package.json b/packages/project/package.json index 313bc5db619..802e2cd9685 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,6 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", + "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js new file mode 100644 index 00000000000..04c0ea3e208 --- /dev/null +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -0,0 +1,109 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import {rimraf} from "rimraf"; +import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); + +test.after.always(async () => { + await rimraf(TEST_DIR).catch(() => {}); +}); + +/** + * Create a unique test directory for each test. + * + * @param {object} t AVA test context + * @returns {string} Path to the ui5DataDir fixture + */ +function createTestDir(t) { + const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + t.context.ui5DataDir = dir; + return dir; +} + +/** + * Create a framework package fixture. + * + * @param {string} ui5DataDir Base data directory + * @param {string} scope Package scope (e.g., "@openui5") + * @param {string} name Package name (e.g., "sap.ui.core") + * @param {string} version Version string + * @param {object} [options] + * @param {Date} [options.mtime] Custom mtime for the package file + * @returns {Promise} + */ +async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { + const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); + await fs.mkdir(pkgDir, {recursive: true}); + const filePath = path.join(pkgDir, "package.json"); + await fs.writeFile(filePath, JSON.stringify({name: `${scope}/${name}`, version})); + if (mtime) { + await fs.utimes(filePath, mtime, mtime); + } +} + +// ===== cleanCache: empty/nonexistent dir ===== + +test("cleanCache: returns empty result for nonexistent directory", async (t) => { + const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); + t.is(result.totalCount, 0); + t.is(result.totalSize, 0); + t.deepEqual(result.entries, []); +}); + +// ===== cleanCache: clean all ===== + +test("cleanCache: clean all removes framework packages", async (t) => { + const ui5DataDir = createTestDir(t); + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + await createPackage(ui5DataDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache({ui5DataDir}); + + t.true(result.totalCount >= 1); + const frameworkEntries = result.entries.filter((e) => e.type === "framework"); + t.is(frameworkEntries.length, 1); + t.is(frameworkEntries[0].path, "framework/packages"); +}); + +// ===== cleanCache: build cache (full clean) ===== + +test("cleanCache: clean all clears buildCache database", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + // Create a real SQLite database with tables and some data + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); + db.exec("INSERT INTO index_cache VALUES ('proj', 'sig', 'source', 'data')"); + db.close(); + + const result = await cleanCache({ui5DataDir}); + + const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); + t.truthy(buildCacheEntry); + + // Verify directory and DB file still exist + await fs.access(buildCacheDir); + await fs.access(dbPath); + + // Verify tables are empty + const dbAfter = new DatabaseSync(dbPath); + const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; + const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; + t.is(contentCount, 0); + t.is(indexCount, 0); + dbAfter.close(); +}); From 12ff67a641eaaf3029848edacd5ce8e491cc9429 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 11:48:58 +0300 Subject: [PATCH 002/114] refactor: Use single place for DB manipulation --- .../lib/build/cache/BuildCacheStorage.js | 26 ++++++++++++++++++ packages/project/lib/cache/CacheCleanup.js | 27 ++++--------------- packages/project/test/lib/package-exports.js | 2 +- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index fc91a486888..d89ced19f66 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -511,6 +511,32 @@ export default class BuildCacheStorage { return new Set(rows.map((row) => row.integrity)); } + /** + * Clears all records from all tables and runs VACUUM. + * Returns the number of bytes freed. + * + * @returns {number} Number of bytes freed + */ + clearAllRecords() { + const {page_count: pageCountBefore} = this.#db.prepare("PRAGMA page_count").get(); + const {page_size: pageSize} = this.#db.prepare("PRAGMA page_size").get(); + const bytesBefore = pageCountBefore * pageSize; + + this.#db.exec("BEGIN"); + this.#db.exec("DELETE FROM content"); + this.#db.exec("DELETE FROM index_cache"); + this.#db.exec("DELETE FROM stage_metadata"); + this.#db.exec("DELETE FROM task_metadata"); + this.#db.exec("DELETE FROM result_metadata"); + this.#db.exec("COMMIT"); + this.#db.exec("VACUUM"); + + const {page_count: pageCountAfter} = this.#db.prepare("PRAGMA page_count").get(); + const bytesAfter = pageCountAfter * pageSize; + + return bytesBefore - bytesAfter; + } + /** * Closes the database connection */ diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 63b243c5535..7e94f6a2521 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; -import {DatabaseSync} from "node:sqlite"; +import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; /** * Get the size of a directory tree recursively. @@ -79,34 +79,17 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; for (const versionDir of versionDirs) { if (!versionDir.isDirectory()) { continue; } - const dbPath = path.join(buildCacheDir, versionDir.name, "cache.db"); - try { - await fs.access(dbPath); - } catch { - continue; - } - - const statBefore = await fs.stat(dbPath); - const sizeBefore = statBefore.size; - - const db = new DatabaseSync(dbPath); - db.exec("BEGIN"); - for (const table of tables) { - db.exec(`DELETE FROM ${table}`); - } - db.exec("COMMIT"); - db.exec("VACUUM"); - db.close(); + const dbDir = path.join(buildCacheDir, versionDir.name); - const statAfter = await fs.stat(dbPath); - const freedSize = sizeBefore - statAfter.size; + const storage = new BuildCacheStorage(dbDir); + const freedSize = storage.clearAllRecords(); + storage.close(); removed.push({ path: `buildCache/${versionDir.name}`, diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 684e8634a84..ec16c6e22bc 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 14); + t.is(Object.keys(packageJson.exports).length, 15); }); // Public API contract (exported modules) From c8fc9f7d4a988949d692b6da138f795d696386e2 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 13:10:28 +0300 Subject: [PATCH 003/114] refactor: Simplify cache clean --- packages/project/lib/cache/CacheCleanup.js | 71 ++++++------------- .../project/test/lib/cache/CacheCleanup.js | 2 +- 2 files changed, 21 insertions(+), 52 deletions(-) diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 7e94f6a2521..7f90f15547c 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -32,32 +32,6 @@ async function getDirectorySize(dirPath) { return total; } -/** - * Clean a single directory by removing it entirely. - * - * @param {string} dirPath Absolute path to directory - * @param {string} displayPath Path to display in results - * @param {string} type Type of cache entry - * @returns {Promise>} Removed entries - */ -async function cleanDirectory(dirPath, displayPath, type) { - const removed = []; - try { - await fs.access(dirPath); - } catch { - return removed; - } - - const size = await getDirectorySize(dirPath); - try { - await fs.rm(dirPath, {recursive: true, force: true}); - removed.push({path: displayPath, type, size}); - } catch { - // Skip on failure - } - return removed; -} - /** * Clean build cache directory by clearing all records from the SQLite database. * @@ -102,7 +76,11 @@ async function cleanBuildCache(buildCacheDir) { } /** - * Scans the UI5 data directory and removes all cache entries. + * Cleans cache directories for framework libraries and incremental build cache. + * + * Removes: + * - framework/ directory: All UI5 framework libraries, download cache, staging files, and locks + * - buildCache/ entries: Clears database records (preserves database files) * * @param {object} options * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory @@ -112,33 +90,24 @@ async function cleanBuildCache(buildCacheDir) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Clean framework packages - allRemoved.push(...await cleanDirectory( - path.join(ui5DataDir, "framework", "packages"), - "framework/packages", - "framework" - )); - - // Clean cacache - allRemoved.push(...await cleanDirectory( - path.join(ui5DataDir, "framework", "cacache"), - "framework/cacache", - "cacache" - )); + // Clean entire framework directory (packages, cacache, staging, locks, etc.) + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size + }); + } catch { + // Framework directory doesn't exist or couldn't be removed + } - // Clean build cache (special: clears DB records, not files) + // Clean build cache (clears DB records, preserves files) allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); - // Clean misc dirs - const miscDirs = [ - ["framework/staging", "staging"], - ["framework/locks", "locks"], - ["server", "server"], - ]; - for (const [rel, type] of miscDirs) { - allRemoved.push(...await cleanDirectory(path.join(ui5DataDir, rel), rel, type)); - } - const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); return { entries: allRemoved, diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js index 04c0ea3e208..7340b807cea 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -64,7 +64,7 @@ test("cleanCache: clean all removes framework packages", async (t) => { t.true(result.totalCount >= 1); const frameworkEntries = result.entries.filter((e) => e.type === "framework"); t.is(frameworkEntries.length, 1); - t.is(frameworkEntries[0].path, "framework/packages"); + t.is(frameworkEntries[0].path, "framework"); }); // ===== cleanCache: build cache (full clean) ===== From 8aac58e4c38cc92eaf1c8b826813ba662ba0b00c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:00:12 +0300 Subject: [PATCH 004/114] refactor: Position correctly the CacheCleanup --- packages/cli/lib/cli/commands/cache.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 2 +- packages/project/lib/{ => build}/cache/CacheCleanup.js | 2 +- packages/project/package.json | 2 +- packages/project/test/lib/{ => build}/cache/CacheCleanup.js | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename packages/project/lib/{ => build}/cache/CacheCleanup.js (97%) rename packages/project/test/lib/{ => build}/cache/CacheCleanup.js (96%) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index ae4ca03f61c..2409d055521 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -4,7 +4,7 @@ import os from "node:os"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache} from "@ui5/project/cache/CacheCleanup"; +import {cleanCache} from "@ui5/project/build/cache/CacheCleanup"; const cacheCommand = { command: "cache", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 53fb40d1a22..4f990c82631 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -27,7 +27,7 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/cache/CacheCleanup": { + "@ui5/project/build/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, }, }); diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js similarity index 97% rename from packages/project/lib/cache/CacheCleanup.js rename to packages/project/lib/build/cache/CacheCleanup.js index 7f90f15547c..b5c929caa07 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "node:fs/promises"; -import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; +import BuildCacheStorage from "./BuildCacheStorage.js"; /** * Get the size of a directory tree recursively. diff --git a/packages/project/package.json b/packages/project/package.json index 802e2cd9685..0ef09ec452f 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,7 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", + "./build/cache/CacheCleanup": "./lib/build/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/build/cache/CacheCleanup.js similarity index 96% rename from packages/project/test/lib/cache/CacheCleanup.js rename to packages/project/test/lib/build/cache/CacheCleanup.js index 7340b807cea..62daad3aa15 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/build/cache/CacheCleanup.js @@ -2,9 +2,9 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; +import {cleanCache} from "../../../../lib/build/cache/CacheCleanup.js"; -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "CacheCleanup"); test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); From a709e3b2fc02fbd9fc04db3ef8d1b60d228170fc Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:48:44 +0300 Subject: [PATCH 005/114] refactor: Add confirmation dialog for the cache clean command --- packages/cli/lib/cli/commands/cache.js | 53 ++++- packages/cli/test/lib/cli/commands/cache.js | 200 +++++++++++++++++- .../lib/build/cache/BuildCacheStorage.js | 15 ++ .../project/lib/build/cache/CacheCleanup.js | 106 ++++++++-- 4 files changed, 343 insertions(+), 31 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 2409d055521..ab254ca546e 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -2,9 +2,10 @@ import chalk from "chalk"; import path from "node:path"; import os from "node:os"; import process from "node:process"; +import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache} from "@ui5/project/build/cache/CacheCleanup"; +import {cleanCache, getCacheInfo} from "@ui5/project/build/cache/CacheCleanup"; const cacheCommand = { command: "cache", @@ -44,6 +45,26 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +/** + * Prompt user for confirmation. + * + * @param {string} question The question to ask + * @returns {Promise} True if user confirmed + */ +async function confirm(question) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +} + async function handleCache() { // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; @@ -57,20 +78,42 @@ async function handleCache() { ui5DataDir = path.join(os.homedir(), ".ui5"); } - const result = await cleanCache({ui5DataDir}); + // Check what items exist before cleaning + const items = await getCacheInfo({ui5DataDir}); - if (result.totalCount === 0) { + if (items.length === 0) { process.stderr.write("Nothing to clean\n"); return; } + // Display items that will be removed + process.stderr.write(chalk.bold("\nThe following items from cache will be removed:\n")); + let totalSize = 0; + for (const item of items) { + totalSize += item.size; + const sizeStr = item.size > 0 ? ` (${formatSize(item.size)})` : ""; + process.stderr.write(` ${chalk.yellow("•")} ${item.path}${sizeStr}\n`); + } + process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); + + // Ask for confirmation + const confirmed = await confirm("Do you want to continue? (y/N) "); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + + // Perform the actual cleanup + const result = await cleanCache({ui5DataDir}); + + process.stderr.write("\n"); for (const entry of result.entries) { const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; - process.stderr.write(`Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); } process.stderr.write( - `\nCleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + + `\n${chalk.green("Success:")} Cleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" ); } diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 4f990c82631..eff92cbd1f7 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -24,11 +24,24 @@ test.beforeEach(async (t) => { sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); t.context.cleanCacheStub = sinon.stub(); + t.context.getCacheInfoStub = sinon.stub(); + + // Mock readline to simulate user confirmation + const mockRLInterface = { + question: sinon.stub(), + close: sinon.stub() + }; + t.context.readlineCreateInterfaceStub = sinon.stub().returns(mockRLInterface); + t.context.mockRLInterface = mockRLInterface; t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, "@ui5/project/build/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, + getCacheInfo: t.context.getCacheInfoStub, + }, + "node:readline": { + createInterface: t.context.readlineCreateInterfaceStub, }, }); }); @@ -38,24 +51,53 @@ test.afterEach.always((t) => { esmock.purge(t.context.cache); }); +test("Command builder", async (t) => { + // Import cache module directly for builder test (before beforeEach stubs are created) + const cacheModule = await import("../../../../lib/cli/commands/cache.js"); + const cliStub = { + demandCommand: sinon.stub().returnsThis(), + command: sinon.stub().returnsThis(), + example: sinon.stub().returnsThis(), + }; + const result = cacheModule.default.builder(cliStub); + t.is(result, cliStub, "Builder returns cli instance"); + t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); + t.is(cliStub.command.callCount, 1, "command called once"); + t.is(cliStub.example.callCount, 1, "example called once"); +}); + test.serial("ui5 cache clean: nothing to clean", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub} = t.context; - cleanCacheStub.resolves({entries: [], totalSize: 0, totalCount: 0}); + // Simulate no cache items + getCacheInfoStub.resolves([]); argv["_"] = ["cache", "clean"]; await cache.handler(argv); t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); + t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called"); }); test.serial("ui5 cache clean: removes entries and reports", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub} = t.context; + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, + mockRLInterface} = t.context; + + // Simulate existing cache items + getCacheInfoStub.resolves([ + {path: "framework/", size: 15 * 1024 * 1024, type: "directory"}, + {path: "buildCache/ (database records)", size: 8 * 1024 * 1024, type: "database"}, + ]); + + // Mock user confirmation + mockRLInterface.question.callsFake((question, callback) => { + callback("y"); + }); cleanCacheStub.resolves({ entries: [ - {path: "@openui5/sap.ui.core/1.120.0", type: "framework", size: 15 * 1024 * 1024}, - {path: "@openui5/sap.m/1.120.0", type: "framework", size: 8 * 1024 * 1024}, + {path: "framework", type: "framework", size: 15 * 1024 * 1024}, + {path: "buildCache", type: "buildCache", size: 8 * 1024 * 1024}, ], totalSize: 23 * 1024 * 1024, totalCount: 2, @@ -64,18 +106,156 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Should have 4 writes: 2 entries + 1 newline + summary - t.true(stderrWriteStub.callCount >= 3, "Multiple lines written to stderr"); - // Check that summary mentions entries count + // Check that confirmation was asked + t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + t.true(mockRLInterface.question.firstCall.args[0].includes("continue"), + "Confirmation question should ask to continue"); + + // Check that cleanCache was called + t.is(cleanCacheStub.callCount, 1, "cleanCache should be called once"); + + // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); - t.true(allOutput.includes("23.0 MB"), "Summary mentions freed size"); + t.true(allOutput.includes("Success"), "Shows success message"); }); -test("Command definition is correct", (t) => { +test.serial("ui5 cache clean: user cancels", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, + mockRLInterface} = t.context; + + // Simulate existing cache items + getCacheInfoStub.resolves([ + {path: "framework/", size: 5 * 1024 * 1024, type: "directory"} + ]); + + // Mock user cancellation + mockRLInterface.question.callsFake((question, callback) => { + callback("n"); + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // Check that confirmation was asked + t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + + // Check that cleanCache was NOT called + t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called when user cancels"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); + t.false(allOutput.includes("Success"), "Should not show success message"); +}); + +test.serial("Command definition is correct", (t) => { // Import without esmock for structure check t.is(t.context.cache.command, "cache"); t.is(t.context.cache.describe, "Manage UI5 CLI cache"); t.is(typeof t.context.cache.builder, "function"); t.is(typeof t.context.cache.handler, "function"); }); + +test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { + const {cache, argv, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + + getCacheInfoStub.resolves([ + {path: "framework/", size: 1024, type: "directory"} + ]); + + mockRLInterface.question.callsFake((question, callback) => { + callback("yes"); + }); + + cleanCacheStub.resolves({ + entries: [{path: "framework", type: "framework", size: 1024}], + totalSize: 1024, + totalCount: 1, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(cleanCacheStub.callCount, 1, "cleanCache should be called with 'yes' confirmation"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + + // Test with small bytes (B), KB, and GB sizes + getCacheInfoStub.resolves([ + {path: "small", size: 512, type: "directory"}, // < 1024 = B + {path: "medium", size: 50 * 1024, type: "directory"}, // KB + {path: "large", size: 2 * 1024 * 1024 * 1024, type: "directory"}, // GB + ]); + + mockRLInterface.question.callsFake((question, callback) => { + callback("y"); + }); + + cleanCacheStub.resolves({ + entries: [ + {path: "small", type: "directory", size: 512}, + {path: "medium", type: "directory", size: 50 * 1024}, + {path: "large", type: "directory", size: 2 * 1024 * 1024 * 1024}, + ], + totalSize: 2 * 1024 * 1024 * 1024 + 50 * 1024 + 512, + totalCount: 3, + }); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("512 B"), "Shows bytes format"); + t.true(allOutput.includes("50.0 KB"), "Shows KB format"); + t.true(allOutput.includes("2.0 GB"), "Shows GB format"); +}); + +test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { + const {cache, argv, getCacheInfoStub} = t.context; + const originalEnv = process.env.UI5_DATA_DIR; + + try { + process.env.UI5_DATA_DIR = "/custom/ui5/path"; + + getCacheInfoStub.resolves([]); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + t.true(getCacheInfoStub.firstCall.args[0].ui5DataDir.includes("custom/ui5/path"), + "Uses environment variable path"); + } finally { + if (originalEnv) { + process.env.UI5_DATA_DIR = originalEnv; + } else { + delete process.env.UI5_DATA_DIR; + } + } +}); + +test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => { + const {cache, argv, getCacheInfoStub, Configuration} = t.context; + const originalEnv = process.env.UI5_DATA_DIR; + + try { + delete process.env.UI5_DATA_DIR; + + Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"})); + getCacheInfoStub.resolves([]); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + } finally { + if (originalEnv) { + process.env.UI5_DATA_DIR = originalEnv; + } + } +}); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index d89ced19f66..02fe11e1714 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -537,6 +537,21 @@ export default class BuildCacheStorage { return bytesBefore - bytesAfter; } + /** + * Checks if the database has any records in any table. + * + * @returns {boolean} True if there are any records + */ + hasRecords() { + const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; + for (const table of tables) { + const count = this.#db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0; + if (count > 0) { + return true; + } + } + return false; + } /** * Closes the database connection */ diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js index b5c929caa07..c9bcd4a7d2f 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -8,7 +8,7 @@ import BuildCacheStorage from "./BuildCacheStorage.js"; * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -async function getDirectorySize(dirPath) { +export async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -75,6 +75,73 @@ async function cleanBuildCache(buildCacheDir) { return removed; } +/** + * Check what cache items exist and their sizes without removing them. + * + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} List of cache items + */ +export async function getCacheInfo({ui5DataDir}) { + const items = []; + + // Check framework directory + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + items.push({ + path: "framework/", + size, + type: "directory" + }); + } + } catch { + // Directory doesn't exist, skip + } + + // Check buildCache directory + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + try { + await fs.access(buildCacheDir); + const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + + let hasAnyRecords = false; + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbDir = path.join(buildCacheDir, versionDir.name); + try { + const storage = new BuildCacheStorage(dbDir); + if (storage.hasRecords()) { + hasAnyRecords = true; + storage.close(); + break; + } + storage.close(); + } catch { + // Skip if database can't be opened + } + } + + if (hasAnyRecords) { + const size = await getDirectorySize(buildCacheDir); + items.push({ + path: "buildCache/ (database records)", + size, + type: "database" + }); + } + } catch { + // Directory doesn't exist, skip + } + + return items; +} + /** * Cleans cache directories for framework libraries and incremental build cache. * @@ -90,23 +157,30 @@ async function cleanBuildCache(buildCacheDir) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Clean entire framework directory (packages, cacache, staging, locks, etc.) - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size - }); - } catch { - // Framework directory doesn't exist or couldn't be removed + // Get info about what exists (reuses getCacheInfo to avoid duplication) + const items = await getCacheInfo({ui5DataDir}); + + // Remove framework if it exists + const frameworkItem = items.find((item) => item.path === "framework/"); + if (frameworkItem) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size: frameworkItem.size + }); + } catch { + // Framework directory couldn't be removed + } } - // Clean build cache (clears DB records, preserves files) - allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + // Clean build cache if it exists + const buildCacheItem = items.find((item) => item.type === "database"); + if (buildCacheItem) { + allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); return { From 33ea6a5ff0c8c026ed61f4fd10af0000400d37bc Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 14:56:25 +0300 Subject: [PATCH 006/114] refactor: Rename cacheVersionDir --- packages/project/lib/build/cache/CacheCleanup.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/build/cache/CacheCleanup.js index c9bcd4a7d2f..700ec9eb51a 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/build/cache/CacheCleanup.js @@ -46,15 +46,15 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - let versionDirs; + let cacheVersionDirs; try { - versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + cacheVersionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); } catch { return removed; } - for (const versionDir of versionDirs) { + for (const versionDir of cacheVersionDirs) { if (!versionDir.isDirectory()) { continue; } From d3c7601101da5161eebbae4ee6c9429ffac748ac Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:18:48 +0300 Subject: [PATCH 007/114] refactor: Restore location of CacheCleanup --- packages/cli/lib/cli/commands/cache.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 2 +- .../lib/{build => }/cache/CacheCleanup.js | 209 ++++++++++++------ packages/project/package.json | 2 +- .../lib/{build => }/cache/CacheCleanup.js | 4 +- 5 files changed, 142 insertions(+), 77 deletions(-) rename packages/project/lib/{build => }/cache/CacheCleanup.js (56%) rename packages/project/test/lib/{build => }/cache/CacheCleanup.js (96%) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index ab254ca546e..845d67119e3 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -5,7 +5,7 @@ import process from "node:process"; import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache, getCacheInfo} from "@ui5/project/build/cache/CacheCleanup"; +import {cleanCache, getCacheInfo} from "@ui5/project/cache/CacheCleanup"; const cacheCommand = { command: "cache", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index eff92cbd1f7..f3ec70381a7 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -36,7 +36,7 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/build/cache/CacheCleanup": { + "@ui5/project/cache/CacheCleanup": { cleanCache: t.context.cleanCacheStub, getCacheInfo: t.context.getCacheInfoStub, }, diff --git a/packages/project/lib/build/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js similarity index 56% rename from packages/project/lib/build/cache/CacheCleanup.js rename to packages/project/lib/cache/CacheCleanup.js index 700ec9eb51a..c866537fc87 100644 --- a/packages/project/lib/build/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,10 @@ import path from "node:path"; import fs from "node:fs/promises"; -import BuildCacheStorage from "./BuildCacheStorage.js"; +import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; + +// ======================================== +// SHARED UTILITIES +// ======================================== /** * Get the size of a directory tree recursively. @@ -8,7 +12,7 @@ import BuildCacheStorage from "./BuildCacheStorage.js"; * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -export async function getDirectorySize(dirPath) { +async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -32,14 +36,123 @@ export async function getDirectorySize(dirPath) { return total; } +// ======================================== +// FRAMEWORK CACHE (ui5Framework namespace) +// Manages: framework/packages, framework/cacache, +// framework/staging, framework/locks, etc. +// ======================================== + +/** + * Check if framework cache exists and get its info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + */ +async function getFrameworkCacheInfo(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + return { + path: "framework/", + size, + type: "directory" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean framework cache directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {{path: string, size: number, type: string}} frameworkInfo Framework cache info + * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + */ +async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { + if (!frameworkInfo) { + return null; + } + + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: "framework", + type: "framework", + size: frameworkInfo.size + }; + } catch { + // Framework directory couldn't be removed + } + return null; +} + +// ======================================== +// BUILD CACHE (build/cache namespace) +// Manages: buildCache/v*/ SQLite databases +// ======================================== + /** - * Clean build cache directory by clearing all records from the SQLite database. + * Check if build cache exists and get its info. * - * @param {string} buildCacheDir Path to buildCache/ + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null + */ +async function getBuildCacheInfo(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + try { + await fs.access(buildCacheDir); + const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + + let hasAnyRecords = false; + for (const versionDir of versionDirs) { + if (!versionDir.isDirectory()) { + continue; + } + + const dbDir = path.join(buildCacheDir, versionDir.name); + try { + const storage = new BuildCacheStorage(dbDir); + if (storage.hasRecords()) { + hasAnyRecords = true; + storage.close(); + break; + } + storage.close(); + } catch { + // Skip if database can't be opened + } + } + + if (hasAnyRecords) { + const size = await getDirectorySize(buildCacheDir); + return { + path: "buildCache/ (database records)", + size, + type: "database" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean build cache by clearing all records from SQLite databases. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise>} Removed entries */ -async function cleanBuildCache(buildCacheDir) { +async function cleanBuildCache(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); const removed = []; + try { await fs.access(buildCacheDir); } catch { @@ -53,7 +166,6 @@ async function cleanBuildCache(buildCacheDir) { return removed; } - for (const versionDir of cacheVersionDirs) { if (!versionDir.isDirectory()) { continue; @@ -75,6 +187,10 @@ async function cleanBuildCache(buildCacheDir) { return removed; } +// ======================================== +// PUBLIC API - Orchestrates both caches +// ======================================== + /** * Check what cache items exist and their sizes without removing them. * @@ -85,58 +201,16 @@ async function cleanBuildCache(buildCacheDir) { export async function getCacheInfo({ui5DataDir}) { const items = []; - // Check framework directory - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - items.push({ - path: "framework/", - size, - type: "directory" - }); - } - } catch { - // Directory doesn't exist, skip + // Check framework cache + const frameworkInfo = await getFrameworkCacheInfo(ui5DataDir); + if (frameworkInfo) { + items.push(frameworkInfo); } - // Check buildCache directory - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - try { - await fs.access(buildCacheDir); - const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); - - let hasAnyRecords = false; - for (const versionDir of versionDirs) { - if (!versionDir.isDirectory()) { - continue; - } - - const dbDir = path.join(buildCacheDir, versionDir.name); - try { - const storage = new BuildCacheStorage(dbDir); - if (storage.hasRecords()) { - hasAnyRecords = true; - storage.close(); - break; - } - storage.close(); - } catch { - // Skip if database can't be opened - } - } - - if (hasAnyRecords) { - const size = await getDirectorySize(buildCacheDir); - items.push({ - path: "buildCache/ (database records)", - size, - type: "database" - }); - } - } catch { - // Directory doesn't exist, skip + // Check build cache + const buildInfo = await getBuildCacheInfo(ui5DataDir); + if (buildInfo) { + items.push(buildInfo); } return items; @@ -157,29 +231,20 @@ export async function getCacheInfo({ui5DataDir}) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Get info about what exists (reuses getCacheInfo to avoid duplication) + // Get info about what exists const items = await getCacheInfo({ui5DataDir}); - // Remove framework if it exists + // Clean framework cache const frameworkItem = items.find((item) => item.path === "framework/"); - if (frameworkItem) { - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size: frameworkItem.size - }); - } catch { - // Framework directory couldn't be removed - } + const frameworkResult = await cleanFrameworkCache(ui5DataDir, frameworkItem); + if (frameworkResult) { + allRemoved.push(frameworkResult); } - // Clean build cache if it exists + // Clean build cache const buildCacheItem = items.find((item) => item.type === "database"); if (buildCacheItem) { - allRemoved.push(...await cleanBuildCache(path.join(ui5DataDir, "buildCache"))); + allRemoved.push(...await cleanBuildCache(ui5DataDir)); } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); diff --git a/packages/project/package.json b/packages/project/package.json index 0ef09ec452f..802e2cd9685 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -31,7 +31,7 @@ "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./build/cache/CacheCleanup": "./lib/build/cache/CacheCleanup.js", + "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/build/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js similarity index 96% rename from packages/project/test/lib/build/cache/CacheCleanup.js rename to packages/project/test/lib/cache/CacheCleanup.js index 62daad3aa15..7340b807cea 100644 --- a/packages/project/test/lib/build/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -2,9 +2,9 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../../lib/build/cache/CacheCleanup.js"; +import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "CacheCleanup"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); From c0a86099f5e980846d5003abb623608423ff9464 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 15:50:10 +0300 Subject: [PATCH 008/114] fix: Clean only current cache version --- .../project/lib/build/cache/CacheManager.js | 2 +- packages/project/lib/cache/CacheCleanup.js | 91 ++++++++----------- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index e1e37a6f521..d36271e9fd9 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -10,7 +10,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -const CACHE_VERSION = "v0_7"; +export const CACHE_VERSION = "v0_7"; /** * Manages persistence for the build cache using a unified SQLite-backed storage diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index c866537fc87..67e942c317c 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -1,6 +1,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; +import {CACHE_VERSION} from "../build/cache/CacheManager.js"; // ======================================== // SHARED UTILITIES @@ -99,89 +100,75 @@ async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { /** * Check if build cache exists and get its info. + * Only checks the current known cache version to avoid processing unknown future versions. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null */ async function getBuildCacheInfo(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); - try { - await fs.access(buildCacheDir); - const versionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); - let hasAnyRecords = false; - for (const versionDir of versionDirs) { - if (!versionDir.isDirectory()) { - continue; - } + try { + await fs.access(dbDir); + } catch { + // Current version directory doesn't exist + return null; + } - const dbDir = path.join(buildCacheDir, versionDir.name); - try { - const storage = new BuildCacheStorage(dbDir); - if (storage.hasRecords()) { - hasAnyRecords = true; - storage.close(); - break; - } - storage.close(); - } catch { - // Skip if database can't be opened + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = await getDirectorySize(buildCacheDir); + return { + path: `buildCache/${CACHE_VERSION} (database records)`, + size, + type: "database" + }; } - } - - if (hasAnyRecords) { - const size = await getDirectorySize(buildCacheDir); - return { - path: "buildCache/ (database records)", - size, - type: "database" - }; + } finally { + storage.close(); } } catch { - // Directory doesn't exist + // Skip if database can't be opened } return null; } /** - * Clean build cache by clearing all records from SQLite databases. + * Clean build cache by clearing all records from SQLite database. + * Only cleans the current known cache version to avoid processing unknown future versions. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise>} Removed entries */ async function cleanBuildCache(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); const removed = []; try { - await fs.access(buildCacheDir); + await fs.access(dbDir); } catch { + // Current version directory doesn't exist return removed; } - let cacheVersionDirs; try { - cacheVersionDirs = await fs.readdir(buildCacheDir, {withFileTypes: true}); - } catch { - return removed; - } - - for (const versionDir of cacheVersionDirs) { - if (!versionDir.isDirectory()) { - continue; - } - - const dbDir = path.join(buildCacheDir, versionDir.name); - const storage = new BuildCacheStorage(dbDir); - const freedSize = storage.clearAllRecords(); - storage.close(); - - removed.push({ - path: `buildCache/${versionDir.name}`, - type: "buildCache", - size: freedSize, - }); + try { + const freedSize = storage.clearAllRecords(); + removed.push({ + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize, + }); + } finally { + storage.close(); + } + } catch { + // Skip if database can't be cleared } return removed; From 3f5bb7e977305484d7a474bc0e3989e8fa3fcd1b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:01:30 +0300 Subject: [PATCH 009/114] refactor: Simplify CacheCleanup --- packages/project/lib/cache/CacheCleanup.js | 189 +++++---------------- 1 file changed, 45 insertions(+), 144 deletions(-) diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js index 67e942c317c..7902ccea9eb 100644 --- a/packages/project/lib/cache/CacheCleanup.js +++ b/packages/project/lib/cache/CacheCleanup.js @@ -3,10 +3,6 @@ import fs from "node:fs/promises"; import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; import {CACHE_VERSION} from "../build/cache/CacheManager.js"; -// ======================================== -// SHARED UTILITIES -// ======================================== - /** * Get the size of a directory tree recursively. * @@ -37,167 +33,52 @@ async function getDirectorySize(dirPath) { return total; } -// ======================================== -// FRAMEWORK CACHE (ui5Framework namespace) -// Manages: framework/packages, framework/cacache, -// framework/staging, framework/locks, etc. -// ======================================== - /** - * Check if framework cache exists and get its info. + * Check what cache items exist and their sizes without removing them. * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + * @param {object} options + * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} List of cache items */ -async function getFrameworkCacheInfo(ui5DataDir) { +export async function getCacheInfo({ui5DataDir}) { + const items = []; + + // Check framework cache const frameworkDir = path.join(ui5DataDir, "framework"); try { await fs.access(frameworkDir); const size = await getDirectorySize(frameworkDir); if (size > 0) { - return { + items.push({ path: "framework/", size, type: "directory" - }; + }); } } catch { // Directory doesn't exist } - return null; -} -/** - * Clean framework cache directory. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @param {{path: string, size: number, type: string}} frameworkInfo Framework cache info - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null - */ -async function cleanFrameworkCache(ui5DataDir, frameworkInfo) { - if (!frameworkInfo) { - return null; - } - - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.rm(frameworkDir, {recursive: true, force: true}); - return { - path: "framework", - type: "framework", - size: frameworkInfo.size - }; - } catch { - // Framework directory couldn't be removed - } - return null; -} - -// ======================================== -// BUILD CACHE (build/cache namespace) -// Manages: buildCache/v*/ SQLite databases -// ======================================== - -/** - * Check if build cache exists and get its info. - * Only checks the current known cache version to avoid processing unknown future versions. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null - */ -async function getBuildCacheInfo(ui5DataDir) { + // Check build cache (only current version) const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { await fs.access(dbDir); - } catch { - // Current version directory doesn't exist - return null; - } - - try { const storage = new BuildCacheStorage(dbDir); try { if (storage.hasRecords()) { const size = await getDirectorySize(buildCacheDir); - return { + items.push({ path: `buildCache/${CACHE_VERSION} (database records)`, size, type: "database" - }; + }); } } finally { storage.close(); } } catch { - // Skip if database can't be opened - } - return null; -} - -/** - * Clean build cache by clearing all records from SQLite database. - * Only cleans the current known cache version to avoid processing unknown future versions. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} Removed entries - */ -async function cleanBuildCache(ui5DataDir) { - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - const removed = []; - - try { - await fs.access(dbDir); - } catch { - // Current version directory doesn't exist - return removed; - } - - try { - const storage = new BuildCacheStorage(dbDir); - try { - const freedSize = storage.clearAllRecords(); - removed.push({ - path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", - size: freedSize, - }); - } finally { - storage.close(); - } - } catch { - // Skip if database can't be cleared - } - - return removed; -} - -// ======================================== -// PUBLIC API - Orchestrates both caches -// ======================================== - -/** - * Check what cache items exist and their sizes without removing them. - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} List of cache items - */ -export async function getCacheInfo({ui5DataDir}) { - const items = []; - - // Check framework cache - const frameworkInfo = await getFrameworkCacheInfo(ui5DataDir); - if (frameworkInfo) { - items.push(frameworkInfo); - } - - // Check build cache - const buildInfo = await getBuildCacheInfo(ui5DataDir); - if (buildInfo) { - items.push(buildInfo); + // Skip if database can't be opened or doesn't exist } return items; @@ -218,20 +99,40 @@ export async function getCacheInfo({ui5DataDir}) { export async function cleanCache({ui5DataDir}) { const allRemoved = []; - // Get info about what exists - const items = await getCacheInfo({ui5DataDir}); - // Clean framework cache - const frameworkItem = items.find((item) => item.path === "framework/"); - const frameworkResult = await cleanFrameworkCache(ui5DataDir, frameworkItem); - if (frameworkResult) { - allRemoved.push(frameworkResult); + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + await fs.rm(frameworkDir, {recursive: true, force: true}); + allRemoved.push({ + path: "framework", + type: "framework", + size + }); + } + } catch { + // Directory doesn't exist or couldn't be removed } - // Clean build cache - const buildCacheItem = items.find((item) => item.type === "database"); - if (buildCacheItem) { - allRemoved.push(...await cleanBuildCache(ui5DataDir)); + // Clean build cache (only current version) + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + try { + await fs.access(dbDir); + const storage = new BuildCacheStorage(dbDir); + try { + const freedSize = storage.clearAllRecords(); + allRemoved.push({ + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize + }); + } finally { + storage.close(); + } + } catch { + // Database doesn't exist or couldn't be cleared } const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); From 5290ae701cc7f3749d49dcf220e74e309ddcdbfe Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 16:13:13 +0300 Subject: [PATCH 010/114] test: Improve coverage --- .../project/test/lib/cache/CacheCleanup.js | 210 +++++++++++++++--- 1 file changed, 184 insertions(+), 26 deletions(-) diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js index 7340b807cea..f9b4d784d20 100644 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ b/packages/project/test/lib/cache/CacheCleanup.js @@ -2,7 +2,7 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import {rimraf} from "rimraf"; -import {cleanCache} from "../../../lib/cache/CacheCleanup.js"; +import {cleanCache, getCacheInfo} from "../../../lib/cache/CacheCleanup.js"; const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); @@ -10,29 +10,12 @@ test.after.always(async () => { await rimraf(TEST_DIR).catch(() => {}); }); -/** - * Create a unique test directory for each test. - * - * @param {object} t AVA test context - * @returns {string} Path to the ui5DataDir fixture - */ function createTestDir(t) { const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); t.context.ui5DataDir = dir; return dir; } -/** - * Create a framework package fixture. - * - * @param {string} ui5DataDir Base data directory - * @param {string} scope Package scope (e.g., "@openui5") - * @param {string} name Package name (e.g., "sap.ui.core") - * @param {string} version Version string - * @param {object} [options] - * @param {Date} [options.mtime] Custom mtime for the package file - * @returns {Promise} - */ async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); await fs.mkdir(pkgDir, {recursive: true}); @@ -43,7 +26,7 @@ async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { } } -// ===== cleanCache: empty/nonexistent dir ===== +// ===== cleanCache tests ===== test("cleanCache: returns empty result for nonexistent directory", async (t) => { const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); @@ -52,8 +35,6 @@ test("cleanCache: returns empty result for nonexistent directory", async (t) => t.deepEqual(result.entries, []); }); -// ===== cleanCache: clean all ===== - test("cleanCache: clean all removes framework packages", async (t) => { const ui5DataDir = createTestDir(t); await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); @@ -67,14 +48,11 @@ test("cleanCache: clean all removes framework packages", async (t) => { t.is(frameworkEntries[0].path, "framework"); }); -// ===== cleanCache: build cache (full clean) ===== - test("cleanCache: clean all clears buildCache database", async (t) => { const ui5DataDir = createTestDir(t); const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); await fs.mkdir(buildCacheDir, {recursive: true}); - // Create a real SQLite database with tables and some data const {DatabaseSync} = await import("node:sqlite"); const dbPath = path.join(buildCacheDir, "cache.db"); const db = new DatabaseSync(dbPath); @@ -95,11 +73,9 @@ test("cleanCache: clean all clears buildCache database", async (t) => { const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); t.truthy(buildCacheEntry); - // Verify directory and DB file still exist await fs.access(buildCacheDir); await fs.access(dbPath); - // Verify tables are empty const dbAfter = new DatabaseSync(dbPath); const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; @@ -107,3 +83,185 @@ test("cleanCache: clean all clears buildCache database", async (t) => { t.is(indexCount, 0); dbAfter.close(); }); + +test("cleanCache: skips empty framework directory", async (t) => { + const ui5DataDir = createTestDir(t); + const frameworkDir = path.join(ui5DataDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await cleanCache({ui5DataDir}); + + t.is(result.totalCount, 0); + const frameworkEntries = result.entries.filter((e) => e.type === "framework"); + t.is(frameworkEntries.length, 0); +}); + +test("cleanCache: cleans both framework and build cache", async (t) => { + const ui5DataDir = createTestDir(t); + + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT); + CREATE TABLE index_cache (project_id TEXT); + CREATE TABLE stage_metadata (project_id TEXT); + CREATE TABLE task_metadata (project_id TEXT); + CREATE TABLE result_metadata (project_id TEXT); + `); + db.exec("INSERT INTO content VALUES ('test')"); + db.close(); + + const result = await cleanCache({ui5DataDir}); + + t.true(result.totalCount >= 1); // At least framework + t.truthy(result.entries.find((e) => e.type === "framework")); + t.true(result.totalSize > 0); + // Build cache may also be cleaned + if (result.totalCount === 2) { + t.truthy(result.entries.find((e) => e.type === "buildCache")); + } +}); + +test("cleanCache: handles corrupted database gracefully", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); + + const result = await cleanCache({ui5DataDir}); + + t.pass(); + const buildEntries = result.entries.filter((e) => e.type === "buildCache"); + t.is(buildEntries.length, 0); +}); + +// ===== getCacheInfo tests ===== + +test("getCacheInfo: returns empty array for nonexistent directory", async (t) => { + const items = await getCacheInfo({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); + t.deepEqual(items, []); +}); + +test("getCacheInfo: detects framework cache with size", async (t) => { + const ui5DataDir = createTestDir(t); + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const items = await getCacheInfo({ui5DataDir}); + + const frameworkItem = items.find((item) => item.path === "framework/"); + t.truthy(frameworkItem); + t.true(frameworkItem.size > 0); + t.is(frameworkItem.type, "directory"); +}); + +test("getCacheInfo: skips empty framework directory", async (t) => { + const ui5DataDir = createTestDir(t); + const frameworkDir = path.join(ui5DataDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const items = await getCacheInfo({ui5DataDir}); + + const frameworkItem = items.find((item) => item.path === "framework/"); + t.falsy(frameworkItem); +}); + +test("getCacheInfo: detects build cache with records", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + const buildItem = items.find((item) => item.type === "database"); + t.truthy(buildItem); + t.is(buildItem.path, "buildCache/v0_7 (database records)"); + t.true(buildItem.size > 0); +}); + +test("getCacheInfo: skips build cache with no records", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); + CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); + CREATE TABLE stage_metadata + (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); + CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); + CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); + `); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + const buildItem = items.find((item) => item.type === "database"); + t.falsy(buildItem); +}); + +test("getCacheInfo: handles corrupted database gracefully", async (t) => { + const ui5DataDir = createTestDir(t); + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + + await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); + + const items = await getCacheInfo({ui5DataDir}); + + t.pass(); + const buildItem = items.find((item) => item.type === "database"); + t.falsy(buildItem); +}); + +test("getCacheInfo: detects both framework and build cache", async (t) => { + const ui5DataDir = createTestDir(t); + + await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); + + const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); + await fs.mkdir(buildCacheDir, {recursive: true}); + const {DatabaseSync} = await import("node:sqlite"); + const dbPath = path.join(buildCacheDir, "cache.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE content (integrity TEXT); + CREATE TABLE index_cache (project_id TEXT); + CREATE TABLE stage_metadata (project_id TEXT); + CREATE TABLE task_metadata (project_id TEXT); + CREATE TABLE result_metadata (project_id TEXT); + `); + db.exec("INSERT INTO content VALUES ('test')"); + db.close(); + + const items = await getCacheInfo({ui5DataDir}); + + t.true(items.length >= 1); // At least framework + t.truthy(items.find((item) => item.path === "framework/")); + // Build cache may also be detected + if (items.length === 2) { + t.truthy(items.find((item) => item.type === "database")); + } +}); From deebaed2586ef3049888f89616a760f2a174ef81 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 29 May 2026 18:36:37 +0300 Subject: [PATCH 011/114] refactor: CLI package orchestrates cache cleanup Provide common interface for cache cleanup, but distribute the real cleanup among the respective destinations --- packages/cli/lib/cli/commands/cache.js | 34 ++- packages/cli/test/lib/cli/commands/cache.js | 120 ++++---- .../lib/build/cache/BuildCacheStorage.js | 11 + .../project/lib/build/cache/CacheManager.js | 64 ++++- packages/project/lib/cache/CacheCleanup.js | 144 ---------- packages/project/lib/ui5Framework/cache.js | 80 ++++++ packages/project/package.json | 3 +- .../test/lib/build/cache/CacheManager.js | 76 +++++ .../project/test/lib/cache/CacheCleanup.js | 267 ------------------ packages/project/test/lib/package-exports.js | 2 +- .../project/test/lib/ui5framework/cache.js | 101 +++++++ 11 files changed, 417 insertions(+), 485 deletions(-) delete mode 100644 packages/project/lib/cache/CacheCleanup.js create mode 100644 packages/project/lib/ui5Framework/cache.js delete mode 100644 packages/project/test/lib/cache/CacheCleanup.js create mode 100644 packages/project/test/lib/ui5framework/cache.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 845d67119e3..87108617141 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -5,7 +5,8 @@ import process from "node:process"; import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; -import {cleanCache, getCacheInfo} from "@ui5/project/cache/CacheCleanup"; +import * as frameworkCache from "@ui5/project/ui5Framework/cache"; +import CacheManager from "@ui5/project/build/cache/CacheManager"; const cacheCommand = { command: "cache", @@ -78,8 +79,16 @@ async function handleCache() { ui5DataDir = path.join(os.homedir(), ".ui5"); } - // Check what items exist before cleaning - const items = await getCacheInfo({ui5DataDir}); + // Check what items exist before cleaning (orchestrate both domains) + const items = []; + const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); + if (frameworkInfo) { + items.push(frameworkInfo); + } + const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + if (buildInfo) { + items.push(buildInfo); + } if (items.length === 0) { process.stderr.write("Nothing to clean\n"); @@ -103,18 +112,27 @@ async function handleCache() { return; } - // Perform the actual cleanup - const result = await cleanCache({ui5DataDir}); + // Perform the actual cleanup (orchestrate both domains) + const removed = []; + const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + if (frameworkResult) { + removed.push(frameworkResult); + } + const buildResult = await CacheManager.cleanCache(ui5DataDir); + if (buildResult) { + removed.push(buildResult); + } process.stderr.write("\n"); - for (const entry of result.entries) { + for (const entry of removed) { const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); } + const totalRemoved = removed.reduce((sum, entry) => sum + entry.size, 0); process.stderr.write( - `\n${chalk.green("Success:")} Cleaned ${result.totalCount} ${result.totalCount === 1 ? "entry" : "entries"}` + - (result.totalSize > 0 ? `, freed ${formatSize(result.totalSize)}` : "") + "\n" + `\n${chalk.green("Success:")} Cleaned ${removed.length} ${removed.length === 1 ? "entry" : "entries"}` + + (totalRemoved > 0 ? `, freed ${formatSize(totalRemoved)}` : "") + "\n" ); } diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index f3ec70381a7..06ab7e9d61b 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -23,8 +23,10 @@ test.beforeEach(async (t) => { t.context.Configuration = Configuration; sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); - t.context.cleanCacheStub = sinon.stub(); - t.context.getCacheInfoStub = sinon.stub(); + t.context.frameworkCacheGetCacheInfo = sinon.stub(); + t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.buildCacheGetCacheInfo = sinon.stub(); + t.context.buildCacheCleanCache = sinon.stub(); // Mock readline to simulate user confirmation const mockRLInterface = { @@ -36,9 +38,15 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, - "@ui5/project/cache/CacheCleanup": { - cleanCache: t.context.cleanCacheStub, - getCacheInfo: t.context.getCacheInfoStub, + "@ui5/project/ui5Framework/cache": { + getCacheInfo: t.context.frameworkCacheGetCacheInfo, + cleanCache: t.context.frameworkCacheCleanCache + }, + "@ui5/project/build/cache/CacheManager": { + default: class { + static getCacheInfo = t.context.buildCacheGetCacheInfo; + static cleanCache = t.context.buildCacheCleanCache; + } }, "node:readline": { createInterface: t.context.readlineCreateInterfaceStub, @@ -67,41 +75,38 @@ test("Command builder", async (t) => { }); test.serial("ui5 cache clean: nothing to clean", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; // Simulate no cache items - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); - t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); }); test.serial("ui5 cache clean: removes entries and reports", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, - mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Simulate existing cache items - getCacheInfoStub.resolves([ - {path: "framework/", size: 15 * 1024 * 1024, type: "directory"}, - {path: "buildCache/ (database records)", size: 8 * 1024 * 1024, type: "database"}, - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves({ + path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024, type: "database" + }); // Mock user confirmation mockRLInterface.question.callsFake((question, callback) => { callback("y"); }); - cleanCacheStub.resolves({ - entries: [ - {path: "framework", type: "framework", size: 15 * 1024 * 1024}, - {path: "buildCache", type: "buildCache", size: 8 * 1024 * 1024}, - ], - totalSize: 23 * 1024 * 1024, - totalCount: 2, - }); + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -112,7 +117,8 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { "Confirmation question should ask to continue"); // Check that cleanCache was called - t.is(cleanCacheStub.callCount, 1, "cleanCache should be called once"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); @@ -122,13 +128,12 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { }); test.serial("ui5 cache clean: user cancels", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, - mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Simulate existing cache items - getCacheInfoStub.resolves([ - {path: "framework/", size: 5 * 1024 * 1024, type: "directory"} - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); // Mock user cancellation mockRLInterface.question.callsFake((question, callback) => { @@ -141,8 +146,9 @@ test.serial("ui5 cache clean: user cancels", async (t) => { // Check that confirmation was asked t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); - // Check that cleanCache was NOT called - t.is(cleanCacheStub.callCount, 0, "cleanCache should not be called when user cancels"); + // Check that cleanup was NOT called + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); @@ -160,51 +166,38 @@ test.serial("Command definition is correct", (t) => { }); test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { - const {cache, argv, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, mockRLInterface} = t.context; - getCacheInfoStub.resolves([ - {path: "framework/", size: 1024, type: "directory"} - ]); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); mockRLInterface.question.callsFake((question, callback) => { callback("yes"); }); - cleanCacheStub.resolves({ - entries: [{path: "framework", type: "framework", size: 1024}], - totalSize: 1024, - totalCount: 1, - }); + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(cleanCacheStub.callCount, 1, "cleanCache should be called with 'yes' confirmation"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called with 'yes' confirmation"); }); test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { - const {cache, argv, stderrWriteStub, cleanCacheStub, getCacheInfoStub, mockRLInterface} = t.context; + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; // Test with small bytes (B), KB, and GB sizes - getCacheInfoStub.resolves([ - {path: "small", size: 512, type: "directory"}, // < 1024 = B - {path: "medium", size: 50 * 1024, type: "directory"}, // KB - {path: "large", size: 2 * 1024 * 1024 * 1024, type: "directory"}, // GB - ]); + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); // < 1024 = B + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); // KB mockRLInterface.question.callsFake((question, callback) => { callback("y"); }); - cleanCacheStub.resolves({ - entries: [ - {path: "small", type: "directory", size: 512}, - {path: "medium", type: "directory", size: 50 * 1024}, - {path: "large", type: "directory", size: 2 * 1024 * 1024 * 1024}, - ], - totalSize: 2 * 1024 * 1024 * 1024 + 50 * 1024 + 512, - totalCount: 3, - }); + frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); + buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -212,23 +205,23 @@ test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("512 B"), "Shows bytes format"); t.true(allOutput.includes("50.0 KB"), "Shows KB format"); - t.true(allOutput.includes("2.0 GB"), "Shows GB format"); }); test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { - const {cache, argv, getCacheInfoStub} = t.context; + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const originalEnv = process.env.UI5_DATA_DIR; try { process.env.UI5_DATA_DIR = "/custom/ui5/path"; - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); - t.true(getCacheInfoStub.firstCall.args[0].ui5DataDir.includes("custom/ui5/path"), + t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); + t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes("custom/ui5/path"), "Uses environment variable path"); } finally { if (originalEnv) { @@ -240,19 +233,20 @@ test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => }); test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => { - const {cache, argv, getCacheInfoStub, Configuration} = t.context; + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, Configuration} = t.context; const originalEnv = process.env.UI5_DATA_DIR; try { delete process.env.UI5_DATA_DIR; Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"})); - getCacheInfoStub.resolves([]); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getCacheInfoStub.callCount, 1, "getCacheInfo called"); + t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); } finally { if (originalEnv) { process.env.UI5_DATA_DIR = originalEnv; diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 02fe11e1714..28c5e16c680 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -566,4 +566,15 @@ export default class BuildCacheStorage { this.#db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); this.#db.close(); } + + /** + * Get the total size of the database file + * + * @returns {number} Database size in bytes + */ + getDatabaseSize() { + const pageCount = this.#db.prepare("PRAGMA page_count").get().page_count; + const pageSize = this.#db.prepare("PRAGMA page_size").get().page_size; + return pageCount * pageSize; + } } diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index d36271e9fd9..1af7788a647 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -10,7 +10,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -export const CACHE_VERSION = "v0_7"; +const CACHE_VERSION = "v0_7"; /** * Manages persistence for the build cache using a unified SQLite-backed storage @@ -337,4 +337,66 @@ export default class CacheManager { cacheManagerInstances.delete(this.#cacheDir); } } + + /** + * Get build cache info for the current version. + * + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null + */ + static async getCacheInfo(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + type: "database" + }; + } + } finally { + storage.close(); + } + } catch { + // Skip if database can't be opened + } + return null; + } + + /** + * Clean build cache by clearing all records from SQLite database for the current version. + * + * @static + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + */ + static async cleanCache(ui5DataDir) { + const buildCacheDir = path.join(ui5DataDir, "buildCache"); + const dbDir = path.join(buildCacheDir, CACHE_VERSION); + + try { + const storage = new BuildCacheStorage(dbDir); + try { + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + type: "buildCache", + size: freedSize + }; + } + } finally { + storage.close(); + } + } catch { + // Skip if database can't be cleared + } + return null; + } } diff --git a/packages/project/lib/cache/CacheCleanup.js b/packages/project/lib/cache/CacheCleanup.js deleted file mode 100644 index 7902ccea9eb..00000000000 --- a/packages/project/lib/cache/CacheCleanup.js +++ /dev/null @@ -1,144 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import BuildCacheStorage from "../build/cache/BuildCacheStorage.js"; -import {CACHE_VERSION} from "../build/cache/CacheManager.js"; - -/** - * Get the size of a directory tree recursively. - * - * @param {string} dirPath Absolute path to directory - * @returns {Promise} Total size in bytes - */ -async function getDirectorySize(dirPath) { - let total = 0; - let entries; - try { - entries = await fs.readdir(dirPath, {withFileTypes: true}); - } catch { - return 0; - } - for (const entry of entries) { - const entryPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - total += await getDirectorySize(entryPath); - } else { - try { - const stat = await fs.stat(entryPath); - total += stat.size; - } catch { - // Skip inaccessible files - } - } - } - return total; -} - -/** - * Check what cache items exist and their sizes without removing them. - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise>} List of cache items - */ -export async function getCacheInfo({ui5DataDir}) { - const items = []; - - // Check framework cache - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - items.push({ - path: "framework/", - size, - type: "directory" - }); - } - } catch { - // Directory doesn't exist - } - - // Check build cache (only current version) - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { - await fs.access(dbDir); - const storage = new BuildCacheStorage(dbDir); - try { - if (storage.hasRecords()) { - const size = await getDirectorySize(buildCacheDir); - items.push({ - path: `buildCache/${CACHE_VERSION} (database records)`, - size, - type: "database" - }); - } - } finally { - storage.close(); - } - } catch { - // Skip if database can't be opened or doesn't exist - } - - return items; -} - -/** - * Cleans cache directories for framework libraries and incremental build cache. - * - * Removes: - * - framework/ directory: All UI5 framework libraries, download cache, staging files, and locks - * - buildCache/ entries: Clears database records (preserves database files) - * - * @param {object} options - * @param {string} options.ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{entries: Array<{path: string, type: string, size: number}>, - * totalSize: number, totalCount: number}>} - */ -export async function cleanCache({ui5DataDir}) { - const allRemoved = []; - - // Clean framework cache - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - await fs.rm(frameworkDir, {recursive: true, force: true}); - allRemoved.push({ - path: "framework", - type: "framework", - size - }); - } - } catch { - // Directory doesn't exist or couldn't be removed - } - - // Clean build cache (only current version) - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - try { - await fs.access(dbDir); - const storage = new BuildCacheStorage(dbDir); - try { - const freedSize = storage.clearAllRecords(); - allRemoved.push({ - path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", - size: freedSize - }); - } finally { - storage.close(); - } - } catch { - // Database doesn't exist or couldn't be cleared - } - - const totalSize = allRemoved.reduce((sum, entry) => sum + entry.size, 0); - return { - entries: allRemoved, - totalSize, - totalCount: allRemoved.length, - }; -} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js new file mode 100644 index 00000000000..9d3b19b7448 --- /dev/null +++ b/packages/project/lib/ui5Framework/cache.js @@ -0,0 +1,80 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +/** + * Get the size of a directory tree recursively. + * + * @param {string} dirPath Absolute path to directory + * @returns {Promise} Total size in bytes + */ +async function getDirectorySize(dirPath) { + let total = 0; + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return 0; + } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + total += await getDirectorySize(entryPath); + } else { + try { + const stat = await fs.stat(entryPath); + total += stat.size; + } catch { + // Skip inaccessible files + } + } + } + return total; +} + +/** + * Get framework cache info. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + */ +export async function getCacheInfo(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + await fs.access(frameworkDir); + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + return { + path: "framework/", + size, + type: "directory" + }; + } + } catch { + // Directory doesn't exist + } + return null; +} + +/** + * Clean framework cache directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + */ +export async function cleanCache(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, "framework"); + try { + const size = await getDirectorySize(frameworkDir); + if (size > 0) { + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: "framework", + type: "framework", + size + }; + } + } catch { + // Directory doesn't exist or couldn't be removed + } + return null; +} diff --git a/packages/project/package.json b/packages/project/package.json index 802e2cd9685..6d26916972d 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -20,18 +20,19 @@ "exports": { "./config/Configuration": "./lib/config/Configuration.js", "./build/cache/Cache": "./lib/build/cache/Cache.js", + "./build/cache/CacheManager": "./lib/build/cache/CacheManager.js", "./specifications/Specification": "./lib/specifications/Specification.js", "./specifications/SpecificationVersion": "./lib/specifications/SpecificationVersion.js", "./ui5Framework/Sapui5MavenSnapshotResolver": "./lib/ui5Framework/Sapui5MavenSnapshotResolver.js", "./ui5Framework/Openui5Resolver": "./lib/ui5Framework/Openui5Resolver.js", "./ui5Framework/Sapui5Resolver": "./lib/ui5Framework/Sapui5Resolver.js", "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", + "./ui5Framework/cache": "./lib/ui5Framework/cache.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", "./graph/projectGraphBuilder": "./lib/graph/projectGraphBuilder.js", "./graph": "./lib/graph/graph.js", - "./cache/CacheCleanup": "./lib/cache/CacheCleanup.js", "./package.json": "./package.json" }, "engines": { diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index e02d0850d48..84736fa5409 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -203,3 +203,79 @@ test.serial("transaction: throwing rolls back metadata and content writes", asyn "Metadata should not exist after rollback"); cm.close(); }); + +test.serial("getCacheInfo: returns null for non-existent cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + const result = await CacheManager.getCacheInfo(testDir); + t.is(result, null); +}); + +test.serial("getCacheInfo: returns null for empty cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create empty cache + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const result = await CacheManager.getCacheInfo(testDir); + t.is(result, null); +}); + +test.serial("getCacheInfo: returns info for cache with records", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create cache with data + const cm = new CacheManager(path.join(testDir, "buildCache")); + await cm.writeIndexCache("proj", "sig", "source", {data: true}); + cm.close(); + + const result = await CacheManager.getCacheInfo(testDir); + t.truthy(result); + t.true(result.path.includes("buildCache")); + t.true(result.path.includes("v0_7")); + t.is(result.type, "database"); + t.true(result.size > 0); +}); + +test.serial("cleanCache: returns null for non-existent cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("cleanCache: returns null for empty cache", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create empty cache + const cm = new CacheManager(path.join(testDir, "buildCache")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.is(result, null); +}); + +test.serial("cleanCache: clears cache and returns result", async (t) => { + const testDir = getUniqueTestDir(); + const {default: CacheManager} = await import("../../../../lib/build/cache/CacheManager.js"); + // Create cache with data + const cm = new CacheManager(path.join(testDir, "buildCache")); + await cm.writeIndexCache("proj", "sig", "source", {data: true}); + cm.putContent("sha256-test", Buffer.from("content")); + cm.close(); + + const result = await CacheManager.cleanCache(testDir); + t.truthy(result); + t.true(result.path.includes("buildCache")); + t.true(result.path.includes("v0_7")); + t.is(result.type, "buildCache"); + t.true(result.size >= 0); + + // Verify cache is empty + const cm2 = new CacheManager(path.join(testDir, "buildCache")); + const check = await cm2.readIndexCache("proj", "sig", "source"); + t.is(check, null); + t.false(cm2.hasContent("sha256-test")); + cm2.close(); +}); diff --git a/packages/project/test/lib/cache/CacheCleanup.js b/packages/project/test/lib/cache/CacheCleanup.js deleted file mode 100644 index f9b4d784d20..00000000000 --- a/packages/project/test/lib/cache/CacheCleanup.js +++ /dev/null @@ -1,267 +0,0 @@ -import test from "ava"; -import path from "node:path"; -import fs from "node:fs/promises"; -import {rimraf} from "rimraf"; -import {cleanCache, getCacheInfo} from "../../../lib/cache/CacheCleanup.js"; - -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "CacheCleanup"); - -test.after.always(async () => { - await rimraf(TEST_DIR).catch(() => {}); -}); - -function createTestDir(t) { - const dir = path.join(TEST_DIR, `test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - t.context.ui5DataDir = dir; - return dir; -} - -async function createPackage(ui5DataDir, scope, name, version, {mtime} = {}) { - const pkgDir = path.join(ui5DataDir, "framework", "packages", scope, name, version); - await fs.mkdir(pkgDir, {recursive: true}); - const filePath = path.join(pkgDir, "package.json"); - await fs.writeFile(filePath, JSON.stringify({name: `${scope}/${name}`, version})); - if (mtime) { - await fs.utimes(filePath, mtime, mtime); - } -} - -// ===== cleanCache tests ===== - -test("cleanCache: returns empty result for nonexistent directory", async (t) => { - const result = await cleanCache({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); - t.is(result.totalCount, 0); - t.is(result.totalSize, 0); - t.deepEqual(result.entries, []); -}); - -test("cleanCache: clean all removes framework packages", async (t) => { - const ui5DataDir = createTestDir(t); - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - await createPackage(ui5DataDir, "@openui5", "sap.m", "1.120.0"); - - const result = await cleanCache({ui5DataDir}); - - t.true(result.totalCount >= 1); - const frameworkEntries = result.entries.filter((e) => e.type === "framework"); - t.is(frameworkEntries.length, 1); - t.is(frameworkEntries[0].path, "framework"); -}); - -test("cleanCache: clean all clears buildCache database", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); - db.exec("INSERT INTO index_cache VALUES ('proj', 'sig', 'source', 'data')"); - db.close(); - - const result = await cleanCache({ui5DataDir}); - - const buildCacheEntry = result.entries.find((e) => e.type === "buildCache"); - t.truthy(buildCacheEntry); - - await fs.access(buildCacheDir); - await fs.access(dbPath); - - const dbAfter = new DatabaseSync(dbPath); - const contentCount = dbAfter.prepare("SELECT COUNT(*) as count FROM content").get().count; - const indexCount = dbAfter.prepare("SELECT COUNT(*) as count FROM index_cache").get().count; - t.is(contentCount, 0); - t.is(indexCount, 0); - dbAfter.close(); -}); - -test("cleanCache: skips empty framework directory", async (t) => { - const ui5DataDir = createTestDir(t); - const frameworkDir = path.join(ui5DataDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - - const result = await cleanCache({ui5DataDir}); - - t.is(result.totalCount, 0); - const frameworkEntries = result.entries.filter((e) => e.type === "framework"); - t.is(frameworkEntries.length, 0); -}); - -test("cleanCache: cleans both framework and build cache", async (t) => { - const ui5DataDir = createTestDir(t); - - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT); - CREATE TABLE index_cache (project_id TEXT); - CREATE TABLE stage_metadata (project_id TEXT); - CREATE TABLE task_metadata (project_id TEXT); - CREATE TABLE result_metadata (project_id TEXT); - `); - db.exec("INSERT INTO content VALUES ('test')"); - db.close(); - - const result = await cleanCache({ui5DataDir}); - - t.true(result.totalCount >= 1); // At least framework - t.truthy(result.entries.find((e) => e.type === "framework")); - t.true(result.totalSize > 0); - // Build cache may also be cleaned - if (result.totalCount === 2) { - t.truthy(result.entries.find((e) => e.type === "buildCache")); - } -}); - -test("cleanCache: handles corrupted database gracefully", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); - - const result = await cleanCache({ui5DataDir}); - - t.pass(); - const buildEntries = result.entries.filter((e) => e.type === "buildCache"); - t.is(buildEntries.length, 0); -}); - -// ===== getCacheInfo tests ===== - -test("getCacheInfo: returns empty array for nonexistent directory", async (t) => { - const items = await getCacheInfo({ui5DataDir: "/tmp/nonexistent-ui5-dir-test"}); - t.deepEqual(items, []); -}); - -test("getCacheInfo: detects framework cache with size", async (t) => { - const ui5DataDir = createTestDir(t); - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const items = await getCacheInfo({ui5DataDir}); - - const frameworkItem = items.find((item) => item.path === "framework/"); - t.truthy(frameworkItem); - t.true(frameworkItem.size > 0); - t.is(frameworkItem.type, "directory"); -}); - -test("getCacheInfo: skips empty framework directory", async (t) => { - const ui5DataDir = createTestDir(t); - const frameworkDir = path.join(ui5DataDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - - const items = await getCacheInfo({ui5DataDir}); - - const frameworkItem = items.find((item) => item.path === "framework/"); - t.falsy(frameworkItem); -}); - -test("getCacheInfo: detects build cache with records", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.exec("INSERT INTO content VALUES ('test-integrity', 'test-data')"); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - const buildItem = items.find((item) => item.type === "database"); - t.truthy(buildItem); - t.is(buildItem.path, "buildCache/v0_7 (database records)"); - t.true(buildItem.size > 0); -}); - -test("getCacheInfo: skips build cache with no records", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT PRIMARY KEY, data BLOB); - CREATE TABLE index_cache (project_id TEXT, build_signature TEXT, kind TEXT, data BLOB); - CREATE TABLE stage_metadata - (project_id TEXT, build_signature TEXT, stage_id TEXT, stage_signature TEXT, data BLOB); - CREATE TABLE task_metadata (project_id TEXT, build_signature TEXT, task_name TEXT, type TEXT, data BLOB); - CREATE TABLE result_metadata (project_id TEXT, build_signature TEXT, stage_signature TEXT, data BLOB); - `); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - const buildItem = items.find((item) => item.type === "database"); - t.falsy(buildItem); -}); - -test("getCacheInfo: handles corrupted database gracefully", async (t) => { - const ui5DataDir = createTestDir(t); - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - - await fs.writeFile(path.join(buildCacheDir, "cache.db"), "not a valid database"); - - const items = await getCacheInfo({ui5DataDir}); - - t.pass(); - const buildItem = items.find((item) => item.type === "database"); - t.falsy(buildItem); -}); - -test("getCacheInfo: detects both framework and build cache", async (t) => { - const ui5DataDir = createTestDir(t); - - await createPackage(ui5DataDir, "@openui5", "sap.ui.core", "1.120.0"); - - const buildCacheDir = path.join(ui5DataDir, "buildCache", "v0_7"); - await fs.mkdir(buildCacheDir, {recursive: true}); - const {DatabaseSync} = await import("node:sqlite"); - const dbPath = path.join(buildCacheDir, "cache.db"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE content (integrity TEXT); - CREATE TABLE index_cache (project_id TEXT); - CREATE TABLE stage_metadata (project_id TEXT); - CREATE TABLE task_metadata (project_id TEXT); - CREATE TABLE result_metadata (project_id TEXT); - `); - db.exec("INSERT INTO content VALUES ('test')"); - db.close(); - - const items = await getCacheInfo({ui5DataDir}); - - t.true(items.length >= 1); // At least framework - t.truthy(items.find((item) => item.path === "framework/")); - // Build cache may also be detected - if (items.length === 2) { - t.truthy(items.find((item) => item.type === "database")); - } -}); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index ec16c6e22bc..35f7a032be3 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 15); + t.is(Object.keys(packageJson.exports).length, 16); }); // Public API contract (exported modules) diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js new file mode 100644 index 00000000000..c09c80708c0 --- /dev/null +++ b/packages/project/test/lib/ui5framework/cache.js @@ -0,0 +1,101 @@ +import test from "ava"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; + +test.beforeEach(async (t) => { + const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + +test.afterEach.always(async (t) => { + if (t.context.testDir) { + await fs.rm(t.context.testDir, {recursive: true, force: true}); + } +}); + +test("getCacheInfo: empty directory returns null", async (t) => { + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: non-existent directory returns null", async (t) => { + const nonExistent = path.join(t.context.testDir, "does-not-exist"); + const result = await getCacheInfo(nonExistent); + t.is(result, null); +}); + +test("getCacheInfo: detects framework directory with files", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework/"); + t.is(result.type, "directory"); + t.true(result.size > 0); +}); + +test("getCacheInfo: returns null for empty framework directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: calculates size recursively", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const subDir = path.join(frameworkDir, "packages"); + await fs.mkdir(subDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "file1.txt"), "test1"); + await fs.writeFile(path.join(subDir, "file2.txt"), "test2"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.true(result.size >= 10); // At least 5 + 5 bytes +}); + +test("cleanCache: returns null for non-existent directory", async (t) => { + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: returns null for empty directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + + const result = await cleanCache(t.context.testDir); + t.is(result, null); +}); + +test("cleanCache: removes framework directory", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + await fs.mkdir(frameworkDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.is(result.type, "framework"); + t.true(result.size > 0); + + // Verify directory was removed + await t.throwsAsync(fs.access(frameworkDir)); +}); + +test("cleanCache: removes nested directories", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const subDir = path.join(frameworkDir, "packages"); + await fs.mkdir(subDir, {recursive: true}); + await fs.writeFile(path.join(subDir, "test.txt"), "content"); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + + // Verify directory and subdirectories were removed + await t.throwsAsync(fs.access(frameworkDir)); +}); From 9ee230e8ac0eb33f8602aef6dd756826953b09a5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 1 Jun 2026 13:44:16 +0300 Subject: [PATCH 012/114] refactor: Add skip confirmation option --- packages/cli/lib/cli/commands/cache.js | 30 +++++++++++++------- packages/cli/test/lib/cli/commands/cache.js | 31 ++++++++++++++++++++- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 87108617141..21408a64596 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -20,15 +20,21 @@ cacheCommand.builder = function(cli) { .demandCommand(1, "Command required. Available command is 'clean'") .command("clean", "Remove all cached UI5 data", { handler: handleCache, - builder: noop, + builder: function(yargs) { + return yargs.option("interactive", { + describe: "Show confirmation prompt before cleaning. Use --no-interactive to skip (e.g. for CI)", + default: true, + type: "boolean", + }); + }, middlewares: [baseMiddleware], }) .example("$0 cache clean", - "Remove all cached UI5 data"); + "Remove all cached UI5 data") + .example("$0 cache clean --no-interactive", + "Remove all cached UI5 data without confirmation (CI mode)"); }; -function noop() {} - /** * Format a byte size as a human-readable string. * @@ -66,7 +72,9 @@ async function confirm(question) { }); } -async function handleCache() { +async function handleCache(argv) { + const interactive = argv?.interactive !== false; + // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { @@ -105,11 +113,13 @@ async function handleCache() { } process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); - // Ask for confirmation - const confirmed = await confirm("Do you want to continue? (y/N) "); - if (!confirmed) { - process.stderr.write("Cancelled\n"); - return; + // Ask for confirmation (skip in non-interactive mode) + if (interactive) { + const confirmed = await confirm("Do you want to continue? (y/N) "); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } } // Perform the actual cleanup (orchestrate both domains) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 06ab7e9d61b..aa5f81e4216 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -71,7 +71,7 @@ test("Command builder", async (t) => { t.is(result, cliStub, "Builder returns cli instance"); t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); t.is(cliStub.command.callCount, 1, "command called once"); - t.is(cliStub.example.callCount, 1, "example called once"); + t.is(cliStub.example.callCount, 2, "example called twice"); }); test.serial("ui5 cache clean: nothing to clean", async (t) => { @@ -253,3 +253,32 @@ test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async } } }); + +test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves({ + path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024, type: "database" + }); + + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 10 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["interactive"] = false; + await cache.handler(argv); + + // Confirmation should NOT be asked + t.is(mockRLInterface.question.callCount, 0, "Should not ask for confirmation in non-interactive mode"); + + // Cleanup should still proceed + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); + + // Check output + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); + t.true(allOutput.includes("Success"), "Shows success message"); +}); From 1997aff0f89c1749e2bae51f091c7002a2a107e8 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 1 Jun 2026 14:07:50 +0300 Subject: [PATCH 013/114] fix: Windows paths for tests --- packages/cli/test/lib/cli/commands/cache.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index aa5f81e4216..1b52bf2f45b 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -1,4 +1,5 @@ import test from "ava"; +import path from "node:path"; import sinon from "sinon"; import esmock from "esmock"; import Configuration from "@ui5/project/config/Configuration"; @@ -221,7 +222,7 @@ test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); - t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes("custom/ui5/path"), + t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes(path.join("custom", "ui5", "path")), "Uses environment variable path"); } finally { if (originalEnv) { From bc4f3c008e5d6314683c2740be7ed65756260cb5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 11:59:13 +0300 Subject: [PATCH 014/114] refactor: Use yesno package for CLI confirmation --- package-lock.json | 3 +- packages/cli/lib/cli/commands/cache.js | 42 +++----- packages/cli/package.json | 3 +- packages/cli/test/lib/cli/commands/cache.js | 109 +++++++++++--------- 4 files changed, 75 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67d2a28a0e1..8c2c29af03c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18349,7 +18349,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "bin": { "ui5": "bin/ui5.cjs" diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 21408a64596..1217995a867 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -2,7 +2,6 @@ import chalk from "chalk"; import path from "node:path"; import os from "node:os"; import process from "node:process"; -import readline from "node:readline"; import baseMiddleware from "../middlewares/base.js"; import Configuration from "@ui5/project/config/Configuration"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; @@ -21,9 +20,10 @@ cacheCommand.builder = function(cli) { .command("clean", "Remove all cached UI5 data", { handler: handleCache, builder: function(yargs) { - return yargs.option("interactive", { - describe: "Show confirmation prompt before cleaning. Use --no-interactive to skip (e.g. for CI)", - default: true, + return yargs.option("yes", { + alias: "y", + describe: "Skip confirmation prompt (e.g. for CI)", + default: false, type: "boolean", }); }, @@ -31,7 +31,7 @@ cacheCommand.builder = function(cli) { }) .example("$0 cache clean", "Remove all cached UI5 data") - .example("$0 cache clean --no-interactive", + .example("$0 cache clean --yes", "Remove all cached UI5 data without confirmation (CI mode)"); }; @@ -52,29 +52,7 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } -/** - * Prompt user for confirmation. - * - * @param {string} question The question to ask - * @returns {Promise} True if user confirmed - */ -async function confirm(question) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stderr - }); - - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); - }); - }); -} - async function handleCache(argv) { - const interactive = argv?.interactive !== false; - // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { @@ -113,9 +91,13 @@ async function handleCache(argv) { } process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); - // Ask for confirmation (skip in non-interactive mode) - if (interactive) { - const confirmed = await confirm("Do you want to continue? (y/N) "); + // Ask for confirmation (skip with --yes) + if (!argv.yes) { + const {default: yesno} = await import("yesno"); + const confirmed = await yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); if (!confirmed) { process.stderr.write("Cancelled\n"); return; diff --git a/packages/cli/package.json b/packages/cli/package.json index c61cd5231af..2444778b3d2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,7 +64,8 @@ "pretty-hrtime": "^1.0.3", "semver": "^7.8.5", "update-notifier": "^7.3.1", - "yargs": "^18.0.0" + "yargs": "^18.0.0", + "yesno": "^0.4.0" }, "devDependencies": { "@istanbuljs/esm-loader-hook": "^0.3.0", diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 1b52bf2f45b..9db1d349f33 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -29,13 +29,8 @@ test.beforeEach(async (t) => { t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); - // Mock readline to simulate user confirmation - const mockRLInterface = { - question: sinon.stub(), - close: sinon.stub() - }; - t.context.readlineCreateInterfaceStub = sinon.stub().returns(mockRLInterface); - t.context.mockRLInterface = mockRLInterface; + // Mock yesno to simulate user confirmation + t.context.yesnoStub = sinon.stub(); t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/config/Configuration": t.context.Configuration, @@ -49,8 +44,8 @@ test.beforeEach(async (t) => { static cleanCache = t.context.buildCacheCleanCache; } }, - "node:readline": { - createInterface: t.context.readlineCreateInterfaceStub, + "yesno": { + default: t.context.yesnoStub, }, }); }); @@ -93,7 +88,7 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { test.serial("ui5 cache clean: removes entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); @@ -102,9 +97,7 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { }); // Mock user confirmation - mockRLInterface.question.callsFake((question, callback) => { - callback("y"); - }); + yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); @@ -113,8 +106,8 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { await cache.handler(argv); // Check that confirmation was asked - t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); - t.true(mockRLInterface.question.firstCall.args[0].includes("continue"), + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); + t.true(yesnoStub.firstCall.args[0].question.includes("continue"), "Confirmation question should ask to continue"); // Check that cleanCache was called @@ -130,22 +123,20 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { test.serial("ui5 cache clean: user cancels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); buildCacheGetCacheInfo.resolves(null); // Mock user cancellation - mockRLInterface.question.callsFake((question, callback) => { - callback("n"); - }); + yesnoStub.resolves(false); argv["_"] = ["cache", "clean"]; await cache.handler(argv); // Check that confirmation was asked - t.is(mockRLInterface.question.callCount, 1, "Should ask for confirmation"); + t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); // Check that cleanup was NOT called t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); @@ -166,36 +157,15 @@ test.serial("Command definition is correct", (t) => { t.is(typeof t.context.cache.handler, "function"); }); -test.serial("ui5 cache clean: accepts 'yes' as confirmation", async (t) => { - const {cache, argv, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheGetCacheInfo, mockRLInterface} = t.context; - - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 1024, type: "directory"}); - buildCacheGetCacheInfo.resolves(null); - - mockRLInterface.question.callsFake((question, callback) => { - callback("yes"); - }); - - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 1024}); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called with 'yes' confirmation"); -}); - test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Test with small bytes (B), KB, and GB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); // < 1024 = B - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); // KB + // Test with B, KB sizes + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); - mockRLInterface.question.callsFake((question, callback) => { - callback("y"); - }); + yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); @@ -255,9 +225,9 @@ test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async } }); -test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async (t) => { +test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, mockRLInterface} = t.context; + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); buildCacheGetCacheInfo.resolves({ @@ -268,11 +238,11 @@ test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; - argv["interactive"] = false; + argv["yes"] = true; await cache.handler(argv); // Confirmation should NOT be asked - t.is(mockRLInterface.question.callCount, 0, "Should not ask for confirmation in non-interactive mode"); + t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); // Cleanup should still proceed t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); @@ -283,3 +253,42 @@ test.serial("ui5 cache clean --no-interactive: skips confirmation prompt", async t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Success"), "Shows success message"); }); + +test.serial("ui5 cache clean: single entry with zero size and GB formatting", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + + yesnoStub.resolves(true); + + frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 0}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 entry"), "Summary uses singular 'entry'"); + t.false(allOutput.includes("freed"), "Should not show 'freed' for zero-size removal"); + + // Reset and test GB formatting + stderrWriteStub.resetHistory(); + frameworkCacheGetCacheInfo.resetBehavior(); + frameworkCacheCleanCache.resetBehavior(); + buildCacheGetCacheInfo.resetBehavior(); + buildCacheCleanCache.resetBehavior(); + frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024, type: "directory"}); + buildCacheGetCacheInfo.resolves(null); + frameworkCacheCleanCache.resolves({path: "large", type: "directory", size: 2.5 * 1024 * 1024 * 1024}); + + argv["yes"] = true; + await cache.handler(argv); + + const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); +}); From 9b620ad83050a86b1002c20bd821e251e44e01c9 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 12:13:34 +0300 Subject: [PATCH 015/114] refactor: Simplify cleanup meta structure --- packages/cli/test/lib/cli/commands/cache.js | 34 +++++++++---------- .../project/lib/build/cache/CacheManager.js | 6 ++-- packages/project/lib/ui5Framework/cache.js | 6 ++-- .../test/lib/build/cache/CacheManager.js | 2 -- .../project/test/lib/ui5framework/cache.js | 2 -- 5 files changed, 21 insertions(+), 29 deletions(-) diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 9db1d349f33..a99f36670d0 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -91,16 +91,16 @@ test.serial("ui5 cache clean: removes entries and reports", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024}); buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024, type: "database" + path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024 }); // Mock user confirmation yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 15 * 1024 * 1024}); - buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 8 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", size: 15 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -126,7 +126,7 @@ test.serial("ui5 cache clean: user cancels", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024}); buildCacheGetCacheInfo.resolves(null); // Mock user cancellation @@ -162,13 +162,13 @@ test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Test with B, KB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512, type: "directory"}); - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024, type: "database"}); + frameworkCacheGetCacheInfo.resolves({path: "small", size: 512}); + buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024}); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "small", type: "directory", size: 512}); - buildCacheCleanCache.resolves({path: "medium", type: "database", size: 50 * 1024}); + frameworkCacheCleanCache.resolves({path: "small", size: 512}); + buildCacheCleanCache.resolves({path: "medium", size: 50 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -229,13 +229,13 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024}); buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024, type: "database" + path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024 }); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 10 * 1024 * 1024}); - buildCacheCleanCache.resolves({path: "buildCache/v0_7", type: "buildCache", size: 5 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", size: 10 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; argv["yes"] = true; @@ -259,12 +259,12 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0}); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", type: "framework", size: 0}); + frameworkCacheCleanCache.resolves({path: "framework", size: 0}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -282,9 +282,9 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as frameworkCacheCleanCache.resetBehavior(); buildCacheGetCacheInfo.resetBehavior(); buildCacheCleanCache.resetBehavior(); - frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024, type: "directory"}); + frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); buildCacheGetCacheInfo.resolves(null); - frameworkCacheCleanCache.resolves({path: "large", type: "directory", size: 2.5 * 1024 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); argv["yes"] = true; await cache.handler(argv); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 1af7788a647..9a2006d76ca 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -343,7 +343,7 @@ export default class CacheManager { * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Build cache info or null + * @returns {Promise<{path: string, size: number}|null>} Build cache info or null */ static async getCacheInfo(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); @@ -357,7 +357,6 @@ export default class CacheManager { return { path: `buildCache/${CACHE_VERSION}`, size, - type: "database" }; } } finally { @@ -374,7 +373,7 @@ export default class CacheManager { * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + * @returns {Promise<{path: string, size: number}|null>} Removal result or null */ static async cleanCache(ui5DataDir) { const buildCacheDir = path.join(ui5DataDir, "buildCache"); @@ -387,7 +386,6 @@ export default class CacheManager { const freedSize = storage.clearAllRecords(); return { path: `buildCache/${CACHE_VERSION}`, - type: "buildCache", size: freedSize }; } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 9d3b19b7448..e738996b96d 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -35,7 +35,7 @@ async function getDirectorySize(dirPath) { * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number, type: string}|null>} Framework cache info or null + * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, "framework"); @@ -46,7 +46,6 @@ export async function getCacheInfo(ui5DataDir) { return { path: "framework/", size, - type: "directory" }; } } catch { @@ -59,7 +58,7 @@ export async function getCacheInfo(ui5DataDir) { * Clean framework cache directory. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, type: string, size: number}|null>} Removal result or null + * @returns {Promise<{path: string, size: number}|null>} Removal result or null */ export async function cleanCache(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, "framework"); @@ -69,7 +68,6 @@ export async function cleanCache(ui5DataDir) { await fs.rm(frameworkDir, {recursive: true, force: true}); return { path: "framework", - type: "framework", size }; } diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 84736fa5409..1a15505c2ab 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -234,7 +234,6 @@ test.serial("getCacheInfo: returns info for cache with records", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); - t.is(result.type, "database"); t.true(result.size > 0); }); @@ -269,7 +268,6 @@ test.serial("cleanCache: clears cache and returns result", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); - t.is(result.type, "buildCache"); t.true(result.size >= 0); // Verify cache is empty diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index c09c80708c0..a8eacf22455 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -35,7 +35,6 @@ test("getCacheInfo: detects framework directory with files", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework/"); - t.is(result.type, "directory"); t.true(result.size > 0); }); @@ -80,7 +79,6 @@ test("cleanCache: removes framework directory", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.is(result.type, "framework"); t.true(result.size > 0); // Verify directory was removed From 93022ae7c3c46e6413e49dfefe330b9a3e3d13a1 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 12:37:27 +0300 Subject: [PATCH 016/114] fix: Add guard to not accidently create a new DB --- packages/project/lib/build/cache/CacheManager.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 9a2006d76ca..5716891ebfa 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,5 +1,6 @@ import path from "node:path"; import os from "node:os"; +import {access} from "node:fs/promises"; import Configuration from "../../config/Configuration.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -349,6 +350,13 @@ export default class CacheManager { const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + try { const storage = new BuildCacheStorage(dbDir); try { @@ -379,6 +387,13 @@ export default class CacheManager { const buildCacheDir = path.join(ui5DataDir, "buildCache"); const dbDir = path.join(buildCacheDir, CACHE_VERSION); + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return null; + } + try { const storage = new BuildCacheStorage(dbDir); try { From d6e445715eb8f4c5c4386159e9b560dfa46203a9 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 15:56:49 +0300 Subject: [PATCH 017/114] refactor: Reuse meta from installers in cache cleanup --- packages/cli/lib/cli/commands/cache.js | 10 +++ packages/cli/test/lib/cli/commands/cache.js | 47 +++++++++++++- .../lib/ui5Framework/AbstractInstaller.js | 5 +- .../lib/ui5Framework/_frameworkPaths.js | 61 +++++++++++++++++++ packages/project/lib/ui5Framework/cache.js | 60 ++++++++++++------ .../lib/ui5Framework/maven/Installer.js | 9 +-- .../project/lib/ui5Framework/npm/Installer.js | 7 ++- .../project/test/lib/ui5framework/cache.js | 43 +++++++++++++ 8 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 packages/project/lib/ui5Framework/_frameworkPaths.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 1217995a867..4933c9af9ca 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -65,6 +65,16 @@ async function handleCache(argv) { ui5DataDir = path.join(os.homedir(), ".ui5"); } + // Abort early if a framework operation is holding a lock — before prompting the user + if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { + process.stderr.write( + `${chalk.red("Error:")} Framework cache is currently locked by an active operation. ` + + "Please wait for it to finish and try again.\n" + ); + process.exitCode = 1; + return; + } + // Check what items exist before cleaning (orchestrate both domains) const items = []; const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index a99f36670d0..4a524af22e1 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -26,6 +26,7 @@ test.beforeEach(async (t) => { t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheIsFrameworkLocked = sinon.stub().resolves(false); t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); @@ -36,7 +37,8 @@ test.beforeEach(async (t) => { "@ui5/project/config/Configuration": t.context.Configuration, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, - cleanCache: t.context.frameworkCacheCleanCache + cleanCache: t.context.frameworkCacheCleanCache, + isFrameworkLocked: t.context.frameworkCacheIsFrameworkLocked, }, "@ui5/project/build/cache/CacheManager": { default: class { @@ -53,6 +55,8 @@ test.beforeEach(async (t) => { test.afterEach.always((t) => { sinon.restore(); esmock.purge(t.context.cache); + // Reset exit code — some tests verify that the handler sets process.exitCode = 1 + process.exitCode = undefined; }); test("Command builder", async (t) => { @@ -292,3 +296,44 @@ test.serial("ui5 cache clean: single entry with zero size and GB formatting", as const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); }); + +test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + // Simulate active lock + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error (not Warning)"); + t.true(allOutput.includes("currently locked by an active operation"), "Shows lock conflict message"); + t.false(allOutput.includes("Success"), "Does not show success message"); + + // Neither getCacheInfo nor cleanCache should be called after a lock abort + t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo should not be called when locked"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); + +test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, + buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + + // Simulate active lock — --yes must NOT bypass the lock check + frameworkCacheIsFrameworkLocked.resolves(true); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Error:"), "Shows Error even with --yes"); + t.false(allOutput.includes("Success"), "Does not show success message"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.is(process.exitCode, 1, "Exit code should be 1"); +}); diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index e13dea7f6e0..6f155ad0799 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -2,6 +2,7 @@ import path from "node:path"; import {mkdirp} from "../utils/fs.js"; import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; +import {LOCK_STALE_MS, getFrameworkLockDir} from "./_frameworkPaths.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -22,7 +23,7 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = path.join(ui5DataDir, "framework", "locks"); + this._lockDir = getFrameworkLockDir(ui5DataDir); } async _synchronize(lockName, callback) { @@ -36,7 +37,7 @@ class AbstractInstaller { log.verbose("Locking " + lockPath); await lock(lockPath, { wait: 10000, - stale: 60000, + stale: LOCK_STALE_MS, retries: 10 }); try { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js new file mode 100644 index 00000000000..fd1dede136d --- /dev/null +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -0,0 +1,61 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; + +// Directory name for framework packages within ui5DataDir +export const FRAMEWORK_DIR_NAME = "framework"; + +// Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize +export const LOCK_STALE_MS = 60000; + +/** + * Resolve the absolute path to the framework directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework directory + */ +export function getFrameworkDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME); +} + +/** + * Resolve the absolute path to the framework locks directory within a UI5 data directory. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the framework locks directory + */ +export function getFrameworkLockDir(ui5DataDir) { + return path.join(ui5DataDir, FRAMEWORK_DIR_NAME, "locks"); +} + +/** + * Check whether any active (non-stale) lockfiles exist in the given locks directory, + * indicating an ongoing download or installation. + * + * @param {string} lockDir Absolute path to a locks directory + * @returns {Promise} True if any non-stale lockfiles are held + */ +export async function hasActiveLocks(lockDir) { + let entries; + try { + entries = await fs.readdir(lockDir); + } catch { + return false; + } + + const lockFiles = entries.filter((name) => name.endsWith(".lock")); + if (lockFiles.length === 0) { + return false; + } + + const {default: lockfile} = await import("lockfile"); + const check = promisify(lockfile.check); + for (const lockFileName of lockFiles) { + const lockPath = path.join(lockDir, lockFileName); + const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); + if (isLocked) { + return true; + } + } + return false; +} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index e738996b96d..7b6fcd4664e 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,13 +1,20 @@ -import path from "node:path"; import fs from "node:fs/promises"; +import path from "node:path"; +import { + FRAMEWORK_DIR_NAME, + getFrameworkDir, + getFrameworkLockDir, + hasActiveLocks, +} from "./_frameworkPaths.js"; /** * Get the size of a directory tree recursively. + * Returns 0 if the directory does not exist or any entry is unreadable. * * @param {string} dirPath Absolute path to directory * @returns {Promise} Total size in bytes */ -async function getDirectorySize(dirPath) { +export async function getDirectorySize(dirPath) { let total = 0; let entries; try { @@ -38,13 +45,13 @@ async function getDirectorySize(dirPath) { * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, "framework"); + const frameworkDir = getFrameworkDir(ui5DataDir); try { await fs.access(frameworkDir); const size = await getDirectorySize(frameworkDir); if (size > 0) { return { - path: "framework/", + path: FRAMEWORK_DIR_NAME + "/", size, }; } @@ -54,25 +61,44 @@ export async function getCacheInfo(ui5DataDir) { return null; } +/** + * Check whether an active (non-stale) framework lock is currently held, + * indicating an ongoing download or installation. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} True if an active lock is held + */ +export async function isFrameworkLocked(ui5DataDir) { + return hasActiveLocks(getFrameworkLockDir(ui5DataDir)); +} + /** * Clean framework cache directory. * + * Checks for active lockfiles before removing the directory to prevent + * deleting files while a download is in progress. + * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number}|null>} Removal result or null + * @throws {Error} If framework packages are currently being installed (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, "framework"); - try { - const size = await getDirectorySize(frameworkDir); - if (size > 0) { - await fs.rm(frameworkDir, {recursive: true, force: true}); - return { - path: "framework", - size - }; - } - } catch { - // Directory doesn't exist or couldn't be removed + const frameworkDir = getFrameworkDir(ui5DataDir); + const size = await getDirectorySize(frameworkDir); + if (size === 0) { + return null; } - return null; + + if (await hasActiveLocks(getFrameworkLockDir(ui5DataDir))) { + throw new Error( + "Framework cache is currently locked by an active operation. " + + "Please wait for it to finish and try again." + ); + } + + await fs.rm(frameworkDir, {recursive: true, force: true}); + return { + path: FRAMEWORK_DIR_NAME, + size, + }; } diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 2c8e45fb7f6..008ca0290e5 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -8,6 +8,7 @@ import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import SnapshotCache from "./SnapshotCache.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -33,10 +34,10 @@ class Installer extends AbstractInstaller { constructor({ui5DataDir, snapshotEndpointUrlCb, snapshotCache = SnapshotCache.Default}) { super(ui5DataDir); - this._artifactsDir = path.join(ui5DataDir, "framework", "artifacts"); - this._packagesDir = path.join(ui5DataDir, "framework", "packages"); - this._metadataDir = path.join(ui5DataDir, "framework", "metadata"); - this._stagingDir = path.join(ui5DataDir, "framework", "staging"); + this._artifactsDir = path.join(getFrameworkDir(ui5DataDir), "artifacts"); + this._packagesDir = path.join(getFrameworkDir(ui5DataDir), "packages"); + this._metadataDir = path.join(getFrameworkDir(ui5DataDir), "metadata"); + this._stagingDir = path.join(getFrameworkDir(ui5DataDir), "staging"); this._snapshotCache = snapshotCache; this._snapshotEndpointUrlCb = snapshotEndpointUrlCb; diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 40d1dae9814..1e9fa2b9b13 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -5,6 +5,7 @@ import {promisify} from "node:util"; import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import {rmrf} from "../../utils/fs.js"; +import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const rename = promisify(fs.rename); @@ -27,15 +28,15 @@ class Installer extends AbstractInstaller { throw new Error(`Installer: Missing parameter "cwd"`); } this._packagesDir = packagesDir ? - path.resolve(packagesDir) : path.join(ui5DataDir, "framework", "packages"); + path.resolve(packagesDir) : path.join(getFrameworkDir(ui5DataDir), "packages"); log.verbose(`Installing to: ${this._packagesDir}`); this._cwd = cwd; this._caCacheDir = cacheDir ? - path.resolve(cacheDir) : path.join(ui5DataDir, "framework", "cacache"); + path.resolve(cacheDir) : path.join(getFrameworkDir(ui5DataDir), "cacache"); this._stagingDir = stagingDir ? - path.resolve(stagingDir) : path.join(ui5DataDir, "framework", "staging"); + path.resolve(stagingDir) : path.join(getFrameworkDir(ui5DataDir), "staging"); } getRegistry() { diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index a8eacf22455..20626b7e20e 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -2,8 +2,13 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; +const lockfileLock = promisify(lockfileLib.lock); +const lockfileUnlock = promisify(lockfileLib.unlock); + test.beforeEach(async (t) => { const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); await fs.mkdir(testDir, {recursive: true}); @@ -97,3 +102,41 @@ test("cleanCache: removes nested directories", async (t) => { // Verify directory and subdirectories were removed await t.throwsAsync(fs.access(frameworkDir)); }); + +test("cleanCache: throws when active lockfiles exist", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + const lockPath = path.join(lockDir, "test-package.lock"); + await lockfileLock(lockPath, {stale: 60000}); + try { + const err = await t.throwsAsync(cleanCache(t.context.testDir)); + t.true(err.message.includes("currently locked by an active operation")); + } finally { + await lockfileUnlock(lockPath); + } +}); + +test("cleanCache: removes directory when lockfiles are stale", async (t) => { + const frameworkDir = path.join(t.context.testDir, "framework"); + const lockDir = path.join(frameworkDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); + + // Create a real lock with a very short stale threshold, then wait for it to expire. + // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. + const lockPath = path.join(lockDir, "stale-package.lock"); + await lockfileLock(lockPath, {stale: 50}); // stale after 50ms + await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk + // Wait long enough for the 50ms threshold to pass + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await cleanCache(t.context.testDir); + t.truthy(result); + t.is(result.path, "framework"); + t.true(result.size > 0); + + await t.throwsAsync(fs.access(frameworkDir)); +}); From 92604c2c5455d229d5fedbb2a877c69a9901f2c7 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 18:54:32 +0300 Subject: [PATCH 018/114] refactor: Cleanup details --- packages/cli/lib/cli/commands/cache.js | 89 +++++--- packages/cli/test/lib/cli/commands/cache.js | 195 +++++++++--------- .../lib/build/cache/BuildCacheStorage.js | 6 +- .../project/lib/build/cache/CacheManager.js | 2 +- packages/project/lib/ui5Framework/cache.js | 34 ++- .../test/lib/build/cache/CacheManager.js | 2 + .../project/test/lib/ui5framework/cache.js | 11 +- 7 files changed, 180 insertions(+), 159 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 4933c9af9ca..b8d1d03b345 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -35,6 +35,11 @@ cacheCommand.builder = function(cli) { "Remove all cached UI5 data without confirmation (CI mode)"); }; +const LABEL_FRAMEWORK = "UI5 Framework packages"; +const LABEL_BUILD = "Build cache (DB)"; +// Pad labels to equal width for two-column alignment +const LABEL_WIDTH = Math.max(LABEL_FRAMEWORK.length, LABEL_BUILD.length); + /** * Format a byte size as a human-readable string. * @@ -52,6 +57,26 @@ function formatSize(bytes) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +/** + * Format a count with its singular/plural word, e.g. "340 files" or "1 file". + * + * @param {number} count + * @returns {string} + */ +function formatFileCount(count) { + return `${count} ${count === 1 ? "file" : "files"}`; +} + +/** + * Pad a label to the shared column width. + * + * @param {string} label + * @returns {string} + */ +function padLabel(label) { + return label.padEnd(LABEL_WIDTH); +} + async function handleCache(argv) { // Resolve UI5 data directory let ui5DataDir = process.env.UI5_DATA_DIR; @@ -76,30 +101,29 @@ async function handleCache(argv) { } // Check what items exist before cleaning (orchestrate both domains) - const items = []; const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); - if (frameworkInfo) { - items.push(frameworkInfo); - } const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); - if (buildInfo) { - items.push(buildInfo); - } - if (items.length === 0) { + if (!frameworkInfo && !buildInfo) { process.stderr.write("Nothing to clean\n"); return; } // Display items that will be removed - process.stderr.write(chalk.bold("\nThe following items from cache will be removed:\n")); - let totalSize = 0; - for (const item of items) { - totalSize += item.size; - const sizeStr = item.size > 0 ? ` (${formatSize(item.size)})` : ""; - process.stderr.write(` ${chalk.yellow("•")} ${item.path}${sizeStr}\n`); + process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); + if (frameworkInfo) { + const detail = formatFileCount(frameworkInfo.count); + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkInfo.path} (${detail})\n` + ); } - process.stderr.write(chalk.bold(`\nTotal: ${formatSize(totalSize)}\n\n`)); + if (buildInfo) { + const detail = buildInfo.size > 0 ? formatSize(buildInfo.size) : ""; + process.stderr.write( + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildInfo.path} (${detail})\n` + ); + } + process.stderr.write("\n"); // Ask for confirmation (skip with --yes) if (!argv.yes) { @@ -115,27 +139,34 @@ async function handleCache(argv) { } // Perform the actual cleanup (orchestrate both domains) - const removed = []; const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + const buildResult = await CacheManager.cleanCache(ui5DataDir); + + process.stderr.write("\n"); if (frameworkResult) { - removed.push(frameworkResult); + const detail = formatFileCount(frameworkResult.count); + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + + ` (${frameworkResult.path} · ${detail})\n` + ); } - const buildResult = await CacheManager.cleanCache(ui5DataDir); if (buildResult) { - removed.push(buildResult); + const detail = buildResult.size > 0 ? formatSize(buildResult.size) : ""; + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + + ` (${buildResult.path}${detail ? ` · ${detail}` : ""})\n` + ); } - process.stderr.write("\n"); - for (const entry of removed) { - const sizeStr = entry.size > 0 ? ` (${formatSize(entry.size)})` : ""; - process.stderr.write(`${chalk.green("✓")} Removed ${chalk.bold(entry.path)}${sizeStr}\n`); + // Success summary + const cleaned = []; + if (frameworkResult) { + cleaned.push(LABEL_FRAMEWORK); } - - const totalRemoved = removed.reduce((sum, entry) => sum + entry.size, 0); - process.stderr.write( - `\n${chalk.green("Success:")} Cleaned ${removed.length} ${removed.length === 1 ? "entry" : "entries"}` + - (totalRemoved > 0 ? `, freed ${formatSize(totalRemoved)}` : "") + "\n" - ); + if (buildResult) { + cleaned.push(LABEL_BUILD); + } + process.stderr.write(`\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`); } export default cacheCommand; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 4a524af22e1..3a27b08bcd0 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -60,7 +60,6 @@ test.afterEach.always((t) => { }); test("Command builder", async (t) => { - // Import cache module directly for builder test (before beforeEach stubs are created) const cacheModule = await import("../../../../lib/cli/commands/cache.js"); const cliStub = { demandCommand: sinon.stub().returnsThis(), @@ -74,11 +73,17 @@ test("Command builder", async (t) => { t.is(cliStub.example.callCount, 2, "example called twice"); }); +test.serial("Command definition is correct", (t) => { + t.is(t.context.cache.command, "cache"); + t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(typeof t.context.cache.builder, "function"); + t.is(typeof t.context.cache.handler, "function"); +}); + test.serial("ui5 cache clean: nothing to clean", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; - // Simulate no cache items frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -90,98 +95,133 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); }); -test.serial("ui5 cache clean: removes entries and reports", async (t) => { +test.serial("ui5 cache clean: removes both entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 15 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 8 * 1024 * 1024 - }); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 340}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); - // Mock user confirmation yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", size: 15 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", count: 340}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Check that confirmation was asked t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - t.true(yesnoStub.firstCall.args[0].question.includes("continue"), - "Confirmation question should ask to continue"); - - // Check that cleanCache was called t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); - t.true(allOutput.includes("2 entries"), "Summary mentions entry count"); - t.true(allOutput.includes("Success"), "Shows success message"); + // Pre-clean listing + t.true(allOutput.includes("UI5 Framework packages"), "Shows framework label"); + t.true(allOutput.includes("Build cache (DB)"), "Shows build cache label"); + t.true(allOutput.includes("framework/"), "Shows framework path"); + t.true(allOutput.includes("buildCache/v0_7"), "Shows build cache path"); + t.true(allOutput.includes("340 files"), "Shows framework file count"); + t.true(allOutput.includes("8.0 MB"), "Shows build cache size"); + t.false(allOutput.includes("Total:"), "Does not show total line"); + // Post-clean output + t.true(allOutput.includes("Removed UI5 Framework packages"), "Shows framework removed line"); + t.true(allOutput.includes("Removed Build cache (DB)"), "Shows build cache removed line"); + // Success line + t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), "Shows success summary"); }); test.serial("ui5 cache clean: user cancels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Simulate existing cache items - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 5 * 1024 * 1024}); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 10}); buildCacheGetCacheInfo.resolves(null); - // Mock user cancellation yesnoStub.resolves(false); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // Check that confirmation was asked t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - - // Check that cleanup was NOT called t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); t.false(allOutput.includes("Success"), "Should not show success message"); }); -test.serial("Command definition is correct", (t) => { - // Import without esmock for structure check - t.is(t.context.cache.command, "cache"); - t.is(t.context.cache.describe, "Manage UI5 CLI cache"); - t.is(typeof t.context.cache.builder, "function"); - t.is(typeof t.context.cache.handler, "function"); +test.serial("ui5 cache clean: framework only", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 1}); + buildCacheGetCacheInfo.resolves(null); + + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves({path: "framework", count: 1}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 file"), "Uses singular 'file'"); + t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); + t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); + t.false(allOutput.includes("and Build"), "Success does not mention build cache"); }); -test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { +test.serial("ui5 cache clean: build only", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - // Test with B, KB sizes - frameworkCacheGetCacheInfo.resolves({path: "small", size: 512}); - buildCacheGetCacheInfo.resolves({path: "medium", size: 50 * 1024}); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention framework"); + t.true(allOutput.includes("50.0 KB"), "Shows build cache size"); + t.true(allOutput.includes("Cleaned Build cache (DB)"), "Success mentions build cache only"); +}); + +test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheCleanCache.resolves({path: "small", size: 512}); - buildCacheCleanCache.resolves({path: "medium", size: 50 * 1024}); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); argv["_"] = ["cache", "clean"]; await cache.handler(argv); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("512 B"), "Shows bytes format"); t.true(allOutput.includes("50.0 KB"), "Shows KB format"); }); +test.serial("ui5 cache clean: formats GB sizes correctly", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2.5 GB"), "Shows GB format"); +}); + test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const originalEnv = process.env.UI5_DATA_DIR; @@ -233,89 +273,41 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 10 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves({ - path: "buildCache/v0_7 (database records)", size: 5 * 1024 * 1024 - }); + frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 100}); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); - frameworkCacheCleanCache.resolves({path: "framework", size: 10 * 1024 * 1024}); + frameworkCacheCleanCache.resolves({path: "framework", count: 100}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; argv["yes"] = true; await cache.handler(argv); - // Confirmation should NOT be asked t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); - - // Cleanup should still proceed t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); - // Check output const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("following items from cache will be removed"), "Shows items to be removed"); t.true(allOutput.includes("Success"), "Shows success message"); }); -test.serial("ui5 cache clean: single entry with zero size and GB formatting", async (t) => { - const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - - // Single cache item with size 0 — covers singular "entry", no "freed", and size=0 branches - frameworkCacheGetCacheInfo.resolves({path: "framework/", size: 0}); - buildCacheGetCacheInfo.resolves(null); - - yesnoStub.resolves(true); - - frameworkCacheCleanCache.resolves({path: "framework", size: 0}); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); - t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); - - const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("1 entry"), "Summary uses singular 'entry'"); - t.false(allOutput.includes("freed"), "Should not show 'freed' for zero-size removal"); - - // Reset and test GB formatting - stderrWriteStub.resetHistory(); - frameworkCacheGetCacheInfo.resetBehavior(); - frameworkCacheCleanCache.resetBehavior(); - buildCacheGetCacheInfo.resetBehavior(); - buildCacheCleanCache.resetBehavior(); - frameworkCacheGetCacheInfo.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); - buildCacheGetCacheInfo.resolves(null); - frameworkCacheCleanCache.resolves({path: "large", size: 2.5 * 1024 * 1024 * 1024}); - - argv["yes"] = true; - await cache.handler(argv); - - const gbOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(gbOutput.includes("2.5 GB"), "Shows GB format"); -}); - test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; - // Simulate active lock frameworkCacheIsFrameworkLocked.resolves(true); argv["_"] = ["cache", "clean"]; await cache.handler(argv); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("Error:"), "Shows Error (not Warning)"); - t.true(allOutput.includes("currently locked by an active operation"), "Shows lock conflict message"); - t.false(allOutput.includes("Success"), "Does not show success message"); - - // Neither getCacheInfo nor cleanCache should be called after a lock abort - t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo should not be called when locked"); - t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.true(allOutput.includes("Error:"), "Shows Error"); + t.true(allOutput.includes("currently locked by an active operation"), "Shows lock message"); + t.false(allOutput.includes("Success"), "Does not show success"); + + t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo not called when locked"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); t.is(process.exitCode, 1, "Exit code should be 1"); }); @@ -323,7 +315,6 @@ test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; - // Simulate active lock — --yes must NOT bypass the lock check frameworkCacheIsFrameworkLocked.resolves(true); argv["_"] = ["cache", "clean"]; @@ -332,8 +323,8 @@ test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Error:"), "Shows Error even with --yes"); - t.false(allOutput.includes("Success"), "Does not show success message"); - t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache should not be called when locked"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when locked"); + t.false(allOutput.includes("Success"), "Does not show success"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); t.is(process.exitCode, 1, "Exit code should be 1"); }); diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 28c5e16c680..9de0deb7501 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -545,13 +545,15 @@ export default class BuildCacheStorage { hasRecords() { const tables = ["content", "index_cache", "stage_metadata", "task_metadata", "result_metadata"]; for (const table of tables) { - const count = this.#db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0; - if (count > 0) { + const {is_populated: isPopulated} = + this.#db.prepare(`SELECT EXISTS(SELECT 1 FROM ${table} LIMIT 1) as is_populated`).get(); + if (isPopulated) { return true; } } return false; } + /** * Closes the database connection */ diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 5716891ebfa..bb8769ca5ca 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -401,7 +401,7 @@ export default class CacheManager { const freedSize = storage.clearAllRecords(); return { path: `buildCache/${CACHE_VERSION}`, - size: freedSize + size: freedSize, }; } } finally { diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 7b6fcd4664e..8a0485eebaa 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -8,13 +8,13 @@ import { } from "./_frameworkPaths.js"; /** - * Get the size of a directory tree recursively. + * Count all files in a directory tree recursively. * Returns 0 if the directory does not exist or any entry is unreadable. * * @param {string} dirPath Absolute path to directory - * @returns {Promise} Total size in bytes + * @returns {Promise} Total file count */ -export async function getDirectorySize(dirPath) { +async function countFiles(dirPath) { let total = 0; let entries; try { @@ -23,16 +23,10 @@ export async function getDirectorySize(dirPath) { return 0; } for (const entry of entries) { - const entryPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - total += await getDirectorySize(entryPath); + total += await countFiles(path.join(dirPath, entry.name)); } else { - try { - const stat = await fs.stat(entryPath); - total += stat.size; - } catch { - // Skip inaccessible files - } + total++; } } return total; @@ -42,17 +36,17 @@ export async function getDirectorySize(dirPath) { * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number}|null>} Framework cache info or null + * @returns {Promise<{path: string, count: number}|null>} Framework cache info or null */ export async function getCacheInfo(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); try { await fs.access(frameworkDir); - const size = await getDirectorySize(frameworkDir); - if (size > 0) { + const count = await countFiles(frameworkDir); + if (count > 0) { return { path: FRAMEWORK_DIR_NAME + "/", - size, + count, }; } } catch { @@ -79,13 +73,13 @@ export async function isFrameworkLocked(ui5DataDir) { * deleting files while a download is in progress. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, size: number}|null>} Removal result or null - * @throws {Error} If framework packages are currently being installed (active lockfiles detected) + * @returns {Promise<{path: string, count: number}|null>} Removal result or null + * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); - const size = await getDirectorySize(frameworkDir); - if (size === 0) { + const count = await countFiles(frameworkDir); + if (count === 0) { return null; } @@ -99,6 +93,6 @@ export async function cleanCache(ui5DataDir) { await fs.rm(frameworkDir, {recursive: true, force: true}); return { path: FRAMEWORK_DIR_NAME, - size, + count, }; } diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 1a15505c2ab..e6415e6c527 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -234,6 +234,7 @@ test.serial("getCacheInfo: returns info for cache with records", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); + t.true(result.size > 0); }); @@ -268,6 +269,7 @@ test.serial("cleanCache: clears cache and returns result", async (t) => { t.truthy(result); t.true(result.path.includes("buildCache")); t.true(result.path.includes("v0_7")); + t.true(result.size >= 0); // Verify cache is empty diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 20626b7e20e..41105323333 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -40,7 +40,7 @@ test("getCacheInfo: detects framework directory with files", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework/"); - t.true(result.size > 0); + t.is(result.count, 1); }); test("getCacheInfo: returns null for empty framework directory", async (t) => { @@ -51,7 +51,7 @@ test("getCacheInfo: returns null for empty framework directory", async (t) => { t.is(result, null); }); -test("getCacheInfo: calculates size recursively", async (t) => { +test("getCacheInfo: counts files recursively", async (t) => { const frameworkDir = path.join(t.context.testDir, "framework"); const subDir = path.join(frameworkDir, "packages"); await fs.mkdir(subDir, {recursive: true}); @@ -60,7 +60,7 @@ test("getCacheInfo: calculates size recursively", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.true(result.size >= 10); // At least 5 + 5 bytes + t.is(result.count, 2); }); test("cleanCache: returns null for non-existent directory", async (t) => { @@ -84,7 +84,7 @@ test("cleanCache: removes framework directory", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.true(result.size > 0); + t.is(result.count, 1); // Verify directory was removed await t.throwsAsync(fs.access(frameworkDir)); @@ -98,6 +98,7 @@ test("cleanCache: removes nested directories", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); + t.is(result.count, 1); // Verify directory and subdirectories were removed await t.throwsAsync(fs.access(frameworkDir)); @@ -136,7 +137,7 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.true(result.size > 0); + t.is(result.count, 1); // only test.txt remains — stale lock file is deleted by lockfileUnlock await t.throwsAsync(fs.access(frameworkDir)); }); From eaaf573876d928e041b98f98d48af32e87338b92 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 20:43:18 +0300 Subject: [PATCH 019/114] fix: Respect datadir config --- packages/cli/lib/cli/commands/cache.js | 42 ++-- packages/cli/lib/framework/utils.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 215 +++++++++++------- packages/project/lib/ui5Framework/cache.js | 2 +- .../project/test/lib/ui5framework/cache.js | 2 +- 5 files changed, 155 insertions(+), 108 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index b8d1d03b345..31c3a0a2f7b 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,7 +3,7 @@ import path from "node:path"; import os from "node:os"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; -import Configuration from "@ui5/project/config/Configuration"; +import {getUi5DataDir} from "../../framework/utils.js"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -78,17 +78,11 @@ function padLabel(label) { } async function handleCache(argv) { - // Resolve UI5 data directory - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - if (ui5DataDir) { - ui5DataDir = path.resolve(process.cwd(), ui5DataDir); - } else { - ui5DataDir = path.join(os.homedir(), ".ui5"); - } + // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: + // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 + // Relative paths are resolved against process.cwd() (project root when invoked from the project). + const ui5DataDir = + (await getUi5DataDir({cwd: process.cwd()})) ?? path.join(os.homedir(), ".ui5"); // Abort early if a framework operation is holding a lock — before prompting the user if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { @@ -100,6 +94,9 @@ async function handleCache(argv) { return; } + // Inform the user immediately — getCacheInfo (especially countFiles) may take a moment + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + // Check what items exist before cleaning (orchestrate both domains) const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); @@ -109,18 +106,26 @@ async function handleCache(argv) { return; } + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + + // Capture build size now — reused for the ✓ line to avoid a before/after mismatch + // (getDatabaseSize ≠ VACUUM-freed bytes returned by clearAllRecords) + const buildPreSize = buildInfo?.size ?? 0; + // Display items that will be removed process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); if (frameworkInfo) { const detail = formatFileCount(frameworkInfo.count); process.stderr.write( - ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkInfo.path} (${detail})\n` + ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` ); } if (buildInfo) { - const detail = buildInfo.size > 0 ? formatSize(buildInfo.size) : ""; + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; process.stderr.write( - ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildInfo.path} (${detail})\n` + ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildAbsPath} (${detail})\n` ); } process.stderr.write("\n"); @@ -147,14 +152,15 @@ async function handleCache(argv) { const detail = formatFileCount(frameworkResult.count); process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + - ` (${frameworkResult.path} · ${detail})\n` + ` (${frameworkAbsPath} · ${detail})\n` ); } if (buildResult) { - const detail = buildResult.size > 0 ? formatSize(buildResult.size) : ""; + // Use pre-clean size so the number matches what was shown before confirmation + const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + - ` (${buildResult.path}${detail ? ` · ${detail}` : ""})\n` + ` (${buildAbsPath}${detail ? ` · ${detail}` : ""})\n` ); } diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 799c8a35253..3bf2d5cd82d 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -49,7 +49,7 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV }); } -async function getUi5DataDir({cwd}) { +export async function getUi5DataDir({cwd}) { // ENV var should take precedence over the dataDir from the configuration. let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 3a27b08bcd0..983bc8b0812 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -1,8 +1,8 @@ import test from "ava"; import path from "node:path"; +import os from "node:os"; import sinon from "sinon"; import esmock from "esmock"; -import Configuration from "@ui5/project/config/Configuration"; function getDefaultArgv() { return { @@ -16,13 +16,17 @@ function getDefaultArgv() { }; } +// Stable absolute path used as the resolved ui5DataDir in most tests +const TEST_UI5_DATA_DIR = path.resolve("/test/ui5/home"); + test.beforeEach(async (t) => { t.context.argv = getDefaultArgv(); - t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); - t.context.Configuration = Configuration; - sinon.stub(Configuration, "fromFile").resolves(new Configuration({})); + // Prevent real env var from leaking into tests + delete process.env.UI5_DATA_DIR; + + t.context.getUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); @@ -30,11 +34,12 @@ test.beforeEach(async (t) => { t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); - // Mock yesno to simulate user confirmation t.context.yesnoStub = sinon.stub(); t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { - "@ui5/project/config/Configuration": t.context.Configuration, + "../../../../lib/framework/utils.js": { + getUi5DataDir: t.context.getUi5DataDirStub, + }, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, cleanCache: t.context.frameworkCacheCleanCache, @@ -55,10 +60,12 @@ test.beforeEach(async (t) => { test.afterEach.always((t) => { sinon.restore(); esmock.purge(t.context.cache); - // Reset exit code — some tests verify that the handler sets process.exitCode = 1 process.exitCode = undefined; + delete process.env.UI5_DATA_DIR; }); +// ─── Command structure ────────────────────────────────────────────────────── + test("Command builder", async (t) => { const cacheModule = await import("../../../../lib/cli/commands/cache.js"); const cliStub = { @@ -80,6 +87,82 @@ test.serial("Command definition is correct", (t) => { t.is(typeof t.context.cache.handler, "function"); }); +// ─── ui5DataDir resolution ────────────────────────────────────────────────── + +test.serial("ui5 cache clean: passes process.cwd() to getUi5DataDir", async (t) => { + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(getUi5DataDirStub.callCount, 1, "getUi5DataDir called once"); + t.deepEqual(getUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, + "Passes {cwd: process.cwd()} to getUi5DataDir"); +}); + +test.serial("ui5 cache clean: falls back to ~/.ui5 when getUi5DataDir returns undefined", async (t) => { + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, + stderrWriteStub} = t.context; + + // Simulate no env var, no config — getUi5DataDir returns undefined + getUi5DataDirStub.resolves(undefined); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const expectedDefault = path.join(os.homedir(), ".ui5"); + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(expectedDefault), + "Falls back to ~/.ui5 and shows it in checking line"); + + // getCacheInfo called with the default path + t.is(frameworkCacheGetCacheInfo.callCount, 1, "getCacheInfo called"); + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], expectedDefault, + "getCacheInfo receives ~/.ui5 as ui5DataDir"); +}); + +test.serial("ui5 cache clean: uses resolved path from getUi5DataDir", async (t) => { + const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + // The stub returns TEST_UI5_DATA_DIR — verify it was passed to getCacheInfo + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, + "getCacheInfo receives the path returned by getUi5DataDir"); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), + "Resolved ui5DataDir shown in checking line"); +}); + +test.serial("ui5 cache clean: relative path from config is resolved via getUi5DataDir", async (t) => { + // getUi5DataDir already resolves relative paths against cwd — verify the cache + // command uses the already-resolved absolute path rather than doing its own resolution. + const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; + + const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); + getUi5DataDirStub.resolves(resolvedPath); + frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves(null); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + t.is(frameworkCacheGetCacheInfo.firstCall.args[0], resolvedPath, + "getCacheInfo receives the pre-resolved absolute path from getUi5DataDir"); +}); + +// ─── Basic flow ───────────────────────────────────────────────────────────── + test.serial("ui5 cache clean: nothing to clean", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo} = t.context; @@ -90,7 +173,9 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(stderrWriteStub.firstCall.firstArg, "Nothing to clean\n"); + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called"); t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); }); @@ -99,42 +184,51 @@ test.serial("ui5 cache clean: removes both entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 340}); + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 340}); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "framework", count: 340}); - buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); // VACUUM freed less argv["_"] = ["cache", "clean"]; await cache.handler(argv); t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called once"); - t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called once"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called once"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called once"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - // Pre-clean listing + + // Checking line with absolute path + t.true(allOutput.includes("Checking cache at"), "Prints checking line"); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); + + // Listing shows absolute paths + const expectedFrameworkAbs = path.join(TEST_UI5_DATA_DIR, "framework"); + const expectedBuildAbs = path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7"); + t.true(allOutput.includes(expectedFrameworkAbs), "Shows absolute framework path"); + t.true(allOutput.includes(expectedBuildAbs), "Shows absolute build cache path"); + + // Labels and detail t.true(allOutput.includes("UI5 Framework packages"), "Shows framework label"); t.true(allOutput.includes("Build cache (DB)"), "Shows build cache label"); - t.true(allOutput.includes("framework/"), "Shows framework path"); - t.true(allOutput.includes("buildCache/v0_7"), "Shows build cache path"); t.true(allOutput.includes("340 files"), "Shows framework file count"); - t.true(allOutput.includes("8.0 MB"), "Shows build cache size"); + t.true(allOutput.includes("8.0 MB"), "Shows build cache pre-clean size"); + t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size (pre-clean size reused)"); t.false(allOutput.includes("Total:"), "Does not show total line"); - // Post-clean output - t.true(allOutput.includes("Removed UI5 Framework packages"), "Shows framework removed line"); - t.true(allOutput.includes("Removed Build cache (DB)"), "Shows build cache removed line"); - // Success line - t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), "Shows success summary"); + + // Success + t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), + "Shows success summary"); }); test.serial("ui5 cache clean: user cancels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 10}); + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 10}); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(false); @@ -143,21 +237,20 @@ test.serial("ui5 cache clean: user cancels", async (t) => { await cache.handler(argv); t.is(yesnoStub.callCount, 1, "Should ask for confirmation"); - t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called when user cancels"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called when user cancels"); + t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when user cancels"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when user cancels"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Cancelled"), "Shows cancelled message"); - t.false(allOutput.includes("Success"), "Should not show success message"); + t.false(allOutput.includes("Success"), "Does not show success message"); }); test.serial("ui5 cache clean: framework only", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 1}); + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 1}); buildCacheGetCacheInfo.resolves(null); - yesnoStub.resolves(true); frameworkCacheCleanCache.resolves({path: "framework", count: 1}); @@ -168,16 +261,14 @@ test.serial("ui5 cache clean: framework only", async (t) => { t.true(allOutput.includes("1 file"), "Uses singular 'file'"); t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); - t.false(allOutput.includes("and Build"), "Success does not mention build cache"); }); test.serial("ui5 cache clean: build only", async (t) => { - const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves(null); + t.context.frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); - yesnoStub.resolves(true); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); @@ -191,10 +282,9 @@ test.serial("ui5 cache clean: build only", async (t) => { }); test.serial("ui5 cache clean: formats byte sizes correctly", async (t) => { - const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves(null); + t.context.frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 50 * 1024}); yesnoStub.resolves(true); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 50 * 1024}); @@ -222,60 +312,12 @@ test.serial("ui5 cache clean: formats GB sizes correctly", async (t) => { t.true(allOutput.includes("2.5 GB"), "Shows GB format"); }); -test.serial("ui5 cache clean: uses UI5_DATA_DIR from environment", async (t) => { - const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; - const originalEnv = process.env.UI5_DATA_DIR; - - try { - process.env.UI5_DATA_DIR = "/custom/ui5/path"; - - frameworkCacheGetCacheInfo.resolves(null); - buildCacheGetCacheInfo.resolves(null); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); - t.true(frameworkCacheGetCacheInfo.firstCall.args[0].includes(path.join("custom", "ui5", "path")), - "Uses environment variable path"); - } finally { - if (originalEnv) { - process.env.UI5_DATA_DIR = originalEnv; - } else { - delete process.env.UI5_DATA_DIR; - } - } -}); - -test.serial("ui5 cache clean: uses config.getUi5DataDir when no env var", async (t) => { - const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, Configuration} = t.context; - const originalEnv = process.env.UI5_DATA_DIR; - - try { - delete process.env.UI5_DATA_DIR; - - Configuration.fromFile.resolves(new Configuration({ui5DataDir: "/config/path"})); - frameworkCacheGetCacheInfo.resolves(null); - buildCacheGetCacheInfo.resolves(null); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(frameworkCacheGetCacheInfo.callCount, 1, "frameworkCache.getCacheInfo called"); - } finally { - if (originalEnv) { - process.env.UI5_DATA_DIR = originalEnv; - } - } -}); - test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework/", count: 100}); + frameworkCacheGetCacheInfo.resolves({path: "framework", count: 100}); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); - frameworkCacheCleanCache.resolves({path: "framework", count: 100}); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); @@ -284,8 +326,8 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { await cache.handler(argv); t.is(yesnoStub.callCount, 0, "Should not ask for confirmation with --yes"); - t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache should be called"); - t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache should be called"); + t.is(frameworkCacheCleanCache.callCount, 1, "frameworkCache.cleanCache called"); + t.is(buildCacheCleanCache.callCount, 1, "buildCache.cleanCache called"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Success"), "Shows success message"); @@ -304,7 +346,6 @@ test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) t.true(allOutput.includes("Error:"), "Shows Error"); t.true(allOutput.includes("currently locked by an active operation"), "Shows lock message"); t.false(allOutput.includes("Success"), "Does not show success"); - t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo not called when locked"); t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 8a0485eebaa..ac610ab82e3 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -45,7 +45,7 @@ export async function getCacheInfo(ui5DataDir) { const count = await countFiles(frameworkDir); if (count > 0) { return { - path: FRAMEWORK_DIR_NAME + "/", + path: FRAMEWORK_DIR_NAME, count, }; } diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 41105323333..2faef12069f 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -39,7 +39,7 @@ test("getCacheInfo: detects framework directory with files", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.is(result.path, "framework/"); + t.is(result.path, "framework"); t.is(result.count, 1); }); From 672a9ddb19132e58fe63c72dc2cfa38b18440087 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 2 Jun 2026 21:56:50 +0300 Subject: [PATCH 020/114] docs: Update cache clean --help CLI information --- packages/cli/lib/cli/commands/cache.js | 27 ++++++++++++--------- packages/cli/test/lib/cli/commands/cache.js | 5 ++-- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 31c3a0a2f7b..d6c246025be 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -9,7 +9,7 @@ import CacheManager from "@ui5/project/build/cache/CacheManager"; const cacheCommand = { command: "cache", - describe: "Manage UI5 CLI cache", + describe: "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)", middlewares: [baseMiddleware], handler: handleCache }; @@ -20,19 +20,22 @@ cacheCommand.builder = function(cli) { .command("clean", "Remove all cached UI5 data", { handler: handleCache, builder: function(yargs) { - return yargs.option("yes", { - alias: "y", - describe: "Skip confirmation prompt (e.g. for CI)", - default: false, - type: "boolean", - }); + return yargs + .option("yes", { + alias: "y", + describe: "Skip the confirmation prompt, e.g. for use in CI pipelines", + default: false, + type: "boolean", + }) + .example("$0 cache clean", + "Remove all cached UI5 data after confirming the prompt") + .example("$0 cache clean --yes", + "Remove all cached UI5 data without confirmation (e.g. in CI)") + .example("UI5_DATA_DIR=/custom/path $0 cache clean", + "Remove cached data from a non-default UI5 data directory"); }, middlewares: [baseMiddleware], - }) - .example("$0 cache clean", - "Remove all cached UI5 data") - .example("$0 cache clean --yes", - "Remove all cached UI5 data without confirmation (CI mode)"); + }); }; const LABEL_FRAMEWORK = "UI5 Framework packages"; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 983bc8b0812..156b30e3958 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -77,12 +77,13 @@ test("Command builder", async (t) => { t.is(result, cliStub, "Builder returns cli instance"); t.is(cliStub.demandCommand.callCount, 1, "demandCommand called once"); t.is(cliStub.command.callCount, 1, "command called once"); - t.is(cliStub.example.callCount, 2, "example called twice"); + t.is(cliStub.example.callCount, 0, "example not called on parent command"); }); test.serial("Command definition is correct", (t) => { t.is(t.context.cache.command, "cache"); - t.is(t.context.cache.describe, "Manage UI5 CLI cache"); + t.is(t.context.cache.describe, + "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)"); t.is(typeof t.context.cache.builder, "function"); t.is(typeof t.context.cache.handler, "function"); }); From 6d12addf1aad8a9f66471b72a0c216e2b4aecafd Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 9 Jun 2026 14:24:08 +0300 Subject: [PATCH 021/114] refactor: Cleanup confirmation revised --- packages/cli/lib/cli/commands/cache.js | 22 ++- packages/cli/test/lib/cli/commands/cache.js | 81 ++++++----- packages/project/lib/ui5Framework/cache.js | 110 ++++++++++---- .../project/test/lib/ui5framework/cache.js | 136 ++++++++++++------ 4 files changed, 234 insertions(+), 115 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index d6c246025be..1ed0ec0546b 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -61,13 +61,19 @@ function formatSize(bytes) { } /** - * Format a count with its singular/plural word, e.g. "340 files" or "1 file". + * Format a library stats detail string, e.g. "2 projects, 3 libraries, 4 versions". + * Each word is independently singular/plural. * - * @param {number} count + * @param {number} libraryCount + * @param {number} projectCount + * @param {number} versionCount * @returns {string} */ -function formatFileCount(count) { - return `${count} ${count === 1 ? "file" : "files"}`; +function formatLibraryStats(libraryCount, projectCount, versionCount) { + const p = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; + const l = `${libraryCount} ${libraryCount === 1 ? "library" : "libraries"}`; + const v = `${versionCount} ${versionCount === 1 ? "version" : "versions"}`; + return `${p}, ${l}, ${v}`; } /** @@ -97,7 +103,7 @@ async function handleCache(argv) { return; } - // Inform the user immediately — getCacheInfo (especially countFiles) may take a moment + // Inform the user immediately — getPackageStats may take a moment on a large cache process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); // Check what items exist before cleaning (orchestrate both domains) @@ -120,7 +126,8 @@ async function handleCache(argv) { // Display items that will be removed process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); if (frameworkInfo) { - const detail = formatFileCount(frameworkInfo.count); + const detail = formatLibraryStats( + frameworkInfo.libraryCount, frameworkInfo.projectCount, frameworkInfo.versionCount); process.stderr.write( ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` ); @@ -152,7 +159,8 @@ async function handleCache(argv) { process.stderr.write("\n"); if (frameworkResult) { - const detail = formatFileCount(frameworkResult.count); + const detail = formatLibraryStats( + frameworkResult.libraryCount, frameworkResult.projectCount, frameworkResult.versionCount); process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + ` (${frameworkAbsPath} · ${detail})\n` diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 156b30e3958..d11adda0be3 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -19,6 +19,9 @@ function getDefaultArgv() { // Stable absolute path used as the resolved ui5DataDir in most tests const TEST_UI5_DATA_DIR = path.resolve("/test/ui5/home"); +// Typical framework stub result shape +const FRAMEWORK_STUB = {path: "framework", projectCount: 2, libraryCount: 18, versionCount: 5}; + test.beforeEach(async (t) => { t.context.argv = getDefaultArgv(); t.context.stderrWriteStub = sinon.stub(process.stderr, "write"); @@ -108,7 +111,6 @@ test.serial("ui5 cache clean: falls back to ~/.ui5 when getUi5DataDir returns un const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; - // Simulate no env var, no config — getUi5DataDir returns undefined getUi5DataDirStub.resolves(undefined); frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -118,11 +120,7 @@ test.serial("ui5 cache clean: falls back to ~/.ui5 when getUi5DataDir returns un const expectedDefault = path.join(os.homedir(), ".ui5"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes(expectedDefault), - "Falls back to ~/.ui5 and shows it in checking line"); - - // getCacheInfo called with the default path - t.is(frameworkCacheGetCacheInfo.callCount, 1, "getCacheInfo called"); + t.true(allOutput.includes(expectedDefault), "Falls back to ~/.ui5 and shows it in checking line"); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], expectedDefault, "getCacheInfo receives ~/.ui5 as ui5DataDir"); }); @@ -136,18 +134,14 @@ test.serial("ui5 cache clean: uses resolved path from getUi5DataDir", async (t) argv["_"] = ["cache", "clean"]; await cache.handler(argv); - // The stub returns TEST_UI5_DATA_DIR — verify it was passed to getCacheInfo t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, "getCacheInfo receives the path returned by getUi5DataDir"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes(TEST_UI5_DATA_DIR), - "Resolved ui5DataDir shown in checking line"); + t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); }); test.serial("ui5 cache clean: relative path from config is resolved via getUi5DataDir", async (t) => { - // getUi5DataDir already resolves relative paths against cwd — verify the cache - // command uses the already-resolved absolute path rather than doing its own resolution. const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); @@ -177,20 +171,20 @@ test.serial("ui5 cache clean: nothing to clean", async (t) => { const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Checking cache at"), "Prints checking line"); t.true(allOutput.includes("Nothing to clean"), "Prints nothing to clean"); - t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache should not be called"); - t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache should not be called"); + t.is(frameworkCacheCleanCache.callCount, 0, "frameworkCache.cleanCache not called"); + t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called"); }); test.serial("ui5 cache clean: removes both entries and reports", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework", count: 340}); + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 8 * 1024 * 1024}); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", count: 340}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 7 * 1024 * 1024}); // VACUUM freed less argv["_"] = ["cache", "clean"]; @@ -202,25 +196,26 @@ test.serial("ui5 cache clean: removes both entries and reports", async (t) => { const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - // Checking line with absolute path + // Checking line t.true(allOutput.includes("Checking cache at"), "Prints checking line"); t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); - // Listing shows absolute paths + // Absolute paths in listing const expectedFrameworkAbs = path.join(TEST_UI5_DATA_DIR, "framework"); const expectedBuildAbs = path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7"); t.true(allOutput.includes(expectedFrameworkAbs), "Shows absolute framework path"); t.true(allOutput.includes(expectedBuildAbs), "Shows absolute build cache path"); - // Labels and detail - t.true(allOutput.includes("UI5 Framework packages"), "Shows framework label"); - t.true(allOutput.includes("Build cache (DB)"), "Shows build cache label"); - t.true(allOutput.includes("340 files"), "Shows framework file count"); - t.true(allOutput.includes("8.0 MB"), "Shows build cache pre-clean size"); - t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size (pre-clean size reused)"); - t.false(allOutput.includes("Total:"), "Does not show total line"); + // Framework detail: projects, libraries, versions + t.true(allOutput.includes("2 projects"), "Shows project count"); + t.true(allOutput.includes("18 libraries"), "Shows library count"); + t.true(allOutput.includes("5 versions"), "Shows version count"); - // Success + // Build cache detail: pre-clean size reused (not VACUUM-freed 7 MB) + t.true(allOutput.includes("8.0 MB"), "Shows pre-clean build cache size"); + t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size"); + + t.false(allOutput.includes("Total:"), "Does not show total line"); t.true(allOutput.includes("Cleaned UI5 Framework packages and Build cache (DB)"), "Shows success summary"); }); @@ -229,9 +224,8 @@ test.serial("ui5 cache clean: user cancels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework", count: 10}); + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); buildCacheGetCacheInfo.resolves(null); - yesnoStub.resolves(false); argv["_"] = ["cache", "clean"]; @@ -246,24 +240,45 @@ test.serial("ui5 cache clean: user cancels", async (t) => { t.false(allOutput.includes("Success"), "Does not show success message"); }); -test.serial("ui5 cache clean: framework only", async (t) => { +test.serial("ui5 cache clean: framework only — singular labels", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework", count: 1}); + const singleStub = {path: "framework", projectCount: 1, libraryCount: 1, versionCount: 1}; + frameworkCacheGetCacheInfo.resolves(singleStub); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves({path: "framework", count: 1}); + frameworkCacheCleanCache.resolves(singleStub); argv["_"] = ["cache", "clean"]; await cache.handler(argv); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("1 file"), "Uses singular 'file'"); + t.true(allOutput.includes("1 project,"), "Uses singular 'project'"); + t.true(allOutput.includes("1 library,"), "Uses singular 'library'"); + t.true(allOutput.includes("1 version"), "Uses singular 'version'"); t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); }); +test.serial("ui5 cache clean: framework only — plural labels", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo, yesnoStub} = t.context; + + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + buildCacheGetCacheInfo.resolves(null); + yesnoStub.resolves(true); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("2 projects"), "Uses plural 'projects'"); + t.true(allOutput.includes("18 libraries"), "Uses plural 'libraries'"); + t.true(allOutput.includes("5 versions"), "Uses plural 'versions'"); +}); + test.serial("ui5 cache clean: build only", async (t) => { const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; @@ -317,9 +332,9 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves({path: "framework", count: 100}); + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); - frameworkCacheCleanCache.resolves({path: "framework", count: 100}); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 5 * 1024 * 1024}); argv["_"] = ["cache", "clean"]; diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index ac610ab82e3..d3a648351a4 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -8,51 +8,91 @@ import { } from "./_frameworkPaths.js"; /** - * Count all files in a directory tree recursively. - * Returns 0 if the directory does not exist or any entry is unreadable. + * Count unique projects, libraries, and versions in the packages/ subdirectory. + * Uses a 3-level readdir walk (project → library → version) with no recursion into + * package contents. Inner levels are parallelised with Promise.all to avoid serial + * I/O on large caches. * - * @param {string} dirPath Absolute path to directory - * @returns {Promise} Total file count + * + * @param {string} packagesDir Absolute path to the packages directory + * @returns {Promise<{projects: number, libraries: number, versions: number}|null>} + * Null if the directory does not exist or contains no installed libraries. */ -async function countFiles(dirPath) { - let total = 0; - let entries; +async function getPackageStats(packagesDir) { + let projectDirs; try { - entries = await fs.readdir(dirPath, {withFileTypes: true}); + projectDirs = await fs.readdir(packagesDir, {withFileTypes: true}); } catch { - return 0; + return null; } - for (const entry of entries) { - if (entry.isDirectory()) { - total += await countFiles(path.join(dirPath, entry.name)); - } else { - total++; + + const librarySet = new Set(); + const versionSet = new Set(); + let totalProjects = 0; + + await Promise.all(projectDirs.filter((e) => e.isDirectory()).map(async (project) => { + let libDirs; + try { + libDirs = await fs.readdir( + path.join(packagesDir, project.name), {withFileTypes: true}); + } catch { + return; } - } - return total; + + let projectHasLibs = false; + await Promise.all(libDirs.filter((e) => e.isDirectory()).map(async (lib) => { + let versionDirs; + try { + versionDirs = await fs.readdir( + path.join(packagesDir, project.name, lib.name), {withFileTypes: true}); + } catch { + return; + } + const installedVersions = versionDirs.filter((v) => v.isDirectory()); + if (installedVersions.length > 0) { + librarySet.add(lib.name); // deduplicated: sap.m counts once across all projects + projectHasLibs = true; + for (const v of installedVersions) { + versionSet.add(v.name); + } + } + })); + + if (projectHasLibs) { + totalProjects++; + } + })); + + return librarySet.size > 0 + ? {projects: totalProjects, libraries: librarySet.size, versions: versionSet.size} + : null; } /** * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, count: number}|null>} Framework cache info or null + * @returns {Promise<{path: string, libraryCount: number, projectCount: number, versionCount: number}|null>} + * Framework cache info, or null if no packages are installed. */ export async function getCacheInfo(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); try { await fs.access(frameworkDir); - const count = await countFiles(frameworkDir); - if (count > 0) { - return { - path: FRAMEWORK_DIR_NAME, - count, - }; - } } catch { - // Directory doesn't exist + return null; + } + + const stats = await getPackageStats(path.join(frameworkDir, "packages")); + if (!stats) { + return null; } - return null; + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: stats.libraries, + projectCount: stats.projects, + versionCount: stats.versions, + }; } /** @@ -73,13 +113,21 @@ export async function isFrameworkLocked(ui5DataDir) { * deleting files while a download is in progress. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, count: number}|null>} Removal result or null + * @returns {Promise<{path: string, libraryCount: number, projectCount: number, versionCount: number}|null>} + * Removal result, or null if nothing was installed. * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); - const count = await countFiles(frameworkDir); - if (count === 0) { + + try { + await fs.access(frameworkDir); + } catch { + return null; + } + + const stats = await getPackageStats(path.join(frameworkDir, "packages")); + if (!stats) { return null; } @@ -93,6 +141,8 @@ export async function cleanCache(ui5DataDir) { await fs.rm(frameworkDir, {recursive: true, force: true}); return { path: FRAMEWORK_DIR_NAME, - count, + libraryCount: stats.libraries, + projectCount: stats.projects, + versionCount: stats.versions, }; } diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 2faef12069f..91e51bad133 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -21,94 +21,136 @@ test.afterEach.always(async (t) => { } }); -test("getCacheInfo: empty directory returns null", async (t) => { +// ─── Helper ────────────────────────────────────────────────────────────────── + +async function mkPackage(testDir, project, library, version) { + const dir = path.join(testDir, "framework", "packages", project, library, version); + await fs.mkdir(dir, {recursive: true}); + // A real package directory has at least a package.json + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); +} + +// ─── getCacheInfo ───────────────────────────────────────────────────────────── + +test("getCacheInfo: non-existent framework directory returns null", async (t) => { const result = await getCacheInfo(t.context.testDir); t.is(result, null); }); -test("getCacheInfo: non-existent directory returns null", async (t) => { - const nonExistent = path.join(t.context.testDir, "does-not-exist"); - const result = await getCacheInfo(nonExistent); +test("getCacheInfo: framework dir exists but no packages/ subdir returns null", async (t) => { + // cacache/ or staging/ without packages/ — nothing meaningful to show + await fs.mkdir(path.join(t.context.testDir, "framework", "cacache"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); t.is(result, null); }); -test("getCacheInfo: detects framework directory with files", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); +test("getCacheInfo: packages/ exists but is empty returns null", async (t) => { + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); + const result = await getCacheInfo(t.context.testDir); + t.is(result, null); +}); + +test("getCacheInfo: counts projects, libraries and versions", async (t) => { + // 2 projects, 2 unique library names, 3 unique versions + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.is(result.count, 1); + t.is(result.projectCount, 2); + t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across projects) + t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 }); -test("getCacheInfo: returns null for empty framework directory", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); +test("getCacheInfo: deduplicates library names across projects", async (t) => { + // sap.m appears under both projects — should count as 1 library + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); const result = await getCacheInfo(t.context.testDir); - t.is(result, null); + t.truthy(result); + t.is(result.projectCount, 2); + t.is(result.libraryCount, 1); // sap.m is the same library regardless of project + t.is(result.versionCount, 2); // 1.120.0 and 1.38.1 }); -test("getCacheInfo: counts files recursively", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - const subDir = path.join(frameworkDir, "packages"); - await fs.mkdir(subDir, {recursive: true}); - await fs.writeFile(path.join(frameworkDir, "file1.txt"), "test1"); - await fs.writeFile(path.join(subDir, "file2.txt"), "test2"); +test("getCacheInfo: deduplicates versions across libraries", async (t) => { + // Both libraries have 1.120.0 — version should count once + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.is(result.count, 2); + t.is(result.projectCount, 1); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 1); // 1.120.0 deduplicated }); -test("cleanCache: returns null for non-existent directory", async (t) => { +test("getCacheInfo: single project, library and version", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await getCacheInfo(t.context.testDir); + t.truthy(result); + t.is(result.projectCount, 1); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); +}); + +// ─── cleanCache ─────────────────────────────────────────────────────────────── + +test("cleanCache: returns null for non-existent framework directory", async (t) => { const result = await cleanCache(t.context.testDir); t.is(result, null); }); -test("cleanCache: returns null for empty directory", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - +test("cleanCache: returns null when packages/ has no installed libraries", async (t) => { + // Empty packages/ — nothing to report or delete + await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); const result = await cleanCache(t.context.testDir); t.is(result, null); }); -test("cleanCache: removes framework directory", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - await fs.mkdir(frameworkDir, {recursive: true}); - await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); +test("cleanCache: removes framework directory and returns stats", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); + await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); + const frameworkDir = path.join(t.context.testDir, "framework"); const result = await cleanCache(t.context.testDir); + t.truthy(result); t.is(result.path, "framework"); - t.is(result.count, 1); + t.is(result.projectCount, 1); + t.is(result.libraryCount, 2); + t.is(result.versionCount, 2); // 1.120.0, 1.148.0 - // Verify directory was removed + // Directory was removed await t.throwsAsync(fs.access(frameworkDir)); }); -test("cleanCache: removes nested directories", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - const subDir = path.join(frameworkDir, "packages"); - await fs.mkdir(subDir, {recursive: true}); - await fs.writeFile(path.join(subDir, "test.txt"), "content"); +test("cleanCache: removes directory with multiple projects", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); + const frameworkDir = path.join(t.context.testDir, "framework"); const result = await cleanCache(t.context.testDir); + t.truthy(result); - t.is(result.count, 1); + t.is(result.projectCount, 2); + t.is(result.libraryCount, 1); // sap.m deduplicated + t.is(result.versionCount, 2); - // Verify directory and subdirectories were removed await t.throwsAsync(fs.access(frameworkDir)); }); test("cleanCache: throws when active lockfiles exist", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - const lockDir = path.join(frameworkDir, "locks"); + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = path.join(t.context.testDir, "framework", "locks"); await fs.mkdir(lockDir, {recursive: true}); - await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); const lockPath = path.join(lockDir, "test-package.lock"); await lockfileLock(lockPath, {stale: 60000}); @@ -121,10 +163,10 @@ test("cleanCache: throws when active lockfiles exist", async (t) => { }); test("cleanCache: removes directory when lockfiles are stale", async (t) => { - const frameworkDir = path.join(t.context.testDir, "framework"); - const lockDir = path.join(frameworkDir, "locks"); + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = path.join(t.context.testDir, "framework", "locks"); await fs.mkdir(lockDir, {recursive: true}); - await fs.writeFile(path.join(frameworkDir, "test.txt"), "content"); // Create a real lock with a very short stale threshold, then wait for it to expire. // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. @@ -134,10 +176,14 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { // Wait long enough for the 50ms threshold to pass await new Promise((resolve) => setTimeout(resolve, 100)); + const frameworkDir = path.join(t.context.testDir, "framework"); const result = await cleanCache(t.context.testDir); + t.truthy(result); t.is(result.path, "framework"); - t.is(result.count, 1); // only test.txt remains — stale lock file is deleted by lockfileUnlock + t.is(result.projectCount, 1); + t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); await t.throwsAsync(fs.access(frameworkDir)); }); From b5cf05133fd2455d2e4741bab5a0ad49d9143806 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 9 Jun 2026 15:19:02 +0300 Subject: [PATCH 022/114] refactor: Revise actual cleanup UX --- packages/cli/lib/cli/commands/cache.js | 70 ++++++++++++++++++- packages/project/lib/ui5Framework/cache.js | 53 ++++++++++++-- .../project/test/lib/ui5framework/cache.js | 4 +- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 1ed0ec0546b..eccf6c45230 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -6,6 +6,7 @@ import baseMiddleware from "../middlewares/base.js"; import {getUi5DataDir} from "../../framework/utils.js"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; +import prettyHrtime from "pretty-hrtime"; const cacheCommand = { command: "cache", @@ -86,6 +87,64 @@ function padLabel(label) { return label.padEnd(LABEL_WIDTH); } +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const PROGRESS_DEBOUNCE_MS = 150; +// Reserve enough columns for the fixed parts of the progress line so the path +// never causes the line to wrap on a standard 80-column terminal. +const PATH_MAX_COLS = 40; + +/** + * Build a progress handler for framework cache deletion. + * Returns a function to pass as onProgress to cleanCache(), plus a finalise() + * to call when deletion completes (clears the in-progress line). + * + * The line is written to stderr with \r so it overwrites itself on each tick, + * producing a single updating line rather than a scrolling log. + * + * @param {string} label Short label shown on the progress line + * @param {[number, number]} startHrtime process.hrtime() snapshot taken when deletion began + * @param {function([number, number]): string} prettyHrtime Formatting function from the pretty-hrtime package + * @returns {{onProgress: function(string): void, finalise: function(): void}} + */ +function createProgressHandler(label, startHrtime, prettyHrtime) { + let lastPrintMs = 0; + let frameIndex = 0; + let lastVisibleLen = 0; + + function onProgress(entryPath) { + const now = Date.now(); + if (now - lastPrintMs < PROGRESS_DEBOUNCE_MS) return; + lastPrintMs = now; + + const elapsed = prettyHrtime(process.hrtime(startHrtime)); + const spinner = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]; + frameIndex++; + + // Trim path so the whole line stays within 80 columns + let displayPath = entryPath; + if (displayPath.length > PATH_MAX_COLS) { + displayPath = "…" + displayPath.slice(-(PATH_MAX_COLS - 1)); + } + + // Build visible text (no ANSI) first to get accurate length for overwrite padding + const visibleText = ` ${spinner} ${label} ${displayPath} ${elapsed}`; + // Then the styled version for actual output + const styledText = ` ${spinner} ${label} ${chalk.dim(displayPath)} ${elapsed}`; + + // Pad to cover any longer previous line, then overwrite in place + const padded = styledText + " ".repeat(Math.max(0, lastVisibleLen - visibleText.length)); + lastVisibleLen = visibleText.length; + + process.stderr.write(`\r${padded}`); + } + + function finalise() { + process.stderr.write(`\r${" ".repeat(lastVisibleLen)}\r`); + } + + return {onProgress, finalise}; +} + async function handleCache(argv) { // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 @@ -154,7 +213,16 @@ async function handleCache(argv) { } // Perform the actual cleanup (orchestrate both domains) - const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + let frameworkResult; + if (frameworkInfo) { + const startHrtime = process.hrtime(); + const {onProgress, finalise} = createProgressHandler(LABEL_FRAMEWORK, startHrtime, prettyHrtime); + try { + frameworkResult = await frameworkCache.cleanCache(ui5DataDir, onProgress); + } finally { + finalise(); + } + } const buildResult = await CacheManager.cleanCache(ui5DataDir); process.stderr.write("\n"); diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index d3a648351a4..c2295ff7850 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -13,7 +13,6 @@ import { * package contents. Inner levels are parallelised with Promise.all to avoid serial * I/O on large caches. * - * * @param {string} packagesDir Absolute path to the packages directory * @returns {Promise<{projects: number, libraries: number, versions: number}|null>} * Null if the directory does not exist or contains no installed libraries. @@ -63,9 +62,42 @@ async function getPackageStats(packagesDir) { } })); - return librarySet.size > 0 - ? {projects: totalProjects, libraries: librarySet.size, versions: versionSet.size} - : null; + return librarySet.size > 0 ? + {projects: totalProjects, libraries: librarySet.size, versions: versionSet.size} : + null; +} + +/** + * Recursively remove a directory, calling onProgress(entryPath) for every + * entry (file or directory) just before it is deleted. + * + * Uses manual traversal instead of fs.rm so callers can observe deletion + * progress. Intentionally serial — parallelising unlink() calls does not + * improve throughput on a single filesystem and makes the progress callback + * ordering unpredictable. + * + * @param {string} dirPath Absolute path to the directory to remove + * @param {function(string): void} onProgress Called with the path of each + * entry immediately before it is deleted + * @returns {Promise} + */ +async function rmRecursive(dirPath, onProgress) { + let entries; + try { + entries = await fs.readdir(dirPath, {withFileTypes: true}); + } catch { + return; + } + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + onProgress(entryPath); + if (entry.isDirectory()) { + await rmRecursive(entryPath, onProgress); + await fs.rmdir(entryPath); + } else { + await fs.unlink(entryPath); + } + } } /** @@ -113,11 +145,14 @@ export async function isFrameworkLocked(ui5DataDir) { * deleting files while a download is in progress. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {function(string): void} [onProgress] Optional callback invoked with + * the absolute path of each entry just before it is deleted. Use for + * progress display. Omit for silent deletion (falls back to fs.rm). * @returns {Promise<{path: string, libraryCount: number, projectCount: number, versionCount: number}|null>} * Removal result, or null if nothing was installed. * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ -export async function cleanCache(ui5DataDir) { +export async function cleanCache(ui5DataDir, onProgress) { const frameworkDir = getFrameworkDir(ui5DataDir); try { @@ -138,7 +173,13 @@ export async function cleanCache(ui5DataDir) { ); } - await fs.rm(frameworkDir, {recursive: true, force: true}); + if (onProgress) { + await rmRecursive(frameworkDir, onProgress); + await fs.rmdir(frameworkDir); + } else { + await fs.rm(frameworkDir, {recursive: true, force: true}); + } + return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 91e51bad133..8e8c970f536 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -61,8 +61,8 @@ test("getCacheInfo: counts projects, libraries and versions", async (t) => { t.truthy(result); t.is(result.path, "framework"); t.is(result.projectCount, 2); - t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across projects) - t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 + t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across projects) + t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 }); test("getCacheInfo: deduplicates library names across projects", async (t) => { From 22fe3905ddc7398dae0ccaf3597e5c2b4648a676 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 9 Jun 2026 16:11:17 +0300 Subject: [PATCH 023/114] fix: Docs generation failure --- packages/cli/lib/cli/commands/cache.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index eccf6c45230..9da79fd110c 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -102,8 +102,8 @@ const PATH_MAX_COLS = 40; * producing a single updating line rather than a scrolling log. * * @param {string} label Short label shown on the progress line - * @param {[number, number]} startHrtime process.hrtime() snapshot taken when deletion began - * @param {function([number, number]): string} prettyHrtime Formatting function from the pretty-hrtime package + * @param {Array} startHrtime process.hrtime() snapshot taken when deletion began + * @param {Function} prettyHrtime Formatting function from the pretty-hrtime package * @returns {{onProgress: function(string): void, finalise: function(): void}} */ function createProgressHandler(label, startHrtime, prettyHrtime) { From 784b0318a3a843c581a4639d6cd10ba3cb35fe4a Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 9 Jun 2026 16:38:35 +0300 Subject: [PATCH 024/114] fix: Respect cleanup locking --- .../lib/ui5Framework/AbstractInstaller.js | 14 +++- .../lib/ui5Framework/_frameworkPaths.js | 4 ++ packages/project/lib/ui5Framework/cache.js | 70 ++++++++++++++++--- .../project/test/lib/ui5framework/cache.js | 30 +++++++- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index 6f155ad0799..6335e068b5c 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -2,7 +2,7 @@ import path from "node:path"; import {mkdirp} from "../utils/fs.js"; import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; -import {LOCK_STALE_MS, getFrameworkLockDir} from "./_frameworkPaths.js"; +import {LOCK_STALE_MS, CLEANUP_LOCK_NAME, getFrameworkLockDir} from "./_frameworkPaths.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -32,8 +32,20 @@ class AbstractInstaller { } = await import("lockfile"); const lock = promisify(lockfile.lock); const unlock = promisify(lockfile.unlock); + const check = promisify(lockfile.check); const lockPath = this._getLockPath(lockName); await mkdirp(this._lockDir); + + // Refuse to start if cache cleanup is in progress — proceeding would write + // into a directory that is being deleted by a concurrent 'ui5 cache clean'. + const cleanupLockPath = path.join(this._lockDir, CLEANUP_LOCK_NAME); + if (await check(cleanupLockPath, {stale: LOCK_STALE_MS})) { + throw new Error( + "Framework cache is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } + log.verbose("Locking " + lockPath); await lock(lockPath, { wait: 10000, diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index fd1dede136d..ae371af31b5 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -8,6 +8,10 @@ export const FRAMEWORK_DIR_NAME = "framework"; // Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize export const LOCK_STALE_MS = 60000; +// Lock name acquired exclusively by cache cleanup — checked by installers to detect +// an in-progress cache deletion before acquiring a per-package lock. +export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; + /** * Resolve the absolute path to the framework directory within a UI5 data directory. * diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index c2295ff7850..6df3adb997b 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,12 +1,18 @@ import fs from "node:fs/promises"; import path from "node:path"; +import {promisify} from "node:util"; import { FRAMEWORK_DIR_NAME, + LOCK_STALE_MS, + CLEANUP_LOCK_NAME, getFrameworkDir, getFrameworkLockDir, hasActiveLocks, } from "./_frameworkPaths.js"; +// CLEANUP_LOCK_NAME is imported from _frameworkPaths.js and also used by +// AbstractInstaller._synchronize to detect in-progress cache deletions. + /** * Count unique projects, libraries, and versions in the packages/ subdirectory. * Uses a 3-level readdir walk (project → library → version) with no recursion into @@ -71,17 +77,21 @@ async function getPackageStats(packagesDir) { * Recursively remove a directory, calling onProgress(entryPath) for every * entry (file or directory) just before it is deleted. * + * Skips any entry whose name matches skipName — used to preserve the locks/ + * directory during cache cleanup so the cleanup lock remains valid throughout. + * * Uses manual traversal instead of fs.rm so callers can observe deletion * progress. Intentionally serial — parallelising unlink() calls does not * improve throughput on a single filesystem and makes the progress callback * ordering unpredictable. * * @param {string} dirPath Absolute path to the directory to remove - * @param {function(string): void} onProgress Called with the path of each + * @param {function(string): void|Promise} onProgress Called with the path of each * entry immediately before it is deleted + * @param {string} [skipName] Directory name to skip at the top level of dirPath * @returns {Promise} */ -async function rmRecursive(dirPath, onProgress) { +async function rmRecursive(dirPath, onProgress, skipName) { let entries; try { entries = await fs.readdir(dirPath, {withFileTypes: true}); @@ -89,8 +99,11 @@ async function rmRecursive(dirPath, onProgress) { return; } for (const entry of entries) { + if (skipName && entry.name === skipName) { + continue; + } const entryPath = path.join(dirPath, entry.name); - onProgress(entryPath); + await onProgress(entryPath); if (entry.isDirectory()) { await rmRecursive(entryPath, onProgress); await fs.rmdir(entryPath); @@ -141,8 +154,10 @@ export async function isFrameworkLocked(ui5DataDir) { /** * Clean framework cache directory. * - * Checks for active lockfiles before removing the directory to prevent - * deleting files while a download is in progress. + * Acquires a cleanup lock before deletion so that concurrent installer + * processes see an active lock and wait rather than writing into a + * partially-deleted cache. The locks/ directory is preserved throughout + * the deletion and removed only after the lock is released. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @param {function(string): void} [onProgress] Optional callback invoked with @@ -166,18 +181,51 @@ export async function cleanCache(ui5DataDir, onProgress) { return null; } - if (await hasActiveLocks(getFrameworkLockDir(ui5DataDir))) { + const lockDir = getFrameworkLockDir(ui5DataDir); + const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + + if (await hasActiveLocks(lockDir)) { throw new Error( "Framework cache is currently locked by an active operation. " + "Please wait for it to finish and try again." ); } - if (onProgress) { - await rmRecursive(frameworkDir, onProgress); - await fs.rmdir(frameworkDir); - } else { - await fs.rm(frameworkDir, {recursive: true, force: true}); + // Ensure the locks directory exists before acquiring our lock + await fs.mkdir(lockDir, {recursive: true}); + + const {default: lockfile} = await import("lockfile"); + const lock = promisify(lockfile.lock); + const unlock = promisify(lockfile.unlock); + + await lock(lockPath, {stale: LOCK_STALE_MS}); + try { + if (onProgress) { + // Delete everything except locks/ so our lock stays valid throughout + await rmRecursive(frameworkDir, onProgress, "locks"); + } else { + // Fast path: delete everything except locks/ with fs.rm, then locks/ separately + const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); + await Promise.all( + entries + .filter((e) => e.name !== "locks") + .map((e) => { + const p = path.join(frameworkDir, e.name); + return e.isDirectory() ? + fs.rm(p, {recursive: true, force: true}) : + fs.unlink(p); + }) + ); + } + } finally { + await unlock(lockPath); + // Remove the locks directory (and our lock file) now that we are done + await fs.rm(lockDir, {recursive: true, force: true}); + // Remove the now-empty framework directory itself + await fs.rmdir(frameworkDir).catch(() => { + // If rmdir fails (e.g. something else recreated a file), ignore — the + // important thing is the cache content is gone and the lock is released. + }); } return { diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 8e8c970f536..28a27c5ebaf 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -183,7 +183,33 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { t.is(result.path, "framework"); t.is(result.projectCount, 1); t.is(result.libraryCount, 1); - t.is(result.versionCount, 1); - await t.throwsAsync(fs.access(frameworkDir)); }); + +test("cleanCache: holds cleanup lock during deletion so concurrent installers see it", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = path.join(t.context.testDir, "framework", "locks"); + let lockObservedDuringDeletion = false; + + // Pass an onProgress callback that fires mid-deletion and checks for the cleanup lock + const onProgress = async () => { + if (lockObservedDuringDeletion) return; // check once is enough + try { + const entries = await fs.readdir(lockDir); + if (entries.some((name) => name === "cache-cleanup.lock")) { + lockObservedDuringDeletion = true; + } + } catch { + // lockDir may not exist yet on the very first callback + } + }; + + const result = await cleanCache(t.context.testDir, onProgress); + t.truthy(result); + t.true(lockObservedDuringDeletion, "cache-cleanup.lock was present during deletion"); + + // After completion: framework/ is fully removed including the locks/ subdir + const frameworkDir = path.join(t.context.testDir, "framework"); + await t.throwsAsync(fs.access(frameworkDir), undefined, "framework/ removed after unlock"); +}); From ec40541dc8d85f328b0d14d348c09404397d8ad5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 9 Jun 2026 18:37:56 +0300 Subject: [PATCH 025/114] refactor: Redundant cleanup summary --- packages/cli/lib/cli/commands/cache.js | 102 ++++-------------- packages/cli/test/lib/cli/commands/cache.js | 61 ++++++----- packages/project/lib/ui5Framework/cache.js | 99 ++++------------- .../project/test/lib/ui5framework/cache.js | 58 +++++----- 4 files changed, 100 insertions(+), 220 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 9da79fd110c..12b323faade 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -6,7 +6,6 @@ import baseMiddleware from "../middlewares/base.js"; import {getUi5DataDir} from "../../framework/utils.js"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; -import prettyHrtime from "pretty-hrtime"; const cacheCommand = { command: "cache", @@ -33,7 +32,17 @@ cacheCommand.builder = function(cli) { .example("$0 cache clean --yes", "Remove all cached UI5 data without confirmation (e.g. in CI)") .example("UI5_DATA_DIR=/custom/path $0 cache clean", - "Remove cached data from a non-default UI5 data directory"); + "Remove cached data from a non-default UI5 data directory") + .epilogue( + "The cache is stored in the UI5 data directory (default: ~/.ui5).\n" + + "Override the location with the UI5_DATA_DIR environment variable or\n" + + "the 'ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + + "Two cache types are removed:\n" + + " UI5 Framework packages Downloaded UI5 library files " + + "(~/.ui5/framework/)\n" + + " Build cache (DB) Incremental build data " + + "(~/.ui5/buildCache/)" + ); }, middlewares: [baseMiddleware], }); @@ -62,19 +71,17 @@ function formatSize(bytes) { } /** - * Format a library stats detail string, e.g. "2 projects, 3 libraries, 4 versions". - * Each word is independently singular/plural. + * Format framework cache stats as a human-readable detail string. + * E.g. "1,189 versions of 155 libraries" or "1 version of 1 library". * * @param {number} libraryCount - * @param {number} projectCount * @param {number} versionCount * @returns {string} */ -function formatLibraryStats(libraryCount, projectCount, versionCount) { - const p = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; - const l = `${libraryCount} ${libraryCount === 1 ? "library" : "libraries"}`; - const v = `${versionCount} ${versionCount === 1 ? "version" : "versions"}`; - return `${p}, ${l}, ${v}`; +function formatFrameworkStats(libraryCount, versionCount) { + const v = `${versionCount.toLocaleString("en-US")} ${versionCount === 1 ? "version" : "versions"}`; + const l = `${libraryCount.toLocaleString("en-US")} ${libraryCount === 1 ? "library" : "libraries"}`; + return `${v} of ${l}`; } /** @@ -87,64 +94,6 @@ function padLabel(label) { return label.padEnd(LABEL_WIDTH); } -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -const PROGRESS_DEBOUNCE_MS = 150; -// Reserve enough columns for the fixed parts of the progress line so the path -// never causes the line to wrap on a standard 80-column terminal. -const PATH_MAX_COLS = 40; - -/** - * Build a progress handler for framework cache deletion. - * Returns a function to pass as onProgress to cleanCache(), plus a finalise() - * to call when deletion completes (clears the in-progress line). - * - * The line is written to stderr with \r so it overwrites itself on each tick, - * producing a single updating line rather than a scrolling log. - * - * @param {string} label Short label shown on the progress line - * @param {Array} startHrtime process.hrtime() snapshot taken when deletion began - * @param {Function} prettyHrtime Formatting function from the pretty-hrtime package - * @returns {{onProgress: function(string): void, finalise: function(): void}} - */ -function createProgressHandler(label, startHrtime, prettyHrtime) { - let lastPrintMs = 0; - let frameIndex = 0; - let lastVisibleLen = 0; - - function onProgress(entryPath) { - const now = Date.now(); - if (now - lastPrintMs < PROGRESS_DEBOUNCE_MS) return; - lastPrintMs = now; - - const elapsed = prettyHrtime(process.hrtime(startHrtime)); - const spinner = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]; - frameIndex++; - - // Trim path so the whole line stays within 80 columns - let displayPath = entryPath; - if (displayPath.length > PATH_MAX_COLS) { - displayPath = "…" + displayPath.slice(-(PATH_MAX_COLS - 1)); - } - - // Build visible text (no ANSI) first to get accurate length for overwrite padding - const visibleText = ` ${spinner} ${label} ${displayPath} ${elapsed}`; - // Then the styled version for actual output - const styledText = ` ${spinner} ${label} ${chalk.dim(displayPath)} ${elapsed}`; - - // Pad to cover any longer previous line, then overwrite in place - const padded = styledText + " ".repeat(Math.max(0, lastVisibleLen - visibleText.length)); - lastVisibleLen = visibleText.length; - - process.stderr.write(`\r${padded}`); - } - - function finalise() { - process.stderr.write(`\r${" ".repeat(lastVisibleLen)}\r`); - } - - return {onProgress, finalise}; -} - async function handleCache(argv) { // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 @@ -185,8 +134,7 @@ async function handleCache(argv) { // Display items that will be removed process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); if (frameworkInfo) { - const detail = formatLibraryStats( - frameworkInfo.libraryCount, frameworkInfo.projectCount, frameworkInfo.versionCount); + const detail = formatFrameworkStats(frameworkInfo.libraryCount, frameworkInfo.versionCount); process.stderr.write( ` ${chalk.yellow("•")} ${padLabel(LABEL_FRAMEWORK)} ${frameworkAbsPath} (${detail})\n` ); @@ -213,22 +161,12 @@ async function handleCache(argv) { } // Perform the actual cleanup (orchestrate both domains) - let frameworkResult; - if (frameworkInfo) { - const startHrtime = process.hrtime(); - const {onProgress, finalise} = createProgressHandler(LABEL_FRAMEWORK, startHrtime, prettyHrtime); - try { - frameworkResult = await frameworkCache.cleanCache(ui5DataDir, onProgress); - } finally { - finalise(); - } - } + const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); const buildResult = await CacheManager.cleanCache(ui5DataDir); process.stderr.write("\n"); if (frameworkResult) { - const detail = formatLibraryStats( - frameworkResult.libraryCount, frameworkResult.projectCount, frameworkResult.versionCount); + const detail = formatFrameworkStats(frameworkResult.libraryCount, frameworkResult.versionCount); process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + ` (${frameworkAbsPath} · ${detail})\n` diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index d11adda0be3..f77865d6b24 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -19,8 +19,8 @@ function getDefaultArgv() { // Stable absolute path used as the resolved ui5DataDir in most tests const TEST_UI5_DATA_DIR = path.resolve("/test/ui5/home"); -// Typical framework stub result shape -const FRAMEWORK_STUB = {path: "framework", projectCount: 2, libraryCount: 18, versionCount: 5}; +// Typical framework stub result shape: { path, libraryCount, versionCount } +const FRAMEWORK_STUB = {path: "framework", libraryCount: 18, versionCount: 5}; test.beforeEach(async (t) => { t.context.argv = getDefaultArgv(); @@ -200,18 +200,14 @@ test.serial("ui5 cache clean: removes both entries and reports", async (t) => { t.true(allOutput.includes("Checking cache at"), "Prints checking line"); t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Shows resolved ui5DataDir"); - // Absolute paths in listing - const expectedFrameworkAbs = path.join(TEST_UI5_DATA_DIR, "framework"); - const expectedBuildAbs = path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7"); - t.true(allOutput.includes(expectedFrameworkAbs), "Shows absolute framework path"); - t.true(allOutput.includes(expectedBuildAbs), "Shows absolute build cache path"); + // Absolute paths + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "framework")), "Shows absolute framework path"); + t.true(allOutput.includes(path.join(TEST_UI5_DATA_DIR, "buildCache/v0_7")), "Shows absolute build path"); - // Framework detail: projects, libraries, versions - t.true(allOutput.includes("2 projects"), "Shows project count"); - t.true(allOutput.includes("18 libraries"), "Shows library count"); - t.true(allOutput.includes("5 versions"), "Shows version count"); + // New format: "5 versions of 18 libraries" + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows new library stats format"); - // Build cache detail: pre-clean size reused (not VACUUM-freed 7 MB) + // Build cache size — pre-clean size reused (not VACUUM-freed 7 MB) t.true(allOutput.includes("8.0 MB"), "Shows pre-clean build cache size"); t.false(allOutput.includes("7.0 MB"), "Does not show VACUUM-freed size"); @@ -240,43 +236,54 @@ test.serial("ui5 cache clean: user cancels", async (t) => { t.false(allOutput.includes("Success"), "Does not show success message"); }); -test.serial("ui5 cache clean: framework only — singular labels", async (t) => { +test.serial("ui5 cache clean: framework only — formats library stats correctly", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, yesnoStub} = t.context; - const singleStub = {path: "framework", projectCount: 1, libraryCount: 1, versionCount: 1}; - frameworkCacheGetCacheInfo.resolves(singleStub); + // Plural + frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves(singleStub); + frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); argv["_"] = ["cache", "clean"]; await cache.handler(argv); - const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("1 project,"), "Uses singular 'project'"); - t.true(allOutput.includes("1 library,"), "Uses singular 'library'"); - t.true(allOutput.includes("1 version"), "Uses singular 'version'"); + let allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("5 versions of 18 libraries"), "Shows plural format"); t.false(allOutput.includes("Build cache (DB)"), "Does not mention build cache"); - t.true(allOutput.includes("Cleaned UI5 Framework packages"), "Success mentions framework only"); + + // Singular — reset stubs + stderrWriteStub.resetHistory(); + const singleStub = {path: "framework", libraryCount: 1, versionCount: 1}; + frameworkCacheGetCacheInfo.resetBehavior(); + frameworkCacheCleanCache.resetBehavior(); + frameworkCacheGetCacheInfo.resolves(singleStub); + frameworkCacheCleanCache.resolves(singleStub); + + argv["yes"] = true; + await cache.handler(argv); + + allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("1 version of 1 library"), "Uses singular 'version' and 'library'"); }); -test.serial("ui5 cache clean: framework only — plural labels", async (t) => { +test.serial("ui5 cache clean: thousands separator in library stats", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, yesnoStub} = t.context; - frameworkCacheGetCacheInfo.resolves(FRAMEWORK_STUB); + const largeStub = {path: "framework", libraryCount: 155, versionCount: 1189}; + frameworkCacheGetCacheInfo.resolves(largeStub); buildCacheGetCacheInfo.resolves(null); yesnoStub.resolves(true); - frameworkCacheCleanCache.resolves(FRAMEWORK_STUB); + frameworkCacheCleanCache.resolves(largeStub); argv["_"] = ["cache", "clean"]; await cache.handler(argv); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes("2 projects"), "Uses plural 'projects'"); - t.true(allOutput.includes("18 libraries"), "Uses plural 'libraries'"); - t.true(allOutput.includes("5 versions"), "Uses plural 'versions'"); + t.true(allOutput.includes("1,189 versions of 155 libraries"), + "Shows thousands separator for large counts"); }); test.serial("ui5 cache clean: build only", async (t) => { diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 6df3adb997b..da0c0012d87 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -10,17 +10,17 @@ import { hasActiveLocks, } from "./_frameworkPaths.js"; -// CLEANUP_LOCK_NAME is imported from _frameworkPaths.js and also used by -// AbstractInstaller._synchronize to detect in-progress cache deletions. - /** - * Count unique projects, libraries, and versions in the packages/ subdirectory. + * Count unique libraries and versions in the packages/ subdirectory. * Uses a 3-level readdir walk (project → library → version) with no recursion into * package contents. Inner levels are parallelised with Promise.all to avoid serial * I/O on large caches. * + * Library names are deduplicated globally: sap.m under @openui5 and @sapui5 counts + * as one library. + * * @param {string} packagesDir Absolute path to the packages directory - * @returns {Promise<{projects: number, libraries: number, versions: number}|null>} + * @returns {Promise<{libraries: number, versions: number}|null>} * Null if the directory does not exist or contains no installed libraries. */ async function getPackageStats(packagesDir) { @@ -33,7 +33,6 @@ async function getPackageStats(packagesDir) { const librarySet = new Set(); const versionSet = new Set(); - let totalProjects = 0; await Promise.all(projectDirs.filter((e) => e.isDirectory()).map(async (project) => { let libDirs; @@ -44,7 +43,6 @@ async function getPackageStats(packagesDir) { return; } - let projectHasLibs = false; await Promise.all(libDirs.filter((e) => e.isDirectory()).map(async (lib) => { let versionDirs; try { @@ -56,68 +54,23 @@ async function getPackageStats(packagesDir) { const installedVersions = versionDirs.filter((v) => v.isDirectory()); if (installedVersions.length > 0) { librarySet.add(lib.name); // deduplicated: sap.m counts once across all projects - projectHasLibs = true; for (const v of installedVersions) { versionSet.add(v.name); } } })); - - if (projectHasLibs) { - totalProjects++; - } })); return librarySet.size > 0 ? - {projects: totalProjects, libraries: librarySet.size, versions: versionSet.size} : + {libraries: librarySet.size, versions: versionSet.size} : null; } -/** - * Recursively remove a directory, calling onProgress(entryPath) for every - * entry (file or directory) just before it is deleted. - * - * Skips any entry whose name matches skipName — used to preserve the locks/ - * directory during cache cleanup so the cleanup lock remains valid throughout. - * - * Uses manual traversal instead of fs.rm so callers can observe deletion - * progress. Intentionally serial — parallelising unlink() calls does not - * improve throughput on a single filesystem and makes the progress callback - * ordering unpredictable. - * - * @param {string} dirPath Absolute path to the directory to remove - * @param {function(string): void|Promise} onProgress Called with the path of each - * entry immediately before it is deleted - * @param {string} [skipName] Directory name to skip at the top level of dirPath - * @returns {Promise} - */ -async function rmRecursive(dirPath, onProgress, skipName) { - let entries; - try { - entries = await fs.readdir(dirPath, {withFileTypes: true}); - } catch { - return; - } - for (const entry of entries) { - if (skipName && entry.name === skipName) { - continue; - } - const entryPath = path.join(dirPath, entry.name); - await onProgress(entryPath); - if (entry.isDirectory()) { - await rmRecursive(entryPath, onProgress); - await fs.rmdir(entryPath); - } else { - await fs.unlink(entryPath); - } - } -} - /** * Get framework cache info. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, libraryCount: number, projectCount: number, versionCount: number}|null>} + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} * Framework cache info, or null if no packages are installed. */ export async function getCacheInfo(ui5DataDir) { @@ -135,7 +88,6 @@ export async function getCacheInfo(ui5DataDir) { return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, - projectCount: stats.projects, versionCount: stats.versions, }; } @@ -160,14 +112,11 @@ export async function isFrameworkLocked(ui5DataDir) { * the deletion and removed only after the lock is released. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @param {function(string): void} [onProgress] Optional callback invoked with - * the absolute path of each entry just before it is deleted. Use for - * progress display. Omit for silent deletion (falls back to fs.rm). - * @returns {Promise<{path: string, libraryCount: number, projectCount: number, versionCount: number}|null>} + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} * Removal result, or null if nothing was installed. * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ -export async function cleanCache(ui5DataDir, onProgress) { +export async function cleanCache(ui5DataDir) { const frameworkDir = getFrameworkDir(ui5DataDir); try { @@ -200,23 +149,18 @@ export async function cleanCache(ui5DataDir, onProgress) { await lock(lockPath, {stale: LOCK_STALE_MS}); try { - if (onProgress) { - // Delete everything except locks/ so our lock stays valid throughout - await rmRecursive(frameworkDir, onProgress, "locks"); - } else { - // Fast path: delete everything except locks/ with fs.rm, then locks/ separately - const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); - await Promise.all( - entries - .filter((e) => e.name !== "locks") - .map((e) => { - const p = path.join(frameworkDir, e.name); - return e.isDirectory() ? - fs.rm(p, {recursive: true, force: true}) : - fs.unlink(p); - }) - ); - } + // Delete everything inside framework/ except locks/ so our lock stays valid throughout + const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); + await Promise.all( + entries + .filter((e) => e.name !== "locks") + .map((e) => { + const p = path.join(frameworkDir, e.name); + return e.isDirectory() ? + fs.rm(p, {recursive: true, force: true}) : + fs.unlink(p); + }) + ); } finally { await unlock(lockPath); // Remove the locks directory (and our lock file) now that we are done @@ -231,7 +175,6 @@ export async function cleanCache(ui5DataDir, onProgress) { return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, - projectCount: stats.projects, versionCount: stats.versions, }; } diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 28a27c5ebaf..bd469804534 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -26,7 +26,6 @@ test.afterEach.always(async (t) => { async function mkPackage(testDir, project, library, version) { const dir = path.join(testDir, "framework", "packages", project, library, version); await fs.mkdir(dir, {recursive: true}); - // A real package directory has at least a package.json await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); } @@ -38,7 +37,6 @@ test("getCacheInfo: non-existent framework directory returns null", async (t) => }); test("getCacheInfo: framework dir exists but no packages/ subdir returns null", async (t) => { - // cacache/ or staging/ without packages/ — nothing meaningful to show await fs.mkdir(path.join(t.context.testDir, "framework", "cacache"), {recursive: true}); const result = await getCacheInfo(t.context.testDir); t.is(result, null); @@ -50,8 +48,8 @@ test("getCacheInfo: packages/ exists but is empty returns null", async (t) => { t.is(result, null); }); -test("getCacheInfo: counts projects, libraries and versions", async (t) => { - // 2 projects, 2 unique library names, 3 unique versions +test("getCacheInfo: counts libraries and versions", async (t) => { + // 2 unique library names across 2 scopes, 3 unique versions await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); @@ -60,20 +58,17 @@ test("getCacheInfo: counts projects, libraries and versions", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); - t.is(result.projectCount, 2); - t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across projects) + t.is(result.libraryCount, 2); // sap.m counted once (deduplicated across scopes) t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 }); -test("getCacheInfo: deduplicates library names across projects", async (t) => { - // sap.m appears under both projects — should count as 1 library +test("getCacheInfo: deduplicates library names across scopes", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.is(result.projectCount, 2); - t.is(result.libraryCount, 1); // sap.m is the same library regardless of project + t.is(result.libraryCount, 1); // sap.m is the same library regardless of scope t.is(result.versionCount, 2); // 1.120.0 and 1.38.1 }); @@ -84,17 +79,15 @@ test("getCacheInfo: deduplicates versions across libraries", async (t) => { const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.is(result.projectCount, 1); t.is(result.libraryCount, 2); t.is(result.versionCount, 1); // 1.120.0 deduplicated }); -test("getCacheInfo: single project, library and version", async (t) => { +test("getCacheInfo: single library and version", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); const result = await getCacheInfo(t.context.testDir); t.truthy(result); - t.is(result.projectCount, 1); t.is(result.libraryCount, 1); t.is(result.versionCount, 1); }); @@ -107,7 +100,6 @@ test("cleanCache: returns null for non-existent framework directory", async (t) }); test("cleanCache: returns null when packages/ has no installed libraries", async (t) => { - // Empty packages/ — nothing to report or delete await fs.mkdir(path.join(t.context.testDir, "framework", "packages"), {recursive: true}); const result = await cleanCache(t.context.testDir); t.is(result, null); @@ -123,15 +115,13 @@ test("cleanCache: removes framework directory and returns stats", async (t) => { t.truthy(result); t.is(result.path, "framework"); - t.is(result.projectCount, 1); t.is(result.libraryCount, 2); t.is(result.versionCount, 2); // 1.120.0, 1.148.0 - // Directory was removed await t.throwsAsync(fs.access(frameworkDir)); }); -test("cleanCache: removes directory with multiple projects", async (t) => { +test("cleanCache: removes directory with multiple scopes", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); @@ -139,7 +129,6 @@ test("cleanCache: removes directory with multiple projects", async (t) => { const result = await cleanCache(t.context.testDir); t.truthy(result); - t.is(result.projectCount, 2); t.is(result.libraryCount, 1); // sap.m deduplicated t.is(result.versionCount, 2); @@ -168,12 +157,10 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { const lockDir = path.join(t.context.testDir, "framework", "locks"); await fs.mkdir(lockDir, {recursive: true}); - // Create a real lock with a very short stale threshold, then wait for it to expire. // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. const lockPath = path.join(lockDir, "stale-package.lock"); await lockfileLock(lockPath, {stale: 50}); // stale after 50ms await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk - // Wait long enough for the 50ms threshold to pass await new Promise((resolve) => setTimeout(resolve, 100)); const frameworkDir = path.join(t.context.testDir, "framework"); @@ -181,35 +168,40 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { t.truthy(result); t.is(result.path, "framework"); - t.is(result.projectCount, 1); t.is(result.libraryCount, 1); + t.is(result.versionCount, 1); + await t.throwsAsync(fs.access(frameworkDir)); }); -test("cleanCache: holds cleanup lock during deletion so concurrent installers see it", async (t) => { +test("cleanCache: holds cleanup lock during deletion", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); const lockDir = path.join(t.context.testDir, "framework", "locks"); - let lockObservedDuringDeletion = false; + let lockObservedBeforeCompletion = false; + + // Start cleanCache without awaiting — run the lock check in parallel + const cleanPromise = cleanCache(t.context.testDir); - // Pass an onProgress callback that fires mid-deletion and checks for the cleanup lock - const onProgress = async () => { - if (lockObservedDuringDeletion) return; // check once is enough + // Poll for the cleanup lock to appear, with a short delay between attempts + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); try { const entries = await fs.readdir(lockDir); if (entries.some((name) => name === "cache-cleanup.lock")) { - lockObservedDuringDeletion = true; + lockObservedBeforeCompletion = true; + break; } } catch { - // lockDir may not exist yet on the very first callback + // locks/ not created yet } - }; + } - const result = await cleanCache(t.context.testDir, onProgress); + const result = await cleanPromise; t.truthy(result); - t.true(lockObservedDuringDeletion, "cache-cleanup.lock was present during deletion"); + t.true(lockObservedBeforeCompletion, "cache-cleanup.lock was present during deletion"); - // After completion: framework/ is fully removed including the locks/ subdir + // After completion: framework/ is fully removed const frameworkDir = path.join(t.context.testDir, "framework"); - await t.throwsAsync(fs.access(frameworkDir), undefined, "framework/ removed after unlock"); + await t.throwsAsync(fs.access(frameworkDir)); }); From 589edd7a55fb761c52b0565b4a6cde71bb31f3d9 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 10 Jun 2026 15:28:56 +0300 Subject: [PATCH 026/114] fix: Cache locking race condition --- .../lib/ui5Framework/AbstractInstaller.js | 19 ++- .../lib/ui5Framework/_frameworkPaths.js | 13 +- packages/project/lib/ui5Framework/cache.js | 23 ++-- .../lib/ui5Framework/maven/Installer.js | 126 +++++++++--------- .../project/lib/ui5Framework/npm/Installer.js | 38 +++--- .../graph/helpers/ui5Framework.integration.js | 8 +- .../project/test/lib/ui5framework/cache.js | 86 ++++++++---- .../test/lib/ui5framework/maven/Installer.js | 3 + .../test/lib/ui5framework/npm/Installer.js | 3 + 9 files changed, 185 insertions(+), 134 deletions(-) diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index 6335e068b5c..fa51627cd91 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -36,16 +36,6 @@ class AbstractInstaller { const lockPath = this._getLockPath(lockName); await mkdirp(this._lockDir); - // Refuse to start if cache cleanup is in progress — proceeding would write - // into a directory that is being deleted by a concurrent 'ui5 cache clean'. - const cleanupLockPath = path.join(this._lockDir, CLEANUP_LOCK_NAME); - if (await check(cleanupLockPath, {stale: LOCK_STALE_MS})) { - throw new Error( - "Framework cache is currently being cleaned. " + - "Please wait for the cache clean operation to finish and try again." - ); - } - log.verbose("Locking " + lockPath); await lock(lockPath, { wait: 10000, @@ -53,6 +43,15 @@ class AbstractInstaller { retries: 10 }); try { + // Abort if cache cleanup is in progress. Checking after acquiring our lock + // ensures cleanCache's hasActiveLocks scan will see us if both run concurrently. + const cleanupLockPath = path.join(this._lockDir, CLEANUP_LOCK_NAME); + if (await check(cleanupLockPath, {stale: LOCK_STALE_MS})) { + throw new Error( + "Framework cache is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } const res = await callback(); return res; } finally { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index ae371af31b5..7b60c844149 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -10,6 +10,13 @@ export const LOCK_STALE_MS = 60000; // Lock name acquired exclusively by cache cleanup — checked by installers to detect // an in-progress cache deletion before acquiring a per-package lock. +// +// Lock naming convention (files live in getFrameworkLockDir(); slashes in package +// names are replaced with dashes by AbstractInstaller#_sanitizeFileName): +// cache-cleanup.lock — held by ui5 cache clean for the full deletion +// install-{pkg}@{ver}.lock — held by maven Installer for the full install lifecycle +// manifest-{pkg}@{ver}.lock — held by npm Installer during manifest fetch (cacache writes) +// package-{pkg}@{ver}.lock — held by both installers during package extraction export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; /** @@ -37,9 +44,11 @@ export function getFrameworkLockDir(ui5DataDir) { * indicating an ongoing download or installation. * * @param {string} lockDir Absolute path to a locks directory + * @param {object} [options] + * @param {string} [options.exclude] Lock file name to skip (e.g. the caller's own lock) * @returns {Promise} True if any non-stale lockfiles are held */ -export async function hasActiveLocks(lockDir) { +export async function hasActiveLocks(lockDir, {exclude} = {}) { let entries; try { entries = await fs.readdir(lockDir); @@ -47,7 +56,7 @@ export async function hasActiveLocks(lockDir) { return false; } - const lockFiles = entries.filter((name) => name.endsWith(".lock")); + const lockFiles = entries.filter((name) => name.endsWith(".lock") && name !== exclude); if (lockFiles.length === 0) { return false; } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index da0c0012d87..bdaa63a4f59 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -133,13 +133,6 @@ export async function cleanCache(ui5DataDir) { const lockDir = getFrameworkLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - if (await hasActiveLocks(lockDir)) { - throw new Error( - "Framework cache is currently locked by an active operation. " + - "Please wait for it to finish and try again." - ); - } - // Ensure the locks directory exists before acquiring our lock await fs.mkdir(lockDir, {recursive: true}); @@ -147,8 +140,16 @@ export async function cleanCache(ui5DataDir) { const lock = promisify(lockfile.lock); const unlock = promisify(lockfile.unlock); + // Acquire first, then check — ensures installers running concurrently will see + // the cleanup lock and abort before writing into a directory being deleted. await lock(lockPath, {stale: LOCK_STALE_MS}); try { + if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { + throw new Error( + "Framework cache is currently locked by an active operation. " + + "Please wait for it to finish and try again." + ); + } // Delete everything inside framework/ except locks/ so our lock stays valid throughout const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); await Promise.all( @@ -162,14 +163,8 @@ export async function cleanCache(ui5DataDir) { }) ); } finally { - await unlock(lockPath); - // Remove the locks directory (and our lock file) now that we are done + await unlock(lockPath).catch(() => {}); await fs.rm(lockDir, {recursive: true, force: true}); - // Remove the now-empty framework directory itself - await fs.rmdir(frameworkDir).catch(() => { - // If rmdir fails (e.g. something else recreated a file), ignore — the - // important thing is the cache content is gone and the lock is released. - }); } return { diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 008ca0290e5..6d982a53170 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -301,69 +301,71 @@ class Installer extends AbstractInstaller { * @returns {@ui5/project/ui5Framework/maven/Installer~InstalledPackage} */ async installPackage({pkgName, groupId, artifactId, version, classifier, extension}) { - const {revision} = await this._fetchArtifactMetadata({ - pkgName, groupId, artifactId, version, classifier, extension - }); - - const coordinates = { - groupId, artifactId, - version, revision, - classifier, extension - }; - - const targetDir = this._getTargetDirForPackage(pkgName, revision); - const installed = await this._projectExists(targetDir); - - if (!installed) { - await this._synchronize(`package-${pkgName}@${revision}`, async () => { - const installed = await this._projectExists(targetDir); - - if (installed) { - log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); - return; - } - - const stagingDir = this._getStagingDirForPackage(pkgName, revision); - - // Check whether staging dir already exists and remove it - if (await this._pathExists(stagingDir)) { - log.verbose(`Removing stale staging directory at ${stagingDir}...`); - await rmrf(stagingDir); - } - - await mkdirp(stagingDir); - - const {artifactPath, removeArtifact} = await this.installArtifact(coordinates); - - log.verbose(`Extracting archive at ${artifactPath} to ${stagingDir}...`); - const zip = new StreamZip({file: artifactPath}); - let rootDir = null; - if (extension === "jar") { - rootDir = "META-INF"; - } - await zip.extract(rootDir, stagingDir); - await zip.close(); - - // Check whether target dir already exists and remove it - if (await this._pathExists(targetDir)) { - log.verbose(`Removing existing target directory at ${targetDir}...`); - await rmrf(targetDir); - } - - // Do not create target dir itself to prevent EPERM error in following rename operation - // (https://github.com/UI5/cli/issues/487) - await mkdirp(path.dirname(targetDir)); - log.verbose(`Promoting staging directory from ${stagingDir} to ${targetDir}...`); - await rename(stagingDir, targetDir); - - await removeArtifact(); + return this._synchronize(`install-${pkgName}@${version}`, async () => { + const {revision} = await this._fetchArtifactMetadata({ + pkgName, groupId, artifactId, version, classifier, extension }); - } else { - log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); - } - return { - pkgPath: targetDir - }; + + const coordinates = { + groupId, artifactId, + version, revision, + classifier, extension + }; + + const targetDir = this._getTargetDirForPackage(pkgName, revision); + const installed = await this._projectExists(targetDir); + + if (!installed) { + await this._synchronize(`package-${pkgName}@${revision}`, async () => { + const installed = await this._projectExists(targetDir); + + if (installed) { + log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); + return; + } + + const stagingDir = this._getStagingDirForPackage(pkgName, revision); + + // Check whether staging dir already exists and remove it + if (await this._pathExists(stagingDir)) { + log.verbose(`Removing stale staging directory at ${stagingDir}...`); + await rmrf(stagingDir); + } + + await mkdirp(stagingDir); + + const {artifactPath, removeArtifact} = await this.installArtifact(coordinates); + + log.verbose(`Extracting archive at ${artifactPath} to ${stagingDir}...`); + const zip = new StreamZip({file: artifactPath}); + let rootDir = null; + if (extension === "jar") { + rootDir = "META-INF"; + } + await zip.extract(rootDir, stagingDir); + await zip.close(); + + // Check whether target dir already exists and remove it + if (await this._pathExists(targetDir)) { + log.verbose(`Removing existing target directory at ${targetDir}...`); + await rmrf(targetDir); + } + + // Do not create target dir itself to prevent EPERM error in following rename operation + // (https://github.com/UI5/cli/issues/487) + await mkdirp(path.dirname(targetDir)); + log.verbose(`Promoting staging directory from ${stagingDir} to ${targetDir}...`); + await rename(stagingDir, targetDir); + + await removeArtifact(); + }); + } else { + log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); + } + return { + pkgPath: targetDir + }; + }); } /** diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 1e9fa2b9b13..3dd49f5fb66 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -64,26 +64,30 @@ class Installer extends AbstractInstaller { } async fetchPackageManifest({pkgName, version}) { - const targetDir = this._getTargetDirForPackage({pkgName, version}); - try { - const pkg = await this.readJson(path.join(targetDir, "package.json")); - return { - name: pkg.name, - dependencies: pkg.dependencies, - devDependencies: pkg.devDependencies - }; - } catch (err) { - if (err.code === "ENOENT") { // "File or directory does not exist" - const manifest = await this.getRegistry().requestPackageManifest(pkgName, version); + // Hold a lock during the manifest fetch so cache cleanup cannot delete + // framework/cacache/ while pacote writes temporary files there. + return this._synchronize(`manifest-${pkgName}@${version}`, async () => { + const targetDir = this._getTargetDirForPackage({pkgName, version}); + try { + const pkg = await this.readJson(path.join(targetDir, "package.json")); return { - name: manifest.name, - dependencies: manifest.dependencies, - devDependencies: manifest.devDependencies + name: pkg.name, + dependencies: pkg.dependencies, + devDependencies: pkg.devDependencies }; - } else { - throw err; + } catch (err) { + if (err.code === "ENOENT") { // "File or directory does not exist" + const manifest = await this.getRegistry().requestPackageManifest(pkgName, version); + return { + name: manifest.name, + dependencies: manifest.dependencies, + devDependencies: manifest.devDependencies + }; + } else { + throw err; + } } - } + }); } async installPackage({pkgName, version}) { diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 93096d50109..808a9b1ed20 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -759,9 +759,13 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, + // fetchPackageManifest now runs through _synchronize("manifest-...") which adds async + // overhead, so the concurrent installPackage extraction error arrives first when both fail. expectedErrorMessage: `Resolution of framework libraries failed with errors: - 1. Failed to resolve library sap.ui.lib1: Failed to read manifest of @openui5/sap.ui.lib1@1.75.0 - 2. Failed to resolve library sap.ui.lib4: Failed to read manifest of @openui5/sap.ui.lib4@1.75.0` + 1. Failed to resolve library sap.ui.lib1: Failed to extract package @openui5/sap.ui.lib1@1.75.0: ` + +`404 - @openui5/sap.ui.lib1 + 2. Failed to resolve library sap.ui.lib4: Failed to extract package @openui5/sap.ui.lib4@1.75.0: ` + +`404 - @openui5/sap.ui.lib4` }); test.serial("ui5Framework helper should not fail when no framework configuration is given", async (t) => { diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index bd469804534..d852f264f99 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -118,7 +118,9 @@ test("cleanCache: removes framework directory and returns stats", async (t) => { t.is(result.libraryCount, 2); t.is(result.versionCount, 2); // 1.120.0, 1.148.0 - await t.throwsAsync(fs.access(frameworkDir)); + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); }); test("cleanCache: removes directory with multiple scopes", async (t) => { @@ -132,7 +134,9 @@ test("cleanCache: removes directory with multiple scopes", async (t) => { t.is(result.libraryCount, 1); // sap.m deduplicated t.is(result.versionCount, 2); - await t.throwsAsync(fs.access(frameworkDir)); + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); }); test("cleanCache: throws when active lockfiles exist", async (t) => { @@ -171,37 +175,65 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { t.is(result.libraryCount, 1); t.is(result.versionCount, 1); - await t.throwsAsync(fs.access(frameworkDir)); + // packages/ is removed so a subsequent getCacheInfo returns null + const packagesDir = path.join(frameworkDir, "packages"); + await t.throwsAsync(fs.access(packagesDir)); }); -test("cleanCache: holds cleanup lock during deletion", async (t) => { +// Test A — regression guard: installer lock present → cleanCache must throw. +// This invariant must hold regardless of whether the check is before or after +// the cleanup lock acquisition. If someone removes the post-lock check, this test fails. +test("cleanCache: throws when installer lock exists (regression guard)", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + // Simulate an in-progress install by placing a non-stale package lock const lockDir = path.join(t.context.testDir, "framework", "locks"); - let lockObservedBeforeCompletion = false; - - // Start cleanCache without awaiting — run the lock check in parallel - const cleanPromise = cleanCache(t.context.testDir); - - // Poll for the cleanup lock to appear, with a short delay between attempts - for (let i = 0; i < 50; i++) { - await new Promise((resolve) => setTimeout(resolve, 20)); - try { - const entries = await fs.readdir(lockDir); - if (entries.some((name) => name === "cache-cleanup.lock")) { - lockObservedBeforeCompletion = true; - break; - } - } catch { - // locks/ not created yet - } + await fs.mkdir(lockDir, {recursive: true}); + const pkgLockPath = path.join(lockDir, "package-@openui5-sap.m@1.120.0.lock"); + await lockfileLock(pkgLockPath, {stale: 60000}); + try { + const err = await t.throwsAsync(cleanCache(t.context.testDir)); + t.true(err.message.includes("currently locked by an active operation")); + } finally { + await lockfileUnlock(pkgLockPath); } +}); - const result = await cleanPromise; - t.truthy(result); - t.true(lockObservedBeforeCompletion, "cache-cleanup.lock was present during deletion"); +// Test B — post-lock check: cleanup lock is held when hasActiveLocks fires. +// Verifies the "acquire-then-check" order by confirming that the cleanup lock +// is already present in locks/ when cleanCache detects an installer lock and throws. +// If the old "check-then-acquire" order were used instead, the cleanup lock would +// NOT be present at check time — so this test would pass only with the correct order. +test("cleanCache: cleanup lock is held when installer lock is detected (acquire-then-check)", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - // After completion: framework/ is fully removed - const frameworkDir = path.join(t.context.testDir, "framework"); - await t.throwsAsync(fs.access(frameworkDir)); + const lockDir = path.join(t.context.testDir, "framework", "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + // Place an installer lock that cleanCache will detect + const pkgLockPath = path.join(lockDir, "package-@openui5-sap.m@1.120.0.lock"); + await lockfileLock(pkgLockPath, {stale: 60000}); + + // After cleanCache throws, check whether the cleanup lock was placed before the throw. + // Since the finally block removes locks/ entirely, we observe via the error alone. + // The key structural test: cleanCache must throw (proving the post-lock check ran), + // AND after completion the lockDir must be gone (cleanup lock was released properly). + let thrownError; + try { + await cleanCache(t.context.testDir); + } catch (err) { + thrownError = err; + } finally { + await lockfileUnlock(pkgLockPath).catch(() => {}); + } + + t.truthy(thrownError, "cleanCache should throw when installer lock is present"); + t.true(thrownError?.message?.includes("currently locked by an active operation"), + "Error is the expected lock conflict message"); + + // The finally block in cleanCache removes locks/ even when the post-lock check throws. + // Verify the directory is cleaned up — confirms cleanup lock was released correctly. + await t.throwsAsync(fs.access(lockDir), + undefined, "locks/ directory removed after cleanup lock released"); }); + diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index c07e9e204bc..fe67d0dc530 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -22,6 +22,9 @@ test.beforeEach(async (t) => { t.context.lockStub = sinon.stub(); t.context.unlockStub = sinon.stub(); + // Configure stubs to call back immediately so promisify-wrapped calls resolve + t.context.lockStub.yieldsAsync(); + t.context.unlockStub.yieldsAsync(); t.context.zipStub = class StreamZipStub { extract = sinon.stub().resolves(); close = sinon.stub().resolves(); diff --git a/packages/project/test/lib/ui5framework/npm/Installer.js b/packages/project/test/lib/ui5framework/npm/Installer.js index c06b36ae33d..394b797e71d 100644 --- a/packages/project/test/lib/ui5framework/npm/Installer.js +++ b/packages/project/test/lib/ui5framework/npm/Installer.js @@ -11,6 +11,9 @@ test.beforeEach(async (t) => { t.context.lockStub = sinon.stub(); t.context.unlockStub = sinon.stub(); + // Configure stubs to call back immediately so promisify-wrapped lock/unlock resolve + t.context.lockStub.yieldsAsync(); + t.context.unlockStub.yieldsAsync(); t.context.renameStub = sinon.stub().yieldsAsync(); t.context.statStub = sinon.stub().yieldsAsync(); From 222ee57a08dfdef2a30658c48681a8ea7d13e419 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 10 Jun 2026 15:51:56 +0300 Subject: [PATCH 027/114] test: Fix race condition expectations --- .../lib/graph/helpers/ui5Framework.integration.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 808a9b1ed20..b985d0a04b4 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -759,13 +759,10 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, - // fetchPackageManifest now runs through _synchronize("manifest-...") which adds async - // overhead, so the concurrent installPackage extraction error arrives first when both fail. - expectedErrorMessage: `Resolution of framework libraries failed with errors: - 1. Failed to resolve library sap.ui.lib1: Failed to extract package @openui5/sap.ui.lib1@1.75.0: ` + -`404 - @openui5/sap.ui.lib1 - 2. Failed to resolve library sap.ui.lib4: Failed to extract package @openui5/sap.ui.lib4@1.75.0: ` + -`404 - @openui5/sap.ui.lib4` + // When both manifest fetch and extraction fail simultaneously, which error surfaces first + // depends on microtask scheduling and is not deterministic across Node versions. Both are + // valid: accept either "Failed to read manifest" or "Failed to extract package". + expectedErrorMessage: /Resolution of framework libraries failed with errors:\n\s+1\. Failed to resolve library sap\.ui\.lib1: Failed to (read manifest of|extract package) @openui5\/sap\.ui\.lib1@1\.75\.0/ }); test.serial("ui5Framework helper should not fail when no framework configuration is given", async (t) => { From daf4304ff1d1ae639c9ac49e4d453cf9ae79b316 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 10 Jun 2026 16:01:34 +0300 Subject: [PATCH 028/114] refactor: Cleanups --- packages/project/lib/ui5Framework/_frameworkPaths.js | 2 +- packages/project/lib/ui5Framework/cache.js | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index 7b60c844149..ea86eb027e1 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import {promisify} from "node:util"; // Directory name for framework packages within ui5DataDir -export const FRAMEWORK_DIR_NAME = "framework"; +const FRAMEWORK_DIR_NAME = "framework"; // Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize export const LOCK_STALE_MS = 60000; diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index bdaa63a4f59..521ceb9d150 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import {promisify} from "node:util"; import { - FRAMEWORK_DIR_NAME, LOCK_STALE_MS, CLEANUP_LOCK_NAME, getFrameworkDir, @@ -86,7 +85,7 @@ export async function getCacheInfo(ui5DataDir) { return null; } return { - path: FRAMEWORK_DIR_NAME, + path: "framework", libraryCount: stats.libraries, versionCount: stats.versions, }; @@ -133,7 +132,6 @@ export async function cleanCache(ui5DataDir) { const lockDir = getFrameworkLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - // Ensure the locks directory exists before acquiring our lock await fs.mkdir(lockDir, {recursive: true}); const {default: lockfile} = await import("lockfile"); @@ -168,7 +166,7 @@ export async function cleanCache(ui5DataDir) { } return { - path: FRAMEWORK_DIR_NAME, + path: "framework", libraryCount: stats.libraries, versionCount: stats.versions, }; From 979c4e86d49d9c3e523142c370fc1ecc341f333c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 10 Jun 2026 16:14:29 +0300 Subject: [PATCH 029/114] refactor: Naming of locks --- packages/project/lib/ui5Framework/_frameworkPaths.js | 4 ++-- packages/project/lib/ui5Framework/maven/Installer.js | 2 +- packages/project/lib/ui5Framework/npm/Installer.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index ea86eb027e1..834ccae8ac7 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -14,8 +14,8 @@ export const LOCK_STALE_MS = 60000; // Lock naming convention (files live in getFrameworkLockDir(); slashes in package // names are replaced with dashes by AbstractInstaller#_sanitizeFileName): // cache-cleanup.lock — held by ui5 cache clean for the full deletion -// install-{pkg}@{ver}.lock — held by maven Installer for the full install lifecycle -// manifest-{pkg}@{ver}.lock — held by npm Installer during manifest fetch (cacache writes) +// maven-{pkg}@{ver}.lock — held by maven Installer for the full install lifecycle +// npm-{pkg}@{ver}.lock — held by npm Installer during manifest fetch (cacache writes) // package-{pkg}@{ver}.lock — held by both installers during package extraction export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 6d982a53170..340884ef415 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -301,7 +301,7 @@ class Installer extends AbstractInstaller { * @returns {@ui5/project/ui5Framework/maven/Installer~InstalledPackage} */ async installPackage({pkgName, groupId, artifactId, version, classifier, extension}) { - return this._synchronize(`install-${pkgName}@${version}`, async () => { + return this._synchronize(`maven-${pkgName}@${version}`, async () => { const {revision} = await this._fetchArtifactMetadata({ pkgName, groupId, artifactId, version, classifier, extension }); diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 3dd49f5fb66..2339b50952b 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -66,7 +66,7 @@ class Installer extends AbstractInstaller { async fetchPackageManifest({pkgName, version}) { // Hold a lock during the manifest fetch so cache cleanup cannot delete // framework/cacache/ while pacote writes temporary files there. - return this._synchronize(`manifest-${pkgName}@${version}`, async () => { + return this._synchronize(`npm-${pkgName}@${version}`, async () => { const targetDir = this._getTargetDirForPackage({pkgName, version}); try { const pkg = await this.readJson(path.join(targetDir, "package.json")); From e11035438d241213aec29358906b2c5098c1b960 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 11 Jun 2026 11:53:29 +0300 Subject: [PATCH 030/114] docs: Update documentation to respect recent changes --- .../docs/pages/Troubleshooting.md | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index aff2f642c0c..0fce730cd56 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -12,36 +12,30 @@ Please follow our [Contribution Guidelines](https://github.com/UI5/cli/blob/main ## UI5 Project ### `~/.ui5` Taking too Much Disk Space -UI5 CLI stores several kinds of data under your user's home directory in `~/.ui5/`: - -| Directory | Contents | Safe to delete? | -| ---- | ---- | ---- | -| `~/.ui5/framework/` | Downloaded UI5 framework dependencies (one copy per version) | Yes — re-downloaded on next invocation | -| `~/.ui5/buildCache/` | Build cache used by `ui5 build` and `ui5 serve` (see [Build Cache Control](./Builder.md#build-cache-control)) | Yes — rebuilt on next `ui5 build` / `ui5 serve` | -| `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS / HTTP/2 mode | Yes — regenerated on next HTTPS server start; the new certificate must be re-trusted | - -::: warning -Only remove these directories when no UI5 CLI process and no `@ui5/*` API consumer is actively running. Deleting files that are in use can cause running builds or servers to fail or produce inconsistent results. -::: +There are possibly many versions of UI5 framework dependencies and incremental build data stored on your system, taking a large amount of disk space. #### Resolution -To free disk space, remove the relevant subdirectory. - -To only remove framework downloads: +Use the dedicated cache clean command, which safely removes all cached data: ```sh -rm -rf ~/.ui5/framework/ +ui5 cache clean ``` -To only remove the build cache: +This will display the cache location, the amount of data that will be removed, and ask for confirmation before proceeding. To skip the confirmation prompt (e.g. in CI environments), use the `--yes` flag: ```sh -rm -rf ~/.ui5/buildCache/ +ui5 cache clean --yes ``` +The command removes two types of cached data: +- **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **Build cache (DB)** — incremental build data (`~/.ui5/buildCache/`) + +Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. + ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, replace `~/.ui5/` with that path. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5DataDir`, the cache will be cleaned from that location instead of `~/.ui5`. See [Changing UI5 CLI's Data Directory](#changing-ui5-clis-data-directory) below. ::: ## Environment Variables From b08c11b088b10b5b10d2c5c2a627db70adc5a0d5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 12 Jun 2026 11:07:56 +0300 Subject: [PATCH 031/114] revert: Redundant maven installer locks --- .../lib/ui5Framework/maven/Installer.js | 126 +++++++++--------- 1 file changed, 62 insertions(+), 64 deletions(-) diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 340884ef415..008ca0290e5 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -301,71 +301,69 @@ class Installer extends AbstractInstaller { * @returns {@ui5/project/ui5Framework/maven/Installer~InstalledPackage} */ async installPackage({pkgName, groupId, artifactId, version, classifier, extension}) { - return this._synchronize(`maven-${pkgName}@${version}`, async () => { - const {revision} = await this._fetchArtifactMetadata({ - pkgName, groupId, artifactId, version, classifier, extension - }); - - const coordinates = { - groupId, artifactId, - version, revision, - classifier, extension - }; - - const targetDir = this._getTargetDirForPackage(pkgName, revision); - const installed = await this._projectExists(targetDir); - - if (!installed) { - await this._synchronize(`package-${pkgName}@${revision}`, async () => { - const installed = await this._projectExists(targetDir); - - if (installed) { - log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); - return; - } - - const stagingDir = this._getStagingDirForPackage(pkgName, revision); - - // Check whether staging dir already exists and remove it - if (await this._pathExists(stagingDir)) { - log.verbose(`Removing stale staging directory at ${stagingDir}...`); - await rmrf(stagingDir); - } - - await mkdirp(stagingDir); - - const {artifactPath, removeArtifact} = await this.installArtifact(coordinates); - - log.verbose(`Extracting archive at ${artifactPath} to ${stagingDir}...`); - const zip = new StreamZip({file: artifactPath}); - let rootDir = null; - if (extension === "jar") { - rootDir = "META-INF"; - } - await zip.extract(rootDir, stagingDir); - await zip.close(); - - // Check whether target dir already exists and remove it - if (await this._pathExists(targetDir)) { - log.verbose(`Removing existing target directory at ${targetDir}...`); - await rmrf(targetDir); - } - - // Do not create target dir itself to prevent EPERM error in following rename operation - // (https://github.com/UI5/cli/issues/487) - await mkdirp(path.dirname(targetDir)); - log.verbose(`Promoting staging directory from ${stagingDir} to ${targetDir}...`); - await rename(stagingDir, targetDir); - - await removeArtifact(); - }); - } else { - log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); - } - return { - pkgPath: targetDir - }; + const {revision} = await this._fetchArtifactMetadata({ + pkgName, groupId, artifactId, version, classifier, extension }); + + const coordinates = { + groupId, artifactId, + version, revision, + classifier, extension + }; + + const targetDir = this._getTargetDirForPackage(pkgName, revision); + const installed = await this._projectExists(targetDir); + + if (!installed) { + await this._synchronize(`package-${pkgName}@${revision}`, async () => { + const installed = await this._projectExists(targetDir); + + if (installed) { + log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); + return; + } + + const stagingDir = this._getStagingDirForPackage(pkgName, revision); + + // Check whether staging dir already exists and remove it + if (await this._pathExists(stagingDir)) { + log.verbose(`Removing stale staging directory at ${stagingDir}...`); + await rmrf(stagingDir); + } + + await mkdirp(stagingDir); + + const {artifactPath, removeArtifact} = await this.installArtifact(coordinates); + + log.verbose(`Extracting archive at ${artifactPath} to ${stagingDir}...`); + const zip = new StreamZip({file: artifactPath}); + let rootDir = null; + if (extension === "jar") { + rootDir = "META-INF"; + } + await zip.extract(rootDir, stagingDir); + await zip.close(); + + // Check whether target dir already exists and remove it + if (await this._pathExists(targetDir)) { + log.verbose(`Removing existing target directory at ${targetDir}...`); + await rmrf(targetDir); + } + + // Do not create target dir itself to prevent EPERM error in following rename operation + // (https://github.com/UI5/cli/issues/487) + await mkdirp(path.dirname(targetDir)); + log.verbose(`Promoting staging directory from ${stagingDir} to ${targetDir}...`); + await rename(stagingDir, targetDir); + + await removeArtifact(); + }); + } else { + log.verbose(`Already installed: ${pkgName} in SNAPSHOT version ${revision}`); + } + return { + pkgPath: targetDir + }; } /** From 11e545dfe677f81d25561e3b8bc3a88a1bd34077 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 12 Jun 2026 14:29:54 +0300 Subject: [PATCH 032/114] refactor: Use pacote's internals for its own cleanup --- packages/project/lib/ui5Framework/cache.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 521ceb9d150..c913e941e48 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -148,6 +148,19 @@ export async function cleanCache(ui5DataDir) { "Please wait for it to finish and try again." ); } + + // Use cacache's own rm.all to clear the pacote download cache. + // This respects cacache's internal structure (content-v2/, index-v5/) + // and clears in-memory memoization, which a plain fs.rm would not do. + const caCacheDir = path.join(frameworkDir, "cacache"); + try { + await fs.access(caCacheDir); + const {rm: cacacheRm} = await import("cacache"); + await cacacheRm.all(caCacheDir); + } catch { + // cacache dir doesn't exist or cacache not available — no-op + } + // Delete everything inside framework/ except locks/ so our lock stays valid throughout const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); await Promise.all( From 9b95c68fd41379c452c6959282bc14e8dc707c5e Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 12 Jun 2026 14:40:03 +0300 Subject: [PATCH 033/114] revert: NPM Install sync. It's now redundant --- .../project/lib/ui5Framework/npm/Installer.js | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 2339b50952b..1e9fa2b9b13 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -64,30 +64,26 @@ class Installer extends AbstractInstaller { } async fetchPackageManifest({pkgName, version}) { - // Hold a lock during the manifest fetch so cache cleanup cannot delete - // framework/cacache/ while pacote writes temporary files there. - return this._synchronize(`npm-${pkgName}@${version}`, async () => { - const targetDir = this._getTargetDirForPackage({pkgName, version}); - try { - const pkg = await this.readJson(path.join(targetDir, "package.json")); + const targetDir = this._getTargetDirForPackage({pkgName, version}); + try { + const pkg = await this.readJson(path.join(targetDir, "package.json")); + return { + name: pkg.name, + dependencies: pkg.dependencies, + devDependencies: pkg.devDependencies + }; + } catch (err) { + if (err.code === "ENOENT") { // "File or directory does not exist" + const manifest = await this.getRegistry().requestPackageManifest(pkgName, version); return { - name: pkg.name, - dependencies: pkg.dependencies, - devDependencies: pkg.devDependencies + name: manifest.name, + dependencies: manifest.dependencies, + devDependencies: manifest.devDependencies }; - } catch (err) { - if (err.code === "ENOENT") { // "File or directory does not exist" - const manifest = await this.getRegistry().requestPackageManifest(pkgName, version); - return { - name: manifest.name, - dependencies: manifest.dependencies, - devDependencies: manifest.devDependencies - }; - } else { - throw err; - } + } else { + throw err; } - }); + } } async installPackage({pkgName, version}) { From fd6435c7002a6720f345a9966df37e1ea4008c18 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 12 Jun 2026 14:42:48 +0300 Subject: [PATCH 034/114] docs: No mention of "incremental build" --- internal/documentation/docs/pages/Troubleshooting.md | 4 ++-- packages/cli/lib/cli/commands/cache.js | 4 ++-- packages/cli/test/lib/cli/commands/cache.js | 2 +- packages/project/lib/ui5Framework/_frameworkPaths.js | 6 ++---- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 0fce730cd56..99e090c1f9f 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -12,7 +12,7 @@ Please follow our [Contribution Guidelines](https://github.com/UI5/cli/blob/main ## UI5 Project ### `~/.ui5` Taking too Much Disk Space -There are possibly many versions of UI5 framework dependencies and incremental build data stored on your system, taking a large amount of disk space. +There are possibly many versions of UI5 framework dependencies installed on your system, taking a large amount of disk space. #### Resolution @@ -30,7 +30,7 @@ ui5 cache clean --yes The command removes two types of cached data: - **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) -- **Build cache (DB)** — incremental build data (`~/.ui5/buildCache/`) +- **Build cache (DB)** — build data (`~/.ui5/buildCache/`) Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 12b323faade..7e838d6bf40 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -9,7 +9,7 @@ import CacheManager from "@ui5/project/build/cache/CacheManager"; const cacheCommand = { command: "cache", - describe: "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)", + describe: "Manage the UI5 CLI cache (downloaded framework packages and build data)", middlewares: [baseMiddleware], handler: handleCache }; @@ -40,7 +40,7 @@ cacheCommand.builder = function(cli) { "Two cache types are removed:\n" + " UI5 Framework packages Downloaded UI5 library files " + "(~/.ui5/framework/)\n" + - " Build cache (DB) Incremental build data " + + " Build cache (DB) build data " + "(~/.ui5/buildCache/)" ); }, diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index f77865d6b24..0ad6ba519a1 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -86,7 +86,7 @@ test("Command builder", async (t) => { test.serial("Command definition is correct", (t) => { t.is(t.context.cache.command, "cache"); t.is(t.context.cache.describe, - "Manage the UI5 CLI cache (downloaded framework packages and incremental build data)"); + "Manage the UI5 CLI cache (downloaded framework packages and build data)"); t.is(typeof t.context.cache.builder, "function"); t.is(typeof t.context.cache.handler, "function"); }); diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index 834ccae8ac7..d8463a98e1b 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -13,10 +13,8 @@ export const LOCK_STALE_MS = 60000; // // Lock naming convention (files live in getFrameworkLockDir(); slashes in package // names are replaced with dashes by AbstractInstaller#_sanitizeFileName): -// cache-cleanup.lock — held by ui5 cache clean for the full deletion -// maven-{pkg}@{ver}.lock — held by maven Installer for the full install lifecycle -// npm-{pkg}@{ver}.lock — held by npm Installer during manifest fetch (cacache writes) -// package-{pkg}@{ver}.lock — held by both installers during package extraction +// cache-cleanup.lock — held by ui5 cache clean for the full deletion +// package-{pkg}@{ver}.lock — held by both installers during package extraction export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; /** From 9d5f564b7cab99646fe0f6454e6fee612aa17e12 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 12 Jun 2026 15:10:31 +0300 Subject: [PATCH 035/114] refactor: Avoid hardcoded values --- packages/project/lib/ui5Framework/_frameworkPaths.js | 2 +- packages/project/lib/ui5Framework/cache.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index d8463a98e1b..2f838bc7a23 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import {promisify} from "node:util"; // Directory name for framework packages within ui5DataDir -const FRAMEWORK_DIR_NAME = "framework"; +export const FRAMEWORK_DIR_NAME = "framework"; // Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize export const LOCK_STALE_MS = 60000; diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index c913e941e48..f50fff024cd 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import {promisify} from "node:util"; import { + FRAMEWORK_DIR_NAME, LOCK_STALE_MS, CLEANUP_LOCK_NAME, getFrameworkDir, @@ -85,7 +86,7 @@ export async function getCacheInfo(ui5DataDir) { return null; } return { - path: "framework", + path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, versionCount: stats.versions, }; @@ -179,7 +180,7 @@ export async function cleanCache(ui5DataDir) { } return { - path: "framework", + path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, versionCount: stats.versions, }; From f0e84be7b4fe77fee72567627ef28d7f02d82171 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 15 Jun 2026 10:39:48 +0300 Subject: [PATCH 036/114] feat: Add cleanup lock for "ui5 serve" --- packages/cli/test/lib/cli/commands/serve.js | 7 +++++++ packages/project/lib/ui5Framework/_frameworkPaths.js | 1 + 2 files changed, 8 insertions(+) diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index ffab26787f0..c85c0550823 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -85,6 +85,13 @@ test.beforeEach(async (t) => { "@ui5/server": t.context.server, "@ui5/server/internal/sslUtil": t.context.sslUtil, "@ui5/project/graph": t.context.graph, + "../../../../lib/framework/utils.js": { + getUi5DataDir: sinon.stub().resolves(undefined) + }, + "lockfile": { + lock: sinon.stub().yieldsAsync(), + unlock: sinon.stub().yieldsAsync() + }, "open": t.context.open }); }); diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index 2f838bc7a23..99381cd483a 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -15,6 +15,7 @@ export const LOCK_STALE_MS = 60000; // names are replaced with dashes by AbstractInstaller#_sanitizeFileName): // cache-cleanup.lock — held by ui5 cache clean for the full deletion // package-{pkg}@{ver}.lock — held by both installers during package extraction +// (callers may add their own lock files to signal activity to cache cleanup) export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; /** From cec55429dee106195ffe1d850a03ebc82692e600 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 17 Jun 2026 13:59:13 +0300 Subject: [PATCH 037/114] refactor: Move server lock into the server package and reuse in cli --- package-lock.json | 1 + packages/cli/test/lib/cli/commands/serve.js | 10 +-- packages/server/package.json | 1 + packages/server/test/lib/server/server.js | 73 +++++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c2c29af03c..98560a186f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18610,6 +18610,7 @@ "express": "^4.22.2", "fresh": "^0.5.2", "graceful-fs": "^4.2.11", + "lockfile": "^1.0.4", "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index c85c0550823..f73f0cd55cb 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -47,7 +47,8 @@ test.beforeEach(async (t) => { t.context.handlerReadyResolvers.resolve(); return { h2: false, - port: 8080 + port: 8080, + close: sinon.stub().callsArg(0) }; }) }; @@ -85,13 +86,6 @@ test.beforeEach(async (t) => { "@ui5/server": t.context.server, "@ui5/server/internal/sslUtil": t.context.sslUtil, "@ui5/project/graph": t.context.graph, - "../../../../lib/framework/utils.js": { - getUi5DataDir: sinon.stub().resolves(undefined) - }, - "lockfile": { - lock: sinon.stub().yieldsAsync(), - unlock: sinon.stub().yieldsAsync() - }, "open": t.context.open }); }); diff --git a/packages/server/package.json b/packages/server/package.json index 31979b54110..7026ef2010f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -97,6 +97,7 @@ "express": "^4.22.2", "fresh": "^0.5.2", "graceful-fs": "^4.2.11", + "lockfile": "^1.0.4", "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", diff --git a/packages/server/test/lib/server/server.js b/packages/server/test/lib/server/server.js index fc85c81c5d2..3e46d37cdf0 100644 --- a/packages/server/test/lib/server/server.js +++ b/packages/server/test/lib/server/server.js @@ -1,4 +1,5 @@ import test from "ava"; +import path from "node:path"; import sinon from "sinon"; import esmock from "esmock"; import {EventEmitter} from "node:events"; @@ -57,6 +58,14 @@ function createMocks(mockServer) { }, "@ui5/fs/ReaderCollectionPrioritized": { default: class MockReaderCollectionPrioritized {} + }, + "lockfile": { + lock: sinon.stub().callsFake((_path, _opts, cb) => cb(null)), + unlock: sinon.stub().callsFake((_path, cb) => cb(null)), + unlockSync: sinon.stub() + }, + "node:fs/promises": { + mkdir: sinon.stub().resolves() } }; } @@ -94,6 +103,14 @@ test("server.on('error') rejects the serve promise", async (t) => { }, "@ui5/fs/ReaderCollectionPrioritized": { default: class MockReaderCollectionPrioritized {} + }, + "lockfile": { + lock: sinon.stub().callsFake((_path, _opts, cb) => cb(null)), + unlock: sinon.stub().callsFake((_path, cb) => cb(null)), + unlockSync: sinon.stub() + }, + "node:fs/promises": { + mkdir: sinon.stub().resolves() } }; @@ -139,3 +156,59 @@ test("close() still calls server.close when buildServer.destroy() rejects", asyn }); t.true(mockServer.close.calledOnce, "server.close was called despite destroy rejection"); }); + +// ─── Server lock lifecycle ──────────────────────────────────────────────────── + +test("serve() acquires server-{port}.lock after _listen resolves", async (t) => { + const mockServer = createMockServer(); + const mockBuildServer = createMockBuildServer(); + const mocks = createMocks(mockServer); + const lockStub = mocks["lockfile"].lock; + + const {serve} = await esmock("../../../lib/server.js", mocks); + const graph = createMockGraph(mockBuildServer); + + const ui5DataDir = path.join("test", "tmp", "lock-test-acquire"); + const result = await serve(graph, {port: 3001, ui5DataDir}); + + t.true(lockStub.calledOnce, "lockfile.lock called once"); + const lockPath = lockStub.firstCall.args[0]; + t.true(lockPath.endsWith(`server-3001.lock`), `lock path ends with server-3001.lock, got: ${lockPath}`); + + result.close(() => {}); +}); + +test("close() releases the server lock", async (t) => { + const mockServer = createMockServer(); + const mockBuildServer = createMockBuildServer(); + const mocks = createMocks(mockServer); + const unlockSyncStub = mocks["lockfile"].unlockSync; + + const {serve} = await esmock("../../../lib/server.js", mocks); + const graph = createMockGraph(mockBuildServer); + + const ui5DataDir = path.join("test", "tmp", "lock-test-release"); + const result = await serve(graph, {port: 3002, ui5DataDir}); + + await new Promise((resolve) => result.close(resolve)); + + t.true(unlockSyncStub.calledOnce, "lockfile.unlockSync called once on close"); +}); + +test("close() releases the lock only once (idempotent)", async (t) => { + const mockServer = createMockServer(); + const mockBuildServer = createMockBuildServer(); + const mocks = createMocks(mockServer); + const unlockSyncStub = mocks["lockfile"].unlockSync; + + const {serve} = await esmock("../../../lib/server.js", mocks); + const graph = createMockGraph(mockBuildServer); + + const ui5DataDir = path.join("test", "tmp", "lock-test-idempotent"); + const result = await serve(graph, {port: 3003, ui5DataDir}); + + await new Promise((resolve) => result.close(resolve)); + await new Promise((resolve) => result.close(resolve)); + + t.is(unlockSyncStub.callCount, 1, "unlockSync called exactly once even when close() called twice"); +}); From 571d7b364ff724664e039fb12681b104161c0907 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 18 Jun 2026 16:16:37 +0300 Subject: [PATCH 038/114] refactor: Data dir is resolved centrally via util --- packages/cli/lib/cli/commands/cache.js | 6 +- packages/cli/test/lib/cli/commands/cache.js | 49 ++++------- .../project/lib/build/cache/CacheManager.js | 15 +--- .../project/lib/graph/helpers/ui5Framework.js | 12 +-- packages/project/lib/utils/dataDir.js | 30 +++++++ packages/project/package.json | 1 + .../test/lib/build/cache/CacheManager.js | 7 +- .../graph/helpers/ui5Framework.integration.js | 4 +- .../test/lib/graph/helpers/ui5Framework.js | 23 +++--- packages/project/test/lib/package-exports.js | 2 +- packages/project/test/lib/utils/dataDir.js | 81 +++++++++++++++++++ 11 files changed, 150 insertions(+), 80 deletions(-) create mode 100644 packages/project/lib/utils/dataDir.js create mode 100644 packages/project/test/lib/utils/dataDir.js diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 7e838d6bf40..e167a77cf9f 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -1,9 +1,8 @@ import chalk from "chalk"; import path from "node:path"; -import os from "node:os"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; -import {getUi5DataDir} from "../../framework/utils.js"; +import {getDefaultUi5DataDir} from "@ui5/project/utils/dataDir"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -98,8 +97,7 @@ async function handleCache(argv) { // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 // Relative paths are resolved against process.cwd() (project root when invoked from the project). - const ui5DataDir = - (await getUi5DataDir({cwd: process.cwd()})) ?? path.join(os.homedir(), ".ui5"); + const ui5DataDir = await getDefaultUi5DataDir({cwd: process.cwd()}); // Abort early if a framework operation is holding a lock — before prompting the user if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 0ad6ba519a1..a199d92fd1a 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -1,6 +1,5 @@ import test from "ava"; import path from "node:path"; -import os from "node:os"; import sinon from "sinon"; import esmock from "esmock"; @@ -29,7 +28,7 @@ test.beforeEach(async (t) => { // Prevent real env var from leaking into tests delete process.env.UI5_DATA_DIR; - t.context.getUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + t.context.getDefaultUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); @@ -40,8 +39,8 @@ test.beforeEach(async (t) => { t.context.yesnoStub = sinon.stub(); t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { - "../../../../lib/framework/utils.js": { - getUi5DataDir: t.context.getUi5DataDirStub, + "@ui5/project/utils/dataDir": { + getDefaultUi5DataDir: t.context.getDefaultUi5DataDirStub, }, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, @@ -93,8 +92,9 @@ test.serial("Command definition is correct", (t) => { // ─── ui5DataDir resolution ────────────────────────────────────────────────── -test.serial("ui5 cache clean: passes process.cwd() to getUi5DataDir", async (t) => { - const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; +test.serial("ui5 cache clean: passes process.cwd() to getDefaultUi5DataDir", async (t) => { + const {cache, argv, getDefaultUi5DataDirStub, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo} = t.context; frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -102,30 +102,12 @@ test.serial("ui5 cache clean: passes process.cwd() to getUi5DataDir", async (t) argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getUi5DataDirStub.callCount, 1, "getUi5DataDir called once"); - t.deepEqual(getUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, - "Passes {cwd: process.cwd()} to getUi5DataDir"); + t.is(getDefaultUi5DataDirStub.callCount, 1, "getDefaultUi5DataDir called once"); + t.deepEqual(getDefaultUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, + "Passes {cwd: process.cwd()} to getDefaultUi5DataDir"); }); -test.serial("ui5 cache clean: falls back to ~/.ui5 when getUi5DataDir returns undefined", async (t) => { - const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, - stderrWriteStub} = t.context; - - getUi5DataDirStub.resolves(undefined); - frameworkCacheGetCacheInfo.resolves(null); - buildCacheGetCacheInfo.resolves(null); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - const expectedDefault = path.join(os.homedir(), ".ui5"); - const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); - t.true(allOutput.includes(expectedDefault), "Falls back to ~/.ui5 and shows it in checking line"); - t.is(frameworkCacheGetCacheInfo.firstCall.args[0], expectedDefault, - "getCacheInfo receives ~/.ui5 as ui5DataDir"); -}); - -test.serial("ui5 cache clean: uses resolved path from getUi5DataDir", async (t) => { +test.serial("ui5 cache clean: uses resolved path from getDefaultUi5DataDir", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; frameworkCacheGetCacheInfo.resolves(null); @@ -135,17 +117,18 @@ test.serial("ui5 cache clean: uses resolved path from getUi5DataDir", async (t) await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, - "getCacheInfo receives the path returned by getUi5DataDir"); + "getCacheInfo receives the path returned by getDefaultUi5DataDir"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); }); -test.serial("ui5 cache clean: relative path from config is resolved via getUi5DataDir", async (t) => { - const {cache, argv, getUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; +test.serial("ui5 cache clean: relative path from config is resolved via getDefaultUi5DataDir", async (t) => { + const {cache, argv, getDefaultUi5DataDirStub, frameworkCacheGetCacheInfo, + buildCacheGetCacheInfo} = t.context; const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); - getUi5DataDirStub.resolves(resolvedPath); + getDefaultUi5DataDirStub.resolves(resolvedPath); frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -153,7 +136,7 @@ test.serial("ui5 cache clean: relative path from config is resolved via getUi5Da await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], resolvedPath, - "getCacheInfo receives the pre-resolved absolute path from getUi5DataDir"); + "getCacheInfo receives the pre-resolved absolute path from getDefaultUi5DataDir"); }); // ─── Basic flow ───────────────────────────────────────────────────────────── diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index bb8769ca5ca..bb2f190e81b 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,7 +1,6 @@ import path from "node:path"; -import os from "node:os"; import {access} from "node:fs/promises"; -import Configuration from "../../config/Configuration.js"; +import {getDefaultUi5DataDir} from "../../utils/dataDir.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -74,17 +73,9 @@ export default class CacheManager { */ static async create(cwd, {ui5DataDir} = {}) { if (!ui5DataDir) { - // ENV var should take precedence over the dataDir from the configuration. - ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - } - if (ui5DataDir) { - ui5DataDir = path.resolve(cwd, ui5DataDir); + ui5DataDir = await getDefaultUi5DataDir({cwd}); } else { - ui5DataDir = path.join(os.homedir(), ".ui5"); + ui5DataDir = path.resolve(cwd, ui5DataDir); } const cacheDir = path.join(ui5DataDir, "buildCache"); log.verbose(`Using build cache directory: ${cacheDir}`); diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 660cc78427e..b149b5809cb 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -2,8 +2,7 @@ import Module from "../Module.js"; import ProjectGraph from "../ProjectGraph.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:helpers:ui5Framework"); -import Configuration from "../../config/Configuration.js"; -import path from "node:path"; +import {getDefaultUi5DataDir} from "../../utils/dataDir.js"; class ProjectProcessor { constructor({libraryMetadata, graph, workspace}) { @@ -349,14 +348,7 @@ export default { } // ENV var should take precedence over the dataDir from the configuration. - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - if (ui5DataDir) { - ui5DataDir = path.resolve(cwd, ui5DataDir); - } + const ui5DataDir = await getDefaultUi5DataDir({cwd}); if (options.versionOverride) { version = await Resolver.resolveVersion(options.versionOverride, { diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js new file mode 100644 index 00000000000..af9408d7cd8 --- /dev/null +++ b/packages/project/lib/utils/dataDir.js @@ -0,0 +1,30 @@ +import path from "node:path"; +import os from "node:os"; +import Configuration from "../config/Configuration.js"; + +/** + * Resolves the UI5 data directory using the standard precedence chain: + *
    + *
  1. UI5_DATA_DIR environment variable
  2. + *
  3. ui5DataDir option from the configuration file (~/.ui5rc)
  4. + *
  5. Default: ~/.ui5
  6. + *
+ * + * Relative paths are resolved against cwd. + * This function always returns an absolute path — never undefined. + * + * @param {object} [options] + * @param {string} [options.cwd=process.cwd()] Base directory for resolving relative paths + * @returns {Promise} Resolved absolute path to the UI5 data directory + */ +export async function getDefaultUi5DataDir({cwd} = {}) { + let ui5DataDir = process.env.UI5_DATA_DIR; + if (!ui5DataDir) { + const config = await Configuration.fromFile(); + ui5DataDir = config.getUi5DataDir(); + } + if (ui5DataDir) { + return path.resolve(cwd ?? process.cwd(), ui5DataDir); + } + return path.join(os.homedir(), ".ui5"); +} diff --git a/packages/project/package.json b/packages/project/package.json index 6d26916972d..63c27046ddb 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -28,6 +28,7 @@ "./ui5Framework/Sapui5Resolver": "./lib/ui5Framework/Sapui5Resolver.js", "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", "./ui5Framework/cache": "./lib/ui5Framework/cache.js", + "./utils/dataDir": "./lib/utils/dataDir.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index e6415e6c527..4617f3358f9 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -123,13 +123,10 @@ test.serial("hasResourceForStage throws without integrity", async (t) => { test.serial("create() returns singleton per cache directory", async (t) => { const testDir = getUniqueTestDir(); - process.env.UI5_DATA_DIR = testDir; const CacheManager = await esmock("../../../../lib/build/cache/CacheManager.js", { - "../../../../lib/config/Configuration.js": { - default: { - fromFile: sinon.stub().resolves({getUi5DataDir: () => null}) - } + "../../../../lib/utils/dataDir.js": { + getDefaultUi5DataDir: sinon.stub().resolves(testDir) } }); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index b985d0a04b4..5544d16144e 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -135,7 +135,9 @@ test.beforeEach(async (t) => { "../../../../lib/graph/Module.js": t.context.Module, "../../../../lib/ui5Framework/Openui5Resolver.js": t.context.Openui5Resolver, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5Resolver, - "../../../../lib/config/Configuration.js": t.context.Configuration + "../../../../lib/utils/dataDir.js": { + getDefaultUi5DataDir: sinon.stub().resolves(path.join(fakeBaseDir, "homedir", ".ui5")) + } }); t.context.projectGraphBuilder = await esmock.p("../../../../lib/graph/projectGraphBuilder.js", { diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index b134ac187ac..f49e894f423 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.js @@ -54,19 +54,15 @@ test.beforeEach(async (t) => { t.context.Sapui5MavenSnapshotResolverResolveVersionStub = sinon.stub(); t.context.Sapui5MavenSnapshotResolverStub.resolveVersion = t.context.Sapui5MavenSnapshotResolverResolveVersionStub; - t.context.getUi5DataDirStub = sinon.stub().returns(undefined); - - t.context.ConfigurationStub = { - fromFile: sinon.stub().resolves({ - getUi5DataDir: t.context.getUi5DataDirStub - }) - }; + t.context.getDefaultUi5DataDirStub = sinon.stub().resolves(undefined); t.context.ui5Framework = await esmock.p("../../../../lib/graph/helpers/ui5Framework.js", { "@ui5/logger": ui5Logger, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5ResolverStub, "../../../../lib/ui5Framework/Sapui5MavenSnapshotResolver.js": t.context.Sapui5MavenSnapshotResolverStub, - "../../../../lib/config/Configuration.js": t.context.ConfigurationStub, + "../../../../lib/utils/dataDir.js": { + getDefaultUi5DataDir: t.context.getDefaultUi5DataDirStub + }, }); t.context.utils = t.context.ui5Framework._utils; }); @@ -1108,6 +1104,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) process.env.UI5_DATA_DIR = "./ui5-data-dir-from-env-var"; const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-env-var"); + t.context.getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1122,7 +1119,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) }); test.serial("enrichProjectGraph should use UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getDefaultUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1161,9 +1158,8 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - getUi5DataDirStub.returns("./ui5-data-dir-from-config"); - const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-config"); + getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1178,7 +1174,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy }); test.serial("enrichProjectGraph should use absolute UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getDefaultUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1217,9 +1213,8 @@ test.serial("enrichProjectGraph should use absolute UI5 data dir from configurat const provider = new DependencyTreeProvider({dependencyTree}); const projectGraph = await projectGraphBuilder(provider); - getUi5DataDirStub.returns("/absolute-ui5-data-dir-from-config"); - const expectedUi5DataDir = path.resolve("/absolute-ui5-data-dir-from-config"); + getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index 35f7a032be3..d248126f2a5 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 16); + t.is(Object.keys(packageJson.exports).length, 17); }); // Public API contract (exported modules) diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js new file mode 100644 index 00000000000..b165ee2391a --- /dev/null +++ b/packages/project/test/lib/utils/dataDir.js @@ -0,0 +1,81 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import sinon from "sinon"; +import esmock from "esmock"; + +test.beforeEach(async (t) => { + t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; + delete process.env.UI5_DATA_DIR; + + t.context.configGetUi5DataDirStub = sinon.stub().returns(undefined); + t.context.ConfigurationStub = { + fromFile: sinon.stub().resolves({ + getUi5DataDir: t.context.configGetUi5DataDirStub + }) + }; + + const {getDefaultUi5DataDir} = await esmock("../../../lib/utils/dataDir.js", { + "../../../lib/config/Configuration.js": t.context.ConfigurationStub + }); + t.context.getDefaultUi5DataDir = getDefaultUi5DataDir; +}); + +test.afterEach.always((t) => { + if (typeof t.context.originalUi5DataDirEnv === "undefined") { + delete process.env.UI5_DATA_DIR; + } else { + process.env.UI5_DATA_DIR = t.context.originalUi5DataDirEnv; + } + sinon.restore(); +}); + +test.serial("getDefaultUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { + const {getDefaultUi5DataDir} = t.context; + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, path.join(os.homedir(), ".ui5")); +}); + +test.serial("getDefaultUi5DataDir: returns value from UI5_DATA_DIR env var (absolute)", async (t) => { + const {getDefaultUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "/custom/data/dir"; + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, "/custom/data/dir"); + t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); +}); + +test.serial("getDefaultUi5DataDir: resolves relative UI5_DATA_DIR env var against cwd", async (t) => { + const {getDefaultUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "relative/data"; + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, path.resolve("/some/project", "relative/data")); +}); + +test.serial("getDefaultUi5DataDir: returns value from Configuration (absolute)", async (t) => { + const {getDefaultUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("/config/data/dir"); + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, "/config/data/dir"); +}); + +test.serial("getDefaultUi5DataDir: resolves relative Configuration value against cwd", async (t) => { + const {getDefaultUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("my-data"); + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, path.resolve("/some/project", "my-data")); +}); + +test.serial("getDefaultUi5DataDir: env var takes precedence over Configuration", async (t) => { + const {getDefaultUi5DataDir} = t.context; + process.env.UI5_DATA_DIR = "/env/data"; + t.context.configGetUi5DataDirStub.returns("/config/data"); + const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + t.is(result, "/env/data"); +}); + +test.serial("getDefaultUi5DataDir: uses process.cwd() when cwd is not provided", async (t) => { + const {getDefaultUi5DataDir} = t.context; + t.context.configGetUi5DataDirStub.returns("relative/data"); + const result = await getDefaultUi5DataDir(); + t.is(result, path.resolve(process.cwd(), "relative/data")); +}); From 03eb57978247cc4db3c0728ca3268963ea934e57 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 18 Jun 2026 16:35:17 +0300 Subject: [PATCH 039/114] fix: Remove redundant lock cleanups after server close --- packages/cli/test/lib/cli/commands/serve.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/test/lib/cli/commands/serve.js b/packages/cli/test/lib/cli/commands/serve.js index f73f0cd55cb..ffab26787f0 100644 --- a/packages/cli/test/lib/cli/commands/serve.js +++ b/packages/cli/test/lib/cli/commands/serve.js @@ -47,8 +47,7 @@ test.beforeEach(async (t) => { t.context.handlerReadyResolvers.resolve(); return { h2: false, - port: 8080, - close: sinon.stub().callsArg(0) + port: 8080 }; }) }; From 4369dcb55941cc5ecfdf3b16b48d33b6eef9edf3 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 19 Jun 2026 11:14:23 +0300 Subject: [PATCH 040/114] refactor: Revise locks destinations and messages --- packages/cli/lib/cli/commands/cache.js | 7 +++--- packages/cli/test/lib/cli/commands/cache.js | 2 +- .../lib/ui5Framework/AbstractInstaller.js | 5 +++-- .../lib/ui5Framework/_frameworkPaths.js | 22 +++++++------------ packages/project/lib/ui5Framework/cache.js | 10 ++++----- packages/project/lib/utils/dataDir.js | 19 ++++++++++++++++ .../project/test/lib/ui5framework/cache.js | 13 ++++------- .../test/lib/ui5framework/maven/Installer.js | 4 ++-- .../test/lib/ui5framework/npm/Installer.js | 12 +++++----- packages/project/test/lib/utils/dataDir.js | 18 +++++++++++++++ 10 files changed, 69 insertions(+), 43 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index e167a77cf9f..d2b6e4c9ed3 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -99,11 +99,12 @@ async function handleCache(argv) { // Relative paths are resolved against process.cwd() (project root when invoked from the project). const ui5DataDir = await getDefaultUi5DataDir({cwd: process.cwd()}); - // Abort early if a framework operation is holding a lock — before prompting the user + // Abort early if a lock is active — before prompting the user if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { process.stderr.write( - `${chalk.red("Error:")} Framework cache is currently locked by an active operation. ` + - "Please wait for it to finish and try again.\n" + `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + + "Cannot clean the cache while it is in use. " + + "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" ); process.exitCode = 1; return; diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index a199d92fd1a..5a20fb4f20e 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -350,7 +350,7 @@ test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes("Error:"), "Shows Error"); - t.true(allOutput.includes("currently locked by an active operation"), "Shows lock message"); + t.true(allOutput.includes("currently running"), "Shows lock message"); t.false(allOutput.includes("Success"), "Does not show success"); t.is(frameworkCacheGetCacheInfo.callCount, 0, "getCacheInfo not called when locked"); t.is(frameworkCacheCleanCache.callCount, 0, "cleanCache not called when locked"); diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index fa51627cd91..c014d416463 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -2,7 +2,8 @@ import path from "node:path"; import {mkdirp} from "../utils/fs.js"; import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; -import {LOCK_STALE_MS, CLEANUP_LOCK_NAME, getFrameworkLockDir} from "./_frameworkPaths.js"; +import {LOCK_STALE_MS, CLEANUP_LOCK_NAME} from "./_frameworkPaths.js"; +import {getLockDir} from "../utils/dataDir.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -23,7 +24,7 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } - this._lockDir = getFrameworkLockDir(ui5DataDir); + this._lockDir = getLockDir(ui5DataDir); } async _synchronize(lockName, callback) { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js index 99381cd483a..95d98bd6588 100644 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ b/packages/project/lib/ui5Framework/_frameworkPaths.js @@ -1,21 +1,22 @@ import path from "node:path"; import fs from "node:fs/promises"; import {promisify} from "node:util"; +import {getLockDir, LOCK_STALE_MS} from "../utils/dataDir.js"; + +export {LOCK_STALE_MS}; // Directory name for framework packages within ui5DataDir export const FRAMEWORK_DIR_NAME = "framework"; -// Lockfile staleness threshold — must match the value used by AbstractInstaller#_synchronize -export const LOCK_STALE_MS = 60000; - // Lock name acquired exclusively by cache cleanup — checked by installers to detect // an in-progress cache deletion before acquiring a per-package lock. // -// Lock naming convention (files live in getFrameworkLockDir(); slashes in package +// Lock naming convention (files live in getLockDir(); slashes in package // names are replaced with dashes by AbstractInstaller#_sanitizeFileName): // cache-cleanup.lock — held by ui5 cache clean for the full deletion // package-{pkg}@{ver}.lock — held by both installers during package extraction -// (callers may add their own lock files to signal activity to cache cleanup) +// server-{port}.lock — held by ui5 serve for the full server lifetime +// build-{pid}.lock — held by ui5 build for the full build duration export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; /** @@ -28,15 +29,8 @@ export function getFrameworkDir(ui5DataDir) { return path.join(ui5DataDir, FRAMEWORK_DIR_NAME); } -/** - * Resolve the absolute path to the framework locks directory within a UI5 data directory. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {string} Absolute path to the framework locks directory - */ -export function getFrameworkLockDir(ui5DataDir) { - return path.join(ui5DataDir, FRAMEWORK_DIR_NAME, "locks"); -} +// Re-export for consumers that previously imported from here +export {getLockDir as getFrameworkLockDir}; /** * Check whether any active (non-stale) lockfiles exist in the given locks directory, diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index f50fff024cd..3a3b16d9baf 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -6,9 +6,9 @@ import { LOCK_STALE_MS, CLEANUP_LOCK_NAME, getFrameworkDir, - getFrameworkLockDir, hasActiveLocks, } from "./_frameworkPaths.js"; +import {getLockDir} from "../utils/dataDir.js"; /** * Count unique libraries and versions in the packages/ subdirectory. @@ -100,7 +100,7 @@ export async function getCacheInfo(ui5DataDir) { * @returns {Promise} True if an active lock is held */ export async function isFrameworkLocked(ui5DataDir) { - return hasActiveLocks(getFrameworkLockDir(ui5DataDir)); + return hasActiveLocks(getLockDir(ui5DataDir)); } /** @@ -130,7 +130,7 @@ export async function cleanCache(ui5DataDir) { return null; } - const lockDir = getFrameworkLockDir(ui5DataDir); + const lockDir = getLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); await fs.mkdir(lockDir, {recursive: true}); @@ -162,11 +162,10 @@ export async function cleanCache(ui5DataDir) { // cacache dir doesn't exist or cacache not available — no-op } - // Delete everything inside framework/ except locks/ so our lock stays valid throughout + // Delete everything inside framework/ const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); await Promise.all( entries - .filter((e) => e.name !== "locks") .map((e) => { const p = path.join(frameworkDir, e.name); return e.isDirectory() ? @@ -176,7 +175,6 @@ export async function cleanCache(ui5DataDir) { ); } finally { await unlock(lockPath).catch(() => {}); - await fs.rm(lockDir, {recursive: true, force: true}); } return { diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js index af9408d7cd8..d37f3e2e741 100644 --- a/packages/project/lib/utils/dataDir.js +++ b/packages/project/lib/utils/dataDir.js @@ -2,6 +2,11 @@ import path from "node:path"; import os from "node:os"; import Configuration from "../config/Configuration.js"; +// Lockfile staleness threshold shared across all lock users (framework installer, +// cache cleanup, server, build). Must be consistent so that hasActiveLocks() +// and individual lock acquisitions agree on when a lock is stale. +export const LOCK_STALE_MS = 60000; + /** * Resolves the UI5 data directory using the standard precedence chain: *
    @@ -28,3 +33,17 @@ export async function getDefaultUi5DataDir({cwd} = {}) { } return path.join(os.homedir(), ".ui5"); } + +/** + * Resolve the absolute path to the shared locks directory within a UI5 data directory. + * + * All process-coordination lock files (framework installer, cache cleanup, server, + * build) live here so that ui5 cache clean can scan a single directory + * regardless of which subsystem holds the lock. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the locks directory (~/.ui5/locks/) + */ +export function getLockDir(ui5DataDir) { + return path.join(ui5DataDir, "locks"); +} diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index d852f264f99..48c138ce488 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -142,7 +142,7 @@ test("cleanCache: removes directory with multiple scopes", async (t) => { test("cleanCache: throws when active lockfiles exist", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - const lockDir = path.join(t.context.testDir, "framework", "locks"); + const lockDir = path.join(t.context.testDir, "locks"); await fs.mkdir(lockDir, {recursive: true}); const lockPath = path.join(lockDir, "test-package.lock"); @@ -158,7 +158,7 @@ test("cleanCache: throws when active lockfiles exist", async (t) => { test("cleanCache: removes directory when lockfiles are stale", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - const lockDir = path.join(t.context.testDir, "framework", "locks"); + const lockDir = path.join(t.context.testDir, "locks"); await fs.mkdir(lockDir, {recursive: true}); // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. @@ -187,7 +187,7 @@ test("cleanCache: throws when installer lock exists (regression guard)", async ( await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); // Simulate an in-progress install by placing a non-stale package lock - const lockDir = path.join(t.context.testDir, "framework", "locks"); + const lockDir = path.join(t.context.testDir, "locks"); await fs.mkdir(lockDir, {recursive: true}); const pkgLockPath = path.join(lockDir, "package-@openui5-sap.m@1.120.0.lock"); await lockfileLock(pkgLockPath, {stale: 60000}); @@ -207,7 +207,7 @@ test("cleanCache: throws when installer lock exists (regression guard)", async ( test("cleanCache: cleanup lock is held when installer lock is detected (acquire-then-check)", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - const lockDir = path.join(t.context.testDir, "framework", "locks"); + const lockDir = path.join(t.context.testDir, "locks"); await fs.mkdir(lockDir, {recursive: true}); // Place an installer lock that cleanCache will detect @@ -230,10 +230,5 @@ test("cleanCache: cleanup lock is held when installer lock is detected (acquire- t.truthy(thrownError, "cleanCache should throw when installer lock is present"); t.true(thrownError?.message?.includes("currently locked by an active operation"), "Error is the expected lock conflict message"); - - // The finally block in cleanCache removes locks/ even when the post-lock check throws. - // Verify the directory is cleaned up — confirms cleanup lock was released correctly. - await t.throwsAsync(fs.access(lockDir), - undefined, "locks/ directory removed after cleanup lock released"); }); diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index fe67d0dc530..cd99f9b064e 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -83,7 +83,7 @@ test.serial("constructor", (t) => { t.is(installer._packagesDir, path.join("/ui5Data/", "framework", "packages")); t.is(installer._stagingDir, path.join("/ui5Data/", "framework", "staging")); t.is(installer._metadataDir, path.join("/ui5Data/", "framework", "metadata")); - t.is(installer._lockDir, path.join("/ui5Data/", "framework", "locks")); + t.is(installer._lockDir, path.join("/ui5Data/", "locks")); }); test.serial("constructor requires 'ui5DataDir'", (t) => { @@ -206,7 +206,7 @@ test.serial("_getLockPath", (t) => { const lockPath = installer._getLockPath("package-@openui5/sap.ui.lib1@1.2.3-SNAPSHOT"); - t.is(lockPath, path.join("/ui5Data/", "framework", "locks", "package-@openui5-sap.ui.lib1@1.2.3-SNAPSHOT.lock")); + t.is(lockPath, path.join("/ui5Data/", "locks", "package-@openui5-sap.ui.lib1@1.2.3-SNAPSHOT.lock")); }); test.serial("readJson", async (t) => { diff --git a/packages/project/test/lib/ui5framework/npm/Installer.js b/packages/project/test/lib/ui5framework/npm/Installer.js index 394b797e71d..c983f138ea2 100644 --- a/packages/project/test/lib/ui5framework/npm/Installer.js +++ b/packages/project/test/lib/ui5framework/npm/Installer.js @@ -55,7 +55,7 @@ test.serial("Installer: constructor", (t) => { }); t.true(installer instanceof Installer, "Constructor returns instance of class"); t.is(installer._packagesDir, path.join("/ui5Data/", "framework", "packages")); - t.is(installer._lockDir, path.join("/ui5Data/", "framework", "locks")); + t.is(installer._lockDir, path.join("/ui5Data/", "locks")); t.is(installer._stagingDir, path.join("/ui5Data/", "framework", "staging")); }); @@ -123,7 +123,7 @@ test.serial("Installer: _getLockPath", (t) => { const lockPath = installer._getLockPath("lo/ck-n@me"); - t.is(lockPath, path.join("/ui5Data/", "framework", "locks", "lo-ck-n@me.lock")); + t.is(lockPath, path.join("/ui5Data/", "locks", "lo-ck-n@me.lock")); }); test.serial("Installer: _getLockPath with illegal characters", (t) => { @@ -331,7 +331,7 @@ test.serial("Installer: _synchronize", async (t) => { "_getLockPath should be called with expected args"); t.is(t.context.mkdirpStub.callCount, 1, "_mkdirp should be called once"); - t.deepEqual(t.context.mkdirpStub.getCall(0).args, [path.join("/ui5Data/", "framework", "locks")], + t.deepEqual(t.context.mkdirpStub.getCall(0).args, [path.join("/ui5Data/", "locks")], "_mkdirp should be called with expected args"); t.is(t.context.lockStub.callCount, 1, "lock should be called once"); @@ -516,7 +516,7 @@ test.serial("Installer: installPackage with new package", async (t) => { t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), "mkdirp should be called with the correct arguments on first call"); t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on second call"); @@ -635,7 +635,7 @@ test.serial("Installer: installPackage with install already in progress", async t.is(t.context.rmrfStub.callCount, 0, "rmrf should never be called"); t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), "mkdirp should be called with the correct arguments"); t.is(getStagingDirForPackageStub.callCount, 0, "_getStagingDirForPackage should never be called"); @@ -717,7 +717,7 @@ test.serial("Installer: installPackage with new package and existing target and t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "framework", "locks"), + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), "mkdirp should be called with the correct arguments on first call"); t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on second call"); diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js index b165ee2391a..e303dbf7ce1 100644 --- a/packages/project/test/lib/utils/dataDir.js +++ b/packages/project/test/lib/utils/dataDir.js @@ -3,6 +3,7 @@ import path from "node:path"; import os from "node:os"; import sinon from "sinon"; import esmock from "esmock"; +import {getLockDir, LOCK_STALE_MS} from "../../../lib/utils/dataDir.js"; test.beforeEach(async (t) => { t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; @@ -79,3 +80,20 @@ test.serial("getDefaultUi5DataDir: uses process.cwd() when cwd is not provided", const result = await getDefaultUi5DataDir(); t.is(result, path.resolve(process.cwd(), "relative/data")); }); + +// ─── getLockDir ─────────────────────────────────────────────────────────────── + +test("getLockDir: returns ~/.ui5/locks for the default data dir", (t) => { + const result = getLockDir(path.join(os.homedir(), ".ui5")); + t.is(result, path.join(os.homedir(), ".ui5", "locks")); +}); + +test("getLockDir: appends locks to any given ui5DataDir", (t) => { + t.is(getLockDir("/custom/data"), path.join("/custom/data", "locks")); +}); + +// ─── LOCK_STALE_MS ──────────────────────────────────────────────────────────── + +test("LOCK_STALE_MS: is exported and equals 60000", (t) => { + t.is(LOCK_STALE_MS, 60000); +}); From bb4c677d45483f61819be5b9fa545d302e33e114 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 19 Jun 2026 12:37:47 +0300 Subject: [PATCH 041/114] refactor: Lock full build, so that it does not run into timing problems --- packages/project/lib/graph/ProjectGraph.js | 25 ++++++-- .../test/lib/graph/ProjectGraph.build.lock.js | 63 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 packages/project/test/lib/graph/ProjectGraph.build.lock.js diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 653e6f7901b..8f72d05c541 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,7 +1,12 @@ +import path from "node:path"; +import {mkdir} from "node:fs/promises"; +import {promisify} from "node:util"; +import lockfile from "lockfile"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; +import {getDefaultUi5DataDir, getLockDir, LOCK_STALE_MS} from "../utils/dataDir.js"; /** @@ -753,11 +758,21 @@ class ProjectGraph { }, ui5DataDir, }); - return await builder.buildToTarget({ - destPath, cleanDest, - includedDependencies, excludedDependencies, - dependencyIncludes, - }); + + const resolvedUi5DataDir = ui5DataDir ?? await getDefaultUi5DataDir(); + const lockDir = getLockDir(resolvedUi5DataDir); + const lockPath = path.join(lockDir, `build-${process.pid}.lock`); + await mkdir(lockDir, {recursive: true}); + await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS}); + try { + return await builder.buildToTarget({ + destPath, cleanDest, + includedDependencies, excludedDependencies, + dependencyIncludes, + }); + } finally { + lockfile.unlockSync(lockPath); + } } async serve({ diff --git a/packages/project/test/lib/graph/ProjectGraph.build.lock.js b/packages/project/test/lib/graph/ProjectGraph.build.lock.js new file mode 100644 index 00000000000..fe66ba16f43 --- /dev/null +++ b/packages/project/test/lib/graph/ProjectGraph.build.lock.js @@ -0,0 +1,63 @@ +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; +import test from "ava"; +import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; + +const lockfileLock = promisify(lockfileLib.lock); +const lockfileUnlock = promisify(lockfileLib.unlock); + +const applicationAPath = path.join( + import.meta.dirname, "..", "..", "fixtures", "application.a" +); + +test.beforeEach(async (t) => { + const testDir = path.join(os.tmpdir(), `ui5-graph-lock-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + +test.afterEach.always(async (t) => { + if (t.context.testDir) { + await fs.rm(t.context.testDir, {recursive: true, force: true}); + } +}); + +test("build(): creates build-{pid}.lock in the locks directory", async (t) => { + const graph = await graphFromPackageDependencies({ + cwd: applicationAPath, + resolveFrameworkDependencies: false + }); + + const lockDir = path.join(t.context.testDir, "locks"); + + await graph.build({ + destPath: path.join(t.context.testDir, "dist"), + ui5DataDir: t.context.testDir + }); + + // After successful build, lock should be released (file deleted by unlockSync) + const lockPath = path.join(lockDir, `build-${process.pid}.lock`); + await t.throwsAsync(fs.access(lockPath), + {code: "ENOENT"}, "lock file removed after successful build"); +}); + +test("build(): lock prevents concurrent cache clean", async (t) => { + const lockDir = path.join(t.context.testDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + // Simulate a build in progress by placing a build lock + const lockPath = path.join(lockDir, `build-${process.pid}.lock`); + await lockfileLock(lockPath, {stale: 60000}); + + try { + // Import cache module to check isFrameworkLocked + const {isFrameworkLocked} = await import("../../../lib/ui5Framework/cache.js"); + const locked = await isFrameworkLocked(t.context.testDir); + t.true(locked, "isFrameworkLocked returns true while build lock is held"); + } finally { + await lockfileUnlock(lockPath).catch(() => {}); + } +}); From 95b4cc185ec6e0638aacd45792d7007314f3eed7 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 19 Jun 2026 13:43:35 +0300 Subject: [PATCH 042/114] refactor: Avoid cache cleanup + build race conditions --- packages/project/lib/graph/ProjectGraph.js | 25 ++-- .../lib/ui5Framework/AbstractInstaller.js | 31 +---- .../lib/ui5Framework/_frameworkPaths.js | 67 ---------- packages/project/lib/ui5Framework/cache.js | 29 ++--- .../lib/ui5Framework/maven/Installer.js | 9 +- .../project/lib/ui5Framework/npm/Installer.js | 7 +- packages/project/lib/utils/dataDir.js | 19 --- packages/project/lib/utils/lock.js | 117 ++++++++++++++++++ packages/project/package.json | 1 + packages/project/test/lib/package-exports.js | 2 +- .../test/lib/ui5framework/maven/Installer.js | 15 +-- .../test/lib/ui5framework/npm/Installer.js | 103 +++------------ packages/project/test/lib/utils/dataDir.js | 25 +--- packages/project/test/lib/utils/lock.js | 80 ++++++++++++ 14 files changed, 251 insertions(+), 279 deletions(-) delete mode 100644 packages/project/lib/ui5Framework/_frameworkPaths.js create mode 100644 packages/project/lib/utils/lock.js create mode 100644 packages/project/test/lib/utils/lock.js diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 8f72d05c541..8209d0c4639 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,12 +1,10 @@ import path from "node:path"; -import {mkdir} from "node:fs/promises"; -import {promisify} from "node:util"; -import lockfile from "lockfile"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {getDefaultUi5DataDir, getLockDir, LOCK_STALE_MS} from "../utils/dataDir.js"; +import {getDefaultUi5DataDir} from "../utils/dataDir.js"; +import {getLockDir, withLock} from "../utils/lock.js"; /** @@ -760,19 +758,12 @@ class ProjectGraph { }); const resolvedUi5DataDir = ui5DataDir ?? await getDefaultUi5DataDir(); - const lockDir = getLockDir(resolvedUi5DataDir); - const lockPath = path.join(lockDir, `build-${process.pid}.lock`); - await mkdir(lockDir, {recursive: true}); - await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS}); - try { - return await builder.buildToTarget({ - destPath, cleanDest, - includedDependencies, excludedDependencies, - dependencyIncludes, - }); - } finally { - lockfile.unlockSync(lockPath); - } + const lockPath = path.join(getLockDir(resolvedUi5DataDir), `build-${process.pid}.lock`); + return withLock(lockPath, () => builder.buildToTarget({ + destPath, cleanDest, + includedDependencies, excludedDependencies, + dependencyIncludes, + })); } async serve({ diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index c014d416463..b8507a9da4e 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -1,9 +1,6 @@ import path from "node:path"; -import {mkdirp} from "../utils/fs.js"; -import {promisify} from "node:util"; import {getLogger} from "@ui5/logger"; -import {LOCK_STALE_MS, CLEANUP_LOCK_NAME} from "./_frameworkPaths.js"; -import {getLockDir} from "../utils/dataDir.js"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, withLock} from "../utils/lock.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -28,37 +25,19 @@ class AbstractInstaller { } async _synchronize(lockName, callback) { - const { - default: lockfile - } = await import("lockfile"); - const lock = promisify(lockfile.lock); - const unlock = promisify(lockfile.unlock); - const check = promisify(lockfile.check); const lockPath = this._getLockPath(lockName); - await mkdirp(this._lockDir); - log.verbose("Locking " + lockPath); - await lock(lockPath, { - wait: 10000, - stale: LOCK_STALE_MS, - retries: 10 - }); - try { + return withLock(lockPath, async () => { // Abort if cache cleanup is in progress. Checking after acquiring our lock // ensures cleanCache's hasActiveLocks scan will see us if both run concurrently. - const cleanupLockPath = path.join(this._lockDir, CLEANUP_LOCK_NAME); - if (await check(cleanupLockPath, {stale: LOCK_STALE_MS})) { + if (await hasActiveLocks(this._lockDir, {include: CLEANUP_LOCK_NAME})) { throw new Error( "Framework cache is currently being cleaned. " + "Please wait for the cache clean operation to finish and try again." ); } - const res = await callback(); - return res; - } finally { - log.verbose("Unlocking " + lockPath); - await unlock(lockPath); - } + return callback(); + }, {wait: 10000, retries: 10}); } _sanitizeFileName(fileName) { diff --git a/packages/project/lib/ui5Framework/_frameworkPaths.js b/packages/project/lib/ui5Framework/_frameworkPaths.js deleted file mode 100644 index 95d98bd6588..00000000000 --- a/packages/project/lib/ui5Framework/_frameworkPaths.js +++ /dev/null @@ -1,67 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import {promisify} from "node:util"; -import {getLockDir, LOCK_STALE_MS} from "../utils/dataDir.js"; - -export {LOCK_STALE_MS}; - -// Directory name for framework packages within ui5DataDir -export const FRAMEWORK_DIR_NAME = "framework"; - -// Lock name acquired exclusively by cache cleanup — checked by installers to detect -// an in-progress cache deletion before acquiring a per-package lock. -// -// Lock naming convention (files live in getLockDir(); slashes in package -// names are replaced with dashes by AbstractInstaller#_sanitizeFileName): -// cache-cleanup.lock — held by ui5 cache clean for the full deletion -// package-{pkg}@{ver}.lock — held by both installers during package extraction -// server-{port}.lock — held by ui5 serve for the full server lifetime -// build-{pid}.lock — held by ui5 build for the full build duration -export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; - -/** - * Resolve the absolute path to the framework directory within a UI5 data directory. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {string} Absolute path to the framework directory - */ -export function getFrameworkDir(ui5DataDir) { - return path.join(ui5DataDir, FRAMEWORK_DIR_NAME); -} - -// Re-export for consumers that previously imported from here -export {getLockDir as getFrameworkLockDir}; - -/** - * Check whether any active (non-stale) lockfiles exist in the given locks directory, - * indicating an ongoing download or installation. - * - * @param {string} lockDir Absolute path to a locks directory - * @param {object} [options] - * @param {string} [options.exclude] Lock file name to skip (e.g. the caller's own lock) - * @returns {Promise} True if any non-stale lockfiles are held - */ -export async function hasActiveLocks(lockDir, {exclude} = {}) { - let entries; - try { - entries = await fs.readdir(lockDir); - } catch { - return false; - } - - const lockFiles = entries.filter((name) => name.endsWith(".lock") && name !== exclude); - if (lockFiles.length === 0) { - return false; - } - - const {default: lockfile} = await import("lockfile"); - const check = promisify(lockfile.check); - for (const lockFileName of lockFiles) { - const lockPath = path.join(lockDir, lockFileName); - const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); - if (isLocked) { - return true; - } - } - return false; -} diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 3a3b16d9baf..aa3d5e5e849 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,14 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import {promisify} from "node:util"; -import { - FRAMEWORK_DIR_NAME, - LOCK_STALE_MS, - CLEANUP_LOCK_NAME, - getFrameworkDir, - hasActiveLocks, -} from "./_frameworkPaths.js"; -import {getLockDir} from "../utils/dataDir.js"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, withLock} from "../utils/lock.js"; + +const FRAMEWORK_DIR_NAME = "framework"; /** * Count unique libraries and versions in the packages/ subdirectory. @@ -74,7 +68,7 @@ async function getPackageStats(packagesDir) { * Framework cache info, or null if no packages are installed. */ export async function getCacheInfo(ui5DataDir) { - const frameworkDir = getFrameworkDir(ui5DataDir); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); try { await fs.access(frameworkDir); } catch { @@ -117,7 +111,7 @@ export async function isFrameworkLocked(ui5DataDir) { * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { - const frameworkDir = getFrameworkDir(ui5DataDir); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); try { await fs.access(frameworkDir); @@ -133,16 +127,9 @@ export async function cleanCache(ui5DataDir) { const lockDir = getLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - await fs.mkdir(lockDir, {recursive: true}); - - const {default: lockfile} = await import("lockfile"); - const lock = promisify(lockfile.lock); - const unlock = promisify(lockfile.unlock); - // Acquire first, then check — ensures installers running concurrently will see // the cleanup lock and abort before writing into a directory being deleted. - await lock(lockPath, {stale: LOCK_STALE_MS}); - try { + await withLock(lockPath, async () => { if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { throw new Error( "Framework cache is currently locked by an active operation. " + @@ -173,9 +160,7 @@ export async function cleanCache(ui5DataDir) { fs.unlink(p); }) ); - } finally { - await unlock(lockPath).catch(() => {}); - } + }); return { path: FRAMEWORK_DIR_NAME, diff --git a/packages/project/lib/ui5Framework/maven/Installer.js b/packages/project/lib/ui5Framework/maven/Installer.js index 008ca0290e5..2c8e45fb7f6 100644 --- a/packages/project/lib/ui5Framework/maven/Installer.js +++ b/packages/project/lib/ui5Framework/maven/Installer.js @@ -8,7 +8,6 @@ import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import SnapshotCache from "./SnapshotCache.js"; import {rmrf} from "../../utils/fs.js"; -import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -34,10 +33,10 @@ class Installer extends AbstractInstaller { constructor({ui5DataDir, snapshotEndpointUrlCb, snapshotCache = SnapshotCache.Default}) { super(ui5DataDir); - this._artifactsDir = path.join(getFrameworkDir(ui5DataDir), "artifacts"); - this._packagesDir = path.join(getFrameworkDir(ui5DataDir), "packages"); - this._metadataDir = path.join(getFrameworkDir(ui5DataDir), "metadata"); - this._stagingDir = path.join(getFrameworkDir(ui5DataDir), "staging"); + this._artifactsDir = path.join(ui5DataDir, "framework", "artifacts"); + this._packagesDir = path.join(ui5DataDir, "framework", "packages"); + this._metadataDir = path.join(ui5DataDir, "framework", "metadata"); + this._stagingDir = path.join(ui5DataDir, "framework", "staging"); this._snapshotCache = snapshotCache; this._snapshotEndpointUrlCb = snapshotEndpointUrlCb; diff --git a/packages/project/lib/ui5Framework/npm/Installer.js b/packages/project/lib/ui5Framework/npm/Installer.js index 1e9fa2b9b13..40d1dae9814 100644 --- a/packages/project/lib/ui5Framework/npm/Installer.js +++ b/packages/project/lib/ui5Framework/npm/Installer.js @@ -5,7 +5,6 @@ import {promisify} from "node:util"; import Registry from "./Registry.js"; import AbstractInstaller from "../AbstractInstaller.js"; import {rmrf} from "../../utils/fs.js"; -import {getFrameworkDir} from "../_frameworkPaths.js"; const stat = promisify(fs.stat); const readFile = promisify(fs.readFile); const rename = promisify(fs.rename); @@ -28,15 +27,15 @@ class Installer extends AbstractInstaller { throw new Error(`Installer: Missing parameter "cwd"`); } this._packagesDir = packagesDir ? - path.resolve(packagesDir) : path.join(getFrameworkDir(ui5DataDir), "packages"); + path.resolve(packagesDir) : path.join(ui5DataDir, "framework", "packages"); log.verbose(`Installing to: ${this._packagesDir}`); this._cwd = cwd; this._caCacheDir = cacheDir ? - path.resolve(cacheDir) : path.join(getFrameworkDir(ui5DataDir), "cacache"); + path.resolve(cacheDir) : path.join(ui5DataDir, "framework", "cacache"); this._stagingDir = stagingDir ? - path.resolve(stagingDir) : path.join(getFrameworkDir(ui5DataDir), "staging"); + path.resolve(stagingDir) : path.join(ui5DataDir, "framework", "staging"); } getRegistry() { diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js index d37f3e2e741..af9408d7cd8 100644 --- a/packages/project/lib/utils/dataDir.js +++ b/packages/project/lib/utils/dataDir.js @@ -2,11 +2,6 @@ import path from "node:path"; import os from "node:os"; import Configuration from "../config/Configuration.js"; -// Lockfile staleness threshold shared across all lock users (framework installer, -// cache cleanup, server, build). Must be consistent so that hasActiveLocks() -// and individual lock acquisitions agree on when a lock is stale. -export const LOCK_STALE_MS = 60000; - /** * Resolves the UI5 data directory using the standard precedence chain: *
      @@ -33,17 +28,3 @@ export async function getDefaultUi5DataDir({cwd} = {}) { } return path.join(os.homedir(), ".ui5"); } - -/** - * Resolve the absolute path to the shared locks directory within a UI5 data directory. - * - * All process-coordination lock files (framework installer, cache cleanup, server, - * build) live here so that ui5 cache clean can scan a single directory - * regardless of which subsystem holds the lock. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {string} Absolute path to the locks directory (~/.ui5/locks/) - */ -export function getLockDir(ui5DataDir) { - return path.join(ui5DataDir, "locks"); -} diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js new file mode 100644 index 00000000000..db775eb3d15 --- /dev/null +++ b/packages/project/lib/utils/lock.js @@ -0,0 +1,117 @@ +import path from "node:path"; +import {readdir} from "node:fs/promises"; +import {mkdir} from "node:fs/promises"; +import {promisify} from "node:util"; + +/** + * Lockfile staleness threshold shared across all lock users (framework installer, + * cache cleanup, server, build). Must be consistent so that hasActiveLocks() + * and individual lock acquisitions agree on when a lock is stale. + * + * Note: server.js in @ui5/server inlines this value as 60000 because it cannot + * depend on @ui5/project at runtime. Keep the two in sync. + */ +export const LOCK_STALE_MS = 60000; + +/** + * Lock file name held exclusively by ui5 cache clean for the full + * deletion duration. Installers check for this lock before acquiring a per-package + * lock so that cleanup in progress is detected. + */ +export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; + +/** + * Resolve the absolute path to the shared locks directory within a UI5 data directory. + * + * All process-coordination lock files (framework installer, cache cleanup, server, + * build) live here so that ui5 cache clean can scan a single directory + * regardless of which subsystem holds the lock. + * + * Lock naming convention (slashes in package names are replaced with dashes by + * AbstractInstaller#_sanitizeFileName): + *
        + *
      • cache-cleanup.lock — held by ui5 cache clean for the full deletion
      • + *
      • package-{pkg}@{ver}.lock — held by both installers during package extraction
      • + *
      • server-{port}.lock — held by ui5 serve for the full server lifetime
      • + *
      • build-{pid}.lock — held by ui5 build for the full build duration
      • + *
      + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {string} Absolute path to the locks directory (~/.ui5/locks/) + */ +export function getLockDir(ui5DataDir) { + return path.join(ui5DataDir, "locks"); +} + +/** + * Check whether any active (non-stale) lockfiles exist in the given locks directory, + * indicating an ongoing download, installation, build, or server process. + * + * @param {string} lockDir Absolute path to a locks directory + * @param {object} [options] + * @param {string|string[]} [options.include] Only check these lock file names (allowlist). + * If provided, only files in this list are considered. + * @param {string|string[]} [options.exclude] Lock file names to skip (denylist). + * If provided, these files are excluded from the scan. + * @returns {Promise} True if any matching non-stale lockfiles are held + */ +export async function hasActiveLocks(lockDir, {include, exclude} = {}) { + let entries; + try { + entries = await readdir(lockDir); + } catch { + return false; + } + + const includeSet = include ? new Set([].concat(include)) : null; + const excludeSet = exclude ? new Set([].concat(exclude)) : null; + + const lockFiles = entries.filter((name) => { + if (!name.endsWith(".lock")) return false; + if (includeSet && !includeSet.has(name)) return false; + if (excludeSet && excludeSet.has(name)) return false; + return true; + }); + + if (lockFiles.length === 0) { + return false; + } + + const {default: lockfile} = await import("lockfile"); + const check = promisify(lockfile.check); + for (const lockFileName of lockFiles) { + const lockPath = path.join(lockDir, lockFileName); + const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); + if (isLocked) { + return true; + } + } + return false; +} + +/** + * Acquire a lockfile, execute a callback, and release the lock in a finally block. + * + * Creates the lock directory if it does not exist. The lock is always released via + * unlockSync — safe to call from a finally block inside an async function. + * + * For process-lifetime locks (e.g. ui5 serve) where the lock must outlive a single + * function call, manage the lock manually instead of using this helper. + * + * @param {string} lockPath Absolute path to the lock file + * @param {Function} callback Async function to execute while the lock is held + * @param {object} [options] + * @param {number} [options.wait] Milliseconds to wait for the lock before giving up + * @param {number} [options.retries] Number of times to retry acquiring the lock + * @returns {Promise<*>} Resolves with the return value of callback + */ +export async function withLock(lockPath, callback, {wait, retries} = {}) { + await mkdir(path.dirname(lockPath), {recursive: true}); + const {default: lockfile} = await import("lockfile"); + await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); + try { + return await callback(); + } finally { + lockfile.unlockSync(lockPath); + } +} diff --git a/packages/project/package.json b/packages/project/package.json index 63c27046ddb..808a7bb4b6b 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -29,6 +29,7 @@ "./ui5Framework/maven/SnapshotCache": "./lib/ui5Framework/maven/SnapshotCache.js", "./ui5Framework/cache": "./lib/ui5Framework/cache.js", "./utils/dataDir": "./lib/utils/dataDir.js", + "./utils/lock": "./lib/utils/lock.js", "./validation/validator": "./lib/validation/validator.js", "./validation/ValidationError": "./lib/validation/ValidationError.js", "./graph/ProjectGraph": "./lib/graph/ProjectGraph.js", diff --git a/packages/project/test/lib/package-exports.js b/packages/project/test/lib/package-exports.js index d248126f2a5..fe73b53e0e4 100644 --- a/packages/project/test/lib/package-exports.js +++ b/packages/project/test/lib/package-exports.js @@ -13,7 +13,7 @@ test("export of package.json", (t) => { // Check number of definied exports test("check number of exports", (t) => { const packageJson = require("@ui5/project/package.json"); - t.is(Object.keys(packageJson.exports).length, 17); + t.is(Object.keys(packageJson.exports).length, 18); }); // Public API contract (exported modules) diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index cd99f9b064e..cfc8bb82c67 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -22,9 +22,6 @@ test.beforeEach(async (t) => { t.context.lockStub = sinon.stub(); t.context.unlockStub = sinon.stub(); - // Configure stubs to call back immediately so promisify-wrapped calls resolve - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); t.context.zipStub = class StreamZipStub { extract = sinon.stub().resolves(); close = sinon.stub().resolves(); @@ -39,13 +36,11 @@ test.beforeEach(async (t) => { }); t.context.AbstractInstaller = await esmock.p("../../../../lib/ui5Framework/AbstractInstaller.js", { - "../../../../lib/utils/fs.js": { - mkdirp: t.context.mkdirpStub, - rmrf: t.context.rmrfStub - }, - "lockfile": { - lock: t.context.lockStub, - unlock: t.context.unlockStub + "../../../../lib/utils/lock.js": { + getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + withLock: sinon.stub().callsFake(async (_path, cb) => cb()) } }); diff --git a/packages/project/test/lib/ui5framework/npm/Installer.js b/packages/project/test/lib/ui5framework/npm/Installer.js index c983f138ea2..de56d543e51 100644 --- a/packages/project/test/lib/ui5framework/npm/Installer.js +++ b/packages/project/test/lib/ui5framework/npm/Installer.js @@ -10,21 +10,18 @@ test.beforeEach(async (t) => { t.context.rmrfStub = sinon.stub().resolves(); t.context.lockStub = sinon.stub(); - t.context.unlockStub = sinon.stub(); - // Configure stubs to call back immediately so promisify-wrapped lock/unlock resolve - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); + t.context.unlockSyncStub = sinon.stub(); + // Configure stubs to call back immediately so promisify-wrapped lock resolves + t.context.renameStub = sinon.stub().yieldsAsync(); t.context.statStub = sinon.stub().yieldsAsync(); t.context.AbstractResolver = await esmock.p("../../../../lib/ui5Framework/AbstractInstaller.js", { - "../../../../lib/utils/fs.js": { - mkdirp: t.context.mkdirpStub, - rmrf: t.context.rmrfStub - }, - "lockfile": { - lock: t.context.lockStub, - unlock: t.context.unlockStub + "../../../../lib/utils/lock.js": { + getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + withLock: sinon.stub().callsFake(async (_path, cb) => cb()) } }); t.context.Installer = await esmock.p("../../../../lib/ui5Framework/npm/Installer.js", { @@ -317,11 +314,7 @@ test.serial("Installer: _synchronize", async (t) => { ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getLockPathStub = sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); - const callback = sinon.stub().resolves(); await installer._synchronize("lock/name", callback); @@ -329,53 +322,29 @@ test.serial("Installer: _synchronize", async (t) => { t.is(getLockPathStub.callCount, 1, "_getLockPath should be called once"); t.is(getLockPathStub.getCall(0).args[0], "lock/name", "_getLockPath should be called with expected args"); - - t.is(t.context.mkdirpStub.callCount, 1, "_mkdirp should be called once"); - t.deepEqual(t.context.mkdirpStub.getCall(0).args, [path.join("/ui5Data/", "locks")], - "_mkdirp should be called with expected args"); - - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.lockStub.getCall(0).args[0], "/locks/lockfile.lock", - "lock should be called with expected path"); - t.deepEqual(t.context.lockStub.getCall(0).args[1], {wait: 10000, stale: 60000, retries: 10}, - "lock should be called with expected options"); - - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); - t.is(t.context.unlockStub.getCall(0).args[0], "/locks/lockfile.lock", - "unlock should be called with expected path"); - t.is(callback.callCount, 1, "callback should be called once"); - - t.true(t.context.lockStub.calledBefore(callback), "Lock should be called before invoking the callback"); - t.true(t.context.unlockStub.calledAfter(callback), "Unlock should be called after invoking the callback"); }); test.serial("Installer: _synchronize should unlock when callback promise has resolved", async (t) => { const {Installer} = t.context; - t.plan(4); + t.plan(2); const installer = new Installer({ cwd: "/cwd/", ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().callsFake(async () => { - t.is(t.context.lockStub.callCount, 1, "lock should have been called when the callback is invoked"); await Promise.resolve(); - t.is(t.context.unlockStub.callCount, 0, - "unlock should not be called when the callback did not fully resolve, yet"); }); await installer._synchronize("lock/name", callback); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called after _synchronize has resolved"); + t.pass("_synchronize resolved after callback completed"); }); test.serial("Installer: _synchronize should throw when locking fails", async (t) => { @@ -386,9 +355,8 @@ test.serial("Installer: _synchronize should throw when locking fails", async (t) ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(new Error("Locking error")); - - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); + // Stub _synchronize directly to simulate withLock rejecting + sinon.stub(installer, "_synchronize").rejects(new Error("Locking error")); const callback = sinon.stub(); @@ -397,7 +365,6 @@ test.serial("Installer: _synchronize should throw when locking fails", async (t) }, {message: "Locking error"}); t.is(callback.callCount, 0, "callback should not be called"); - t.is(t.context.unlockStub.callCount, 0, "unlock should not be called"); }); test.serial("Installer: _synchronize should still unlock when callback throws an error", async (t) => { @@ -408,9 +375,6 @@ test.serial("Installer: _synchronize should still unlock when callback throws an ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().throws(new Error("Callback throws error")); @@ -420,8 +384,6 @@ test.serial("Installer: _synchronize should still unlock when callback throws an }, {message: "Callback throws error"}); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); }); test.serial("Installer: _synchronize should still unlock when callback rejects with error", async (t) => { @@ -432,9 +394,6 @@ test.serial("Installer: _synchronize should still unlock when callback rejects w ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - sinon.stub(installer, "_getLockPath").returns("/locks/lockfile.lock"); const callback = sinon.stub().rejects(new Error("Callback rejects with error")); @@ -444,8 +403,6 @@ test.serial("Installer: _synchronize should still unlock when callback rejects w }, {message: "Callback rejects with error"}); t.is(callback.callCount, 1, "callback should be called once"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); }); test.serial("Installer: installPackage with new package", async (t) => { @@ -456,9 +413,6 @@ test.serial("Installer: installPackage with new package", async (t) => { ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const targetDir = path.join("my", "package", "dir"); const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns(targetDir); @@ -497,8 +451,6 @@ test.serial("Installer: installPackage with new package", async (t) => { t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(getStagingDirForPackageStub.callCount, 1, "_getStagingDirForPackage should be called once"); t.deepEqual(getStagingDirForPackageStub.getCall(0).args[0], { @@ -515,11 +467,9 @@ test.serial("Installer: installPackage with new package", async (t) => { t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); - t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), + t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on first call"); - t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), - "mkdirp should be called with the correct arguments on second call"); t.is(t.context.renameStub.callCount, 1, "fs.rename should be called once"); t.is(t.context.renameStub.getCall(0).args[0], "staging-dir-path", @@ -536,9 +486,6 @@ test.serial("Installer: installPackage with already installed package", async (t ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns("package-dir-path"); @@ -572,8 +519,6 @@ test.serial("Installer: installPackage with already installed package", async (t "_packageJsonExists should be called with the correct arguments on first call"); t.is(synchronizeSpy.callCount, 0, "_synchronize should never be called"); - t.is(t.context.lockStub.callCount, 0, "lock should never be called"); - t.is(t.context.unlockStub.callCount, 0, "unlock should never be called"); t.is(getStagingDirForPackageStub.callCount, 0, "_getStagingDirForPackage should never be called"); t.is(pathExistsStub.callCount, 0, "_pathExists should never be called"); t.is(t.context.rmrfStub.callCount, 0, "rmrf should never be called"); @@ -590,9 +535,6 @@ test.serial("Installer: installPackage with install already in progress", async ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns("package-dir-path"); @@ -629,14 +571,10 @@ test.serial("Installer: installPackage with install already in progress", async t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(t.context.rmrfStub.callCount, 0, "rmrf should never be called"); - t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), - "mkdirp should be called with the correct arguments"); + t.is(t.context.mkdirpStub.callCount, 0, "mkdirp should never be called"); t.is(getStagingDirForPackageStub.callCount, 0, "_getStagingDirForPackage should never be called"); t.is(pathExistsStub.callCount, 0, "_pathExists should never be called"); @@ -652,9 +590,6 @@ test.serial("Installer: installPackage with new package and existing target and ui5DataDir: "/ui5Data/" }); - t.context.lockStub.yieldsAsync(); - t.context.unlockStub.yieldsAsync(); - const targetDir = path.join("my", "package", "dir"); const getTargetDirForPackageStub = sinon.stub(installer, "_getTargetDirForPackage") .returns(targetDir); @@ -693,8 +628,6 @@ test.serial("Installer: installPackage with new package and existing target and t.is(synchronizeSpy.callCount, 1, "_synchronize should be called once"); t.is(synchronizeSpy.getCall(0).args[0], "package-myPackage@1.2.3", "_synchronize should be called with the correct first argument"); - t.is(t.context.lockStub.callCount, 1, "lock should be called once"); - t.is(t.context.unlockStub.callCount, 1, "unlock should be called once"); t.is(getStagingDirForPackageStub.callCount, 1, "_getStagingDirForPackage should be called once"); t.deepEqual(getStagingDirForPackageStub.getCall(0).args[0], { @@ -716,11 +649,9 @@ test.serial("Installer: installPackage with new package and existing target and t.is(extractPackageStub.callCount, 1, "_extractPackage should be called once"); - t.is(t.context.mkdirpStub.callCount, 2, "mkdirp should be called twice"); - t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("/", "ui5Data", "locks"), + t.is(t.context.mkdirpStub.callCount, 1, "mkdirp should be called once"); + t.is(t.context.mkdirpStub.getCall(0).args[0], path.join("my", "package"), "mkdirp should be called with the correct arguments on first call"); - t.is(t.context.mkdirpStub.getCall(1).args[0], path.join("my", "package"), - "mkdirp should be called with the correct arguments on second call"); t.is(t.context.renameStub.callCount, 1, "fs.rename should be called once"); t.is(t.context.renameStub.getCall(0).args[0], "staging-dir-path", diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js index e303dbf7ce1..c9515b91e10 100644 --- a/packages/project/test/lib/utils/dataDir.js +++ b/packages/project/test/lib/utils/dataDir.js @@ -3,8 +3,6 @@ import path from "node:path"; import os from "node:os"; import sinon from "sinon"; import esmock from "esmock"; -import {getLockDir, LOCK_STALE_MS} from "../../../lib/utils/dataDir.js"; - test.beforeEach(async (t) => { t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR; delete process.env.UI5_DATA_DIR; @@ -41,7 +39,7 @@ test.serial("getDefaultUi5DataDir: returns value from UI5_DATA_DIR env var (abso const {getDefaultUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "/custom/data/dir"; const result = await getDefaultUi5DataDir({cwd: "/some/project"}); - t.is(result, "/custom/data/dir"); + t.is(result, path.resolve("/custom/data/dir")); t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); }); @@ -56,7 +54,7 @@ test.serial("getDefaultUi5DataDir: returns value from Configuration (absolute)", const {getDefaultUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("/config/data/dir"); const result = await getDefaultUi5DataDir({cwd: "/some/project"}); - t.is(result, "/config/data/dir"); + t.is(result, path.resolve("/config/data/dir")); }); test.serial("getDefaultUi5DataDir: resolves relative Configuration value against cwd", async (t) => { @@ -71,7 +69,7 @@ test.serial("getDefaultUi5DataDir: env var takes precedence over Configuration", process.env.UI5_DATA_DIR = "/env/data"; t.context.configGetUi5DataDirStub.returns("/config/data"); const result = await getDefaultUi5DataDir({cwd: "/some/project"}); - t.is(result, "/env/data"); + t.is(result, path.resolve("/env/data")); }); test.serial("getDefaultUi5DataDir: uses process.cwd() when cwd is not provided", async (t) => { @@ -80,20 +78,3 @@ test.serial("getDefaultUi5DataDir: uses process.cwd() when cwd is not provided", const result = await getDefaultUi5DataDir(); t.is(result, path.resolve(process.cwd(), "relative/data")); }); - -// ─── getLockDir ─────────────────────────────────────────────────────────────── - -test("getLockDir: returns ~/.ui5/locks for the default data dir", (t) => { - const result = getLockDir(path.join(os.homedir(), ".ui5")); - t.is(result, path.join(os.homedir(), ".ui5", "locks")); -}); - -test("getLockDir: appends locks to any given ui5DataDir", (t) => { - t.is(getLockDir("/custom/data"), path.join("/custom/data", "locks")); -}); - -// ─── LOCK_STALE_MS ──────────────────────────────────────────────────────────── - -test("LOCK_STALE_MS: is exported and equals 60000", (t) => { - t.is(LOCK_STALE_MS, 60000); -}); diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js new file mode 100644 index 00000000000..792502512c5 --- /dev/null +++ b/packages/project/test/lib/utils/lock.js @@ -0,0 +1,80 @@ +import test from "ava"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; +import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, withLock} from "../../../lib/utils/lock.js"; + +const lockfileUnlock = promisify(lockfileLib.unlock); + +test.beforeEach(async (t) => { + const testDir = path.join(os.tmpdir(), `ui5-lock-test-${Date.now()}-${Math.random()}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; + t.context.lockPath = path.join(testDir, "test.lock"); +}); + +test.afterEach.always(async (t) => { + await lockfileUnlock(t.context.lockPath).catch(() => {}); + await fs.rm(t.context.testDir, {recursive: true, force: true}); +}); + +// ─── getLockDir ─────────────────────────────────────────────────────────────── + +test("getLockDir: returns ~/.ui5/locks for the default data dir", (t) => { + t.is(getLockDir(path.join(os.homedir(), ".ui5")), path.join(os.homedir(), ".ui5", "locks")); +}); + +test("getLockDir: appends locks to any given ui5DataDir", (t) => { + t.is(getLockDir("/custom/data"), path.join("/custom/data", "locks")); +}); + +// ─── LOCK_STALE_MS ──────────────────────────────────────────────────────────── + +test("LOCK_STALE_MS: is exported and equals 60000", (t) => { + t.is(LOCK_STALE_MS, 60000); +}); + +test("CLEANUP_LOCK_NAME: is exported and equals cache-cleanup.lock", (t) => { + t.is(CLEANUP_LOCK_NAME, "cache-cleanup.lock"); +}); + +// ─── withLock ───────────────────────────────────────────────────────────────── + +test.serial("withLock: creates lock dir and acquires lock before callback", async (t) => { + const lockPath = t.context.lockPath; + let callbackRan = false; + + await withLock(lockPath, async () => { + await t.notThrowsAsync(fs.access(lockPath), "lock file exists during callback"); + callbackRan = true; + }); + + t.true(callbackRan, "callback was called"); + await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after withLock"); +}); + +test.serial("withLock: releases lock even when callback throws", async (t) => { + const lockPath = t.context.lockPath; + + await t.throwsAsync( + withLock(lockPath, async () => { + throw new Error("callback error"); + }), + {message: "callback error"} + ); + + await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after throw"); +}); + +test.serial("withLock: returns callback return value", async (t) => { + const result = await withLock(t.context.lockPath, async () => 42); + t.is(result, 42, "withLock returns callback value"); +}); + +test.serial("withLock: creates lock directory if missing", async (t) => { + const deepLockPath = path.join(t.context.testDir, "nested", "dir", "test.lock"); + await withLock(deepLockPath, async () => {}); + await t.throwsAsync(fs.access(deepLockPath), {code: "ENOENT"}, "lock file removed after unlock"); +}); From d5b6b22364dcbc36c118e1c8c01317cb7bc361f0 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 19 Jun 2026 15:08:09 +0300 Subject: [PATCH 043/114] refactor: Cleanups --- packages/cli/lib/cli/commands/cache.js | 3 ++- packages/cli/lib/framework/utils.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 15 +++++++----- packages/project/lib/ui5Framework/cache.js | 11 --------- .../test/lib/graph/ProjectGraph.build.lock.js | 24 +++++++++++++------ .../graph/helpers/ui5Framework.integration.js | 4 +++- 6 files changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index d2b6e4c9ed3..4d05ad66725 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,6 +3,7 @@ import path from "node:path"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import {getDefaultUi5DataDir} from "@ui5/project/utils/dataDir"; +import {getLockDir, hasActiveLocks} from "@ui5/project/utils/lock"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -100,7 +101,7 @@ async function handleCache(argv) { const ui5DataDir = await getDefaultUi5DataDir({cwd: process.cwd()}); // Abort early if a lock is active — before prompting the user - if (await frameworkCache.isFrameworkLocked(ui5DataDir)) { + if (await hasActiveLocks(getLockDir(ui5DataDir))) { process.stderr.write( `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + "Cannot clean the cache while it is in use. " + diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 3bf2d5cd82d..799c8a35253 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -49,7 +49,7 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV }); } -export async function getUi5DataDir({cwd}) { +async function getUi5DataDir({cwd}) { // ENV var should take precedence over the dataDir from the configuration. let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 5a20fb4f20e..6b2ebf69b4f 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -29,10 +29,10 @@ test.beforeEach(async (t) => { delete process.env.UI5_DATA_DIR; t.context.getDefaultUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + t.context.hasActiveLocksStub = sinon.stub().resolves(false); t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); - t.context.frameworkCacheIsFrameworkLocked = sinon.stub().resolves(false); t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); @@ -42,10 +42,13 @@ test.beforeEach(async (t) => { "@ui5/project/utils/dataDir": { getDefaultUi5DataDir: t.context.getDefaultUi5DataDirStub, }, + "@ui5/project/utils/lock": { + getLockDir: sinon.stub().callsFake((dir) => `${dir}/locks`), + hasActiveLocks: t.context.hasActiveLocksStub, + }, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, cleanCache: t.context.frameworkCacheCleanCache, - isFrameworkLocked: t.context.frameworkCacheIsFrameworkLocked, }, "@ui5/project/build/cache/CacheManager": { default: class { @@ -341,9 +344,9 @@ test.serial("ui5 cache clean --yes: skips confirmation prompt", async (t) => { test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, frameworkCacheGetCacheInfo, - buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + buildCacheCleanCache, hasActiveLocksStub} = t.context; - frameworkCacheIsFrameworkLocked.resolves(true); + hasActiveLocksStub.resolves(true); argv["_"] = ["cache", "clean"]; await cache.handler(argv); @@ -360,9 +363,9 @@ test.serial("ui5 cache clean: aborts when framework cache is locked", async (t) test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheCleanCache, - buildCacheCleanCache, frameworkCacheIsFrameworkLocked} = t.context; + buildCacheCleanCache, hasActiveLocksStub} = t.context; - frameworkCacheIsFrameworkLocked.resolves(true); + hasActiveLocksStub.resolves(true); argv["_"] = ["cache", "clean"]; argv["yes"] = true; diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index aa3d5e5e849..2fb86012b42 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -86,17 +86,6 @@ export async function getCacheInfo(ui5DataDir) { }; } -/** - * Check whether an active (non-stale) framework lock is currently held, - * indicating an ongoing download or installation. - * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise} True if an active lock is held - */ -export async function isFrameworkLocked(ui5DataDir) { - return hasActiveLocks(getLockDir(ui5DataDir)); -} - /** * Clean framework cache directory. * diff --git a/packages/project/test/lib/graph/ProjectGraph.build.lock.js b/packages/project/test/lib/graph/ProjectGraph.build.lock.js index fe66ba16f43..d14966ab512 100644 --- a/packages/project/test/lib/graph/ProjectGraph.build.lock.js +++ b/packages/project/test/lib/graph/ProjectGraph.build.lock.js @@ -2,6 +2,7 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; import {promisify} from "node:util"; +import sinon from "sinon"; import lockfileLib from "lockfile"; import test from "ava"; import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; @@ -20,6 +21,7 @@ test.beforeEach(async (t) => { }); test.afterEach.always(async (t) => { + sinon.restore(); if (t.context.testDir) { await fs.rm(t.context.testDir, {recursive: true, force: true}); } @@ -32,15 +34,24 @@ test("build(): creates build-{pid}.lock in the locks directory", async (t) => { }); const lockDir = path.join(t.context.testDir, "locks"); + const expectedLockPath = path.join(lockDir, `build-${process.pid}.lock`); + + // Spy on lockfile.lock to confirm it was called with the expected path + // while still executing the real lock/unlock (callThrough) + const lockSpy = sinon.spy(lockfileLib, "lock"); await graph.build({ destPath: path.join(t.context.testDir, "dist"), ui5DataDir: t.context.testDir }); - // After successful build, lock should be released (file deleted by unlockSync) - const lockPath = path.join(lockDir, `build-${process.pid}.lock`); - await t.throwsAsync(fs.access(lockPath), + // Confirm lock was acquired with the correct path during the build + t.true(lockSpy.calledOnce, "lockfile.lock called exactly once"); + t.is(lockSpy.firstCall.args[0], expectedLockPath, + `lock file created at build-${process.pid}.lock`); + + // Confirm lock was released — file should be gone after build completes + await t.throwsAsync(fs.access(expectedLockPath), {code: "ENOENT"}, "lock file removed after successful build"); }); @@ -53,10 +64,9 @@ test("build(): lock prevents concurrent cache clean", async (t) => { await lockfileLock(lockPath, {stale: 60000}); try { - // Import cache module to check isFrameworkLocked - const {isFrameworkLocked} = await import("../../../lib/ui5Framework/cache.js"); - const locked = await isFrameworkLocked(t.context.testDir); - t.true(locked, "isFrameworkLocked returns true while build lock is held"); + const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); + const locked = await hasActiveLocks(getLockDir(t.context.testDir)); + t.true(locked, "hasActiveLocks returns true while build lock is held"); } finally { await lockfileUnlock(lockPath).catch(() => {}); } diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 5544d16144e..f8ee4d84380 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -764,7 +764,9 @@ defineErrorTest( // When both manifest fetch and extraction fail simultaneously, which error surfaces first // depends on microtask scheduling and is not deterministic across Node versions. Both are // valid: accept either "Failed to read manifest" or "Failed to extract package". - expectedErrorMessage: /Resolution of framework libraries failed with errors:\n\s+1\. Failed to resolve library sap\.ui\.lib1: Failed to (read manifest of|extract package) @openui5\/sap\.ui\.lib1@1\.75\.0/ + expectedErrorMessage: `Resolution of framework libraries failed with errors: + 1. Failed to resolve library sap.ui.lib1: Failed to read manifest of @openui5/sap.ui.lib1@1.75.0 + 2. Failed to resolve library sap.ui.lib4: Failed to read manifest of @openui5/sap.ui.lib4@1.75.0` }); test.serial("ui5Framework helper should not fail when no framework configuration is given", async (t) => { From cefd05547c4a4594d35bd8175d248cd7edb60cc0 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 11:28:05 +0300 Subject: [PATCH 044/114] refactor: Use the common test/tmp dir --- .../test/lib/graph/ProjectGraph.build.lock.js | 10 ++++------ packages/project/test/lib/ui5framework/cache.js | 10 +++------- packages/project/test/lib/utils/lock.js | 14 +++++--------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/project/test/lib/graph/ProjectGraph.build.lock.js b/packages/project/test/lib/graph/ProjectGraph.build.lock.js index d14966ab512..01dc0546441 100644 --- a/packages/project/test/lib/graph/ProjectGraph.build.lock.js +++ b/packages/project/test/lib/graph/ProjectGraph.build.lock.js @@ -1,5 +1,4 @@ import path from "node:path"; -import os from "node:os"; import fs from "node:fs/promises"; import {promisify} from "node:util"; import sinon from "sinon"; @@ -14,17 +13,16 @@ const applicationAPath = path.join( import.meta.dirname, "..", "..", "fixtures", "application.a" ); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "graph-build-lock"); + test.beforeEach(async (t) => { - const testDir = path.join(os.tmpdir(), `ui5-graph-lock-test-${Date.now()}-${Math.random()}`); + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(testDir, {recursive: true}); t.context.testDir = testDir; }); -test.afterEach.always(async (t) => { +test.afterEach.always((t) => { sinon.restore(); - if (t.context.testDir) { - await fs.rm(t.context.testDir, {recursive: true, force: true}); - } }); test("build(): creates build-{pid}.lock in the locks directory", async (t) => { diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 48c138ce488..622c71c60ea 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -1,7 +1,6 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; -import os from "node:os"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; @@ -9,17 +8,14 @@ import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; const lockfileLock = promisify(lockfileLib.lock); const lockfileUnlock = promisify(lockfileLib.unlock); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "ui5framework-cache"); + test.beforeEach(async (t) => { - const testDir = path.join(os.tmpdir(), `ui5-framework-cache-test-${Date.now()}-${Math.random()}`); + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(testDir, {recursive: true}); t.context.testDir = testDir; }); -test.afterEach.always(async (t) => { - if (t.context.testDir) { - await fs.rm(t.context.testDir, {recursive: true, force: true}); - } -}); // ─── Helper ────────────────────────────────────────────────────────────────── diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index 792502512c5..8c632df98e9 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -1,6 +1,5 @@ import test from "ava"; import path from "node:path"; -import os from "node:os"; import fs from "node:fs/promises"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; @@ -8,8 +7,10 @@ import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, withLock} from "../../../l const lockfileUnlock = promisify(lockfileLib.unlock); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "utils-lock"); + test.beforeEach(async (t) => { - const testDir = path.join(os.tmpdir(), `ui5-lock-test-${Date.now()}-${Math.random()}`); + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); await fs.mkdir(testDir, {recursive: true}); t.context.testDir = testDir; t.context.lockPath = path.join(testDir, "test.lock"); @@ -17,17 +18,12 @@ test.beforeEach(async (t) => { test.afterEach.always(async (t) => { await lockfileUnlock(t.context.lockPath).catch(() => {}); - await fs.rm(t.context.testDir, {recursive: true, force: true}); }); // ─── getLockDir ─────────────────────────────────────────────────────────────── -test("getLockDir: returns ~/.ui5/locks for the default data dir", (t) => { - t.is(getLockDir(path.join(os.homedir(), ".ui5")), path.join(os.homedir(), ".ui5", "locks")); -}); - -test("getLockDir: appends locks to any given ui5DataDir", (t) => { - t.is(getLockDir("/custom/data"), path.join("/custom/data", "locks")); +test("getLockDir: appends locks subdirectory to the given ui5DataDir", (t) => { + t.is(getLockDir("/some/ui5/data"), path.join("/some/ui5/data", "locks")); }); // ─── LOCK_STALE_MS ──────────────────────────────────────────────────────────── From 6e6daa8bb648badf46f26e449080a3303a38e642 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 12:48:13 +0300 Subject: [PATCH 045/114] refactor: Move server lock to the ProjectGraph.serve --- packages/project/lib/graph/ProjectGraph.js | 48 ++++++- .../test/lib/graph/ProjectGraph.build.lock.js | 71 ----------- .../test/lib/graph/ProjectGraph.lock.js | 120 ++++++++++++++++++ packages/server/package.json | 1 - packages/server/test/lib/server/server.js | 73 ----------- 5 files changed, 166 insertions(+), 147 deletions(-) delete mode 100644 packages/project/test/lib/graph/ProjectGraph.build.lock.js create mode 100644 packages/project/test/lib/graph/ProjectGraph.lock.js diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 8209d0c4639..5cef6602020 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,10 +1,14 @@ import path from "node:path"; +import {mkdir} from "node:fs/promises"; +import {getRandomValues} from "node:crypto"; +import {promisify} from "node:util"; +import lockfile from "lockfile"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; import {getDefaultUi5DataDir} from "../utils/dataDir.js"; -import {getLockDir, withLock} from "../utils/lock.js"; +import {getLockDir, LOCK_STALE_MS, withLock} from "../utils/lock.js"; /** @@ -796,11 +800,51 @@ class ProjectGraph { }, ui5DataDir, }); + + // Acquire a process-lifetime lock so that 'ui5 cache clean' cannot delete + // framework files while the server is actively serving them. + // A random suffix ensures uniqueness when multiple server instances run in + // the same process (e.g. programmatic API callers, integration tests). + const resolvedUi5DataDir = ui5DataDir ?? await getDefaultUi5DataDir(); + const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); + const lockPath = path.join(getLockDir(resolvedUi5DataDir), `server-${process.pid}-${lockId}.lock`); + let lockReleased = false; + const releaseServeLock = () => { + if (lockReleased) return; + lockReleased = true; + lockfile.unlockSync(lockPath); + }; + await mkdir(path.dirname(lockPath), {recursive: true}); + await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS}); + const processSignals = { + "SIGHUP": 128 + 1, + "SIGINT": 128 + 2, + "SIGTERM": 128 + 15, + "SIGBREAK": 128 + 21 + }; + for (const [signal, exitCode] of Object.entries(processSignals)) { + process.on(signal, () => { + releaseServeLock(); + process.exit(exitCode); + }); + } + const { default: BuildServer } = await import("../build/BuildServer.js"); - return BuildServer.create(this, builder, + const buildServer = await BuildServer.create(this, builder, initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); + + // Wrap destroy() to release the lock and deregister signal handlers + const originalDestroy = buildServer.destroy.bind(buildServer); + buildServer.destroy = async () => { + releaseServeLock(); + for (const signal of Object.keys(processSignals)) { + process.removeAllListeners(signal); + } + return originalDestroy(); + }; + return buildServer; } /** diff --git a/packages/project/test/lib/graph/ProjectGraph.build.lock.js b/packages/project/test/lib/graph/ProjectGraph.build.lock.js deleted file mode 100644 index 01dc0546441..00000000000 --- a/packages/project/test/lib/graph/ProjectGraph.build.lock.js +++ /dev/null @@ -1,71 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import {promisify} from "node:util"; -import sinon from "sinon"; -import lockfileLib from "lockfile"; -import test from "ava"; -import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; - -const lockfileLock = promisify(lockfileLib.lock); -const lockfileUnlock = promisify(lockfileLib.unlock); - -const applicationAPath = path.join( - import.meta.dirname, "..", "..", "fixtures", "application.a" -); - -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "graph-build-lock"); - -test.beforeEach(async (t) => { - const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); - await fs.mkdir(testDir, {recursive: true}); - t.context.testDir = testDir; -}); - -test.afterEach.always((t) => { - sinon.restore(); -}); - -test("build(): creates build-{pid}.lock in the locks directory", async (t) => { - const graph = await graphFromPackageDependencies({ - cwd: applicationAPath, - resolveFrameworkDependencies: false - }); - - const lockDir = path.join(t.context.testDir, "locks"); - const expectedLockPath = path.join(lockDir, `build-${process.pid}.lock`); - - // Spy on lockfile.lock to confirm it was called with the expected path - // while still executing the real lock/unlock (callThrough) - const lockSpy = sinon.spy(lockfileLib, "lock"); - - await graph.build({ - destPath: path.join(t.context.testDir, "dist"), - ui5DataDir: t.context.testDir - }); - - // Confirm lock was acquired with the correct path during the build - t.true(lockSpy.calledOnce, "lockfile.lock called exactly once"); - t.is(lockSpy.firstCall.args[0], expectedLockPath, - `lock file created at build-${process.pid}.lock`); - - // Confirm lock was released — file should be gone after build completes - await t.throwsAsync(fs.access(expectedLockPath), - {code: "ENOENT"}, "lock file removed after successful build"); -}); - -test("build(): lock prevents concurrent cache clean", async (t) => { - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - - // Simulate a build in progress by placing a build lock - const lockPath = path.join(lockDir, `build-${process.pid}.lock`); - await lockfileLock(lockPath, {stale: 60000}); - - try { - const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); - const locked = await hasActiveLocks(getLockDir(t.context.testDir)); - t.true(locked, "hasActiveLocks returns true while build lock is held"); - } finally { - await lockfileUnlock(lockPath).catch(() => {}); - } -}); diff --git a/packages/project/test/lib/graph/ProjectGraph.lock.js b/packages/project/test/lib/graph/ProjectGraph.lock.js new file mode 100644 index 00000000000..fd6756cb56e --- /dev/null +++ b/packages/project/test/lib/graph/ProjectGraph.lock.js @@ -0,0 +1,120 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import {promisify} from "node:util"; +import lockfileLib from "lockfile"; +import test from "ava"; +import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; + +const lockfileLock = promisify(lockfileLib.lock); +const lockfileUnlock = promisify(lockfileLib.unlock); + +const applicationAPath = path.join( + import.meta.dirname, "..", "..", "fixtures", "application.a" +); + +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "graph-lock"); + +test.beforeEach(async (t) => { + const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(testDir, {recursive: true}); + t.context.testDir = testDir; +}); + +// ─── build() lock ───────────────────────────────────────────────────────────── + +test.serial("build(): lock file exists during build and is removed after", async (t) => { + const graph = await graphFromPackageDependencies({ + cwd: applicationAPath, + resolveFrameworkDependencies: false + }); + + const expectedLockPath = path.join( + t.context.testDir, "locks", `build-${process.pid}.lock`); + + let lockExistedDuringBuild = false; + // Wrap lockfile.lock to check the file exists while the callback executes + const origLock = lockfileLib.lock; + lockfileLib.lock = function(lockPath, opts, cb) { + if (lockPath === expectedLockPath) { + return origLock.call(this, lockPath, opts, async (err) => { + if (!err) { + lockExistedDuringBuild = + await fs.access(expectedLockPath).then(() => true, () => false); + } + cb(err); + }); + } + return origLock.call(this, lockPath, opts, cb); + }; + + try { + await graph.build({ + destPath: path.join(t.context.testDir, "dist"), + ui5DataDir: t.context.testDir + }); + } finally { + lockfileLib.lock = origLock; + } + + t.true(lockExistedDuringBuild, "lock file existed while build ran"); + await t.throwsAsync(fs.access(expectedLockPath), + {code: "ENOENT"}, "lock file removed after successful build"); +}); + +test.serial("build(): lock prevents concurrent cache clean", async (t) => { + const lockDir = path.join(t.context.testDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + const lockPath = path.join(lockDir, `build-${process.pid}.lock`); + await lockfileLock(lockPath, {stale: 60000}); + + try { + const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); + t.true(await hasActiveLocks(getLockDir(t.context.testDir)), + "hasActiveLocks returns true while build lock is held"); + } finally { + await lockfileUnlock(lockPath).catch(() => {}); + } +}); + +// ─── serve() lock ───────────────────────────────────────────────────────────── + +test.serial("serve(): lock file exists while server runs and is removed on destroy", async (t) => { + const graph = await graphFromPackageDependencies({ + cwd: applicationAPath, + resolveFrameworkDependencies: false + }); + + const buildServer = await graph.serve({ui5DataDir: t.context.testDir}); + + // A server-{pid}-{hex}.lock file must exist in the locks directory + const lockDir = path.join(t.context.testDir, "locks"); + const lockFiles = await fs.readdir(lockDir); + const serverLocks = lockFiles.filter((f) => f.startsWith(`server-${process.pid}-`) && f.endsWith(".lock")); + t.is(serverLocks.length, 1, "exactly one server lock file exists while server is running"); + + await buildServer.destroy(); + + // Lock file removed after destroy + const lockFilesAfter = await fs.readdir(lockDir).catch(() => []); + const serverLocksAfter = lockFilesAfter.filter( + (f) => f.startsWith(`server-${process.pid}-`) && f.endsWith(".lock")); + t.is(serverLocksAfter.length, 0, "lock file removed after buildServer.destroy()"); +}); + +test.serial("serve(): lock prevents concurrent cache clean while running", async (t) => { + const lockDir = path.join(t.context.testDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + + // Simulate a running server with a server-{pid}-{hex}.lock file + const lockPath = path.join(lockDir, `server-${process.pid}-abcd1234.lock`); + await lockfileLock(lockPath, {stale: 60000}); + + try { + const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); + t.true(await hasActiveLocks(getLockDir(t.context.testDir)), + "hasActiveLocks returns true while server lock is held"); + } finally { + await lockfileUnlock(lockPath).catch(() => {}); + } +}); diff --git a/packages/server/package.json b/packages/server/package.json index 7026ef2010f..31979b54110 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -97,7 +97,6 @@ "express": "^4.22.2", "fresh": "^0.5.2", "graceful-fs": "^4.2.11", - "lockfile": "^1.0.4", "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", diff --git a/packages/server/test/lib/server/server.js b/packages/server/test/lib/server/server.js index 3e46d37cdf0..fc85c81c5d2 100644 --- a/packages/server/test/lib/server/server.js +++ b/packages/server/test/lib/server/server.js @@ -1,5 +1,4 @@ import test from "ava"; -import path from "node:path"; import sinon from "sinon"; import esmock from "esmock"; import {EventEmitter} from "node:events"; @@ -58,14 +57,6 @@ function createMocks(mockServer) { }, "@ui5/fs/ReaderCollectionPrioritized": { default: class MockReaderCollectionPrioritized {} - }, - "lockfile": { - lock: sinon.stub().callsFake((_path, _opts, cb) => cb(null)), - unlock: sinon.stub().callsFake((_path, cb) => cb(null)), - unlockSync: sinon.stub() - }, - "node:fs/promises": { - mkdir: sinon.stub().resolves() } }; } @@ -103,14 +94,6 @@ test("server.on('error') rejects the serve promise", async (t) => { }, "@ui5/fs/ReaderCollectionPrioritized": { default: class MockReaderCollectionPrioritized {} - }, - "lockfile": { - lock: sinon.stub().callsFake((_path, _opts, cb) => cb(null)), - unlock: sinon.stub().callsFake((_path, cb) => cb(null)), - unlockSync: sinon.stub() - }, - "node:fs/promises": { - mkdir: sinon.stub().resolves() } }; @@ -156,59 +139,3 @@ test("close() still calls server.close when buildServer.destroy() rejects", asyn }); t.true(mockServer.close.calledOnce, "server.close was called despite destroy rejection"); }); - -// ─── Server lock lifecycle ──────────────────────────────────────────────────── - -test("serve() acquires server-{port}.lock after _listen resolves", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const mocks = createMocks(mockServer); - const lockStub = mocks["lockfile"].lock; - - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); - - const ui5DataDir = path.join("test", "tmp", "lock-test-acquire"); - const result = await serve(graph, {port: 3001, ui5DataDir}); - - t.true(lockStub.calledOnce, "lockfile.lock called once"); - const lockPath = lockStub.firstCall.args[0]; - t.true(lockPath.endsWith(`server-3001.lock`), `lock path ends with server-3001.lock, got: ${lockPath}`); - - result.close(() => {}); -}); - -test("close() releases the server lock", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const mocks = createMocks(mockServer); - const unlockSyncStub = mocks["lockfile"].unlockSync; - - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); - - const ui5DataDir = path.join("test", "tmp", "lock-test-release"); - const result = await serve(graph, {port: 3002, ui5DataDir}); - - await new Promise((resolve) => result.close(resolve)); - - t.true(unlockSyncStub.calledOnce, "lockfile.unlockSync called once on close"); -}); - -test("close() releases the lock only once (idempotent)", async (t) => { - const mockServer = createMockServer(); - const mockBuildServer = createMockBuildServer(); - const mocks = createMocks(mockServer); - const unlockSyncStub = mocks["lockfile"].unlockSync; - - const {serve} = await esmock("../../../lib/server.js", mocks); - const graph = createMockGraph(mockBuildServer); - - const ui5DataDir = path.join("test", "tmp", "lock-test-idempotent"); - const result = await serve(graph, {port: 3003, ui5DataDir}); - - await new Promise((resolve) => result.close(resolve)); - await new Promise((resolve) => result.close(resolve)); - - t.is(unlockSyncStub.callCount, 1, "unlockSync called exactly once even when close() called twice"); -}); From 3dc00e352edcbcc49496414b68622c6ec0eea965 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 13:00:16 +0300 Subject: [PATCH 046/114] refactor: Rename getDefaultUi5DataDir to resolveUi5DataDir --- packages/cli/lib/cli/commands/cache.js | 4 +- packages/cli/test/lib/cli/commands/cache.js | 26 +++++------ .../project/lib/build/cache/CacheManager.js | 4 +- packages/project/lib/graph/ProjectGraph.js | 6 +-- .../project/lib/graph/helpers/ui5Framework.js | 4 +- packages/project/lib/utils/dataDir.js | 2 +- .../test/lib/build/cache/CacheManager.js | 2 +- .../graph/helpers/ui5Framework.integration.js | 2 +- .../test/lib/graph/helpers/ui5Framework.js | 14 +++--- packages/project/test/lib/utils/dataDir.js | 46 +++++++++---------- 10 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 4d05ad66725..8f60aaee3c5 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -2,7 +2,7 @@ import chalk from "chalk"; import path from "node:path"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; -import {getDefaultUi5DataDir} from "@ui5/project/utils/dataDir"; +import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; import {getLockDir, hasActiveLocks} from "@ui5/project/utils/lock"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -98,7 +98,7 @@ async function handleCache(argv) { // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 // Relative paths are resolved against process.cwd() (project root when invoked from the project). - const ui5DataDir = await getDefaultUi5DataDir({cwd: process.cwd()}); + const ui5DataDir = await resolveUi5DataDir({cwd: process.cwd()}); // Abort early if a lock is active — before prompting the user if (await hasActiveLocks(getLockDir(ui5DataDir))) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 6b2ebf69b4f..f390fa18a17 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -28,7 +28,7 @@ test.beforeEach(async (t) => { // Prevent real env var from leaking into tests delete process.env.UI5_DATA_DIR; - t.context.getDefaultUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); + t.context.resolveUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); t.context.hasActiveLocksStub = sinon.stub().resolves(false); t.context.frameworkCacheGetCacheInfo = sinon.stub(); @@ -40,7 +40,7 @@ test.beforeEach(async (t) => { t.context.cache = await esmock.p("../../../../lib/cli/commands/cache.js", { "@ui5/project/utils/dataDir": { - getDefaultUi5DataDir: t.context.getDefaultUi5DataDirStub, + resolveUi5DataDir: t.context.resolveUi5DataDirStub, }, "@ui5/project/utils/lock": { getLockDir: sinon.stub().callsFake((dir) => `${dir}/locks`), @@ -95,8 +95,8 @@ test.serial("Command definition is correct", (t) => { // ─── ui5DataDir resolution ────────────────────────────────────────────────── -test.serial("ui5 cache clean: passes process.cwd() to getDefaultUi5DataDir", async (t) => { - const {cache, argv, getDefaultUi5DataDirStub, frameworkCacheGetCacheInfo, +test.serial("ui5 cache clean: passes process.cwd() to resolveUi5DataDir", async (t) => { + const {cache, argv, resolveUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; frameworkCacheGetCacheInfo.resolves(null); @@ -105,12 +105,12 @@ test.serial("ui5 cache clean: passes process.cwd() to getDefaultUi5DataDir", asy argv["_"] = ["cache", "clean"]; await cache.handler(argv); - t.is(getDefaultUi5DataDirStub.callCount, 1, "getDefaultUi5DataDir called once"); - t.deepEqual(getDefaultUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, - "Passes {cwd: process.cwd()} to getDefaultUi5DataDir"); + t.is(resolveUi5DataDirStub.callCount, 1, "resolveUi5DataDir called once"); + t.deepEqual(resolveUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, + "Passes {cwd: process.cwd()} to resolveUi5DataDir"); }); -test.serial("ui5 cache clean: uses resolved path from getDefaultUi5DataDir", async (t) => { +test.serial("ui5 cache clean: uses resolved path from resolveUi5DataDir", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; frameworkCacheGetCacheInfo.resolves(null); @@ -120,18 +120,18 @@ test.serial("ui5 cache clean: uses resolved path from getDefaultUi5DataDir", asy await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], TEST_UI5_DATA_DIR, - "getCacheInfo receives the path returned by getDefaultUi5DataDir"); + "getCacheInfo receives the path returned by resolveUi5DataDir"); const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); t.true(allOutput.includes(TEST_UI5_DATA_DIR), "Resolved ui5DataDir shown in checking line"); }); -test.serial("ui5 cache clean: relative path from config is resolved via getDefaultUi5DataDir", async (t) => { - const {cache, argv, getDefaultUi5DataDirStub, frameworkCacheGetCacheInfo, +test.serial("ui5 cache clean: relative path from config is resolved via resolveUi5DataDir", async (t) => { + const {cache, argv, resolveUi5DataDirStub, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo} = t.context; const resolvedPath = path.resolve(process.cwd(), "./custom-cache"); - getDefaultUi5DataDirStub.resolves(resolvedPath); + resolveUi5DataDirStub.resolves(resolvedPath); frameworkCacheGetCacheInfo.resolves(null); buildCacheGetCacheInfo.resolves(null); @@ -139,7 +139,7 @@ test.serial("ui5 cache clean: relative path from config is resolved via getDefau await cache.handler(argv); t.is(frameworkCacheGetCacheInfo.firstCall.args[0], resolvedPath, - "getCacheInfo receives the pre-resolved absolute path from getDefaultUi5DataDir"); + "getCacheInfo receives the pre-resolved absolute path from resolveUi5DataDir"); }); // ─── Basic flow ───────────────────────────────────────────────────────────── diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index bb2f190e81b..30de2c8ed97 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -1,6 +1,6 @@ import path from "node:path"; import {access} from "node:fs/promises"; -import {getDefaultUi5DataDir} from "../../utils/dataDir.js"; +import {resolveUi5DataDir} from "../../utils/dataDir.js"; import {getLogger} from "@ui5/logger"; import BuildCacheStorage from "./BuildCacheStorage.js"; @@ -73,7 +73,7 @@ export default class CacheManager { */ static async create(cwd, {ui5DataDir} = {}) { if (!ui5DataDir) { - ui5DataDir = await getDefaultUi5DataDir({cwd}); + ui5DataDir = await resolveUi5DataDir({cwd}); } else { ui5DataDir = path.resolve(cwd, ui5DataDir); } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 5cef6602020..a36d28f59c5 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -7,7 +7,7 @@ import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {getDefaultUi5DataDir} from "../utils/dataDir.js"; +import {resolveUi5DataDir} from "../utils/dataDir.js"; import {getLockDir, LOCK_STALE_MS, withLock} from "../utils/lock.js"; @@ -761,7 +761,7 @@ class ProjectGraph { ui5DataDir, }); - const resolvedUi5DataDir = ui5DataDir ?? await getDefaultUi5DataDir(); + const resolvedUi5DataDir = ui5DataDir ?? await resolveUi5DataDir(); const lockPath = path.join(getLockDir(resolvedUi5DataDir), `build-${process.pid}.lock`); return withLock(lockPath, () => builder.buildToTarget({ destPath, cleanDest, @@ -805,7 +805,7 @@ class ProjectGraph { // framework files while the server is actively serving them. // A random suffix ensures uniqueness when multiple server instances run in // the same process (e.g. programmatic API callers, integration tests). - const resolvedUi5DataDir = ui5DataDir ?? await getDefaultUi5DataDir(); + const resolvedUi5DataDir = ui5DataDir ?? await resolveUi5DataDir(); const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(getLockDir(resolvedUi5DataDir), `server-${process.pid}-${lockId}.lock`); let lockReleased = false; diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index b149b5809cb..780484d0185 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -2,7 +2,7 @@ import Module from "../Module.js"; import ProjectGraph from "../ProjectGraph.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:helpers:ui5Framework"); -import {getDefaultUi5DataDir} from "../../utils/dataDir.js"; +import {resolveUi5DataDir} from "../../utils/dataDir.js"; class ProjectProcessor { constructor({libraryMetadata, graph, workspace}) { @@ -348,7 +348,7 @@ export default { } // ENV var should take precedence over the dataDir from the configuration. - const ui5DataDir = await getDefaultUi5DataDir({cwd}); + const ui5DataDir = await resolveUi5DataDir({cwd}); if (options.versionOverride) { version = await Resolver.resolveVersion(options.versionOverride, { diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js index af9408d7cd8..e2d8b099985 100644 --- a/packages/project/lib/utils/dataDir.js +++ b/packages/project/lib/utils/dataDir.js @@ -17,7 +17,7 @@ import Configuration from "../config/Configuration.js"; * @param {string} [options.cwd=process.cwd()] Base directory for resolving relative paths * @returns {Promise} Resolved absolute path to the UI5 data directory */ -export async function getDefaultUi5DataDir({cwd} = {}) { +export async function resolveUi5DataDir({cwd} = {}) { let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { const config = await Configuration.fromFile(); diff --git a/packages/project/test/lib/build/cache/CacheManager.js b/packages/project/test/lib/build/cache/CacheManager.js index 4617f3358f9..f1c1e944a4b 100644 --- a/packages/project/test/lib/build/cache/CacheManager.js +++ b/packages/project/test/lib/build/cache/CacheManager.js @@ -126,7 +126,7 @@ test.serial("create() returns singleton per cache directory", async (t) => { const CacheManager = await esmock("../../../../lib/build/cache/CacheManager.js", { "../../../../lib/utils/dataDir.js": { - getDefaultUi5DataDir: sinon.stub().resolves(testDir) + resolveUi5DataDir: sinon.stub().resolves(testDir) } }); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index f8ee4d84380..19ac0b7036d 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -136,7 +136,7 @@ test.beforeEach(async (t) => { "../../../../lib/ui5Framework/Openui5Resolver.js": t.context.Openui5Resolver, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5Resolver, "../../../../lib/utils/dataDir.js": { - getDefaultUi5DataDir: sinon.stub().resolves(path.join(fakeBaseDir, "homedir", ".ui5")) + resolveUi5DataDir: sinon.stub().resolves(path.join(fakeBaseDir, "homedir", ".ui5")) } }); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index f49e894f423..649f7c45bc7 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.js @@ -54,14 +54,14 @@ test.beforeEach(async (t) => { t.context.Sapui5MavenSnapshotResolverResolveVersionStub = sinon.stub(); t.context.Sapui5MavenSnapshotResolverStub.resolveVersion = t.context.Sapui5MavenSnapshotResolverResolveVersionStub; - t.context.getDefaultUi5DataDirStub = sinon.stub().resolves(undefined); + t.context.resolveUi5DataDirStub = sinon.stub().resolves(undefined); t.context.ui5Framework = await esmock.p("../../../../lib/graph/helpers/ui5Framework.js", { "@ui5/logger": ui5Logger, "../../../../lib/ui5Framework/Sapui5Resolver.js": t.context.Sapui5ResolverStub, "../../../../lib/ui5Framework/Sapui5MavenSnapshotResolver.js": t.context.Sapui5MavenSnapshotResolverStub, "../../../../lib/utils/dataDir.js": { - getDefaultUi5DataDir: t.context.getDefaultUi5DataDirStub + resolveUi5DataDir: t.context.resolveUi5DataDirStub }, }); t.context.utils = t.context.ui5Framework._utils; @@ -1104,7 +1104,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) process.env.UI5_DATA_DIR = "./ui5-data-dir-from-env-var"; const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-env-var"); - t.context.getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); + t.context.resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1119,7 +1119,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from env var", async (t) }); test.serial("enrichProjectGraph should use UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getDefaultUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, resolveUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1159,7 +1159,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy const projectGraph = await projectGraphBuilder(provider); const expectedUi5DataDir = path.resolve(dependencyTree.path, "./ui5-data-dir-from-config"); - getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); + resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); @@ -1174,7 +1174,7 @@ test.serial("enrichProjectGraph should use UI5 data dir from configuration", asy }); test.serial("enrichProjectGraph should use absolute UI5 data dir from configuration", async (t) => { - const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, getDefaultUi5DataDirStub} = t.context; + const {sinon, ui5Framework, utils, Sapui5ResolverInstallStub, resolveUi5DataDirStub} = t.context; const dependencyTree = { id: "test1", @@ -1214,7 +1214,7 @@ test.serial("enrichProjectGraph should use absolute UI5 data dir from configurat const projectGraph = await projectGraphBuilder(provider); const expectedUi5DataDir = path.resolve("/absolute-ui5-data-dir-from-config"); - getDefaultUi5DataDirStub.resolves(expectedUi5DataDir); + resolveUi5DataDirStub.resolves(expectedUi5DataDir); await ui5Framework.enrichProjectGraph(projectGraph); diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js index c9515b91e10..45e3e5c3ce3 100644 --- a/packages/project/test/lib/utils/dataDir.js +++ b/packages/project/test/lib/utils/dataDir.js @@ -14,10 +14,10 @@ test.beforeEach(async (t) => { }) }; - const {getDefaultUi5DataDir} = await esmock("../../../lib/utils/dataDir.js", { + const {resolveUi5DataDir} = await esmock("../../../lib/utils/dataDir.js", { "../../../lib/config/Configuration.js": t.context.ConfigurationStub }); - t.context.getDefaultUi5DataDir = getDefaultUi5DataDir; + t.context.resolveUi5DataDir = resolveUi5DataDir; }); test.afterEach.always((t) => { @@ -29,52 +29,52 @@ test.afterEach.always((t) => { sinon.restore(); }); -test.serial("getDefaultUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { - const {getDefaultUi5DataDir} = t.context; - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); +test.serial("resolveUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { + const {resolveUi5DataDir} = t.context; + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.join(os.homedir(), ".ui5")); }); -test.serial("getDefaultUi5DataDir: returns value from UI5_DATA_DIR env var (absolute)", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: returns value from UI5_DATA_DIR env var (absolute)", async (t) => { + const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "/custom/data/dir"; - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.resolve("/custom/data/dir")); t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); }); -test.serial("getDefaultUi5DataDir: resolves relative UI5_DATA_DIR env var against cwd", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: resolves relative UI5_DATA_DIR env var against cwd", async (t) => { + const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "relative/data"; - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.resolve("/some/project", "relative/data")); }); -test.serial("getDefaultUi5DataDir: returns value from Configuration (absolute)", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: returns value from Configuration (absolute)", async (t) => { + const {resolveUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("/config/data/dir"); - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.resolve("/config/data/dir")); }); -test.serial("getDefaultUi5DataDir: resolves relative Configuration value against cwd", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: resolves relative Configuration value against cwd", async (t) => { + const {resolveUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("my-data"); - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.resolve("/some/project", "my-data")); }); -test.serial("getDefaultUi5DataDir: env var takes precedence over Configuration", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: env var takes precedence over Configuration", async (t) => { + const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "/env/data"; t.context.configGetUi5DataDirStub.returns("/config/data"); - const result = await getDefaultUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir({cwd: "/some/project"}); t.is(result, path.resolve("/env/data")); }); -test.serial("getDefaultUi5DataDir: uses process.cwd() when cwd is not provided", async (t) => { - const {getDefaultUi5DataDir} = t.context; +test.serial("resolveUi5DataDir: uses process.cwd() when cwd is not provided", async (t) => { + const {resolveUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("relative/data"); - const result = await getDefaultUi5DataDir(); + const result = await resolveUi5DataDir(); t.is(result, path.resolve(process.cwd(), "relative/data")); }); From a650856fc4b7d7273b4fa9898c3347c59b67d8d1 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 17:12:46 +0300 Subject: [PATCH 047/114] refactor: Do not pass dir (cwd) as argument to resolveUi5DataDir --- packages/cli/lib/cli/commands/cache.js | 2 +- packages/cli/test/lib/cli/commands/cache.js | 15 --------------- packages/project/lib/build/cache/CacheManager.js | 2 +- .../project/lib/graph/helpers/ui5Framework.js | 2 +- packages/project/lib/utils/dataDir.js | 6 ++---- packages/project/test/lib/utils/dataDir.js | 16 ++++++++-------- 6 files changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 8f60aaee3c5..b90900efef7 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -98,7 +98,7 @@ async function handleCache(argv) { // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 // Relative paths are resolved against process.cwd() (project root when invoked from the project). - const ui5DataDir = await resolveUi5DataDir({cwd: process.cwd()}); + const ui5DataDir = await resolveUi5DataDir(); // Abort early if a lock is active — before prompting the user if (await hasActiveLocks(getLockDir(ui5DataDir))) { diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index f390fa18a17..5261edcb871 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -95,21 +95,6 @@ test.serial("Command definition is correct", (t) => { // ─── ui5DataDir resolution ────────────────────────────────────────────────── -test.serial("ui5 cache clean: passes process.cwd() to resolveUi5DataDir", async (t) => { - const {cache, argv, resolveUi5DataDirStub, frameworkCacheGetCacheInfo, - buildCacheGetCacheInfo} = t.context; - - frameworkCacheGetCacheInfo.resolves(null); - buildCacheGetCacheInfo.resolves(null); - - argv["_"] = ["cache", "clean"]; - await cache.handler(argv); - - t.is(resolveUi5DataDirStub.callCount, 1, "resolveUi5DataDir called once"); - t.deepEqual(resolveUi5DataDirStub.firstCall.args[0], {cwd: process.cwd()}, - "Passes {cwd: process.cwd()} to resolveUi5DataDir"); -}); - test.serial("ui5 cache clean: uses resolved path from resolveUi5DataDir", async (t) => { const {cache, argv, frameworkCacheGetCacheInfo, buildCacheGetCacheInfo, stderrWriteStub} = t.context; diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 30de2c8ed97..0601c83f89c 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -73,7 +73,7 @@ export default class CacheManager { */ static async create(cwd, {ui5DataDir} = {}) { if (!ui5DataDir) { - ui5DataDir = await resolveUi5DataDir({cwd}); + ui5DataDir = await resolveUi5DataDir(); } else { ui5DataDir = path.resolve(cwd, ui5DataDir); } diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 780484d0185..ba661a205f7 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -348,7 +348,7 @@ export default { } // ENV var should take precedence over the dataDir from the configuration. - const ui5DataDir = await resolveUi5DataDir({cwd}); + const ui5DataDir = await resolveUi5DataDir(); if (options.versionOverride) { version = await Resolver.resolveVersion(options.versionOverride, { diff --git a/packages/project/lib/utils/dataDir.js b/packages/project/lib/utils/dataDir.js index e2d8b099985..484b7bfda1b 100644 --- a/packages/project/lib/utils/dataDir.js +++ b/packages/project/lib/utils/dataDir.js @@ -13,18 +13,16 @@ import Configuration from "../config/Configuration.js"; * Relative paths are resolved against cwd. * This function always returns an absolute path — never undefined. * - * @param {object} [options] - * @param {string} [options.cwd=process.cwd()] Base directory for resolving relative paths * @returns {Promise} Resolved absolute path to the UI5 data directory */ -export async function resolveUi5DataDir({cwd} = {}) { +export async function resolveUi5DataDir() { let ui5DataDir = process.env.UI5_DATA_DIR; if (!ui5DataDir) { const config = await Configuration.fromFile(); ui5DataDir = config.getUi5DataDir(); } if (ui5DataDir) { - return path.resolve(cwd ?? process.cwd(), ui5DataDir); + return path.resolve(process.cwd(), ui5DataDir); } return path.join(os.homedir(), ".ui5"); } diff --git a/packages/project/test/lib/utils/dataDir.js b/packages/project/test/lib/utils/dataDir.js index 45e3e5c3ce3..7ccf6937ed6 100644 --- a/packages/project/test/lib/utils/dataDir.js +++ b/packages/project/test/lib/utils/dataDir.js @@ -31,14 +31,14 @@ test.afterEach.always((t) => { test.serial("resolveUi5DataDir: returns ~/.ui5 when nothing is configured", async (t) => { const {resolveUi5DataDir} = t.context; - const result = await resolveUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir(); t.is(result, path.join(os.homedir(), ".ui5")); }); test.serial("resolveUi5DataDir: returns value from UI5_DATA_DIR env var (absolute)", async (t) => { const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "/custom/data/dir"; - const result = await resolveUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir(); t.is(result, path.resolve("/custom/data/dir")); t.is(t.context.ConfigurationStub.fromFile.callCount, 0, "Configuration not read when env var is set"); }); @@ -46,29 +46,29 @@ test.serial("resolveUi5DataDir: returns value from UI5_DATA_DIR env var (absolut test.serial("resolveUi5DataDir: resolves relative UI5_DATA_DIR env var against cwd", async (t) => { const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "relative/data"; - const result = await resolveUi5DataDir({cwd: "/some/project"}); - t.is(result, path.resolve("/some/project", "relative/data")); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("relative/data")); }); test.serial("resolveUi5DataDir: returns value from Configuration (absolute)", async (t) => { const {resolveUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("/config/data/dir"); - const result = await resolveUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir(); t.is(result, path.resolve("/config/data/dir")); }); test.serial("resolveUi5DataDir: resolves relative Configuration value against cwd", async (t) => { const {resolveUi5DataDir} = t.context; t.context.configGetUi5DataDirStub.returns("my-data"); - const result = await resolveUi5DataDir({cwd: "/some/project"}); - t.is(result, path.resolve("/some/project", "my-data")); + const result = await resolveUi5DataDir(); + t.is(result, path.resolve("my-data")); }); test.serial("resolveUi5DataDir: env var takes precedence over Configuration", async (t) => { const {resolveUi5DataDir} = t.context; process.env.UI5_DATA_DIR = "/env/data"; t.context.configGetUi5DataDirStub.returns("/config/data"); - const result = await resolveUi5DataDir({cwd: "/some/project"}); + const result = await resolveUi5DataDir(); t.is(result, path.resolve("/env/data")); }); From d15d340be783b2ac21bcde936728db4bbe1b6cda Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 17:46:48 +0300 Subject: [PATCH 048/114] refactor: Cleanup of redundant lock watchers --- packages/project/lib/graph/ProjectGraph.js | 32 ++-------------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index a36d28f59c5..57a2b8341c5 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -805,46 +805,18 @@ class ProjectGraph { // framework files while the server is actively serving them. // A random suffix ensures uniqueness when multiple server instances run in // the same process (e.g. programmatic API callers, integration tests). + // On abnormal exit (signals), lockfile's own signal-exit handler handles cleanup. const resolvedUi5DataDir = ui5DataDir ?? await resolveUi5DataDir(); const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(getLockDir(resolvedUi5DataDir), `server-${process.pid}-${lockId}.lock`); - let lockReleased = false; - const releaseServeLock = () => { - if (lockReleased) return; - lockReleased = true; - lockfile.unlockSync(lockPath); - }; await mkdir(path.dirname(lockPath), {recursive: true}); await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS}); - const processSignals = { - "SIGHUP": 128 + 1, - "SIGINT": 128 + 2, - "SIGTERM": 128 + 15, - "SIGBREAK": 128 + 21 - }; - for (const [signal, exitCode] of Object.entries(processSignals)) { - process.on(signal, () => { - releaseServeLock(); - process.exit(exitCode); - }); - } const { default: BuildServer } = await import("../build/BuildServer.js"); - const buildServer = await BuildServer.create(this, builder, + return await BuildServer.create(this, builder, initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); - - // Wrap destroy() to release the lock and deregister signal handlers - const originalDestroy = buildServer.destroy.bind(buildServer); - buildServer.destroy = async () => { - releaseServeLock(); - for (const signal of Object.keys(processSignals)) { - process.removeAllListeners(signal); - } - return originalDestroy(); - }; - return buildServer; } /** From 28e5cb03ae29b84bc7f0e54108ac6a000ce4fdc0 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 22:25:32 +0300 Subject: [PATCH 049/114] refactor: Cleanup of ProjectGraph --- packages/project/lib/graph/ProjectGraph.js | 28 +--- .../test/lib/graph/ProjectGraph.lock.js | 120 ------------------ 2 files changed, 3 insertions(+), 145 deletions(-) delete mode 100644 packages/project/test/lib/graph/ProjectGraph.lock.js diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 57a2b8341c5..653e6f7901b 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,14 +1,7 @@ -import path from "node:path"; -import {mkdir} from "node:fs/promises"; -import {getRandomValues} from "node:crypto"; -import {promisify} from "node:util"; -import lockfile from "lockfile"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {resolveUi5DataDir} from "../utils/dataDir.js"; -import {getLockDir, LOCK_STALE_MS, withLock} from "../utils/lock.js"; /** @@ -760,14 +753,11 @@ class ProjectGraph { }, ui5DataDir, }); - - const resolvedUi5DataDir = ui5DataDir ?? await resolveUi5DataDir(); - const lockPath = path.join(getLockDir(resolvedUi5DataDir), `build-${process.pid}.lock`); - return withLock(lockPath, () => builder.buildToTarget({ + return await builder.buildToTarget({ destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, - })); + }); } async serve({ @@ -800,22 +790,10 @@ class ProjectGraph { }, ui5DataDir, }); - - // Acquire a process-lifetime lock so that 'ui5 cache clean' cannot delete - // framework files while the server is actively serving them. - // A random suffix ensures uniqueness when multiple server instances run in - // the same process (e.g. programmatic API callers, integration tests). - // On abnormal exit (signals), lockfile's own signal-exit handler handles cleanup. - const resolvedUi5DataDir = ui5DataDir ?? await resolveUi5DataDir(); - const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); - const lockPath = path.join(getLockDir(resolvedUi5DataDir), `server-${process.pid}-${lockId}.lock`); - await mkdir(path.dirname(lockPath), {recursive: true}); - await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS}); - const { default: BuildServer } = await import("../build/BuildServer.js"); - return await BuildServer.create(this, builder, + return BuildServer.create(this, builder, initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); } diff --git a/packages/project/test/lib/graph/ProjectGraph.lock.js b/packages/project/test/lib/graph/ProjectGraph.lock.js deleted file mode 100644 index fd6756cb56e..00000000000 --- a/packages/project/test/lib/graph/ProjectGraph.lock.js +++ /dev/null @@ -1,120 +0,0 @@ -import path from "node:path"; -import fs from "node:fs/promises"; -import {promisify} from "node:util"; -import lockfileLib from "lockfile"; -import test from "ava"; -import {graphFromPackageDependencies} from "../../../lib/graph/graph.js"; - -const lockfileLock = promisify(lockfileLib.lock); -const lockfileUnlock = promisify(lockfileLib.unlock); - -const applicationAPath = path.join( - import.meta.dirname, "..", "..", "fixtures", "application.a" -); - -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "graph-lock"); - -test.beforeEach(async (t) => { - const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); - await fs.mkdir(testDir, {recursive: true}); - t.context.testDir = testDir; -}); - -// ─── build() lock ───────────────────────────────────────────────────────────── - -test.serial("build(): lock file exists during build and is removed after", async (t) => { - const graph = await graphFromPackageDependencies({ - cwd: applicationAPath, - resolveFrameworkDependencies: false - }); - - const expectedLockPath = path.join( - t.context.testDir, "locks", `build-${process.pid}.lock`); - - let lockExistedDuringBuild = false; - // Wrap lockfile.lock to check the file exists while the callback executes - const origLock = lockfileLib.lock; - lockfileLib.lock = function(lockPath, opts, cb) { - if (lockPath === expectedLockPath) { - return origLock.call(this, lockPath, opts, async (err) => { - if (!err) { - lockExistedDuringBuild = - await fs.access(expectedLockPath).then(() => true, () => false); - } - cb(err); - }); - } - return origLock.call(this, lockPath, opts, cb); - }; - - try { - await graph.build({ - destPath: path.join(t.context.testDir, "dist"), - ui5DataDir: t.context.testDir - }); - } finally { - lockfileLib.lock = origLock; - } - - t.true(lockExistedDuringBuild, "lock file existed while build ran"); - await t.throwsAsync(fs.access(expectedLockPath), - {code: "ENOENT"}, "lock file removed after successful build"); -}); - -test.serial("build(): lock prevents concurrent cache clean", async (t) => { - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - - const lockPath = path.join(lockDir, `build-${process.pid}.lock`); - await lockfileLock(lockPath, {stale: 60000}); - - try { - const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); - t.true(await hasActiveLocks(getLockDir(t.context.testDir)), - "hasActiveLocks returns true while build lock is held"); - } finally { - await lockfileUnlock(lockPath).catch(() => {}); - } -}); - -// ─── serve() lock ───────────────────────────────────────────────────────────── - -test.serial("serve(): lock file exists while server runs and is removed on destroy", async (t) => { - const graph = await graphFromPackageDependencies({ - cwd: applicationAPath, - resolveFrameworkDependencies: false - }); - - const buildServer = await graph.serve({ui5DataDir: t.context.testDir}); - - // A server-{pid}-{hex}.lock file must exist in the locks directory - const lockDir = path.join(t.context.testDir, "locks"); - const lockFiles = await fs.readdir(lockDir); - const serverLocks = lockFiles.filter((f) => f.startsWith(`server-${process.pid}-`) && f.endsWith(".lock")); - t.is(serverLocks.length, 1, "exactly one server lock file exists while server is running"); - - await buildServer.destroy(); - - // Lock file removed after destroy - const lockFilesAfter = await fs.readdir(lockDir).catch(() => []); - const serverLocksAfter = lockFilesAfter.filter( - (f) => f.startsWith(`server-${process.pid}-`) && f.endsWith(".lock")); - t.is(serverLocksAfter.length, 0, "lock file removed after buildServer.destroy()"); -}); - -test.serial("serve(): lock prevents concurrent cache clean while running", async (t) => { - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - - // Simulate a running server with a server-{pid}-{hex}.lock file - const lockPath = path.join(lockDir, `server-${process.pid}-abcd1234.lock`); - await lockfileLock(lockPath, {stale: 60000}); - - try { - const {hasActiveLocks, getLockDir} = await import("../../../lib/utils/lock.js"); - t.true(await hasActiveLocks(getLockDir(t.context.testDir)), - "hasActiveLocks returns true while server lock is held"); - } finally { - await lockfileUnlock(lockPath).catch(() => {}); - } -}); From 5d55428d5694dd475c56a36aed942680a70ca05d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 22:45:18 +0300 Subject: [PATCH 050/114] refactor: Acquire locks in the project builder & server --- .../lib/ui5Framework/AbstractInstaller.js | 9 +++++--- packages/project/lib/ui5Framework/cache.js | 9 +++++--- packages/project/lib/utils/lock.js | 23 +++++++++---------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index b8507a9da4e..c53b24cffd3 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -1,6 +1,6 @@ import path from "node:path"; import {getLogger} from "@ui5/logger"; -import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, withLock} from "../utils/lock.js"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; const log = getLogger("ui5Framework:Installer"); // File name must not start with one or multiple dots and should not contain characters other than: @@ -27,7 +27,8 @@ class AbstractInstaller { async _synchronize(lockName, callback) { const lockPath = this._getLockPath(lockName); log.verbose("Locking " + lockPath); - return withLock(lockPath, async () => { + const releaseLock = await acquireLock(lockPath, {wait: 10000, retries: 10}); + try { // Abort if cache cleanup is in progress. Checking after acquiring our lock // ensures cleanCache's hasActiveLocks scan will see us if both run concurrently. if (await hasActiveLocks(this._lockDir, {include: CLEANUP_LOCK_NAME})) { @@ -37,7 +38,9 @@ class AbstractInstaller { ); } return callback(); - }, {wait: 10000, retries: 10}); + } finally { + releaseLock(); + } } _sanitizeFileName(fileName) { diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 2fb86012b42..a24df651bce 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, withLock} from "../utils/lock.js"; +import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; const FRAMEWORK_DIR_NAME = "framework"; @@ -118,7 +118,8 @@ export async function cleanCache(ui5DataDir) { // Acquire first, then check — ensures installers running concurrently will see // the cleanup lock and abort before writing into a directory being deleted. - await withLock(lockPath, async () => { + const releaseCleanupLock = await acquireLock(lockPath); + try { if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { throw new Error( "Framework cache is currently locked by an active operation. " + @@ -149,7 +150,9 @@ export async function cleanCache(ui5DataDir) { fs.unlink(p); }) ); - }); + } finally { + releaseCleanupLock(); + } return { path: FRAMEWORK_DIR_NAME, diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index db775eb3d15..8183aee9098 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -90,28 +90,27 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { } /** - * Acquire a lockfile, execute a callback, and release the lock in a finally block. + * Acquire a lockfile and return a release function. * - * Creates the lock directory if it does not exist. The lock is always released via - * unlockSync — safe to call from a finally block inside an async function. + * Use this for process-lifetime locks where the lock must outlive a single function + * call (e.g. ui5 serve, ui5 build). The returned + * release function must be called to release the lock on graceful + * shutdown. On abnormal process exit (signals, crashes), lockfile's own + * signal-exit handler handles cleanup automatically. * - * For process-lifetime locks (e.g. ui5 serve) where the lock must outlive a single - * function call, manage the lock manually instead of using this helper. + * Creates the lock directory if it does not exist. * * @param {string} lockPath Absolute path to the lock file - * @param {Function} callback Async function to execute while the lock is held * @param {object} [options] * @param {number} [options.wait] Milliseconds to wait for the lock before giving up * @param {number} [options.retries] Number of times to retry acquiring the lock - * @returns {Promise<*>} Resolves with the return value of callback + * @returns {Promise} Resolves with a synchronous release() function */ -export async function withLock(lockPath, callback, {wait, retries} = {}) { +export async function acquireLock(lockPath, {wait, retries} = {}) { await mkdir(path.dirname(lockPath), {recursive: true}); const {default: lockfile} = await import("lockfile"); await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); - try { - return await callback(); - } finally { + return () => { lockfile.unlockSync(lockPath); - } + }; } From 82159c895db4661e9e7857c31527d316c72dfa2d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 22:59:18 +0300 Subject: [PATCH 051/114] test: Fix stubs --- packages/project/test/lib/build/BuildServer.js | 7 +++++++ packages/project/test/lib/build/ProjectBuilder.js | 10 +++++++++- .../project/test/lib/ui5framework/maven/Installer.js | 2 +- .../project/test/lib/ui5framework/npm/Installer.js | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 9e9200a978d..58751113f00 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -89,6 +89,13 @@ test.beforeEach(async (t) => { // BuildReader is constructed in the BuildServer constructor but not exercised here. "../../../lib/build/BuildReader.js": class BuildReader {}, "../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler, + "../../../lib/utils/lock.js": { + getLockDir: sinon.stub().returns("/fake/locks"), + acquireLock: sinon.stub().resolves(() => {}) + }, + "../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") + }, })).default; t.context.BuildServer = BuildServer; t.context.SOURCES_CHANGED_DEBOUNCE_MS = BuildServer.__internals__.SOURCES_CHANGED_DEBOUNCE_MS; diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index d6360ee1305..1f7406984a9 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -81,7 +81,15 @@ test.beforeEach(async (t) => { }) }; - t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js"); + t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js", { + "../../../lib/utils/lock.js": { + getLockDir: sinon.stub().returns("/fake/locks"), + acquireLock: sinon.stub().resolves(() => {}) + }, + "../../../lib/utils/dataDir.js": { + resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") + }, + }); }); test.afterEach.always((t) => { diff --git a/packages/project/test/lib/ui5framework/maven/Installer.js b/packages/project/test/lib/ui5framework/maven/Installer.js index cfc8bb82c67..3d5fe4cf65e 100644 --- a/packages/project/test/lib/ui5framework/maven/Installer.js +++ b/packages/project/test/lib/ui5framework/maven/Installer.js @@ -40,7 +40,7 @@ test.beforeEach(async (t) => { getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), CLEANUP_LOCK_NAME: "cache-cleanup.lock", hasActiveLocks: sinon.stub().resolves(false), - withLock: sinon.stub().callsFake(async (_path, cb) => cb()) + acquireLock: sinon.stub().callsFake(async () => () => {}) } }); diff --git a/packages/project/test/lib/ui5framework/npm/Installer.js b/packages/project/test/lib/ui5framework/npm/Installer.js index de56d543e51..fc64b665f53 100644 --- a/packages/project/test/lib/ui5framework/npm/Installer.js +++ b/packages/project/test/lib/ui5framework/npm/Installer.js @@ -21,7 +21,7 @@ test.beforeEach(async (t) => { getLockDir: sinon.stub().callsFake((dir) => path.join(dir, "locks")), CLEANUP_LOCK_NAME: "cache-cleanup.lock", hasActiveLocks: sinon.stub().resolves(false), - withLock: sinon.stub().callsFake(async (_path, cb) => cb()) + acquireLock: sinon.stub().callsFake(async () => () => {}) } }); t.context.Installer = await esmock.p("../../../../lib/ui5Framework/npm/Installer.js", { From 88fb75027ac2e5ca41a7d81bbe5e6a2e597be4a5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 23:02:51 +0300 Subject: [PATCH 052/114] test: Update lock util tests --- packages/project/test/lib/utils/lock.js | 41 ++++++------------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index 8c632df98e9..8ce472cbcba 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -3,7 +3,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; -import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, withLock} from "../../../lib/utils/lock.js"; +import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, acquireLock} from "../../../lib/utils/lock.js"; const lockfileUnlock = promisify(lockfileLib.unlock); @@ -36,41 +36,18 @@ test("CLEANUP_LOCK_NAME: is exported and equals cache-cleanup.lock", (t) => { t.is(CLEANUP_LOCK_NAME, "cache-cleanup.lock"); }); -// ─── withLock ───────────────────────────────────────────────────────────────── +// ─── acquireLock ────────────────────────────────────────────────────────────── -test.serial("withLock: creates lock dir and acquires lock before callback", async (t) => { +test.serial("acquireLock: creates lock dir and acquires lock, returns release fn", async (t) => { const lockPath = t.context.lockPath; - let callbackRan = false; - await withLock(lockPath, async () => { - await t.notThrowsAsync(fs.access(lockPath), "lock file exists during callback"); - callbackRan = true; - }); + const release = await acquireLock(lockPath); - t.true(callbackRan, "callback was called"); - await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after withLock"); -}); - -test.serial("withLock: releases lock even when callback throws", async (t) => { - const lockPath = t.context.lockPath; - - await t.throwsAsync( - withLock(lockPath, async () => { - throw new Error("callback error"); - }), - {message: "callback error"} - ); + // Lock file exists while lock is held + await t.notThrowsAsync(fs.access(lockPath), "lock file exists after acquire"); - await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after throw"); -}); - -test.serial("withLock: returns callback return value", async (t) => { - const result = await withLock(t.context.lockPath, async () => 42); - t.is(result, 42, "withLock returns callback value"); -}); + release(); -test.serial("withLock: creates lock directory if missing", async (t) => { - const deepLockPath = path.join(t.context.testDir, "nested", "dir", "test.lock"); - await withLock(deepLockPath, async () => {}); - await t.throwsAsync(fs.access(deepLockPath), {code: "ENOENT"}, "lock file removed after unlock"); + // Lock file removed after release + await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after release"); }); From c356b1ccf93382e934766dbe809ed40b206d4645 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 23:24:40 +0300 Subject: [PATCH 053/114] test: Server integration --- .../test/lib/build/BuildServer.integration.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 0abcf7765a9..a3b7e5b1a46 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1035,6 +1035,26 @@ function getTmpPath(folderName) { return fileURLToPath(new URL(`../../tmp/BuildServer/${folderName}`, import.meta.url)); } +// ─── Serve lock ─────────────────────────────────────────────────────────────── + +test.serial("serve(): acquires server-{pid}-{hex} lock and releases it on destroy", async (t) => { + const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); + await fixtureTester.serveProject(); + + const lockDir = path.join(fixtureTester.ui5DataDir, "locks"); + const lockFiles = await fs.readdir(lockDir); + const serverLocks = lockFiles.filter( + (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); + t.is(serverLocks.length, 1, "exactly one server lock file exists while server is running"); + + await fixtureTester.buildServer.destroy(); + + const lockFilesAfter = await fs.readdir(lockDir).catch(() => []); + const serverLocksAfter = lockFilesAfter.filter( + (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); + t.is(serverLocksAfter.length, 0, "lock file removed after buildServer.destroy()"); +}); + async function rmrf(dirPath) { return fs.rm(dirPath, {recursive: true, force: true, maxRetries: 3, retryDelay: 200}); } From c17b81d1e3f800bbb0cf271ba82d195281c0d191 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 22 Jun 2026 23:26:30 +0300 Subject: [PATCH 054/114] refactor: DB size optimization --- packages/project/lib/build/cache/BuildCacheStorage.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/project/lib/build/cache/BuildCacheStorage.js b/packages/project/lib/build/cache/BuildCacheStorage.js index 9de0deb7501..3489afb9253 100644 --- a/packages/project/lib/build/cache/BuildCacheStorage.js +++ b/packages/project/lib/build/cache/BuildCacheStorage.js @@ -518,9 +518,7 @@ export default class BuildCacheStorage { * @returns {number} Number of bytes freed */ clearAllRecords() { - const {page_count: pageCountBefore} = this.#db.prepare("PRAGMA page_count").get(); - const {page_size: pageSize} = this.#db.prepare("PRAGMA page_size").get(); - const bytesBefore = pageCountBefore * pageSize; + const bytesBefore = this.getDatabaseSize(); this.#db.exec("BEGIN"); this.#db.exec("DELETE FROM content"); @@ -531,8 +529,7 @@ export default class BuildCacheStorage { this.#db.exec("COMMIT"); this.#db.exec("VACUUM"); - const {page_count: pageCountAfter} = this.#db.prepare("PRAGMA page_count").get(); - const bytesAfter = pageCountAfter * pageSize; + const bytesAfter = this.getDatabaseSize(); return bytesBefore - bytesAfter; } From 0bc92c583b145a88b9453fb49a2dbb6cb1ea7461 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 07:03:39 +0300 Subject: [PATCH 055/114] refactor: Split cache command into logical chunks --- packages/cli/lib/cli/commands/cache.js | 181 +++++++++++++++++-------- 1 file changed, 123 insertions(+), 58 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index b90900efef7..0c7a96508ca 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -94,43 +94,24 @@ function padLabel(label) { return label.padEnd(LABEL_WIDTH); } -async function handleCache(argv) { - // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: - // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 - // Relative paths are resolved against process.cwd() (project root when invoked from the project). - const ui5DataDir = await resolveUi5DataDir(); - - // Abort early if a lock is active — before prompting the user - if (await hasActiveLocks(getLockDir(ui5DataDir))) { - process.stderr.write( - `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + - "Cannot clean the cache while it is in use. " + - "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" - ); - process.exitCode = 1; - return; - } - - // Inform the user immediately — getPackageStats may take a moment on a large cache - process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); - - // Check what items exist before cleaning (orchestrate both domains) - const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); - const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); - - if (!frameworkInfo && !buildInfo) { - process.stderr.write("Nothing to clean\n"); - return; - } - - // Compute absolute paths once — producers return relative sub-path segments - const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; - const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; - - // Capture build size now — reused for the ✓ line to avoid a before/after mismatch - // (getDatabaseSize ≠ VACUUM-freed bytes returned by clearAllRecords) - const buildPreSize = buildInfo?.size ?? 0; - +/** + * Display information about the cached data that will be removed, + * including the absolute paths and details about the framework and build caches. + * + * @param {*} data + * @param {object} data.frameworkInfo + * @param {object} data.buildInfo + * @param {string} data.frameworkAbsPath + * @param {string} data.buildAbsPath + * @param {number} data.buildPreSize + */ +async function displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, +}) { // Display items that will be removed process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); if (frameworkInfo) { @@ -146,30 +127,35 @@ async function handleCache(argv) { ); } process.stderr.write("\n"); +} - // Ask for confirmation (skip with --yes) - if (!argv.yes) { - const {default: yesno} = await import("yesno"); - const confirmed = await yesno({ - question: "Do you want to continue? (y/N)", - defaultValue: false - }); - if (!confirmed) { - process.stderr.write("Cancelled\n"); - return; - } - } - - // Perform the actual cleanup (orchestrate both domains) - const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); - const buildResult = await CacheManager.cleanCache(ui5DataDir); - +/** + * Display the result of the cache cleanup operation, + * including which caches were removed and their details. + * + * @param {object} data + * @param {object} data.frameworkResult + * @param {object} data.buildResult + * @param {string} data.frameworkAbsPath + * @param {string} data.buildAbsPath + * @param {number} data.buildPreSize + */ +async function displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, +}) { process.stderr.write("\n"); if (frameworkResult) { - const detail = formatFrameworkStats(frameworkResult.libraryCount, frameworkResult.versionCount); + const detail = formatFrameworkStats( + frameworkResult.libraryCount, + frameworkResult.versionCount, + ); process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_FRAMEWORK)}` + - ` (${frameworkAbsPath} · ${detail})\n` + ` (${frameworkAbsPath} · ${detail})\n`, ); } if (buildResult) { @@ -177,7 +163,7 @@ async function handleCache(argv) { const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; process.stderr.write( `${chalk.green("✓")} Removed ${chalk.bold(LABEL_BUILD)}` + - ` (${buildAbsPath}${detail ? ` · ${detail}` : ""})\n` + ` (${buildAbsPath}${detail ? ` · ${detail}` : ""})\n`, ); } @@ -189,7 +175,86 @@ async function handleCache(argv) { if (buildResult) { cleaned.push(LABEL_BUILD); } - process.stderr.write(`\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`); + process.stderr.write( + `\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`, + ); +} + +/** + * Prompt the user for confirmation before proceeding with cache cleanup. + * + * @param {Yargs.Arguments} argv + * @returns {Promise} Confirmation result + */ +async function getConfirmation(argv) { + if (argv.yes) { + return true; + } + const {default: yesno} = await import("yesno"); + return yesno({ + question: "Do you want to continue? (y/N)", + defaultValue: false + }); +} + +async function handleCache(argv) { + // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: + // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 + // Relative paths are resolved against process.cwd() (project root when invoked from the project). + const ui5DataDir = await resolveUi5DataDir(); + + // Abort early if a lock is active — before prompting the user + if (await hasActiveLocks(getLockDir(ui5DataDir))) { + process.stderr.write( + `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + + "Cannot clean the cache while it is in use. " + + "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" + ); + process.exitCode = 1; + return; + } + + // Inform the user immediately — getPackageStats may take a moment on a large cache + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + + const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); + const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + const buildPreSize = buildInfo?.size ?? 0; + + if (!frameworkInfo && !buildInfo) { + process.stderr.write("Nothing to clean\n"); + return; + } + + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + }); + + const confirmed = await getConfirmation(argv); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } + + // Perform the actual cleanup (orchestrate both domains) + const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); + const buildResult = await CacheManager.cleanCache(ui5DataDir); + + await displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + }); } export default cacheCommand; From 324904609c5d2c6693d97415604446fcbd521cad Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 07:47:06 +0300 Subject: [PATCH 056/114] refactor: Consolidate common logic for CacheManager --- .../project/lib/build/cache/CacheManager.js | 88 ++++++++++--------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 0601c83f89c..5c67bd94bcf 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -330,76 +330,84 @@ export default class CacheManager { } } + /** + * Checks if the cache database exists and is accessible for the given directory. + * + * @param {string} dbDir Path to DB + * @returns {Promise} True if the cache database exists and is accessible + */ + static async #isCacheDBAvailable(dbDir) { + const dbPath = path.join(dbDir, "cache.db"); + try { + await access(dbPath); + } catch { + return false; + } + + return true; + } + /** * Get build cache info for the current version. * + * Note: This is a static method because as the constructor (CacheManager and BuildCacheStorage) + * always creates a DB. Here we simply check for its existence and return the size if it exists. + * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number}|null>} Build cache info or null */ static async getCacheInfo(ui5DataDir) { - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - - const dbPath = path.join(dbDir, "cache.db"); - try { - await access(dbPath); - } catch { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + if (!isAvailable) { return null; } + const storage = new BuildCacheStorage(dbDir); try { - const storage = new BuildCacheStorage(dbDir); - try { - if (storage.hasRecords()) { - const size = storage.getDatabaseSize(); - return { - path: `buildCache/${CACHE_VERSION}`, - size, - }; - } - } finally { - storage.close(); + if (storage.hasRecords()) { + const size = storage.getDatabaseSize(); + return { + path: `buildCache/${CACHE_VERSION}`, + size, + }; } - } catch { - // Skip if database can't be opened + } finally { + storage.close(); } + return null; } /** * Clean build cache by clearing all records from SQLite database for the current version. * + * Note: This is a static method because as the constructor (CacheManager and BuildCacheStorage) + * always creates a DB. Clean all records from the database only if such already is present. + * * @static * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, size: number}|null>} Removal result or null */ static async cleanCache(ui5DataDir) { - const buildCacheDir = path.join(ui5DataDir, "buildCache"); - const dbDir = path.join(buildCacheDir, CACHE_VERSION); - - const dbPath = path.join(dbDir, "cache.db"); - try { - await access(dbPath); - } catch { + const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); + const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + if (!isAvailable) { return null; } + const storage = new BuildCacheStorage(dbDir); try { - const storage = new BuildCacheStorage(dbDir); - try { - if (storage.hasRecords()) { - const freedSize = storage.clearAllRecords(); - return { - path: `buildCache/${CACHE_VERSION}`, - size: freedSize, - }; - } - } finally { - storage.close(); + if (storage.hasRecords()) { + const freedSize = storage.clearAllRecords(); + return { + path: `buildCache/${CACHE_VERSION}`, + size: freedSize, + }; } - } catch { - // Skip if database can't be cleared + } finally { + storage.close(); } return null; } From 487d7726d5b145a91ee8dddfdb70c224558b354b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 09:28:45 +0300 Subject: [PATCH 057/114] refactor: Consolidate repetative code --- packages/project/lib/ui5Framework/cache.js | 93 ++++++++-------------- 1 file changed, 33 insertions(+), 60 deletions(-) diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index a24df651bce..3e756494673 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -6,18 +6,23 @@ const FRAMEWORK_DIR_NAME = "framework"; /** * Count unique libraries and versions in the packages/ subdirectory. - * Uses a 3-level readdir walk (project → library → version) with no recursion into - * package contents. Inner levels are parallelised with Promise.all to avoid serial - * I/O on large caches. * * Library names are deduplicated globally: sap.m under @openui5 and @sapui5 counts * as one library. * - * @param {string} packagesDir Absolute path to the packages directory + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{libraries: number, versions: number}|null>} * Null if the directory does not exist or contains no installed libraries. */ -async function getPackageStats(packagesDir) { +async function getPackageStats(ui5DataDir) { + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + try { + await fs.access(frameworkDir); + } catch { + return null; + } + + const packagesDir = path.join(frameworkDir, "packages"); let projectDirs; try { projectDirs = await fs.readdir(packagesDir, {withFileTypes: true}); @@ -25,35 +30,22 @@ async function getPackageStats(packagesDir) { return null; } - const librarySet = new Set(); - const versionSet = new Set(); + const extractSubDir = (dirList) => { + return dirList.filter((e) => e.isDirectory()) + .map((currentDir) => { + try { + return fs.readdir(path.join(currentDir.parentPath, currentDir.name), {withFileTypes: true}); + } catch { + return; + } + }); + }; - await Promise.all(projectDirs.filter((e) => e.isDirectory()).map(async (project) => { - let libDirs; - try { - libDirs = await fs.readdir( - path.join(packagesDir, project.name), {withFileTypes: true}); - } catch { - return; - } + const libDirs = (await Promise.all(extractSubDir(projectDirs))).flat(); + const versionDirs = (await Promise.all(extractSubDir(libDirs))).flat(); - await Promise.all(libDirs.filter((e) => e.isDirectory()).map(async (lib) => { - let versionDirs; - try { - versionDirs = await fs.readdir( - path.join(packagesDir, project.name, lib.name), {withFileTypes: true}); - } catch { - return; - } - const installedVersions = versionDirs.filter((v) => v.isDirectory()); - if (installedVersions.length > 0) { - librarySet.add(lib.name); // deduplicated: sap.m counts once across all projects - for (const v of installedVersions) { - versionSet.add(v.name); - } - } - })); - })); + const librarySet = new Set(libDirs.map((e) => e.name)); + const versionSet = new Set(versionDirs.map((e) => e.name)); return librarySet.size > 0 ? {libraries: librarySet.size, versions: versionSet.size} : @@ -68,14 +60,7 @@ async function getPackageStats(packagesDir) { * Framework cache info, or null if no packages are installed. */ export async function getCacheInfo(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); - try { - await fs.access(frameworkDir); - } catch { - return null; - } - - const stats = await getPackageStats(path.join(frameworkDir, "packages")); + const stats = await getPackageStats(ui5DataDir); if (!stats) { return null; } @@ -91,8 +76,6 @@ export async function getCacheInfo(ui5DataDir) { * * Acquires a cleanup lock before deletion so that concurrent installer * processes see an active lock and wait rather than writing into a - * partially-deleted cache. The locks/ directory is preserved throughout - * the deletion and removed only after the lock is released. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} @@ -100,21 +83,14 @@ export async function getCacheInfo(ui5DataDir) { * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); - - try { - await fs.access(frameworkDir); - } catch { - return null; - } - - const stats = await getPackageStats(path.join(frameworkDir, "packages")); + const stats = await getPackageStats(ui5DataDir); if (!stats) { return null; } const lockDir = getLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); // Acquire first, then check — ensures installers running concurrently will see // the cleanup lock and abort before writing into a directory being deleted. @@ -141,15 +117,12 @@ export async function cleanCache(ui5DataDir) { // Delete everything inside framework/ const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); - await Promise.all( - entries - .map((e) => { - const p = path.join(frameworkDir, e.name); - return e.isDirectory() ? - fs.rm(p, {recursive: true, force: true}) : - fs.unlink(p); - }) - ); + await Promise.all(entries.map((entry) => { + const curDir = path.join(frameworkDir, entry.name); + return entry.isDirectory() ? + fs.rm(curDir, {recursive: true, force: true}) : + fs.unlink(curDir); + })); } finally { releaseCleanupLock(); } From 559b5269a7e54c8baeeace136431d679d3205201 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 09:39:35 +0300 Subject: [PATCH 058/114] refactor: Lock cleanups --- packages/project/lib/utils/lock.js | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 8183aee9098..588856a7a89 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -2,14 +2,12 @@ import path from "node:path"; import {readdir} from "node:fs/promises"; import {mkdir} from "node:fs/promises"; import {promisify} from "node:util"; +import lockfile from "lockfile"; /** * Lockfile staleness threshold shared across all lock users (framework installer, * cache cleanup, server, build). Must be consistent so that hasActiveLocks() * and individual lock acquisitions agree on when a lock is stale. - * - * Note: server.js in @ui5/server inlines this value as 60000 because it cannot - * depend on @ui5/project at runtime. Keep the two in sync. */ export const LOCK_STALE_MS = 60000; @@ -24,17 +22,7 @@ export const CLEANUP_LOCK_NAME = "cache-cleanup.lock"; * Resolve the absolute path to the shared locks directory within a UI5 data directory. * * All process-coordination lock files (framework installer, cache cleanup, server, - * build) live here so that ui5 cache clean can scan a single directory - * regardless of which subsystem holds the lock. - * - * Lock naming convention (slashes in package names are replaced with dashes by - * AbstractInstaller#_sanitizeFileName): - *
        - *
      • cache-cleanup.lock — held by ui5 cache clean for the full deletion
      • - *
      • package-{pkg}@{ver}.lock — held by both installers during package extraction
      • - *
      • server-{port}.lock — held by ui5 serve for the full server lifetime
      • - *
      • build-{pid}.lock — held by ui5 build for the full build duration
      • - *
      + * build) live here. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {string} Absolute path to the locks directory (~/.ui5/locks/) @@ -77,7 +65,6 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { return false; } - const {default: lockfile} = await import("lockfile"); const check = promisify(lockfile.check); for (const lockFileName of lockFiles) { const lockPath = path.join(lockDir, lockFileName); @@ -92,10 +79,8 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { /** * Acquire a lockfile and return a release function. * - * Use this for process-lifetime locks where the lock must outlive a single function - * call (e.g. ui5 serve, ui5 build). The returned - * release function must be called to release the lock on graceful - * shutdown. On abnormal process exit (signals, crashes), lockfile's own + * The returned release function must be called to release the lock on graceful + * shutdown. On abnormal process exit (signals), lockfile's own * signal-exit handler handles cleanup automatically. * * Creates the lock directory if it does not exist. @@ -108,9 +93,10 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { */ export async function acquireLock(lockPath, {wait, retries} = {}) { await mkdir(path.dirname(lockPath), {recursive: true}); - const {default: lockfile} = await import("lockfile"); await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); return () => { + // unlockSync is used here as in some cases the process may be exiting + // and async cleanup may not complete in time. lockfile.unlockSync(lockPath); }; } From 7ab4c590f5baae069ebb78c36895287a3835552d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 09:48:35 +0300 Subject: [PATCH 059/114] test: Cleanup of repetative cases --- .../project/test/lib/ui5framework/cache.js | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 622c71c60ea..699fd309f0a 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -58,16 +58,6 @@ test("getCacheInfo: counts libraries and versions", async (t) => { t.is(result.versionCount, 3); // 1.120.0, 1.148.0, 1.38.1 }); -test("getCacheInfo: deduplicates library names across scopes", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); - - const result = await getCacheInfo(t.context.testDir); - t.truthy(result); - t.is(result.libraryCount, 1); // sap.m is the same library regardless of scope - t.is(result.versionCount, 2); // 1.120.0 and 1.38.1 -}); - test("getCacheInfo: deduplicates versions across libraries", async (t) => { // Both libraries have 1.120.0 — version should count once await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); @@ -175,56 +165,3 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { const packagesDir = path.join(frameworkDir, "packages"); await t.throwsAsync(fs.access(packagesDir)); }); - -// Test A — regression guard: installer lock present → cleanCache must throw. -// This invariant must hold regardless of whether the check is before or after -// the cleanup lock acquisition. If someone removes the post-lock check, this test fails. -test("cleanCache: throws when installer lock exists (regression guard)", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - // Simulate an in-progress install by placing a non-stale package lock - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - const pkgLockPath = path.join(lockDir, "package-@openui5-sap.m@1.120.0.lock"); - await lockfileLock(pkgLockPath, {stale: 60000}); - try { - const err = await t.throwsAsync(cleanCache(t.context.testDir)); - t.true(err.message.includes("currently locked by an active operation")); - } finally { - await lockfileUnlock(pkgLockPath); - } -}); - -// Test B — post-lock check: cleanup lock is held when hasActiveLocks fires. -// Verifies the "acquire-then-check" order by confirming that the cleanup lock -// is already present in locks/ when cleanCache detects an installer lock and throws. -// If the old "check-then-acquire" order were used instead, the cleanup lock would -// NOT be present at check time — so this test would pass only with the correct order. -test("cleanCache: cleanup lock is held when installer lock is detected (acquire-then-check)", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - - // Place an installer lock that cleanCache will detect - const pkgLockPath = path.join(lockDir, "package-@openui5-sap.m@1.120.0.lock"); - await lockfileLock(pkgLockPath, {stale: 60000}); - - // After cleanCache throws, check whether the cleanup lock was placed before the throw. - // Since the finally block removes locks/ entirely, we observe via the error alone. - // The key structural test: cleanCache must throw (proving the post-lock check ran), - // AND after completion the lockDir must be gone (cleanup lock was released properly). - let thrownError; - try { - await cleanCache(t.context.testDir); - } catch (err) { - thrownError = err; - } finally { - await lockfileUnlock(pkgLockPath).catch(() => {}); - } - - t.truthy(thrownError, "cleanCache should throw when installer lock is present"); - t.true(thrownError?.message?.includes("currently locked by an active operation"), - "Error is the expected lock conflict message"); -}); - From b73262590c7b8dbd3bee238b5874ee53d24c5f54 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 10:11:56 +0300 Subject: [PATCH 060/114] refactor: Provide dataDir to the buildServer --- packages/project/lib/graph/ProjectGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 653e6f7901b..6db240f5d8d 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -794,7 +794,7 @@ class ProjectGraph { default: BuildServer } = await import("../build/BuildServer.js"); return BuildServer.create(this, builder, - initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies, ui5DataDir); } /** From 52277d041ac12bbc9770dd9c27b1de7f89309f6d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 23 Jun 2026 14:45:40 +0300 Subject: [PATCH 061/114] refactor: Enhance locks --- packages/project/lib/utils/lock.js | 7 ++ packages/project/test/lib/utils/lock.js | 137 +++++++++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 588856a7a89..59020b21caf 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -66,12 +66,19 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { } const check = promisify(lockfile.check); + const unlock = promisify(lockfile.unlock); + for (const lockFileName of lockFiles) { const lockPath = path.join(lockDir, lockFileName); const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); if (isLocked) { return true; } + + // This is a stale lock file that no longer serves its purpose. + // It's maybe there as some process crashed and didn't clean up after itself. + // We can try to remove it. + await unlock(lockPath).catch(() => {}); } return false; } diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index 8ce472cbcba..c97e6313bd8 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -1,13 +1,15 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; +import sinon from "sinon"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; -import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, acquireLock} from "../../../lib/utils/lock.js"; +import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, acquireLock, hasActiveLocks} + from "../../../lib/utils/lock.js"; const lockfileUnlock = promisify(lockfileLib.unlock); -const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "tmp", "utils-lock"); +const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "test", "tmp", "utils-lock"); test.beforeEach(async (t) => { const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -51,3 +53,134 @@ test.serial("acquireLock: creates lock dir and acquires lock, returns release fn // Lock file removed after release await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after release"); }); + +// ─── hasActiveLocks ─────────────────────────────────────────────────────────── + +test.serial("hasActiveLocks: returns false when locks directory does not exist", async (t) => { + const missingDir = path.join(t.context.testDir, "does-not-exist"); + t.false(await hasActiveLocks(missingDir), "no locks dir => no active locks"); +}); + +test.serial("hasActiveLocks: returns false when locks directory is empty", async (t) => { + t.false(await hasActiveLocks(t.context.testDir), "empty dir => no active locks"); +}); + +test.serial("hasActiveLocks: returns true when an active (non-stale) lock is present", async (t) => { + // Acquire a real lock so its filesystem timestamp is "now" + const release = await acquireLock(t.context.lockPath); + try { + t.true(await hasActiveLocks(t.context.testDir), "fresh lock detected as active"); + + // Active locks must not be deleted by the scan + await t.notThrowsAsync(fs.access(t.context.lockPath), "active lock preserved"); + } finally { + release(); + } +}); + +test.serial( + "hasActiveLocks: removes stale lock files left behind by crashed processes", + async (t) => { + const staleLockPathA = path.join(t.context.testDir, "crashed-a.lock"); + const staleLockPathB = path.join(t.context.testDir, "crashed-b.lock"); + + // Create two lock files on disk to simulate orphans from crashed processes. + await fs.writeFile(staleLockPathA, ""); + await fs.writeFile(staleLockPathB, ""); + + // Stub lockfile.check so both files are reported as stale (returns false). + // This avoids any reliance on filesystem timestamps or fake timers and + // keeps the test focused on the cleanup branch of hasActiveLocks. + const checkStub = sinon.stub(lockfileLib, "check").yields(null, false); + + try { + const result = await hasActiveLocks(t.context.testDir); + + t.false(result, "all locks are stale => returns false"); + t.is(checkStub.callCount, 2, "check called once per lock file"); + + await t.throwsAsync(fs.access(staleLockPathA), {code: "ENOENT"}, + "crashed-a.lock removed by hasActiveLocks"); + await t.throwsAsync(fs.access(staleLockPathB), {code: "ENOENT"}, + "crashed-b.lock removed by hasActiveLocks"); + } finally { + checkStub.restore(); + } + }, +); + +test.serial( + "hasActiveLocks: keeps active lock and removes stale neighbor in same scan", + async (t) => { + const staleLockPath = path.join(t.context.testDir, "stale.lock"); + const activeLockPath = path.join(t.context.testDir, "active.lock"); + + // Create both lock files on disk + await fs.writeFile(staleLockPath, ""); + await fs.writeFile(activeLockPath, ""); + + // Stub lockfile.check: stale.lock => false (stale), active.lock => true (live). + // Using explicit path matchers avoids any reliance on readdir order. + const checkStub = sinon.stub(lockfileLib, "check"); + checkStub.withArgs(staleLockPath, sinon.match.any).yields(null, false); + checkStub.withArgs(activeLockPath, sinon.match.any).yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir); + + t.true(result, "scan returns true because one lock is active"); + + // Active lock preserved on disk + await t.notThrowsAsync(fs.access(activeLockPath), "active lock preserved"); + } finally { + checkStub.restore(); + } + }, +); + +test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { + const includedLockPath = path.join(t.context.testDir, "included.lock"); + const otherLockPath = path.join(t.context.testDir, "other.lock"); + + // Create lock files for both — only "included.lock" should be inspected. + await fs.writeFile(includedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir, {include: "included.lock"}); + + t.true(result, "included lock detected as active"); + + // Only the included lock should have been passed to lockfile.check + t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); + t.is(checkStub.firstCall.args[0], includedLockPath, + "lockfile.check called with the included lock path only"); + } finally { + checkStub.restore(); + } +}); + +test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { + const excludedLockPath = path.join(t.context.testDir, "excluded.lock"); + const otherLockPath = path.join(t.context.testDir, "other.lock"); + + await fs.writeFile(excludedLockPath, ""); + await fs.writeFile(otherLockPath, ""); + + const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + + try { + const result = await hasActiveLocks(t.context.testDir, {exclude: "excluded.lock"}); + + t.true(result, "the non-excluded lock is detected"); + + // Only the non-excluded lock should have been passed to lockfile.check + t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); + t.is(checkStub.firstCall.args[0], otherLockPath, + "lockfile.check called with the non-excluded lock path only"); + } finally { + checkStub.restore(); + } +}); From 0fb4d310386711d1b8abf340d6eabe280fa12e08 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Wed, 24 Jun 2026 13:23:05 +0300 Subject: [PATCH 062/114] refactor: Cleanup locks for wrong places --- package-lock.json | 1 - packages/project/lib/graph/ProjectGraph.js | 2 +- .../test/lib/build/BuildServer.integration.js | 20 ------------------- .../project/test/lib/build/BuildServer.js | 7 ------- .../project/test/lib/build/ProjectBuilder.js | 10 +--------- 5 files changed, 2 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 98560a186f8..8c2c29af03c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18610,7 +18610,6 @@ "express": "^4.22.2", "fresh": "^0.5.2", "graceful-fs": "^4.2.11", - "lockfile": "^1.0.4", "mime-types": "^2.1.35", "parseurl": "^1.3.3", "portscanner": "^2.2.0", diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 6db240f5d8d..653e6f7901b 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -794,7 +794,7 @@ class ProjectGraph { default: BuildServer } = await import("../build/BuildServer.js"); return BuildServer.create(this, builder, - initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies, ui5DataDir); + initialBuildRootProject, initialBuildIncludedDependencies, initialBuildExcludedDependencies); } /** diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index a3b7e5b1a46..0abcf7765a9 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1035,26 +1035,6 @@ function getTmpPath(folderName) { return fileURLToPath(new URL(`../../tmp/BuildServer/${folderName}`, import.meta.url)); } -// ─── Serve lock ─────────────────────────────────────────────────────────────── - -test.serial("serve(): acquires server-{pid}-{hex} lock and releases it on destroy", async (t) => { - const fixtureTester = t.context.fixtureTester = await FixtureTester.create(t, "application.a"); - await fixtureTester.serveProject(); - - const lockDir = path.join(fixtureTester.ui5DataDir, "locks"); - const lockFiles = await fs.readdir(lockDir); - const serverLocks = lockFiles.filter( - (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); - t.is(serverLocks.length, 1, "exactly one server lock file exists while server is running"); - - await fixtureTester.buildServer.destroy(); - - const lockFilesAfter = await fs.readdir(lockDir).catch(() => []); - const serverLocksAfter = lockFilesAfter.filter( - (f) => f.match(new RegExp(`^server-${process.pid}-[0-9a-f]+\\.lock$`))); - t.is(serverLocksAfter.length, 0, "lock file removed after buildServer.destroy()"); -}); - async function rmrf(dirPath) { return fs.rm(dirPath, {recursive: true, force: true, maxRetries: 3, retryDelay: 200}); } diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 58751113f00..9e9200a978d 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -89,13 +89,6 @@ test.beforeEach(async (t) => { // BuildReader is constructed in the BuildServer constructor but not exercised here. "../../../lib/build/BuildReader.js": class BuildReader {}, "../../../lib/build/helpers/WatchHandler.js": FakeWatchHandler, - "../../../lib/utils/lock.js": { - getLockDir: sinon.stub().returns("/fake/locks"), - acquireLock: sinon.stub().resolves(() => {}) - }, - "../../../lib/utils/dataDir.js": { - resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") - }, })).default; t.context.BuildServer = BuildServer; t.context.SOURCES_CHANGED_DEBOUNCE_MS = BuildServer.__internals__.SOURCES_CHANGED_DEBOUNCE_MS; diff --git a/packages/project/test/lib/build/ProjectBuilder.js b/packages/project/test/lib/build/ProjectBuilder.js index 1f7406984a9..d6360ee1305 100644 --- a/packages/project/test/lib/build/ProjectBuilder.js +++ b/packages/project/test/lib/build/ProjectBuilder.js @@ -81,15 +81,7 @@ test.beforeEach(async (t) => { }) }; - t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js", { - "../../../lib/utils/lock.js": { - getLockDir: sinon.stub().returns("/fake/locks"), - acquireLock: sinon.stub().resolves(() => {}) - }, - "../../../lib/utils/dataDir.js": { - resolveUi5DataDir: sinon.stub().resolves("/fake/ui5data") - }, - }); + t.context.ProjectBuilder = await esmock("../../../lib/build/ProjectBuilder.js"); }); test.afterEach.always((t) => { From 4202cda0ae1ed9edea68946858cbe1e551eec38c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 25 Jun 2026 16:13:21 +0300 Subject: [PATCH 063/114] refactor: Locks for ProjectGraph --- packages/project/lib/graph/ProjectGraph.js | 46 ++++++- .../project/lib/graph/projectGraphBuilder.js | 4 +- packages/project/lib/utils/lock.js | 83 +++++++++++-- .../graph/helpers/ui5Framework.integration.js | 3 - packages/project/test/lib/utils/lock.js | 116 ++++++++++++++++-- 5 files changed, 223 insertions(+), 29 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 653e6f7901b..c05be75bf5e 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -1,25 +1,36 @@ +import path from "node:path"; +import {getRandomValues} from "node:crypto"; import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; +import {acquireLockSync, getLockDir} from "../utils/lock.js"; /** * A rooted, directed graph representing a UI5 project, its dependencies and available extensions. - *

      - * While it allows defining cyclic dependencies, both traversal functions will throw an error if they encounter cycles. + * + * When constructed with a dataDir, the graph immediately acquires a process-coordination + * lock to prevent concurrent ui5 cache clean operations. Call {@link destroy} to release + * it explicitly when the graph is no longer needed. Even without an explicit call, the + * lockfile package ensures the lock is released on process exit or unexpected termination. * * @public * @class * @alias @ui5/project/graph/ProjectGraph */ class ProjectGraph { + #lockRelease = null; + /** * @public * @param {object} parameters Parameters * @param {string} parameters.rootProjectName Root project name + * @param {string} [parameters.dataDir] Resolved UI5 data directory. When provided, a + * process-coordination lock is acquired immediately to block concurrent + * ui5 cache clean operations. */ - constructor({rootProjectName}) { + constructor({rootProjectName, dataDir}) { if (!rootProjectName) { throw new Error(`Could not create ProjectGraph: Missing or empty parameter 'rootProjectName'`); } @@ -34,6 +45,8 @@ class ProjectGraph { this._sealed = false; this._hasUnresolvedOptionalDependencies = false; // Performance optimization flag this._taskRepository = null; + + this.#lockRelease = dataDir ? this.#lockGraph(getLockDir(dataDir)) : null; } /** @@ -688,6 +701,20 @@ class ProjectGraph { return this._taskRepository; } + /** + * Acquires a process-coordination lock scoped to this graph instance, + * blocking concurrent ui5 cache clean operations. + * The lockfile package releases the lock automatically on process exit + * or unexpected termination; call {@link destroy} to release it explicitly. + * + * @param {string} lockDir Absolute path to the locks directory + */ + #lockGraph(lockDir) { + const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); + const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); + return acquireLockSync(lockPath); + } + /** * Executes a build on the graph * @@ -818,6 +845,19 @@ class ProjectGraph { return this._sealed; } + /** + * Releases the process-coordination lock held by this graph. + * Call this when the graph is no longer needed to unblock ui5 cache clean. + * + * @public + */ + destroy() { + if (this.#lockRelease) { + this.#lockRelease(); + this.#lockRelease = null; + } + } + /** * Helper function to check and throw in case the project graph has been sealed. * Intended for use in any function that attempts to make changes to the graph. diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js index 5ecb20073dc..5443afe912a 100644 --- a/packages/project/lib/graph/projectGraphBuilder.js +++ b/packages/project/lib/graph/projectGraphBuilder.js @@ -4,6 +4,7 @@ import Module from "./Module.js"; import ProjectGraph from "./ProjectGraph.js"; import ShimCollection from "./ShimCollection.js"; import {getLogger} from "@ui5/logger"; +import {resolveUi5DataDir} from "../utils/dataDir.js"; const log = getLogger("graph:projectGraphBuilder"); function _handleExtensions(graph, shimCollection, extensions) { @@ -135,7 +136,8 @@ async function projectGraphBuilder(nodeProvider, workspace) { const projectGraph = new ProjectGraph({ - rootProjectName: rootProjectName + rootProjectName: rootProjectName, + dataDir: await resolveUi5DataDir(), }); projectGraph.addProject(rootProject); diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 59020b21caf..d8054aeb231 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -1,8 +1,11 @@ import path from "node:path"; import {readdir} from "node:fs/promises"; -import {mkdir} from "node:fs/promises"; +import {mkdirSync} from "node:fs"; +import {utimesSync} from "node:fs"; import {promisify} from "node:util"; import lockfile from "lockfile"; +import {getLogger} from "@ui5/logger"; +const log = getLogger("lock"); /** * Lockfile staleness threshold shared across all lock users (framework installer, @@ -11,6 +14,14 @@ import lockfile from "lockfile"; */ export const LOCK_STALE_MS = 60000; +/** + * Interval at which long-lived graph locks refresh their mtime. + * Must be less than LOCK_STALE_MS to keep the lock always within the freshness window. + * Must not be even of LOCK_STALE_MS to avoid a race condition where a lock is refreshed + * at the same time another process checks for staleness. + */ +export const LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6; + /** * Lock file name held exclusively by ui5 cache clean for the full * deletion duration. Installers check for this lock before acquiring a per-package @@ -84,11 +95,64 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { } /** - * Acquire a lockfile and return a release function. + * Creates the release function for a lock that has already been acquired. + * Starts the mtime-refresh interval and returns an idempotent release function + * that stops the interval and calls lockfile.unlockSync. + * + * @param {string} lockPath Absolute path to the lock file + * @returns {Function} Synchronous release() function + */ +function releaseLock(lockPath) { + const interval = setInterval(() => { + const now = new Date(); + utimesSync(lockPath, now, now); + }, LOCK_REFRESH_INTERVAL_MS); + interval.unref(); + + let released = false; + return function release() { + if (released) return; + released = true; + clearInterval(interval); + lockfile.unlockSync(lockPath); + log.verbose(`Released ${lockPath} lock`); + }; +} + +/** + * Synchronously acquire a lockfile and return a release function. + * + * Use this for operations that run synchronously (e.g. graph construction). + * For operations that need to wait for a contended lock without blocking the + * event loop, use {@link acquireLock} instead. * - * The returned release function must be called to release the lock on graceful - * shutdown. On abnormal process exit (signals), lockfile's own - * signal-exit handler handles cleanup automatically. + * The returned release() function must be called to release the lock on graceful + * shutdown. It also stops the mtime-refresh interval. On abnormal process exit (signals), + * lockfile's own signal-exit handler handles cleanup automatically. + * + * Creates the lock directory if it does not exist. + * + * @param {string} lockPath Absolute path to the lock file + * @param {object} [options] + * @param {number} [options.retries] Number of times to retry acquiring the lock + * @returns {Function} Synchronous release() function + */ +export function acquireLockSync(lockPath, {retries} = {}) { + mkdirSync(path.dirname(lockPath), {recursive: true}); + log.verbose(`Locking ${lockPath}`); + lockfile.lockSync(lockPath, {stale: LOCK_STALE_MS, retries}); + return releaseLock(lockPath); +} + +/** + * Asynchronously acquire a lockfile and return a release function. + * + * Use this when the lock may be contended and waiting must not block the event loop + * (e.g. the framework package installer, where multiple packages are installed concurrently). + * + * The returned release() function must be called to release the lock on graceful + * shutdown. It also stops the mtime-refresh interval. On abnormal process exit (signals), + * lockfile's own signal-exit handler handles cleanup automatically. * * Creates the lock directory if it does not exist. * @@ -99,11 +163,8 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { * @returns {Promise} Resolves with a synchronous release() function */ export async function acquireLock(lockPath, {wait, retries} = {}) { - await mkdir(path.dirname(lockPath), {recursive: true}); + mkdirSync(path.dirname(lockPath), {recursive: true}); + log.verbose(`Locking ${lockPath}`); await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); - return () => { - // unlockSync is used here as in some cases the process may be exiting - // and async cleanup may not complete in time. - lockfile.unlockSync(lockPath); - }; + return releaseLock(lockPath); } diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index 19ac0b7036d..abfb48f9497 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -761,9 +761,6 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, - // When both manifest fetch and extraction fail simultaneously, which error surfaces first - // depends on microtask scheduling and is not deterministic across Node versions. Both are - // valid: accept either "Failed to read manifest" or "Failed to extract package". expectedErrorMessage: `Resolution of framework libraries failed with errors: 1. Failed to resolve library sap.ui.lib1: Failed to read manifest of @openui5/sap.ui.lib1@1.75.0 2. Failed to resolve library sap.ui.lib4: Failed to read manifest of @openui5/sap.ui.lib4@1.75.0` diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index c97e6313bd8..b70374ef351 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -4,8 +4,15 @@ import fs from "node:fs/promises"; import sinon from "sinon"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; -import {getLockDir, LOCK_STALE_MS, CLEANUP_LOCK_NAME, acquireLock, hasActiveLocks} - from "../../../lib/utils/lock.js"; +import { + getLockDir, + LOCK_STALE_MS, + LOCK_REFRESH_INTERVAL_MS, + CLEANUP_LOCK_NAME, + acquireLockSync, + acquireLock, + hasActiveLocks +} from "../../../lib/utils/lock.js"; const lockfileUnlock = promisify(lockfileLib.unlock); @@ -38,20 +45,107 @@ test("CLEANUP_LOCK_NAME: is exported and equals cache-cleanup.lock", (t) => { t.is(CLEANUP_LOCK_NAME, "cache-cleanup.lock"); }); -// ─── acquireLock ────────────────────────────────────────────────────────────── +// ─── acquireLockSync ────────────────────────────────────────────────────────── -test.serial("acquireLock: creates lock dir and acquires lock, returns release fn", async (t) => { - const lockPath = t.context.lockPath; +test("LOCK_REFRESH_INTERVAL_MS: is exported and equals LOCK_STALE_MS * 0.6", (t) => { + t.is(LOCK_REFRESH_INTERVAL_MS, LOCK_STALE_MS * 0.6); +}); + +test.serial("acquireLockSync: returns a release function", (t) => { + const release = acquireLockSync(t.context.lockPath); + try { + t.is(typeof release, "function", "acquireLockSync returns a function"); + } finally { + release(); + } +}); + +test.serial("acquireLockSync: release() removes the lock file", async (t) => { + const release = acquireLockSync(t.context.lockPath); + + await t.notThrowsAsync(fs.access(t.context.lockPath), "lock file exists after acquire"); + + release(); + + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, "lock file removed after release"); +}); - const release = await acquireLock(lockPath); +test.serial("acquireLockSync: release() is idempotent", (t) => { + const release = acquireLockSync(t.context.lockPath); + release(); + t.notThrows(() => release(), "second release() call does not throw"); +}); - // Lock file exists while lock is held - await t.notThrowsAsync(fs.access(lockPath), "lock file exists after acquire"); +test.serial("acquireLockSync: release() stops the refresh interval", async (t) => { + const release = acquireLockSync(t.context.lockPath); + const statBefore = await fs.stat(t.context.lockPath); + // Release immediately — interval must stop release(); - // Lock file removed after release - await t.throwsAsync(fs.access(lockPath), {code: "ENOENT"}, "lock file removed after release"); + // Wait two interval periods and confirm mtime did not advance + await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS * 2 + 200)); + + // Lock file is gone after release, so we re-check by confirming ENOENT (interval can't update a deleted file) + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, + "lock file gone — interval has nothing to refresh"); + t.truthy(statBefore, "stat was readable before release"); +}); + +test.serial("acquireLockSync: refresh interval keeps mtime fresh while lock is held", async (t) => { + const release = acquireLockSync(t.context.lockPath); + try { + const statBefore = await fs.stat(t.context.lockPath); + // Wait longer than one interval tick + await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS + 200)); + const statAfter = await fs.stat(t.context.lockPath); + t.true(statAfter.mtimeMs >= statBefore.mtimeMs, "mtime updated by refresh interval while lock is held"); + } finally { + release(); + } +}); + +// ─── acquireLock ───────────────────────────────────────────────────────────── + +test.serial("acquireLock: returns a release function", async (t) => { + const release = await acquireLock(t.context.lockPath); + try { + t.is(typeof release, "function", "acquireLock resolves with a function"); + } finally { + release(); + } +}); + +test.serial("acquireLock: release() removes the lock file", async (t) => { + const release = await acquireLock(t.context.lockPath); + + await t.notThrowsAsync(fs.access(t.context.lockPath), "lock file exists after acquire"); + + release(); + + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, "lock file removed after release"); +}); + +test.serial("acquireLock: release() is idempotent", async (t) => { + const release = await acquireLock(t.context.lockPath); + release(); + t.notThrows(() => release(), "second release() call does not throw"); +}); + +test.serial("acquireLock: waits for a contended lock without blocking", async (t) => { + // Acquire the lock from the outside first + const firstRelease = await acquireLock(t.context.lockPath); + try { + // Second acquirer should wait and eventually succeed once the first releases + const acquirePromise = acquireLock(t.context.lockPath, {wait: 5000, retries: 10}); + // Release the first lock after a short delay + setTimeout(() => firstRelease(), 50); + const secondRelease = await acquirePromise; + t.pass("second acquireLock resolved after first was released"); + secondRelease(); + } finally { + firstRelease(); // no-op if already released + } }); // ─── hasActiveLocks ─────────────────────────────────────────────────────────── @@ -67,7 +161,7 @@ test.serial("hasActiveLocks: returns false when locks directory is empty", async test.serial("hasActiveLocks: returns true when an active (non-stale) lock is present", async (t) => { // Acquire a real lock so its filesystem timestamp is "now" - const release = await acquireLock(t.context.lockPath); + const release = await acquireLockSync(t.context.lockPath); try { t.true(await hasActiveLocks(t.context.testDir), "fresh lock detected as active"); From a022f2d53d811af28173327da1b985fa738e91de Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 25 Jun 2026 21:47:21 +0300 Subject: [PATCH 064/114] test: Fix unstable tests --- .../test/lib/graph/helpers/ui5Framework.integration.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js index abfb48f9497..636845cd910 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.integration.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.integration.js @@ -62,7 +62,8 @@ test.beforeEach(async (t) => { }, "lockfile": { lock: sinon.stub().yieldsAsync(), - unlock: sinon.stub().yieldsAsync() + unlock: sinon.stub().yieldsAsync(), + unlockSync: sinon.stub(), } }); @@ -761,9 +762,9 @@ defineErrorTest( frameworkName: "OpenUI5", failMetadata: true, failExtract: true, - expectedErrorMessage: `Resolution of framework libraries failed with errors: - 1. Failed to resolve library sap.ui.lib1: Failed to read manifest of @openui5/sap.ui.lib1@1.75.0 - 2. Failed to resolve library sap.ui.lib4: Failed to read manifest of @openui5/sap.ui.lib4@1.75.0` + // Both manifest() and extract() fail concurrently. Which error surfaces first is + // non-deterministic across Node versions and platforms — accept either variant. + expectedErrorMessage: /^Resolution of framework libraries failed with errors:\n\s+1\. Failed to resolve library sap\.ui\.lib1: (Failed to read manifest of|Failed to extract package) @openui5\/sap\.ui\.lib1/ }); test.serial("ui5Framework helper should not fail when no framework configuration is given", async (t) => { From 8c4ceba78aba446bef593cb742be612ecd49810c Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 09:05:10 +0300 Subject: [PATCH 065/114] docs: JSDoc updates --- packages/project/lib/graph/ProjectGraph.js | 5 +++++ packages/project/lib/ui5Framework/cache.js | 6 +++++- packages/project/lib/utils/lock.js | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index c05be75bf5e..43ece2ce9e1 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -849,6 +849,11 @@ class ProjectGraph { * Releases the process-coordination lock held by this graph. * Call this when the graph is no longer needed to unblock ui5 cache clean. * + * If not called explicitly, the lockfile package's signal-exit + * handler releases the lock on normal process exit or signal termination. The lock file + * will also be ignored by ui5 cache clean once it ages past the staleness + * threshold (LOCK_STALE_MS). + * * @public */ destroy() { diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 3e756494673..7d4f3ed405f 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -75,7 +75,11 @@ export async function getCacheInfo(ui5DataDir) { * Clean framework cache directory. * * Acquires a cleanup lock before deletion so that concurrent installer - * processes see an active lock and wait rather than writing into a + * processes see an active lock and abort rather than writing into a + * directory that is being deleted. + * + * The lock directory (~/.ui5/locks/) is outside + * ~/.ui5/framework/ and is not affected by the deletion. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index d8054aeb231..8fa31d24134 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -134,7 +134,9 @@ function releaseLock(lockPath) { * * @param {string} lockPath Absolute path to the lock file * @param {object} [options] - * @param {number} [options.retries] Number of times to retry acquiring the lock + * @param {number} [options.retries] Number of synchronous retries on contention. + * Each retry blocks the event loop — only use with a unique lock path where + * contention is impossible (e.g. a path containing a per-process random suffix). * @returns {Function} Synchronous release() function */ export function acquireLockSync(lockPath, {retries} = {}) { From ccd1b77e0b2124e3c9a3077e70900b7ff32894b2 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 10:17:01 +0300 Subject: [PATCH 066/114] fix: Separate async & sync dir creation --- packages/project/lib/utils/lock.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 8fa31d24134..db09b2db271 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -1,7 +1,6 @@ import path from "node:path"; -import {readdir} from "node:fs/promises"; -import {mkdirSync} from "node:fs"; -import {utimesSync} from "node:fs"; +import {readdir, mkdir} from "node:fs/promises"; +import {mkdirSync, utimesSync} from "node:fs"; import {promisify} from "node:util"; import lockfile from "lockfile"; import {getLogger} from "@ui5/logger"; @@ -102,7 +101,7 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { * @param {string} lockPath Absolute path to the lock file * @returns {Function} Synchronous release() function */ -function releaseLock(lockPath) { +function resolveReleaseLockFn(lockPath) { const interval = setInterval(() => { const now = new Date(); utimesSync(lockPath, now, now); @@ -143,7 +142,7 @@ export function acquireLockSync(lockPath, {retries} = {}) { mkdirSync(path.dirname(lockPath), {recursive: true}); log.verbose(`Locking ${lockPath}`); lockfile.lockSync(lockPath, {stale: LOCK_STALE_MS, retries}); - return releaseLock(lockPath); + return resolveReleaseLockFn(lockPath); } /** @@ -165,8 +164,8 @@ export function acquireLockSync(lockPath, {retries} = {}) { * @returns {Promise} Resolves with a synchronous release() function */ export async function acquireLock(lockPath, {wait, retries} = {}) { - mkdirSync(path.dirname(lockPath), {recursive: true}); log.verbose(`Locking ${lockPath}`); + await mkdir(path.dirname(lockPath), {recursive: true}); await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); - return releaseLock(lockPath); + return resolveReleaseLockFn(lockPath); } From 83a26ccab39b8d35b8b8c91758f9176f43b59172 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 13:20:32 +0300 Subject: [PATCH 067/114] refactor: Add ui5DataDir as required in ProjectGraph's constructor --- packages/project/lib/graph/ProjectGraph.js | 34 +-- .../project/lib/graph/helpers/ui5Framework.js | 8 +- .../project/lib/graph/projectGraphBuilder.js | 2 +- .../test/lib/build/BuildServer.integration.js | 7 +- .../lib/build/ProjectBuilder.integration.js | 8 +- .../project/test/lib/graph/ProjectGraph.js | 273 ++++++++++++------ .../test/lib/graph/helpers/ui5Framework.js | 26 +- 7 files changed, 233 insertions(+), 125 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 43ece2ce9e1..fc1b64c1f05 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -26,15 +26,19 @@ class ProjectGraph { * @public * @param {object} parameters Parameters * @param {string} parameters.rootProjectName Root project name - * @param {string} [parameters.dataDir] Resolved UI5 data directory. When provided, a - * process-coordination lock is acquired immediately to block concurrent - * ui5 cache clean operations. + * @param {string} [parameters.ui5DataDir] Explicit UI5 data directory to use for the build cache & locks. + * Overrides the UI5_DATA_DIR environment variable, the UI5 configuration file, + * and the default of ~/.ui5. */ - constructor({rootProjectName, dataDir}) { + constructor({rootProjectName, ui5DataDir}) { if (!rootProjectName) { throw new Error(`Could not create ProjectGraph: Missing or empty parameter 'rootProjectName'`); } + if (!ui5DataDir) { + throw new Error(`Could not create ProjectGraph: Missing or empty parameter 'ui5DataDir'`); + } this._rootProjectName = rootProjectName; + this._ui5DataDir = ui5DataDir; this._projects = new Map(); // maps project name to instance (= nodes) this._adjList = new Map(); // maps project name to dependencies (= edges) @@ -45,8 +49,6 @@ class ProjectGraph { this._sealed = false; this._hasUnresolvedOptionalDependencies = false; // Performance optimization flag this._taskRepository = null; - - this.#lockRelease = dataDir ? this.#lockGraph(getLockDir(dataDir)) : null; } /** @@ -707,12 +709,12 @@ class ProjectGraph { * The lockfile package releases the lock automatically on process exit * or unexpected termination; call {@link destroy} to release it explicitly. * - * @param {string} lockDir Absolute path to the locks directory */ - #lockGraph(lockDir) { + _lockGraph() { + const lockDir = getLockDir(this._ui5DataDir); const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); - return acquireLockSync(lockPath); + this.#lockRelease = acquireLockSync(lockPath); } /** @@ -743,10 +745,6 @@ class ProjectGraph { * Processes build results into a specific directory structure. * @param {module:@ui5/project/build/cache/Cache} [parameters.cache=Default] * Cache mode to use for building UI5 projects - * @param {string} [parameters.ui5DataDir] - * Explicit UI5 data directory to use for the build cache. Overrides the - * UI5_DATA_DIR environment variable, the UI5 configuration file, - * and the default of ~/.ui5. * @returns {Promise} Promise resolving to undefined once build has finished */ async build({ @@ -756,8 +754,7 @@ class ProjectGraph { selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], outputStyle = OutputStyleEnum.Default, - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -778,7 +775,7 @@ class ProjectGraph { includedTasks, excludedTasks, outputStyle, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); return await builder.buildToTarget({ destPath, cleanDest, @@ -792,8 +789,7 @@ class ProjectGraph { initialBuildIncludedDependencies = [], initialBuildExcludedDependencies = [], selfContained = false, cssVariables = false, jsdoc = false, createBuildManifest = false, includedTasks = [], excludedTasks = [], - cache = Cache.Default, - ui5DataDir, + cache = Cache.Default }) { this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { @@ -815,7 +811,7 @@ class ProjectGraph { outputStyle: OutputStyleEnum.Default, cache }, - ui5DataDir, + ui5DataDir: this._ui5DataDir, }); const { default: BuildServer diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index ba661a205f7..719dd1403c6 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -289,6 +289,11 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { + // Lock the ProjectGraph for cache cleanup as it will be modified by adding framework dependencies to it. + // The lock will be released when the ProjectGraph is no longer used i.e. + // after ProjectGraph.build() or ProjectGraph.serve() finish or are terminated. + // projectGraph._lockGraph(); + const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); const frameworkName = rootProject.getFrameworkName(); @@ -398,7 +403,8 @@ export default { } const frameworkGraph = new ProjectGraph({ - rootProjectName: `fake-root-of-${rootProject.getName()}-framework-dependency-graph` + rootProjectName: `fake-root-of-${rootProject.getName()}-framework-dependency-graph`, + ui5DataDir, }); const projectProcessor = new utils.ProjectProcessor({ diff --git a/packages/project/lib/graph/projectGraphBuilder.js b/packages/project/lib/graph/projectGraphBuilder.js index 5443afe912a..e4fd1502c68 100644 --- a/packages/project/lib/graph/projectGraphBuilder.js +++ b/packages/project/lib/graph/projectGraphBuilder.js @@ -137,7 +137,7 @@ async function projectGraphBuilder(nodeProvider, workspace) { const projectGraph = new ProjectGraph({ rootProjectName: rootProjectName, - dataDir: await resolveUi5DataDir(), + ui5DataDir: await resolveUi5DataDir(), }); projectGraph.addProject(rootProject); diff --git a/packages/project/test/lib/build/BuildServer.integration.js b/packages/project/test/lib/build/BuildServer.integration.js index 0abcf7765a9..13e03b28c4e 100644 --- a/packages/project/test/lib/build/BuildServer.integration.js +++ b/packages/project/test/lib/build/BuildServer.integration.js @@ -1077,13 +1077,18 @@ class FixtureTester { } async serveProject({graphConfig = {}, config = {}, expectBuildErrors = false} = {}) { + // Point resolveUi5DataDir() to the fixture-isolated data dir so the graph + // constructor and the build cache use the same isolated path. + const olDataDir = process.env.UI5_DATA_DIR; + process.env.UI5_DATA_DIR = this.ui5DataDir; const graph = this.graph = await graphFromPackageDependencies({ ...graphConfig, cwd: this.fixturePath, }); + process.env.UI5_DATA_DIR = olDataDir; // Execute the build - this.buildServer = await graph.serve({...config, ui5DataDir: this.ui5DataDir}); + this.buildServer = await graph.serve({...config}); this.buildServer.on("error", (err) => { if (!expectBuildErrors) { this._t.fail(`Build server error: ${err.message}`); diff --git a/packages/project/test/lib/build/ProjectBuilder.integration.js b/packages/project/test/lib/build/ProjectBuilder.integration.js index c754b7213f1..d795343d5cf 100644 --- a/packages/project/test/lib/build/ProjectBuilder.integration.js +++ b/packages/project/test/lib/build/ProjectBuilder.integration.js @@ -2718,13 +2718,19 @@ class FixtureTester { await this._initialize(); this._sinon.resetHistory(); + // Point resolveUi5DataDir() to the fixture-isolated data dir so the graph + // constructor and the build cache use the same isolated path. + const oldUi5DataDir = process.env.UI5_DATA_DIR; + process.env.UI5_DATA_DIR = this.ui5DataDir; const graph = await graphFromPackageDependencies({ ...graphConfig, cwd: this.fixturePath, }); + process.env.UI5_DATA_DIR = oldUi5DataDir; + // Execute the build - await graph.build({...config, ui5DataDir: this.ui5DataDir}); + await graph.build({...config}); // Apply assertions if provided if (assertions) { diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index 9f546a47685..d0736d47fed 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -6,6 +6,9 @@ import Specification from "../../../lib/specifications/Specification.js"; const __dirname = import.meta.dirname; +// Dummy data directory passed to all ProjectGraph instances in unit tests. +const TEST_UI5_DATA_DIR = "/test/tmp/ui5/data"; + const applicationAPath = path.join(__dirname, "..", "..", "fixtures", "application.a"); async function createProject(name) { @@ -77,7 +80,11 @@ test.beforeEach(async (t) => { t.context.ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { "@ui5/logger": { getLogger: sinon.stub().withArgs("graph:ProjectGraph").returns(t.context.log) - } + }, + "../../../lib/utils/lock.js": { + acquireLock: sinon.stub().resolves(() => {}), + getLockDir: sinon.stub().returns("/test/locks"), + }, }); }); @@ -90,7 +97,8 @@ test("Instantiate a basic project graph", (t) => { const {ProjectGraph} = t.context; t.notThrows(() => { new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); }, "Should not throw"); }); @@ -107,7 +115,8 @@ test("Instantiate a basic project with missing parameter rootProjectName", (t) = test("getRoot", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "application.a" + rootProjectName: "application.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("application.a"); graph.addProject(project); @@ -118,7 +127,8 @@ test("getRoot", async (t) => { test("getRoot: Root not added to graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "application.a" + rootProjectName: "application.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -132,7 +142,8 @@ test("getRoot: Root not added to graph", (t) => { test("add-/getProject", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("application.a"); graph.addProject(project); @@ -143,7 +154,8 @@ test("add-/getProject", async (t) => { test("addProject: Add duplicate", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -164,7 +176,8 @@ test("addProject: Add duplicate", async (t) => { test("addProject: Add project with integer-like name", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project = await createProject("1337"); @@ -179,7 +192,8 @@ test("addProject: Add project with integer-like name", async (t) => { test("getProject: Project is not in graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const res = graph.getProject("application.a"); t.is(res, undefined, "Should return undefined"); @@ -188,7 +202,8 @@ test("getProject: Project is not in graph", (t) => { test("getProjects", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -205,7 +220,8 @@ test("getProjects", async (t) => { test("getProjectNames", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -222,7 +238,8 @@ test("getProjectNames", async (t) => { test("getSize", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const project1 = await createProject("application.a"); graph.addProject(project1); @@ -240,7 +257,8 @@ test("getSize", async (t) => { test("add-/getExtension", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension = await createExtension("extension.a"); graph.addExtension(extension); @@ -251,7 +269,8 @@ test("add-/getExtension", async (t) => { test("addExtension: Add duplicate", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension1 = await createExtension("extension.a"); graph.addExtension(extension1); @@ -272,7 +291,8 @@ test("addExtension: Add duplicate", async (t) => { test("addExtension: Add extension with integer-like name", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension = await createExtension("1337"); @@ -287,7 +307,8 @@ test("addExtension: Add extension with integer-like name", async (t) => { test("getExtension: Project is not in graph", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const res = graph.getExtension("extension.a"); t.is(res, undefined, "Should return undefined"); @@ -296,7 +317,8 @@ test("getExtension: Project is not in graph", (t) => { test("getExtensions", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const extension1 = await createExtension("extension.a"); graph.addExtension(extension1); @@ -312,7 +334,8 @@ test("getExtensions", async (t) => { test("declareDependency / getDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -343,7 +366,8 @@ test("declareDependency / getDependencies", async (t) => { test("getTransitiveDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -368,7 +392,8 @@ test("getTransitiveDependencies", async (t) => { test("getTransitiveDependencies: Unknown project", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -382,7 +407,8 @@ test("getTransitiveDependencies: Unknown project", (t) => { test("declareDependency: Unknown source", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.b")); @@ -398,7 +424,8 @@ test("declareDependency: Unknown source", async (t) => { test("declareDependency: Unknown target", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -414,7 +441,8 @@ test("declareDependency: Unknown target", async (t) => { test("declareDependency: Same target as source", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -431,7 +459,8 @@ test("declareDependency: Same target as source", async (t) => { test("declareDependency: Already declared", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -448,7 +477,8 @@ test("declareDependency: Already declared", async (t) => { test("declareDependency: Already declared as optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -468,7 +498,8 @@ test("declareDependency: Already declared as optional", async (t) => { test("declareDependency: Already declared as non-optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -486,7 +517,8 @@ test("declareDependency: Already declared as non-optional", async (t) => { test("declareDependency: Already declared as optional, now non-optional", async (t) => { const {ProjectGraph, log} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -503,7 +535,8 @@ test("declareDependency: Already declared as optional, now non-optional", async test("getDependencies: Project without dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -515,7 +548,8 @@ test("getDependencies: Project without dependencies", async (t) => { test("getDependencies: Unknown project", (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "my root project" + rootProjectName: "my root project", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = t.throws(() => { @@ -529,7 +563,8 @@ test("getDependencies: Unknown project", (t) => { test("resolveOptionalDependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -564,7 +599,8 @@ test("resolveOptionalDependencies", async (t) => { test("resolveOptionalDependencies: Optional dependency has not been resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -615,7 +651,8 @@ test("resolveOptionalDependencies: Optional dependency has not been resolved", a test("resolveOptionalDependencies: Dependency of optional dependency has not been resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -642,7 +679,8 @@ test("resolveOptionalDependencies: Dependency of optional dependency has not bee test("resolveOptionalDependencies: Cyclic optional dependency is not resolved", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -671,7 +709,8 @@ test("resolveOptionalDependencies: Cyclic optional dependency is not resolved", test("resolveOptionalDependencies: Resolves transitive optional dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -706,7 +745,8 @@ test("resolveOptionalDependencies: Resolves transitive optional dependencies", a test("traverseBreadthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -736,7 +776,8 @@ test("traverseBreadthFirst: Async", async (t) => { test("traverseBreadthFirst: Sync", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -759,7 +800,8 @@ test("traverseBreadthFirst: Sync", async (t) => { test("traverseBreadthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -779,7 +821,8 @@ test("traverseBreadthFirst: No project visited twice", async (t) => { test("traverseBreadthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -796,7 +839,8 @@ test("traverseBreadthFirst: Detect cycle", async (t) => { test("traverseBreadthFirst: No cycle when visited breadth first", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -817,7 +861,8 @@ test("traverseBreadthFirst: No cycle when visited breadth first", async (t) => { test("traverseBreadthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = await t.throwsAsync(graph.traverseBreadthFirst(() => {})); @@ -829,7 +874,8 @@ test("traverseBreadthFirst: Can't find start node", async (t) => { test("traverseBreadthFirst: Custom start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -854,7 +900,8 @@ test("traverseBreadthFirst: Custom start node", async (t) => { test("traverseBreadthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -890,7 +937,8 @@ test("traverseBreadthFirst: dependencies parameter", async (t) => { test("traverseBreadthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -909,7 +957,8 @@ test("traverseBreadthFirst: Dependency declaration order is followed", async (t) ]); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -931,7 +980,8 @@ test("traverseBreadthFirst: Dependency declaration order is followed", async (t) test("traverseDepthFirst: Async", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -961,7 +1011,8 @@ test("traverseDepthFirst: Async", async (t) => { test("traverseDepthFirst: Sync", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -984,7 +1035,8 @@ test("traverseDepthFirst: Sync", async (t) => { test("traverseDepthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1004,7 +1056,8 @@ test("traverseDepthFirst: No project visited twice", async (t) => { test("traverseDepthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1021,7 +1074,8 @@ test("traverseDepthFirst: Detect cycle", async (t) => { test("traverseDepthFirst: Cycle which does not occur in BFS", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1041,7 +1095,8 @@ test("traverseDepthFirst: Cycle which does not occur in BFS", async (t) => { test("traverseDepthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const error = await t.throwsAsync(graph.traverseDepthFirst(() => {})); @@ -1053,7 +1108,8 @@ test("traverseDepthFirst: Can't find start node", async (t) => { test("traverseDepthFirst: Custom start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1078,7 +1134,8 @@ test("traverseDepthFirst: Custom start node", async (t) => { test("traverseDepthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1114,7 +1171,8 @@ test("traverseDepthFirst: dependencies parameter", async (t) => { test("traverseDepthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1133,7 +1191,8 @@ test("traverseDepthFirst: Dependency declaration order is followed", async (t) = ]); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -1155,7 +1214,8 @@ test("traverseDepthFirst: Dependency declaration order is followed", async (t) = test("traverseDependenciesDepthFirst: Basic traversal without including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1178,7 +1238,8 @@ test("traverseDependenciesDepthFirst: Basic traversal without including start mo test("traverseDependenciesDepthFirst: Basic traversal including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1202,7 +1263,8 @@ test("traverseDependenciesDepthFirst: Basic traversal including start module", a test("traverseDependenciesDepthFirst: Using boolean as first parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1226,7 +1288,8 @@ test("traverseDependenciesDepthFirst: Using boolean as first parameter", async ( test("traverseDependenciesDepthFirst: No dependencies", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1241,7 +1304,8 @@ test("traverseDependenciesDepthFirst: No dependencies", async (t) => { test("traverseDependenciesDepthFirst: Diamond dependency structure", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1268,7 +1332,8 @@ test("traverseDependenciesDepthFirst: Diamond dependency structure", async (t) = test("traverseDependenciesDepthFirst: Complex dependency chain", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1297,7 +1362,8 @@ test("traverseDependenciesDepthFirst: Complex dependency chain", async (t) => { test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1323,7 +1389,8 @@ test("traverseDependenciesDepthFirst: No project visited twice", async (t) => { test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1341,7 +1408,8 @@ test("traverseDependenciesDepthFirst: Can't find start node", async (t) => { test("traverseDependenciesDepthFirst: dependencies parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1377,7 +1445,8 @@ test("traverseDependenciesDepthFirst: dependencies parameter", async (t) => { test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1399,7 +1468,8 @@ test("traverseDependenciesDepthFirst: Detect cycle", async (t) => { test("traverseDependenciesDepthFirst: Dependency declaration order is followed", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1422,7 +1492,8 @@ test("traverseDependenciesDepthFirst: Dependency declaration order is followed", ], "First graph should visit in declaration order"); const graph2 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.b")); @@ -1448,7 +1519,8 @@ test("traverseDependenciesDepthFirst: Dependency declaration order is followed", test("traverseDependents: Basic traversal without including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1471,7 +1543,8 @@ test("traverseDependents: Basic traversal without including start module", async test("traverseDependents: Basic traversal including start module", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1495,7 +1568,8 @@ test("traverseDependents: Basic traversal including start module", async (t) => test("traverseDependents: Using boolean as first parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.c" + rootProjectName: "library.c", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1519,7 +1593,8 @@ test("traverseDependents: Using boolean as first parameter", async (t) => { test("traverseDependents: No dependents", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1537,7 +1612,8 @@ test("traverseDependents: No dependents", async (t) => { test("traverseDependents: Multiple dependents", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1563,7 +1639,8 @@ test("traverseDependents: Multiple dependents", async (t) => { test("traverseDependents: Complex chain", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1592,7 +1669,8 @@ test("traverseDependents: Complex chain", async (t) => { test("traverseDependents: No project visited twice", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1620,7 +1698,8 @@ test("traverseDependents: No project visited twice", async (t) => { test("traverseDependents: Can't find start node", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); @@ -1639,7 +1718,8 @@ test("traverseDependents: Can't find start node", async (t) => { test("traverseDependents: dependents parameter", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1677,7 +1757,8 @@ test("traverseDependents: dependents parameter", async (t) => { test("traverseDependents: Detect cycle", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1699,10 +1780,12 @@ test("traverseDependents: Detect cycle", async (t) => { test("join", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1764,10 +1847,12 @@ test("join", async (t) => { test("join: Preserves hasUnresolvedOptionalDependencies flag", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph1.addProject(await createProject("library.b")); @@ -1784,10 +1869,12 @@ test("join: Preserves hasUnresolvedOptionalDependencies flag", async (t) => { test("join: Seals incoming graph", (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); @@ -1800,10 +1887,12 @@ test("join: Seals incoming graph", (t) => { test("join: Incoming graph already sealed", (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "theme.a" + rootProjectName: "theme.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph2.seal(); @@ -1816,10 +1905,12 @@ test("join: Incoming graph already sealed", (t) => { test("join: Unexpected project intersection", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "😹" + rootProjectName: "😹", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "😼" + rootProjectName: "😼", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addProject(await createProject("library.a")); graph2.addProject(await createProject("library.a")); @@ -1837,10 +1928,12 @@ test("join: Unexpected project intersection", async (t) => { test("join: Unexpected extension intersection", async (t) => { const {ProjectGraph} = t.context; const graph1 = new ProjectGraph({ - rootProjectName: "😹" + rootProjectName: "😹", + ui5DataDir: TEST_UI5_DATA_DIR, }); const graph2 = new ProjectGraph({ - rootProjectName: "😼" + rootProjectName: "😼", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph1.addExtension(await createExtension("extension.a")); graph2.addExtension(await createExtension("extension.a")); @@ -1859,7 +1952,8 @@ test("join: Unexpected extension intersection", async (t) => { test("Seal/isSealed", async (t) => { const {ProjectGraph} = t.context; const graph = new ProjectGraph({ - rootProjectName: "library.a" + rootProjectName: "library.a", + ui5DataDir: TEST_UI5_DATA_DIR, }); graph.addProject(await createProject("library.a")); graph.addProject(await createProject("library.b")); @@ -1907,7 +2001,8 @@ test("Seal/isSealed", async (t) => { const graph2 = new ProjectGraph({ - rootProjectName: "library.x" + rootProjectName: "library.x", + ui5DataDir: TEST_UI5_DATA_DIR, }); t.throws(() => { graph.join(graph2); diff --git a/packages/project/test/lib/graph/helpers/ui5Framework.js b/packages/project/test/lib/graph/helpers/ui5Framework.js index 649f7c45bc7..4147c22a51d 100644 --- a/packages/project/test/lib/graph/helpers/ui5Framework.js +++ b/packages/project/test/lib/graph/helpers/ui5Framework.js @@ -54,7 +54,7 @@ test.beforeEach(async (t) => { t.context.Sapui5MavenSnapshotResolverResolveVersionStub = sinon.stub(); t.context.Sapui5MavenSnapshotResolverStub.resolveVersion = t.context.Sapui5MavenSnapshotResolverResolveVersionStub; - t.context.resolveUi5DataDirStub = sinon.stub().resolves(undefined); + t.context.resolveUi5DataDirStub = sinon.stub().resolves("/test/ui5/data"); t.context.ui5Framework = await esmock.p("../../../../lib/graph/helpers/ui5Framework.js", { "@ui5/logger": ui5Logger, @@ -127,7 +127,7 @@ test.serial("enrichProjectGraph", async (t) => { snapshotCache: undefined, cwd: dependencyTree.path, version: dependencyTree.configuration.framework.version, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); @@ -332,7 +332,7 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { t.is(Sapui5ResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5ResolverResolveVersionStub.getCall(0).args, ["1.99", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); @@ -340,7 +340,7 @@ test.serial("enrichProjectGraph: With versionOverride", async (t) => { snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -394,7 +394,7 @@ test.serial("enrichProjectGraph: With versionOverride containing snapshot versio t.is(Sapui5MavenSnapshotResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5MavenSnapshotResolverResolveVersionStub.getCall(0).args, ["1.99-SNAPSHOT", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, @@ -403,7 +403,7 @@ test.serial("enrichProjectGraph: With versionOverride containing snapshot versio snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -457,7 +457,7 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot t.is(Sapui5MavenSnapshotResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5MavenSnapshotResolverResolveVersionStub.getCall(0).args, ["latest-snapshot", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5MavenSnapshotResolverStub.callCount, 1, @@ -466,7 +466,7 @@ test.serial("enrichProjectGraph: With versionOverride containing latest-snapshot snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9-SNAPSHOT", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -626,7 +626,7 @@ test.serial("enrichProjectGraph should resolve framework project with version an snapshotCache: undefined, cwd: dependencyTree.path, version: "1.2.3", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -722,7 +722,7 @@ test.serial("enrichProjectGraph should resolve framework project " + t.is(Sapui5ResolverResolveVersionStub.callCount, 1); t.deepEqual(Sapui5ResolverResolveVersionStub.getCall(0).args, ["3.4.5", { cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", }]); t.is(Sapui5ResolverStub.callCount, 1, "Sapui5Resolver#constructor should be called once"); @@ -731,7 +731,7 @@ test.serial("enrichProjectGraph should resolve framework project " + snapshotCache: undefined, cwd: dependencyTree.path, version: "1.99.9", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: undefined }], "Sapui5Resolver#constructor should be called with expected args"); }); @@ -996,7 +996,7 @@ test.serial("enrichProjectGraph should use framework library metadata from works snapshotCache: undefined, cwd: dependencyTree.path, version: "1.111.1", - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", providedLibraryMetadata: workspaceFrameworkLibraryMetadata }], "Sapui5Resolver#constructor should be called with expected args"); t.is(Sapui5ResolverStub.getCall(0).args[0].providedLibraryMetadata, workspaceFrameworkLibraryMetadata); @@ -1054,7 +1054,7 @@ test.serial("enrichProjectGraph should allow omitting framework version in case t.deepEqual(Sapui5ResolverStub.getCall(0).args, [{ snapshotCache: undefined, cwd: dependencyTree.path, - ui5DataDir: undefined, + ui5DataDir: "/test/ui5/data", version: undefined, providedLibraryMetadata: workspaceFrameworkLibraryMetadata }], "Sapui5Resolver#constructor should be called with expected args"); From 989a7011867e70b4b6798b684e91c05437148ce8 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 13:26:01 +0300 Subject: [PATCH 068/114] refactor: Give a name for the graph's internal locking method --- packages/project/lib/graph/ProjectGraph.js | 2 +- packages/project/lib/graph/helpers/ui5Framework.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index fc1b64c1f05..3d851ee1f6d 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -710,7 +710,7 @@ class ProjectGraph { * or unexpected termination; call {@link destroy} to release it explicitly. * */ - _lockGraph() { + _preventCacheClean() { const lockDir = getLockDir(this._ui5DataDir); const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index 719dd1403c6..b24ec4dc817 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -292,7 +292,7 @@ export default { // Lock the ProjectGraph for cache cleanup as it will be modified by adding framework dependencies to it. // The lock will be released when the ProjectGraph is no longer used i.e. // after ProjectGraph.build() or ProjectGraph.serve() finish or are terminated. - // projectGraph._lockGraph(); + // projectGraph._preventCacheClean(); const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); From 1b55875663242cefeecdea13d9825d7f06b39865 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 14:20:24 +0300 Subject: [PATCH 069/114] refactor: Move locks to the specific lock points --- packages/project/lib/graph/ProjectGraph.js | 47 ++++++++++++++----- .../project/lib/graph/helpers/ui5Framework.js | 5 +- packages/project/lib/utils/lock.js | 6 ++- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 3d851ee1f6d..de46bde7a3f 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -4,16 +4,18 @@ import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {acquireLockSync, getLockDir} from "../utils/lock.js"; +import {acquireLock, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from "../utils/lock.js"; /** * A rooted, directed graph representing a UI5 project, its dependencies and available extensions. * - * When constructed with a dataDir, the graph immediately acquires a process-coordination - * lock to prevent concurrent ui5 cache clean operations. Call {@link destroy} to release - * it explicitly when the graph is no longer needed. Even without an explicit call, the - * lockfile package ensures the lock is released on process exit or unexpected termination. + * When constructed with a ui5DataDir, the graph acquires a process-coordination + * lock during {@link enrichProjectGraph} to prevent concurrent ui5 cache clean + * operations. If a cache clean is already running, the lock acquisition waits for it to finish + * before proceeding. Call {@link destroy} to release the lock explicitly when the graph is no + * longer needed. Even without an explicit call, the lockfile package ensures the + * lock is released on process exit or unexpected termination. * * @public * @class @@ -704,17 +706,38 @@ class ProjectGraph { } /** - * Acquires a process-coordination lock scoped to this graph instance, - * blocking concurrent ui5 cache clean operations. - * The lockfile package releases the lock automatically on process exit - * or unexpected termination; call {@link destroy} to release it explicitly. + * Acquires a process-coordination lock scoped to this graph instance to prevent + * concurrent ui5 cache clean operations from running while framework + * packages are being downloaded or the build/serve lifecycle is active. * + * If a cache clean is already in progress, waits for it to finish before acquiring + * the lock. If a cache clean starts after the lock is acquired it will detect this + * graph's lock and abort. Throws if the cache clean is still active after the lock + * is acquired (i.e. the timing gap could not be closed). + * + * Must be called after construction and before {@link enrichProjectGraph}. + * The lock is released by {@link destroy}. */ - _preventCacheClean() { + async _preventCacheClean() { const lockDir = getLockDir(this._ui5DataDir); const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); - this.#lockRelease = acquireLockSync(lockPath); + + // Use the async variant with wait+retries so that if a cache clean is currently + // holding cache-cleanup.lock, we yield to the event loop and retry rather than + // spin-blocking. Cache clean is expected to complete within seconds. + this.#lockRelease = await acquireLock(lockPath, {wait: 10000, retries: 10}); + + // Check after acquiring our lock — if cache clean is still active at this point + // the timing gap could not be closed and it is unsafe to proceed. + if (await hasActiveLocks(lockDir, {include: CLEANUP_LOCK_NAME})) { + this.#lockRelease(); + this.#lockRelease = null; + throw new Error( + "UI5 data directory is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } } /** @@ -756,6 +779,7 @@ class ProjectGraph { outputStyle = OutputStyleEnum.Default, cache = Cache.Default }) { + this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being built. this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -791,6 +815,7 @@ class ProjectGraph { includedTasks = [], excludedTasks = [], cache = Cache.Default }) { + this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being served this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index b24ec4dc817..e24efdc2d51 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -289,10 +289,7 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { - // Lock the ProjectGraph for cache cleanup as it will be modified by adding framework dependencies to it. - // The lock will be released when the ProjectGraph is no longer used i.e. - // after ProjectGraph.build() or ProjectGraph.serve() finish or are terminated. - // projectGraph._preventCacheClean(); + await projectGraph._preventCacheClean(); const {workspace, snapshotCache} = options; const rootProject = projectGraph.getRoot(); diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index db09b2db271..77d50508e1c 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -1,6 +1,6 @@ import path from "node:path"; import {readdir, mkdir} from "node:fs/promises"; -import {mkdirSync, utimesSync} from "node:fs"; +import {mkdirSync, utimesSync, existsSync} from "node:fs"; import {promisify} from "node:util"; import lockfile from "lockfile"; import {getLogger} from "@ui5/logger"; @@ -103,6 +103,10 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { */ function resolveReleaseLockFn(lockPath) { const interval = setInterval(() => { + if (!existsSync(lockPath)) { + clearInterval(interval); + return; + } const now = new Date(); utimesSync(lockPath, now, now); }, LOCK_REFRESH_INTERVAL_MS); From cac6e7eb6c9494b5d8523b3173a188b71b1f94b3 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Fri, 26 Jun 2026 14:43:30 +0300 Subject: [PATCH 070/114] feat: ProjectGraph's lock now has a tolerance to wat for the cache clean to finish --- packages/project/lib/graph/ProjectGraph.js | 43 +++++++++++-------- .../project/test/lib/graph/ProjectGraph.js | 4 +- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index de46bde7a3f..59d4dcdba36 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -4,7 +4,7 @@ import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {acquireLock, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from "../utils/lock.js"; +import {acquireLockSync, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from "../utils/lock.js"; /** @@ -710,33 +710,38 @@ class ProjectGraph { * concurrent ui5 cache clean operations from running while framework * packages are being downloaded or the build/serve lifecycle is active. * - * If a cache clean is already in progress, waits for it to finish before acquiring - * the lock. If a cache clean starts after the lock is acquired it will detect this - * graph's lock and abort. Throws if the cache clean is still active after the lock - * is acquired (i.e. the timing gap could not be closed). + * If a cache clean is already in progress, polls until it finishes (up to 10 s) + * before acquiring the graph lock. The double-check after acquiring guards the + * narrow window between the poll and the lock acquisition. Throws if a cache + * clean is still active after that window. * - * Must be called after construction and before {@link enrichProjectGraph}. + * Called by projectGraphBuilder immediately after construction so the lock is + * in place before any framework downloads or build/serve work begins. * The lock is released by {@link destroy}. */ async _preventCacheClean() { const lockDir = getLockDir(this._ui5DataDir); + // Acquire our lock so any cache clean that starts now will detect us and abort. + // First acquire, then lock, so that the await of hasActiveLocks does not open a window for a race condition. const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); + this.#lockRelease = acquireLockSync(lockPath); - // Use the async variant with wait+retries so that if a cache clean is currently - // holding cache-cleanup.lock, we yield to the event loop and retry rather than - // spin-blocking. Cache clean is expected to complete within seconds. - this.#lockRelease = await acquireLock(lockPath, {wait: 10000, retries: 10}); + // Poll until any in-progress cache clean releases its lock, or we time out. + const POLL_INTERVAL_MS = 200; + const TIMEOUT_MS = 10000; + const deadline = Date.now() + TIMEOUT_MS; + while (await hasActiveLocks(lockDir, {include: CLEANUP_LOCK_NAME})) { + if (Date.now() >= deadline) { + this.#lockRelease.release(); + this.#lockRelease = null; - // Check after acquiring our lock — if cache clean is still active at this point - // the timing gap could not be closed and it is unsafe to proceed. - if (await hasActiveLocks(lockDir, {include: CLEANUP_LOCK_NAME})) { - this.#lockRelease(); - this.#lockRelease = null; - throw new Error( - "UI5 data directory is currently being cleaned. " + - "Please wait for the cache clean operation to finish and try again." - ); + throw new Error( + "UI5 data directory is currently being cleaned. " + + "Please wait for the cache clean operation to finish and try again." + ); + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } } diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index d0736d47fed..a7a9b97e402 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -82,8 +82,10 @@ test.beforeEach(async (t) => { getLogger: sinon.stub().withArgs("graph:ProjectGraph").returns(t.context.log) }, "../../../lib/utils/lock.js": { - acquireLock: sinon.stub().resolves(() => {}), + acquireLockSync: sinon.stub().returns(() => {}), getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), }, }); }); From 94e0e4129507655b896c57bd451a4eac9df00418 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 01:41:14 +0300 Subject: [PATCH 071/114] refactor: Cache clean now first renames the framework dir and then wipes it out This is an atomic operation which means quickly releases the lock. The deletion is postponed and this means that it can be broken. For that purpose add also functionality to delete such leftover dirs (orphaned) and have a full cleanup --- packages/cli/lib/cli/commands/cache.js | 53 ++++- packages/cli/test/lib/cli/commands/cache.js | 87 ++++++++ packages/project/lib/ui5Framework/cache.js | 194 +++++++++++++++--- .../project/test/lib/ui5framework/cache.js | 166 +++++++++++++-- 4 files changed, 446 insertions(+), 54 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 0c7a96508ca..6fae0adc07d 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -96,14 +96,16 @@ function padLabel(label) { /** * Display information about the cached data that will be removed, - * including the absolute paths and details about the framework and build caches. + * including the absolute paths and details about the framework and build caches, + * and any orphaned staging directories from previously interrupted clean operations. * - * @param {*} data + * @param {object} data * @param {object} data.frameworkInfo * @param {object} data.buildInfo * @param {string} data.frameworkAbsPath * @param {string} data.buildAbsPath * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfo */ async function displayCacheInfo({ frameworkInfo, @@ -111,6 +113,7 @@ async function displayCacheInfo({ frameworkAbsPath, buildAbsPath, buildPreSize, + orphanedInfo, }) { // Display items that will be removed process.stderr.write(chalk.bold("\nThe following cached data will be removed:\n\n")); @@ -126,12 +129,24 @@ async function displayCacheInfo({ ` ${chalk.yellow("•")} ${padLabel(LABEL_BUILD)} ${buildAbsPath} (${detail})\n` ); } + if (orphanedInfo && orphanedInfo.length > 0) { + process.stderr.write( + ` ${chalk.yellow("•")} ${chalk.bold("Orphaned framework data")}` + + ` (incomplete previous clean — ` + + `${orphanedInfo.length} director${orphanedInfo.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfo) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${detail})\n`); + } + } process.stderr.write("\n"); } /** * Display the result of the cache cleanup operation, - * including which caches were removed and their details. + * including which caches were removed and their details, + * and any orphaned staging directories that were also cleaned up. * * @param {object} data * @param {object} data.frameworkResult @@ -139,6 +154,7 @@ async function displayCacheInfo({ * @param {string} data.frameworkAbsPath * @param {string} data.buildAbsPath * @param {number} data.buildPreSize + * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfoWithAbsPaths */ async function displayCleanupResult({ frameworkResult, @@ -146,9 +162,10 @@ async function displayCleanupResult({ frameworkAbsPath, buildAbsPath, buildPreSize, + orphanedInfoWithAbsPaths, }) { process.stderr.write("\n"); - if (frameworkResult) { + if (frameworkResult && frameworkAbsPath) { const detail = formatFrameworkStats( frameworkResult.libraryCount, frameworkResult.versionCount, @@ -158,6 +175,17 @@ async function displayCleanupResult({ ` (${frameworkAbsPath} · ${detail})\n`, ); } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + process.stderr.write( + `${chalk.green("✓")} Removed ${chalk.bold("Orphaned framework data")}` + + ` (${orphanedInfoWithAbsPaths.length}` + + ` director${orphanedInfoWithAbsPaths.length === 1 ? "y" : "ies"})\n` + ); + for (const orphan of orphanedInfoWithAbsPaths) { + const detail = formatFrameworkStats(orphan.libraryCount, orphan.versionCount); + process.stderr.write(` ${chalk.dim(orphan.absPath)} (${detail})\n`); + } + } if (buildResult) { // Use pre-clean size so the number matches what was shown before confirmation const detail = buildPreSize > 0 ? formatSize(buildPreSize) : ""; @@ -175,6 +203,9 @@ async function displayCleanupResult({ if (buildResult) { cleaned.push(LABEL_BUILD); } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0 && !frameworkResult) { + cleaned.push("Orphaned framework data"); + } process.stderr.write( `\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`, ); @@ -217,15 +248,21 @@ async function handleCache(argv) { // Inform the user immediately — getPackageStats may take a moment on a large cache process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); - const frameworkInfo = await frameworkCache.getCacheInfo(ui5DataDir); - const buildInfo = await CacheManager.getCacheInfo(ui5DataDir); + const [frameworkInfo, buildInfo, orphanedInfo] = await Promise.all([ + frameworkCache.getCacheInfo(ui5DataDir), + CacheManager.getCacheInfo(ui5DataDir), + frameworkCache.getOrphanedInfo(ui5DataDir), + ]); // Compute absolute paths once — producers return relative sub-path segments const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; const buildPreSize = buildInfo?.size ?? 0; + const orphanedInfoWithAbsPaths = orphanedInfo.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); - if (!frameworkInfo && !buildInfo) { + if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { process.stderr.write("Nothing to clean\n"); return; } @@ -236,6 +273,7 @@ async function handleCache(argv) { frameworkAbsPath, buildAbsPath, buildPreSize, + orphanedInfo: orphanedInfoWithAbsPaths, }); const confirmed = await getConfirmation(argv); @@ -254,6 +292,7 @@ async function handleCache(argv) { frameworkAbsPath, buildAbsPath, buildPreSize, + orphanedInfoWithAbsPaths, }); } diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 5261edcb871..04394d9bd4f 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -33,6 +33,7 @@ test.beforeEach(async (t) => { t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheGetOrphanedInfo = sinon.stub().resolves([]); t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); @@ -49,6 +50,7 @@ test.beforeEach(async (t) => { "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, cleanCache: t.context.frameworkCacheCleanCache, + getOrphanedInfo: t.context.frameworkCacheGetOrphanedInfo, }, "@ui5/project/build/cache/CacheManager": { default: class { @@ -363,3 +365,88 @@ test.serial("ui5 cache clean --yes: also aborts when framework cache is locked", t.is(buildCacheCleanCache.callCount, 0, "buildCache.cleanCache not called when locked"); t.is(process.exitCode, 1, "Exit code should be 1"); }); + +test.serial("ui5 cache clean: formats byte sizes correctly (< 1 KB)", async (t) => { + const {cache, argv, stderrWriteStub, buildCacheCleanCache, buildCacheGetCacheInfo, yesnoStub} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + buildCacheGetCacheInfo.resolves({path: "buildCache/v0_7", size: 500}); + yesnoStub.resolves(true); + buildCacheCleanCache.resolves({path: "buildCache/v0_7", size: 500}); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("500 B"), "Shows bytes format for size < 1 KB"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in pre-confirmation summary", async (t) => { + const {cache, argv, stderrWriteStub, yesnoStub, + frameworkCacheCleanCache, frameworkCacheGetOrphanedInfo} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_abcd", libraryCount: 5, versionCount: 2}, + ]); + frameworkCacheCleanCache.resolves(null); + + yesnoStub.resolves(true); + + argv["_"] = ["cache", "clean"]; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section in pre-confirm summary"); + t.true(allOutput.includes("incomplete previous clean"), "Shows orphaned context message"); + t.true(allOutput.includes("1 directory"), "Shows singular 'directory' for one orphan"); + t.true(allOutput.includes(".framework_to_delete_abcd"), "Shows orphaned dir path"); + t.true(allOutput.includes("2 versions of 5 libraries"), "Shows orphaned dir stats"); +}); + +test.serial("ui5 cache clean: shows orphaned framework data in post-clean summary", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: ".framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); + frameworkCacheCleanCache.resolves({ + path: "framework", libraryCount: 3, versionCount: 1, orphaned: [], + }); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Removed Orphaned framework data"), "Shows orphaned section in result"); + t.true(allOutput.includes("2 directories"), "Shows plural 'directories' for multiple orphans"); + t.true(allOutput.includes(".framework_to_delete_ab12"), "Shows first orphaned dir path"); + t.true(allOutput.includes(".framework_to_delete_cd34"), "Shows second orphaned dir path"); +}); + +test.serial("ui5 cache clean: shows orphaned-only success summary when no active framework", async (t) => { + const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, + frameworkCacheCleanCache} = t.context; + + t.context.frameworkCacheGetCacheInfo.resolves(null); + t.context.buildCacheGetCacheInfo.resolves(null); + frameworkCacheGetOrphanedInfo.resolves([ + {path: ".framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); + frameworkCacheCleanCache.resolves(null); + + argv["_"] = ["cache", "clean"]; + argv["yes"] = true; + await cache.handler(argv); + + const allOutput = stderrWriteStub.args.map((a) => a[0]).join(""); + t.true(allOutput.includes("Orphaned framework data"), "Shows orphaned section"); + t.true(allOutput.includes("Cleaned Orphaned framework data"), "Success summary mentions orphaned data"); + t.false(allOutput.includes("UI5 Framework packages"), "Does not mention main framework when absent"); +}); diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 7d4f3ed405f..0013ecc0e12 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,21 +1,28 @@ import fs from "node:fs/promises"; import path from "node:path"; +import {getRandomValues} from "node:crypto"; import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; const FRAMEWORK_DIR_NAME = "framework"; +/** + * Prefix used for staging directories created during an atomic framework cache clean. + * The directory is renamed to this prefix + a random hex suffix before deletion so that + * the original path immediately becomes unavailable to concurrent processes. + */ +const STAGING_DIR_PREFIX = ".framework_to_delete_"; + /** * Count unique libraries and versions in the packages/ subdirectory. * * Library names are deduplicated globally: sap.m under @openui5 and @sapui5 counts * as one library. * - * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @param {string} frameworkDir Absolute path to the framework directory * @returns {Promise<{libraries: number, versions: number}|null>} * Null if the directory does not exist or contains no installed libraries. */ -async function getPackageStats(ui5DataDir) { - const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); +async function getPackageStats(frameworkDir) { try { await fs.access(frameworkDir); } catch { @@ -60,7 +67,8 @@ async function getPackageStats(ui5DataDir) { * Framework cache info, or null if no packages are installed. */ export async function getCacheInfo(ui5DataDir) { - const stats = await getPackageStats(ui5DataDir); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); if (!stats) { return null; } @@ -72,33 +80,137 @@ export async function getCacheInfo(ui5DataDir) { } /** - * Clean framework cache directory. + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). + * Returns stats per orphan without deleting anything. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise>} + */ +export async function getOrphanedInfo(ui5DataDir) { + let entries; + try { + entries = await fs.readdir(ui5DataDir, {withFileTypes: true}); + } catch { + return []; + } + + const orphans = entries.filter( + (e) => e.isDirectory() && e.name.startsWith(STAGING_DIR_PREFIX) + ); + + if (orphans.length === 0) { + return []; + } + + const results = await Promise.all(orphans.map(async (orphan) => { + const orphanDir = path.join(ui5DataDir, orphan.name); + const stats = await getPackageStats(orphanDir); + if (!stats) { + return null; + } + return { + path: orphan.name, + libraryCount: stats.libraries, + versionCount: stats.versions, + }; + })); + + return results.filter(Boolean); +} + +/** + * Scans ui5DataDir for orphaned staging directories left behind by previously + * interrupted clean operations (i.e. process killed after rename but before deletion). * - * Acquires a cleanup lock before deletion so that concurrent installer - * processes see an active lock and abort rather than writing into a - * directory that is being deleted. + * Returns an array of result objects — one per orphaned directory found — each + * containing the path, library count and version count so the caller can include + * them in the cleanup summary. * - * The lock directory (~/.ui5/locks/) is outside - * ~/.ui5/framework/ and is not affected by the deletion. + * Deletion failures are swallowed per entry so one stuck directory does not prevent + * the others from being removed. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} - * Removal result, or null if nothing was installed. + * @returns {Promise>} + */ +async function cleanupOrphanedStagingDirs(ui5DataDir) { + const orphans = await getOrphanedInfo(ui5DataDir); + + for (const orphan of orphans) { + const orphanDir = path.join(ui5DataDir, orphan.path); + try { + await fs.rm(orphanDir, {recursive: true, force: true}); + } catch { + // Ignore deletion errors + } + } + + return orphans; +} + +/** + * Clean the framework cache directory. + * + * Strategy + * ──────── + * Rather than deleting files one-by-one while holding the cleanup lock (which + * can take seconds on large caches), the clean uses an atomic rename to make the + * directory disappear in a single filesystem operation: + * + * 1. Acquire the cleanup lock and verify no other framework operation is active. + * 2. Clear cacache's in-process memoization (no path needed — global operation). + * 3. Atomically rename framework/ to a hidden staging dir. + * After this point the original path no longer exists: concurrent builds will + * see it as absent and create a fresh framework/ directory. + * 4. Release the cleanup lock immediately — it is now safe because the original + * path is gone and the staging dir is not referenced by any other component. + * 5. Delete the staging dir recursively outside the lock — its contents are + * now fully private to this operation. + * 6. Collect stats and scan for orphaned staging dirs from previous interrupted cleans. + * + * Orphaned staging dirs from previous interrupted runs are also collected and + * deleted, and their stats are included in the return value. + * + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise<{ + * path: string, + * libraryCount: number, + * versionCount: number, + * orphaned: Array<{path: string, libraryCount: number, versionCount: number}> + * }|null>} + * Removal result including orphaned-dir stats, or null if nothing was installed. * @throws {Error} If a framework operation is currently active (active lockfiles detected) */ export async function cleanCache(ui5DataDir) { - const stats = await getPackageStats(ui5DataDir); + const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); + const stats = await getPackageStats(frameworkDir); if (!stats) { - return null; + // Always clean up orphaned staging dirs from previously interrupted cleans, + // regardless of whether an active framework/ directory exists. Orphans can + // accumulate even after the framework has been fully cleaned. + const orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); + + if (orphaned.length > 0) { + return { + path: FRAMEWORK_DIR_NAME, + libraryCount: 0, + versionCount: 0, + orphaned + }; + } else { + // No active framework to clean — return null only if there were also no orphans. + // If orphans were cleaned, still return null (caller handles orphan-only reporting separately). + return null; + } } const lockDir = getLockDir(ui5DataDir); const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); - // Acquire first, then check — ensures installers running concurrently will see - // the cleanup lock and abort before writing into a directory being deleted. + // Acquire first, then check — ensures concurrent framework operations will see + // the cleanup lock and abort before we start the rename. const releaseCleanupLock = await acquireLock(lockPath); + try { if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { throw new Error( @@ -107,33 +219,47 @@ export async function cleanCache(ui5DataDir) { ); } - // Use cacache's own rm.all to clear the pacote download cache. - // This respects cacache's internal structure (content-v2/, index-v5/) - // and clears in-memory memoization, which a plain fs.rm would not do. - const caCacheDir = path.join(frameworkDir, "cacache"); + // Clear cacache's in-process memoization before the rename. + // clearMemoized() operates globally (no path argument) and is synchronous, + // so it is safe to call here before the directory moves. try { - await fs.access(caCacheDir); - const {rm: cacacheRm} = await import("cacache"); - await cacacheRm.all(caCacheDir); + const {clearMemoized} = await import("cacache"); + clearMemoized(); } catch { - // cacache dir doesn't exist or cacache not available — no-op + // cacache not available — no-op } - // Delete everything inside framework/ - const entries = await fs.readdir(frameworkDir, {withFileTypes: true}); - await Promise.all(entries.map((entry) => { - const curDir = path.join(frameworkDir, entry.name); - return entry.isDirectory() ? - fs.rm(curDir, {recursive: true, force: true}) : - fs.unlink(curDir); - })); - } finally { + // Atomically rename framework/ to a staging directory. + // fs.rename is a single syscall and completes in microseconds. + // After this line the original path no longer exists. + const stagingDir = path.join( + ui5DataDir, + `${STAGING_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` + ); + await fs.rename(frameworkDir, stagingDir); + + // Release the lock immediately after the atomic rename. + // The original path is gone — no concurrent installer can write into it. + // Holding the lock during the slow recursive deletion below is unnecessary + // and would block other operations for no safety benefit. releaseCleanupLock(); + + // Delete the staging directory. This is the slow part, + // but it is now outside the lock and fully private to this operation. + await fs.rm(stagingDir, {recursive: true, force: true}); + } catch (err) { + // Release the lock if we haven't already (i.e. an error occurred before the rename). + releaseCleanupLock(); + throw err; } + // Collect and delete orphaned staging dirs from previously interrupted cleans. + const orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); + return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, versionCount: stats.versions, + orphaned, }; } diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index 699fd309f0a..f62e9d6204a 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -1,9 +1,12 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; +import sinon from "sinon"; +import esmock from "esmock"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; +import {getLockDir, CLEANUP_LOCK_NAME} from "../../../lib/utils/lock.js"; const lockfileLock = promisify(lockfileLib.lock); const lockfileUnlock = promisify(lockfileLib.unlock); @@ -17,7 +20,7 @@ test.beforeEach(async (t) => { }); -// ─── Helper ────────────────────────────────────────────────────────────────── +// ─── Helpers ───────────────────────────────────────────────────────────────── async function mkPackage(testDir, project, library, version) { const dir = path.join(testDir, "framework", "packages", project, library, version); @@ -25,6 +28,12 @@ async function mkPackage(testDir, project, library, version) { await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); } +async function mkPackageIn(baseDir, project, library, version) { + const dir = path.join(baseDir, "packages", project, library, version); + await fs.mkdir(dir, {recursive: true}); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({name: `${project}/${library}`, version})); +} + // ─── getCacheInfo ───────────────────────────────────────────────────────────── test("getCacheInfo: non-existent framework directory returns null", async (t) => { @@ -91,7 +100,7 @@ test("cleanCache: returns null when packages/ has no installed libraries", async t.is(result, null); }); -test("cleanCache: removes framework directory and returns stats", async (t) => { +test("cleanCache: renames then removes framework directory and returns stats", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.120.0"); await mkPackage(t.context.testDir, "@openui5", "sap.ui.core", "1.148.0"); @@ -104,25 +113,38 @@ test("cleanCache: removes framework directory and returns stats", async (t) => { t.is(result.libraryCount, 2); t.is(result.versionCount, 2); // 1.120.0, 1.148.0 - // packages/ is removed so a subsequent getCacheInfo returns null - const packagesDir = path.join(frameworkDir, "packages"); - await t.throwsAsync(fs.access(packagesDir)); + // framework/ is gone — getCacheInfo returns null + t.is(await getCacheInfo(t.context.testDir), null); + + // No staging dirs remain after a successful clean + const entries = await fs.readdir(t.context.testDir); + t.false(entries.some((e) => e.startsWith(".framework_to_delete_")), + "no staging dirs remain after successful clean"); + + // packages/ is gone + await t.throwsAsync(fs.access(path.join(frameworkDir, "packages"))); }); test("cleanCache: removes directory with multiple scopes", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); await mkPackage(t.context.testDir, "@sapui5", "sap.m", "1.38.1"); - const frameworkDir = path.join(t.context.testDir, "framework"); const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.libraryCount, 1); // sap.m deduplicated t.is(result.versionCount, 2); - // packages/ is removed so a subsequent getCacheInfo returns null - const packagesDir = path.join(frameworkDir, "packages"); - await t.throwsAsync(fs.access(packagesDir)); + t.is(await getCacheInfo(t.context.testDir), null); +}); + +test("cleanCache: returns empty orphaned array when no orphans exist", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.deepEqual(result.orphaned, [], "orphaned array is empty when no staging dirs exist"); }); test("cleanCache: throws when active lockfiles exist", async (t) => { @@ -153,15 +175,133 @@ test("cleanCache: removes directory when lockfiles are stale", async (t) => { await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk await new Promise((resolve) => setTimeout(resolve, 100)); - const frameworkDir = path.join(t.context.testDir, "framework"); const result = await cleanCache(t.context.testDir); t.truthy(result); t.is(result.path, "framework"); t.is(result.libraryCount, 1); t.is(result.versionCount, 1); + t.is(await getCacheInfo(t.context.testDir), null); +}); + +test("cleanCache: lock is released immediately after rename, before deletion completes", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const lockDir = getLockDir(t.context.testDir); + const cleanupLockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + let lockReleasedBeforeDeletion = false; + + // Load a version of cleanCache with a stubbed fs.rm that checks the lock state + // at the moment the staging dir deletion begins. Uses esmock so the stub is + // injected into the module's own import — not a monkey-patch on the shared fs instance. + const rmStub = sinon.stub().callsFake(async (p, opts) => { + try { + await fs.access(cleanupLockPath); + // Lock file still exists — not yet released + } catch { + // Lock file is gone — released before deletion + lockReleasedBeforeDeletion = true; + } + return fs.rm(p, opts); + }); + + const {cleanCache: cleanCacheMocked} = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + await cleanCacheMocked(t.context.testDir); + } finally { + esmock.purge(cleanCacheMocked); + } + + t.true(lockReleasedBeforeDeletion, + "cleanup lock is released before the staging directory deletion begins"); +}); + +// ─── cleanCache: orphaned staging dir handling ──────────────────────────────── + +test("cleanCache: detects and removes orphaned staging dirs, reports them in result", async (t) => { + // The primary framework to clean + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + // Simulate a staging dir left by an interrupted previous clean + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.110.0"); + + const result = await cleanCache(t.context.testDir); + + t.truthy(result); + t.is(result.path, "framework"); - // packages/ is removed so a subsequent getCacheInfo returns null - const packagesDir = path.join(frameworkDir, "packages"); - await t.throwsAsync(fs.access(packagesDir)); + // Orphaned dir is reported in the result + t.is(result.orphaned.length, 1, "one orphaned dir reported"); + const orphanResult = result.orphaned[0]; + t.true(orphanResult.path.startsWith(".framework_to_delete_"), "orphan path has staging prefix"); + t.is(orphanResult.libraryCount, 1); + t.is(orphanResult.versionCount, 2); + + // Both directories are physically gone + await t.throwsAsync(fs.access(orphanDir), {code: "ENOENT"}, "orphaned staging dir removed"); + await t.throwsAsync(fs.access(path.join(t.context.testDir, "framework")), {code: "ENOENT"}, + "primary framework dir removed"); +}); + +test("cleanCache: removes multiple orphaned staging dirs and reports each", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + const orphan1 = path.join(t.context.testDir, ".framework_to_delete_1111"); + const orphan2 = path.join(t.context.testDir, ".framework_to_delete_2222"); + + await mkPackageIn(orphan1, "@openui5", "sap.m", "1.90.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.91.0"); + await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.92.0"); + + const result = await cleanCache(t.context.testDir); + + t.is(result.orphaned.length, 2, "two orphaned dirs reported"); + + // Sort by path for deterministic assertions + const sorted = [...result.orphaned].sort((a, b) => a.path.localeCompare(b.path)); + t.is(sorted[0].libraryCount, 1); + t.is(sorted[0].versionCount, 1); + t.is(sorted[1].libraryCount, 1); + t.is(sorted[1].versionCount, 2); + + await t.throwsAsync(fs.access(orphan1), {code: "ENOENT"}); + await t.throwsAsync(fs.access(orphan2), {code: "ENOENT"}); +}); + +test("cleanCache: orphaned dir deletion failure is non-fatal", async (t) => { + await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); + + // Create an orphan + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_fail"); + await mkPackageIn(orphanDir, "@openui5", "sap.m", "1.80.0"); + + // Use esmock to inject an fs.rm stub that fails only for the orphan dir, + // ensuring the stub intercepts calls inside cache.js (not the test's own fs binding). + const rmStub = sinon.stub().callsFake(async (p, opts) => { + if (p === orphanDir) { + throw new Error("simulated deletion failure"); + } + return fs.rm(p, opts); + }); + + const {cleanCache: cleanCacheMocked} = await esmock.p( + "../../../lib/ui5Framework/cache.js", + {"node:fs/promises": {...fs, rm: rmStub}} + ); + + try { + // Should not throw — orphan deletion failure is swallowed + const result = await t.notThrowsAsync(cleanCacheMocked(t.context.testDir)); + t.truthy(result, "cleanCache completes despite orphan deletion failure"); + } finally { + esmock.purge(cleanCacheMocked); + // Cleanup the orphan manually since the stub prevented deletion + await fs.rm(orphanDir, {recursive: true, force: true}).catch(() => {}); + } }); From de4f67b798ca341838bf12b40ad24d7c271f58c4 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 09:49:40 +0300 Subject: [PATCH 072/114] docs: Explain why we lock the graph in enrichProjectGraph --- packages/project/lib/graph/helpers/ui5Framework.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/project/lib/graph/helpers/ui5Framework.js b/packages/project/lib/graph/helpers/ui5Framework.js index e24efdc2d51..f531619fd0b 100644 --- a/packages/project/lib/graph/helpers/ui5Framework.js +++ b/packages/project/lib/graph/helpers/ui5Framework.js @@ -289,6 +289,9 @@ export default { * Promise resolving with the given graph instance to allow method chaining */ enrichProjectGraph: async function(projectGraph, options = {}) { + // Lock the graph so the entire lifecycle from this point — framework resolution, + // build, and serve — is treated as atomic by ui5 cache clean. Without this lock, + // cache clean could wipe framework packages or build data that the graph is actively using. await projectGraph._preventCacheClean(); const {workspace, snapshotCache} = options; From 42e647d3edfe47e03bbf0ef63b948695dc2a5290 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 09:53:25 +0300 Subject: [PATCH 073/114] docs: Correctly address params in JSDoc --- packages/project/lib/graph/ProjectGraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 59d4dcdba36..f7c2e4f26d2 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -28,7 +28,7 @@ class ProjectGraph { * @public * @param {object} parameters Parameters * @param {string} parameters.rootProjectName Root project name - * @param {string} [parameters.ui5DataDir] Explicit UI5 data directory to use for the build cache & locks. + * @param {string} parameters.ui5DataDir Explicit UI5 data directory to use for the build cache & locks. * Overrides the UI5_DATA_DIR environment variable, the UI5 configuration file, * and the default of ~/.ui5. */ From fa9e7abb2823997a5603f27cf481520cbcbb75ab Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 09:58:41 +0300 Subject: [PATCH 074/114] docs: Refactor _preventCacheClean JSDoc block --- packages/project/lib/graph/ProjectGraph.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index f7c2e4f26d2..aec0c78b745 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -708,16 +708,17 @@ class ProjectGraph { /** * Acquires a process-coordination lock scoped to this graph instance to prevent * concurrent ui5 cache clean operations from running while framework - * packages are being downloaded or the build/serve lifecycle is active. + * packages are being downloaded, or while those packages are actively referenced + * within the graph during the build/serve lifecycle. + * + * This is necessary because the graph holds references to framework package paths + * throughout its lifetime — not just during downloads. A cache clean after the download + * completes, but before the build/serve finishes, would delete files the graph is using. * * If a cache clean is already in progress, polls until it finishes (up to 10 s) * before acquiring the graph lock. The double-check after acquiring guards the * narrow window between the poll and the lock acquisition. Throws if a cache * clean is still active after that window. - * - * Called by projectGraphBuilder immediately after construction so the lock is - * in place before any framework downloads or build/serve work begins. - * The lock is released by {@link destroy}. */ async _preventCacheClean() { const lockDir = getLockDir(this._ui5DataDir); From 929ac6cf76674ade50d2f16f714f2463e1df6995 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 10:35:14 +0300 Subject: [PATCH 075/114] refactor: Explicit destruction of the ProjectGraph where needed --- packages/cli/lib/cli/commands/tree.js | 3 +++ packages/project/lib/build/BuildServer.js | 2 ++ packages/project/lib/graph/ProjectGraph.js | 8 +++++++- packages/project/test/lib/build/BuildServer.js | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/tree.js b/packages/cli/lib/cli/commands/tree.js index 3c2ec93c3e6..a48b86c8ff2 100644 --- a/packages/cli/lib/cli/commands/tree.js +++ b/packages/cli/lib/cli/commands/tree.js @@ -188,6 +188,9 @@ tree.handler = async function(argv) { `Dependency graph generation took ${chalk.bold(elapsedTime)}`)); process.stderr.write("\n"); } + + // Release the process-coordination lock held by the graph + graph.destroy(); }; async function getElapsedTime(startTime) { diff --git a/packages/project/lib/build/BuildServer.js b/packages/project/lib/build/BuildServer.js index 22cc4bd873a..e460d13fa9e 100644 --- a/packages/project/lib/build/BuildServer.js +++ b/packages/project/lib/build/BuildServer.js @@ -199,6 +199,8 @@ class BuildServer extends EventEmitter { // (e.g. Force-mode stale-cache errors). Otherwise the SQLite handle leaks // and subsequent fs.rm of the cache directory fails with EBUSY on Windows. this.#projectBuilder.closeCacheManager(); + // Explicitly destroy the ProjectGraph to release any process-coordination locks + this.#graph.destroy(); } } diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index aec0c78b745..b83670f788f 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -807,11 +807,17 @@ class ProjectGraph { }, ui5DataDir: this._ui5DataDir, }); - return await builder.buildToTarget({ + + const result = await builder.buildToTarget({ destPath, cleanDest, includedDependencies, excludedDependencies, dependencyIncludes, }); + + // Explicitly release the process-coordination lock now that the build has finished + this.destroy(); + + return result; } async serve({ diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 9e9200a978d..28f018a16d1 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -68,6 +68,7 @@ test.beforeEach(async (t) => { traverseDependents: function* (_projectName) { yield {project: rootProject}; }, + destroy: sinon.stub(), }; t.context.projectBuilder = { closeCacheManager: sinon.stub(), From 7f3e2cc1370f716eb93aac0dd0474cdd0461edc0 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 11:02:16 +0300 Subject: [PATCH 076/114] fix: Protect _preventCacheClean from overwriting the locks --- packages/project/lib/graph/ProjectGraph.js | 9 ++- .../project/test/lib/graph/ProjectGraph.js | 67 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index b83670f788f..3ec16f81e0e 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -721,6 +721,11 @@ class ProjectGraph { * clean is still active after that window. */ async _preventCacheClean() { + // If already locked (e.g. enrichProjectGraph already called this), reuse the existing lock. + if (this.#lockRelease) { + return; + } + const lockDir = getLockDir(this._ui5DataDir); // Acquire our lock so any cache clean that starts now will detect us and abort. // First acquire, then lock, so that the await of hasActiveLocks does not open a window for a race condition. @@ -785,7 +790,7 @@ class ProjectGraph { outputStyle = OutputStyleEnum.Default, cache = Cache.Default }) { - this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being built. + await this._preventCacheClean(); this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -827,7 +832,7 @@ class ProjectGraph { includedTasks = [], excludedTasks = [], cache = Cache.Default }) { - this._preventCacheClean(); // Prevent concurrent cache clean operations while the graph is being served + await this._preventCacheClean(); this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index a7a9b97e402..3c02dcddf6b 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -2031,3 +2031,70 @@ test("Seal/isSealed", async (t) => { const extension = graph.getExtension("extension.b"); t.is(extension, undefined, "extension.b should not be added"); }); + +test.serial("_preventCacheClean(): calling it multiple times reuses the same lock", async (t) => { + const sinon = t.context.sinon; + + const releaseSpy = sinon.spy(); + const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLockSync: acquireLockSyncStub, + getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + }, + }); + + const graph = new ProjectGraph({rootProjectName: "test", ui5DataDir: TEST_UI5_DATA_DIR}); + + await graph._preventCacheClean(); + await graph._preventCacheClean(); + await graph._preventCacheClean(); + + t.is(acquireLockSyncStub.callCount, 1, + "acquireLockSync called exactly once — subsequent calls reuse the existing lock"); + t.is(releaseSpy.callCount, 0, "lock not released between calls"); + + esmock.purge(ProjectGraph); +}); + +test.serial("_preventCacheClean(): after destroy() releases the lock, a new call acquires a fresh one", async (t) => { + const sinon = t.context.sinon; + + const releaseSpy = sinon.spy(); + const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + + const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { + "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, + "../../../lib/utils/lock.js": { + acquireLockSync: acquireLockSyncStub, + getLockDir: sinon.stub().returns("/test/locks"), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", + hasActiveLocks: sinon.stub().resolves(false), + }, + }); + + const graph = new ProjectGraph({rootProjectName: "test", ui5DataDir: TEST_UI5_DATA_DIR}); + + // First acquisition + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 1, "lock acquired on first call"); + + // Repeated call — still the same lock, not re-acquired + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 1, "no second acquisition while lock is held"); + + // Release via destroy() + graph.destroy(); + t.is(releaseSpy.callCount, 1, "lock released by destroy()"); + + // After release, _preventCacheClean() must acquire a fresh lock + await graph._preventCacheClean(); + t.is(acquireLockSyncStub.callCount, 2, + "new lock acquired after destroy() — lock is not held anymore"); + + esmock.purge(ProjectGraph); +}); From 15f0a51167f810a03e6bbd2c7df4a9af3525de33 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 11:20:09 +0300 Subject: [PATCH 077/114] docs: Update LOCK_REFRESH_INTERVAL_MS description --- packages/project/lib/utils/lock.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 77d50508e1c..1dc562d9ac0 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -16,8 +16,6 @@ export const LOCK_STALE_MS = 60000; /** * Interval at which long-lived graph locks refresh their mtime. * Must be less than LOCK_STALE_MS to keep the lock always within the freshness window. - * Must not be even of LOCK_STALE_MS to avoid a race condition where a lock is refreshed - * at the same time another process checks for staleness. */ export const LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6; From bf9b8548323a49e4970ad7686cca10b4e23abe8f Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 11:49:21 +0300 Subject: [PATCH 078/114] refactor: Try to explain better orphaned + framework dirs cleanup order --- packages/project/lib/ui5Framework/cache.js | 23 +++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index 0013ecc0e12..f8759e9bcb5 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -184,10 +184,12 @@ async function cleanupOrphanedStagingDirs(ui5DataDir) { export async function cleanCache(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); const stats = await getPackageStats(frameworkDir); + // No active framework to clean. Exit early, + // but there may be orphaned staging dirs from previous interrupted cleans. if (!stats) { - // Always clean up orphaned staging dirs from previously interrupted cleans, - // regardless of whether an active framework/ directory exists. Orphans can - // accumulate even after the framework has been fully cleaned. + // No need to lock here as the orphaned cleanup is and safe to run concurrently + // and independently of other operations. + // Note: In case of orphaned dirs have occupied much space, this cleanup might take a while. const orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); if (orphaned.length > 0) { @@ -210,7 +212,7 @@ export async function cleanCache(ui5DataDir) { // Acquire first, then check — ensures concurrent framework operations will see // the cleanup lock and abort before we start the rename. const releaseCleanupLock = await acquireLock(lockPath); - + let orphaned = []; try { if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { throw new Error( @@ -244,18 +246,21 @@ export async function cleanCache(ui5DataDir) { // and would block other operations for no safety benefit. releaseCleanupLock(); - // Delete the staging directory. This is the slow part, - // but it is now outside the lock and fully private to this operation. + // Delete the staging directory. Do not delegate to cleanupOrphanedStagingDirs() + // as this is the target dir we want to delete and the summary should not include it in the orphaned list. await fs.rm(stagingDir, {recursive: true, force: true}); + + // Cleanup any orphaned staging directories from previous interrupted cleans. + // This operation is NOT prior the rename of the framework directory, because + // we want to release the lock as soon as possible after the rename. + // The orphaned cleanup is safe to run concurrently and independently of other operations. + orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); } catch (err) { // Release the lock if we haven't already (i.e. an error occurred before the rename). releaseCleanupLock(); throw err; } - // Collect and delete orphaned staging dirs from previously interrupted cleans. - const orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); - return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, From 964a885459239f5cc81e9a547306b5d6f7c95210 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 12:12:00 +0300 Subject: [PATCH 079/114] refactor: Optimize lock.js --- packages/project/lib/utils/lock.js | 9 +- packages/project/test/lib/utils/lock.js | 104 ++++++++++++++++-------- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 1dc562d9ac0..d26ca4c67ec 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -3,6 +3,8 @@ import {readdir, mkdir} from "node:fs/promises"; import {mkdirSync, utimesSync, existsSync} from "node:fs"; import {promisify} from "node:util"; import lockfile from "lockfile"; +const check = promisify(lockfile.check); +const unlock = promisify(lockfile.unlock); import {getLogger} from "@ui5/logger"; const log = getLogger("lock"); @@ -73,9 +75,6 @@ export async function hasActiveLocks(lockDir, {include, exclude} = {}) { return false; } - const check = promisify(lockfile.check); - const unlock = promisify(lockfile.unlock); - for (const lockFileName of lockFiles) { const lockPath = path.join(lockDir, lockFileName); const isLocked = await check(lockPath, {stale: LOCK_STALE_MS}); @@ -112,7 +111,9 @@ function resolveReleaseLockFn(lockPath) { let released = false; return function release() { - if (released) return; + if (released) { + return; + } released = true; clearInterval(interval); lockfile.unlockSync(lockPath); diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index b70374ef351..3edb15ffa4e 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -2,6 +2,7 @@ import test from "ava"; import path from "node:path"; import fs from "node:fs/promises"; import sinon from "sinon"; +import esmock from "esmock"; import {promisify} from "node:util"; import lockfileLib from "lockfile"; import { @@ -77,31 +78,48 @@ test.serial("acquireLockSync: release() is idempotent", (t) => { }); test.serial("acquireLockSync: release() stops the refresh interval", async (t) => { - const release = acquireLockSync(t.context.lockPath); - const statBefore = await fs.stat(t.context.lockPath); + const clock = sinon.useFakeTimers({toFake: ["setInterval", "clearInterval", "setTimeout"]}); + try { + const release = acquireLockSync(t.context.lockPath); + const statBefore = await fs.stat(t.context.lockPath); - // Release immediately — interval must stop - release(); + // Release immediately — interval must stop + release(); - // Wait two interval periods and confirm mtime did not advance - await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS * 2 + 200)); + // Advance past two interval periods — if the interval were still running it would + // call utimesSync on the (now deleted) file and throw an uncaught ENOENT. + await clock.tickAsync(LOCK_REFRESH_INTERVAL_MS * 2 + 1); - // Lock file is gone after release, so we re-check by confirming ENOENT (interval can't update a deleted file) - await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, - "lock file gone — interval has nothing to refresh"); - t.truthy(statBefore, "stat was readable before release"); + await t.throwsAsync(fs.access(t.context.lockPath), {code: "ENOENT"}, + "lock file gone — interval has nothing to refresh"); + t.truthy(statBefore, "stat was readable before release"); + } finally { + clock.restore(); + } }); test.serial("acquireLockSync: refresh interval keeps mtime fresh while lock is held", async (t) => { - const release = acquireLockSync(t.context.lockPath); + // Start fake timers at the current real time so new Date() in the interval + // returns a timestamp that advances beyond the file's creation mtime. + const clock = sinon.useFakeTimers({ + now: Date.now(), + toFake: ["setInterval", "clearInterval", "setTimeout", "Date"], + }); try { - const statBefore = await fs.stat(t.context.lockPath); - // Wait longer than one interval tick - await new Promise((resolve) => setTimeout(resolve, LOCK_REFRESH_INTERVAL_MS + 200)); - const statAfter = await fs.stat(t.context.lockPath); - t.true(statAfter.mtimeMs >= statBefore.mtimeMs, "mtime updated by refresh interval while lock is held"); + const release = acquireLockSync(t.context.lockPath); + try { + const statBefore = await fs.stat(t.context.lockPath); + // Advance past one interval tick — Date now returns a later time, so + // utimesSync sets a later mtime that fs.stat will reflect. + await clock.tickAsync(LOCK_REFRESH_INTERVAL_MS + 1); + const statAfter = await fs.stat(t.context.lockPath); + t.true(statAfter.mtimeMs >= statBefore.mtimeMs, + "mtime updated by refresh interval while lock is held"); + } finally { + release(); + } } finally { - release(); + clock.restore(); } }); @@ -178,17 +196,25 @@ test.serial( const staleLockPathA = path.join(t.context.testDir, "crashed-a.lock"); const staleLockPathB = path.join(t.context.testDir, "crashed-b.lock"); - // Create two lock files on disk to simulate orphans from crashed processes. await fs.writeFile(staleLockPathA, ""); await fs.writeFile(staleLockPathB, ""); - // Stub lockfile.check so both files are reported as stale (returns false). - // This avoids any reliance on filesystem timestamps or fake timers and - // keeps the test focused on the cleanup branch of hasActiveLocks. - const checkStub = sinon.stub(lockfileLib, "check").yields(null, false); + // lock.js captures `check` and `unlock` at module load time via promisify(). + // Stubbing lockfileLib.check after load has no effect — use esmock so lock.js + // captures the stubs when it first runs promisify() on the injected module. + const checkStub = sinon.stub().yields(null, false); + // Simulate lockfile.unlock deleting the file, as the real implementation does. + const unlockStub = sinon.stub().callsFake((lockPath, cb) => { + fs.unlink(lockPath).catch(() => {}).finally(() => cb(null)); + }); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); try { - const result = await hasActiveLocks(t.context.testDir); + const result = await hasActiveLocksWithStubs(t.context.testDir); t.false(result, "all locks are stale => returns false"); t.is(checkStub.callCount, 2, "check called once per lock file"); @@ -198,7 +224,7 @@ test.serial( await t.throwsAsync(fs.access(staleLockPathB), {code: "ENOENT"}, "crashed-b.lock removed by hasActiveLocks"); } finally { - checkStub.restore(); + esmock.purge(hasActiveLocksWithStubs); } }, ); @@ -209,12 +235,9 @@ test.serial( const staleLockPath = path.join(t.context.testDir, "stale.lock"); const activeLockPath = path.join(t.context.testDir, "active.lock"); - // Create both lock files on disk await fs.writeFile(staleLockPath, ""); await fs.writeFile(activeLockPath, ""); - // Stub lockfile.check: stale.lock => false (stale), active.lock => true (live). - // Using explicit path matchers avoids any reliance on readdir order. const checkStub = sinon.stub(lockfileLib, "check"); checkStub.withArgs(staleLockPath, sinon.match.any).yields(null, false); checkStub.withArgs(activeLockPath, sinon.match.any).yields(null, true); @@ -236,23 +259,27 @@ test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { const includedLockPath = path.join(t.context.testDir, "included.lock"); const otherLockPath = path.join(t.context.testDir, "other.lock"); - // Create lock files for both — only "included.lock" should be inspected. await fs.writeFile(includedLockPath, ""); await fs.writeFile(otherLockPath, ""); - const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + const checkStub = sinon.stub().yields(null, true); + const unlockStub = sinon.stub().yields(null); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); try { - const result = await hasActiveLocks(t.context.testDir, {include: "included.lock"}); + const result = await hasActiveLocksWithStubs(t.context.testDir, {include: "included.lock"}); t.true(result, "included lock detected as active"); - // Only the included lock should have been passed to lockfile.check t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); t.is(checkStub.firstCall.args[0], includedLockPath, "lockfile.check called with the included lock path only"); } finally { - checkStub.restore(); + esmock.purge(hasActiveLocksWithStubs); } }); @@ -263,18 +290,23 @@ test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { await fs.writeFile(excludedLockPath, ""); await fs.writeFile(otherLockPath, ""); - const checkStub = sinon.stub(lockfileLib, "check").yields(null, true); + const checkStub = sinon.stub().yields(null, true); + const unlockStub = sinon.stub().yields(null); + + const {hasActiveLocks: hasActiveLocksWithStubs} = await esmock.p( + "../../../lib/utils/lock.js", + {lockfile: {check: checkStub, unlock: unlockStub}} + ); try { - const result = await hasActiveLocks(t.context.testDir, {exclude: "excluded.lock"}); + const result = await hasActiveLocksWithStubs(t.context.testDir, {exclude: "excluded.lock"}); t.true(result, "the non-excluded lock is detected"); - // Only the non-excluded lock should have been passed to lockfile.check t.is(checkStub.callCount, 1, "lockfile.check called exactly once"); t.is(checkStub.firstCall.args[0], otherLockPath, "lockfile.check called with the non-excluded lock path only"); } finally { - checkStub.restore(); + esmock.purge(hasActiveLocksWithStubs); } }); From 9597031216d48968198025ec587ec89ba19dbd5b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 12:14:13 +0300 Subject: [PATCH 080/114] refactor: Fix wrong case for isCacheDbAvailable --- packages/project/lib/build/cache/CacheManager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 5c67bd94bcf..0c4b4530d9c 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -336,7 +336,7 @@ export default class CacheManager { * @param {string} dbDir Path to DB * @returns {Promise} True if the cache database exists and is accessible */ - static async #isCacheDBAvailable(dbDir) { + static async #isCacheDbAvailable(dbDir) { const dbPath = path.join(dbDir, "cache.db"); try { await access(dbPath); @@ -359,7 +359,7 @@ export default class CacheManager { */ static async getCacheInfo(ui5DataDir) { const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); - const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + const isAvailable = await CacheManager.#isCacheDbAvailable(dbDir); if (!isAvailable) { return null; } @@ -392,7 +392,7 @@ export default class CacheManager { */ static async cleanCache(ui5DataDir) { const dbDir = path.join(ui5DataDir, "buildCache", CACHE_VERSION); - const isAvailable = await CacheManager.#isCacheDBAvailable(dbDir); + const isAvailable = await CacheManager.#isCacheDbAvailable(dbDir); if (!isAvailable) { return null; } From 06ecfeb316122f2b3cdf0a5f6eec91407209eb2a Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 12:27:27 +0300 Subject: [PATCH 081/114] refactor: Resolve internally getLockDir in hasActiveLocks & remove redundant comments in cli/cache --- packages/cli/lib/cli/commands/cache.js | 7 +--- packages/cli/test/lib/cli/commands/cache.js | 1 - packages/project/lib/graph/ProjectGraph.js | 2 +- .../lib/ui5Framework/AbstractInstaller.js | 3 +- packages/project/lib/ui5Framework/cache.js | 2 +- packages/project/lib/utils/lock.js | 10 +++-- packages/project/test/lib/utils/lock.js | 41 ++++++++++--------- 7 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 6fae0adc07d..234c51b811d 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,7 +3,7 @@ import path from "node:path"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; -import {getLockDir, hasActiveLocks} from "@ui5/project/utils/lock"; +import {hasActiveLocks} from "@ui5/project/utils/lock"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -229,13 +229,10 @@ async function getConfirmation(argv) { } async function handleCache(argv) { - // Resolve UI5 data directory — uses the same resolution chain as ui5 build/serve: - // UI5_DATA_DIR env var → ui5DataDir config (~/.ui5rc) → default ~/.ui5 - // Relative paths are resolved against process.cwd() (project root when invoked from the project). const ui5DataDir = await resolveUi5DataDir(); // Abort early if a lock is active — before prompting the user - if (await hasActiveLocks(getLockDir(ui5DataDir))) { + if (await hasActiveLocks(ui5DataDir)) { process.stderr.write( `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + "Cannot clean the cache while it is in use. " + diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 04394d9bd4f..9aa8145c3e5 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -44,7 +44,6 @@ test.beforeEach(async (t) => { resolveUi5DataDir: t.context.resolveUi5DataDirStub, }, "@ui5/project/utils/lock": { - getLockDir: sinon.stub().callsFake((dir) => `${dir}/locks`), hasActiveLocks: t.context.hasActiveLocksStub, }, "@ui5/project/ui5Framework/cache": { diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 3ec16f81e0e..a7ec37f422c 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -737,7 +737,7 @@ class ProjectGraph { const POLL_INTERVAL_MS = 200; const TIMEOUT_MS = 10000; const deadline = Date.now() + TIMEOUT_MS; - while (await hasActiveLocks(lockDir, {include: CLEANUP_LOCK_NAME})) { + while (await hasActiveLocks(this._ui5DataDir, {include: CLEANUP_LOCK_NAME})) { if (Date.now() >= deadline) { this.#lockRelease.release(); this.#lockRelease = null; diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index c53b24cffd3..bf807e712d4 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -21,6 +21,7 @@ class AbstractInstaller { if (!ui5DataDir) { throw new Error(`Installer: Missing parameter "ui5DataDir"`); } + this._ui5DataDir = ui5DataDir; this._lockDir = getLockDir(ui5DataDir); } @@ -31,7 +32,7 @@ class AbstractInstaller { try { // Abort if cache cleanup is in progress. Checking after acquiring our lock // ensures cleanCache's hasActiveLocks scan will see us if both run concurrently. - if (await hasActiveLocks(this._lockDir, {include: CLEANUP_LOCK_NAME})) { + if (await hasActiveLocks(this._ui5DataDir, {include: CLEANUP_LOCK_NAME})) { throw new Error( "Framework cache is currently being cleaned. " + "Please wait for the cache clean operation to finish and try again." diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index f8759e9bcb5..bf8708c0ef4 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -214,7 +214,7 @@ export async function cleanCache(ui5DataDir) { const releaseCleanupLock = await acquireLock(lockPath); let orphaned = []; try { - if (await hasActiveLocks(lockDir, {exclude: CLEANUP_LOCK_NAME})) { + if (await hasActiveLocks(ui5DataDir, {exclude: CLEANUP_LOCK_NAME})) { throw new Error( "Framework cache is currently locked by an active operation. " + "Please wait for it to finish and try again." diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index d26ca4c67ec..2c8692b00c2 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -42,10 +42,11 @@ export function getLockDir(ui5DataDir) { } /** - * Check whether any active (non-stale) lockfiles exist in the given locks directory, - * indicating an ongoing download, installation, build, or server process. + * Check whether any active (non-stale) lockfiles exist in the locks directory + * for the given UI5 data directory, indicating an ongoing download, installation, + * build, or server process. * - * @param {string} lockDir Absolute path to a locks directory + * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @param {object} [options] * @param {string|string[]} [options.include] Only check these lock file names (allowlist). * If provided, only files in this list are considered. @@ -53,7 +54,8 @@ export function getLockDir(ui5DataDir) { * If provided, these files are excluded from the scan. * @returns {Promise} True if any matching non-stale lockfiles are held */ -export async function hasActiveLocks(lockDir, {include, exclude} = {}) { +export async function hasActiveLocks(ui5DataDir, {include, exclude} = {}) { + const lockDir = getLockDir(ui5DataDir); let entries; try { entries = await readdir(lockDir); diff --git a/packages/project/test/lib/utils/lock.js b/packages/project/test/lib/utils/lock.js index 3edb15ffa4e..b81df02d82b 100644 --- a/packages/project/test/lib/utils/lock.js +++ b/packages/project/test/lib/utils/lock.js @@ -20,10 +20,13 @@ const lockfileUnlock = promisify(lockfileLib.unlock); const TEST_DIR = path.join(import.meta.dirname, "..", "..", "..", "test", "tmp", "utils-lock"); test.beforeEach(async (t) => { - const testDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); - await fs.mkdir(testDir, {recursive: true}); - t.context.testDir = testDir; - t.context.lockPath = path.join(testDir, "test.lock"); + // ui5DataDir — hasActiveLocks derives lockDir = ui5DataDir/locks/ internally + const ui5DataDir = path.join(TEST_DIR, `${Date.now()}-${Math.random().toString(36).slice(2)}`); + const lockDir = path.join(ui5DataDir, "locks"); + await fs.mkdir(lockDir, {recursive: true}); + t.context.ui5DataDir = ui5DataDir; + t.context.lockDir = lockDir; + t.context.lockPath = path.join(lockDir, "test.lock"); }); test.afterEach.always(async (t) => { @@ -169,19 +172,19 @@ test.serial("acquireLock: waits for a contended lock without blocking", async (t // ─── hasActiveLocks ─────────────────────────────────────────────────────────── test.serial("hasActiveLocks: returns false when locks directory does not exist", async (t) => { - const missingDir = path.join(t.context.testDir, "does-not-exist"); + const missingDir = path.join(t.context.ui5DataDir, "does-not-exist"); t.false(await hasActiveLocks(missingDir), "no locks dir => no active locks"); }); test.serial("hasActiveLocks: returns false when locks directory is empty", async (t) => { - t.false(await hasActiveLocks(t.context.testDir), "empty dir => no active locks"); + t.false(await hasActiveLocks(t.context.ui5DataDir), "empty dir => no active locks"); }); test.serial("hasActiveLocks: returns true when an active (non-stale) lock is present", async (t) => { // Acquire a real lock so its filesystem timestamp is "now" const release = await acquireLockSync(t.context.lockPath); try { - t.true(await hasActiveLocks(t.context.testDir), "fresh lock detected as active"); + t.true(await hasActiveLocks(t.context.ui5DataDir), "fresh lock detected as active"); // Active locks must not be deleted by the scan await t.notThrowsAsync(fs.access(t.context.lockPath), "active lock preserved"); @@ -193,8 +196,8 @@ test.serial("hasActiveLocks: returns true when an active (non-stale) lock is pre test.serial( "hasActiveLocks: removes stale lock files left behind by crashed processes", async (t) => { - const staleLockPathA = path.join(t.context.testDir, "crashed-a.lock"); - const staleLockPathB = path.join(t.context.testDir, "crashed-b.lock"); + const staleLockPathA = path.join(t.context.lockDir, "crashed-a.lock"); + const staleLockPathB = path.join(t.context.lockDir, "crashed-b.lock"); await fs.writeFile(staleLockPathA, ""); await fs.writeFile(staleLockPathB, ""); @@ -214,7 +217,7 @@ test.serial( ); try { - const result = await hasActiveLocksWithStubs(t.context.testDir); + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir); t.false(result, "all locks are stale => returns false"); t.is(checkStub.callCount, 2, "check called once per lock file"); @@ -232,8 +235,8 @@ test.serial( test.serial( "hasActiveLocks: keeps active lock and removes stale neighbor in same scan", async (t) => { - const staleLockPath = path.join(t.context.testDir, "stale.lock"); - const activeLockPath = path.join(t.context.testDir, "active.lock"); + const staleLockPath = path.join(t.context.lockDir, "stale.lock"); + const activeLockPath = path.join(t.context.lockDir, "active.lock"); await fs.writeFile(staleLockPath, ""); await fs.writeFile(activeLockPath, ""); @@ -243,7 +246,7 @@ test.serial( checkStub.withArgs(activeLockPath, sinon.match.any).yields(null, true); try { - const result = await hasActiveLocks(t.context.testDir); + const result = await hasActiveLocks(t.context.ui5DataDir); t.true(result, "scan returns true because one lock is active"); @@ -256,8 +259,8 @@ test.serial( ); test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { - const includedLockPath = path.join(t.context.testDir, "included.lock"); - const otherLockPath = path.join(t.context.testDir, "other.lock"); + const includedLockPath = path.join(t.context.lockDir, "included.lock"); + const otherLockPath = path.join(t.context.lockDir, "other.lock"); await fs.writeFile(includedLockPath, ""); await fs.writeFile(otherLockPath, ""); @@ -271,7 +274,7 @@ test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { ); try { - const result = await hasActiveLocksWithStubs(t.context.testDir, {include: "included.lock"}); + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir, {include: "included.lock"}); t.true(result, "included lock detected as active"); @@ -284,8 +287,8 @@ test.serial("hasActiveLocks: honours include option (allowlist)", async (t) => { }); test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { - const excludedLockPath = path.join(t.context.testDir, "excluded.lock"); - const otherLockPath = path.join(t.context.testDir, "other.lock"); + const excludedLockPath = path.join(t.context.lockDir, "excluded.lock"); + const otherLockPath = path.join(t.context.lockDir, "other.lock"); await fs.writeFile(excludedLockPath, ""); await fs.writeFile(otherLockPath, ""); @@ -299,7 +302,7 @@ test.serial("hasActiveLocks: honours exclude option (denylist)", async (t) => { ); try { - const result = await hasActiveLocksWithStubs(t.context.testDir, {exclude: "excluded.lock"}); + const result = await hasActiveLocksWithStubs(t.context.ui5DataDir, {exclude: "excluded.lock"}); t.true(result, "the non-excluded lock is detected"); From e8bccdc5af8cb1f71fc7f11b56995083bbdfaee5 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 12:34:44 +0300 Subject: [PATCH 082/114] refactor: Promisify per module --- packages/project/lib/utils/lock.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 2c8692b00c2..3d71ad65e9a 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -5,6 +5,7 @@ import {promisify} from "node:util"; import lockfile from "lockfile"; const check = promisify(lockfile.check); const unlock = promisify(lockfile.unlock); +const lock = promisify(lockfile.lock); import {getLogger} from "@ui5/logger"; const log = getLogger("lock"); @@ -171,6 +172,6 @@ export function acquireLockSync(lockPath, {retries} = {}) { export async function acquireLock(lockPath, {wait, retries} = {}) { log.verbose(`Locking ${lockPath}`); await mkdir(path.dirname(lockPath), {recursive: true}); - await promisify(lockfile.lock)(lockPath, {stale: LOCK_STALE_MS, wait, retries}); + await lock(lockPath, {stale: LOCK_STALE_MS, wait, retries}); return resolveReleaseLockFn(lockPath); } From f846ddfc40a816ebb346c39e1400b41db18afac7 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 13:25:18 +0300 Subject: [PATCH 083/114] refactor: Consolidate *Ui5DataDir implementation We don't need to adjust the code in Resolvers / installers as they will always now receive the resolved ui5DataDir. But as they are exported from the package, the guarded code must stay i.e. `path.resolve(ui5DataDir || path.join(os.homedir(), ".ui5"))` --- packages/cli/lib/framework/utils.js | 18 +---- packages/cli/test/lib/framework/utils.js | 83 ++++-------------------- 2 files changed, 15 insertions(+), 86 deletions(-) diff --git a/packages/cli/lib/framework/utils.js b/packages/cli/lib/framework/utils.js index 799c8a35253..0599b824e47 100644 --- a/packages/cli/lib/framework/utils.js +++ b/packages/cli/lib/framework/utils.js @@ -1,6 +1,5 @@ -import path from "node:path"; import {graphFromStaticFile, graphFromPackageDependencies} from "@ui5/project/graph"; -import Configuration from "@ui5/project/config/Configuration"; +import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; export async function getRootProjectConfiguration(projectGraphOptions) { let graph; @@ -37,7 +36,7 @@ export async function createFrameworkResolverInstance({frameworkName, frameworkV return new Resolver({ cwd, version: frameworkVersion, - ui5DataDir: await utils.getUi5DataDir({cwd}) + ui5DataDir: await resolveUi5DataDir() }); } @@ -45,26 +44,15 @@ export async function frameworkResolverResolveVersion({frameworkName, frameworkV const Resolver = await utils.getFrameworkResolver(frameworkName, frameworkVersion); return Resolver.resolveVersion(frameworkVersion, { cwd, - ui5DataDir: await utils.getUi5DataDir({cwd}) + ui5DataDir: await resolveUi5DataDir() }); } -async function getUi5DataDir({cwd}) { - // ENV var should take precedence over the dataDir from the configuration. - let ui5DataDir = process.env.UI5_DATA_DIR; - if (!ui5DataDir) { - const config = await Configuration.fromFile(); - ui5DataDir = config.getUi5DataDir(); - } - return ui5DataDir ? path.resolve(cwd, ui5DataDir) : undefined; -} - const utils = { getRootProjectConfiguration, getFrameworkResolver, createFrameworkResolverInstance, frameworkResolverResolveVersion, - getUi5DataDir }; let _utils; // For mocking of functions in unit tests and testing internal functions diff --git a/packages/cli/test/lib/framework/utils.js b/packages/cli/test/lib/framework/utils.js index 0ac6f4aaf37..1094bce26d9 100644 --- a/packages/cli/test/lib/framework/utils.js +++ b/packages/cli/test/lib/framework/utils.js @@ -1,7 +1,6 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import path from "node:path"; test.beforeEach(async (t) => { // Tests either rely on not having UI5_DATA_DIR defined, or explicitly define it @@ -21,12 +20,7 @@ test.beforeEach(async (t) => { t.context.Openui5Resolver = sinon.stub(); t.context.Sapui5MavenSnapshotResolver = sinon.stub(); - t.context.ConfigurationGetUi5DataDirStub = sinon.stub().returns(undefined); - t.context.ConfigurationStub = { - fromFile: sinon.stub().resolves({ - getUi5DataDir: t.context.ConfigurationGetUi5DataDirStub - }) - }; + t.context.resolveUi5DataDirStub = sinon.stub().resolves(undefined); t.context.utils = await esmock.p("../../../lib/framework/utils.js", { "@ui5/project/graph": { @@ -42,7 +36,9 @@ test.beforeEach(async (t) => { "@ui5/project/ui5Framework/Sapui5MavenSnapshotResolver": { default: t.context.Sapui5MavenSnapshotResolver }, - "@ui5/project/config/Configuration": t.context.ConfigurationStub + "@ui5/project/utils/dataDir": { + resolveUi5DataDir: t.context.resolveUi5DataDirStub + } }); t.context._utils = t.context.utils._utils; }); @@ -144,11 +140,11 @@ test.serial("getFrameworkResolver: Invalid framework.name", async (t) => { test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => { const {createFrameworkResolverInstance} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const ResolverStub = sinon.stub().returns({}); sinon.stub(t.context._utils, "getFrameworkResolver").resolves(ResolverStub); - sinon.stub(t.context._utils, "getUi5DataDir").resolves(undefined); + resolveUi5DataDirStub.resolves(undefined); const result = await createFrameworkResolverInstance({ frameworkName: "", @@ -163,12 +159,7 @@ test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => "" ]); - t.is(t.context._utils.getUi5DataDir.callCount, 1); - t.deepEqual(t.context._utils.getUi5DataDir.getCall(0).args, [ - { - cwd: "my-project-path" - } - ]); + t.is(resolveUi5DataDirStub.callCount, 1); t.is(ResolverStub.callCount, 1); t.is(result, ResolverStub.getCall(0).returnValue); @@ -184,11 +175,11 @@ test.serial("createFrameworkResolverInstance: Without ui5DataDir", async (t) => test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { const {createFrameworkResolverInstance} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const ResolverStub = sinon.stub().returns({}); sinon.stub(t.context._utils, "getFrameworkResolver").resolves(ResolverStub); - sinon.stub(t.context._utils, "getUi5DataDir").resolves("my-ui5-data-dir"); + resolveUi5DataDirStub.resolves("my-ui5-data-dir"); const result = await createFrameworkResolverInstance({ frameworkName: "", @@ -203,12 +194,7 @@ test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { "" ]); - t.is(t.context._utils.getUi5DataDir.callCount, 1); - t.deepEqual(t.context._utils.getUi5DataDir.getCall(0).args, [ - { - cwd: "my-project-path" - } - ]); + t.is(resolveUi5DataDirStub.callCount, 1); t.is(ResolverStub.callCount, 1); t.is(result, ResolverStub.getCall(0).returnValue); @@ -224,13 +210,13 @@ test.serial("createFrameworkResolverInstance: With ui5DataDir", async (t) => { test.serial("frameworkResolverResolveVersion", async (t) => { const {frameworkResolverResolveVersion} = t.context.utils; - const {sinon} = t.context; + const {sinon, resolveUi5DataDirStub} = t.context; const resolveVersionStub = sinon.stub().resolves("1.111.1"); sinon.stub(t.context._utils, "getFrameworkResolver").resolves({ resolveVersion: resolveVersionStub }); - sinon.stub(t.context._utils, "getUi5DataDir").resolves(undefined); + resolveUi5DataDirStub.resolves(undefined); const result = await frameworkResolverResolveVersion({ frameworkName: "SAPUI5", @@ -250,48 +236,3 @@ test.serial("frameworkResolverResolveVersion", async (t) => { } ]); }); - -test.serial("getUi5DataDir: no value defined", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, undefined); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -}); - -test.serial("getUi5DataDir: from environment variable", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - // Environment variable must be preferred over configuration value - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - process.env.UI5_DATA_DIR = ".ui5-data-dir-from-env-variable"; - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-env-variable")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 0); -}); - -test.serial("getUi5DataDir: from Configuration", async (t) => { - const {ConfigurationGetUi5DataDirStub} = t.context; - const {getUi5DataDir} = t.context._utils; - - ConfigurationGetUi5DataDirStub.returns(".ui5-data-dir-from-configuration"); - - const result = await getUi5DataDir({ - cwd: path.resolve("foo") - }); - - t.is(result, path.join(path.resolve("foo"), ".ui5-data-dir-from-configuration")); - - t.is(ConfigurationGetUi5DataDirStub.callCount, 1); -}); From 569cd0475b799553e3531a92e27e4917753417dd Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 13:38:39 +0300 Subject: [PATCH 084/114] test: Fix failing CI tests because of a missing stub --- packages/cli/test/lib/cli/commands/tree.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/test/lib/cli/commands/tree.js b/packages/cli/test/lib/cli/commands/tree.js index e08b338d236..657359cc7c6 100644 --- a/packages/cli/test/lib/cli/commands/tree.js +++ b/packages/cli/test/lib/cli/commands/tree.js @@ -33,6 +33,7 @@ test.beforeEach(async (t) => { traverseBreadthFirst: t.context.traverseBreadthFirst, getExtensionNames: t.context.getExtensionNames, getExtension: t.context.getExtension, + destroy: sinon.stub(), }; t.context.graph = { graphFromStaticFile: sinon.stub().resolves(fakeGraph), From 3ccf5725543eb5475c55f71ece34fd52cb4de09f Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 14:57:49 +0300 Subject: [PATCH 085/114] refactor: Lock on cache clean command --- packages/cli/lib/cli/commands/cache.js | 37 ++++- packages/cli/test/lib/cli/commands/cache.js | 22 ++- .../project/lib/build/cache/CacheManager.js | 13 ++ packages/project/lib/ui5Framework/cache.js | 123 ++++------------ .../project/test/lib/ui5framework/cache.js | 137 ++++-------------- 5 files changed, 115 insertions(+), 217 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 234c51b811d..ba4ab77520d 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -3,7 +3,7 @@ import path from "node:path"; import process from "node:process"; import baseMiddleware from "../middlewares/base.js"; import {resolveUi5DataDir} from "@ui5/project/utils/dataDir"; -import {hasActiveLocks} from "@ui5/project/utils/lock"; +import {acquireLock, CLEANUP_LOCK_NAME, getLockDir, hasActiveLocks} from "@ui5/project/utils/lock"; import * as frameworkCache from "@ui5/project/ui5Framework/cache"; import CacheManager from "@ui5/project/build/cache/CacheManager"; @@ -230,9 +230,16 @@ async function getConfirmation(argv) { async function handleCache(argv) { const ui5DataDir = await resolveUi5DataDir(); + const lockDir = getLockDir(ui5DataDir); + const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); + + // Acquire first, then check — ensures concurrent framework operations will see + // the cleanup lock and abort before the actual cleanup. + const releaseCleanupLock = await acquireLock(lockPath); // Abort early if a lock is active — before prompting the user if (await hasActiveLocks(ui5DataDir)) { + releaseCleanupLock(); // Release the lock before exiting process.stderr.write( `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + "Cannot clean the cache while it is in use. " + @@ -255,12 +262,13 @@ async function handleCache(argv) { const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; const buildPreSize = buildInfo?.size ?? 0; - const orphanedInfoWithAbsPaths = orphanedInfo.map( + const preCleanOrphanedInfo = orphanedInfo.map( (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) ); if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { process.stderr.write("Nothing to clean\n"); + releaseCleanupLock(); // Release the lock before exiting return; } @@ -270,18 +278,35 @@ async function handleCache(argv) { frameworkAbsPath, buildAbsPath, buildPreSize, - orphanedInfo: orphanedInfoWithAbsPaths, + orphanedInfo: preCleanOrphanedInfo, }); const confirmed = await getConfirmation(argv); if (!confirmed) { process.stderr.write("Cancelled\n"); + releaseCleanupLock(); // Release the lock before exiting return; } - // Perform the actual cleanup (orchestrate both domains) - const frameworkResult = await frameworkCache.cleanCache(ui5DataDir); - const buildResult = await CacheManager.cleanCache(ui5DataDir); + const [frameworkResult, buildResult] = await Promise.all([ + frameworkCache.cleanCache(ui5DataDir), + CacheManager.cleanCache(ui5DataDir), + ]); + + // Release the lock. Critical sections are done. + releaseCleanupLock(); + + // Clean additional resources that are safe to run outside the lock. + // For the framework cache this handles orphaned staging dirs from previous + // interrupted cleans. These are fully independent of any active operation. + const [additionalFrameworkResult] = await Promise.all([ + frameworkCache.cleanAdditional(ui5DataDir), + // The same interface. No-op + CacheManager.cleanAdditional(ui5DataDir), + ]); + const orphanedInfoWithAbsPaths = additionalFrameworkResult.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); await displayCleanupResult({ frameworkResult, diff --git a/packages/cli/test/lib/cli/commands/cache.js b/packages/cli/test/lib/cli/commands/cache.js index 9aa8145c3e5..20a652e96fa 100644 --- a/packages/cli/test/lib/cli/commands/cache.js +++ b/packages/cli/test/lib/cli/commands/cache.js @@ -30,9 +30,11 @@ test.beforeEach(async (t) => { t.context.resolveUi5DataDirStub = sinon.stub().resolves(TEST_UI5_DATA_DIR); t.context.hasActiveLocksStub = sinon.stub().resolves(false); + t.context.acquireLockStub = sinon.stub().resolves(() => {}); t.context.frameworkCacheGetCacheInfo = sinon.stub(); t.context.frameworkCacheCleanCache = sinon.stub(); + t.context.frameworkCacheCleanAdditional = sinon.stub().resolves([]); t.context.frameworkCacheGetOrphanedInfo = sinon.stub().resolves([]); t.context.buildCacheGetCacheInfo = sinon.stub(); t.context.buildCacheCleanCache = sinon.stub(); @@ -45,16 +47,21 @@ test.beforeEach(async (t) => { }, "@ui5/project/utils/lock": { hasActiveLocks: t.context.hasActiveLocksStub, + acquireLock: t.context.acquireLockStub, + getLockDir: sinon.stub().returns(path.join(TEST_UI5_DATA_DIR, "locks")), + CLEANUP_LOCK_NAME: "cache-cleanup.lock", }, "@ui5/project/ui5Framework/cache": { getCacheInfo: t.context.frameworkCacheGetCacheInfo, cleanCache: t.context.frameworkCacheCleanCache, + cleanAdditional: t.context.frameworkCacheCleanAdditional, getOrphanedInfo: t.context.frameworkCacheGetOrphanedInfo, }, "@ui5/project/build/cache/CacheManager": { default: class { static getCacheInfo = t.context.buildCacheGetCacheInfo; static cleanCache = t.context.buildCacheCleanCache; + static cleanAdditional = sinon.stub().resolves([]); } }, "yesno": { @@ -406,7 +413,7 @@ test.serial("ui5 cache clean: shows orphaned framework data in pre-confirmation test.serial("ui5 cache clean: shows orphaned framework data in post-clean summary", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, - frameworkCacheCleanCache} = t.context; + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; t.context.frameworkCacheGetCacheInfo.resolves({path: "framework", libraryCount: 3, versionCount: 1}); t.context.buildCacheGetCacheInfo.resolves(null); @@ -414,9 +421,11 @@ test.serial("ui5 cache clean: shows orphaned framework data in post-clean summar {path: ".framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, {path: ".framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, ]); - frameworkCacheCleanCache.resolves({ - path: "framework", libraryCount: 3, versionCount: 1, orphaned: [], - }); + frameworkCacheCleanCache.resolves({path: "framework", libraryCount: 3, versionCount: 1}); + frameworkCacheCleanAdditional.resolves([ + {path: ".framework_to_delete_ab12", libraryCount: 3, versionCount: 1}, + {path: ".framework_to_delete_cd34", libraryCount: 3, versionCount: 1}, + ]); argv["_"] = ["cache", "clean"]; argv["yes"] = true; @@ -431,7 +440,7 @@ test.serial("ui5 cache clean: shows orphaned framework data in post-clean summar test.serial("ui5 cache clean: shows orphaned-only success summary when no active framework", async (t) => { const {cache, argv, stderrWriteStub, frameworkCacheGetOrphanedInfo, - frameworkCacheCleanCache} = t.context; + frameworkCacheCleanCache, frameworkCacheCleanAdditional} = t.context; t.context.frameworkCacheGetCacheInfo.resolves(null); t.context.buildCacheGetCacheInfo.resolves(null); @@ -439,6 +448,9 @@ test.serial("ui5 cache clean: shows orphaned-only success summary when no active {path: ".framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, ]); frameworkCacheCleanCache.resolves(null); + frameworkCacheCleanAdditional.resolves([ + {path: ".framework_to_delete_zz99", libraryCount: 10, versionCount: 3}, + ]); argv["_"] = ["cache", "clean"]; argv["yes"] = true; diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 0c4b4530d9c..da44399f1cd 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -411,4 +411,17 @@ export default class CacheManager { } return null; } + + /** + * Clean additional build cache resources that are safe to remove independently + * of any active process-coordination lock. + * + * @static + * @param {string} _ui5DataDir Resolved absolute path to UI5 data directory + * @returns {Promise} + */ + static async cleanAdditional(_ui5DataDir) { + // No-op. Keep the cache clean interface aligned. + return []; + } } diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index bf8708c0ef4..ab9e91787ff 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import {getRandomValues} from "node:crypto"; -import {getLockDir, CLEANUP_LOCK_NAME, hasActiveLocks, acquireLock} from "../utils/lock.js"; const FRAMEWORK_DIR_NAME = "framework"; @@ -133,7 +132,7 @@ export async function getOrphanedInfo(ui5DataDir) { * @param {string} ui5DataDir Resolved absolute path to UI5 data directory * @returns {Promise>} */ -async function cleanupOrphanedStagingDirs(ui5DataDir) { +export async function cleanAdditional(ui5DataDir) { const orphans = await getOrphanedInfo(ui5DataDir); for (const orphan of orphans) { @@ -151,120 +150,50 @@ async function cleanupOrphanedStagingDirs(ui5DataDir) { /** * Clean the framework cache directory. * - * Strategy - * ──────── - * Rather than deleting files one-by-one while holding the cleanup lock (which - * can take seconds on large caches), the clean uses an atomic rename to make the - * directory disappear in a single filesystem operation: + * Uses an atomic rename to make the framework directory disappear in a single + * filesystem operation, then deletes the staging dir outside of any lock: * - * 1. Acquire the cleanup lock and verify no other framework operation is active. - * 2. Clear cacache's in-process memoization (no path needed — global operation). - * 3. Atomically rename framework/ to a hidden staging dir. + * 1. Clear cacache's in-process memoization (no path needed — global operation). + * 2. Atomically rename framework/ to a hidden staging dir. * After this point the original path no longer exists: concurrent builds will * see it as absent and create a fresh framework/ directory. - * 4. Release the cleanup lock immediately — it is now safe because the original - * path is gone and the staging dir is not referenced by any other component. - * 5. Delete the staging dir recursively outside the lock — its contents are - * now fully private to this operation. - * 6. Collect stats and scan for orphaned staging dirs from previous interrupted cleans. - * - * Orphaned staging dirs from previous interrupted runs are also collected and - * deleted, and their stats are included in the return value. + * 3. Delete the staging dir recursively. Its contents are now fully private + * to this operation. * * @param {string} ui5DataDir Resolved absolute path to UI5 data directory - * @returns {Promise<{ - * path: string, - * libraryCount: number, - * versionCount: number, - * orphaned: Array<{path: string, libraryCount: number, versionCount: number}> - * }|null>} - * Removal result including orphaned-dir stats, or null if nothing was installed. - * @throws {Error} If a framework operation is currently active (active lockfiles detected) + * @returns {Promise<{path: string, libraryCount: number, versionCount: number}|null>} + * Removal result, or null if no framework packages were installed. */ export async function cleanCache(ui5DataDir) { const frameworkDir = path.join(ui5DataDir, FRAMEWORK_DIR_NAME); const stats = await getPackageStats(frameworkDir); - // No active framework to clean. Exit early, - // but there may be orphaned staging dirs from previous interrupted cleans. if (!stats) { - // No need to lock here as the orphaned cleanup is and safe to run concurrently - // and independently of other operations. - // Note: In case of orphaned dirs have occupied much space, this cleanup might take a while. - const orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); - - if (orphaned.length > 0) { - return { - path: FRAMEWORK_DIR_NAME, - libraryCount: 0, - versionCount: 0, - orphaned - }; - } else { - // No active framework to clean — return null only if there were also no orphans. - // If orphans were cleaned, still return null (caller handles orphan-only reporting separately). - return null; - } + return null; } - const lockDir = getLockDir(ui5DataDir); - const lockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - - // Acquire first, then check — ensures concurrent framework operations will see - // the cleanup lock and abort before we start the rename. - const releaseCleanupLock = await acquireLock(lockPath); - let orphaned = []; + // Clear cacache's in-process memoization before the rename. + // clearMemoized() operates globally (no path argument) and is synchronous. try { - if (await hasActiveLocks(ui5DataDir, {exclude: CLEANUP_LOCK_NAME})) { - throw new Error( - "Framework cache is currently locked by an active operation. " + - "Please wait for it to finish and try again." - ); - } - - // Clear cacache's in-process memoization before the rename. - // clearMemoized() operates globally (no path argument) and is synchronous, - // so it is safe to call here before the directory moves. - try { - const {clearMemoized} = await import("cacache"); - clearMemoized(); - } catch { - // cacache not available — no-op - } - - // Atomically rename framework/ to a staging directory. - // fs.rename is a single syscall and completes in microseconds. - // After this line the original path no longer exists. - const stagingDir = path.join( - ui5DataDir, - `${STAGING_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` - ); - await fs.rename(frameworkDir, stagingDir); - - // Release the lock immediately after the atomic rename. - // The original path is gone — no concurrent installer can write into it. - // Holding the lock during the slow recursive deletion below is unnecessary - // and would block other operations for no safety benefit. - releaseCleanupLock(); + const {clearMemoized} = await import("cacache"); + clearMemoized(); + } catch { + // cacache not available — no-op + } - // Delete the staging directory. Do not delegate to cleanupOrphanedStagingDirs() - // as this is the target dir we want to delete and the summary should not include it in the orphaned list. - await fs.rm(stagingDir, {recursive: true, force: true}); + // Atomically rename framework/ to a staging directory. + // fs.rename is a single syscall and completes in microseconds. + // After this line the original path no longer exists. + const stagingDir = path.join( + ui5DataDir, + `${STAGING_DIR_PREFIX}${Buffer.from(getRandomValues(new Uint8Array(2))).toString("hex")}` + ); + await fs.rename(frameworkDir, stagingDir); - // Cleanup any orphaned staging directories from previous interrupted cleans. - // This operation is NOT prior the rename of the framework directory, because - // we want to release the lock as soon as possible after the rename. - // The orphaned cleanup is safe to run concurrently and independently of other operations. - orphaned = await cleanupOrphanedStagingDirs(ui5DataDir); - } catch (err) { - // Release the lock if we haven't already (i.e. an error occurred before the rename). - releaseCleanupLock(); - throw err; - } + await fs.rm(stagingDir, {recursive: true, force: true}); return { path: FRAMEWORK_DIR_NAME, libraryCount: stats.libraries, versionCount: stats.versions, - orphaned, }; } diff --git a/packages/project/test/lib/ui5framework/cache.js b/packages/project/test/lib/ui5framework/cache.js index f62e9d6204a..6aa849418b8 100644 --- a/packages/project/test/lib/ui5framework/cache.js +++ b/packages/project/test/lib/ui5framework/cache.js @@ -3,13 +3,7 @@ import path from "node:path"; import fs from "node:fs/promises"; import sinon from "sinon"; import esmock from "esmock"; -import {promisify} from "node:util"; -import lockfileLib from "lockfile"; -import {getCacheInfo, cleanCache} from "../../../lib/ui5Framework/cache.js"; -import {getLockDir, CLEANUP_LOCK_NAME} from "../../../lib/utils/lock.js"; - -const lockfileLock = promisify(lockfileLib.lock); -const lockfileUnlock = promisify(lockfileLib.unlock); +import {getCacheInfo, cleanCache, cleanAdditional} from "../../../lib/ui5Framework/cache.js"; const TEST_DIR = path.join(import.meta.dirname, "..", "..", "tmp", "ui5framework-cache"); @@ -138,120 +132,52 @@ test("cleanCache: removes directory with multiple scopes", async (t) => { t.is(await getCacheInfo(t.context.testDir), null); }); -test("cleanCache: returns empty orphaned array when no orphans exist", async (t) => { +test("cleanCache: does not include orphaned field in result", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); const result = await cleanCache(t.context.testDir); t.truthy(result); - t.deepEqual(result.orphaned, [], "orphaned array is empty when no staging dirs exist"); -}); - -test("cleanCache: throws when active lockfiles exist", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); - - const lockPath = path.join(lockDir, "test-package.lock"); - await lockfileLock(lockPath, {stale: 60000}); - try { - const err = await t.throwsAsync(cleanCache(t.context.testDir)); - t.true(err.message.includes("currently locked by an active operation")); - } finally { - await lockfileUnlock(lockPath); - } + t.false(Object.prototype.hasOwnProperty.call(result, "orphaned"), + "cleanCache result does not include orphaned — use cleanAdditional for that"); }); -test("cleanCache: removes directory when lockfiles are stale", async (t) => { +test("cleanCache: does not remove orphaned staging dirs — that is cleanAdditional's job", async (t) => { await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - const lockDir = path.join(t.context.testDir, "locks"); - await fs.mkdir(lockDir, {recursive: true}); + const orphanDir = path.join(t.context.testDir, ".framework_to_delete_abcd"); + await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); - // lockfile.check uses ctime — fs.utimes only changes mtime, so backdating mtime won't work. - const lockPath = path.join(lockDir, "stale-package.lock"); - await lockfileLock(lockPath, {stale: 50}); // stale after 50ms - await lockfileUnlock(lockPath); // unlock so ctime stops being "now" — file still exists on disk - await new Promise((resolve) => setTimeout(resolve, 100)); + await cleanCache(t.context.testDir); - const result = await cleanCache(t.context.testDir); - - t.truthy(result); - t.is(result.path, "framework"); - t.is(result.libraryCount, 1); - t.is(result.versionCount, 1); - t.is(await getCacheInfo(t.context.testDir), null); + // Orphan is still present after cleanCache — cleanAdditional handles it + await t.notThrowsAsync(fs.access(orphanDir), "orphaned dir is not touched by cleanCache"); }); -test("cleanCache: lock is released immediately after rename, before deletion completes", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - const lockDir = getLockDir(t.context.testDir); - const cleanupLockPath = path.join(lockDir, CLEANUP_LOCK_NAME); - let lockReleasedBeforeDeletion = false; - - // Load a version of cleanCache with a stubbed fs.rm that checks the lock state - // at the moment the staging dir deletion begins. Uses esmock so the stub is - // injected into the module's own import — not a monkey-patch on the shared fs instance. - const rmStub = sinon.stub().callsFake(async (p, opts) => { - try { - await fs.access(cleanupLockPath); - // Lock file still exists — not yet released - } catch { - // Lock file is gone — released before deletion - lockReleasedBeforeDeletion = true; - } - return fs.rm(p, opts); - }); - - const {cleanCache: cleanCacheMocked} = await esmock.p( - "../../../lib/ui5Framework/cache.js", - {"node:fs/promises": {...fs, rm: rmStub}} - ); - - try { - await cleanCacheMocked(t.context.testDir); - } finally { - esmock.purge(cleanCacheMocked); - } +// ─── cleanAdditional ────────────────────────────────────────────────────────── - t.true(lockReleasedBeforeDeletion, - "cleanup lock is released before the staging directory deletion begins"); +test("cleanAdditional: returns empty array when no orphaned staging dirs exist", async (t) => { + const result = await cleanAdditional(t.context.testDir); + t.deepEqual(result, []); }); -// ─── cleanCache: orphaned staging dir handling ──────────────────────────────── - -test("cleanCache: detects and removes orphaned staging dirs, reports them in result", async (t) => { - // The primary framework to clean - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - // Simulate a staging dir left by an interrupted previous clean +test("cleanAdditional: detects and removes orphaned staging dirs, reports them", async (t) => { const orphanDir = path.join(t.context.testDir, ".framework_to_delete_abcd"); await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.100.0"); await mkPackageIn(orphanDir, "@openui5", "sap.ui.core", "1.110.0"); - const result = await cleanCache(t.context.testDir); - - t.truthy(result); - t.is(result.path, "framework"); + const result = await cleanAdditional(t.context.testDir); - // Orphaned dir is reported in the result - t.is(result.orphaned.length, 1, "one orphaned dir reported"); - const orphanResult = result.orphaned[0]; + t.is(result.length, 1, "one orphaned dir reported"); + const orphanResult = result[0]; t.true(orphanResult.path.startsWith(".framework_to_delete_"), "orphan path has staging prefix"); t.is(orphanResult.libraryCount, 1); t.is(orphanResult.versionCount, 2); - // Both directories are physically gone await t.throwsAsync(fs.access(orphanDir), {code: "ENOENT"}, "orphaned staging dir removed"); - await t.throwsAsync(fs.access(path.join(t.context.testDir, "framework")), {code: "ENOENT"}, - "primary framework dir removed"); }); -test("cleanCache: removes multiple orphaned staging dirs and reports each", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - +test("cleanAdditional: removes multiple orphaned staging dirs and reports each", async (t) => { const orphan1 = path.join(t.context.testDir, ".framework_to_delete_1111"); const orphan2 = path.join(t.context.testDir, ".framework_to_delete_2222"); @@ -259,12 +185,11 @@ test("cleanCache: removes multiple orphaned staging dirs and reports each", asyn await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.91.0"); await mkPackageIn(orphan2, "@openui5", "sap.ui.core", "1.92.0"); - const result = await cleanCache(t.context.testDir); + const result = await cleanAdditional(t.context.testDir); - t.is(result.orphaned.length, 2, "two orphaned dirs reported"); + t.is(result.length, 2, "two orphaned dirs reported"); - // Sort by path for deterministic assertions - const sorted = [...result.orphaned].sort((a, b) => a.path.localeCompare(b.path)); + const sorted = [...result].sort((a, b) => a.path.localeCompare(b.path)); t.is(sorted[0].libraryCount, 1); t.is(sorted[0].versionCount, 1); t.is(sorted[1].libraryCount, 1); @@ -274,15 +199,10 @@ test("cleanCache: removes multiple orphaned staging dirs and reports each", asyn await t.throwsAsync(fs.access(orphan2), {code: "ENOENT"}); }); -test("cleanCache: orphaned dir deletion failure is non-fatal", async (t) => { - await mkPackage(t.context.testDir, "@openui5", "sap.m", "1.120.0"); - - // Create an orphan +test("cleanAdditional: orphaned dir deletion failure is non-fatal", async (t) => { const orphanDir = path.join(t.context.testDir, ".framework_to_delete_fail"); await mkPackageIn(orphanDir, "@openui5", "sap.m", "1.80.0"); - // Use esmock to inject an fs.rm stub that fails only for the orphan dir, - // ensuring the stub intercepts calls inside cache.js (not the test's own fs binding). const rmStub = sinon.stub().callsFake(async (p, opts) => { if (p === orphanDir) { throw new Error("simulated deletion failure"); @@ -290,18 +210,17 @@ test("cleanCache: orphaned dir deletion failure is non-fatal", async (t) => { return fs.rm(p, opts); }); - const {cleanCache: cleanCacheMocked} = await esmock.p( + const {cleanAdditional: cleanAdditionalMocked} = await esmock.p( "../../../lib/ui5Framework/cache.js", {"node:fs/promises": {...fs, rm: rmStub}} ); try { - // Should not throw — orphan deletion failure is swallowed - const result = await t.notThrowsAsync(cleanCacheMocked(t.context.testDir)); - t.truthy(result, "cleanCache completes despite orphan deletion failure"); + const result = await t.notThrowsAsync(cleanAdditionalMocked(t.context.testDir)); + t.truthy(result, "cleanAdditional completes despite orphan deletion failure"); } finally { - esmock.purge(cleanCacheMocked); - // Cleanup the orphan manually since the stub prevented deletion + esmock.purge(cleanAdditionalMocked); await fs.rm(orphanDir, {recursive: true, force: true}).catch(() => {}); } }); + From 0281bda4f0fa88b91399f0919aac24ed455960ec Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 15:32:21 +0300 Subject: [PATCH 086/114] refactor: Always release the cache clean lock --- packages/cli/lib/cli/commands/cache.js | 155 +++++++++++++------------ 1 file changed, 78 insertions(+), 77 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index ba4ab77520d..71d5857827b 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -100,10 +100,10 @@ function padLabel(label) { * and any orphaned staging directories from previously interrupted clean operations. * * @param {object} data - * @param {object} data.frameworkInfo - * @param {object} data.buildInfo - * @param {string} data.frameworkAbsPath - * @param {string} data.buildAbsPath + * @param {object|null} data.frameworkInfo + * @param {object|null} data.buildInfo + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath * @param {number} data.buildPreSize * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfo */ @@ -149,10 +149,10 @@ async function displayCacheInfo({ * and any orphaned staging directories that were also cleaned up. * * @param {object} data - * @param {object} data.frameworkResult - * @param {object} data.buildResult - * @param {string} data.frameworkAbsPath - * @param {string} data.buildAbsPath + * @param {object|null} data.frameworkResult + * @param {object|null} data.buildResult + * @param {string|null} data.frameworkAbsPath + * @param {string|null} data.buildAbsPath * @param {number} data.buildPreSize * @param {Array<{absPath: string, libraryCount: number, versionCount: number}>} data.orphanedInfoWithAbsPaths */ @@ -237,85 +237,86 @@ async function handleCache(argv) { // the cleanup lock and abort before the actual cleanup. const releaseCleanupLock = await acquireLock(lockPath); - // Abort early if a lock is active — before prompting the user - if (await hasActiveLocks(ui5DataDir)) { - releaseCleanupLock(); // Release the lock before exiting - process.stderr.write( - `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + - "Cannot clean the cache while it is in use. " + - "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" - ); - process.exitCode = 1; - return; - } + try { + // Abort early if a lock is active — before prompting the user. + if (await hasActiveLocks(ui5DataDir, {exclude: CLEANUP_LOCK_NAME})) { + process.stderr.write( + `${chalk.red("Error:")} A UI5 server or build process is currently running. ` + + "Cannot clean the cache while it is in use. " + + "Please stop all running 'ui5 serve' or wait for 'ui5 build' processes to finish.\n" + ); + process.exitCode = 1; + return; + } - // Inform the user immediately — getPackageStats may take a moment on a large cache - process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); + process.stderr.write(`Checking cache at ${chalk.bold(ui5DataDir)} …\n`); - const [frameworkInfo, buildInfo, orphanedInfo] = await Promise.all([ - frameworkCache.getCacheInfo(ui5DataDir), - CacheManager.getCacheInfo(ui5DataDir), - frameworkCache.getOrphanedInfo(ui5DataDir), - ]); + const [frameworkInfo, buildInfo, orphanedInfo] = await Promise.all([ + frameworkCache.getCacheInfo(ui5DataDir), + CacheManager.getCacheInfo(ui5DataDir), + frameworkCache.getOrphanedInfo(ui5DataDir), + ]); - // Compute absolute paths once — producers return relative sub-path segments - const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; - const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; - const buildPreSize = buildInfo?.size ?? 0; - const preCleanOrphanedInfo = orphanedInfo.map( - (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) - ); + // Compute absolute paths once — producers return relative sub-path segments + const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; + const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; + const buildPreSize = buildInfo?.size ?? 0; + const preCleanOrphanedInfo = orphanedInfo.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); - if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { - process.stderr.write("Nothing to clean\n"); - releaseCleanupLock(); // Release the lock before exiting - return; - } + if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } - await displayCacheInfo({ - frameworkInfo, - buildInfo, - frameworkAbsPath, - buildAbsPath, - buildPreSize, - orphanedInfo: preCleanOrphanedInfo, - }); + await displayCacheInfo({ + frameworkInfo, + buildInfo, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfo: preCleanOrphanedInfo, + }); - const confirmed = await getConfirmation(argv); - if (!confirmed) { - process.stderr.write("Cancelled\n"); - releaseCleanupLock(); // Release the lock before exiting - return; - } + const confirmed = await getConfirmation(argv); + if (!confirmed) { + process.stderr.write("Cancelled\n"); + return; + } - const [frameworkResult, buildResult] = await Promise.all([ - frameworkCache.cleanCache(ui5DataDir), - CacheManager.cleanCache(ui5DataDir), - ]); + const [frameworkResult, buildResult] = await Promise.all([ + frameworkCache.cleanCache(ui5DataDir), + CacheManager.cleanCache(ui5DataDir), + ]); - // Release the lock. Critical sections are done. - releaseCleanupLock(); + // Release the lock. Critical sections are done. + // The finally block will call releaseCleanupLock() again, which is a no-op (idempotent). + releaseCleanupLock(); - // Clean additional resources that are safe to run outside the lock. - // For the framework cache this handles orphaned staging dirs from previous - // interrupted cleans. These are fully independent of any active operation. - const [additionalFrameworkResult] = await Promise.all([ - frameworkCache.cleanAdditional(ui5DataDir), - // The same interface. No-op - CacheManager.cleanAdditional(ui5DataDir), - ]); - const orphanedInfoWithAbsPaths = additionalFrameworkResult.map( - (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) - ); + // Clean additional resources that are safe to run outside the lock. + // For the framework cache this handles orphaned staging dirs from previous + // interrupted cleans. These are fully independent of any active operation. + const [additionalFrameworkResult] = await Promise.all([ + frameworkCache.cleanAdditional(ui5DataDir), + // The same interface. No-op + CacheManager.cleanAdditional(ui5DataDir), + ]); + const orphanedInfoWithAbsPaths = additionalFrameworkResult.map( + (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) + ); - await displayCleanupResult({ - frameworkResult, - buildResult, - frameworkAbsPath, - buildAbsPath, - buildPreSize, - orphanedInfoWithAbsPaths, - }); + await displayCleanupResult({ + frameworkResult, + buildResult, + frameworkAbsPath, + buildAbsPath, + buildPreSize, + orphanedInfoWithAbsPaths, + }); + } finally { + releaseCleanupLock(); + } } export default cacheCommand; From 1c18d655363220348b391bbf91067be9456ef576 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 15:36:36 +0300 Subject: [PATCH 087/114] docs: Update comments --- packages/project/lib/graph/ProjectGraph.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index a7ec37f422c..9bed4a26592 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -727,8 +727,8 @@ class ProjectGraph { } const lockDir = getLockDir(this._ui5DataDir); - // Acquire our lock so any cache clean that starts now will detect us and abort. - // First acquire, then lock, so that the await of hasActiveLocks does not open a window for a race condition. + // Acquire our lock first, so any cache clean that starts concurrently will detect us and abort. + // Only then check whether a cache clean is already in progress — this order closes the race window. const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); this.#lockRelease = acquireLockSync(lockPath); From 3b9b38f1ab2206a213f7282dd89f384d03eab353 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 16:13:10 +0300 Subject: [PATCH 088/114] refactor: Reorganize output information --- packages/cli/lib/cli/commands/cache.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 71d5857827b..63bb7fcb1f4 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -200,12 +200,12 @@ async function displayCleanupResult({ if (frameworkResult) { cleaned.push(LABEL_FRAMEWORK); } + if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0) { + cleaned.push("Orphaned framework data"); + } if (buildResult) { cleaned.push(LABEL_BUILD); } - if (orphanedInfoWithAbsPaths && orphanedInfoWithAbsPaths.length > 0 && !frameworkResult) { - cleaned.push("Orphaned framework data"); - } process.stderr.write( `\n${chalk.green("Success:")} Cleaned ${cleaned.join(" and ")}\n`, ); @@ -257,6 +257,11 @@ async function handleCache(argv) { frameworkCache.getOrphanedInfo(ui5DataDir), ]); + if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { + process.stderr.write("Nothing to clean\n"); + return; + } + // Compute absolute paths once — producers return relative sub-path segments const frameworkAbsPath = frameworkInfo ? path.join(ui5DataDir, frameworkInfo.path) : null; const buildAbsPath = buildInfo ? path.join(ui5DataDir, buildInfo.path) : null; @@ -265,11 +270,6 @@ async function handleCache(argv) { (o) => ({...o, absPath: path.join(ui5DataDir, o.path)}) ); - if (!frameworkInfo && !buildInfo && orphanedInfo.length === 0) { - process.stderr.write("Nothing to clean\n"); - return; - } - await displayCacheInfo({ frameworkInfo, buildInfo, From 26d212f60fefaf8eeec59fe15079adbd325df070 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 16:35:44 +0300 Subject: [PATCH 089/114] fix: Adjust comments & wrongly called method --- packages/project/lib/graph/ProjectGraph.js | 2 +- packages/project/lib/ui5Framework/cache.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 9bed4a26592..2d73652f52b 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -739,7 +739,7 @@ class ProjectGraph { const deadline = Date.now() + TIMEOUT_MS; while (await hasActiveLocks(this._ui5DataDir, {include: CLEANUP_LOCK_NAME})) { if (Date.now() >= deadline) { - this.#lockRelease.release(); + this.#lockRelease?.(); this.#lockRelease = null; throw new Error( diff --git a/packages/project/lib/ui5Framework/cache.js b/packages/project/lib/ui5Framework/cache.js index ab9e91787ff..3019cabbef2 100644 --- a/packages/project/lib/ui5Framework/cache.js +++ b/packages/project/lib/ui5Framework/cache.js @@ -151,7 +151,8 @@ export async function cleanAdditional(ui5DataDir) { * Clean the framework cache directory. * * Uses an atomic rename to make the framework directory disappear in a single - * filesystem operation, then deletes the staging dir outside of any lock: + * filesystem operation. The caller is responsible for holding the cleanup lock + * for the full duration of this call: * * 1. Clear cacache's in-process memoization (no path needed — global operation). * 2. Atomically rename framework/ to a hidden staging dir. From e60788aa2650b659ec7adfa31f2346c264f7f2e7 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 17:22:14 +0300 Subject: [PATCH 090/114] fix: Await synchronization callback --- packages/project/lib/ui5Framework/AbstractInstaller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/lib/ui5Framework/AbstractInstaller.js b/packages/project/lib/ui5Framework/AbstractInstaller.js index bf807e712d4..fb59433cf3f 100644 --- a/packages/project/lib/ui5Framework/AbstractInstaller.js +++ b/packages/project/lib/ui5Framework/AbstractInstaller.js @@ -38,7 +38,7 @@ class AbstractInstaller { "Please wait for the cache clean operation to finish and try again." ); } - return callback(); + return await callback(); } finally { releaseLock(); } From 7cc216c4201ee68be12e529ec8354f47ebd8d80b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 17:27:10 +0300 Subject: [PATCH 091/114] revert: Restore Troubleshooting guide after merge conflict --- internal/documentation/docs/pages/Troubleshooting.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 99e090c1f9f..d70a81ff3ca 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -12,7 +12,17 @@ Please follow our [Contribution Guidelines](https://github.com/UI5/cli/blob/main ## UI5 Project ### `~/.ui5` Taking too Much Disk Space -There are possibly many versions of UI5 framework dependencies installed on your system, taking a large amount of disk space. +UI5 CLI stores several kinds of data under your user's home directory in `~/.ui5/`: + +| Directory | Contents | Safe to delete? | +| ---- | ---- | ---- | +| `~/.ui5/framework/` | Downloaded UI5 framework dependencies (one copy per version) | Yes — re-downloaded on next invocation | +| `~/.ui5/buildCache/` | Build cache used by `ui5 build` and `ui5 serve` (see [Build Cache Control](./Builder.md#build-cache-control)) | Yes — rebuilt on next `ui5 build` / `ui5 serve` | +| `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS / HTTP/2 mode | Yes — regenerated on next HTTPS server start; the new certificate must be re-trusted | + +::: warning +Only remove these directories when no UI5 CLI process and no `@ui5/*` API consumer is actively running. Deleting files that are in use can cause running builds or servers to fail or produce inconsistent results. +::: #### Resolution From a9f169d61a43347bb49b465645fe0182798b88bf Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 23:01:40 +0300 Subject: [PATCH 092/114] refactor: No braceless returns --- packages/project/lib/utils/lock.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/project/lib/utils/lock.js b/packages/project/lib/utils/lock.js index 3d71ad65e9a..8789424a57d 100644 --- a/packages/project/lib/utils/lock.js +++ b/packages/project/lib/utils/lock.js @@ -68,9 +68,15 @@ export async function hasActiveLocks(ui5DataDir, {include, exclude} = {}) { const excludeSet = exclude ? new Set([].concat(exclude)) : null; const lockFiles = entries.filter((name) => { - if (!name.endsWith(".lock")) return false; - if (includeSet && !includeSet.has(name)) return false; - if (excludeSet && excludeSet.has(name)) return false; + if (!name.endsWith(".lock")) { + return false; + } + if (includeSet && !includeSet.has(name)) { + return false; + } + if (excludeSet && excludeSet.has(name)) { + return false; + } return true; }); From f273c3cd604a24d5ec033571ada1acbb7bcd497d Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 23:02:18 +0300 Subject: [PATCH 093/114] refactor: Prevent clean cache only after sealed --- packages/project/lib/graph/ProjectGraph.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 2d73652f52b..c3147e6b5bf 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -790,7 +790,6 @@ class ProjectGraph { outputStyle = OutputStyleEnum.Default, cache = Cache.Default }) { - await this._preventCacheClean(); this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -798,6 +797,7 @@ class ProjectGraph { `Each graph can only be built or served once`); } this._builtOrServed = true; + await this._preventCacheClean(); const { default: ProjectBuilder } = await import("../build/ProjectBuilder.js"); @@ -832,7 +832,6 @@ class ProjectGraph { includedTasks = [], excludedTasks = [], cache = Cache.Default }) { - await this._preventCacheClean(); this.seal(); // Do not allow further changes to the graph if (this._builtOrServed) { throw new Error( @@ -840,6 +839,7 @@ class ProjectGraph { `Each graph can only be built or served once`); } this._builtOrServed = true; + await this._preventCacheClean(); const { default: ProjectBuilder } = await import("../build/ProjectBuilder.js"); From 5e87c0e3376a10c1c7227bf08074401dd8a8ec05 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 23:06:11 +0300 Subject: [PATCH 094/114] revert: Wiped out documentation --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index d70a81ff3ca..9b6fc835748 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -45,7 +45,7 @@ The command removes two types of cached data: Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5DataDir`, the cache will be cleaned from that location instead of `~/.ui5`. See [Changing UI5 CLI's Data Directory](#changing-ui5-clis-data-directory) below. +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, replace `~/.ui5/` with that path. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## Environment Variables From 2f8c7c2805b746d3fb40dbc3a9ef1fa8e0dba5a6 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 23:08:12 +0300 Subject: [PATCH 095/114] docs: Update Troubleshooting to reflect orphaned branches --- internal/documentation/docs/pages/Troubleshooting.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 9b6fc835748..60aa850dd67 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -38,9 +38,10 @@ This will display the cache location, the amount of data that will be removed, a ui5 cache clean --yes ``` -The command removes two types of cached data: +The command removes the following cached data: - **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) - **Build cache (DB)** — build data (`~/.ui5/buildCache/`) +- **Orphaned framework data** — incomplete framework directories left over from previously interrupted clean operations (`~/.ui5/.framework_to_delete_*/`) Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. From 5834b55bada399c52186a1ade862cc6a165ed9a7 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Mon, 29 Jun 2026 23:19:27 +0300 Subject: [PATCH 096/114] docs: Update CLI information to reflect orphaned branches --- packages/cli/lib/cli/commands/cache.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 63bb7fcb1f4..94937bdd2c3 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -36,12 +36,14 @@ cacheCommand.builder = function(cli) { .epilogue( "The cache is stored in the UI5 data directory (default: ~/.ui5).\n" + "Override the location with the UI5_DATA_DIR environment variable or\n" + - "the 'ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + - "Two cache types are removed:\n" + + "the 'ui5 config set ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + + "The following cache types are removed:\n" + " UI5 Framework packages Downloaded UI5 library files " + "(~/.ui5/framework/)\n" + - " Build cache (DB) build data " + - "(~/.ui5/buildCache/)" + " Build cache (DB) Build data " + + "(~/.ui5/buildCache/)\n" + + " Orphaned framework data Incomplete directories from previously interrupted cleans\n" + + " (~/.ui5/.framework_to_delete_*/)" ); }, middlewares: [baseMiddleware], From 571c275bf8469b53c90afd84114e98ed7f2205ed Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 30 Jun 2026 11:03:09 +0300 Subject: [PATCH 097/114] docs: Correct JSDoc link --- packages/project/lib/graph/ProjectGraph.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index c3147e6b5bf..1ff8ce47d13 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -11,7 +11,8 @@ import {acquireLockSync, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from ".. * A rooted, directed graph representing a UI5 project, its dependencies and available extensions. * * When constructed with a ui5DataDir, the graph acquires a process-coordination - * lock during {@link enrichProjectGraph} to prevent concurrent ui5 cache clean + * lock during {@link @ui5/project/graph/helpers/ui5Framework~enrichProjectGraph enrichProjectGraph} + * to prevent concurrent ui5 cache clean * operations. If a cache clean is already running, the lock acquisition waits for it to finish * before proceeding. Call {@link destroy} to release the lock explicitly when the graph is no * longer needed. Even without an explicit call, the lockfile package ensures the From cd95dc5f2794256139b585f073f9460c2885e130 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 30 Jun 2026 12:14:14 +0300 Subject: [PATCH 098/114] docs: Make ui5 cache clean command info section clearer --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 60aa850dd67..a02b33d9150 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -46,7 +46,7 @@ The command removes the following cached data: Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, replace `~/.ui5/` with that path. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## Environment Variables From 98955ba06711bf148a75d60bec428dae74f5f217 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:06:33 +0300 Subject: [PATCH 099/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index a02b33d9150..b82f65f10a8 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -32,7 +32,7 @@ Use the dedicated cache clean command, which safely removes all cached data: ui5 cache clean ``` -This will display the cache location, the amount of data that will be removed, and ask for confirmation before proceeding. To skip the confirmation prompt (e.g. in CI environments), use the `--yes` flag: +This displays the cache location, the amount of data that gets removed, and asks for confirmation before proceeding. To skip the confirmation prompt (for example in CI environments), use the `--yes` flag: ```sh ui5 cache clean --yes From 0cf461e6a6d9d1862220cdecb6f11de5c79f7e43 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:06:47 +0300 Subject: [PATCH 100/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index b82f65f10a8..2d3cd38b996 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -39,7 +39,7 @@ ui5 cache clean --yes ``` The command removes the following cached data: -- **UI5 Framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) +- **UI5 framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) - **Build cache (DB)** — build data (`~/.ui5/buildCache/`) - **Orphaned framework data** — incomplete framework directories left over from previously interrupted clean operations (`~/.ui5/.framework_to_delete_*/`) From ff79e6724841c13228aa9625bb127c71f6697092 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:07:02 +0300 Subject: [PATCH 101/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 2d3cd38b996..a6ca812a981 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -43,7 +43,7 @@ The command removes the following cached data: - **Build cache (DB)** — build data (`~/.ui5/buildCache/`) - **Orphaned framework data** — incomplete framework directories left over from previously interrupted clean operations (`~/.ui5/.framework_to_delete_*/`) -Any missing framework dependencies will be downloaded again during the next UI5 CLI invocation. +Any missing framework dependencies are downloaded during the next UI5 CLI invocation. ::: info If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). From 517892fb90a6a0303ca3e013e3be6a5010153869 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:07:13 +0300 Subject: [PATCH 102/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index a6ca812a981..5dc6383cfa9 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -46,7 +46,7 @@ The command removes the following cached data: Any missing framework dependencies are downloaded during the next UI5 CLI invocation. ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## Environment Variables From 978828471f2d98a29797e0bfb527a628befcc102 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:07:26 +0300 Subject: [PATCH 103/114] docs: Update packages/cli/lib/cli/commands/cache.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 94937bdd2c3..fab9e3b3e9f 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -28,7 +28,7 @@ cacheCommand.builder = function(cli) { type: "boolean", }) .example("$0 cache clean", - "Remove all cached UI5 data after confirming the prompt") + "Remove all cached UI5 data after confirmation") .example("$0 cache clean --yes", "Remove all cached UI5 data without confirmation (e.g. in CI)") .example("UI5_DATA_DIR=/custom/path $0 cache clean", From b17bbec35c9372e34fa226541fc7f06af47a5fa1 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:07:36 +0300 Subject: [PATCH 104/114] docs: Update packages/cli/lib/cli/commands/cache.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index fab9e3b3e9f..2ae5f84a30a 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -30,7 +30,7 @@ cacheCommand.builder = function(cli) { .example("$0 cache clean", "Remove all cached UI5 data after confirmation") .example("$0 cache clean --yes", - "Remove all cached UI5 data without confirmation (e.g. in CI)") + "Remove all cached UI5 data without confirmation (e.g. in CI scenarios)") .example("UI5_DATA_DIR=/custom/path $0 cache clean", "Remove cached data from a non-default UI5 data directory") .epilogue( From 050d2f13177f0d0903753aca738e23e5fabc9c35 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:07:45 +0300 Subject: [PATCH 105/114] docs: Update packages/cli/lib/cli/commands/cache.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 2ae5f84a30a..557ce89c0c3 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -38,7 +38,7 @@ cacheCommand.builder = function(cli) { "Override the location with the UI5_DATA_DIR environment variable or\n" + "the 'ui5 config set ui5DataDir' configuration option (see 'ui5 config --help').\n\n" + "The following cache types are removed:\n" + - " UI5 Framework packages Downloaded UI5 library files " + + " UI5 framework packages: Downloaded UI5 library files " + "(~/.ui5/framework/)\n" + " Build cache (DB) Build data " + "(~/.ui5/buildCache/)\n" + From 3545ca144c3724d780b8965301b752fa6080a011 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:08:01 +0300 Subject: [PATCH 106/114] docs: Update packages/cli/lib/cli/commands/cache.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 557ce89c0c3..3a723d61b9f 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -40,7 +40,7 @@ cacheCommand.builder = function(cli) { "The following cache types are removed:\n" + " UI5 framework packages: Downloaded UI5 library files " + "(~/.ui5/framework/)\n" + - " Build cache (DB) Build data " + + " Build cache (DB): Build data " + "(~/.ui5/buildCache/)\n" + " Orphaned framework data Incomplete directories from previously interrupted cleans\n" + " (~/.ui5/.framework_to_delete_*/)" From 2cb63162e2a868e817fceeb6d7b8608f80b4050e Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:08:12 +0300 Subject: [PATCH 107/114] docs: Update packages/cli/lib/cli/commands/cache.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- packages/cli/lib/cli/commands/cache.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/lib/cli/commands/cache.js b/packages/cli/lib/cli/commands/cache.js index 3a723d61b9f..787839cfde5 100644 --- a/packages/cli/lib/cli/commands/cache.js +++ b/packages/cli/lib/cli/commands/cache.js @@ -42,7 +42,7 @@ cacheCommand.builder = function(cli) { "(~/.ui5/framework/)\n" + " Build cache (DB): Build data " + "(~/.ui5/buildCache/)\n" + - " Orphaned framework data Incomplete directories from previously interrupted cleans\n" + + " Orphaned framework data: Incomplete directories from previously interrupted cleanups\n" + " (~/.ui5/.framework_to_delete_*/)" ); }, From c5d515ac5c3fcb84b9e7520ea7b8152ed627b7c2 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 30 Jun 2026 17:08:23 +0300 Subject: [PATCH 108/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Günter Klatt --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 5dc6383cfa9..f8fc8fcb8d9 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -41,7 +41,7 @@ ui5 cache clean --yes The command removes the following cached data: - **UI5 framework packages** — downloaded UI5 library files (`~/.ui5/framework/`) - **Build cache (DB)** — build data (`~/.ui5/buildCache/`) -- **Orphaned framework data** — incomplete framework directories left over from previously interrupted clean operations (`~/.ui5/.framework_to_delete_*/`) +- **Orphaned framework data** — incomplete framework directories left over from previously interrupted cleanup operations (`~/.ui5/.framework_to_delete_*/`) Any missing framework dependencies are downloaded during the next UI5 CLI invocation. From b1c2796de027f69caafb23d4badd5830f16d0511 Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 14 Jul 2026 11:06:11 +0300 Subject: [PATCH 109/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md Co-authored-by: Merlin Beutlberger --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index f8fc8fcb8d9..6907ddf8e3a 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -46,7 +46,7 @@ The command removes the following cached data: Any missing framework dependencies are downloaded during the next UI5 CLI invocation. ::: info -If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command automatically cleans up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). +If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command will clean up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). ::: ## Environment Variables From d9845c9ca341e397760a5ab3a1ee73d882fd3c1e Mon Sep 17 00:00:00 2001 From: Yavor Ivanov Date: Tue, 14 Jul 2026 11:06:32 +0300 Subject: [PATCH 110/114] docs: Update internal/documentation/docs/pages/Troubleshooting.md Co-authored-by: Merlin Beutlberger --- internal/documentation/docs/pages/Troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/documentation/docs/pages/Troubleshooting.md b/internal/documentation/docs/pages/Troubleshooting.md index 6907ddf8e3a..22a00ad7e5a 100644 --- a/internal/documentation/docs/pages/Troubleshooting.md +++ b/internal/documentation/docs/pages/Troubleshooting.md @@ -43,7 +43,7 @@ The command removes the following cached data: - **Build cache (DB)** — build data (`~/.ui5/buildCache/`) - **Orphaned framework data** — incomplete framework directories left over from previously interrupted cleanup operations (`~/.ui5/.framework_to_delete_*/`) -Any missing framework dependencies are downloaded during the next UI5 CLI invocation. +Any required framework dependencies will be re-downloaded during the next UI5 CLI invocation. ::: info If you have configured a custom data directory via `UI5_DATA_DIR` or `ui5 config set ui5DataDir`, the `ui5 cache clean` command will clean up that location instead of the default `~/.ui5/`. See [Changing UI5 CLI's Data Directory](#changing-ui5-cli-s-data-directory). From 5fcad57911637aa251abd8a7fc7ec3af226c317a Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 14 Jul 2026 14:34:13 +0300 Subject: [PATCH 111/114] docs: Add Lessons learned summary document --- CACHE_CLEAN_SUMAMRY.md | 144 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 CACHE_CLEAN_SUMAMRY.md diff --git a/CACHE_CLEAN_SUMAMRY.md b/CACHE_CLEAN_SUMAMRY.md new file mode 100644 index 00000000000..d5bc1a35c1f --- /dev/null +++ b/CACHE_CLEAN_SUMAMRY.md @@ -0,0 +1,144 @@ +# Lessons Learned — `ui5 cache clean` + +This document captures what was learned building the "Clean cache command" so each topic can be broken down and decided individually later. + +What started as a simple `rm -rf ~/.ui5/framework` replacement grew into a cross-package concurrency, lifecycle, and refactoring problem. The scope creep is the central lesson — every "simple deletion" of a shared resource pulls in coordination with every process that reads or writes it. + +--- + +## 1. General locking and locks concept + +### 1.1 Sync vs async lock + +**Current state:** The codebase uses mostly the async variant from [`packages/project/lib/utils/lock.js`](../packages/project/lib/utils/lock.js): + +**Why sync was needed in the first place:** There are places where async +acquire is simply not possible: constructors, synchronous methods, etc. +The strong side of having a synchronous lock mechanism is that it also +locks the main thread, so there is no possibility for any race conditions. + +**The hard problem with `lockSync`:** The `lockfile` library itself makes the limitation explicit — if you pass `opts.wait` or `opts.retryWait` to `lockSync`, it throws: + +``` +'opts.wait not supported sync for obvious reasons' +``` + +This means a sync lock **cannot wait** for a contended lock to become free. It either acquires immediately or throws. The only mitigation is `opts.retries` (fixed-count retries, each blocking the event loop). This works only when: +- The lock path is unique per process (no realistic contention), or +- A brief blocking retry loop is acceptable. + +`ProjectGraph` uses a unique path per-process-per-instance (`graph-{pid}-{randomHex}.lock`), so contention is impossible and `retries` is not needed. But this constraint is fragile — it relies on the path being unique rather than on a genuine no-contention guarantee. + +**Async `lock()` advantages that sync lacks:** +- `opts.wait` — polls for up to N ms before giving up, without blocking the event loop. +- `opts.retryWait` — configurable delay between retries. +- Non-blocking: other async tasks can continue while waiting. + +**Lesson:** Sync locks are error-prone by design — they block the event loop and cannot wait for contention to resolve. They should only be used in narrow cases where the lock path is guaranteed unique (no contention possible) and the call site is genuinely sync-only with no path to async. + +### 1.2 Where to lock + +**What we did:** The lock scope moved several times during the PR: +- First only around per-package **downloads**. +- Then discovered a second window: the **install** phase (copying files into the cache) was unprotected. +- Then extended to cover the **whole `ProjectGraph` lifecycle** (construction → build/serve → destroy), because the graph holds live references to framework paths *after* download completes. + +The final answer: the graph is the coordination unit. `_preventCacheClean()` is called from `enrichProjectGraph` (framework resolution) and again from `build()`/`serve()` — guarded by an idempotency check so only one lock exists per instance. + +**Lesson:** "Where to lock" is really "what is the unit of work that holds the resource?" We kept finding new windows because we were thinking in terms of individual operations (download, install) rather than the lifetime of the consumer (the graph). The right question is *"who is using the files, and for how long?"* — not *"which function deletes/writes them?"* + +**Open question:** Granularity of the lock! Wouldn't it be easier to simply lock the command in `@ui5/cli` i.e. `cache clean`? But what about if some process uses the `@ui5/project`'s API standalone in parallel? This way we lock the command, but not the API itself. The answer can be found when we agree on the Lesson above. + +### 1.3 How to lock + +**What we did:** Chose the `lockfile` npm package (file-based advisory locks in `~/.ui5/locks/`) over an in-process mutex, because coordination must span **separate OS processes** (`ui5 build`, `ui5 serve`, `ui5 cache clean`, and external `@ui5/*` API consumers), not just async tasks in one process. + +Key mechanics we had to get right: +- **Staleness** (`LOCK_STALE_MS = 60000`): a crashed process must not deadlock everyone forever. `hasActiveLocks` treats older-than-stale locks as dead and garbage-collects them. +- **mtime refresh** (`LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6`): a long-running server would otherwise look "stale" after 60s and get its lock stolen. An interval refreshes the mtime; `interval.unref()` so it doesn't keep the process alive. +- **Acquire-then-check ordering** (TOCTOU): the cache clean must acquire its cleanup lock *first*, then check `hasActiveLocks`, so a concurrently starting installer is guaranteed to see one of the two locks. + +**Lesson:** File-based cross-process locking has a surprising number of correctness traps (staleness vs. refresh vs. the check/acquire order). A from-scratch concept should treat these as first-class requirements. + +### 1.4 How to release to prevent deadlocks + +Lock release must be guaranteed regardless of how control exits a protected section. Multiple safety layers are needed because a stranded lock is worse than no lock: + +- **Make release idempotent** — the release function should be safe to call multiple times (e.g. from both an explicit early-exit path and a `finally` block). Without idempotency, double-release either throws or corrupts state. +- **Always use `try/finally`** — any exception, early return, or branch that bypasses an explicit `release()` call is a latent deadlock. The `finally` block is the only reliable guarantee. +- **Await async work inside the lock** — if the protected section is async, the `finally` must not run until the work resolves. `return callback()` inside a `try/finally` releases the lock immediately when the Promise is returned, before it settles. The correct form is `return await callback()`. +- **Explicit release + automatic backstop** — graceful shutdown calls `release()` explicitly. Abnormal termination (signals, crashes) is covered by the `lockfile` library's signal-exit handler, which removes all known locks on process exit. Neither alone is sufficient. + +**Lesson:** Deadlock prevention is layered: idempotent release + `try/finally` + `await` discipline + signal-exit backstop. Each layer covers a failure mode the others miss. The `await` mistake in particular is subtle — a single dropped keyword looks harmless but silently allows concurrent access to the protected resource. + +**Lifecycle / cleanup ownership of `ProjectGraph`.** JS has no destructor; the graph relies on an explicit `destroy()` plus `lockfile`'s signal-exit backstop. `BuildServer` and CLI commands (`tree.js`, `build.js`) must remember to call `destroy()`. This "who calls destroy, and when" question is a real design gap that spans the whole `ProjectGraph` public API — arguably bigger than the locking topic itself. + +--- + +## 2. Performance & UX + +### 2.1 What information to display to the end user + +**What we did — and reversed twice:** +- v1: show **byte size** of the cache. For big caches it might take significant amount of time to compute the size. Native OS variants are also slow and for different platforms there are different tools. +- v2: show **file count** — still slow (traversal of ~2M files) and "2 million files" is meaningless to a user. +- v3 (final): show **"X versions of Y libraries"** — a shallow directory traversal (project → library → version), fast and meaningful. + +We also added: a pre-confirmation summary, a `--yes` flag and a post-clean success line. + +**Lesson:** "How much will this free?" is expensive to answer accurately and not even what the user cares about. The useful signal was *what* is being removed (libraries/versions), not *how many bytes*. We spent significant effort computing a number we ultimately threw away. **Decide the UX metric before implementing the measurement.** + +### 2.2 What, where, and how to purge data + +Three distinct storage layers each needed a different purge strategy: + +- **Memory** — `cacache`'s in-process memoization. Cleared via `clearMemoized()` (global, synchronous) before the rename. +- **DB** — the SQLite build cache (`buildCache/v0_7/cache.db`). Cleaned via `clearAllRecords()` inside a transaction. Crucially, we made it **version-aware**: only the current `CACHE_VERSION` is purged, because we cannot know the schema of a future cache version ("wipe everything" is not robust across schema versions). The DB *file* is intentionally kept; only records are deleted. +- **File system** — the `framework/` directory. We adopted an **atomic-rename-then-delete** strategy: rename `framework/` → `.framework_to_delete_/` (one fast syscall, makes the path disappear instantly), release the lock, then delete the staging dir outside the lock. This minimizes lock-hold time and lets a concurrent build see the path as simply absent. + +The rename strategy introduced a new concept: **orphaned staging dirs** (a clean interrupted after rename but before delete). We added `cleanAdditional()` to sweep these up independently — they're safe to remove any time, outside the lock. + +**Lesson:** Three storage backends = three purge semantics. The atomic rename was the key insight for the file system (short lock, no corruption), but it *created* a new failure mode (orphans) that needed its own cleanup path. Every optimization added a corner. The DB version-awareness is a genuinely important correctness constraint that should survive into any future concept. + +--- + +## 3. Refactoring + +### 3.1 DRY — installers: consolidate locking + +**What we did:** `AbstractInstaller` (base for npm + maven installers) now imports the shared lock utilities instead of promisifying `lockfile` inline per call. `_synchronize` centralizes: acquire → check-for-cleanup → run callback → release. + +**Open item flagged during review:** The `framework/` directory name is still hardcoded in each installer rather than abstracted in `AbstractInstaller`. The clean concern and the install concern both know about `framework/` independently. The proposed direction: abstract the `framework/` location and the locking into `AbstractInstaller`, then have the cache cleaner reuse that knowledge — so there's one source of truth for "where framework files live and how they're locked." + +**Lesson:** The installers and the cleaner are two sides of the same resource but evolved in different namespaces (`ui5Framework/` vs the CLI command). They share path + lock knowledge but not code. + +### 3.2 Resolution of UI5 data dir + +**What we did:** Introduced [`resolveUi5DataDir()`](../packages/project/lib/utils/dataDir.js) as the single resolver (env var → config → `~/.ui5` default, always returns an absolute path). Removed a duplicate `getUi5DataDir` from `packages/cli/lib/framework/utils.js`. + +**Still inconsistent (flagged):** Resolution still happens **in-place** in a few spots. The Resolvers/Installers (e.g. `Openui5Resolver`) keep a fallback `ui5DataDir || path.join(os.homedir(), ".ui5")` because they're publicly exported and can be called without a pre-resolved dir. So there isn't *one* resolution path — there's a util plus defensive fallbacks at the public API boundary. + +**Lesson:** "One utility" isn't the same as "one resolution path." Public API entry points force defensive fallbacks. A clean concept needs to decide: is `ui5DataDir` resolved once at the outermost boundary and threaded through, or resolved lazily wherever needed? We have a mix, and the mix is the problem. This also introduced backward incompatibilities. + +### 3.3 Location of cached resources, locks, etc. + +**What we did:** Consolidated all lock files into `~/.ui5/locks/` (previously `~/.ui5/framework/locks/`). As the lock scope grew beyond framework (to build/serve/cleanup), a framework-scoped lock dir no longer made sense. + +**Lesson:** The lock directory location is a consequence of lock *scope*. When scope expanded, the location had to move. This is a symptom of the same root issue: scope kept growing, and physical layout followed reactively. + +**Open question (RandomByte):** Moving all locks to `~/.ui5/locks/` creates a **compatibility concern** with previous CLI versions that still write framework-installer locks to `~/.ui5/framework/locks/`. If a user runs two CLI versions in parallel — e.g. a project pinned to an older version alongside a global newer version — the old version won't see the new locks and vice versa. + +One option is to keep framework-dependency-related locks under `~/.ui5/framework/locks/` and only place new, broader-scope locks (build, serve, cache-clean) under `~/.ui5/locks/`. This would preserve backward compatibility for the installer lock path while still enabling the new coordination locks. + +**Analysis of mixed-version scenarios:** The actual cross-version impact is more limited than it first appears. Locks are only meaningful when shared between cooperating processes of the *same* version. The mixed-version scenarios break down as: + +- **v5 only or v4 only** — nothing breaks; everything works as expected. +- **v4 and v5 in parallel** — v5's `cache clean` is the only command that uses the new lock dir. v5's locks will not prevent v4 from downloading or installing framework resources (v4 doesn't know about `~/.ui5/locks/`). The only real clash is: running v5's `cache clean` while v4's `serve` or `build` is active. In the worst case, some framework resources are deleted mid-use and the v4 command must be re-run. + +**Remaining decision:** Is the v4+v5 concurrent-clean risk acceptable, or does it warrant keeping framework locks at their old path? The risk is bounded (a re-run is sufficient recovery), but it is user-visible. + +--- + +## Summary + +Every sub-topic in the comment traces back to one root: **deleting a shared resource forces you to model every reader and writer of that resource, across processes, for their full lifetime.** The complexity wasn't in the deletion — it was in proving nobody else is mid-flight. Restarting the concept should begin from that model (who owns the framework cache, its lock contract, and its lifecycle), and let the command, the installers, and the graph consume that one contract — rather than each discovering the coordination need independently, which is how we got here. From 0e26d0457c74f9ef41d07f80fafdd23b162a959b Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Tue, 14 Jul 2026 15:19:31 +0300 Subject: [PATCH 112/114] test: Add missing destroy stub to BuildServer graph fixtures BuildServer.destroy() calls this.#graph.destroy() to release process-coordination locks. The graph stubs in makeGraphWithLib() and the inline graphs in the serve-status / triggerRequestQueue tests were missing this method, causing teardown failures. --- packages/project/test/lib/build/BuildServer.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/project/test/lib/build/BuildServer.js b/packages/project/test/lib/build/BuildServer.js index 28f018a16d1..3886d7fd929 100644 --- a/packages/project/test/lib/build/BuildServer.js +++ b/packages/project/test/lib/build/BuildServer.js @@ -22,7 +22,7 @@ async function drainUntil(clock, statusEvents, predicate, {maxTicks = 100} = {}) // Graph with a single library dependency behind the root project. Used by the // BUILDING → VALIDATING → … tests to leave one INITIAL project after the build // cycle, which is the trigger for the post-build validation pass. -function makeGraphWithLib() { +function makeGraphWithLib(sinon) { const rootProject = {getName: () => "root.project"}; const libProject = {getName: () => "library.x"}; const graph = { @@ -34,6 +34,7 @@ function makeGraphWithLib() { yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon ? sinon.stub() : () => {}, }; return {rootProject, libProject, graph}; } @@ -341,7 +342,7 @@ test.serial( // Augment the graph with a single library dependency so the build cycle leaves // one INITIAL project behind, which is exactly the trigger for VALIDATING. - const {graph} = makeGraphWithLib(); + const {graph} = makeGraphWithLib(sinon); // validateCaches: report library.x's cache as fresh — usesCache=true triggers the // promote-to-FRESH path that closes out the validation pass with no stale projects. @@ -394,7 +395,7 @@ test.serial( "serve-status: VALIDATING → STALE when validation finds a stale cache", async (t) => { const {BuildServer, sinon, clock} = t.context; - const {graph} = makeGraphWithLib(); + const {graph} = makeGraphWithLib(sinon); // usesCache=false → project must stay INITIAL → final state is STALE. const projectBuilder = { @@ -447,6 +448,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; const validationError = new Error("validateCaches blew up"); @@ -508,6 +510,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; // Hold the validation pass open so the reader request can land mid-VALIDATING. @@ -606,6 +609,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; let releaseValidation; @@ -699,6 +703,7 @@ test.serial( yield {project: rootProject}; yield {project: libProject}; }, + destroy: sinon.stub(), }; let releaseValidation; @@ -791,6 +796,7 @@ test.serial( traverseDependents: function* (projectName) { yield {project: projectsByName[projectName]}; }, + destroy: sinon.stub(), }; // Track concurrent invocations of projectBuilder.build. The second From 8f7dd068076793b973a8572c9418134e463ef0ef Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 16 Jul 2026 15:08:13 +0300 Subject: [PATCH 113/114] docs: Locks document --- doc-process-coordination-locks.md | 184 ++++++++++++++++++ packages/project/lib/graph/ProjectGraph.js | 4 +- .../project/test/lib/graph/ProjectGraph.js | 20 +- 3 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 doc-process-coordination-locks.md diff --git a/doc-process-coordination-locks.md b/doc-process-coordination-locks.md new file mode 100644 index 00000000000..c96b16de990 --- /dev/null +++ b/doc-process-coordination-locks.md @@ -0,0 +1,184 @@ +- Start Date: 2026-07-16 +- RFC PR: [#1394](https://github.com/UI5/cli/pull/1394) +- Issue: - +- Affected components + + [ ] [ui5-builder](./packages/builder) + + [x] [ui5-server](./packages/server) + + [x] [ui5-cli](./packages/cli) + + [ ] [ui5-fs](./packages/fs) + + [x] [ui5-project](./packages/project) + + [ ] [ui5-logger](./packages/logger) + + +# RFC 0019 Process-Coordination Locks + +## Summary + +Introduce a shared, file-based advisory locking mechanism across `@ui5/project` and `@ui5/cli` so that `ui5 cache clean` can safely delete framework packages and build cache data without corrupting concurrent `ui5 build`, `ui5 serve`, or programmatic `@ui5/*` API consumers. + +## Motivation + +`ui5 cache clean` deletes files inside `~/.ui5/` that may be actively used by other running UI5 CLI processes. Without coordination, a cache clean can: + +- Delete framework packages while a concurrent `ui5 build` is installing or reading them, causing `ENOENT` errors mid-build. +- Clear the SQLite build-cache database while a running `ui5 serve` holds it open, corrupting its WAL state. +- Race against a concurrent installer, resulting in a partially-installed framework directory. + +UI5 CLI processes are **separate OS processes**, not async tasks within one Node.js event loop. An in-process mutex is therefore insufficient. Cross-process coordination requires file-based advisory locks that survive across PIDs and are visible in the filesystem. + +## Detailed design + +### Lock directory + +All process-coordination lock files live in a single shared directory: + +``` +~/.ui5/locks/ +``` + +This location is configurable via `UI5_DATA_DIR` / `ui5 config set ui5DataDir` — the lock directory is always `path.join(ui5DataDir, "locks")`. + +Lock files are created with the `lockfile` npm package, which provides POSIX-compatible exclusive file creation and a signal-exit handler that removes all known locks on process exit. + +### Why `lockfile` + +The `lockfile` package was already used by the framework installers (`AbstractInstaller`) for per-package installation locks before this RFC. Reusing it for the new process-coordination locks keeps the dependency footprint flat and ensures consistent behavior across all locking in the codebase. + +Key properties that make it suitable: + +- **Staleness support** — every lock carries a `stale` threshold; locks older than the threshold are treated as dead (left by a crashed process) and can be cleaned up without manual intervention. +- **Signal-exit integration** — the package registers handlers for `SIGINT`, `SIGTERM`, and `exit` that synchronously remove all locks it knows about, providing automatic cleanup on both graceful shutdown and unexpected termination. +- **Sync and async variants** — `lockfile.lock` (async) and `lockfile.lockSync` (sync) share the same staleness semantics, making them interchangeable from a correctness perspective while allowing each call site to pick the variant that fits its execution context. + +### Lock types + +Two categories of lock are used: + +| Lock | File pattern | Held by | Checked by | +|---|---|---|---| +| Graph lock | `graph-{pid}-{randomHex}.lock` | `ProjectGraph` instance (for its full lifetime) | `ui5 cache clean` before deletion | +| Cleanup lock | `cache-cleanup.lock` | `ui5 cache clean` | Framework installer (`AbstractInstaller`) before package operations | + +### Graph lock lifecycle + +`ProjectGraph._preventCacheClean()` acquires a graph lock so that `ui5 cache clean` can detect any active build or serve process. The lock: + +- Is acquired asynchronously via `acquireLock` (unique path, no contention possible). +- Refreshes its mtime every `LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6` so it does not appear stale during long-running operations. +- Is released in `ProjectGraph.destroy()`, which must be called explicitly by callers when the graph is no longer needed. +- Is automatically removed on abnormal process exit by the `lockfile` signal-exit handler. + +The lock path encodes the PID and a random hex suffix (`graph-{pid}-{randomHex}.lock`) to guarantee uniqueness across concurrent instances. + +#### Why `ProjectGraph` is the lock owner + +During the development of `ui5 cache clean`, a key design question was: where exactly should the lock be acquired? Candidates considered included the CLI command itself, individual install methods, individual build or serve methods, and specific cached resources. + +The eventual answer: **the `ProjectGraph` instance is the correct lock owner**, because the graph holds live references to cached resources for its entire lifetime — not just during downloads or builds. Once a graph is constructed, it references framework package paths that must not be deleted until the graph is destroyed. The invariant is simple: *while a `ProjectGraph` is alive, `cache clean` must not proceed*. + +#### What "alive" means for `ProjectGraph` + +In the context of the UI5 CLI, a graph's lifetime matches a command invocation: + +- `ui5 build` — graph lives for the duration of the build. +- `ui5 serve` — graph lives for the lifetime of the dev server. +- `ui5 tree` — graph lives while the dependency tree is computed and printed. + +In all these cases, the CLI command is the obvious caller of `destroy()` at the end of the operation. + +#### Complexity with programmatic API usage + +`ProjectGraph` is a public export of `@ui5/project`. Developers can construct and use graphs directly without going through the CLI. This raises questions that are not fully resolved by the current implementation: + +- A graph used as an API may **live longer than necessary** if the consumer does not call `destroy()`. +- The responsibility for calling `destroy()` falls on the API consumer, but there is no enforced contract. The current implementation relies on documentation and the `lockfile` signal-exit backstop. + +These questions are captured in the Unresolved Questions section. + +### Cleanup lock lifecycle + +`ui5 cache clean` acquires `cache-cleanup.lock` before any deletion: + +1. Acquire `cache-cleanup.lock` (async, no wait — if already held, the command was invoked twice concurrently). +2. Scan `~/.ui5/locks/` for any graph lock (excluding `cache-cleanup.lock` itself). If any non-stale graph lock exists, abort with a user-visible error. +3. Perform deletion (atomic rename of `framework/` + SQLite `clearAllRecords()`). +4. Release `cache-cleanup.lock`. + +The framework installer (`AbstractInstaller._synchronize`) checks for `cache-cleanup.lock` after acquiring its own per-package lock. If the cleanup lock is active, the install is aborted with a clear error message. + +### Staleness + +Locks older than `LOCK_STALE_MS = 60000` ms are treated as dead (from a crashed process) and garbage-collected by `hasActiveLocks`. The mtime-refresh interval (`LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6 = 36000` ms) ensures a live lock is always refreshed before it appears stale. + +### Sync vs async acquisition + +Two variants are provided in `packages/project/lib/utils/lock.js`: + +**Async (preferred):** + +`acquireLock(lockPath, {wait, retries})` — asynchronous; non-blocking; supports `opts.wait` (poll up to N ms before giving up) and `opts.retryWait` (delay between retries). Multiple concurrent callers can queue without blocking the event loop. This is the correct choice for all code that can be async — in particular, for operations where lock contention is possible (e.g. multiple concurrent package downloads). + +**Sync (only when async is impossible):** + +`acquireLockSync(lockPath, {retries})` — synchronous; blocks the event loop for the duration of the acquisition. Its key benefit is that it makes lock acquisition atomic from the perspective of the current thread — no other JS code can run between the call and the return. This is the only viable option when: + +- The call site is inside a **constructor** (JS constructors cannot be `async`). +- The call site is inside a **synchronous method** with no path to `async`. + +The critical limitation of `lockSync` is that it **cannot wait** for a contended lock. The underlying `lockfile` library throws if `opts.wait` or `opts.retryWait` are passed to `lockSync`: + +``` +'opts.wait not supported sync for obvious reasons' +``` + +It either acquires immediately or throws. The only mitigation is `opts.retries` (a fixed-count retry loop, each blocking the event loop). This is acceptable only when the lock path is guaranteed unique per process (no realistic contention), such as a path encoding `{pid}-{randomHex}`. + +### Release safety + +Lock release must be guaranteed regardless of how control exits a protected section: + +- The release function is **idempotent** — safe to call multiple times from both an explicit path and a `finally` block. +- All protected sections use `try { ... } finally { release(); }`. +- Async callbacks inside a lock must be `await`ed: `return await callback()`, not `return callback()`. Without `await`, the `finally` fires before the async work completes, releasing the lock prematurely. +- Abnormal termination is covered by the `lockfile` signal-exit handler. + +### `hasActiveLocks` utility + +```js +hasActiveLocks(ui5DataDir, {include?, exclude?}) +``` + +Scans `~/.ui5/locks/` for non-stale lock files. The `include` and `exclude` options allow filtering by filename (e.g. exclude the caller's own lock when checking for others). Stale lock files are garbage-collected as a side effect. + +## How we teach this + +- The lock contract is documented in JSDoc on `ProjectGraph`, `_preventCacheClean()`, `acquireLockSync`, `acquireLock`, and `AbstractInstaller._synchronize`. +- Callers of `ProjectGraph` that build or serve (CLI commands, `BuildServer`) must call `graph.destroy()` when the graph is no longer needed to release the lock. +- The `ui5 cache clean` command displays a clear error when an active lock is detected, telling the user to stop running processes first. + +## Drawbacks + +- Adds a new runtime dependency (`lockfile`) and a new `~/.ui5/locks/` directory in the user's data dir. +- `ProjectGraph.destroy()` must be called explicitly. JS has no destructor; forgetting to call it leaks the lock until process exit (covered by the signal-exit backstop, but not ideal for long-lived processes). +- `ui5 cache clean` cannot protect against a build that started but has not yet reached `_preventCacheClean()` — there is a narrow startup window (~200–400 ms) where the race can be lost. The build recovers gracefully by re-downloading, but the user sees an unexpected full re-download. + +## Alternatives + +**In-process mutex:** Does not work — `ui5 build` and `ui5 cache clean` are separate OS processes. + +**Single cleanup lock in the CLI command only:** Protects the clean operation but does not prevent an installer from running into a half-deleted `framework/` directory. + +**No locking — atomic rename only:** The atomic rename of `framework/` prevents corruption of files already opened, but does not prevent a concurrent installer from writing into the newly-absent path between the rename and the installer's own lock acquisition. + +**Keep framework locks at `~/.ui5/framework/locks/`:** Preserves backward compatibility with v4 (which writes installer locks there). A mixed v4+v5 scenario means v5's `cache clean` cannot see v4's installer locks — the worst case is a re-run of the v4 command. This is an open decision; see Unresolved Questions. + +## Unresolved Questions and Bikeshedding + +*This section should be removed (i.e. resolved) before merging* + +1. **Lock granularity:** Should the lock live at the CLI command layer (`@ui5/cli`) or inside `@ui5/project`? Locking only in the CLI command misses programmatic API consumers; locking inside `@ui5/project` adds complexity to the library. The current design locks at the `ProjectGraph` level inside `@ui5/project`. + +2. **Lock directory location — v4 compatibility:** v4 writes framework installer locks to `~/.ui5/framework/locks/`. v5 moved them to `~/.ui5/locks/`. The two versions are invisible to each other. Should v5 keep framework-installer locks at the old path for backward compatibility, placing only the new broader-scope locks (build, serve, cache-clean) in `~/.ui5/locks/`? + +3. **`ProjectGraph` lock ownership for API consumers:** `ProjectGraph` is a public export. When a developer uses it directly (not via the CLI), who is responsible for acquiring and releasing the lock? Should `@ui5/project` always lock — even for read-only graph traversal — or should locking be opt-in? Should the library enforce explicit `destroy()` calls rather than relying on the signal-exit backstop? The current implementation locks unconditionally and documents the `destroy()` contract, but does not enforce it. + diff --git a/packages/project/lib/graph/ProjectGraph.js b/packages/project/lib/graph/ProjectGraph.js index 1ff8ce47d13..e73b5564334 100644 --- a/packages/project/lib/graph/ProjectGraph.js +++ b/packages/project/lib/graph/ProjectGraph.js @@ -4,7 +4,7 @@ import OutputStyleEnum from "../build/helpers/ProjectBuilderOutputStyle.js"; import {getLogger} from "@ui5/logger"; const log = getLogger("graph:ProjectGraph"); import Cache from "../../../project/lib/build/cache/Cache.js"; -import {acquireLockSync, CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir} from "../utils/lock.js"; +import {CLEANUP_LOCK_NAME, hasActiveLocks, getLockDir, acquireLock} from "../utils/lock.js"; /** @@ -732,7 +732,7 @@ class ProjectGraph { // Only then check whether a cache clean is already in progress — this order closes the race window. const lockId = Buffer.from(getRandomValues(new Uint8Array(4))).toString("hex"); const lockPath = path.join(lockDir, `graph-${process.pid}-${lockId}.lock`); - this.#lockRelease = acquireLockSync(lockPath); + this.#lockRelease = await acquireLock(lockPath); // Poll until any in-progress cache clean releases its lock, or we time out. const POLL_INTERVAL_MS = 200; diff --git a/packages/project/test/lib/graph/ProjectGraph.js b/packages/project/test/lib/graph/ProjectGraph.js index 3c02dcddf6b..91d89fc92e5 100644 --- a/packages/project/test/lib/graph/ProjectGraph.js +++ b/packages/project/test/lib/graph/ProjectGraph.js @@ -82,7 +82,7 @@ test.beforeEach(async (t) => { getLogger: sinon.stub().withArgs("graph:ProjectGraph").returns(t.context.log) }, "../../../lib/utils/lock.js": { - acquireLockSync: sinon.stub().returns(() => {}), + acquireLock: sinon.stub().resolves(() => {}), getLockDir: sinon.stub().returns("/test/locks"), CLEANUP_LOCK_NAME: "cache-cleanup.lock", hasActiveLocks: sinon.stub().resolves(false), @@ -2036,12 +2036,12 @@ test.serial("_preventCacheClean(): calling it multiple times reuses the same loc const sinon = t.context.sinon; const releaseSpy = sinon.spy(); - const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + const acquireLockStub = sinon.stub().resolves(releaseSpy); const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, "../../../lib/utils/lock.js": { - acquireLockSync: acquireLockSyncStub, + acquireLock: acquireLockStub, getLockDir: sinon.stub().returns("/test/locks"), CLEANUP_LOCK_NAME: "cache-cleanup.lock", hasActiveLocks: sinon.stub().resolves(false), @@ -2054,8 +2054,8 @@ test.serial("_preventCacheClean(): calling it multiple times reuses the same loc await graph._preventCacheClean(); await graph._preventCacheClean(); - t.is(acquireLockSyncStub.callCount, 1, - "acquireLockSync called exactly once — subsequent calls reuse the existing lock"); + t.is(acquireLockStub.callCount, 1, + "acquireLock called exactly once — subsequent calls reuse the existing lock"); t.is(releaseSpy.callCount, 0, "lock not released between calls"); esmock.purge(ProjectGraph); @@ -2065,12 +2065,12 @@ test.serial("_preventCacheClean(): after destroy() releases the lock, a new call const sinon = t.context.sinon; const releaseSpy = sinon.spy(); - const acquireLockSyncStub = sinon.stub().returns(releaseSpy); + const acquireLockStub = sinon.stub().resolves(releaseSpy); const ProjectGraph = await esmock.p("../../../lib/graph/ProjectGraph.js", { "@ui5/logger": {getLogger: sinon.stub().returns(t.context.log)}, "../../../lib/utils/lock.js": { - acquireLockSync: acquireLockSyncStub, + acquireLock: acquireLockStub, getLockDir: sinon.stub().returns("/test/locks"), CLEANUP_LOCK_NAME: "cache-cleanup.lock", hasActiveLocks: sinon.stub().resolves(false), @@ -2081,11 +2081,11 @@ test.serial("_preventCacheClean(): after destroy() releases the lock, a new call // First acquisition await graph._preventCacheClean(); - t.is(acquireLockSyncStub.callCount, 1, "lock acquired on first call"); + t.is(acquireLockStub.callCount, 1, "lock acquired on first call"); // Repeated call — still the same lock, not re-acquired await graph._preventCacheClean(); - t.is(acquireLockSyncStub.callCount, 1, "no second acquisition while lock is held"); + t.is(acquireLockStub.callCount, 1, "no second acquisition while lock is held"); // Release via destroy() graph.destroy(); @@ -2093,7 +2093,7 @@ test.serial("_preventCacheClean(): after destroy() releases the lock, a new call // After release, _preventCacheClean() must acquire a fresh lock await graph._preventCacheClean(); - t.is(acquireLockSyncStub.callCount, 2, + t.is(acquireLockStub.callCount, 2, "new lock acquired after destroy() — lock is not held anymore"); esmock.purge(ProjectGraph); From 9b00241e1cfbb742319cb15d2234a86f0dd8ace3 Mon Sep 17 00:00:00 2001 From: d3xter666 Date: Thu, 16 Jul 2026 15:21:25 +0300 Subject: [PATCH 114/114] docs: Update summary to reference the lock doc --- CACHE_CLEAN_SUMAMRY.md | 66 ++++-------------------------------------- 1 file changed, 5 insertions(+), 61 deletions(-) diff --git a/CACHE_CLEAN_SUMAMRY.md b/CACHE_CLEAN_SUMAMRY.md index d5bc1a35c1f..dca797e93cc 100644 --- a/CACHE_CLEAN_SUMAMRY.md +++ b/CACHE_CLEAN_SUMAMRY.md @@ -8,71 +8,15 @@ What started as a simple `rm -rf ~/.ui5/framework` replacement grew into a cross ## 1. General locking and locks concept -### 1.1 Sync vs async lock +The complete locking design is documented in [doc-process-coordination-locks.md](./doc-process-coordination-locks.md). That document covers: why file-based locks are needed (separate OS processes), why the `lockfile` package was chosen, the two lock types (graph lock and cleanup lock), their lifecycles, staleness and mtime refresh mechanics, sync vs async acquisition trade-offs, release safety rules, and open questions around `ProjectGraph` ownership and v4 backward compatibility. -**Current state:** The codebase uses mostly the async variant from [`packages/project/lib/utils/lock.js`](../packages/project/lib/utils/lock.js): +**What we struggled with and learned:** -**Why sync was needed in the first place:** There are places where async -acquire is simply not possible: constructors, synchronous methods, etc. -The strong side of having a synchronous lock mechanism is that it also -locks the main thread, so there is no possibility for any race conditions. +Locking turned out to be the hardest part of this feature. We kept discovering new unprotected windows — first only downloads, then installs, then the whole graph lifetime. The core lesson was a shift in framing: **"where to lock" is really "who holds the resource, and for how long"** — not "which function writes or deletes it." Once we identified `ProjectGraph` as the right lock owner (it holds live references for its entire lifetime), the rest followed. -**The hard problem with `lockSync`:** The `lockfile` library itself makes the limitation explicit — if you pass `opts.wait` or `opts.retryWait` to `lockSync`, it throws: - -``` -'opts.wait not supported sync for obvious reasons' -``` - -This means a sync lock **cannot wait** for a contended lock to become free. It either acquires immediately or throws. The only mitigation is `opts.retries` (fixed-count retries, each blocking the event loop). This works only when: -- The lock path is unique per process (no realistic contention), or -- A brief blocking retry loop is acceptable. - -`ProjectGraph` uses a unique path per-process-per-instance (`graph-{pid}-{randomHex}.lock`), so contention is impossible and `retries` is not needed. But this constraint is fragile — it relies on the path being unique rather than on a genuine no-contention guarantee. - -**Async `lock()` advantages that sync lacks:** -- `opts.wait` — polls for up to N ms before giving up, without blocking the event loop. -- `opts.retryWait` — configurable delay between retries. -- Non-blocking: other async tasks can continue while waiting. - -**Lesson:** Sync locks are error-prone by design — they block the event loop and cannot wait for contention to resolve. They should only be used in narrow cases where the lock path is guaranteed unique (no contention possible) and the call site is genuinely sync-only with no path to async. - -### 1.2 Where to lock - -**What we did:** The lock scope moved several times during the PR: -- First only around per-package **downloads**. -- Then discovered a second window: the **install** phase (copying files into the cache) was unprotected. -- Then extended to cover the **whole `ProjectGraph` lifecycle** (construction → build/serve → destroy), because the graph holds live references to framework paths *after* download completes. - -The final answer: the graph is the coordination unit. `_preventCacheClean()` is called from `enrichProjectGraph` (framework resolution) and again from `build()`/`serve()` — guarded by an idempotency check so only one lock exists per instance. - -**Lesson:** "Where to lock" is really "what is the unit of work that holds the resource?" We kept finding new windows because we were thinking in terms of individual operations (download, install) rather than the lifetime of the consumer (the graph). The right question is *"who is using the files, and for how long?"* — not *"which function deletes/writes them?"* - -**Open question:** Granularity of the lock! Wouldn't it be easier to simply lock the command in `@ui5/cli` i.e. `cache clean`? But what about if some process uses the `@ui5/project`'s API standalone in parallel? This way we lock the command, but not the API itself. The answer can be found when we agree on the Lesson above. - -### 1.3 How to lock - -**What we did:** Chose the `lockfile` npm package (file-based advisory locks in `~/.ui5/locks/`) over an in-process mutex, because coordination must span **separate OS processes** (`ui5 build`, `ui5 serve`, `ui5 cache clean`, and external `@ui5/*` API consumers), not just async tasks in one process. - -Key mechanics we had to get right: -- **Staleness** (`LOCK_STALE_MS = 60000`): a crashed process must not deadlock everyone forever. `hasActiveLocks` treats older-than-stale locks as dead and garbage-collects them. -- **mtime refresh** (`LOCK_REFRESH_INTERVAL_MS = LOCK_STALE_MS * 0.6`): a long-running server would otherwise look "stale" after 60s and get its lock stolen. An interval refreshes the mtime; `interval.unref()` so it doesn't keep the process alive. -- **Acquire-then-check ordering** (TOCTOU): the cache clean must acquire its cleanup lock *first*, then check `hasActiveLocks`, so a concurrently starting installer is guaranteed to see one of the two locks. - -**Lesson:** File-based cross-process locking has a surprising number of correctness traps (staleness vs. refresh vs. the check/acquire order). A from-scratch concept should treat these as first-class requirements. - -### 1.4 How to release to prevent deadlocks - -Lock release must be guaranteed regardless of how control exits a protected section. Multiple safety layers are needed because a stranded lock is worse than no lock: - -- **Make release idempotent** — the release function should be safe to call multiple times (e.g. from both an explicit early-exit path and a `finally` block). Without idempotency, double-release either throws or corrupts state. -- **Always use `try/finally`** — any exception, early return, or branch that bypasses an explicit `release()` call is a latent deadlock. The `finally` block is the only reliable guarantee. -- **Await async work inside the lock** — if the protected section is async, the `finally` must not run until the work resolves. `return callback()` inside a `try/finally` releases the lock immediately when the Promise is returned, before it settles. The correct form is `return await callback()`. -- **Explicit release + automatic backstop** — graceful shutdown calls `release()` explicitly. Abnormal termination (signals, crashes) is covered by the `lockfile` library's signal-exit handler, which removes all known locks on process exit. Neither alone is sufficient. - -**Lesson:** Deadlock prevention is layered: idempotent release + `try/finally` + `await` discipline + signal-exit backstop. Each layer covers a failure mode the others miss. The `await` mistake in particular is subtle — a single dropped keyword looks harmless but silently allows concurrent access to the protected resource. - -**Lifecycle / cleanup ownership of `ProjectGraph`.** JS has no destructor; the graph relies on an explicit `destroy()` plus `lockfile`'s signal-exit backstop. `BuildServer` and CLI commands (`tree.js`, `build.js`) must remember to call `destroy()`. This "who calls destroy, and when" question is a real design gap that spans the whole `ProjectGraph` public API — arguably bigger than the locking topic itself. +The other recurring challenge was getting release right. Every early-exit path and every async callback inside a protected section is a potential deadlock. We introduced a regression during the PR by dropping a single `await` keyword — `return callback()` instead of `return await callback()` — which released the lock before the async work finished. This kind of bug is silent and hard to spot in review. +The open questions that still need team decisions are in [Unresolved Questions](./doc-process-coordination-locks.md#unresolved-questions-and-bikeshedding) of the RFC. --- ## 2. Performance & UX