disk-space-saviour 0.19.0 → 0.21.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 +30 -0
- package/README.md +47 -16
- package/package.json +1 -1
- package/src/clean.js +21 -1
- package/src/cli.js +2 -1
- package/src/env/local.js +21 -1
- package/src/git-remote.js +168 -0
- package/src/git.js +147 -6
- package/src/index.d.ts +11 -1
- package/src/liveness.js +46 -0
- package/src/playwright-registry.js +77 -0
- package/src/report.js +29 -4
- package/src/rules/apps.js +3 -2
- package/src/rules/downloads.js +21 -9
- package/src/rules/other.js +56 -10
- package/src/rules/versions.js +4 -0
- package/src/scan.js +46 -8
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +115 -12
- package/src/scanners/versions.js +53 -7
- package/src/swap.js +286 -28
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,9 +7,9 @@ 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
|
-
import { compareVersions } from '../rules/versions.js';
|
|
12
|
+
import { VERSION_RULES, compareVersions } from '../rules/versions.js';
|
|
13
13
|
import { block, makeItem } from '../items.js';
|
|
14
14
|
import { expandGlobPaths, expandRulePath, isWithin } from '../paths.js';
|
|
15
15
|
import { olderThan, scanTimeBusy, resolvePaths } from './common.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,31 @@ 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
|
+
item.bytes = 0;
|
|
242
|
+
item.reason = `shares its disk blocks with ${sharedWith}`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
214
246
|
async function addBlockers(context, rule, item) {
|
|
215
247
|
const { env, git } = context;
|
|
216
248
|
if (rule.scope === 'system' && !(await env.isRoot())) {
|
|
@@ -218,24 +250,56 @@ async function addBlockers(context, rule, item) {
|
|
|
218
250
|
}
|
|
219
251
|
if (rule.git) {
|
|
220
252
|
for (const target of item.paths) {
|
|
221
|
-
for (const reason of
|
|
222
|
-
|
|
223
|
-
|
|
253
|
+
for (const reason of rule.git === 'clone'
|
|
254
|
+
? await git.cloneBlockers(target)
|
|
255
|
+
: await git.treeBlockers(target, {
|
|
256
|
+
allowNoRepo: rule.git === 'ifPresent',
|
|
257
|
+
})) {
|
|
224
258
|
block(item, reason);
|
|
225
259
|
}
|
|
226
260
|
}
|
|
227
261
|
}
|
|
262
|
+
if (rule.registry === 'playwright') {
|
|
263
|
+
context.playwrightRegistries ??= new Map();
|
|
264
|
+
for (const target of item.paths) {
|
|
265
|
+
block(
|
|
266
|
+
item,
|
|
267
|
+
await playwrightBlocker(env, target, context.playwrightRegistries)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
228
271
|
const busy = await scanTimeBusy(context, item);
|
|
229
272
|
if (busy) {
|
|
230
273
|
block(item, `busy: ${busy}`);
|
|
231
274
|
}
|
|
232
275
|
}
|
|
233
276
|
|
|
277
|
+
const namesRule = (entry, rule) =>
|
|
278
|
+
entry === rule.id || entry.includes(`:${rule.id}:`);
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Rules of `rules` that `only` entries name directly or through an item id
|
|
282
|
+
* (`env:rule:path`), when every entry names a global or version rule; null
|
|
283
|
+
* when an entry may match anything else and every rule has to run.
|
|
284
|
+
*/
|
|
285
|
+
export function targetedRules(only = [], rules = GLOBAL_RULES) {
|
|
286
|
+
const targetable = [...GLOBAL_RULES, ...VERSION_RULES];
|
|
287
|
+
const covered =
|
|
288
|
+
only.length > 0 &&
|
|
289
|
+
only.every((entry) => targetable.some((rule) => namesRule(entry, rule)));
|
|
290
|
+
return covered
|
|
291
|
+
? rules.filter((rule) => only.some((entry) => namesRule(entry, rule)))
|
|
292
|
+
: null;
|
|
293
|
+
}
|
|
294
|
+
|
|
234
295
|
/**
|
|
235
296
|
* Scans global cache and leftover locations.
|
|
236
297
|
* @returns {Promise<object[]>} report items
|
|
237
298
|
*/
|
|
238
|
-
export async function scanGlobal(
|
|
299
|
+
export async function scanGlobal(
|
|
300
|
+
context,
|
|
301
|
+
{ rules = targetedRules(context.options.only) ?? GLOBAL_RULES } = {}
|
|
302
|
+
) {
|
|
239
303
|
const { env, options } = context;
|
|
240
304
|
const homes = options.homes ?? (await env.homeDirs());
|
|
241
305
|
const tmpDirs = options.tmpDirs ?? (await env.tmpDirs());
|
|
@@ -271,9 +335,12 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
271
335
|
|
|
272
336
|
const accepted = [];
|
|
273
337
|
for (const target of candidates) {
|
|
338
|
+
// Acceptance can read Git metadata of thousands of temporary entries
|
|
339
|
+
// without passing through the environment, so honour the budget here.
|
|
340
|
+
checkSignal(env.signal);
|
|
274
341
|
if (
|
|
275
342
|
!(await timed(context.timings, 'global:accept', () =>
|
|
276
|
-
acceptedPath(env, rule, target, stats)
|
|
343
|
+
acceptedPath(env, rule, target, stats, seen)
|
|
277
344
|
))
|
|
278
345
|
) {
|
|
279
346
|
continue;
|
|
@@ -317,7 +384,7 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
317
384
|
: measured.filter(large).map((entry) => [entry]);
|
|
318
385
|
for (const group of groups.filter((g) => g.length > 0)) {
|
|
319
386
|
const item = await timed(context.timings, 'global:item', () =>
|
|
320
|
-
buildItem(context, rule, base, group)
|
|
387
|
+
buildItem(context, rule, base, group, homes)
|
|
321
388
|
);
|
|
322
389
|
items.push(item);
|
|
323
390
|
if ((await context.ready?.([item])) === false) {
|
|
@@ -328,7 +395,40 @@ export async function scanGlobal(context, { rules = GLOBAL_RULES } = {}) {
|
|
|
328
395
|
return items;
|
|
329
396
|
}
|
|
330
397
|
|
|
331
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Whether `target` or one of its ancestors is in `claimed`. Both come from
|
|
400
|
+
* the same glob expansion, so ancestors compare as plain strings and the
|
|
401
|
+
* cost is the path depth, not the number of claimed paths.
|
|
402
|
+
*/
|
|
403
|
+
function claimedAbove(env, target, claimed) {
|
|
404
|
+
for (let dir = target; ; dir = env.path.dirname(dir)) {
|
|
405
|
+
if (claimed.has(dir)) {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (env.path.dirname(dir) === dir) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A temporary directory holding a Git work tree with a network remote,
|
|
416
|
+
* outside every path another rule (or a parent clone) already claimed.
|
|
417
|
+
*/
|
|
418
|
+
async function tempClone(env, target, seen) {
|
|
419
|
+
if (env.kind !== 'host' || claimedAbove(env, target, seen)) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
// Other users' temporary directories are unreadable; that is no error.
|
|
424
|
+
const { commonDir } = await gitDirectories(target);
|
|
425
|
+
return (await networkRemotes(commonDir)).length > 0;
|
|
426
|
+
} catch {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function acceptedPath(env, rule, target, stats, seen = new Set()) {
|
|
332
432
|
const name = env.path.basename(target);
|
|
333
433
|
if (rule.excludeNames?.includes(name)) {
|
|
334
434
|
return false;
|
|
@@ -336,6 +436,9 @@ async function acceptedPath(env, rule, target, stats) {
|
|
|
336
436
|
if (rule.directoryOnly && stats.get(target)?.type !== 'dir') {
|
|
337
437
|
return false;
|
|
338
438
|
}
|
|
439
|
+
if (rule.git === 'clone' && !(await tempClone(env, target, seen))) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
339
442
|
if (rule.fileOnly && stats.get(target)?.type !== 'file') {
|
|
340
443
|
return false;
|
|
341
444
|
}
|
package/src/scanners/versions.js
CHANGED
|
@@ -20,6 +20,7 @@ import { block, makeItem } from '../items.js';
|
|
|
20
20
|
import { expandGlobPath, expandRulePath } from '../paths.js';
|
|
21
21
|
import { scanTimeBusy, resolvePaths } from './common.js';
|
|
22
22
|
import { configuredPattern } from '../env/cache-roots.js';
|
|
23
|
+
import { targetedRules } from './global.js';
|
|
23
24
|
|
|
24
25
|
async function installDirs(env, rule, home) {
|
|
25
26
|
const dirs = [];
|
|
@@ -234,11 +235,13 @@ function referencedInstalls(rule, installs, refs, versionOf) {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
/**
|
|
237
|
-
* Global packages a user installed into a version (`npm install -g`)
|
|
238
|
-
*
|
|
239
|
-
* 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.
|
|
240
243
|
*/
|
|
241
|
-
async function
|
|
244
|
+
async function noteGlobalPackages(env, rule, dir, item) {
|
|
242
245
|
if (!rule.globalPackages) {
|
|
243
246
|
return;
|
|
244
247
|
}
|
|
@@ -261,7 +264,44 @@ async function blockGlobalPackages(env, rule, dir, item) {
|
|
|
261
264
|
if (own.length > 0) {
|
|
262
265
|
const shown = own.slice(0, 5).join(', ');
|
|
263
266
|
const more = own.length > 5 ? ` and ${own.length - 5} more` : '';
|
|
264
|
-
|
|
267
|
+
item.globalPackages = own;
|
|
268
|
+
item.reason += `; also removes its global packages: ${shown}${more}`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Lock files a manager writes for every running process of a version
|
|
274
|
+
* (`rule.locks`, `{version}` replaced). A version stays while its lock names
|
|
275
|
+
* a live process, and while the lock or the process list cannot be read.
|
|
276
|
+
*/
|
|
277
|
+
async function blockLiveLock(context, rule, home, version, item) {
|
|
278
|
+
if (!rule.locks) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const { env } = context;
|
|
282
|
+
const file = expandRulePath(rule.locks.replace('{version}', version), {
|
|
283
|
+
home,
|
|
284
|
+
pathApi: env.path,
|
|
285
|
+
});
|
|
286
|
+
if (!(await env.exists(file))) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
let pid = null;
|
|
290
|
+
try {
|
|
291
|
+
pid = JSON.parse((await env.readText(file)) ?? '').pid;
|
|
292
|
+
} catch {
|
|
293
|
+
pid = null;
|
|
294
|
+
}
|
|
295
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
296
|
+
block(item, `${rule.manager} lock ${file} cannot be read`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
context.lockProcesses ??= env.processes().catch(() => null);
|
|
300
|
+
const processes = await context.lockProcesses;
|
|
301
|
+
if (!processes) {
|
|
302
|
+
block(item, `cannot verify the process holding ${file}`);
|
|
303
|
+
} else if (processes.some((proc) => proc.pid === pid)) {
|
|
304
|
+
block(item, `locked by running ${rule.manager} (pid ${pid}, ${file})`);
|
|
265
305
|
}
|
|
266
306
|
}
|
|
267
307
|
|
|
@@ -320,7 +360,8 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
320
360
|
: null,
|
|
321
361
|
});
|
|
322
362
|
await blockPyenvEnvironments(env, rule, dir, item);
|
|
323
|
-
await
|
|
363
|
+
await noteGlobalPackages(env, rule, dir, item);
|
|
364
|
+
await blockLiveLock(context, rule, home, version, item);
|
|
324
365
|
const busy = await scanTimeBusy(context, item);
|
|
325
366
|
if (busy) {
|
|
326
367
|
block(item, `busy: ${busy}`);
|
|
@@ -334,7 +375,12 @@ async function ruleItems(context, rule, home, pins) {
|
|
|
334
375
|
* Scans version manager installs.
|
|
335
376
|
* @returns {Promise<object[]>} report items
|
|
336
377
|
*/
|
|
337
|
-
export async function scanVersions(
|
|
378
|
+
export async function scanVersions(
|
|
379
|
+
context,
|
|
380
|
+
{
|
|
381
|
+
rules = targetedRules(context.options.only, VERSION_RULES) ?? VERSION_RULES,
|
|
382
|
+
} = {}
|
|
383
|
+
) {
|
|
338
384
|
const { env, options } = context;
|
|
339
385
|
const homes = options.homes ?? (await env.homeDirs());
|
|
340
386
|
const pins = await projectPins(
|
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
|
}
|