@teambit/isolator 1.0.1002 → 1.0.1003
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capsule-cache.d.ts +192 -0
- package/dist/capsule-cache.js +733 -0
- package/dist/capsule-cache.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -1
- package/dist/isolator.composition.d.ts +2 -1
- package/dist/isolator.main.runtime.d.ts +16 -0
- package/dist/isolator.main.runtime.js +57 -6
- package/dist/isolator.main.runtime.js.map +1 -1
- package/dist/{preview-1780084871933.js → preview-1780507494169.js} +2 -2
- package/package.json +24 -24
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.CapsuleCache = exports.CAPSULE_TRASH_DIR = exports.CAPSULE_ORIGIN_FILE = void 0;
|
|
7
|
+
function _fsExtra() {
|
|
8
|
+
const data = _interopRequireDefault(require("fs-extra"));
|
|
9
|
+
_fsExtra = function () {
|
|
10
|
+
return data;
|
|
11
|
+
};
|
|
12
|
+
return data;
|
|
13
|
+
}
|
|
14
|
+
function _uuid() {
|
|
15
|
+
const data = require("uuid");
|
|
16
|
+
_uuid = function () {
|
|
17
|
+
return data;
|
|
18
|
+
};
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
function _path() {
|
|
22
|
+
const data = _interopRequireDefault(require("path"));
|
|
23
|
+
_path = function () {
|
|
24
|
+
return data;
|
|
25
|
+
};
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
function _child_process() {
|
|
29
|
+
const data = require("child_process");
|
|
30
|
+
_child_process = function () {
|
|
31
|
+
return data;
|
|
32
|
+
};
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
function _pMap() {
|
|
36
|
+
const data = _interopRequireDefault(require("p-map"));
|
|
37
|
+
_pMap = function () {
|
|
38
|
+
return data;
|
|
39
|
+
};
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
function _harmonyModules() {
|
|
43
|
+
const data = require("@teambit/harmony.modules.concurrency");
|
|
44
|
+
_harmonyModules = function () {
|
|
45
|
+
return data;
|
|
46
|
+
};
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
function _harmonyModules2() {
|
|
50
|
+
const data = require("@teambit/harmony.modules.feature-toggle");
|
|
51
|
+
_harmonyModules2 = function () {
|
|
52
|
+
return data;
|
|
53
|
+
};
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
function _legacy() {
|
|
57
|
+
const data = require("@teambit/legacy.constants");
|
|
58
|
+
_legacy = function () {
|
|
59
|
+
return data;
|
|
60
|
+
};
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
64
|
+
/**
|
|
65
|
+
* Manages the global capsules cache: origin markers, fast-delete + trash sweep, and the
|
|
66
|
+
* prune pipeline (manual `bit capsule prune` and the gated auto-prune trigger).
|
|
67
|
+
*
|
|
68
|
+
* Owned by `IsolatorMain` but operationally independent — the isolator constructs an
|
|
69
|
+
* instance, registers its hooks, and delegates the externally-visible cache-management
|
|
70
|
+
* methods (`deleteCapsules`, `pruneCapsules`, `listAllCapsuleRoots`) to it. Keeps
|
|
71
|
+
* `isolator.main.runtime.ts` focused on creating isolated component environments rather
|
|
72
|
+
* than also being responsible for evicting them.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Marker file written into every capsule dir we manage. Its presence tells the prune logic
|
|
77
|
+
* what kind of dir this is, where it came from, and (via its mtime) when it was last used.
|
|
78
|
+
*/
|
|
79
|
+
const CAPSULE_ORIGIN_FILE = exports.CAPSULE_ORIGIN_FILE = '.bit-capsule-origin.json';
|
|
80
|
+
const CAPSULE_TRASH_DIR = exports.CAPSULE_TRASH_DIR = '.trash';
|
|
81
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
82
|
+
const VALID_CAPSULE_KINDS = new Set(['workspace', 'scope-aspects-root', 'scope-aspect', 'scope']);
|
|
83
|
+
function toFiniteNumber(value) {
|
|
84
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
85
|
+
const n = Number(value);
|
|
86
|
+
return Number.isFinite(n) ? n : undefined;
|
|
87
|
+
}
|
|
88
|
+
class CapsuleCache {
|
|
89
|
+
constructor(logger, cli, configStore, /** Thunk so the cache stays independent of `GlobalConfigMain` — IsolatorMain forwards it. */
|
|
90
|
+
getRootDir) {
|
|
91
|
+
this.logger = logger;
|
|
92
|
+
this.cli = cli;
|
|
93
|
+
this.configStore = configStore;
|
|
94
|
+
this.getRootDir = getRootDir;
|
|
95
|
+
}
|
|
96
|
+
async deleteCapsules(rootDir) {
|
|
97
|
+
const dirToDelete = rootDir || this.getRootDir();
|
|
98
|
+
const marker = await this.readOriginMarker(dirToDelete);
|
|
99
|
+
this.logger.debug(`[capsule-delete] removing ${dirToDelete}` + (marker?.originPath ? ` origin=${marker.originPath}` : ''));
|
|
100
|
+
await this.scheduleFastDelete(dirToDelete);
|
|
101
|
+
return dirToDelete;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Move a capsule dir into a sibling `.trash/<uuid>/` so it disappears from the cache
|
|
106
|
+
* immediately (same-filesystem rename is O(1)), then kick off a detached `rm -rf` so the
|
|
107
|
+
* actual byte-by-byte cleanup happens in the background. This avoids the multi-second
|
|
108
|
+
* stalls users see when deleting capsules with thousands of files.
|
|
109
|
+
*/
|
|
110
|
+
async scheduleFastDelete(dir) {
|
|
111
|
+
const exists = await _fsExtra().default.pathExists(dir);
|
|
112
|
+
if (!exists) return;
|
|
113
|
+
const globalRoot = this.getRootDir();
|
|
114
|
+
// Edge case: deleting the global root itself. We can't move a dir into its own
|
|
115
|
+
// child (`.trash/...`), so just do a direct remove. This is rare — only `bit
|
|
116
|
+
// capsule delete --all` hits it.
|
|
117
|
+
if (_path().default.resolve(dir) === _path().default.resolve(globalRoot)) {
|
|
118
|
+
await _fsExtra().default.remove(dir);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const trashRoot = _path().default.join(globalRoot, CAPSULE_TRASH_DIR);
|
|
122
|
+
await _fsExtra().default.ensureDir(trashRoot);
|
|
123
|
+
const trashTarget = _path().default.join(trashRoot, `${_path().default.basename(dir)}-${(0, _uuid().v4)()}`);
|
|
124
|
+
try {
|
|
125
|
+
await _fsExtra().default.move(dir, trashTarget, {
|
|
126
|
+
overwrite: true
|
|
127
|
+
});
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Likely cross-device — fall back to a synchronous remove.
|
|
130
|
+
this.logger.debug(`scheduleFastDelete: rename failed for ${dir}, falling back to fs.remove (${err.message})`);
|
|
131
|
+
await _fsExtra().default.remove(dir);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Run through the gated sweepTrashAsync path so we never have more than one sweep
|
|
135
|
+
// running concurrently — even if many bit processes are moving things to trash.
|
|
136
|
+
this.sweepTrashAsync();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sweep the `.trash` dir in a detached background process. Gated by a PID-stamped
|
|
141
|
+
* lock so we never have more than one sweep running at a time across all concurrent
|
|
142
|
+
* bit processes — previously we spawned one per `bit` invocation and they piled up
|
|
143
|
+
* into the thousands, saturating disk I/O.
|
|
144
|
+
*/
|
|
145
|
+
sweepTrashAsync() {
|
|
146
|
+
const trashRoot = _path().default.join(this.getRootDir(), CAPSULE_TRASH_DIR);
|
|
147
|
+
// No trash → nothing to do. Cheap synchronous check avoids spawning a process at all.
|
|
148
|
+
if (!_fsExtra().default.existsSync(trashRoot)) return;
|
|
149
|
+
const lockPath = _path().default.join(this.getRootDir(), '.trash-sweep.lock');
|
|
150
|
+
if (this.isSweepLockActive(lockPath)) {
|
|
151
|
+
this.logger.debug(`trash sweep already running (per ${lockPath}), skipping`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
_fsExtra().default.writeFileSync(lockPath, String(process.pid), {
|
|
156
|
+
flag: 'w'
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.logger.debug(`failed to write sweep lock at ${lockPath}: ${err.message}`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.spawnDetachedSweep(trashRoot, lockPath);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A sweep lock is "active" if the PID it names is still running. If the PID file
|
|
167
|
+
* exists but the process is gone (e.g. crashed mid-sweep), we treat it as stale and
|
|
168
|
+
* allow a new sweep to claim it.
|
|
169
|
+
*/
|
|
170
|
+
isSweepLockActive(lockPath) {
|
|
171
|
+
let pidStr;
|
|
172
|
+
try {
|
|
173
|
+
pidStr = _fsExtra().default.readFileSync(lockPath, 'utf8').trim();
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const pid = Number(pidStr);
|
|
178
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
179
|
+
try {
|
|
180
|
+
// Signal 0 = "is this PID alive?" — no actual signal sent.
|
|
181
|
+
process.kill(pid, 0);
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Register a process-exit hook that, at most once per ~24h, spawns a detached
|
|
190
|
+
* `bit capsule prune` child so the actual work runs out-of-process and never delays
|
|
191
|
+
* the parent's exit. Gated by the mtime of a stamp file under the capsules root so
|
|
192
|
+
* concurrent Bit invocations can't all trigger it at once, and behind the
|
|
193
|
+
* `capsule-auto-prune` feature flag while the behavior is being validated.
|
|
194
|
+
*/
|
|
195
|
+
registerAutoPruneHook() {
|
|
196
|
+
this.cli.registerOnBeforeExit(async () => {
|
|
197
|
+
try {
|
|
198
|
+
await this.maybeAutoPrune();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.logger.debug(`auto-prune skipped due to error: ${err?.message ?? err}`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async maybeAutoPrune() {
|
|
205
|
+
// Experimental: the automatic prune only runs for users who opt in via the feature flag
|
|
206
|
+
// (`BIT_FEATURES=capsule-auto-prune` or `bit config set features=capsule-auto-prune`).
|
|
207
|
+
// Until it's promoted to GA, the default behavior is unchanged — capsules are never
|
|
208
|
+
// auto-deleted. The manual `bit capsule prune` command is always available regardless.
|
|
209
|
+
if (!(0, _harmonyModules2().isFeatureEnabled)(_harmonyModules2().CAPSULE_AUTO_PRUNE)) return;
|
|
210
|
+
|
|
211
|
+
// configStore may surface this as either string `'false'` (from `bit config set`)
|
|
212
|
+
// or boolean `false` (from a hand-edited JSON config) — accept both. This is a
|
|
213
|
+
// secondary escape hatch for once the feature is GA and the flag is removed.
|
|
214
|
+
const enabled = this.configStore.getConfig(_legacy().CFG_CAPSULES_AUTO_PRUNE);
|
|
215
|
+
if (enabled === 'false' || enabled === false) return;
|
|
216
|
+
const root = this.getRootDir();
|
|
217
|
+
if (!(await _fsExtra().default.pathExists(root))) return;
|
|
218
|
+
const stampPath = _path().default.join(root, '.last-capsule-prune');
|
|
219
|
+
const isStampFresh = async () => {
|
|
220
|
+
try {
|
|
221
|
+
const stat = await _fsExtra().default.stat(stampPath);
|
|
222
|
+
return Date.now() - stat.mtime.getTime() < ONE_DAY_MS;
|
|
223
|
+
} catch {
|
|
224
|
+
return false; // missing — needs a prune
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
// Fast path: stamp is recent, nothing to do (no contention here).
|
|
228
|
+
if (await isStampFresh()) return;
|
|
229
|
+
|
|
230
|
+
// Atomic claim: only one process across all concurrent bit invocations may win the
|
|
231
|
+
// daily slot. `wx` is O_CREAT|O_EXCL — it throws if the lock already exists, so the
|
|
232
|
+
// check-and-write below can't race. The lock is held only for the few ms it takes to
|
|
233
|
+
// re-check the stamp and spawn the detached child, then removed in `finally`.
|
|
234
|
+
const claimPath = `${stampPath}.claim`;
|
|
235
|
+
let claimed = false;
|
|
236
|
+
try {
|
|
237
|
+
await _fsExtra().default.close(await _fsExtra().default.open(claimPath, 'wx'));
|
|
238
|
+
claimed = true;
|
|
239
|
+
} catch {
|
|
240
|
+
// Another process is mid-claim, or a previous run leaked the lock. If it's stale
|
|
241
|
+
// (older than the daily window), reclaim it by removing + re-opening with O_EXCL —
|
|
242
|
+
// if two processes race the reclaim, only one's `wx` open succeeds and the other
|
|
243
|
+
// yields. Otherwise yield.
|
|
244
|
+
try {
|
|
245
|
+
const claimStat = await _fsExtra().default.stat(claimPath);
|
|
246
|
+
if (Date.now() - claimStat.mtime.getTime() < ONE_DAY_MS) return;
|
|
247
|
+
await _fsExtra().default.remove(claimPath);
|
|
248
|
+
await _fsExtra().default.close(await _fsExtra().default.open(claimPath, 'wx'));
|
|
249
|
+
claimed = true;
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
// Re-check under the lock: a process that just held it may have refreshed the stamp.
|
|
256
|
+
if (await isStampFresh()) return;
|
|
257
|
+
await _fsExtra().default.outputFile(stampPath, '');
|
|
258
|
+
|
|
259
|
+
// Guard against non-numeric/empty config (NaN) and negative values (which would
|
|
260
|
+
// invert the age cutoff / size target and wipe the whole cache).
|
|
261
|
+
const olderThanDays = Math.max(0, toFiniteNumber(this.configStore.getConfig(_legacy().CFG_CAPSULES_MAX_AGE_DAYS)) ?? 30);
|
|
262
|
+
const sizeTargetGb = Math.max(0, toFiniteNumber(this.configStore.getConfig(_legacy().CFG_CAPSULES_MAX_SIZE_GB)) ?? 10);
|
|
263
|
+
this.logger.debug(`[auto-prune] spawning detached child. olderThanDays=${olderThanDays}, sizeTargetGb=${sizeTargetGb}`);
|
|
264
|
+
this.spawnDetachedAutoPrune(olderThanDays, sizeTargetGb);
|
|
265
|
+
} finally {
|
|
266
|
+
if (claimed) {
|
|
267
|
+
try {
|
|
268
|
+
await _fsExtra().default.remove(claimPath);
|
|
269
|
+
} catch {
|
|
270
|
+
// ignore — a stale claim lock is reclaimed by the age check above
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fire-and-forget: spawn a detached child running `bit capsule prune`. Using the same
|
|
278
|
+
* bit binary that's currently running (via process.argv[0] + argv[1]) so we don't depend
|
|
279
|
+
* on PATH. stdio is ignored so nothing leaks to the user's terminal.
|
|
280
|
+
*
|
|
281
|
+
* Recursion guard: the child also runs onBeforeExit → maybeAutoPrune, but it reads the
|
|
282
|
+
* stamp file that we just wrote and bails out before re-spawning.
|
|
283
|
+
*/
|
|
284
|
+
spawnDetachedAutoPrune(olderThanDays, sizeTargetGb) {
|
|
285
|
+
const bitEntry = process.argv[1];
|
|
286
|
+
if (!bitEntry) {
|
|
287
|
+
this.logger.debug('[auto-prune] cannot detach: process.argv[1] is empty');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const child = (0, _child_process().spawn)(process.execPath, [bitEntry, 'capsule', 'prune', '--older-than', String(olderThanDays), '--size-target', String(sizeTargetGb)], {
|
|
292
|
+
detached: true,
|
|
293
|
+
stdio: 'ignore',
|
|
294
|
+
windowsHide: true
|
|
295
|
+
});
|
|
296
|
+
child.unref();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.logger.debug(`[auto-prune] failed to spawn detached child: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Spawn one detached Node process that recursively removes `trashRoot`. Using
|
|
304
|
+
* `process.execPath` with an inline `fs.rmSync` keeps this portable across macOS,
|
|
305
|
+
* Linux, and Windows (where there's no `rm` binary). When `lockPath` is given, the
|
|
306
|
+
* child clears the lock on exit so the next bit invocation can claim a fresh sweep slot.
|
|
307
|
+
*/
|
|
308
|
+
spawnDetachedSweep(trashRoot, lockPath) {
|
|
309
|
+
const script = lockPath ? `try { require('fs').rmSync(${JSON.stringify(trashRoot)}, { recursive: true, force: true }); } finally { try { require('fs').rmSync(${JSON.stringify(lockPath)}, { force: true }); } catch (_) {} }` : `require('fs').rmSync(${JSON.stringify(trashRoot)}, { recursive: true, force: true })`;
|
|
310
|
+
try {
|
|
311
|
+
const child = (0, _child_process().spawn)(process.execPath, ['-e', script], {
|
|
312
|
+
detached: true,
|
|
313
|
+
stdio: 'ignore',
|
|
314
|
+
windowsHide: true
|
|
315
|
+
});
|
|
316
|
+
child.unref();
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this.logger.debug(`failed to spawn detached trash sweep: ${err.message}`);
|
|
319
|
+
// Don't leak the lock if the spawn itself failed. Use fs-extra's removeSync
|
|
320
|
+
// (rmSync isn't in the @types/fs-extra version pinned by this component).
|
|
321
|
+
if (lockPath) {
|
|
322
|
+
try {
|
|
323
|
+
_fsExtra().default.removeSync(lockPath);
|
|
324
|
+
} catch {
|
|
325
|
+
// ignore
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Write the origin marker if missing; otherwise just bump its mtime so it reflects
|
|
333
|
+
* "last used at". Failures are non-fatal — markers are best-effort metadata.
|
|
334
|
+
*/
|
|
335
|
+
async ensureOriginMarker(dir, kind, originPath) {
|
|
336
|
+
const markerPath = _path().default.join(dir, CAPSULE_ORIGIN_FILE);
|
|
337
|
+
try {
|
|
338
|
+
if (await _fsExtra().default.pathExists(markerPath)) {
|
|
339
|
+
const now = new Date();
|
|
340
|
+
await _fsExtra().default.utimes(markerPath, now, now);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const marker = {
|
|
344
|
+
originPath,
|
|
345
|
+
createdAt: new Date().toISOString(),
|
|
346
|
+
kind
|
|
347
|
+
};
|
|
348
|
+
await _fsExtra().default.outputJson(markerPath, marker);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
this.logger.debug(`failed to write capsule origin marker at ${markerPath}: ${err.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Mark all per-component capsule subdirs as scope-aspect kind, originated from the
|
|
356
|
+
* scope-aspects root. Used right after a scope-aspects isolation.
|
|
357
|
+
*/
|
|
358
|
+
async ensureAspectCapsuleMarkers(capsuleList, rootOriginPath) {
|
|
359
|
+
await Promise.all(capsuleList.map(async capsule => {
|
|
360
|
+
if (!_fsExtra().default.existsSync(capsule.path)) return;
|
|
361
|
+
await this.ensureOriginMarker(capsule.path, 'scope-aspect', rootOriginPath);
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Single source of truth for the dated-capsules date-dir name (`YYYY-M-D`, no zero-pad).
|
|
367
|
+
* Used by `getCapsulesRootDir` when writing and `pruneDatedCapsulesChildren` when reading,
|
|
368
|
+
* so the two can never drift.
|
|
369
|
+
*/
|
|
370
|
+
getDatedCapsuleDirName(date = new Date()) {
|
|
371
|
+
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Standard filter for "real" capsule subdirs we may walk or prune. Skips files, the trash
|
|
376
|
+
* dir (dot-prefixed), `node_modules`, and any other hidden/internal dir.
|
|
377
|
+
*/
|
|
378
|
+
isPrunableSubdir(entry) {
|
|
379
|
+
return entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Combined marker read + last-used resolution for a capsule dir: one `readJson`, one
|
|
384
|
+
* fallback `stat`. Replaces the three-syscall (`readMarker` + `getOriginMarkerMtime` +
|
|
385
|
+
* dir `stat`) idiom that was copy-pasted across the prune walks.
|
|
386
|
+
*/
|
|
387
|
+
async readMarkerInfo(dir) {
|
|
388
|
+
const marker = await this.readOriginMarker(dir);
|
|
389
|
+
if (marker) {
|
|
390
|
+
const mtime = await this.getOriginMarkerMtime(dir);
|
|
391
|
+
if (mtime) return {
|
|
392
|
+
marker,
|
|
393
|
+
lastUsedMs: mtime.getTime()
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const stat = await _fsExtra().default.stat(dir).catch(() => undefined);
|
|
397
|
+
return {
|
|
398
|
+
marker,
|
|
399
|
+
lastUsedMs: (stat?.mtime ?? new Date(0)).getTime()
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
async readOriginMarker(dir) {
|
|
403
|
+
try {
|
|
404
|
+
const raw = await _fsExtra().default.readJson(_path().default.join(dir, CAPSULE_ORIGIN_FILE));
|
|
405
|
+
if (raw && typeof raw.originPath === 'string' &&
|
|
406
|
+
// Reject unknown kinds (corrupted markers or values from a future Bit) so they
|
|
407
|
+
// fall through to the 'unmarked' path in pruneCapsules rather than silently
|
|
408
|
+
// skipping deletion.
|
|
409
|
+
VALID_CAPSULE_KINDS.has(raw.kind)) {
|
|
410
|
+
return raw;
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
// missing or malformed — treat as unmarked
|
|
414
|
+
}
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
async getOriginMarkerMtime(dir) {
|
|
418
|
+
try {
|
|
419
|
+
const stat = await _fsExtra().default.stat(_path().default.join(dir, CAPSULE_ORIGIN_FILE));
|
|
420
|
+
return stat.mtime;
|
|
421
|
+
} catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Walk the global capsules root and return entries with their classification, size, and
|
|
428
|
+
* last-used time. Used by prune and by `bit capsule list`.
|
|
429
|
+
*/
|
|
430
|
+
async listAllCapsuleRoots(opts = {}) {
|
|
431
|
+
const withSizes = opts.withSizes !== false;
|
|
432
|
+
const root = this.getRootDir();
|
|
433
|
+
if (!(await _fsExtra().default.pathExists(root))) return [];
|
|
434
|
+
const entries = await _fsExtra().default.readdir(root, {
|
|
435
|
+
withFileTypes: true
|
|
436
|
+
});
|
|
437
|
+
const subdirs = entries.filter(e => this.isPrunableSubdir(e));
|
|
438
|
+
// Bounded concurrency: on a multi-GB cache with hundreds of subdirs and tens of
|
|
439
|
+
// thousands of files per subdir, an unbounded Promise.all of recursive size walks
|
|
440
|
+
// can hit OS file-descriptor limits (EMFILE) and thrash disk.
|
|
441
|
+
return (0, _pMap().default)(subdirs, async entry => {
|
|
442
|
+
const subPath = _path().default.join(root, entry.name);
|
|
443
|
+
const {
|
|
444
|
+
marker,
|
|
445
|
+
lastUsedMs
|
|
446
|
+
} = await this.readMarkerInfo(subPath);
|
|
447
|
+
const sizeBytes = withSizes ? await this.computeDirSize(subPath) : 0;
|
|
448
|
+
return {
|
|
449
|
+
path: subPath,
|
|
450
|
+
kind: marker?.kind ?? 'unmarked',
|
|
451
|
+
originPath: marker?.originPath,
|
|
452
|
+
lastUsedMs,
|
|
453
|
+
sizeBytes
|
|
454
|
+
};
|
|
455
|
+
}, {
|
|
456
|
+
concurrency: (0, _harmonyModules().concurrentIOLimit)()
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Sum sizes of all entries under `dir`. Tolerant of symlinks and permission errors —
|
|
462
|
+
* any failure returns the partial sum so we never throw from the prune path.
|
|
463
|
+
* Uses bounded concurrency to avoid EMFILE on deep trees.
|
|
464
|
+
*/
|
|
465
|
+
async computeDirSize(dir) {
|
|
466
|
+
let total = 0;
|
|
467
|
+
const concurrency = (0, _harmonyModules().concurrentIOLimit)();
|
|
468
|
+
const walk = async current => {
|
|
469
|
+
let entries;
|
|
470
|
+
try {
|
|
471
|
+
entries = await _fsExtra().default.readdir(current, {
|
|
472
|
+
withFileTypes: true
|
|
473
|
+
});
|
|
474
|
+
} catch {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await (0, _pMap().default)(entries, async entry => {
|
|
478
|
+
const p = _path().default.join(current, entry.name);
|
|
479
|
+
if (entry.isDirectory()) {
|
|
480
|
+
await walk(p);
|
|
481
|
+
} else if (entry.isFile()) {
|
|
482
|
+
try {
|
|
483
|
+
const st = await _fsExtra().default.lstat(p);
|
|
484
|
+
total += st.size;
|
|
485
|
+
} catch {
|
|
486
|
+
// ignore
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}, {
|
|
490
|
+
concurrency
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
await walk(dir);
|
|
494
|
+
return total;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Apply the prune rules from the plan:
|
|
499
|
+
* - workspace caps: deleted unconditionally (unless keepWorkspaceCaps)
|
|
500
|
+
* - scope-aspects-root: never deleted as a whole; per-aspect-version children pruned by age
|
|
501
|
+
* - scope caps and unmarked dirs older than threshold: deleted
|
|
502
|
+
* - orphans (marker says originPath gone): deleted
|
|
503
|
+
* - after the above, if sizeTargetGb given and size still exceeds it, evict oldest-first
|
|
504
|
+
*/
|
|
505
|
+
async pruneCapsules(opts = {}) {
|
|
506
|
+
// Clamp to >= 0: a negative age would put the cutoff in the future (everything looks
|
|
507
|
+
// "too old" → whole cache deleted); a negative size target would force evicting
|
|
508
|
+
// everything. Both are almost certainly user error, so floor them at 0.
|
|
509
|
+
const olderThanDays = Math.max(0, opts.olderThanDays ?? 30);
|
|
510
|
+
const sizeTargetGb = opts.sizeTargetGb === undefined ? undefined : Math.max(0, opts.sizeTargetGb);
|
|
511
|
+
const includeOrphans = opts.includeOrphans !== false;
|
|
512
|
+
const keepWorkspaceCaps = opts.keepWorkspaceCaps === true;
|
|
513
|
+
const dryRun = opts.dryRun === true;
|
|
514
|
+
// Size accounting requires an expensive recursive lstat across the whole cache. Skip
|
|
515
|
+
// it by default so the foreground command returns in ms (deletes are O(1) renames);
|
|
516
|
+
// force on for size-target enforcement and when the caller asks for byte accounting.
|
|
517
|
+
const computeSizes = opts.withSizes === true || sizeTargetGb !== undefined;
|
|
518
|
+
const ageCutoffMs = Date.now() - olderThanDays * ONE_DAY_MS;
|
|
519
|
+
const datedDirName = this.configStore.getConfig(_legacy().CFG_CAPSULES_SCOPES_ASPECTS_DATED_DIR) || 'dated-capsules';
|
|
520
|
+
const roots = await this.listAllCapsuleRoots({
|
|
521
|
+
withSizes: computeSizes
|
|
522
|
+
});
|
|
523
|
+
const totalSizeBefore = computeSizes ? roots.reduce((sum, r) => sum + r.sizeBytes, 0) : 0;
|
|
524
|
+
const removed = [];
|
|
525
|
+
const removeEntry = (p, kind, reason, sizeBytes, originPath) => this.recordRemoval(removed, {
|
|
526
|
+
path: p,
|
|
527
|
+
kind,
|
|
528
|
+
reason,
|
|
529
|
+
sizeBytes,
|
|
530
|
+
originPath
|
|
531
|
+
}, dryRun);
|
|
532
|
+
for (const root of roots) {
|
|
533
|
+
if (_path().default.basename(root.path) === datedDirName) {
|
|
534
|
+
await this.pruneDatedCapsulesChildren(root.path, dryRun, computeSizes, removed);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (root.kind === 'workspace') {
|
|
538
|
+
if (keepWorkspaceCaps) continue;
|
|
539
|
+
await removeEntry(root.path, root.kind, 'workspace-cap', root.sizeBytes, root.originPath);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (root.kind === 'scope' || root.kind === 'unmarked') {
|
|
543
|
+
const orphan = includeOrphans && root.originPath && !(await _fsExtra().default.pathExists(root.originPath));
|
|
544
|
+
const tooOld = root.lastUsedMs < ageCutoffMs;
|
|
545
|
+
if (orphan) {
|
|
546
|
+
await removeEntry(root.path, root.kind, 'orphan', root.sizeBytes, root.originPath);
|
|
547
|
+
} else if (tooOld) {
|
|
548
|
+
// For unmarked dirs, sniff content first to avoid nuking a legacy scope-aspects root.
|
|
549
|
+
if (root.kind === 'unmarked' && (await this.looksLikeAspectsRoot(root.path))) {
|
|
550
|
+
await this.pruneAspectsRootChildren(root.path, ageCutoffMs, dryRun, computeSizes, removed);
|
|
551
|
+
} else {
|
|
552
|
+
await removeEntry(root.path, root.kind, `older-than-${olderThanDays}d`, root.sizeBytes, root.originPath);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (root.kind === 'scope-aspects-root') {
|
|
558
|
+
await this.pruneAspectsRootChildren(root.path, ageCutoffMs, dryRun, computeSizes, removed);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (sizeTargetGb !== undefined) {
|
|
563
|
+
await this.applySizeTarget(sizeTargetGb, removed, dryRun);
|
|
564
|
+
}
|
|
565
|
+
const totalRemovedBytes = removed.reduce((sum, r) => sum + r.sizeBytes, 0);
|
|
566
|
+
// For dry-run, report the *projected* post-prune size so the CLI summary stays
|
|
567
|
+
// internally consistent (cache: X → X − freed). Real prune subtracts the same.
|
|
568
|
+
const totalSizeAfter = Math.max(0, totalSizeBefore - totalRemovedBytes);
|
|
569
|
+
return {
|
|
570
|
+
removed,
|
|
571
|
+
totalRemovedBytes,
|
|
572
|
+
totalSizeBeforeBytes: totalSizeBefore,
|
|
573
|
+
totalSizeAfterBytes: totalSizeAfter,
|
|
574
|
+
dryRun
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Record a removal in the prune report and, unless this is a dry run, actually delete it
|
|
580
|
+
* (fast rename-to-trash). Keeps the "report and delete are gated by the same dryRun flag"
|
|
581
|
+
* invariant in one place so the per-kind prune helpers can't drift apart.
|
|
582
|
+
*/
|
|
583
|
+
async recordRemoval(removed, entry, dryRun) {
|
|
584
|
+
removed.push(entry);
|
|
585
|
+
this.logger.debug(`[capsule-prune] ${dryRun ? 'would remove' : 'removing'} [${entry.kind} · ${entry.reason}] ${entry.path}` + (entry.originPath ? ` origin=${entry.originPath}` : ''));
|
|
586
|
+
if (!dryRun) await this.scheduleFastDelete(entry.path);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The `dated-capsules` dir holds per-date subdirs (`YYYY-M-D`) of in-flight isolation
|
|
591
|
+
* runs. These are recreated on every isolation, so anything that isn't *today*'s
|
|
592
|
+
* subdir is leftover from a previous run and safe to delete. Today's subdir is
|
|
593
|
+
* preserved to avoid racing a concurrent bit process that may still be writing to it.
|
|
594
|
+
*/
|
|
595
|
+
async pruneDatedCapsulesChildren(rootPath, dryRun, computeSizes, removed) {
|
|
596
|
+
const todayDir = this.getDatedCapsuleDirName();
|
|
597
|
+
let entries;
|
|
598
|
+
try {
|
|
599
|
+
entries = await _fsExtra().default.readdir(rootPath, {
|
|
600
|
+
withFileTypes: true
|
|
601
|
+
});
|
|
602
|
+
} catch {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
if (!this.isPrunableSubdir(entry)) continue;
|
|
607
|
+
if (entry.name === todayDir) continue;
|
|
608
|
+
const childPath = _path().default.join(rootPath, entry.name);
|
|
609
|
+
const sizeBytes = computeSizes ? await this.computeDirSize(childPath) : 0;
|
|
610
|
+
await this.recordRemoval(removed, {
|
|
611
|
+
path: childPath,
|
|
612
|
+
kind: 'unmarked',
|
|
613
|
+
reason: 'dated-capsules-not-today',
|
|
614
|
+
sizeBytes
|
|
615
|
+
}, dryRun);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Legacy unmarked dirs may still be a scope-aspects root. Heuristic: a child subdir whose
|
|
621
|
+
* name contains `@` (aspect-version pattern like `teambit.node_node@1.3.4`).
|
|
622
|
+
*/
|
|
623
|
+
async looksLikeAspectsRoot(dir) {
|
|
624
|
+
try {
|
|
625
|
+
const entries = await _fsExtra().default.readdir(dir, {
|
|
626
|
+
withFileTypes: true
|
|
627
|
+
});
|
|
628
|
+
return entries.some(e => e.isDirectory() && e.name.includes('@'));
|
|
629
|
+
} catch {
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Prune per-aspect-version children of a scope-aspects root purely by age (marker mtime,
|
|
636
|
+
* which is touched on every aspect load).
|
|
637
|
+
*
|
|
638
|
+
* Note there's deliberately no orphan check here: a scope-aspect child's `originPath` is
|
|
639
|
+
* the *logical* scope-aspects path (e.g. `<scope.path>-aspects`) used only to hash the
|
|
640
|
+
* capsule root dir name — it need not exist as a real directory, so treating a missing
|
|
641
|
+
* `originPath` as "orphan" would wrongly delete capsules of currently-used aspects.
|
|
642
|
+
* Orphan pruning is still honored elsewhere for `workspace`/`scope` kinds.
|
|
643
|
+
*/
|
|
644
|
+
async pruneAspectsRootChildren(rootPath, ageCutoffMs, dryRun, computeSizes, removed) {
|
|
645
|
+
let entries;
|
|
646
|
+
try {
|
|
647
|
+
entries = await _fsExtra().default.readdir(rootPath, {
|
|
648
|
+
withFileTypes: true
|
|
649
|
+
});
|
|
650
|
+
} catch {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
for (const entry of entries) {
|
|
654
|
+
if (!this.isPrunableSubdir(entry)) continue;
|
|
655
|
+
const childPath = _path().default.join(rootPath, entry.name);
|
|
656
|
+
const {
|
|
657
|
+
marker,
|
|
658
|
+
lastUsedMs
|
|
659
|
+
} = await this.readMarkerInfo(childPath);
|
|
660
|
+
if (lastUsedMs < ageCutoffMs) {
|
|
661
|
+
const sizeBytes = computeSizes ? await this.computeDirSize(childPath) : 0;
|
|
662
|
+
await this.recordRemoval(removed, {
|
|
663
|
+
path: childPath,
|
|
664
|
+
kind: 'scope-aspect',
|
|
665
|
+
reason: 'aspect-older-than-cutoff',
|
|
666
|
+
sizeBytes,
|
|
667
|
+
originPath: marker?.originPath
|
|
668
|
+
}, dryRun);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* After the standard prune, if total still exceeds the target, keep evicting the
|
|
675
|
+
* oldest remaining aspect-version subdirs until under the limit.
|
|
676
|
+
*/
|
|
677
|
+
async applySizeTarget(sizeTargetGb, removed, dryRun) {
|
|
678
|
+
const targetBytes = sizeTargetGb * 1024 * 1024 * 1024;
|
|
679
|
+
const removedPaths = new Set(removed.map(r => r.path));
|
|
680
|
+
// Re-walk what's left (one pass, with sizes) to find both the current total and the
|
|
681
|
+
// oldest aspect-version children to evict.
|
|
682
|
+
const roots = await this.listAllCapsuleRoots();
|
|
683
|
+
const totalBytes = roots.reduce((sum, r) => sum + r.sizeBytes, 0);
|
|
684
|
+
const aspectChildren = [];
|
|
685
|
+
for (const root of roots) {
|
|
686
|
+
if (root.kind !== 'scope-aspects-root' && !(root.kind === 'unmarked' && (await this.looksLikeAspectsRoot(root.path)))) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
let entries = [];
|
|
690
|
+
try {
|
|
691
|
+
entries = await _fsExtra().default.readdir(root.path, {
|
|
692
|
+
withFileTypes: true
|
|
693
|
+
});
|
|
694
|
+
} catch {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
for (const entry of entries) {
|
|
698
|
+
if (!this.isPrunableSubdir(entry)) continue;
|
|
699
|
+
const childPath = _path().default.join(root.path, entry.name);
|
|
700
|
+
if (removedPaths.has(childPath)) continue;
|
|
701
|
+
const {
|
|
702
|
+
marker,
|
|
703
|
+
lastUsedMs
|
|
704
|
+
} = await this.readMarkerInfo(childPath);
|
|
705
|
+
const sizeBytes = await this.computeDirSize(childPath);
|
|
706
|
+
aspectChildren.push({
|
|
707
|
+
path: childPath,
|
|
708
|
+
lastUsedMs,
|
|
709
|
+
sizeBytes,
|
|
710
|
+
originPath: marker?.originPath
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
aspectChildren.sort((a, b) => a.lastUsedMs - b.lastUsedMs);
|
|
715
|
+
// In dry-run the standard-prune entries are still on disk (counted in totalBytes), so
|
|
716
|
+
// subtract them; in a real run they were already moved to trash and excluded from the walk.
|
|
717
|
+
let remainingBytes = totalBytes - (dryRun ? removed.reduce((s, r) => s + r.sizeBytes, 0) : 0);
|
|
718
|
+
for (const child of aspectChildren) {
|
|
719
|
+
if (remainingBytes <= targetBytes) break;
|
|
720
|
+
await this.recordRemoval(removed, {
|
|
721
|
+
path: child.path,
|
|
722
|
+
kind: 'scope-aspect',
|
|
723
|
+
reason: `size-target-${sizeTargetGb}gb`,
|
|
724
|
+
sizeBytes: child.sizeBytes,
|
|
725
|
+
originPath: child.originPath
|
|
726
|
+
}, dryRun);
|
|
727
|
+
remainingBytes -= child.sizeBytes;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
exports.CapsuleCache = CapsuleCache;
|
|
732
|
+
|
|
733
|
+
//# sourceMappingURL=capsule-cache.js.map
|