@superblocksteam/sdk 2.0.130-next.2 → 2.0.130-next.4
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/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.mjs +11 -0
- package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
- package/dist/cli-replacement/dependency-install-classifier.test.mjs +26 -0
- package/dist/cli-replacement/dependency-install-classifier.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +63 -4
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/cli-replacement/disk-space.d.mts +86 -0
- package/dist/cli-replacement/disk-space.d.mts.map +1 -0
- package/dist/cli-replacement/disk-space.mjs +133 -0
- package/dist/cli-replacement/disk-space.mjs.map +1 -0
- package/dist/cli-replacement/disk-space.test.d.mts +2 -0
- package/dist/cli-replacement/disk-space.test.d.mts.map +1 -0
- package/dist/cli-replacement/disk-space.test.mjs +186 -0
- package/dist/cli-replacement/disk-space.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server-metrics.d.mts +11 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.mjs +18 -0
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/telemetry/memory-metrics.d.ts +7 -0
- package/dist/telemetry/memory-metrics.d.ts.map +1 -1
- package/dist/telemetry/memory-metrics.js +221 -44
- package/dist/telemetry/memory-metrics.js.map +1 -1
- package/dist/telemetry/memory-metrics.test.d.ts +2 -0
- package/dist/telemetry/memory-metrics.test.d.ts.map +1 -0
- package/dist/telemetry/memory-metrics.test.js +256 -0
- package/dist/telemetry/memory-metrics.test.js.map +1 -0
- package/dist/vite-plugin-sdk-api-entry-point.d.mts +14 -1
- package/dist/vite-plugin-sdk-api-entry-point.d.mts.map +1 -1
- package/dist/vite-plugin-sdk-api-entry-point.mjs +68 -7
- package/dist/vite-plugin-sdk-api-entry-point.mjs.map +1 -1
- package/dist/vite-plugin-sdk-api-entry-point.test.mjs +53 -1
- package/dist/vite-plugin-sdk-api-entry-point.test.mjs.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/dependency-install-classifier.mts +14 -0
- package/src/cli-replacement/dependency-install-classifier.test.mts +39 -0
- package/src/cli-replacement/dev.mts +81 -10
- package/src/cli-replacement/disk-space.mts +216 -0
- package/src/cli-replacement/disk-space.test.mts +246 -0
- package/src/dev-utils/dev-server-metrics.mts +20 -0
- package/src/telemetry/memory-metrics.test.ts +363 -0
- package/src/telemetry/memory-metrics.ts +329 -60
- package/src/vite-plugin-sdk-api-entry-point.mts +79 -9
- package/src/vite-plugin-sdk-api-entry-point.test.mts +134 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import nodeFs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Minimal logger surface so this helper stays decoupled from the winston
|
|
5
|
+
* instance threaded through `dev.mts` (and trivially fakeable in tests). */
|
|
6
|
+
export interface ReclaimLogger {
|
|
7
|
+
info: (message: string, meta?: unknown) => void;
|
|
8
|
+
warn: (message: string, meta?: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DiskSpace {
|
|
12
|
+
totalBytes: number;
|
|
13
|
+
freeBytes: number;
|
|
14
|
+
usedBytes: number;
|
|
15
|
+
/** Whole-number percent of the volume in use (0–100). */
|
|
16
|
+
percentUsed: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** ENOSPC ("no space left on device") signature. npm surfaces a disk-full
|
|
20
|
+
* failure as a structured `code: "ENOSPC"` (under `--json`), a raw
|
|
21
|
+
* `errno -28`, or the kernel string — match all three. Centralised here (the
|
|
22
|
+
* disk domain) so both the install classifier and the recovery escalation
|
|
23
|
+
* gate agree on what "still out of space" means. */
|
|
24
|
+
export const DISK_FULL_PATTERN =
|
|
25
|
+
/\bENOSPC\b|no space left on device|\berrno -28\b/i;
|
|
26
|
+
|
|
27
|
+
/** The error shape `util.promisify(exec)` rejects with (stdout/stderr/message
|
|
28
|
+
* preserved separately). */
|
|
29
|
+
export interface ExecLikeFailure {
|
|
30
|
+
stdout?: string;
|
|
31
|
+
stderr?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when an exec-style failure looks like a disk-full (ENOSPC) error. */
|
|
36
|
+
export function isDiskFullError(failure: ExecLikeFailure): boolean {
|
|
37
|
+
const text = `${failure.stdout ?? ""}\n${failure.stderr ?? ""}\n${failure.message ?? ""}`;
|
|
38
|
+
return DISK_FULL_PATTERN.test(text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Best-effort `df` for the filesystem backing `dir`. Returns `undefined` if
|
|
42
|
+
* the path can't be statfs'd (missing dir, unsupported platform) — callers
|
|
43
|
+
* treat disk telemetry as advisory and must never fail an install over it. */
|
|
44
|
+
export async function getDiskSpace(
|
|
45
|
+
dir: string,
|
|
46
|
+
): Promise<DiskSpace | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
const s = await nodeFs.statfs(dir);
|
|
49
|
+
const totalBytes = s.blocks * s.bsize;
|
|
50
|
+
// `bavail` = blocks free to an unprivileged process — the number that
|
|
51
|
+
// actually governs whether npm can keep writing (vs `bfree`, which counts
|
|
52
|
+
// root-reserved blocks the install can't touch).
|
|
53
|
+
const freeBytes = s.bavail * s.bsize;
|
|
54
|
+
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
|
55
|
+
const percentUsed =
|
|
56
|
+
totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
|
|
57
|
+
return { totalBytes, freeBytes, usedBytes, percentUsed };
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Regenerable artifacts that are safe to delete to recover from an ENOSPC:
|
|
64
|
+
* every entry is rebuilt on the next dev-server start / Vite optimize pass.
|
|
65
|
+
* This list MUST NOT contain source files, app config, or user drafts —
|
|
66
|
+
* reclaim is composition-agnostic (delete whatever exists) precisely because
|
|
67
|
+
* it only ever touches throwaway build output. `node_modules` is deliberately
|
|
68
|
+
* excluded here and gated behind `includeNodeModules` (tier 2). */
|
|
69
|
+
export const RECLAIMABLE_ARTIFACTS: readonly string[] = [
|
|
70
|
+
".turbo",
|
|
71
|
+
".vite",
|
|
72
|
+
"build",
|
|
73
|
+
"dist",
|
|
74
|
+
"node_modules/.cache",
|
|
75
|
+
"node_modules/.vite",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export interface ReclaimResult {
|
|
79
|
+
/** Bytes reclaimed, measured as the increase in filesystem free space across
|
|
80
|
+
* the deletions (a `statfs` delta). O(1) — deliberately NOT a directory walk:
|
|
81
|
+
* sizing a real `node_modules` (50k–200k files) on an already-ENOSPC volume
|
|
82
|
+
* could block recovery for minutes just to produce an advisory number. */
|
|
83
|
+
bytesFreed: number;
|
|
84
|
+
/** cwd-relative paths that existed and were deleted (for telemetry). */
|
|
85
|
+
removed: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ReclaimOptions {
|
|
89
|
+
/** Also remove `node_modules` — the tier-2 lever. After an ENOSPC mid-
|
|
90
|
+
* install the tree is half-written anyway, so a clean reinstall is the
|
|
91
|
+
* correct recovery AND it collapses the "old tree + new tree" 2× peak that
|
|
92
|
+
* the private-registry validation install otherwise holds on the volume. */
|
|
93
|
+
includeNodeModules?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Cheap, non-recursive existence check (single `stat`, no tree walk). */
|
|
97
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
98
|
+
try {
|
|
99
|
+
await nodeFs.stat(p);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Delete regenerable build artifacts under `cwd` to recover space after an
|
|
107
|
+
* ENOSPC. Strictly scoped to `cwd`, best-effort (a failure on one target
|
|
108
|
+
* never aborts the rest), and safe to call unconditionally.
|
|
109
|
+
*
|
|
110
|
+
* NOTE: `cwd` is always the Superblocks-managed app directory (the live-edit
|
|
111
|
+
* PVC mount). The bare `build`/`dist` entries in `RECLAIMABLE_ARTIFACTS` are
|
|
112
|
+
* only safe under that invariant; never call this against an arbitrary or
|
|
113
|
+
* user-supplied path. */
|
|
114
|
+
export async function reclaimDiskSpace(
|
|
115
|
+
cwd: string,
|
|
116
|
+
logger: ReclaimLogger,
|
|
117
|
+
options: ReclaimOptions = {},
|
|
118
|
+
): Promise<ReclaimResult> {
|
|
119
|
+
const targets = [...RECLAIMABLE_ARTIFACTS];
|
|
120
|
+
if (options.includeNodeModules) targets.push("node_modules");
|
|
121
|
+
|
|
122
|
+
const before = await getDiskSpace(cwd);
|
|
123
|
+
const removed: string[] = [];
|
|
124
|
+
|
|
125
|
+
for (const rel of targets) {
|
|
126
|
+
const abs = path.join(cwd, rel);
|
|
127
|
+
if (!(await pathExists(abs))) continue; // nothing to reclaim
|
|
128
|
+
try {
|
|
129
|
+
await nodeFs.rm(abs, { recursive: true, force: true });
|
|
130
|
+
removed.push(rel);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger.warn(`Could not reclaim ${rel} during disk recovery`, {
|
|
133
|
+
error: String(err),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const after = await getDiskSpace(cwd);
|
|
139
|
+
const bytesFreed =
|
|
140
|
+
before && after ? Math.max(0, after.freeBytes - before.freeBytes) : 0;
|
|
141
|
+
|
|
142
|
+
return { bytesFreed, removed };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface DiskRecoveryResult {
|
|
146
|
+
recovered: boolean;
|
|
147
|
+
/** The error from the final failed install attempt, when recovery failed —
|
|
148
|
+
* so the caller can reclassify it for the most accurate surfaced error. */
|
|
149
|
+
lastError?: unknown;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Recover from an ENOSPC install failure by reclaiming disk space in
|
|
153
|
+
* escalating tiers and retrying the install after each:
|
|
154
|
+
*
|
|
155
|
+
* Tier 1 — regenerable build caches only (cheap, non-destructive).
|
|
156
|
+
* Tier 2 — also `node_modules` (a clean reinstall: collapses the
|
|
157
|
+
* "old tree + new tree" 2× peak and discards the half-written
|
|
158
|
+
* tree the ENOSPC left behind).
|
|
159
|
+
*
|
|
160
|
+
* `runInstall` re-runs the exact same install the caller already attempted.
|
|
161
|
+
* Returns `{ recovered: true }` as soon as a retry succeeds; otherwise
|
|
162
|
+
* `{ recovered: false, lastError }` so the caller surfaces an honest
|
|
163
|
+
* disk_full error. Every tier's `df` + freed bytes are logged so a future
|
|
164
|
+
* ENOSPC documents its own volume composition — the only way to inspect a
|
|
165
|
+
* live-edit PVC that `reclaimPolicy: Delete` destroys at teardown. */
|
|
166
|
+
export async function recoverFromDiskFull(
|
|
167
|
+
cwd: string,
|
|
168
|
+
runInstall: () => Promise<unknown>,
|
|
169
|
+
logger: ReclaimLogger,
|
|
170
|
+
): Promise<DiskRecoveryResult> {
|
|
171
|
+
logger.warn(
|
|
172
|
+
`Package install hit ENOSPC; attempting disk recovery [marker=DEV_SERVER_DISK_RECOVERY] cwd=${cwd}`,
|
|
173
|
+
{ disk: await getDiskSpace(cwd) },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const tiers: { name: string; options: ReclaimOptions }[] = [
|
|
177
|
+
{ name: "caches", options: {} },
|
|
178
|
+
{ name: "node_modules", options: { includeNodeModules: true } },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
let lastError: unknown;
|
|
182
|
+
for (const tier of tiers) {
|
|
183
|
+
const { bytesFreed, removed } = await reclaimDiskSpace(
|
|
184
|
+
cwd,
|
|
185
|
+
logger,
|
|
186
|
+
tier.options,
|
|
187
|
+
);
|
|
188
|
+
logger.info(
|
|
189
|
+
`Disk recovery tier '${tier.name}' freed ${bytesFreed} bytes [marker=DEV_SERVER_DISK_RECOVERY removed=${removed.join("|") || "nothing"}]`,
|
|
190
|
+
{ disk: await getDiskSpace(cwd), removed, bytesFreed },
|
|
191
|
+
);
|
|
192
|
+
try {
|
|
193
|
+
await runInstall();
|
|
194
|
+
logger.info(
|
|
195
|
+
`Package install succeeded after disk recovery tier '${tier.name}'`,
|
|
196
|
+
);
|
|
197
|
+
return { recovered: true };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
lastError = err;
|
|
200
|
+
// Only escalate to the next (more destructive) tier if we are STILL out
|
|
201
|
+
// of space. A non-ENOSPC failure means the disk problem is resolved and a
|
|
202
|
+
// different error surfaced (e.g. a registry/dependency error now that the
|
|
203
|
+
// caches are gone); escalating would delete a usable node_modules for no
|
|
204
|
+
// reason and leave the app worse off. Stop and surface this error.
|
|
205
|
+
if (!isDiskFullError(err as ExecLikeFailure)) {
|
|
206
|
+
logger.warn(
|
|
207
|
+
`Disk recovery retry after tier '${tier.name}' failed with a non-ENOSPC error; not escalating [marker=DEV_SERVER_DISK_RECOVERY]`,
|
|
208
|
+
{ error: String(err) },
|
|
209
|
+
);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { recovered: false, lastError };
|
|
216
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import nodeFs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getDiskSpace,
|
|
9
|
+
reclaimDiskSpace,
|
|
10
|
+
recoverFromDiskFull,
|
|
11
|
+
} from "./disk-space.mjs";
|
|
12
|
+
|
|
13
|
+
const silentLogger = { info: vi.fn(), warn: vi.fn() };
|
|
14
|
+
|
|
15
|
+
async function writeFile(p: string, bytes: number): Promise<void> {
|
|
16
|
+
await nodeFs.mkdir(path.dirname(p), { recursive: true });
|
|
17
|
+
await nodeFs.writeFile(p, Buffer.alloc(bytes, "x"));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function exists(p: string): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
await nodeFs.stat(p);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("reclaimDiskSpace", () => {
|
|
30
|
+
let cwd: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
cwd = await nodeFs.mkdtemp(path.join(os.tmpdir(), "reclaim-test-"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await nodeFs.rm(cwd, { recursive: true, force: true });
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("deletes regenerable build artifacts, leaving source and node_modules intact", async () => {
|
|
42
|
+
await writeFile(path.join(cwd, ".vite", "deps", "chunk.js"), 1000);
|
|
43
|
+
await writeFile(path.join(cwd, "dist", "bundle.js"), 500);
|
|
44
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
45
|
+
await writeFile(path.join(cwd, "src", "App.tsx"), 42);
|
|
46
|
+
|
|
47
|
+
const result = await reclaimDiskSpace(cwd, silentLogger);
|
|
48
|
+
|
|
49
|
+
// Regenerable artifacts are gone…
|
|
50
|
+
expect(await exists(path.join(cwd, ".vite"))).toBe(false);
|
|
51
|
+
expect(await exists(path.join(cwd, "dist"))).toBe(false);
|
|
52
|
+
// …source and the dependency tree survive by default.
|
|
53
|
+
expect(await exists(path.join(cwd, "src", "App.tsx"))).toBe(true);
|
|
54
|
+
expect(await exists(path.join(cwd, "node_modules"))).toBe(true);
|
|
55
|
+
|
|
56
|
+
expect(result.removed).toContain(".vite");
|
|
57
|
+
expect(result.removed).toContain("dist");
|
|
58
|
+
expect(result.removed).not.toContain("node_modules");
|
|
59
|
+
expect(result.bytesFreed).toBeGreaterThanOrEqual(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("removes node_modules too when includeNodeModules is set (the tier-2 lever)", async () => {
|
|
63
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
64
|
+
await writeFile(path.join(cwd, "src", "App.tsx"), 42);
|
|
65
|
+
|
|
66
|
+
const result = await reclaimDiskSpace(cwd, silentLogger, {
|
|
67
|
+
includeNodeModules: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(await exists(path.join(cwd, "node_modules"))).toBe(false);
|
|
71
|
+
expect(await exists(path.join(cwd, "src", "App.tsx"))).toBe(true);
|
|
72
|
+
expect(result.removed).toContain("node_modules");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reports bytesFreed as the statfs free-space delta (no directory walk)", async () => {
|
|
76
|
+
await writeFile(path.join(cwd, ".vite", "dep.js"), 100);
|
|
77
|
+
const baseStatfs = {
|
|
78
|
+
type: 0,
|
|
79
|
+
bsize: 4096,
|
|
80
|
+
blocks: 1000,
|
|
81
|
+
bfree: 200,
|
|
82
|
+
bavail: 100,
|
|
83
|
+
files: 0,
|
|
84
|
+
ffree: 0,
|
|
85
|
+
};
|
|
86
|
+
const statfsSpy = vi
|
|
87
|
+
.spyOn(nodeFs, "statfs")
|
|
88
|
+
.mockResolvedValueOnce({ ...baseStatfs, bavail: 100 }) // before
|
|
89
|
+
.mockResolvedValueOnce({ ...baseStatfs, bavail: 150 }); // after
|
|
90
|
+
|
|
91
|
+
const result = await reclaimDiskSpace(cwd, silentLogger);
|
|
92
|
+
|
|
93
|
+
expect(result.removed).toContain(".vite");
|
|
94
|
+
expect(result.bytesFreed).toBe((150 - 100) * 4096);
|
|
95
|
+
statfsSpy.mockRestore();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("warns and skips a target whose deletion fails, still removing the others", async () => {
|
|
99
|
+
await writeFile(path.join(cwd, ".vite", "dep.js"), 100);
|
|
100
|
+
await writeFile(path.join(cwd, "dist", "bundle.js"), 100);
|
|
101
|
+
const warn = vi.fn();
|
|
102
|
+
const rmSpy = vi.spyOn(nodeFs, "rm").mockImplementation((async (
|
|
103
|
+
p: Parameters<typeof nodeFs.rm>[0],
|
|
104
|
+
) => {
|
|
105
|
+
if (String(p).endsWith(".vite")) throw new Error("EBUSY");
|
|
106
|
+
}) as typeof nodeFs.rm);
|
|
107
|
+
|
|
108
|
+
const result = await reclaimDiskSpace(cwd, { info: vi.fn(), warn });
|
|
109
|
+
|
|
110
|
+
expect(result.removed).toContain("dist");
|
|
111
|
+
expect(result.removed).not.toContain(".vite");
|
|
112
|
+
expect(warn).toHaveBeenCalledWith(
|
|
113
|
+
expect.stringContaining(".vite"),
|
|
114
|
+
expect.objectContaining({ error: expect.any(String) }),
|
|
115
|
+
);
|
|
116
|
+
rmSpy.mockRestore();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("preserves the npm debug log dir (.superblocks/logs) so the ENOSPC autopsy survives recovery", async () => {
|
|
120
|
+
// installPackages points NPM_CONFIG_LOGS_DIR at <app>/.superblocks/logs, so
|
|
121
|
+
// on an ENOSPC this holds npm's debug log of the failure we're diagnosing.
|
|
122
|
+
await writeFile(
|
|
123
|
+
path.join(cwd, ".superblocks", "logs", "2026-06-15-debug-0.log"),
|
|
124
|
+
500,
|
|
125
|
+
);
|
|
126
|
+
await writeFile(path.join(cwd, ".vite", "dep.js"), 100);
|
|
127
|
+
|
|
128
|
+
const result = await reclaimDiskSpace(cwd, silentLogger, {
|
|
129
|
+
includeNodeModules: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(await exists(path.join(cwd, ".superblocks", "logs"))).toBe(true);
|
|
133
|
+
expect(result.removed).not.toContain(".superblocks/logs");
|
|
134
|
+
// Regenerable caches are still reclaimed.
|
|
135
|
+
expect(result.removed).toContain(".vite");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("is a no-op (no throw, nothing removed) when there are no regenerable artifacts", async () => {
|
|
139
|
+
await writeFile(path.join(cwd, "src", "App.tsx"), 42);
|
|
140
|
+
|
|
141
|
+
const result = await reclaimDiskSpace(cwd, silentLogger);
|
|
142
|
+
|
|
143
|
+
expect(result.removed).toEqual([]);
|
|
144
|
+
expect(await exists(path.join(cwd, "src", "App.tsx"))).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("recoverFromDiskFull", () => {
|
|
149
|
+
let cwd: string;
|
|
150
|
+
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
cwd = await nodeFs.mkdtemp(path.join(os.tmpdir(), "recover-test-"));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
afterEach(async () => {
|
|
156
|
+
await nodeFs.rm(cwd, { recursive: true, force: true });
|
|
157
|
+
vi.clearAllMocks();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("recovers at tier 1 (caches) without touching node_modules when the retry then succeeds", async () => {
|
|
161
|
+
await writeFile(path.join(cwd, ".vite", "dep.js"), 1000);
|
|
162
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
163
|
+
|
|
164
|
+
const runInstall = vi
|
|
165
|
+
.fn<() => Promise<string>>()
|
|
166
|
+
.mockResolvedValueOnce("ok");
|
|
167
|
+
|
|
168
|
+
const result = await recoverFromDiskFull(cwd, runInstall, silentLogger);
|
|
169
|
+
|
|
170
|
+
expect(result.recovered).toBe(true);
|
|
171
|
+
// Tier 1 ran (caches cleared)…
|
|
172
|
+
expect(await exists(path.join(cwd, ".vite"))).toBe(false);
|
|
173
|
+
// …but tier 2 was never reached, so the dependency tree is preserved.
|
|
174
|
+
expect(await exists(path.join(cwd, "node_modules"))).toBe(true);
|
|
175
|
+
expect(runInstall).toHaveBeenCalledTimes(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("escalates to tier 2 (node_modules) and recovers when the clean reinstall succeeds", async () => {
|
|
179
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
180
|
+
|
|
181
|
+
const runInstall = vi
|
|
182
|
+
.fn<() => Promise<string>>()
|
|
183
|
+
.mockRejectedValueOnce(new Error("ENOSPC again"))
|
|
184
|
+
.mockResolvedValueOnce("ok");
|
|
185
|
+
|
|
186
|
+
const result = await recoverFromDiskFull(cwd, runInstall, silentLogger);
|
|
187
|
+
|
|
188
|
+
expect(result.recovered).toBe(true);
|
|
189
|
+
expect(await exists(path.join(cwd, "node_modules"))).toBe(false);
|
|
190
|
+
expect(runInstall).toHaveBeenCalledTimes(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("does NOT escalate to tier 2 (preserves node_modules) when the tier-1 retry fails for a non-ENOSPC reason", async () => {
|
|
194
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
195
|
+
const resolveError = new Error(
|
|
196
|
+
"npm error code ERESOLVE\nunable to resolve dependency tree",
|
|
197
|
+
);
|
|
198
|
+
const runInstall = vi
|
|
199
|
+
.fn<() => Promise<string>>()
|
|
200
|
+
.mockRejectedValueOnce(resolveError);
|
|
201
|
+
|
|
202
|
+
const result = await recoverFromDiskFull(cwd, runInstall, silentLogger);
|
|
203
|
+
|
|
204
|
+
expect(result.recovered).toBe(false);
|
|
205
|
+
expect(result.lastError).toBe(resolveError);
|
|
206
|
+
// The disk wasn't the problem on retry, so node_modules must be preserved
|
|
207
|
+
// rather than destroyed by a needless tier-2 clean reinstall.
|
|
208
|
+
expect(await exists(path.join(cwd, "node_modules"))).toBe(true);
|
|
209
|
+
expect(runInstall).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reports failure with the last error when even a clean reinstall can't fit", async () => {
|
|
213
|
+
await writeFile(path.join(cwd, "node_modules", "react", "index.js"), 2000);
|
|
214
|
+
const finalError = new Error("ENOSPC: no space left on device");
|
|
215
|
+
|
|
216
|
+
const runInstall = vi
|
|
217
|
+
.fn<() => Promise<string>>()
|
|
218
|
+
.mockRejectedValueOnce(new Error("ENOSPC 1"))
|
|
219
|
+
.mockRejectedValueOnce(finalError);
|
|
220
|
+
|
|
221
|
+
const result = await recoverFromDiskFull(cwd, runInstall, silentLogger);
|
|
222
|
+
|
|
223
|
+
expect(result.recovered).toBe(false);
|
|
224
|
+
expect(result.lastError).toBe(finalError);
|
|
225
|
+
expect(runInstall).toHaveBeenCalledTimes(2);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("getDiskSpace", () => {
|
|
230
|
+
it("returns numeric totals with a sane percentUsed for a real directory", async () => {
|
|
231
|
+
const space = await getDiskSpace(os.tmpdir());
|
|
232
|
+
expect(space).toBeDefined();
|
|
233
|
+
expect(space!.totalBytes).toBeGreaterThan(0);
|
|
234
|
+
expect(space!.freeBytes).toBeGreaterThanOrEqual(0);
|
|
235
|
+
expect(space!.usedBytes).toBeGreaterThanOrEqual(0);
|
|
236
|
+
expect(space!.percentUsed).toBeGreaterThanOrEqual(0);
|
|
237
|
+
expect(space!.percentUsed).toBeLessThanOrEqual(100);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("returns undefined for a path that cannot be statfs'd", async () => {
|
|
241
|
+
const space = await getDiskSpace(
|
|
242
|
+
path.join(os.tmpdir(), "definitely-does-not-exist-xyz", "nested"),
|
|
243
|
+
);
|
|
244
|
+
expect(space).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -407,6 +407,26 @@ class DevServerMetrics {
|
|
|
407
407
|
.add(1, labels);
|
|
408
408
|
});
|
|
409
409
|
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Records an ENOSPC disk-recovery attempt and its outcome. Lets dashboards
|
|
413
|
+
* and alerts answer "how often is the live-edit PVC filling up, and is
|
|
414
|
+
* recovery actually rescuing the install?" without depending on log
|
|
415
|
+
* ingestion (the PVC is destroyed at teardown, so logs can be lost).
|
|
416
|
+
*
|
|
417
|
+
* `recovered` is the only label — bounded cardinality (true/false).
|
|
418
|
+
*/
|
|
419
|
+
recordDiskRecovery({ recovered }: { recovered: boolean }): void {
|
|
420
|
+
const labels = { recovered: String(recovered) };
|
|
421
|
+
this.record(() => {
|
|
422
|
+
getMeter()
|
|
423
|
+
.createCounter("dev_server_disk_recovery_total", {
|
|
424
|
+
description:
|
|
425
|
+
"Count of ENOSPC disk-recovery attempts during the dev-server initial install, labeled by whether a retry succeeded.",
|
|
426
|
+
})
|
|
427
|
+
.add(1, labels);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
410
430
|
}
|
|
411
431
|
|
|
412
432
|
/** Process-wide dev-server metrics singleton. */
|