disk-space-saviour 0.20.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/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
  }