disk-space-saviour 0.15.1 → 0.16.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/liveness.js CHANGED
@@ -20,6 +20,46 @@ import { formatDuration } from './units.js';
20
20
 
21
21
  const PROC_COMM_LIMIT = 15;
22
22
 
23
+ function uncertaintyRoots(proc) {
24
+ const roots = processArgs(proc).flatMap((arg) => {
25
+ const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
26
+ return value.startsWith('/') ? [value] : [];
27
+ });
28
+ if (proc.name === 'dockerd') {
29
+ roots.push('/var/lib/docker', '/run');
30
+ }
31
+ if (proc.name === 'containerd') {
32
+ roots.push('/var/lib/containerd', '/run/containerd');
33
+ }
34
+ return roots;
35
+ }
36
+
37
+ function couldAccess(proc, stat) {
38
+ return (
39
+ proc.uid === 0 ||
40
+ typeof proc.uid !== 'number' ||
41
+ !stat ||
42
+ stat.uid === undefined ||
43
+ stat.uid === proc.uid ||
44
+ stat.mode === undefined ||
45
+ (stat.mode & 0o007) !== 0 ||
46
+ // Group bits can also be an ACL mask granting another named identity.
47
+ (stat.mode & 0o070) !== 0
48
+ );
49
+ }
50
+
51
+ function ancestors(target, pathApi) {
52
+ const result = [];
53
+ for (
54
+ let dir = pathApi.dirname(target);
55
+ dir !== pathApi.dirname(dir);
56
+ dir = pathApi.dirname(dir)
57
+ ) {
58
+ result.push(dir);
59
+ }
60
+ return result;
61
+ }
62
+
23
63
  function nameMatches(name, candidate) {
24
64
  return (
25
65
  name === candidate ||
@@ -131,6 +171,7 @@ export class LivenessProbe {
131
171
  this.probeError = false;
132
172
  this.realPaths = new Map();
133
173
  this.pathOwners = new Map();
174
+ this.pathStats = new Map();
134
175
  }
135
176
 
136
177
  async refresh(force = false) {
@@ -145,6 +186,9 @@ export class LivenessProbe {
145
186
  (processes === null || this.openPaths === null) &&
146
187
  (this.env.platform === 'linux' || this.env.platform === 'darwin');
147
188
  this.refreshedAt = this.now();
189
+ this.realPaths.clear();
190
+ this.pathStats.clear();
191
+ this.pathOwners.clear();
148
192
  }
149
193
 
150
194
  /** Processes of the last refresh, or null when they could not be listed. */
@@ -159,18 +203,46 @@ export class LivenessProbe {
159
203
  * @param {string[]} paths
160
204
  */
161
205
  async resolve(paths) {
162
- if (this.env.unreadableProcesses?.length && this.env.stat) {
163
- for (const target of paths) {
164
- this.pathOwners.set(target, (await this.env.stat(target))?.uid);
206
+ if (this.env.unreadableProcesses?.length) {
207
+ const missing = [
208
+ ...new Set(
209
+ paths.flatMap((target) => [
210
+ target,
211
+ ...ancestors(target, this.env.path),
212
+ ])
213
+ ),
214
+ ].filter((target) => !this.pathStats.has(target));
215
+ const stats = this.env.statMany
216
+ ? await this.env.statMany(missing)
217
+ : new Map(
218
+ await Promise.all(
219
+ missing.map(async (target) => [
220
+ target,
221
+ await this.env.stat?.(target),
222
+ ])
223
+ )
224
+ );
225
+ for (const target of missing) {
226
+ this.pathStats.set(target, stats.get(target));
227
+ this.pathOwners.set(target, stats.get(target)?.uid);
165
228
  }
166
229
  }
167
- if (typeof this.env.realPath !== 'function') {
230
+ if (!this.env.realPath && !this.env.realPathMany) {
168
231
  return;
169
232
  }
170
- for (const target of paths) {
171
- if (!this.realPaths.has(target)) {
172
- this.realPaths.set(target, await this.env.realPath(target));
173
- }
233
+ const missing = paths.filter((target) => !this.realPaths.has(target));
234
+ const resolved = this.env.realPathMany
235
+ ? await this.env.realPathMany(missing)
236
+ : new Map(
237
+ await Promise.all(
238
+ missing.map(async (target) => [
239
+ target,
240
+ await this.env.realPath(target),
241
+ ])
242
+ )
243
+ );
244
+ for (const target of missing) {
245
+ this.realPaths.set(target, resolved.get(target) ?? null);
174
246
  }
175
247
  }
176
248
 
@@ -271,29 +343,25 @@ export class LivenessProbe {
271
343
  uncertainUsage(paths) {
272
344
  for (const proc of this.env.unreadableProcesses ?? []) {
273
345
  const args = processArgs(proc);
274
- const roots = args.flatMap((arg) => {
275
- const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
276
- return value.startsWith('/') ? [value] : [];
277
- });
278
- if (proc.name === 'dockerd') {
279
- roots.push('/var/lib/docker', '/run');
280
- }
281
- if (proc.name === 'containerd') {
282
- roots.push('/var/lib/containerd', '/run/containerd');
283
- }
346
+ const roots = uncertaintyRoots(proc);
347
+ const scopedDaemon =
348
+ proc.uid === 0 &&
349
+ ['dockerd', 'containerd'].includes(proc.name) &&
350
+ args.length > 0;
284
351
  for (const target of paths) {
285
352
  const intersects = roots.some(
286
353
  (root) =>
287
354
  isWithin(root, target, this.env.path) ||
288
355
  isWithin(target, root, this.env.path)
289
356
  );
290
- const owner = this.pathOwners.get(target);
291
- if (
292
- intersects ||
293
- proc.uid === null ||
294
- owner === undefined ||
295
- owner === proc.uid
296
- ) {
357
+ if (scopedDaemon && !intersects) {
358
+ continue;
359
+ }
360
+ if (!intersects && this.inaccessible(proc, target)) {
361
+ continue;
362
+ }
363
+ const stat = this.pathStats.get(target);
364
+ if (intersects || couldAccess(proc, stat)) {
297
365
  return `unreadable process ${proc.name || 'unknown'} (pid ${proc.pid}, uid ${proc.uid ?? 'unknown'}) may use ${target}`;
298
366
  }
299
367
  }
@@ -301,6 +369,25 @@ export class LivenessProbe {
301
369
  return null;
302
370
  }
303
371
 
372
+ inaccessible(proc, target) {
373
+ if (proc.uid === 0 || typeof proc.uid !== 'number') {
374
+ return false;
375
+ }
376
+ return ancestors(target, this.env.path).some((dir) => {
377
+ const stat = this.pathStats.get(dir);
378
+ if (
379
+ !stat ||
380
+ stat.mode === undefined ||
381
+ stat.uid === proc.uid ||
382
+ stat.mode & 0o001
383
+ ) {
384
+ return false;
385
+ }
386
+ const groupAccess = stat.mode & 0o010;
387
+ return !groupAccess;
388
+ });
389
+ }
390
+
304
391
  /**
305
392
  * First reason the item is busy, or null when it is idle. Items with
306
393
  * `checks.perPath` leave open files to `openPathsOf`: the caller skips
package/src/options.js CHANGED
@@ -32,6 +32,7 @@ export const DEFAULTS = {
32
32
  yes: false,
33
33
  backupDir: null,
34
34
  auditDir: null,
35
+ scanBudget: '2m',
35
36
  };
36
37
 
37
38
  function toBytes(value) {
@@ -70,6 +71,10 @@ export function resolveOptions(input = {}) {
70
71
  }
71
72
  }
72
73
  const staleAge = input.olderThan ?? merged.staleAge;
74
+ const scanBudgetMs = toMs(merged.scanBudget);
75
+ if (!Number.isFinite(scanBudgetMs) || scanBudgetMs <= 0) {
76
+ throw new Error('scanBudget must be a positive finite duration');
77
+ }
73
78
  const roots = toList(merged.roots);
74
79
  return {
75
80
  ...merged,
@@ -83,6 +88,7 @@ export function resolveOptions(input = {}) {
83
88
  ? null
84
89
  : toMs(merged.investigationMaxAge),
85
90
  minSizeBytes: toBytes(merged.minSize),
91
+ scanBudgetMs,
86
92
  journalKeepBytes: toBytes(merged.journalKeep),
87
93
  containerFilter: toList(merged.containers),
88
94
  allowDirtyContainers: toList(merged.allowDirtyContainers),
package/src/report.js CHANGED
@@ -52,7 +52,7 @@ function itemLine(item, showEnv) {
52
52
  ? ` (needs ${consentFlag(item)})`
53
53
  : '';
54
54
  const storage = item.sizeUnknown
55
- ? ' (may be in an image layer; freeable bytes unknown, excluded from totals)'
55
+ ? ` (${formatBytes(item.totalBytes ?? 0)} visible; may be in an image layer; freeable bytes unknown, excluded from totals)`
56
56
  : item.imageBytes
57
57
  ? ` (${formatBytes(item.imageBytes)} in image${item.imageRef ? ` ${item.imageRef}` : ''}; 0 B of that data freeable now; remove the stopped container and unused image after the task completes)`
58
58
  : '';
@@ -102,7 +102,7 @@ function blockedSection(report, options) {
102
102
  }
103
103
  return [
104
104
  '',
105
- `BLOCKED ${plural(items.length, 'item')} (kept)`,
105
+ `BLOCKED ${plural(items.length, 'item')} (kept), ${formatBytes(report.totals.blocked.bytes)} known writable, ${formatBytes(report.totals.blocked.totalBytes ?? report.totals.blocked.bytes)} visible${report.totals.blocked.unknownBytes ? ` (${formatBytes(report.totals.blocked.unknownBytes)} with unknown writable size)` : ''}`,
106
106
  ...limited(lines, options.limit * 2),
107
107
  ];
108
108
  }
@@ -322,6 +322,12 @@ export function formatReport(report, options = {}) {
322
322
  lines.push(...tierSection(report, tier, { limit }));
323
323
  }
324
324
  for (const env of report.environments) {
325
+ if (env.layerSource) {
326
+ lines.push(` ${env.label}: layer source ${env.layerSource}`);
327
+ }
328
+ if (env.scanStatus && env.scanStatus !== 'complete') {
329
+ lines.push(` ${env.label}: scan incomplete (${env.scanStatus})`);
330
+ }
325
331
  if (env.hint) {
326
332
  lines.push(` ${env.label}: ${env.hint}`);
327
333
  }
@@ -0,0 +1,66 @@
1
+ /** Download leftovers of language version managers; installed runtimes are kept. */
2
+ export const TOOLCHAIN_CACHE_RULES = [
3
+ {
4
+ id: 'perlbrew-build',
5
+ ecosystem: 'scripting',
6
+ description: 'perlbrew build trees and source archives',
7
+ paths: [
8
+ '~/perl5/perlbrew/build',
9
+ '~/perl5/perlbrew/dists',
10
+ '~/.perl5/build',
11
+ '~/.perl5/dists',
12
+ ],
13
+ aggregate: true,
14
+ busy: ['perlbrew', 'perl', 'make'],
15
+ native: {
16
+ tool: 'perlbrew',
17
+ argv: ['perlbrew', 'clean'],
18
+ cacheRoot: '~/perl5/perlbrew',
19
+ },
20
+ },
21
+ {
22
+ id: 'sdkman-tmp',
23
+ ecosystem: 'jvm',
24
+ description: 'SDKMAN download leftovers',
25
+ paths: ['~/.sdkman/tmp/*'],
26
+ aggregate: true,
27
+ busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
28
+ native: {
29
+ tool: 'bash',
30
+ init: '~/.sdkman/bin/sdkman-init.sh',
31
+ command: 'sdk flush tmp',
32
+ },
33
+ },
34
+ {
35
+ id: 'sdkman-archives',
36
+ ecosystem: 'jvm',
37
+ description: 'SDKMAN downloaded archives',
38
+ paths: ['~/.sdkman/archives/*'],
39
+ aggregate: true,
40
+ busy: ['sdk', 'sdkman', 'java', 'curl', 'unzip'],
41
+ },
42
+ {
43
+ id: 'nvm-cache',
44
+ ecosystem: 'javascript',
45
+ description: 'nvm downloaded Node archives',
46
+ paths: ['~/.nvm/.cache'],
47
+ busy: ['nvm', 'node', 'curl'],
48
+ native: { tool: 'sh', init: '~/.nvm/nvm.sh', command: 'nvm cache clear' },
49
+ },
50
+ {
51
+ id: 'ruby-gem-cache',
52
+ ecosystem: 'ruby',
53
+ description: 'downloaded Ruby gem archives',
54
+ paths: [
55
+ '~/.rbenv/versions/*/lib/ruby/gems/*/cache/*.gem',
56
+ '~/.rvm/gems/*/cache/*.gem',
57
+ '~/.local/share/gem/ruby/*/cache/*.gem',
58
+ '~/.gem/ruby/*/cache/*.gem',
59
+ '{GEM_HOME}/cache/*.gem',
60
+ '{GEM_PATH}/cache/*.gem',
61
+ ],
62
+ aggregate: true,
63
+ fileOnly: true,
64
+ busy: ['gem', 'bundle', 'ruby'],
65
+ },
66
+ ];
package/src/scan.js CHANGED
@@ -7,9 +7,12 @@ import {
7
7
  configureContainerHost,
8
8
  resetWritableLayers,
9
9
  } from './docker/writable.js';
10
+ import { setInterval, clearInterval } from 'node:timers';
10
11
  import { scanDocker } from './docker/scan.js';
11
12
  import { LocalEnv } from './env/local.js';
12
13
  import { configureScanPolicy } from './env/policy.js';
14
+ import { configureCacheRoots } from './env/cache-roots.js';
15
+ import { checkSignal, environmentScope } from './env/scope.js';
13
16
  import { block, tierTotals } from './items.js';
14
17
  import { hostExecutor, trace } from './exec.js';
15
18
  import { GitInspector } from './git.js';
@@ -59,6 +62,9 @@ const RESCAN_OPTIONS = new Set([
59
62
  'journalKeep',
60
63
  'env',
61
64
  'now',
65
+ 'signal',
66
+ 'scanBudget',
67
+ 'onScanProgress',
62
68
  ]);
63
69
 
64
70
  function safetySnapshot(report) {
@@ -133,6 +139,7 @@ export async function environmentContext(env, options, overrides = {}) {
133
139
  });
134
140
  await liveness.refresh(true);
135
141
  const homes = await env.homeDirs();
142
+ await configureCacheRoots(env, homes);
136
143
  const tmpDirs = await env.tmpDirs();
137
144
  const roots = overrides.roots ?? (await defaultRoots(env, homes, tmpDirs));
138
145
  return {
@@ -152,16 +159,71 @@ export async function environmentContext(env, options, overrides = {}) {
152
159
  * @returns {Promise<object[]>} items
153
160
  */
154
161
  export async function scanEnvironment(env, options, extra = {}) {
155
- const errors = extra.errors ?? [];
156
- let context;
162
+ const controller = new AbortController();
163
+ const abort = () => controller.abort(options.signal.reason);
164
+ options.signal?.addEventListener('abort', abort, { once: true });
165
+ if (options.signal?.aborted) {
166
+ abort();
167
+ }
168
+ const started = Date.now(),
169
+ deadline = started + options.scanBudgetMs;
170
+ const timer = setTimeout(
171
+ () => controller.abort(new Error('scan time budget exceeded')),
172
+ options.scanBudgetMs
173
+ );
174
+ const found = new Map();
175
+ let scanner = 'context';
176
+ const progress = () =>
177
+ options.onScanProgress?.({
178
+ env: env.id,
179
+ scanner,
180
+ elapsedMs: Date.now() - started,
181
+ items: found.size,
182
+ status: env.scanStatus,
183
+ });
184
+ env.scanStatus = 'scanning';
185
+ const interval = setInterval(progress, 5000);
157
186
  try {
158
- context = await environmentContext(env, options, extra);
187
+ return await environmentScope(
188
+ env,
189
+ controller.signal,
190
+ () => {
191
+ progress();
192
+ return scanEnvironmentScoped(env, options, extra, found, (name) => {
193
+ scanner = name;
194
+ progress();
195
+ });
196
+ },
197
+ deadline
198
+ );
159
199
  } catch (error) {
160
- errors.push({ env: env.id, scanner: 'context', message: error.message });
161
- return [];
200
+ env.scanStatus = options.signal?.aborted
201
+ ? 'aborted'
202
+ : controller.signal.aborted
203
+ ? 'budget-exceeded'
204
+ : 'error';
205
+ (extra.errors ?? []).push({ env: env.id, scanner, message: error.message });
206
+ const items = [...found.values()];
207
+ for (const item of items) {
208
+ block(item, `scan incomplete: ${error.message}`);
209
+ }
210
+ return items;
211
+ } finally {
212
+ clearTimeout(timer);
213
+ clearInterval(interval);
214
+ options.signal?.removeEventListener('abort', abort);
215
+ progress();
162
216
  }
163
- const items = [];
217
+ }
218
+
219
+ async function scanEnvironmentScoped(env, options, extra, found, stage) {
220
+ const errors = extra.errors ?? [];
221
+ const context = await environmentContext(env, options, extra);
222
+ context.emit = (item) => found.set(item.id, item);
223
+ let incomplete = false;
164
224
  for (const name of options.scanners) {
225
+ checkSignal(env.signal);
226
+ stage(name);
165
227
  const scanner = SCANNERS[name];
166
228
  if (!scanner) {
167
229
  throw new Error(
@@ -169,13 +231,26 @@ export async function scanEnvironment(env, options, extra = {}) {
169
231
  );
170
232
  }
171
233
  const started = Date.now();
234
+ const previous = new Set(found.keys());
172
235
  try {
173
- items.push(...(await scanner(context)));
236
+ const entries = await scanner(context);
237
+ for (const item of entries) {
238
+ context.emit(item);
239
+ }
174
240
  } catch (error) {
241
+ checkSignal(env.signal);
242
+ incomplete = true;
243
+ for (const [id, item] of found) {
244
+ if (!previous.has(id)) {
245
+ block(item, `scan incomplete: ${error.message}`);
246
+ }
247
+ }
175
248
  errors.push({ env: env.id, scanner: name, message: error.message });
176
249
  }
177
250
  trace('scanner', name, 'in', env.id, `${Date.now() - started}ms`);
178
251
  }
252
+ stage('storage');
253
+ const items = [...found.values()];
179
254
  await attributeStorage(env, items);
180
255
  for (const item of items) {
181
256
  for (const target of item.paths) {
@@ -185,6 +260,7 @@ export async function scanEnvironment(env, options, extra = {}) {
185
260
  for (const message of env.accessErrors?.values() ?? []) {
186
261
  errors.push({ env: env.id, scanner: 'filesystem', message });
187
262
  }
263
+ env.scanStatus = incomplete ? 'error' : 'complete';
188
264
  return items;
189
265
  }
190
266
 
@@ -307,6 +383,8 @@ function envDescriptor(env, depth, chain) {
307
383
  depth,
308
384
  chain,
309
385
  hint: env.probeHint ?? null,
386
+ scanStatus: env.scanStatus ?? null,
387
+ layerSource: env.writableLayer?.source ?? null,
310
388
  };
311
389
  }
312
390
 
@@ -373,6 +451,30 @@ function blockMountedHostItems(items, mounts, pathApi) {
373
451
  }
374
452
 
375
453
  export async function scan(input = {}) {
454
+ const env = input.env ?? new LocalEnv();
455
+ try {
456
+ return await environmentScope(env, input.signal, () =>
457
+ scanScoped({ ...input, env })
458
+ );
459
+ } catch (error) {
460
+ if (!input.signal?.aborted) {
461
+ throw error;
462
+ }
463
+ env.scanStatus = 'aborted';
464
+ const options = resolveOptions(input);
465
+ return buildReport({
466
+ options,
467
+ env,
468
+ items: [],
469
+ environments: [envDescriptor(env, 0, [])],
470
+ docker: null,
471
+ errors: [{ env: env.id, scanner: 'context', message: error.message }],
472
+ startedAt: options.now(),
473
+ });
474
+ }
475
+ }
476
+
477
+ async function scanScoped(input) {
376
478
  const options = resolveOptions(input);
377
479
  const env = input.env ?? new LocalEnv();
378
480
  const startedAt = options.now();
@@ -420,6 +522,8 @@ function reportOptions(options) {
420
522
  const serializable = { ...options };
421
523
  delete serializable.now;
422
524
  delete serializable.env;
525
+ delete serializable.signal;
526
+ delete serializable.onScanProgress;
423
527
  return serializable;
424
528
  }
425
529
 
@@ -440,6 +544,7 @@ export function buildReport({
440
544
  tool: 'disk-space-saviour',
441
545
  createdAt: new Date(options.now()).toISOString(),
442
546
  durationMs: options.now() - startedAt,
547
+ aborted: Boolean(options.signal?.aborted),
443
548
  host: { env: env.id, label: env.label, platform: env.platform },
444
549
  options: reportOptions(options),
445
550
  environments,
@@ -1,3 +1,4 @@
1
+ import { scanItems } from '../env/scope.js';
1
2
  /**
2
3
  * Snapshot stores of AI coding agents built on the opencode storage layout
3
4
  * (`@link-assistant/agent`, opencode).
@@ -112,7 +113,7 @@ async function appItems(context, app, root) {
112
113
  mtimeMs: usages.get(state.entry.path)?.newestMtimeMs ?? 0,
113
114
  }))
114
115
  .sort((a, b) => b.mtimeMs - a.mtimeMs)[0].state;
115
- const items = [];
116
+ const items = scanItems(context);
116
117
  for (const state of states) {
117
118
  const usage = usages.get(state.entry.path);
118
119
  const verdict =
@@ -148,7 +149,7 @@ async function appItems(context, app, root) {
148
149
  export async function scanAgents(context, { apps = AGENT_APPS } = {}) {
149
150
  const { env, options } = context;
150
151
  const homes = options.homes ?? (await env.homeDirs());
151
- const items = [];
152
+ const items = scanItems(context);
152
153
  const seen = new Set();
153
154
  for (const home of homes) {
154
155
  for (const dataDir of dataDirs(env, home)) {
@@ -26,10 +26,15 @@ export async function listMany(env, dirs) {
26
26
  * @returns {string|null}
27
27
  */
28
28
  export async function scanTimeBusy(context, item) {
29
- await context.liveness?.resolve(item.paths);
29
+ await resolvePaths(context, item.paths);
30
30
  return context.liveness ? context.liveness.busyReason(item) : null;
31
31
  }
32
32
 
33
+ /** Prime one batch before constructing items, so later per-item probes are cached. */
34
+ export async function resolvePaths(context, paths) {
35
+ await context.liveness?.resolve(paths);
36
+ }
37
+
33
38
  /**
34
39
  * Whether `now - timestamp` exceeds `windowMs` (unknown timestamps count as
35
40
  * recent, so they are never treated as abandoned).