@superblocksteam/sdk 2.0.153 → 2.0.154-next.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/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +59 -7
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +35 -1
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/cli-replacement/install-packages.npm-registry.test.mjs +30 -0
- package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
- package/dist/cli-replacement/npm-install-cpu.d.mts +15 -0
- package/dist/cli-replacement/npm-install-cpu.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-cpu.mjs +79 -0
- package/dist/cli-replacement/npm-install-cpu.mjs.map +1 -0
- package/dist/cli-replacement/npm-install-cpu.test.d.mts +2 -0
- package/dist/cli-replacement/npm-install-cpu.test.d.mts.map +1 -0
- package/dist/cli-replacement/npm-install-cpu.test.mjs +84 -0
- package/dist/cli-replacement/npm-install-cpu.test.mjs.map +1 -0
- package/package.json +6 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +129 -58
- package/src/cli-replacement/dev.mts +42 -0
- package/src/cli-replacement/install-packages.npm-registry.test.mts +43 -0
- package/src/cli-replacement/npm-install-cpu.mts +86 -0
- package/src/cli-replacement/npm-install-cpu.test.mts +131 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superblocksteam/sdk",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.154-next.0",
|
|
4
4
|
"description": "Superblocks JS SDK",
|
|
5
5
|
"homepage": "https://www.superblocks.com",
|
|
6
6
|
"license": "Superblocks Community Software License",
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"vite-tsconfig-paths": "^6.0.4",
|
|
58
58
|
"winston": "^3.17.0",
|
|
59
59
|
"yaml": "^2.7.1",
|
|
60
|
-
"@superblocksteam/library-shared": "2.0.
|
|
61
|
-
"@superblocksteam/shared": "0.
|
|
62
|
-
"@superblocksteam/telemetry": "2.0.
|
|
63
|
-
"@superblocksteam/
|
|
64
|
-
"@superblocksteam/
|
|
60
|
+
"@superblocksteam/library-shared": "2.0.154-next.0",
|
|
61
|
+
"@superblocksteam/shared": "0.9607.2",
|
|
62
|
+
"@superblocksteam/telemetry": "2.0.154-next.0",
|
|
63
|
+
"@superblocksteam/vite-plugin-file-sync": "2.0.154-next.0",
|
|
64
|
+
"@superblocksteam/util": "2.0.154-next.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@eslint/js": "^9.39.2",
|
|
@@ -153,6 +153,7 @@ vi.mock("@superblocksteam/vite-plugin-file-sync/npm-registry", async () => {
|
|
|
153
153
|
return {
|
|
154
154
|
NpmInstallBlocked,
|
|
155
155
|
shouldIgnoreInstallScripts: vi.fn(() => false),
|
|
156
|
+
maybeWriteNpmrcForDir: vi.fn(async () => undefined),
|
|
156
157
|
// Used by the dependency-install-classifier reachable from installPackages'
|
|
157
158
|
// catch path (APPS-4450). Previously the only test that hit this codepath
|
|
158
159
|
// (`logs a structured error when the background install fails`) didn't
|
|
@@ -121,7 +121,7 @@ vi.mock("@superblocksteam/vite-plugin-file-sync/ai-service", () => ({
|
|
|
121
121
|
removeIntegrationCache = vi.fn(async () => undefined);
|
|
122
122
|
remediateDeclaredDependencies = vi.fn(async () => undefined);
|
|
123
123
|
chatSessionStore = { invalidateCache: vi.fn() };
|
|
124
|
-
getNpmRegistryClient =
|
|
124
|
+
getNpmRegistryClient = mockGetNpmRegistryClient;
|
|
125
125
|
},
|
|
126
126
|
AiServiceFeatureFlags: class {
|
|
127
127
|
static create = vi.fn(() => ({}));
|
|
@@ -136,63 +136,84 @@ vi.mock("@superblocksteam/vite-plugin-file-sync/ai-service", () => ({
|
|
|
136
136
|
|
|
137
137
|
vi.mock("@superblocksteam/vite-plugin-file-sync/npm-registry", () => ({
|
|
138
138
|
shouldIgnoreInstallScripts: vi.fn(() => false),
|
|
139
|
+
maybeWriteNpmrcForDir: vi.fn(async () => {
|
|
140
|
+
// Delay before the log push so this test fails if syncHomeNpmrc is
|
|
141
|
+
// fire-and-forget (initiation order alone would still look correct).
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
143
|
+
startupOpLog.push("project-npmrc:maybeWriteNpmrcForDir");
|
|
144
|
+
}),
|
|
139
145
|
}));
|
|
140
146
|
|
|
141
|
-
const { startupOpLog, buildMockGitService } =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
const { startupOpLog, buildMockGitService, mockGetNpmRegistryClient } =
|
|
148
|
+
vi.hoisted(() => {
|
|
149
|
+
const startupOpLog: string[] = [];
|
|
150
|
+
const mockGetNpmRegistryClient = vi.fn(
|
|
151
|
+
():
|
|
152
|
+
| {
|
|
153
|
+
getConfig: ReturnType<typeof vi.fn>;
|
|
154
|
+
organizationId: string;
|
|
155
|
+
}
|
|
156
|
+
| undefined => ({
|
|
157
|
+
organizationId: "org-1",
|
|
158
|
+
getConfig: vi.fn(async () => ({
|
|
159
|
+
source: "not-configured",
|
|
160
|
+
config: { configured: false },
|
|
161
|
+
})),
|
|
154
162
|
}),
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
);
|
|
164
|
+
function buildMockGitService(cwd: string) {
|
|
165
|
+
return {
|
|
166
|
+
workDir: cwd,
|
|
167
|
+
configure: vi.fn(async () => {
|
|
168
|
+
startupOpLog.push("git:configure");
|
|
169
|
+
}),
|
|
170
|
+
init: vi.fn(async () => {
|
|
171
|
+
startupOpLog.push("git:init");
|
|
172
|
+
}),
|
|
173
|
+
addRemote: vi.fn(async () => {
|
|
174
|
+
startupOpLog.push("git:addRemote");
|
|
175
|
+
}),
|
|
176
|
+
fetch: vi.fn(async () => {
|
|
177
|
+
startupOpLog.push("git:fetch");
|
|
178
|
+
}),
|
|
179
|
+
raw: vi.fn(async (args: string | string[]) => {
|
|
180
|
+
const argv = Array.isArray(args) ? args : [args];
|
|
181
|
+
startupOpLog.push(`git:raw:${argv.join(" ")}`);
|
|
182
|
+
if (argv[0] === "ls-remote" && argv.includes("__live__")) {
|
|
183
|
+
return "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\trefs/heads/__live__\n";
|
|
184
|
+
}
|
|
185
|
+
if (argv[0] === "branch" && argv.includes("--show-current")) {
|
|
186
|
+
return "__live__\n";
|
|
187
|
+
}
|
|
188
|
+
if (argv[0] === "commit") {
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
168
191
|
return "";
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return { startupOpLog, buildMockGitService };
|
|
195
|
-
});
|
|
192
|
+
}),
|
|
193
|
+
push: vi.fn(async () => {
|
|
194
|
+
startupOpLog.push("git:push");
|
|
195
|
+
}),
|
|
196
|
+
getDefaultBranch: vi.fn(async () => "main"),
|
|
197
|
+
getRemotes: vi.fn(async () => [
|
|
198
|
+
{ name: "origin", refs: { fetch: "https://example.test/repo.git" } },
|
|
199
|
+
]),
|
|
200
|
+
remote: vi.fn(async () => {
|
|
201
|
+
startupOpLog.push("git:remote");
|
|
202
|
+
}),
|
|
203
|
+
revparse: vi.fn(async (ref: string) => {
|
|
204
|
+
startupOpLog.push(`git:revparse:${ref}`);
|
|
205
|
+
return "abc123\n";
|
|
206
|
+
}),
|
|
207
|
+
checkout: vi.fn(async () => {
|
|
208
|
+
startupOpLog.push("git:checkout");
|
|
209
|
+
}),
|
|
210
|
+
checkoutOrCreate: vi.fn(async () => {
|
|
211
|
+
startupOpLog.push("git:checkoutOrCreate");
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { startupOpLog, buildMockGitService, mockGetNpmRegistryClient };
|
|
216
|
+
});
|
|
196
217
|
|
|
197
218
|
vi.mock("@superblocksteam/vite-plugin-file-sync/git-service", () => ({
|
|
198
219
|
createGitService: vi.fn((cwd: string) => {
|
|
@@ -284,10 +305,16 @@ vi.mock("./automatic-upgrades.js", () => ({
|
|
|
284
305
|
// `getNpmRegistryClient()` stub so the call site doesn't `TypeError`
|
|
285
306
|
// and silently swallow this mock.
|
|
286
307
|
vi.mock("./home-npmrc.mjs", () => ({
|
|
287
|
-
syncHomeNpmrc: vi.fn(async () =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
308
|
+
syncHomeNpmrc: vi.fn(async () => {
|
|
309
|
+
// Longer delay than maybeWriteNpmrcForDir: if both run without await,
|
|
310
|
+
// project pushes first and the order assertion fails.
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
312
|
+
startupOpLog.push("home-npmrc:syncHomeNpmrc");
|
|
313
|
+
return {
|
|
314
|
+
outcome: "skipped-not-configured",
|
|
315
|
+
path: "/tmp/.superblocks/npmrc",
|
|
316
|
+
};
|
|
317
|
+
}),
|
|
291
318
|
superblocksNpmrcPath: vi.fn(() => "/tmp/.superblocks/npmrc"),
|
|
292
319
|
superblocksLogsPath: vi.fn((appDir: string) => `${appDir}/.superblocks/logs`),
|
|
293
320
|
}));
|
|
@@ -383,6 +410,7 @@ function setupExecMock() {
|
|
|
383
410
|
_opts: unknown,
|
|
384
411
|
cb?: (err: null, result: { stdout: string }) => void,
|
|
385
412
|
) => {
|
|
413
|
+
startupOpLog.push("installPackages:exec");
|
|
386
414
|
if (typeof _opts === "function") {
|
|
387
415
|
(_opts as (err: null, result: { stdout: string }) => void)(null, {
|
|
388
416
|
stdout: "installed",
|
|
@@ -399,6 +427,13 @@ describe("dev startup: git reconciliation before DBFS download", () => {
|
|
|
399
427
|
|
|
400
428
|
beforeEach(async () => {
|
|
401
429
|
vi.clearAllMocks();
|
|
430
|
+
mockGetNpmRegistryClient.mockImplementation(() => ({
|
|
431
|
+
organizationId: "org-1",
|
|
432
|
+
getConfig: vi.fn(async () => ({
|
|
433
|
+
source: "not-configured",
|
|
434
|
+
config: { configured: false },
|
|
435
|
+
})),
|
|
436
|
+
}));
|
|
402
437
|
startupOpLog.length = 0;
|
|
403
438
|
setupExecMock();
|
|
404
439
|
mockCheckVersions.mockResolvedValue({
|
|
@@ -444,4 +479,40 @@ describe("dev startup: git reconciliation before DBFS download", () => {
|
|
|
444
479
|
"[dev-startup] Post-download hash matches server",
|
|
445
480
|
);
|
|
446
481
|
});
|
|
482
|
+
|
|
483
|
+
it("reconciles project .npmrc after home sync and before boot install", async () => {
|
|
484
|
+
const { maybeWriteNpmrcForDir } =
|
|
485
|
+
await import("@superblocksteam/vite-plugin-file-sync/npm-registry");
|
|
486
|
+
const { syncHomeNpmrc } = await import("./home-npmrc.mjs");
|
|
487
|
+
const { dev } = await import("./dev.mjs");
|
|
488
|
+
|
|
489
|
+
await dev(buildDevOptions(testCwd) as any);
|
|
490
|
+
|
|
491
|
+
const homeIdx = startupOpLog.indexOf("home-npmrc:syncHomeNpmrc");
|
|
492
|
+
const projectIdx = startupOpLog.indexOf(
|
|
493
|
+
"project-npmrc:maybeWriteNpmrcForDir",
|
|
494
|
+
);
|
|
495
|
+
const installIdx = startupOpLog.indexOf("installPackages:exec");
|
|
496
|
+
|
|
497
|
+
expect(syncHomeNpmrc).toHaveBeenCalled();
|
|
498
|
+
expect(maybeWriteNpmrcForDir).toHaveBeenCalled();
|
|
499
|
+
expect(homeIdx).toBeGreaterThanOrEqual(0);
|
|
500
|
+
expect(projectIdx).toBeGreaterThanOrEqual(0);
|
|
501
|
+
expect(homeIdx).toBeLessThan(projectIdx);
|
|
502
|
+
if (installIdx >= 0) {
|
|
503
|
+
expect(projectIdx).toBeLessThan(installIdx);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("skips project .npmrc reconcile when the npm registry client is absent", async () => {
|
|
508
|
+
mockGetNpmRegistryClient.mockReturnValue(undefined);
|
|
509
|
+
const { maybeWriteNpmrcForDir } =
|
|
510
|
+
await import("@superblocksteam/vite-plugin-file-sync/npm-registry");
|
|
511
|
+
const { dev } = await import("./dev.mjs");
|
|
512
|
+
|
|
513
|
+
await dev(buildDevOptions(testCwd) as any);
|
|
514
|
+
|
|
515
|
+
expect(maybeWriteNpmrcForDir).not.toHaveBeenCalled();
|
|
516
|
+
expect(startupOpLog).not.toContain("project-npmrc:maybeWriteNpmrcForDir");
|
|
517
|
+
});
|
|
447
518
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as child_process from "node:child_process";
|
|
2
2
|
import * as nodeFs from "node:fs/promises";
|
|
3
3
|
import type { Server as HttpServer } from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import * as readline from "node:readline";
|
|
6
7
|
import { promisify } from "node:util";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
isGitHubRemoteUrl,
|
|
25
26
|
SUPERBLOCKS_LIVE_GIT_BRANCH,
|
|
26
27
|
} from "@superblocksteam/shared";
|
|
28
|
+
import { npmRegistryEmitter } from "@superblocksteam/telemetry";
|
|
27
29
|
import { maskUnixSignals } from "@superblocksteam/util";
|
|
28
30
|
import {
|
|
29
31
|
AiService,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
type NpmRegistryFetchResult,
|
|
46
48
|
type ParseContext,
|
|
47
49
|
NpmInstallBlocked,
|
|
50
|
+
maybeWriteNpmrcForDir,
|
|
48
51
|
shouldIgnoreInstallScripts,
|
|
49
52
|
} from "@superblocksteam/vite-plugin-file-sync/npm-registry";
|
|
50
53
|
import { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/operation-queue";
|
|
@@ -97,6 +100,7 @@ import {
|
|
|
97
100
|
syncHomeNpmrc,
|
|
98
101
|
} from "./home-npmrc.mjs";
|
|
99
102
|
import { normalizeWorkspaceProtocolForNpm } from "./normalize-workspace-protocol.js";
|
|
103
|
+
import { readReapedChildCpuSeconds } from "./npm-install-cpu.mjs";
|
|
100
104
|
import { parseNpmInstallSummary } from "./npm-install-summary.mjs";
|
|
101
105
|
import { consumeNpmInstallTimingAttributes } from "./npm-install-timing.mjs";
|
|
102
106
|
import {
|
|
@@ -642,6 +646,11 @@ export async function installPackages(
|
|
|
642
646
|
env: buildInstallEnv(superblocksNpmrcPath(), logsDir),
|
|
643
647
|
});
|
|
644
648
|
installStart = Date.now();
|
|
649
|
+
// Reaped-child cpu total before the install, so the delta across the
|
|
650
|
+
// awaited spawn below covers the cpu the install burned. Compared against
|
|
651
|
+
// the wall-clock duration it separates a compute-bound install from one
|
|
652
|
+
// that spent its time waiting on the network or the disk.
|
|
653
|
+
const childCpuSecondsBefore = await readReapedChildCpuSeconds();
|
|
645
654
|
|
|
646
655
|
// Cheap `df` snapshot before the install (statfs only — no directory walk
|
|
647
656
|
// on the happy path). On a 3Gi live-edit PVC this is the baseline that
|
|
@@ -660,6 +669,7 @@ export async function installPackages(
|
|
|
660
669
|
|
|
661
670
|
const { stdout } = await runInstall();
|
|
662
671
|
const installDurationMs = Date.now() - installStart;
|
|
672
|
+
const childCpuSecondsAfter = await readReapedChildCpuSeconds();
|
|
663
673
|
// npm's `--timing` breakdown lives in a file, so it has to be read back
|
|
664
674
|
// once the install is done. Only npm writes one; reading under pnpm would
|
|
665
675
|
// report a leftover npm file as this install's timings. The `catch` keeps
|
|
@@ -682,11 +692,27 @@ export async function installPackages(
|
|
|
682
692
|
"Package installation completed successfully",
|
|
683
693
|
attributes,
|
|
684
694
|
);
|
|
695
|
+
// Cpu the install burned, to be read against the wall-clock duration
|
|
696
|
+
// beside it. Omitted rather than zeroed where procfs is unavailable, so a
|
|
697
|
+
// reading we could not take never looks like an install that used no cpu.
|
|
698
|
+
// The core count is what makes the ratio interpretable: 19 cpu-seconds
|
|
699
|
+
// over 19 wall-seconds is saturated on one core and nearly idle on eight.
|
|
700
|
+
const cpuAttributes: Record<string, number> =
|
|
701
|
+
childCpuSecondsBefore !== null && childCpuSecondsAfter !== null
|
|
702
|
+
? {
|
|
703
|
+
"superblocks.npm.install.cpu_count": os.availableParallelism(),
|
|
704
|
+
"superblocks.npm.install.cpu_seconds": Math.max(
|
|
705
|
+
0,
|
|
706
|
+
childCpuSecondsAfter - childCpuSecondsBefore,
|
|
707
|
+
),
|
|
708
|
+
}
|
|
709
|
+
: {};
|
|
685
710
|
// Reuse the existing npm-install namespace and its duration unit. The
|
|
686
711
|
// package counts are log-only sizing data from this separate install path.
|
|
687
712
|
const commonAttributes = {
|
|
688
713
|
"superblocks.npm.install.disk_recovered": false,
|
|
689
714
|
"superblocks.npm.install.duration_seconds": installDurationMs / 1000,
|
|
715
|
+
...cpuAttributes,
|
|
690
716
|
...timingAttributes,
|
|
691
717
|
};
|
|
692
718
|
if (summary) {
|
|
@@ -1996,6 +2022,22 @@ export async function dev(options: {
|
|
|
1996
2022
|
}
|
|
1997
2023
|
}
|
|
1998
2024
|
|
|
2025
|
+
// Reconcile the project-dir `.npmrc` on the live-edit PVC before
|
|
2026
|
+
// the boot install. Home sync alone is not enough: npm prefers
|
|
2027
|
+
// the project file, which can hold a stale JWT or a
|
|
2028
|
+
// deleted-registry proxy URL across pod recycles.
|
|
2029
|
+
if (npmRegistryClient) {
|
|
2030
|
+
try {
|
|
2031
|
+
await maybeWriteNpmrcForDir(cwd, npmRegistryClient);
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
logger.warn(
|
|
2034
|
+
"[project-npmrc] boot reconcile failed unexpectedly; project .npmrc left untouched",
|
|
2035
|
+
getErrorMeta(error),
|
|
2036
|
+
);
|
|
2037
|
+
npmRegistryEmitter.recordSyncProjectNpmrc("error");
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1999
2041
|
let hasCliUpdated = false;
|
|
2000
2042
|
let upgradePromises: Promise<void>[] = [];
|
|
2001
2043
|
const forceUpgrade =
|
|
@@ -76,6 +76,16 @@ vi.mock("./npm-install-timing.mjs", () => ({
|
|
|
76
76
|
consumeTimingMock(logsDir),
|
|
77
77
|
}));
|
|
78
78
|
|
|
79
|
+
// Install cpu time is read from procfs, which does not exist on macOS, so a
|
|
80
|
+
// real read would make the emitted attributes differ between a developer
|
|
81
|
+
// machine and CI. The reader has its own coverage in `npm-install-cpu.test.mts`;
|
|
82
|
+
// here we only care that a reading reaches the success log line, and that no
|
|
83
|
+
// reading leaves the attributes off it entirely.
|
|
84
|
+
const readChildCpuMock = vi.fn<() => Promise<number | null>>(async () => null);
|
|
85
|
+
vi.mock("./npm-install-cpu.mjs", () => ({
|
|
86
|
+
readReapedChildCpuSeconds: () => readChildCpuMock(),
|
|
87
|
+
}));
|
|
88
|
+
|
|
79
89
|
const mockLogger = {
|
|
80
90
|
info: vi.fn(),
|
|
81
91
|
infoStructured: vi.fn(),
|
|
@@ -392,6 +402,39 @@ describe("installPackages success logging", () => {
|
|
|
392
402
|
expect(mockLogger.info).toHaveBeenCalledWith("ok");
|
|
393
403
|
});
|
|
394
404
|
|
|
405
|
+
it("emits the cpu time the install burned alongside its wall-clock duration", async () => {
|
|
406
|
+
// Two reads bracket the install, so the emitted figure is the difference.
|
|
407
|
+
readChildCpuMock.mockResolvedValueOnce(12).mockResolvedValueOnce(30.5);
|
|
408
|
+
const { installPackages } = await import("./dev.mjs");
|
|
409
|
+
|
|
410
|
+
await installPackages("/tmp/app", mockLogger as never);
|
|
411
|
+
|
|
412
|
+
expect(mockLogger.infoStructured).toHaveBeenCalledWith(
|
|
413
|
+
"Package installation completed successfully",
|
|
414
|
+
expect.objectContaining({
|
|
415
|
+
"superblocks.npm.install.cpu_count": expect.any(Number),
|
|
416
|
+
"superblocks.npm.install.cpu_seconds": 18.5,
|
|
417
|
+
"superblocks.npm.install.duration_seconds": expect.any(Number),
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("omits cpu attributes when only one of the two reads succeeds", async () => {
|
|
423
|
+
// Half a measurement is not one: a difference taken against a missing
|
|
424
|
+
// baseline would invent a number rather than report one.
|
|
425
|
+
readChildCpuMock.mockResolvedValueOnce(12).mockResolvedValueOnce(null);
|
|
426
|
+
const { installPackages } = await import("./dev.mjs");
|
|
427
|
+
|
|
428
|
+
await installPackages("/tmp/app", mockLogger as never);
|
|
429
|
+
|
|
430
|
+
expect(mockLogger.infoStructured).toHaveBeenCalledWith(
|
|
431
|
+
"Package installation completed successfully",
|
|
432
|
+
expect.not.objectContaining({
|
|
433
|
+
"superblocks.npm.install.cpu_seconds": expect.anything(),
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
395
438
|
it("marks parsed npm summaries and emits their counts", async () => {
|
|
396
439
|
const stdout = JSON.stringify({
|
|
397
440
|
added: 8,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm's `--timing` breakdown says which phase an install spent its time in,
|
|
3
|
+
* but not whether the pod was working or waiting. Both readings are consistent
|
|
4
|
+
* with the same 19 second `reify:unpack`: npm streaming tarballs over the
|
|
5
|
+
* network, or npm burning cpu it cannot get enough of. Live edit pods request
|
|
6
|
+
* 200m of cpu and set no ceiling, so under contention they get a small
|
|
7
|
+
* proportional share of the machine, and the usual throttling metric never
|
|
8
|
+
* fires because there is no quota to throttle against.
|
|
9
|
+
*
|
|
10
|
+
* Comparing cpu time against wall time separates the two. An install that
|
|
11
|
+
* burns roughly its wall time in cpu is compute-bound and the share it gets
|
|
12
|
+
* matters; one that burns a fraction of it is waiting on the network or the
|
|
13
|
+
* disk, and cpu is a dead end.
|
|
14
|
+
*
|
|
15
|
+
* Linux tracks this per process in `/proc/self/stat`: `cutime` and `cstime`
|
|
16
|
+
* are the user and system time of children this process has already waited
|
|
17
|
+
* for. Bracketing an awaited spawn with two reads gives that spawn's cpu time
|
|
18
|
+
* without instrumenting npm itself.
|
|
19
|
+
*
|
|
20
|
+
* The figure is a ceiling rather than an exact attribution: any other child
|
|
21
|
+
* the dev-server happens to reap inside the same window lands in the same
|
|
22
|
+
* counters. Nothing else it spawns is remotely as expensive as an install, so
|
|
23
|
+
* the reading is dominated by npm, but a few hundred milliseconds of git in
|
|
24
|
+
* there is expected and does not change what the number is used for.
|
|
25
|
+
*/
|
|
26
|
+
import nodeFs from "node:fs/promises";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `cutime`/`cstime` are in clock ticks. The kernel reports procfs times in
|
|
30
|
+
* USER_HZ, which is 100 on Linux regardless of the kernel's internal tick
|
|
31
|
+
* rate, and there is no way to read `sysconf(_SC_CLK_TCK)` from Node.
|
|
32
|
+
*/
|
|
33
|
+
const USER_HZ = 100;
|
|
34
|
+
|
|
35
|
+
const PROC_SELF_STAT = "/proc/self/stat";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Field offsets counted from `state`, which is the first field after the
|
|
39
|
+
* executable name. `/proc/[pid]/stat` numbers `state` as field 3, so a
|
|
40
|
+
* one-indexed field N from the man page sits at N - 3 here.
|
|
41
|
+
*/
|
|
42
|
+
const CUTIME_OFFSET = 13;
|
|
43
|
+
const CSTIME_OFFSET = 14;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sum the cpu time of children this process has already reaped, in seconds.
|
|
47
|
+
*
|
|
48
|
+
* Returns null when the line cannot be read as procfs output, so a platform
|
|
49
|
+
* without procfs omits the measurement rather than reporting zero cpu.
|
|
50
|
+
*/
|
|
51
|
+
export function parseReapedChildCpuSeconds(stat: string): number | null {
|
|
52
|
+
// The executable name is unescaped and may itself contain spaces and
|
|
53
|
+
// parentheses, so the positional fields start after its LAST closing paren.
|
|
54
|
+
const commEnd = stat.lastIndexOf(")");
|
|
55
|
+
if (commEnd === -1) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fields = stat
|
|
60
|
+
.slice(commEnd + 1)
|
|
61
|
+
.trim()
|
|
62
|
+
.split(/\s+/);
|
|
63
|
+
const cutime = Number(fields[CUTIME_OFFSET]);
|
|
64
|
+
const cstime = Number(fields[CSTIME_OFFSET]);
|
|
65
|
+
if (!Number.isFinite(cutime) || !Number.isFinite(cstime)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (cutime + cstime) / USER_HZ;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the reaped-child cpu total for this process.
|
|
74
|
+
*
|
|
75
|
+
* Never throws: this brackets a real install, and no telemetry read may turn a
|
|
76
|
+
* completed install into a failure.
|
|
77
|
+
*/
|
|
78
|
+
export async function readReapedChildCpuSeconds(
|
|
79
|
+
statPath: string = PROC_SELF_STAT,
|
|
80
|
+
): Promise<number | null> {
|
|
81
|
+
try {
|
|
82
|
+
return parseReapedChildCpuSeconds(await nodeFs.readFile(statPath, "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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 } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseReapedChildCpuSeconds,
|
|
9
|
+
readReapedChildCpuSeconds,
|
|
10
|
+
} from "./npm-install-cpu.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a `/proc/self/stat` line. Real ones are a single space-separated line
|
|
14
|
+
* whose second field is the executable name in parentheses; everything after
|
|
15
|
+
* it is positional. Only utime/stime (fields 14 and 15) and cutime/cstime
|
|
16
|
+
* (fields 16 and 17) matter here, so the rest is filler.
|
|
17
|
+
*/
|
|
18
|
+
function statLine({
|
|
19
|
+
comm = "node",
|
|
20
|
+
utime = 0,
|
|
21
|
+
stime = 0,
|
|
22
|
+
cutime = 0,
|
|
23
|
+
cstime = 0,
|
|
24
|
+
trailing = 20,
|
|
25
|
+
}: {
|
|
26
|
+
comm?: string;
|
|
27
|
+
utime?: number;
|
|
28
|
+
stime?: number;
|
|
29
|
+
cutime?: number;
|
|
30
|
+
cstime?: number;
|
|
31
|
+
trailing?: number;
|
|
32
|
+
}): string {
|
|
33
|
+
const beforeTimes = [
|
|
34
|
+
"R", // 3 state
|
|
35
|
+
"1", // 4 ppid
|
|
36
|
+
"1", // 5 pgrp
|
|
37
|
+
"1", // 6 session
|
|
38
|
+
"0", // 7 tty_nr
|
|
39
|
+
"-1", // 8 tpgid
|
|
40
|
+
"4194304", // 9 flags
|
|
41
|
+
"0", // 10 minflt
|
|
42
|
+
"0", // 11 cminflt
|
|
43
|
+
"0", // 12 majflt
|
|
44
|
+
"0", // 13 cmajflt
|
|
45
|
+
];
|
|
46
|
+
const fields = [
|
|
47
|
+
...beforeTimes,
|
|
48
|
+
String(utime),
|
|
49
|
+
String(stime),
|
|
50
|
+
String(cutime),
|
|
51
|
+
String(cstime),
|
|
52
|
+
...Array.from({ length: trailing }, () => "0"),
|
|
53
|
+
];
|
|
54
|
+
return `42 (${comm}) ${fields.join(" ")}\n`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let tmpDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
tmpDir = await nodeFs.mkdtemp(path.join(os.tmpdir(), "npm-install-cpu-"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await nodeFs.rm(tmpDir, { force: true, recursive: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("parseReapedChildCpuSeconds", () => {
|
|
68
|
+
it("sums the child user and system columns and converts clock ticks to seconds", () => {
|
|
69
|
+
// 250 + 150 ticks at 100 ticks per second.
|
|
70
|
+
expect(
|
|
71
|
+
parseReapedChildCpuSeconds(statLine({ cutime: 250, cstime: 150 })),
|
|
72
|
+
).toBe(4);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("ignores the process's own cpu columns", () => {
|
|
76
|
+
// utime/stime are this process's; only cutime/cstime cover reaped children.
|
|
77
|
+
expect(
|
|
78
|
+
parseReapedChildCpuSeconds(
|
|
79
|
+
statLine({ utime: 9999, stime: 9999, cutime: 100, cstime: 0 }),
|
|
80
|
+
),
|
|
81
|
+
).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads a zero as a real measurement rather than as missing", () => {
|
|
85
|
+
// An install that spawned nothing genuinely burned no child cpu, which is
|
|
86
|
+
// exactly the case worth telling apart from a slow one.
|
|
87
|
+
expect(parseReapedChildCpuSeconds(statLine({}))).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("handles an executable name containing spaces and parentheses", () => {
|
|
91
|
+
// The comm field is not escaped, so splitting on spaces from the left
|
|
92
|
+
// shifts every positional field after it.
|
|
93
|
+
expect(
|
|
94
|
+
parseReapedChildCpuSeconds(
|
|
95
|
+
statLine({ comm: "my (weird) proc", cutime: 100, cstime: 100 }),
|
|
96
|
+
),
|
|
97
|
+
).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns null when the line has no closing parenthesis", () => {
|
|
101
|
+
expect(parseReapedChildCpuSeconds("42 node R 1 1 1")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns null when the line stops before the child cpu columns", () => {
|
|
105
|
+
expect(parseReapedChildCpuSeconds("42 (node) R 1 1 1")).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when a child cpu column is not a number", () => {
|
|
109
|
+
expect(
|
|
110
|
+
parseReapedChildCpuSeconds(
|
|
111
|
+
statLine({ cutime: 100 }).replace(" 100 0", " x 0"),
|
|
112
|
+
),
|
|
113
|
+
).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("readReapedChildCpuSeconds", () => {
|
|
118
|
+
it("reads the measurement from the given path", async () => {
|
|
119
|
+
const file = path.join(tmpDir, "stat");
|
|
120
|
+
await nodeFs.writeFile(file, statLine({ cutime: 500, cstime: 500 }));
|
|
121
|
+
await expect(readReapedChildCpuSeconds(file)).resolves.toBe(10);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns null when the file does not exist", async () => {
|
|
125
|
+
// macOS and any other non-procfs platform: the attribute is omitted
|
|
126
|
+
// rather than reported as zero cpu.
|
|
127
|
+
await expect(
|
|
128
|
+
readReapedChildCpuSeconds(path.join(tmpDir, "missing")),
|
|
129
|
+
).resolves.toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|