@malloy-publisher/server 0.0.197 → 0.0.198

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 (36) hide show
  1. package/README.docker.md +47 -0
  2. package/dist/app/api-doc.yaml +3 -20
  3. package/dist/app/assets/{EnvironmentPage-BVkQH_xQ.js → EnvironmentPage-C7rtH4mC.js} +1 -1
  4. package/dist/app/assets/{HomePage-BgH9UkjK.js → HomePage-DwkH7OrS.js} +1 -1
  5. package/dist/app/assets/{MainPage-DiBxABem.js → MainPage-D38LtZDV.js} +1 -1
  6. package/dist/app/assets/{ModelPage-oS70fj83.js → ModelPage-DOol8Mz7.js} +1 -1
  7. package/dist/app/assets/{PackagePage-F_qLDAdv.js → PackagePage-0tgzA_kO.js} +1 -1
  8. package/dist/app/assets/{RouteError-WqpffppN.js → RouteError-BaMsOSly.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-_YmC-ebR.js → WorkbookPage-Cx4SePkx.js} +1 -1
  10. package/dist/app/assets/{core-B8L9xCYT.es-BcRLJTnC.js → core-CbsC6R_Y.es-Cwf6asf3.js} +1 -1
  11. package/dist/app/assets/{index-rg8Ok8nl.js → index-DL6BZTuw.js} +1 -1
  12. package/dist/app/assets/{index-C3XPaTaS.js → index-DNofXMxi.js} +1 -1
  13. package/dist/app/assets/{index-BMViiwtJ.js → index-U38AyjJL.js} +3 -3
  14. package/dist/app/assets/{index.umd-CCAfKkxY.js → index.umd-B68wGGkM.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +812 -450
  17. package/package.json +1 -1
  18. package/src/config.spec.ts +81 -0
  19. package/src/config.ts +126 -0
  20. package/src/controller/package.controller.ts +70 -29
  21. package/src/errors.ts +13 -0
  22. package/src/health.ts +0 -26
  23. package/src/mcp/tools/discovery_tools.ts +6 -2
  24. package/src/path_safety.spec.ts +158 -0
  25. package/src/path_safety.ts +140 -0
  26. package/src/server.ts +13 -0
  27. package/src/service/environment.ts +614 -198
  28. package/src/service/environment_admission.spec.ts +180 -0
  29. package/src/service/environment_store.spec.ts +0 -19
  30. package/src/service/environment_store.ts +24 -21
  31. package/src/service/manifest_service.spec.ts +7 -2
  32. package/src/service/manifest_service.ts +8 -2
  33. package/src/service/materialization_service.ts +14 -3
  34. package/src/service/package_memory_governor.spec.ts +173 -0
  35. package/src/service/package_memory_governor.ts +233 -0
  36. package/src/service/package_race.spec.ts +208 -0
@@ -0,0 +1,180 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { ServiceUnavailableError } from "../errors";
7
+ import { buildEnvironmentMalloyConfig } from "./connection";
8
+ import { Environment } from "./environment";
9
+ import type { PackageMemoryGovernor } from "./package_memory_governor";
10
+
11
+ /**
12
+ * Minimal subset of {@link PackageMemoryGovernor} that
13
+ * `Environment.assertCanAdmitNewPackage` actually consults. Allows us
14
+ * to drive the gate from the tests without spinning the real OTel
15
+ * instrumentation pipeline.
16
+ */
17
+ class StubGovernor {
18
+ public backpressured = false;
19
+ isBackpressured(): boolean {
20
+ return this.backpressured;
21
+ }
22
+ }
23
+
24
+ function makeEnvironment(envPath: string): Environment {
25
+ const malloyConfig = buildEnvironmentMalloyConfig([], envPath);
26
+ return new Environment("test-env", envPath, malloyConfig, []);
27
+ }
28
+
29
+ describe("Environment admission gate (memory governor choke point)", () => {
30
+ let envDir: string;
31
+
32
+ beforeEach(() => {
33
+ envDir = fs.mkdtempSync(
34
+ path.join(os.tmpdir(), "publisher-env-admission-"),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ fs.rmSync(envDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it("admits new packages when no governor is attached (legacy behaviour)", async () => {
43
+ const env = makeEnvironment(envDir);
44
+ // No governor set; the gate must be a pure no-op. The package
45
+ // doesn't exist on disk, so addPackage rejects with
46
+ // PackageNotFoundError — that we get any error other than 503
47
+ // is the assertion.
48
+ let caught: unknown;
49
+ try {
50
+ await env.addPackage("does-not-exist");
51
+ } catch (err) {
52
+ caught = err;
53
+ }
54
+ expect(caught).toBeDefined();
55
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
56
+ });
57
+
58
+ it("rejects getPackage cache-miss with 503 when back-pressured", async () => {
59
+ const env = makeEnvironment(envDir);
60
+ const governor = new StubGovernor();
61
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
62
+ governor.backpressured = true;
63
+
64
+ // No package by this name has ever been loaded, so this is the
65
+ // exact "lazy-load on cache miss" path Monty flagged. The gate
66
+ // must throw before Package.create touches the disk.
67
+ await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
68
+ ServiceUnavailableError,
69
+ );
70
+ });
71
+
72
+ it("rejects getPackage reload=true with 503 when back-pressured", async () => {
73
+ const env = makeEnvironment(envDir);
74
+ const governor = new StubGovernor();
75
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
76
+ governor.backpressured = true;
77
+
78
+ await expect(env.getPackage("ghost", true)).rejects.toBeInstanceOf(
79
+ ServiceUnavailableError,
80
+ );
81
+ });
82
+
83
+ it("rejects addPackage with 503 when back-pressured (after the 404 check passes)", async () => {
84
+ // Create a real (empty) package directory so the existence
85
+ // check passes and the gate gets to run. Without this, the
86
+ // PackageNotFoundError would mask the 503 we want to assert.
87
+ const pkgName = "real-pkg";
88
+ fs.mkdirSync(path.join(envDir, pkgName));
89
+
90
+ const env = makeEnvironment(envDir);
91
+ const governor = new StubGovernor();
92
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
93
+ governor.backpressured = true;
94
+
95
+ await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
96
+ ServiceUnavailableError,
97
+ );
98
+ });
99
+
100
+ it("returns 404 (not 503) when the package directory does not exist, even under pressure", async () => {
101
+ // 404 must take precedence over 503: a permanent "you forgot to
102
+ // upload the package" error should not be masked as a transient
103
+ // "retry later" — otherwise operators chase phantom memory
104
+ // problems while the real fix is a missing artifact.
105
+ const env = makeEnvironment(envDir);
106
+ const governor = new StubGovernor();
107
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
108
+ governor.backpressured = true;
109
+
110
+ let caught: unknown;
111
+ try {
112
+ await env.addPackage("never-existed");
113
+ } catch (err) {
114
+ caught = err;
115
+ }
116
+ expect(caught).toBeDefined();
117
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
118
+ });
119
+
120
+ it("allowAdmission=true bypasses the gate (for future warmup/probe callers)", async () => {
121
+ const env = makeEnvironment(envDir);
122
+ const governor = new StubGovernor();
123
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
124
+ governor.backpressured = true;
125
+
126
+ // With the bypass, the gate must not fire — the call should
127
+ // proceed to the real loader and fail there with some other
128
+ // error (PackageNotFoundError-equivalent from Package.create on
129
+ // a non-existent directory). The assertion is "not 503".
130
+ let caught: unknown;
131
+ try {
132
+ await env.getPackage("ghost", false, { allowAdmission: true });
133
+ } catch (err) {
134
+ caught = err;
135
+ }
136
+ expect(caught).toBeDefined();
137
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
138
+ });
139
+
140
+ it("clearing back-pressure on the governor immediately re-admits new loads", async () => {
141
+ const env = makeEnvironment(envDir);
142
+ const governor = new StubGovernor();
143
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
144
+
145
+ governor.backpressured = true;
146
+ await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
147
+ ServiceUnavailableError,
148
+ );
149
+
150
+ // Flip the flag (simulating the periodic poller crossing the
151
+ // low-water mark) and verify the next call no longer 503s.
152
+ governor.backpressured = false;
153
+ let caught: unknown;
154
+ try {
155
+ await env.getPackage("ghost", false);
156
+ } catch (err) {
157
+ caught = err;
158
+ }
159
+ expect(caught).toBeDefined();
160
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
161
+ });
162
+
163
+ it("detaching the governor (set null) reverts to legacy admit-everything", async () => {
164
+ const env = makeEnvironment(envDir);
165
+ const governor = new StubGovernor();
166
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
167
+ governor.backpressured = true;
168
+
169
+ env.setMemoryGovernor(null);
170
+
171
+ let caught: unknown;
172
+ try {
173
+ await env.getPackage("ghost", false);
174
+ } catch (err) {
175
+ caught = err;
176
+ }
177
+ expect(caught).toBeDefined();
178
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
179
+ });
180
+ });
@@ -355,15 +355,6 @@ describe("EnvironmentStore Service", () => {
355
355
  expect(projects.length).toBe(2);
356
356
  expect(projects.map((p) => p.name)).toContain(projectName1);
357
357
  expect(projects.map((p) => p.name)).toContain(projectName2);
358
-
359
- // All envs initialized cleanly → status is "serving" (not
360
- // "degraded") and there's no failedEnvironments key on the
361
- // response. This is the happy-path companion to the
362
- // "should skip a project with invalid startup connection config"
363
- // test which exercises the degraded path.
364
- const status = await newEnvironmentStore.getStatus();
365
- expect(status.operationalState).toBe("serving");
366
- expect(status.failedEnvironments).toBeUndefined();
367
358
  });
368
359
 
369
360
  it("should skip a project with invalid startup connection config", async () => {
@@ -436,16 +427,6 @@ describe("EnvironmentStore Service", () => {
436
427
  await expect(
437
428
  newEnvironmentStore.getEnvironment(invalidProjectName),
438
429
  ).rejects.toThrow();
439
-
440
- // The skipped environment should surface in the status response
441
- // so external callers (CI smoke tests, dashboards) can tell the
442
- // server is only partially serving.
443
- const status = await newEnvironmentStore.getStatus();
444
- expect(status.operationalState).toBe("degraded");
445
- expect(status.failedEnvironments).toBeDefined();
446
- expect(status.failedEnvironments?.map((f) => f.name)).toContain(
447
- invalidProjectName,
448
- );
449
430
  });
450
431
 
451
432
  it("should handle project updates", async () => {
@@ -25,16 +25,12 @@ import {
25
25
  FrozenConfigError,
26
26
  PackageNotFoundError,
27
27
  } from "../errors";
28
- import {
29
- getOperationalState,
30
- markDegraded,
31
- markNotReady,
32
- markReady,
33
- } from "../health";
28
+ import { getOperationalState, markNotReady, markReady } from "../health";
34
29
  import { formatDuration, logger } from "../logger";
35
30
  import { Connection } from "../storage/DatabaseInterface";
36
31
  import { StorageConfig, StorageManager } from "../storage/StorageManager";
37
32
  import { Environment, PackageStatus } from "./environment";
33
+ import type { PackageMemoryGovernor } from "./package_memory_governor";
38
34
  type ApiEnvironment = components["schemas"]["Environment"];
39
35
 
40
36
  const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
@@ -101,12 +97,15 @@ export class EnvironmentStore {
101
97
  public publisherConfigIsFrozen: boolean;
102
98
  public finishedInitialization: Promise<void>;
103
99
  private isInitialized: boolean = false;
104
- private failedEnvironments: Array<{ name: string; error: string }> = [];
105
100
  public storageManager: StorageManager;
106
101
  private s3Client = new S3({
107
102
  followRegionRedirects: true,
108
103
  });
109
104
  private gcsClient: Storage;
105
+ // Shared by every Environment so the back-pressure decision is
106
+ // process-wide. Set once at server start via setMemoryGovernor;
107
+ // new Environments pick it up at construction.
108
+ private memoryGovernor: PackageMemoryGovernor | null = null;
110
109
 
111
110
  constructor(serverRootPath: string) {
112
111
  this.serverRootPath = serverRootPath;
@@ -123,6 +122,19 @@ export class EnvironmentStore {
123
122
  this.finishedInitialization = this.initialize();
124
123
  }
125
124
 
125
+ /**
126
+ * Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
127
+ * Propagated to every Environment so the back-pressure decision is
128
+ * process-wide, and remembered so any Environment created *after*
129
+ * this call also picks it up at construction.
130
+ */
131
+ public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
132
+ this.memoryGovernor = governor;
133
+ for (const env of this.environments.values()) {
134
+ env.setMemoryGovernor(governor);
135
+ }
136
+ }
137
+
126
138
  private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
127
139
  try {
128
140
  await this.addEnvironment(
@@ -148,10 +160,6 @@ export class EnvironmentStore {
148
160
  `Error initializing environment${label}; skipping environment`,
149
161
  this.extractErrorDataFromError(error),
150
162
  );
151
- this.failedEnvironments.push({
152
- name: environmentName ?? "<unknown>",
153
- error: error instanceof Error ? error.message : String(error),
154
- });
155
163
  }
156
164
 
157
165
  private async initialize() {
@@ -248,6 +256,9 @@ export class EnvironmentStore {
248
256
  ...conn.config,
249
257
  })),
250
258
  );
259
+ environmentInstance.setMemoryGovernor(
260
+ this.memoryGovernor,
261
+ );
251
262
 
252
263
  // Get packages from database
253
264
  const packages = await repository.listPackages(
@@ -285,11 +296,7 @@ export class EnvironmentStore {
285
296
  }
286
297
 
287
298
  this.isInitialized = true;
288
- if (this.failedEnvironments.length > 0) {
289
- markDegraded();
290
- } else {
291
- markReady();
292
- }
299
+ markReady();
293
300
  const initializationDuration = performance.now() - initialTime;
294
301
  logger.info(
295
302
  `Environment store successfully initialized in ${formatDuration(initializationDuration)}`,
@@ -703,11 +710,6 @@ export class EnvironmentStore {
703
710
  frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
704
711
  operationalState:
705
712
  getOperationalState() as components["schemas"]["ServerStatus"]["operationalState"],
706
- ...(this.failedEnvironments.length > 0 && {
707
- failedEnvironments: [
708
- ...this.failedEnvironments,
709
- ] as components["schemas"]["ServerStatus"]["failedEnvironments"],
710
- }),
711
713
  };
712
714
 
713
715
  const environments = await this.listEnvironments(true);
@@ -856,6 +858,7 @@ export class EnvironmentStore {
856
858
  absoluteEnvironmentPath,
857
859
  environment.connections || [],
858
860
  );
861
+ newEnvironment.setMemoryGovernor(this.memoryGovernor);
859
862
 
860
863
  if (!newEnvironment.metadata) newEnvironment.metadata = {};
861
864
  newEnvironment.metadata.location = absoluteEnvironmentPath;
@@ -39,6 +39,7 @@ function createMocks() {
39
39
  } as unknown as sinon.SinonStubbedInstance<ResourceRepository>;
40
40
 
41
41
  const reloadAllModels = sandbox.stub().resolves();
42
+ const reloadAllModelsForPackage = sandbox.stub().resolves();
42
43
 
43
44
  const pkg = {
44
45
  reloadAllModels,
@@ -46,6 +47,7 @@ function createMocks() {
46
47
 
47
48
  const environment = {
48
49
  getPackage: sandbox.stub().resolves(pkg),
50
+ reloadAllModelsForPackage,
49
51
  };
50
52
 
51
53
  const environmentStore = {
@@ -66,6 +68,7 @@ function createMocks() {
66
68
  environment,
67
69
  pkg,
68
70
  reloadAllModels,
71
+ reloadAllModelsForPackage,
69
72
  service,
70
73
  };
71
74
  }
@@ -153,7 +156,9 @@ describe("ManifestService", () => {
153
156
  ),
154
157
  ).toBe(true);
155
158
  expect(ctx.environment.getPackage.calledWith("pkg", false)).toBe(true);
156
- expect(ctx.reloadAllModels.calledWith(manifest.entries)).toBe(true);
159
+ expect(
160
+ ctx.reloadAllModelsForPackage.calledWith("pkg", manifest.entries),
161
+ ).toBe(true);
157
162
  });
158
163
 
159
164
  it("should return an empty manifest when no entries exist", async () => {
@@ -170,7 +175,7 @@ describe("ManifestService", () => {
170
175
  );
171
176
 
172
177
  expect(result.entries).toEqual({});
173
- expect(ctx.reloadAllModels.calledWith({})).toBe(true);
178
+ expect(ctx.reloadAllModelsForPackage.calledWith("pkg", {})).toBe(true);
174
179
  });
175
180
  });
176
181
 
@@ -82,8 +82,14 @@ export class ManifestService {
82
82
  environmentName,
83
83
  false,
84
84
  );
85
- const pkg = await environment.getPackage(packageName, false);
86
- await pkg.reloadAllModels(manifest.entries);
85
+ // Ensure the package is loaded, then reload its models under the
86
+ // per-package mutex so the disk reads are serialized against
87
+ // installPackage / deletePackage.
88
+ await environment.getPackage(packageName, false);
89
+ await environment.reloadAllModelsForPackage(
90
+ packageName,
91
+ manifest.entries,
92
+ );
87
93
 
88
94
  logger.info("Reloaded manifest and recompiled models", {
89
95
  environmentId,
@@ -376,8 +376,14 @@ export class MaterializationService {
376
376
  environmentName,
377
377
  false,
378
378
  );
379
- const pkg = await environment.getPackage(packageName, false);
380
- await pkg.reloadAllModels(updatedManifest.entries);
379
+ // Ensure the package is loaded, then reload models under the
380
+ // per-package mutex so the disk reads are serialized against
381
+ // installPackage / deletePackage.
382
+ await environment.getPackage(packageName, false);
383
+ await environment.reloadAllModelsForPackage(
384
+ packageName,
385
+ updatedManifest.entries,
386
+ );
381
387
  }
382
388
 
383
389
  await this.transitionExecution(executionId, "SUCCESS", {
@@ -604,8 +610,13 @@ export class MaterializationService {
604
610
  // ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
605
611
  // `connections` is built lazily from the connection names the plan
606
612
  // actually targets — no upfront ATTACH on every environment connection.
613
+ // Hold the per-package mutex for the duration of the compile so the
614
+ // `fs.stat` + `runtime.loadModel` calls inside `compilePackageBuildPlan`
615
+ // are serialized against `installPackage` / `deletePackage`.
607
616
  const { graphs, sources, connectionDigests, connections } =
608
- await this.compilePackageBuildPlan(pkg, signal);
617
+ await environment.withPackageLock(packageName, () =>
618
+ this.compilePackageBuildPlan(pkg, signal),
619
+ );
609
620
 
610
621
  if (graphs.length === 0) {
611
622
  logger.info("No persist sources to build");
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import type { MemoryGovernorConfig } from "../config";
4
+ import { PackageMemoryGovernor } from "./package_memory_governor";
5
+
6
+ const ONE_GB = 1024 * 1024 * 1024;
7
+
8
+ function makeConfig(
9
+ overrides: Partial<MemoryGovernorConfig> = {},
10
+ ): MemoryGovernorConfig {
11
+ return {
12
+ maxMemoryBytes: ONE_GB,
13
+ highWaterFraction: 0.8,
14
+ lowWaterFraction: 0.7,
15
+ checkIntervalMs: 5_000,
16
+ backpressureEnabled: true,
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Test driver that lets us push a sequence of RSS values into the
23
+ * governor and inspect the state machine's reactions deterministically
24
+ * — no real allocations, no real timers.
25
+ */
26
+ class FakeRssSampler {
27
+ private value = 0;
28
+ constructor(initial = 0) {
29
+ this.value = initial;
30
+ }
31
+ set(value: number): void {
32
+ this.value = value;
33
+ }
34
+ sampler = (): number => this.value;
35
+ }
36
+
37
+ describe("PackageMemoryGovernor", () => {
38
+ it("does not activate back-pressure below the high-water mark", () => {
39
+ const rss = new FakeRssSampler(0.5 * ONE_GB);
40
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
41
+ gov.tick();
42
+ expect(gov.isBackpressured()).toBe(false);
43
+ });
44
+
45
+ it("activates back-pressure at or above the high-water mark", () => {
46
+ const rss = new FakeRssSampler(0);
47
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
48
+
49
+ rss.set(0.79 * ONE_GB);
50
+ gov.tick();
51
+ expect(gov.isBackpressured()).toBe(false);
52
+
53
+ // 0.8 * 1GB is exactly the high-water threshold; using >= so it
54
+ // trips on the boundary.
55
+ rss.set(0.8 * ONE_GB);
56
+ gov.tick();
57
+ expect(gov.isBackpressured()).toBe(true);
58
+ });
59
+
60
+ it("does not clear back-pressure inside the hysteresis band", () => {
61
+ const rss = new FakeRssSampler(0.9 * ONE_GB);
62
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
63
+ gov.tick();
64
+ expect(gov.isBackpressured()).toBe(true);
65
+
66
+ // Between low (0.7) and high (0.8) — must stay backpressured.
67
+ rss.set(0.75 * ONE_GB);
68
+ gov.tick();
69
+ expect(gov.isBackpressured()).toBe(true);
70
+ });
71
+
72
+ it("clears back-pressure at or below the low-water mark", () => {
73
+ const rss = new FakeRssSampler(0.9 * ONE_GB);
74
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
75
+ gov.tick();
76
+ expect(gov.isBackpressured()).toBe(true);
77
+
78
+ // The implementation floors lowWaterBytes (= 0.7 * 1GB → 751619276),
79
+ // so we need to feed a value at or below that integer — `0.7 * 1GB`
80
+ // as a float is 751619276.8 which sits just above the threshold.
81
+ rss.set(0.69 * ONE_GB);
82
+ gov.tick();
83
+ expect(gov.isBackpressured()).toBe(false);
84
+ });
85
+
86
+ it("re-activates after recovery if RSS climbs again", () => {
87
+ const rss = new FakeRssSampler(0);
88
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
89
+
90
+ rss.set(0.85 * ONE_GB);
91
+ gov.tick();
92
+ expect(gov.isBackpressured()).toBe(true);
93
+
94
+ rss.set(0.6 * ONE_GB);
95
+ gov.tick();
96
+ expect(gov.isBackpressured()).toBe(false);
97
+
98
+ rss.set(0.9 * ONE_GB);
99
+ gov.tick();
100
+ expect(gov.isBackpressured()).toBe(true);
101
+ });
102
+
103
+ it("samples but never flips the flag when backpressureEnabled=false", () => {
104
+ const rss = new FakeRssSampler(0.95 * ONE_GB);
105
+ const gov = new PackageMemoryGovernor(
106
+ makeConfig({ backpressureEnabled: false }),
107
+ rss.sampler,
108
+ );
109
+ gov.tick();
110
+ expect(gov.isBackpressured()).toBe(false);
111
+ // Status still tracks RSS even though the flag is suppressed.
112
+ expect(gov.getStatus().rssBytes).toBe(0.95 * ONE_GB);
113
+ });
114
+
115
+ it("survives a throwing sampler without crashing or flipping state", () => {
116
+ let throwOnce = true;
117
+ const gov = new PackageMemoryGovernor(makeConfig(), () => {
118
+ if (throwOnce) {
119
+ throwOnce = false;
120
+ throw new Error("simulated sampling failure");
121
+ }
122
+ return 0.4 * ONE_GB;
123
+ });
124
+
125
+ // First tick: sampler throws; governor swallows it and leaves
126
+ // the state untouched.
127
+ gov.tick();
128
+ expect(gov.isBackpressured()).toBe(false);
129
+
130
+ // Second tick succeeds.
131
+ gov.tick();
132
+ expect(gov.isBackpressured()).toBe(false);
133
+ });
134
+
135
+ it("start() takes an immediate sample so a hot-start respects the cap", () => {
136
+ const rss = new FakeRssSampler(0.95 * ONE_GB);
137
+ const gov = new PackageMemoryGovernor(
138
+ // Big interval so we know the initial sample isn't from a
139
+ // delayed tick.
140
+ makeConfig({ checkIntervalMs: 60_000 }),
141
+ rss.sampler,
142
+ );
143
+ gov.start();
144
+ expect(gov.isBackpressured()).toBe(true);
145
+ gov.stop();
146
+ });
147
+
148
+ it("stop() clears back-pressure and is idempotent", () => {
149
+ const rss = new FakeRssSampler(0.95 * ONE_GB);
150
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
151
+ gov.tick();
152
+ expect(gov.isBackpressured()).toBe(true);
153
+
154
+ gov.stop();
155
+ expect(gov.isBackpressured()).toBe(false);
156
+ // Second call is a no-op (no thrown error, flag stays cleared).
157
+ gov.stop();
158
+ expect(gov.isBackpressured()).toBe(false);
159
+ });
160
+
161
+ it("exposes computed threshold bytes through getStatus", () => {
162
+ const rss = new FakeRssSampler(0.4 * ONE_GB);
163
+ const gov = new PackageMemoryGovernor(makeConfig(), rss.sampler);
164
+ gov.tick();
165
+ const status = gov.getStatus();
166
+ expect(status.maxMemoryBytes).toBe(ONE_GB);
167
+ expect(status.highWaterBytes).toBe(Math.floor(0.8 * ONE_GB));
168
+ expect(status.lowWaterBytes).toBe(Math.floor(0.7 * ONE_GB));
169
+ expect(status.rssBytes).toBe(0.4 * ONE_GB);
170
+ expect(status.backpressured).toBe(false);
171
+ expect(typeof status.lastSampledAt).toBe("number");
172
+ });
173
+ });