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.
@@ -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
- async function buildItem(context, rule, base, group) {
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 === 'ifPresent' ? { type: 'scratch' } : undefined,
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 await git.treeBlockers(target, {
222
- allowNoRepo: rule.git === 'ifPresent',
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(context, { rules = GLOBAL_RULES } = {}) {
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
- async function acceptedPath(env, rule, target, stats) {
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
  }
@@ -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`) are
238
- * lost with it. Packages bundled with the runtime and caches that are
239
- * installed again on demand (`rule.globalPackages.regenerable`) are not.
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 blockGlobalPackages(env, rule, dir, item) {
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
- block(item, `has globally installed packages: ${shown}${more}`);
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 blockGlobalPackages(env, rule, dir, item);
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(context, { rules = VERSION_RULES } = {}) {
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
- /** Swap is a capacity diagnostic, never a deletion candidate. */
2
- import { parseSize } from './units.js';
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, 10);
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
- export async function readSwap(env) {
38
- if (env.kind !== 'host' || env.platform !== 'darwin') {
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
- const usage = await env.run(['sysctl', 'vm.swapusage']);
43
- const swap = usage.code === 0 ? parseSwapUsage(usage.stdout) : null;
44
- if (!swap) {
45
- return null;
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
  }