disk-space-saviour 0.20.0 → 0.22.0
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/CHANGELOG.md +16 -0
- package/README.md +70 -12
- package/package.json +1 -1
- package/src/app-owners.js +87 -0
- package/src/clean.js +24 -0
- package/src/cli.js +32 -3
- package/src/conditions.js +145 -0
- package/src/env/local.js +91 -8
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +65 -1
- package/src/index.js +1 -0
- package/src/items.js +3 -0
- package/src/liveness.js +113 -4
- package/src/playwright-registry.js +77 -0
- package/src/report.js +143 -28
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +5 -3
- package/src/rules/other.js +56 -10
- package/src/scan.js +37 -4
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +96 -10
- package/src/scanners/projects.js +18 -2
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
- package/src/usage.js +601 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium browsers on macOS clone their app bundle at launch with APFS
|
|
3
|
+
* `clonefile` and replace the clone's main executable with a hard link to
|
|
4
|
+
* the installed one (chrome/browser/mac/code_sign_clone_manager.mm). The
|
|
5
|
+
* browser holds no file open inside its clone: it runs the shared inode,
|
|
6
|
+
* which `lsof` reports under the installed path until an update replaces
|
|
7
|
+
* the app. After an update the clone keeps the running browser's code
|
|
8
|
+
* signature valid, so a clone stays while any process runs its executable.
|
|
9
|
+
* Until then the clone shares every block with the installed app, and
|
|
10
|
+
* removing it frees nothing but directory entries.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BUNDLE_SUFFIX = '.bundle';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Main executables of the bundle cloned in `clone`, and whether the clone
|
|
17
|
+
* still shares its blocks with an app installed in one of `appDirs`.
|
|
18
|
+
* @returns {Promise<{executables: string[], sharedWith: string|null}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function appCloneDetails(env, clone, appDirs) {
|
|
21
|
+
const executables = [];
|
|
22
|
+
let sharedWith = null;
|
|
23
|
+
for (const bundle of await env.list(clone)) {
|
|
24
|
+
if (
|
|
25
|
+
bundle.type !== 'dir' ||
|
|
26
|
+
!bundle.name.endsWith(`.app${BUNDLE_SUFFIX}`)
|
|
27
|
+
) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const appName = bundle.name.slice(0, -BUNDLE_SUFFIX.length);
|
|
31
|
+
const macos = env.path.join(bundle.path, 'Contents', 'MacOS');
|
|
32
|
+
for (const entry of await env.list(macos)) {
|
|
33
|
+
if (entry.type !== 'file') {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
executables.push(entry.path);
|
|
37
|
+
sharedWith ??= await installedCopy(env, entry, appName, appDirs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { executables, sharedWith };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function installedCopy(env, executable, appName, appDirs) {
|
|
44
|
+
const inode = (await env.stat(executable.path))?.inode;
|
|
45
|
+
for (const dir of appDirs) {
|
|
46
|
+
const app = env.path.join(dir, appName);
|
|
47
|
+
const installed = env.path.join(app, 'Contents', 'MacOS', executable.name);
|
|
48
|
+
if (inode && (await env.stat(installed))?.inode === inode) {
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
package/src/scanners/common.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function listMany(env, dirs) {
|
|
|
27
27
|
*/
|
|
28
28
|
export async function scanTimeBusy(context, item) {
|
|
29
29
|
await resolvePaths(context, item.paths);
|
|
30
|
+
await context.liveness?.resolveExecutables(item.checks?.executables);
|
|
30
31
|
return context.liveness ? context.liveness.busyReason(item) : null;
|
|
31
32
|
}
|
|
32
33
|
|
package/src/scanners/global.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { scanItems } from '../env/scope.js';
|
|
1
|
+
import { checkSignal, scanItems } from '../env/scope.js';
|
|
2
2
|
/**
|
|
3
3
|
* Global per-user caches (ecosystem download and compilation caches, IDE and
|
|
4
4
|
* browser caches), system package caches, and leftovers in temporary
|
|
@@ -7,7 +7,7 @@ import { scanItems } from '../env/scope.js';
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CACHE_RULES } from '../rules/ecosystems.js';
|
|
10
|
-
import { OTHER_RULES } from '../rules/other.js';
|
|
10
|
+
import { OTHER_RULES, TEMP_CLONE_RULES } from '../rules/other.js';
|
|
11
11
|
import { APP_RULES, expandChromiumCaches } from '../rules/apps.js';
|
|
12
12
|
import { VERSION_RULES, compareVersions } from '../rules/versions.js';
|
|
13
13
|
import { block, makeItem } from '../items.js';
|
|
@@ -17,6 +17,10 @@ import { configuredPattern } from '../env/cache-roots.js';
|
|
|
17
17
|
import { TOOLCHAIN_CACHE_RULES } from '../rules/toolchains.js';
|
|
18
18
|
import { DOWNLOAD_RULES } from '../rules/downloads.js';
|
|
19
19
|
import { timed } from '../timings.js';
|
|
20
|
+
import { appCloneDetails } from './app-clones.js';
|
|
21
|
+
import { gitDirectories } from '../git.js';
|
|
22
|
+
import { networkRemotes } from '../git-remote.js';
|
|
23
|
+
import { playwrightBlocker } from '../playwright-registry.js';
|
|
20
24
|
|
|
21
25
|
export const GLOBAL_RULES = [
|
|
22
26
|
...CACHE_RULES,
|
|
@@ -24,6 +28,7 @@ export const GLOBAL_RULES = [
|
|
|
24
28
|
...APP_RULES,
|
|
25
29
|
...TOOLCHAIN_CACHE_RULES,
|
|
26
30
|
...DOWNLOAD_RULES,
|
|
31
|
+
...TEMP_CLONE_RULES,
|
|
27
32
|
];
|
|
28
33
|
|
|
29
34
|
function scopeBases(rule, homes, tmpDirs) {
|
|
@@ -173,7 +178,13 @@ function olderVersions(env, rule, paths, stats) {
|
|
|
173
178
|
return paths.filter((target) => old.has(target));
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
|
|
181
|
+
/** What cleanup re-verifies right before deleting, by the rule's `git` mode. */
|
|
182
|
+
const GIT_RECHECKS = {
|
|
183
|
+
ifPresent: { type: 'scratch' },
|
|
184
|
+
clone: { type: 'clone' },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
async function buildItem(context, rule, base, group, homes) {
|
|
177
188
|
const { env } = context;
|
|
178
189
|
const paths = group.map((entry) => entry.path);
|
|
179
190
|
const appRoot = rule.markers
|
|
@@ -195,7 +206,7 @@ async function buildItem(context, rule, base, group) {
|
|
|
195
206
|
reason: rule.minAge
|
|
196
207
|
? `untouched for longer than the ${rule.minAge === 'stale' ? 'activity' : 'inactivity'} window`
|
|
197
208
|
: 're-downloaded or re-created on demand',
|
|
198
|
-
recheck: rule.git
|
|
209
|
+
recheck: GIT_RECHECKS[rule.git],
|
|
199
210
|
action: action ?? { type: 'none' },
|
|
200
211
|
checks: {
|
|
201
212
|
busy: [
|
|
@@ -207,10 +218,35 @@ async function buildItem(context, rule, base, group) {
|
|
|
207
218
|
},
|
|
208
219
|
});
|
|
209
220
|
block(item, blocker);
|
|
221
|
+
if (rule.appClone) {
|
|
222
|
+
await describeAppClone(context, rule, item, homes);
|
|
223
|
+
}
|
|
210
224
|
await addBlockers(context, rule, item);
|
|
211
225
|
return item;
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
async function describeAppClone(context, rule, item, homes) {
|
|
229
|
+
const { env } = context;
|
|
230
|
+
const appDirs = rule.appClone.installed
|
|
231
|
+
.flatMap((dir) =>
|
|
232
|
+
homes.map((home) => expandRulePath(dir, { home, pathApi: env.path }))
|
|
233
|
+
)
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
const { executables, sharedWith } = await appCloneDetails(env, item.path, [
|
|
236
|
+
...new Set(appDirs),
|
|
237
|
+
]);
|
|
238
|
+
item.checks.executables = executables;
|
|
239
|
+
if (sharedWith) {
|
|
240
|
+
// Every block is shared with the installed app: nothing to reclaim.
|
|
241
|
+
// The report lists it under `shared` so the space `du` counts for it
|
|
242
|
+
// is explained.
|
|
243
|
+
item.sharedWith = sharedWith;
|
|
244
|
+
item.apparentBytes = item.bytes;
|
|
245
|
+
item.bytes = 0;
|
|
246
|
+
item.reason = `shares its disk blocks with ${sharedWith}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
214
250
|
async function addBlockers(context, rule, item) {
|
|
215
251
|
const { env, git } = context;
|
|
216
252
|
if (rule.scope === 'system' && !(await env.isRoot())) {
|
|
@@ -218,13 +254,24 @@ async function addBlockers(context, rule, item) {
|
|
|
218
254
|
}
|
|
219
255
|
if (rule.git) {
|
|
220
256
|
for (const target of item.paths) {
|
|
221
|
-
for (const reason of
|
|
222
|
-
|
|
223
|
-
|
|
257
|
+
for (const reason of rule.git === 'clone'
|
|
258
|
+
? await git.cloneBlockers(target)
|
|
259
|
+
: await git.treeBlockers(target, {
|
|
260
|
+
allowNoRepo: rule.git === 'ifPresent',
|
|
261
|
+
})) {
|
|
224
262
|
block(item, reason);
|
|
225
263
|
}
|
|
226
264
|
}
|
|
227
265
|
}
|
|
266
|
+
if (rule.registry === 'playwright') {
|
|
267
|
+
context.playwrightRegistries ??= new Map();
|
|
268
|
+
for (const target of item.paths) {
|
|
269
|
+
block(
|
|
270
|
+
item,
|
|
271
|
+
await playwrightBlocker(env, target, context.playwrightRegistries)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
228
275
|
const busy = await scanTimeBusy(context, item);
|
|
229
276
|
if (busy) {
|
|
230
277
|
block(item, `busy: ${busy}`);
|
|
@@ -292,9 +339,12 @@ export async function scanGlobal(
|
|
|
292
339
|
|
|
293
340
|
const accepted = [];
|
|
294
341
|
for (const target of candidates) {
|
|
342
|
+
// Acceptance can read Git metadata of thousands of temporary entries
|
|
343
|
+
// without passing through the environment, so honour the budget here.
|
|
344
|
+
checkSignal(env.signal);
|
|
295
345
|
if (
|
|
296
346
|
!(await timed(context.timings, 'global:accept', () =>
|
|
297
|
-
acceptedPath(env, rule, target, stats)
|
|
347
|
+
acceptedPath(env, rule, target, stats, seen)
|
|
298
348
|
))
|
|
299
349
|
) {
|
|
300
350
|
continue;
|
|
@@ -338,7 +388,7 @@ export async function scanGlobal(
|
|
|
338
388
|
: measured.filter(large).map((entry) => [entry]);
|
|
339
389
|
for (const group of groups.filter((g) => g.length > 0)) {
|
|
340
390
|
const item = await timed(context.timings, 'global:item', () =>
|
|
341
|
-
buildItem(context, rule, base, group)
|
|
391
|
+
buildItem(context, rule, base, group, homes)
|
|
342
392
|
);
|
|
343
393
|
items.push(item);
|
|
344
394
|
if ((await context.ready?.([item])) === false) {
|
|
@@ -349,7 +399,40 @@ export async function scanGlobal(
|
|
|
349
399
|
return items;
|
|
350
400
|
}
|
|
351
401
|
|
|
352
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Whether `target` or one of its ancestors is in `claimed`. Both come from
|
|
404
|
+
* the same glob expansion, so ancestors compare as plain strings and the
|
|
405
|
+
* cost is the path depth, not the number of claimed paths.
|
|
406
|
+
*/
|
|
407
|
+
function claimedAbove(env, target, claimed) {
|
|
408
|
+
for (let dir = target; ; dir = env.path.dirname(dir)) {
|
|
409
|
+
if (claimed.has(dir)) {
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
if (env.path.dirname(dir) === dir) {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A temporary directory holding a Git work tree with a network remote,
|
|
420
|
+
* outside every path another rule (or a parent clone) already claimed.
|
|
421
|
+
*/
|
|
422
|
+
async function tempClone(env, target, seen) {
|
|
423
|
+
if (env.kind !== 'host' || claimedAbove(env, target, seen)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
// Other users' temporary directories are unreadable; that is no error.
|
|
428
|
+
const { commonDir } = await gitDirectories(target);
|
|
429
|
+
return (await networkRemotes(commonDir)).length > 0;
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function acceptedPath(env, rule, target, stats, seen = new Set()) {
|
|
353
436
|
const name = env.path.basename(target);
|
|
354
437
|
if (rule.excludeNames?.includes(name)) {
|
|
355
438
|
return false;
|
|
@@ -357,6 +440,9 @@ async function acceptedPath(env, rule, target, stats) {
|
|
|
357
440
|
if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
|
|
358
441
|
return false;
|
|
359
442
|
}
|
|
443
|
+
if (rule.git === 'clone' && !(await tempClone(env, target, seen))) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
360
446
|
if (rule.fileOnly && stats.get(target)?.type !== 'file') {
|
|
361
447
|
return false;
|
|
362
448
|
}
|
package/src/scanners/projects.js
CHANGED
|
@@ -9,6 +9,9 @@ import { scanItems } from '../env/scope.js';
|
|
|
9
9
|
* are present. Generic names (`build`, `lib`, `target`, `bin`) that fail
|
|
10
10
|
* every rule are searched again below, with the remaining depth, so a
|
|
11
11
|
* non-matching `build` directory never hides real projects inside it.
|
|
12
|
+
* Git worktree folders (`.worktrees`, `worktrees`, `.claude/worktrees`)
|
|
13
|
+
* hold whole checkouts, so the search below them starts with the full
|
|
14
|
+
* depth again: an agent's worktree deep in a project is a project too.
|
|
12
15
|
*
|
|
13
16
|
* Cargo `target` directories additionally yield `cargo-superseded` items:
|
|
14
17
|
* older build hashes that a newer build of the same unit replaced (see
|
|
@@ -23,6 +26,7 @@ import { analyzeRustProfile, findRustProfiles } from './rust.js';
|
|
|
23
26
|
import { timed } from '../timings.js';
|
|
24
27
|
|
|
25
28
|
const ECOSYSTEM_NAMES = new Map(ECOSYSTEMS.map((e) => [e.id, e.name]));
|
|
29
|
+
const WORKTREE_DIRS = ['.worktrees', 'worktrees'];
|
|
26
30
|
|
|
27
31
|
function anyMatch(entries, globs) {
|
|
28
32
|
return (entries ?? []).some((entry) =>
|
|
@@ -37,6 +41,13 @@ function depthBelow(env, roots, target) {
|
|
|
37
41
|
return env.path.relative(root, target).split(/[\\/]/).filter(Boolean).length;
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** Depth left below a directory no rule claims; worktrees start afresh. */
|
|
45
|
+
function remainingDepth(env, dirs, candidate, { depth, maxDepth }) {
|
|
46
|
+
return WORKTREE_DIRS.includes(candidate.name)
|
|
47
|
+
? maxDepth
|
|
48
|
+
: depth - depthBelow(env, dirs, candidate.path);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function ruleAccepts(env, rule, candidate, selfListing, grandListing) {
|
|
41
52
|
if (rule.markers && !anyMatch(candidate.siblings, rule.markers)) {
|
|
42
53
|
return false;
|
|
@@ -139,7 +150,9 @@ async function indexedMatches(env, roots, globs, maxDepth, rules) {
|
|
|
139
150
|
export async function findProjectDirs(env, roots, options = {}) {
|
|
140
151
|
const rules = options.rules ?? PROJECT_RULES;
|
|
141
152
|
const maxDepth = options.maxDepth ?? 6;
|
|
142
|
-
const globs = [
|
|
153
|
+
const globs = [
|
|
154
|
+
...new Set([...rules.flatMap((rule) => rule.names), ...WORKTREE_DIRS]),
|
|
155
|
+
];
|
|
143
156
|
const matches = [];
|
|
144
157
|
const seen = new Set();
|
|
145
158
|
const accept = (match) => {
|
|
@@ -171,7 +184,10 @@ export async function findProjectDirs(env, roots, options = {}) {
|
|
|
171
184
|
}
|
|
172
185
|
continue;
|
|
173
186
|
}
|
|
174
|
-
const remaining =
|
|
187
|
+
const remaining = remainingDepth(env, dirs, match.candidate, {
|
|
188
|
+
depth,
|
|
189
|
+
maxDepth,
|
|
190
|
+
});
|
|
175
191
|
if (remaining > 0) {
|
|
176
192
|
next.set(remaining, [
|
|
177
193
|
...(next.get(remaining) ?? []),
|
package/src/scanners/versions.js
CHANGED
|
@@ -235,11 +235,13 @@ function referencedInstalls(rule, installs, refs, versionOf) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* Global packages a user installed into a version (`npm install -g`)
|
|
239
|
-
*
|
|
240
|
-
* installed again on demand (`rule.globalPackages.regenerable`) are
|
|
238
|
+
* Global packages a user installed into a version (`npm install -g`) go
|
|
239
|
+
* with it, so the item names them. Packages bundled with the runtime and
|
|
240
|
+
* caches installed again on demand (`rule.globalPackages.regenerable`) are
|
|
241
|
+
* not named. A package that is running keeps the version through the
|
|
242
|
+
* process checks: its binary, arguments or PATH entry lies inside.
|
|
241
243
|
*/
|
|
242
|
-
async function
|
|
244
|
+
async function noteGlobalPackages(env, rule, dir, item) {
|
|
243
245
|
if (!rule.globalPackages) {
|
|
244
246
|
return;
|
|
245
247
|
}
|
|
@@ -262,7 +264,8 @@ async function blockGlobalPackages(env, rule, dir, item) {
|
|
|
262
264
|
if (own.length > 0) {
|
|
263
265
|
const shown = own.slice(0, 5).join(', ');
|
|
264
266
|
const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
|
|
265
|
-
|
|
267
|
+
item.globalPackages = own;
|
|
268
|
+
item.reason += `; also removes its global packages: ${shown}${more}`;
|
|
266
269
|
}
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -357,7 +360,7 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
357
360
|
: null,
|
|
358
361
|
});
|
|
359
362
|
await blockPyenvEnvironments(env, rule, dir, item);
|
|
360
|
-
await
|
|
363
|
+
await noteGlobalPackages(env, rule, dir, item);
|
|
361
364
|
await blockLiveLock(context, rule, home, version, item);
|
|
362
365
|
const busy = await scanTimeBusy(context, item);
|
|
363
366
|
if (busy) {
|
package/src/swap.js
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Swap is a capacity diagnostic, never a deletion candidate. Swap files
|
|
3
|
+
* live on the system disk, so memory a process keeps but never touches
|
|
4
|
+
* turns into used disk space. Each scan records swap usage and the largest
|
|
5
|
+
* memory holders in the state directory; the next scan reports how much
|
|
6
|
+
* both grew since the oldest sample of the last week.
|
|
7
|
+
*/
|
|
8
|
+
import { promises as fsp } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { AGENT_NAMES } from './health.js';
|
|
11
|
+
import { formatBytes, parseSize } from './units.js';
|
|
12
|
+
|
|
13
|
+
const GIB = 1024 ** 3;
|
|
14
|
+
export const HISTORY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
|
+
const HISTORY_LIMIT = 200;
|
|
16
|
+
const HOLDER_LIMIT = 10;
|
|
17
|
+
/** Growth below this is noise from normal paging. */
|
|
18
|
+
const GROWTH_WARNING_BYTES = GIB;
|
|
19
|
+
/** A single process holding this much is worth naming in a warning. */
|
|
20
|
+
const LARGE_HOLDER_BYTES = 2 * GIB;
|
|
3
21
|
|
|
4
22
|
export function parseSwapUsage(text) {
|
|
5
23
|
const match =
|
|
@@ -31,39 +49,279 @@ export function parseMemoryHolders(text) {
|
|
|
31
49
|
compressedBytes: parseSize(match[4].replace(/[+-]$/, '')),
|
|
32
50
|
}))
|
|
33
51
|
.sort((a, b) => b.memoryBytes - a.memoryBytes)
|
|
34
|
-
.slice(0,
|
|
52
|
+
.slice(0, HOLDER_LIMIT);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Swap totals from `/proc/meminfo`, or null. */
|
|
56
|
+
export function parseMeminfo(text) {
|
|
57
|
+
const field = (name) => {
|
|
58
|
+
const match = new RegExp(`^${name}:\\s+(\\d+)\\s*kB`, 'm').exec(text);
|
|
59
|
+
return match ? Number(match[1]) * 1024 : null;
|
|
60
|
+
};
|
|
61
|
+
const total = field('SwapTotal');
|
|
62
|
+
const free = field('SwapFree');
|
|
63
|
+
return total === null || free === null || total === 0
|
|
64
|
+
? null
|
|
65
|
+
: { total, used: total - free, free };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Name, PID, resident and swapped bytes from a `/proc/<pid>/status`. */
|
|
69
|
+
export function parseProcStatus(text) {
|
|
70
|
+
const value = (name) => new RegExp(`^${name}:\\s+(.*)$`, 'm').exec(text)?.[1];
|
|
71
|
+
const kib = (name) => Number(/^(\d+)/.exec(value(name) ?? '')?.[1] ?? 0);
|
|
72
|
+
const pid = Number(value('Pid'));
|
|
73
|
+
if (!Number.isInteger(pid)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const residentBytes = kib('VmRSS') * 1024;
|
|
77
|
+
const swappedBytes = kib('VmSwap') * 1024;
|
|
78
|
+
return {
|
|
79
|
+
pid,
|
|
80
|
+
command: value('Name')?.trim() ?? '',
|
|
81
|
+
memoryBytes: residentBytes + swappedBytes,
|
|
82
|
+
swappedBytes,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function linuxHolders(procRoot) {
|
|
87
|
+
const pids = (await fsp.readdir(procRoot)).filter((name) =>
|
|
88
|
+
/^\d+$/.test(name)
|
|
89
|
+
);
|
|
90
|
+
const holders = await Promise.all(
|
|
91
|
+
pids.map((pid) =>
|
|
92
|
+
fsp
|
|
93
|
+
.readFile(path.join(procRoot, pid, 'status'), 'utf8')
|
|
94
|
+
.then(parseProcStatus, () => null)
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
return holders
|
|
98
|
+
.filter((holder) => holder && holder.memoryBytes > 0)
|
|
99
|
+
.sort(
|
|
100
|
+
(a, b) => b.swappedBytes - a.swappedBytes || b.memoryBytes - a.memoryBytes
|
|
101
|
+
)
|
|
102
|
+
.slice(0, HOLDER_LIMIT);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readDarwin(env) {
|
|
106
|
+
const usage = await env.run(['sysctl', 'vm.swapusage']);
|
|
107
|
+
const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
|
|
108
|
+
if (!swap) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const top = await env.run([
|
|
112
|
+
'top',
|
|
113
|
+
'-l',
|
|
114
|
+
'1',
|
|
115
|
+
'-o',
|
|
116
|
+
'mem',
|
|
117
|
+
'-n',
|
|
118
|
+
String(HOLDER_LIMIT),
|
|
119
|
+
'-stats',
|
|
120
|
+
'pid,command,mem,cmprs',
|
|
121
|
+
]);
|
|
122
|
+
return {
|
|
123
|
+
...swap,
|
|
124
|
+
holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
|
|
125
|
+
attribution:
|
|
126
|
+
'Largest memory consumers (memory includes compressed and swapped pages); macOS does not expose per-process swap ownership here.',
|
|
127
|
+
advice:
|
|
128
|
+
'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
|
|
129
|
+
};
|
|
35
130
|
}
|
|
36
131
|
|
|
37
|
-
|
|
38
|
-
|
|
132
|
+
async function readLinux(procRoot) {
|
|
133
|
+
const swap = parseMeminfo(
|
|
134
|
+
await fsp.readFile(path.join(procRoot, 'meminfo'), 'utf8')
|
|
135
|
+
);
|
|
136
|
+
if (!swap) {
|
|
39
137
|
return null;
|
|
40
138
|
}
|
|
139
|
+
return {
|
|
140
|
+
...swap,
|
|
141
|
+
holders: await linuxHolders(procRoot),
|
|
142
|
+
attribution:
|
|
143
|
+
'Processes with the most swapped memory (VmSwap in /proc/<pid>/status), then the most resident memory.',
|
|
144
|
+
advice:
|
|
145
|
+
'Swap is managed by the kernel; dss never deletes swap files or partitions.',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const holderKey = (holder) => `${holder.pid}\0${holder.command}`;
|
|
150
|
+
|
|
151
|
+
/** Samples of the last week, oldest first; missing state is empty. */
|
|
152
|
+
export async function loadSwapHistory(stateDir, now) {
|
|
153
|
+
if (!stateDir) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const data = JSON.parse(
|
|
158
|
+
await fsp.readFile(path.join(stateDir, 'swap-history.json'), 'utf8')
|
|
159
|
+
);
|
|
160
|
+
return (Array.isArray(data.samples) ? data.samples : []).filter(
|
|
161
|
+
(sample) =>
|
|
162
|
+
Number.isFinite(sample?.time) &&
|
|
163
|
+
sample.time <= now &&
|
|
164
|
+
now - sample.time <= HISTORY_WINDOW_MS
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function saveSwapHistory(stateDir, samples) {
|
|
172
|
+
if (!stateDir) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
41
175
|
try {
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
176
|
+
await fsp.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
177
|
+
const file = path.join(stateDir, 'swap-history.json');
|
|
178
|
+
const temporary = `${file}.${process.pid}`;
|
|
179
|
+
await fsp.writeFile(
|
|
180
|
+
temporary,
|
|
181
|
+
JSON.stringify({ schema: 1, samples: samples.slice(-HISTORY_LIMIT) }),
|
|
182
|
+
{ mode: 0o600 }
|
|
183
|
+
);
|
|
184
|
+
await fsp.rename(temporary, file);
|
|
185
|
+
} catch {
|
|
186
|
+
// History only adds growth figures; a read-only state dir loses them.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Adds swap and per-holder growth since the oldest sample of `history`.
|
|
192
|
+
* A holder's baseline is the oldest sample that lists the same PID and
|
|
193
|
+
* command, so a restarted process starts over.
|
|
194
|
+
*/
|
|
195
|
+
export function withGrowth(swap, history) {
|
|
196
|
+
const baseline = history[0];
|
|
197
|
+
const firstSeen = new Map();
|
|
198
|
+
for (const sample of history) {
|
|
199
|
+
for (const holder of sample.holders ?? []) {
|
|
200
|
+
if (!firstSeen.has(holderKey(holder))) {
|
|
201
|
+
firstSeen.set(holderKey(holder), { time: sample.time, ...holder });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
...swap,
|
|
207
|
+
growth: baseline
|
|
208
|
+
? { since: baseline.time, usedBytes: swap.used - baseline.used }
|
|
209
|
+
: null,
|
|
210
|
+
holders: swap.holders.map((holder) => {
|
|
211
|
+
const first = firstSeen.get(holderKey(holder));
|
|
212
|
+
return first
|
|
213
|
+
? {
|
|
214
|
+
...holder,
|
|
215
|
+
growth: {
|
|
216
|
+
since: first.time,
|
|
217
|
+
memoryBytes: holder.memoryBytes - first.memoryBytes,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
: holder;
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const isAgent = (holder) =>
|
|
226
|
+
AGENT_NAMES.includes(holder.command.trim().toLowerCase());
|
|
227
|
+
|
|
228
|
+
function holderSummary(holder) {
|
|
229
|
+
const grew =
|
|
230
|
+
holder.growth?.memoryBytes > 0
|
|
231
|
+
? `, +${formatBytes(holder.growth.memoryBytes)} since ${new Date(holder.growth.since).toISOString()}`
|
|
232
|
+
: '';
|
|
233
|
+
return `${holder.command} (pid ${holder.pid}): ${formatBytes(holder.memoryBytes)}${grew}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Warnings for swap that is large, growing, or larger than the free disk
|
|
238
|
+
* space it can grow into, naming the processes that hold the memory.
|
|
239
|
+
* @param {object} swap result of `withGrowth`
|
|
240
|
+
* @param {{free: number}|null} disk
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
export function swapWarnings(swap, disk) {
|
|
244
|
+
const reasons = [];
|
|
245
|
+
if (swap.growth && swap.growth.usedBytes >= GROWTH_WARNING_BYTES) {
|
|
246
|
+
reasons.push(
|
|
247
|
+
`grew by ${formatBytes(swap.growth.usedBytes)} since ${new Date(swap.growth.since).toISOString()}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (swap.total > 0 && swap.used >= GIB && swap.used >= 0.75 * swap.total) {
|
|
251
|
+
reasons.push(
|
|
252
|
+
`is ${Math.round((100 * swap.used) / swap.total)}% full, so the system adds swap files on disk when memory demand grows`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (disk && swap.used >= GIB && disk.free < swap.used) {
|
|
256
|
+
reasons.push(
|
|
257
|
+
`has only ${formatBytes(disk.free)} of free disk to grow into, less than the ${formatBytes(swap.used)} already used`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (reasons.length === 0) {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
// The kernel (PID 0) cannot be quit, so naming it helps nobody.
|
|
264
|
+
const candidates = swap.holders.filter((holder) => holder.pid > 0);
|
|
265
|
+
const growing = candidates.filter(
|
|
266
|
+
(holder) => holder.growth?.memoryBytes >= GROWTH_WARNING_BYTES
|
|
267
|
+
);
|
|
268
|
+
const large = candidates.filter(
|
|
269
|
+
(holder) => holder.memoryBytes >= LARGE_HOLDER_BYTES
|
|
270
|
+
);
|
|
271
|
+
const named = [...new Set([...growing, ...large])].slice(0, 5);
|
|
272
|
+
const warnings = [`Swap ${reasons.join('; ')}.`];
|
|
273
|
+
if (named.length > 0) {
|
|
274
|
+
warnings.push(
|
|
275
|
+
`Memory held by: ${named.map(holderSummary).join('; ')}. Quitting or restarting them frees that swap and its disk space.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (named.some(isAgent)) {
|
|
279
|
+
warnings.push(
|
|
280
|
+
'Long-running AI agent sessions keep their history in memory: quit the session and resume it (for example `codex resume <session-id>` or `claude --resume`) to release it without losing the conversation.'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return warnings;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Swap usage, its largest memory holders, growth since earlier scans and
|
|
288
|
+
* warnings; null where swap cannot be read.
|
|
289
|
+
* @param {object} env
|
|
290
|
+
* @param {{stateDir?: string|false, disk?: object|null, now?: number,
|
|
291
|
+
* procRoot?: string}} [options]
|
|
292
|
+
*/
|
|
293
|
+
export async function readSwap(env, options = {}) {
|
|
294
|
+
if (env.kind !== 'host') {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const { stateDir, disk = null, now = Date.now() } = options;
|
|
298
|
+
let swap = null;
|
|
299
|
+
try {
|
|
300
|
+
if (env.platform === 'darwin') {
|
|
301
|
+
swap = await readDarwin(env);
|
|
302
|
+
} else if (env.platform === 'linux') {
|
|
303
|
+
swap = await readLinux(options.procRoot ?? '/proc');
|
|
46
304
|
}
|
|
47
|
-
const top = await env.run([
|
|
48
|
-
'top',
|
|
49
|
-
'-l',
|
|
50
|
-
'1',
|
|
51
|
-
'-o',
|
|
52
|
-
'mem',
|
|
53
|
-
'-n',
|
|
54
|
-
'10',
|
|
55
|
-
'-stats',
|
|
56
|
-
'pid,command,mem,cmprs',
|
|
57
|
-
]);
|
|
58
|
-
return {
|
|
59
|
-
...swap,
|
|
60
|
-
holders: top.code === 0 ? parseMemoryHolders(top.stdout) : [],
|
|
61
|
-
attribution:
|
|
62
|
-
'Largest resident/compressed memory consumers; macOS does not expose per-process swap ownership here.',
|
|
63
|
-
advice:
|
|
64
|
-
'If Docker Desktop is consuming memory, restart it after saving running work or lower its VM memory limit. Swap files are managed by macOS; dss never deletes them.',
|
|
65
|
-
};
|
|
66
305
|
} catch {
|
|
306
|
+
swap = null;
|
|
307
|
+
}
|
|
308
|
+
if (!swap) {
|
|
67
309
|
return null;
|
|
68
310
|
}
|
|
311
|
+
const history = await loadSwapHistory(stateDir, now);
|
|
312
|
+
const grown = withGrowth(swap, history);
|
|
313
|
+
await saveSwapHistory(stateDir, [
|
|
314
|
+
...history,
|
|
315
|
+
{
|
|
316
|
+
time: now,
|
|
317
|
+
used: swap.used,
|
|
318
|
+
total: swap.total,
|
|
319
|
+
holders: swap.holders.map(({ pid, command, memoryBytes }) => ({
|
|
320
|
+
pid,
|
|
321
|
+
command,
|
|
322
|
+
memoryBytes,
|
|
323
|
+
})),
|
|
324
|
+
},
|
|
325
|
+
]);
|
|
326
|
+
return { ...grown, warnings: swapWarnings(grown, disk) };
|
|
69
327
|
}
|