disk-space-saviour 0.15.2 → 0.17.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 +20 -0
- package/README.md +74 -1
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +153 -11
- package/src/cli.js +102 -14
- package/src/docker/cli.js +11 -7
- package/src/docker/scan.js +25 -4
- package/src/docker/upperdir.js +44 -0
- package/src/docker/writable.js +90 -13
- package/src/emergency.js +37 -6
- package/src/env/batches.js +20 -0
- package/src/env/cache-roots.js +138 -0
- package/src/env/inspection.js +100 -0
- package/src/env/local.js +314 -96
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- package/src/env/proc.js +13 -1
- package/src/env/process-scripts.js +24 -0
- package/src/env/scope.js +95 -0
- package/src/env/shell.js +146 -108
- package/src/exec.js +79 -8
- package/src/fast-clean.js +39 -0
- package/src/health.js +16 -5
- package/src/index.d.ts +52 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +9 -1
- package/src/report.js +25 -3
- package/src/rules/toolchains.js +66 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +240 -19
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +91 -22
- package/src/scanners/projects.js +114 -20
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +25 -13
- package/src/swap.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a518d7b: Reuse recent authenticated scans, stream safe global-cache cleanup before project discovery, and stop emergency scans as soon as the free-space goal is met. Add bounded discovery, persistent directory and size indexes, separate candidate/sizing progress counters, and macOS swap diagnostics while retaining fresh deletion checks.
|
|
8
|
+
|
|
9
|
+
## 0.16.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- dddd5f6: Batch remote filesystem operations, add scan budgets and partial reports on
|
|
14
|
+
cancellation, discover containerd upperdirs, batch safe container deletions with
|
|
15
|
+
allocated-byte accounting, and retry process inspection by owner while reporting
|
|
16
|
+
blocked sizes and environment hints.
|
|
17
|
+
|
|
18
|
+
Find version-manager download caches and idle semver baseline builds, honor
|
|
19
|
+
configured cache roots, and watch host task health within its mount namespace.
|
|
20
|
+
Preserve scan and interrupted cleanup pre-scan reports when audit storage is
|
|
21
|
+
unwritable, and avoid root-owned default state under another user's home.
|
|
22
|
+
|
|
3
23
|
## 0.15.2
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -76,7 +76,14 @@ Run `dss --help` for every option. The most useful ones:
|
|
|
76
76
|
- `--scanner NAME` restricts scanning to `projects`, `global`, `versions`,
|
|
77
77
|
`agents` or `system`.
|
|
78
78
|
- `--report FILE` limits cleaning to items in a saved `dss scan --json`
|
|
79
|
-
report.
|
|
79
|
+
report. `--from FILE` also accepts an audit JSON containing the full report.
|
|
80
|
+
Recent reports authenticated by this installation are reused; older, unsigned
|
|
81
|
+
or edited reports require discovery again. Changing discovery or action options
|
|
82
|
+
also rebuilds the plan. Every deletion still gets fresh checks.
|
|
83
|
+
- `clean` automatically reuses a matching scan from the last 30 minutes.
|
|
84
|
+
`--fresh` bypasses this plan and the discovery/size index; `--cache-dir DIR`
|
|
85
|
+
chooses the state location (default `$XDG_STATE_HOME/disk-space-saviour`, or
|
|
86
|
+
`~/.local/state/disk-space-saviour`).
|
|
80
87
|
|
|
81
88
|
## Tiers
|
|
82
89
|
|
|
@@ -118,6 +125,7 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
118
125
|
macOS) are skipped. So are projects with a running `cargo`, `gradle`,
|
|
119
126
|
`npm`, `node` or similar tool, and anything modified within
|
|
120
127
|
`--older-than` (1h).
|
|
128
|
+
Container paths are checked individually before each bounded deletion batch.
|
|
121
129
|
- **Rust builds are pruned in two parts.** `cargo-superseded` removes
|
|
122
130
|
older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
|
|
123
131
|
`build/*`, `.fingerprint`) that a newer build replaced, but never while
|
|
@@ -135,6 +143,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
135
143
|
environment stops at once, and the audit log and summary say which
|
|
136
144
|
process was lost. A build that simply finishes during the cleanup also
|
|
137
145
|
stops it: an exit cannot be told apart from a crash.
|
|
146
|
+
Host cleanup watches processes in its own mount namespace; each container
|
|
147
|
+
watches its own tasks. Processes with unknown namespace membership remain
|
|
148
|
+
watched.
|
|
138
149
|
- **Git awareness.** Whole projects and containers are protected while they
|
|
139
150
|
have unsaved changes, unpushed commits or stashes. Ignored dependency and
|
|
140
151
|
build outputs can be cleaned despite unrelated source changes; tracked or
|
|
@@ -152,6 +163,9 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
|
|
|
152
163
|
and left per environment, and disk usage before and after. Scan audits
|
|
153
164
|
include every candidate and its blockers; blocked candidates do not
|
|
154
165
|
contribute to reclaimable tier totals.
|
|
166
|
+
If a read-only scan cannot save its audit, it warns and still prints the
|
|
167
|
+
report. Root scans with another user's `HOME` use a private temporary audit
|
|
168
|
+
directory. Set `--audit-dir` to choose a persistent writable location.
|
|
155
169
|
|
|
156
170
|
Long-lived `npm exec`/`npx` servers allow idle npm download and node-gyp
|
|
157
171
|
header caches to be cleaned. Active npx hashes remain protected through
|
|
@@ -165,6 +179,53 @@ and produces one environment hint. Old isolation logs and sanitized upload stagi
|
|
|
165
179
|
are moderate-tier candidates. Opam download cleanup removes its cache
|
|
166
180
|
directory directly and does not invoke `opam clean` or migrate the root.
|
|
167
181
|
|
|
182
|
+
Scans have a two-minute budget per filesystem environment, configurable with
|
|
183
|
+
`--scan-budget 5m` (library: `scanBudget`). Progress goes to stderr, leaving
|
|
184
|
+
JSON stdout clean. SIGINT and SIGTERM stop new work, cancel subprocesses and
|
|
185
|
+
print completed findings marked `aborted`; incomplete findings remain blocked.
|
|
186
|
+
The CLI exits 130 or 143 respectively. A second signal exits immediately.
|
|
187
|
+
Library callers can pass an `AbortSignal` and an `onScanProgress` callback.
|
|
188
|
+
|
|
189
|
+
Global caches run first, prioritizing rules that can use native sizing without
|
|
190
|
+
recursive activity timestamps, and emit findings as each rule finishes. Non-interactive
|
|
191
|
+
`clean` plans or deletes completed safe host caches while the remaining discovery
|
|
192
|
+
continues. `emergency` does the same, checks actual free space after each deletion,
|
|
193
|
+
and skips the remaining scan once the goal is met. Interactive runs show a plan
|
|
194
|
+
before asking for deletion consent. A later phase hitting its budget blocks its
|
|
195
|
+
partial findings while completed independent findings stay usable.
|
|
196
|
+
|
|
197
|
+
Project discovery uses eight directory workers, prunes matched generated trees,
|
|
198
|
+
Git objects, home media/Photos libraries, macOS Library and mounted volumes.
|
|
199
|
+
Source activity is checked separately without sizing entire project trees again.
|
|
200
|
+
Spotlight (`mdfind`) or `plocate`/`locate` supplies verified early hints when
|
|
201
|
+
available; the walk also runs because an index can miss files. Version-pin discovery
|
|
202
|
+
also prunes dependency/build trees.
|
|
203
|
+
|
|
204
|
+
The persistent `index/` stores directory listings and candidate measurements.
|
|
205
|
+
Each descendant directory's own mtime/ctime and identity is checked before reusing
|
|
206
|
+
its listing, so a nested addition is visible even when an ancestor did not change.
|
|
207
|
+
Unchanged directories avoid another listing; discovery still checks their metadata.
|
|
208
|
+
Sizing uses bounded workers and `du -skx` when no recursive activity timestamp is
|
|
209
|
+
needed. Sizes cached for up to an hour are labeled **estimated**, because changing
|
|
210
|
+
an existing descendant need not update the candidate's directory mtime. Cleanup
|
|
211
|
+
always bypasses that cache and rechecks processes, open files, Git, path boundaries
|
|
212
|
+
and sizes. Safe cleanup also rechecks source activity. Explicit moderate cleanup
|
|
213
|
+
preserves the ability to remove ignored dependencies when only source changed.
|
|
214
|
+
Library callers opt into state with `cacheDir`.
|
|
215
|
+
|
|
216
|
+
On macOS, reports include `sysctl vm.swapusage` and the largest resident/compressed
|
|
217
|
+
memory consumers from `top`. These are memory diagnostics, not verified per-process
|
|
218
|
+
swap ownership. When Docker Desktop consumes memory, saving work and restarting it
|
|
219
|
+
or lowering its VM memory limit can help. dss never deletes swap files.
|
|
220
|
+
|
|
221
|
+
Cache discovery honors tool configuration before default paths, including
|
|
222
|
+
`GOMODCACHE`, `GOPATH`, `GOCACHE`, `CARGO_HOME`, `RUSTUP_HOME`,
|
|
223
|
+
`GRADLE_USER_HOME`, `DENO_DIR`, `BUN_INSTALL_CACHE_DIR` and `XDG_CACHE_HOME`.
|
|
224
|
+
Go, npm, pip, Yarn and pnpm cache queries are bounded and cached per environment.
|
|
225
|
+
Safe rules also cover perlbrew build/download trees, SDKMAN tmp/archive contents,
|
|
226
|
+
nvm downloads, idle `target/semver-checks`, and downloaded Ruby `.gem` files.
|
|
227
|
+
SDKMAN parent directories and installed gems are preserved.
|
|
228
|
+
|
|
168
229
|
## Docker
|
|
169
230
|
|
|
170
231
|
With `--docker`, or automatically when a daemon is reachable, `dss` handles
|
|
@@ -188,6 +249,11 @@ three kinds of Docker data:
|
|
|
188
249
|
this is denied or incomplete, cleanup stays blocked and names the
|
|
189
250
|
unreadable processes. This also applies when dss runs inside a container
|
|
190
251
|
with access to its Docker daemon.
|
|
252
|
+
Root probes also retry unreadable processes as their owning UID/GID with
|
|
253
|
+
`setpriv` when available. Readable evidence is retained after failed retries;
|
|
254
|
+
unresolved inspection produces one environment hint. Blocked totals show
|
|
255
|
+
measured bytes and separately identify visible bytes whose freeable size is
|
|
256
|
+
unknown.
|
|
191
257
|
- **Stopped containers** are listed with their size, image, command, owner
|
|
192
258
|
session (`HIVE_MIND_PARENT_SESSION_ID` and other session labels or
|
|
193
259
|
environment variables, else a session id in the container name), task
|
|
@@ -223,6 +289,13 @@ memory headroom, and skips excessive candidate counts. Fresh Git checks before
|
|
|
223
289
|
stopped-container removal still inspect new work. Verbose mode reports storage
|
|
224
290
|
measurement progress. Host scans prune runtime, nested overlay, read-only and
|
|
225
291
|
unselected mount points; explicitly selected ordinary mount roots are allowed.
|
|
292
|
+
For containerd-backed running containers, the daemon-side init process's
|
|
293
|
+
`/proc/<pid>/mountinfo` supplies the overlay upperdir when `GraphDriver` is absent.
|
|
294
|
+
JSON environments and the text summary show the storage source. Paths are
|
|
295
|
+
measured on the daemon host and removed inside the container so overlay
|
|
296
|
+
whiteouts are respected; cleanup reports the measured change in allocated
|
|
297
|
+
upperdir blocks when available. Remote filesystem operations use bounded
|
|
298
|
+
POSIX-shell batches and do not require Node inside the target container.
|
|
226
299
|
|
|
227
300
|
Docker discovery uses a size-free container listing and measures each
|
|
228
301
|
container independently. Failed size queries are retried once; objects
|
package/bin/dss.js
CHANGED
|
@@ -3,14 +3,31 @@
|
|
|
3
3
|
import { runCli } from '../src/cli.js';
|
|
4
4
|
|
|
5
5
|
const controller = new AbortController();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
let received = null,
|
|
7
|
+
fallback;
|
|
8
|
+
const abort = (signal) => {
|
|
9
|
+
const code = signal === 'SIGTERM' ? 143 : 130;
|
|
10
|
+
if (received) {
|
|
11
|
+
process.exit(code);
|
|
12
|
+
}
|
|
13
|
+
received = code;
|
|
14
|
+
controller.abort(new Error(`interrupted by ${signal}`));
|
|
15
|
+
fallback = setTimeout(() => process.exit(code), 5000);
|
|
16
|
+
fallback.unref();
|
|
17
|
+
};
|
|
18
|
+
const interrupt = () => abort('SIGINT'),
|
|
19
|
+
terminate = () => abort('SIGTERM');
|
|
20
|
+
process.on('SIGINT', interrupt);
|
|
21
|
+
process.on('SIGTERM', terminate);
|
|
9
22
|
try {
|
|
10
23
|
process.exitCode = await runCli(process.argv.slice(2), {
|
|
11
24
|
signal: controller.signal,
|
|
12
25
|
});
|
|
26
|
+
if (received) {
|
|
27
|
+
process.exitCode = received;
|
|
28
|
+
}
|
|
13
29
|
} finally {
|
|
14
|
-
|
|
15
|
-
process.removeListener('
|
|
30
|
+
clearTimeout(fallback);
|
|
31
|
+
process.removeListener('SIGINT', interrupt);
|
|
32
|
+
process.removeListener('SIGTERM', terminate);
|
|
16
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "disk-space-saviour",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/audit.js
CHANGED
|
@@ -12,6 +12,7 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
export const AUDIT_SCHEMA = 1;
|
|
15
|
+
let privateAuditDirectory;
|
|
15
16
|
|
|
16
17
|
export function throwIfAborted(options) {
|
|
17
18
|
if (options.signal?.aborted) {
|
|
@@ -74,8 +75,24 @@ export function startAudit(command, fields = {}) {
|
|
|
74
75
|
* @returns {Promise<string>}
|
|
75
76
|
*/
|
|
76
77
|
export async function writeAudit(audit, options = {}) {
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
let dir = auditDirectory(options);
|
|
79
|
+
// sudo/docker exec may retain another user's HOME. Do not leave root-owned
|
|
80
|
+
// default state there; explicit audit locations remain the caller's choice.
|
|
81
|
+
if (
|
|
82
|
+
!options.auditDir &&
|
|
83
|
+
!process.env.DSS_AUDIT_DIR &&
|
|
84
|
+
process.getuid?.() === 0
|
|
85
|
+
) {
|
|
86
|
+
const home = os.homedir();
|
|
87
|
+
const owner = await fsp.stat(home).catch(() => null);
|
|
88
|
+
if (owner && owner.uid !== 0 && dir.startsWith(`${home}${path.sep}`)) {
|
|
89
|
+
privateAuditDirectory ??= fsp.mkdtemp(
|
|
90
|
+
path.join(os.tmpdir(), 'dss-audit-0-')
|
|
91
|
+
);
|
|
92
|
+
dir = await privateAuditDirectory;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
79
96
|
if (!options.inProgress) {
|
|
80
97
|
audit.finishedAt ??= new Date().toISOString();
|
|
81
98
|
}
|
package/src/clean.js
CHANGED
|
@@ -31,6 +31,9 @@ import {
|
|
|
31
31
|
import { imageNamed } from './docker/images.js';
|
|
32
32
|
import { resolveEnvironment } from './env/resolve.js';
|
|
33
33
|
import { configureScanPolicy } from './env/policy.js';
|
|
34
|
+
import { configureCacheRoots } from './env/cache-roots.js';
|
|
35
|
+
import { environmentScope, signalExecutor } from './env/scope.js';
|
|
36
|
+
import { batches } from './env/batches.js';
|
|
34
37
|
import { GitInspector } from './git.js';
|
|
35
38
|
import { HealthWatch } from './health.js';
|
|
36
39
|
import { consentFlag, dropNested, selectByTier, tierRank } from './items.js';
|
|
@@ -41,6 +44,7 @@ import { OTHER_RULES } from './rules/other.js';
|
|
|
41
44
|
import { compareVersions, VERSION_RULES } from './rules/versions.js';
|
|
42
45
|
import { scanVersions } from './scanners/versions.js';
|
|
43
46
|
import { analyzeRustProfile } from './scanners/rust.js';
|
|
47
|
+
import { currentProjectTier } from './scanners/projects.js';
|
|
44
48
|
import {
|
|
45
49
|
filterItems,
|
|
46
50
|
revalidateReport,
|
|
@@ -158,7 +162,7 @@ export class Cleaner {
|
|
|
158
162
|
this.report = report;
|
|
159
163
|
this.options = options;
|
|
160
164
|
this.contexts = new Map();
|
|
161
|
-
this.health = new HealthWatch();
|
|
165
|
+
this.health = new HealthWatch(options.priorHealth);
|
|
162
166
|
this.descriptors = new Map(
|
|
163
167
|
(report.environments ?? []).map((env) => [env.id, env])
|
|
164
168
|
);
|
|
@@ -219,6 +223,18 @@ export class Cleaner {
|
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
221
225
|
const ctx = this.context(item.env);
|
|
226
|
+
const executor = ctx.executor;
|
|
227
|
+
ctx.executor = signalExecutor(executor, this.options.signal);
|
|
228
|
+
try {
|
|
229
|
+
return await environmentScope(ctx.env, this.options.signal, () =>
|
|
230
|
+
this.#processScoped(ctx, item, entry)
|
|
231
|
+
);
|
|
232
|
+
} finally {
|
|
233
|
+
ctx.executor = executor;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async #processScoped(ctx, item, entry) {
|
|
222
238
|
const fresh = await this.#recheck(ctx, item);
|
|
223
239
|
if (fresh.reason) {
|
|
224
240
|
entry.reason = fresh.reason;
|
|
@@ -356,7 +372,20 @@ export class Cleaner {
|
|
|
356
372
|
}
|
|
357
373
|
|
|
358
374
|
async #staticBlocker(ctx, item) {
|
|
375
|
+
if (item.project && this.options.tier === 'safe') {
|
|
376
|
+
const tier = await currentProjectTier(
|
|
377
|
+
{ ...ctx, options: this.options, now: this.options.now() },
|
|
378
|
+
item
|
|
379
|
+
);
|
|
380
|
+
if (tierRank(tier) > tierRank(this.options.tier)) {
|
|
381
|
+
return `project recently active; requires the ${tier} tier`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
359
384
|
if (item.recheck?.type === 'version') {
|
|
385
|
+
if (!ctx.cacheRootsConfigured) {
|
|
386
|
+
await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
|
|
387
|
+
ctx.cacheRootsConfigured = true;
|
|
388
|
+
}
|
|
360
389
|
const rule = VERSION_RULES.find(
|
|
361
390
|
(candidate) => candidate.id === item.rule
|
|
362
391
|
);
|
|
@@ -366,6 +395,7 @@ export class Cleaner {
|
|
|
366
395
|
options: {
|
|
367
396
|
...this.options,
|
|
368
397
|
homes: [item.recheck.home],
|
|
398
|
+
freshSizes: true,
|
|
369
399
|
roots: this.report.options.roots ?? [],
|
|
370
400
|
},
|
|
371
401
|
now: this.options.now(),
|
|
@@ -410,9 +440,18 @@ export class Cleaner {
|
|
|
410
440
|
if (
|
|
411
441
|
ctx.env.kind === 'host' &&
|
|
412
442
|
type === 'remove' &&
|
|
413
|
-
this.report.docker?.daemons?.some(
|
|
443
|
+
(this.report.docker?.daemons?.some(
|
|
444
|
+
(daemon) => daemon.env === ctx.env.id
|
|
445
|
+
) ||
|
|
446
|
+
this.options.streaming)
|
|
414
447
|
) {
|
|
415
|
-
const
|
|
448
|
+
const docker = new DockerCli(ctx.executor);
|
|
449
|
+
if (this.options.streaming && !(await docker.info())) {
|
|
450
|
+
return this.options.docker === true
|
|
451
|
+
? 'cannot verify running Docker bind mounts'
|
|
452
|
+
: null;
|
|
453
|
+
}
|
|
454
|
+
const mounts = await runningBindMounts(docker);
|
|
416
455
|
for (const mount of mounts) {
|
|
417
456
|
if (
|
|
418
457
|
item.paths.some(
|
|
@@ -469,6 +508,9 @@ export class Cleaner {
|
|
|
469
508
|
* item stops; an item checked path by path skips just the busy path.
|
|
470
509
|
*/
|
|
471
510
|
async #removePaths(ctx, item, entry) {
|
|
511
|
+
if (ctx.env.kind === 'container' && item.checks?.perPath) {
|
|
512
|
+
return this.#removeBatches(ctx, item, entry);
|
|
513
|
+
}
|
|
472
514
|
for (const target of item.action.paths) {
|
|
473
515
|
if (this.options.signal?.aborted) {
|
|
474
516
|
throw new Error('cleanup aborted');
|
|
@@ -490,18 +532,114 @@ export class Cleaner {
|
|
|
490
532
|
entry.reason = boundary;
|
|
491
533
|
return;
|
|
492
534
|
}
|
|
493
|
-
await ctx
|
|
494
|
-
ctx.env.writableLayer?.forget?.(target, ctx.env.path);
|
|
495
|
-
entry.status = 'removed';
|
|
496
|
-
entry.deletedPaths.push(target);
|
|
497
|
-
entry.freedBytes += usage?.bytes ?? 0;
|
|
498
|
-
await this.options.onProgress?.(entry);
|
|
535
|
+
await this.#deletePaths(ctx, entry, [target], usage?.bytes ?? 0);
|
|
499
536
|
}
|
|
500
537
|
if (entry.deletedPaths.length === 0 && entry.skippedPaths.length > 0) {
|
|
501
538
|
entry.reason = 'every path was in use or recently written';
|
|
502
539
|
}
|
|
503
540
|
}
|
|
504
541
|
|
|
542
|
+
async #removeBatches(ctx, item, entry) {
|
|
543
|
+
for (const batch of batches(item.action.paths)) {
|
|
544
|
+
if (!(await this.#removeBatch(ctx, item, entry, batch))) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** One short remote deletion per batch, after fresh health/liveness/boundary checks. */
|
|
551
|
+
async #removeBatch(ctx, item, entry, batch) {
|
|
552
|
+
await configureScanPolicy(ctx.env, this.options);
|
|
553
|
+
await ctx.liveness.refresh(true);
|
|
554
|
+
const stopped = this.health.verify(item, ctx.liveness.processList());
|
|
555
|
+
if (stopped) {
|
|
556
|
+
entry.reason = stopped;
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
await ctx.liveness.resolve(batch);
|
|
560
|
+
const usages = await ctx.env.usageMany(batch);
|
|
561
|
+
const { selected, reason } = this.#batchSelection(
|
|
562
|
+
ctx,
|
|
563
|
+
item,
|
|
564
|
+
entry,
|
|
565
|
+
batch,
|
|
566
|
+
usages
|
|
567
|
+
);
|
|
568
|
+
if (reason) {
|
|
569
|
+
entry.reason = reason;
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
if (!selected.length) {
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
await this.#deletePaths(ctx, entry, selected, sumBytes(usages, selected));
|
|
576
|
+
const lost = await this.health.check(ctx.env, item);
|
|
577
|
+
if (lost) {
|
|
578
|
+
entry.reason = lost;
|
|
579
|
+
}
|
|
580
|
+
return !lost;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#batchSelection(ctx, item, entry, batch, usages) {
|
|
584
|
+
const selected = [];
|
|
585
|
+
for (const target of batch) {
|
|
586
|
+
const blocker =
|
|
587
|
+
ctx.env.scanPolicy.removalReason(target) ??
|
|
588
|
+
ctx.liveness.busyReason({ ...item, paths: [target] });
|
|
589
|
+
if (blocker) {
|
|
590
|
+
return { selected: [], reason: blocker };
|
|
591
|
+
}
|
|
592
|
+
const usage = usages.get(target);
|
|
593
|
+
const busy = this.#pathActivity(ctx, item, target, usage);
|
|
594
|
+
const unavailable = this.#unavailableUsage(usage);
|
|
595
|
+
if (busy || unavailable) {
|
|
596
|
+
entry.skippedPaths.push({ path: target, reason: busy ?? unavailable });
|
|
597
|
+
} else {
|
|
598
|
+
selected.push(target);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { selected };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#unavailableUsage(usage) {
|
|
605
|
+
if (!usage) {
|
|
606
|
+
return 'path disappeared';
|
|
607
|
+
}
|
|
608
|
+
if (usage.sizeUnknown) {
|
|
609
|
+
return 'writable-layer size unknown';
|
|
610
|
+
}
|
|
611
|
+
return usage.bytes === 0 ? 'no writable bytes remain' : null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async #deletePaths(ctx, entry, selected, estimate) {
|
|
615
|
+
const before = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
616
|
+
selected,
|
|
617
|
+
ctx.env.path
|
|
618
|
+
);
|
|
619
|
+
await ctx.env.remove(selected);
|
|
620
|
+
const after = await ctx.env.writableLayer?.allocatedBytes?.(
|
|
621
|
+
selected,
|
|
622
|
+
ctx.env.path
|
|
623
|
+
);
|
|
624
|
+
for (const target of selected) {
|
|
625
|
+
ctx.env.writableLayer?.forget?.(target, ctx.env.path);
|
|
626
|
+
}
|
|
627
|
+
entry.status = 'removed';
|
|
628
|
+
entry.deletedPaths.push(...selected);
|
|
629
|
+
entry.freedBytes += this.#freedBytes(ctx, entry, before, after, estimate);
|
|
630
|
+
await this.options.onProgress?.(entry);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#freedBytes(ctx, entry, before, after, fallback) {
|
|
634
|
+
if (typeof before === 'number' && typeof after === 'number') {
|
|
635
|
+
return Math.max(0, before - after);
|
|
636
|
+
}
|
|
637
|
+
if (ctx.env.writableLayer) {
|
|
638
|
+
entry.freedEstimated = true;
|
|
639
|
+
}
|
|
640
|
+
return fallback;
|
|
641
|
+
}
|
|
642
|
+
|
|
505
643
|
#pathActivity(ctx, item, target, usage) {
|
|
506
644
|
return (
|
|
507
645
|
item.checks?.perPath &&
|
|
@@ -789,7 +927,8 @@ export async function clean(report, input = {}) {
|
|
|
789
927
|
const audit = startAudit(input.command ?? 'clean', {
|
|
790
928
|
dryRun: Boolean(options.dryRun),
|
|
791
929
|
tier: options.tier,
|
|
792
|
-
report
|
|
930
|
+
report,
|
|
931
|
+
entries: [...(input.priorEntries ?? [])],
|
|
793
932
|
});
|
|
794
933
|
const persistProgress = async (entry) => {
|
|
795
934
|
if (!audit.entries.includes(entry)) {
|
|
@@ -809,7 +948,10 @@ export async function clean(report, input = {}) {
|
|
|
809
948
|
await writeAudit(audit, { ...options, inProgress: true });
|
|
810
949
|
}
|
|
811
950
|
try {
|
|
812
|
-
|
|
951
|
+
const done = new Set(audit.entries.map((entry) => entry.id));
|
|
952
|
+
for (const item of planItems(report, options).filter(
|
|
953
|
+
(item) => !done.has(item.id)
|
|
954
|
+
)) {
|
|
813
955
|
if (options.signal?.aborted) {
|
|
814
956
|
throw new Error('cleanup aborted');
|
|
815
957
|
}
|