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/CHANGELOG.md +10 -0
- package/README.md +35 -12
- package/package.json +1 -1
- package/src/clean.js +13 -0
- 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 +5 -3
- package/src/rules/other.js +56 -10
- package/src/scan.js +7 -3
- package/src/scanners/app-clones.js +53 -0
- package/src/scanners/common.js +1 -0
- package/src/scanners/global.js +92 -10
- package/src/scanners/versions.js +9 -6
- package/src/swap.js +286 -28
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
|
}
|