@superblocksteam/sdk 2.0.153 → 2.0.154-next.1

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.
Files changed (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
  3. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  4. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +59 -7
  5. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  6. package/dist/cli-replacement/dev.d.mts.map +1 -1
  7. package/dist/cli-replacement/dev.mjs +35 -1
  8. package/dist/cli-replacement/dev.mjs.map +1 -1
  9. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +30 -0
  10. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
  11. package/dist/cli-replacement/npm-install-cpu.d.mts +15 -0
  12. package/dist/cli-replacement/npm-install-cpu.d.mts.map +1 -0
  13. package/dist/cli-replacement/npm-install-cpu.mjs +79 -0
  14. package/dist/cli-replacement/npm-install-cpu.mjs.map +1 -0
  15. package/dist/cli-replacement/npm-install-cpu.test.d.mts +2 -0
  16. package/dist/cli-replacement/npm-install-cpu.test.d.mts.map +1 -0
  17. package/dist/cli-replacement/npm-install-cpu.test.mjs +84 -0
  18. package/dist/cli-replacement/npm-install-cpu.test.mjs.map +1 -0
  19. package/package.json +6 -6
  20. package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
  21. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +129 -58
  22. package/src/cli-replacement/dev.mts +42 -0
  23. package/src/cli-replacement/install-packages.npm-registry.test.mts +43 -0
  24. package/src/cli-replacement/npm-install-cpu.mts +86 -0
  25. package/src/cli-replacement/npm-install-cpu.test.mts +131 -0
  26. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superblocksteam/sdk",
3
- "version": "2.0.153",
3
+ "version": "2.0.154-next.1",
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.153",
61
- "@superblocksteam/shared": "0.9605.3",
62
- "@superblocksteam/telemetry": "2.0.153",
63
- "@superblocksteam/util": "2.0.153",
64
- "@superblocksteam/vite-plugin-file-sync": "2.0.153"
60
+ "@superblocksteam/shared": "0.9607.3",
61
+ "@superblocksteam/telemetry": "2.0.154-next.1",
62
+ "@superblocksteam/library-shared": "2.0.154-next.1",
63
+ "@superblocksteam/util": "2.0.154-next.1",
64
+ "@superblocksteam/vite-plugin-file-sync": "2.0.154-next.1"
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 = vi.fn(() => undefined);
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 } = vi.hoisted(() => {
142
- const startupOpLog: string[] = [];
143
- function buildMockGitService(cwd: string) {
144
- return {
145
- workDir: cwd,
146
- configure: vi.fn(async () => {
147
- startupOpLog.push("git:configure");
148
- }),
149
- init: vi.fn(async () => {
150
- startupOpLog.push("git:init");
151
- }),
152
- addRemote: vi.fn(async () => {
153
- startupOpLog.push("git:addRemote");
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
- fetch: vi.fn(async () => {
156
- startupOpLog.push("git:fetch");
157
- }),
158
- raw: vi.fn(async (args: string | string[]) => {
159
- const argv = Array.isArray(args) ? args : [args];
160
- startupOpLog.push(`git:raw:${argv.join(" ")}`);
161
- if (argv[0] === "ls-remote" && argv.includes("__live__")) {
162
- return "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\trefs/heads/__live__\n";
163
- }
164
- if (argv[0] === "branch" && argv.includes("--show-current")) {
165
- return "__live__\n";
166
- }
167
- if (argv[0] === "commit") {
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
- return "";
171
- }),
172
- push: vi.fn(async () => {
173
- startupOpLog.push("git:push");
174
- }),
175
- getDefaultBranch: vi.fn(async () => "main"),
176
- getRemotes: vi.fn(async () => [
177
- { name: "origin", refs: { fetch: "https://example.test/repo.git" } },
178
- ]),
179
- remote: vi.fn(async () => {
180
- startupOpLog.push("git:remote");
181
- }),
182
- revparse: vi.fn(async (ref: string) => {
183
- startupOpLog.push(`git:revparse:${ref}`);
184
- return "abc123\n";
185
- }),
186
- checkout: vi.fn(async () => {
187
- startupOpLog.push("git:checkout");
188
- }),
189
- checkoutOrCreate: vi.fn(async () => {
190
- startupOpLog.push("git:checkoutOrCreate");
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
- outcome: "skipped-not-configured",
289
- path: "/tmp/.superblocks/npmrc",
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
+ });