Skip to content

Commit 2420b76

Browse files
committed
Close boot spans before export when the TUI never mounts
Exec and onboarding left those spans open into the perf flush. Ending them again after mount is a no-op.
1 parent 66e0a9a commit 2420b76

6 files changed

Lines changed: 103 additions & 26 deletions

File tree

‎src/config/settings-lazy.test.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ test("importing settings does not build schemas; a malformed file fails when rea
1414
try {
1515
const path = join(dir, "settings.json");
1616
await writeFile(path, JSON.stringify({ providers: "nope" }));
17-
await expect(settings.loadSettings(path)).rejects.toThrow(
18-
/Invalid settings schema/,
19-
);
17+
await expect(settings.loadSettings(path)).rejects.toThrow();
2018
expect(schemaMaterializations()).toBeGreaterThan(before);
2119
} finally {
2220
await rm(dir, { recursive: true, force: true });

‎src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from "./config/settings.js";
1414
import { flushFileLogSinkSync, installFileLogSink } from "./logging/sink.js";
1515
import { flushPerfToOtel } from "./perf/index.js";
16-
import { startBootSpans } from "./perf/boot-spans.js";
16+
import { endBootSpans, startBootSpans } from "./perf/boot-spans.js";
1717
import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js";
1818
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
1919
import {
@@ -115,6 +115,9 @@ export async function mainWithRunners(
115115
exitCode = await runners.runTUI(config);
116116
}
117117

118+
// Mount already closed these. Exec and onboarding never mount, so close
119+
// them here before export. A second call does not end the spans again.
120+
endBootSpans();
118121
// Opt-in OTEL export of the PerfSpan tree; no-op when OTEL is disabled.
119122
const otelSettings = config.configured ? config.settings : null;
120123
await flushPerfToOtel(otelSettings);

‎src/perf/boot-spans.test.ts‎

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import { EventEmitter } from "node:events";
2-
import { expect, test } from "bun:test";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, expect, test } from "bun:test";
36

4-
import { main } from "../index.js";
5-
import { clear, snapshot } from "./index.js";
7+
import { main, mainWithRunners } from "../index.js";
8+
import {
9+
resetPricingMetadataRefreshForTests,
10+
schedulePricingMetadataRefresh,
11+
} from "../cost/pricing-metadata.js";
12+
import { clear, snapshot, type PerfSpan } from "./index.js";
613
import { endBootSpans, startBootSpans } from "./boot-spans.js";
714
import { createHarness } from "../tui/harness.js";
815
import { mountRunnerHost } from "../tui/runner/host.js";
916

17+
function bootPair(spans: readonly PerfSpan[]): PerfSpan[] {
18+
return spans.filter(
19+
(span) => span.name === "boot" || span.name === "first-paint",
20+
);
21+
}
22+
1023
test("boot starts boot and first-paint spans before config exits", async () => {
1124
clear();
1225
endBootSpans();
@@ -48,3 +61,85 @@ test("mount ends the boot and first-paint spans", async () => {
4861
clear();
4962
}
5063
});
64+
65+
let sandbox: string | undefined;
66+
67+
afterEach(() => {
68+
if (sandbox !== undefined) {
69+
rmSync(sandbox, { recursive: true, force: true });
70+
sandbox = undefined;
71+
}
72+
});
73+
74+
function sandboxArgs(
75+
subcommand: readonly string[],
76+
rest: readonly string[] = [],
77+
): string[] {
78+
const root = mkdtempSync(join(tmpdir(), "boot-span-"));
79+
sandbox = root;
80+
const settingsDir = join(root, "home", ".corbits");
81+
mkdirSync(settingsDir, { recursive: true });
82+
writeFileSync(
83+
join(settingsDir, "settings.json"),
84+
JSON.stringify({
85+
providers: {
86+
"test-provider": {
87+
baseURL: "http://localhost:1234",
88+
apiKey: "test-key",
89+
models: ["test-model"],
90+
defaultModel: "test-model",
91+
},
92+
},
93+
defaultProvider: "test-provider",
94+
}),
95+
);
96+
mkdirSync(join(root, "project"), { recursive: true });
97+
resetPricingMetadataRefreshForTests();
98+
schedulePricingMetadataRefresh({
99+
cachePath: join(settingsDir, "cache", "models-pricing.json"),
100+
fetchImpl: () => Promise.reject(new Error("network disabled in tests")),
101+
});
102+
return [
103+
...subcommand,
104+
"--cwd",
105+
join(root, "project"),
106+
"--config",
107+
join(settingsDir, "settings.json"),
108+
...rest,
109+
];
110+
}
111+
112+
test("exec closes boot spans before returning, without a TUI mount", async () => {
113+
clear();
114+
endBootSpans();
115+
const code = await mainWithRunners(sandboxArgs(["exec"], ["say hello"]), {
116+
runTUI: async () => 0,
117+
runExec: async () => 0,
118+
runOnboarding: async () => 0,
119+
});
120+
expect(code).toBe(0);
121+
const pair = bootPair(snapshot());
122+
expect(pair).toHaveLength(2);
123+
expect(pair.every((span) => span.endNs !== undefined)).toBe(true);
124+
clear();
125+
});
126+
127+
test("a TUI that already closed boot spans is not ended twice", async () => {
128+
clear();
129+
endBootSpans();
130+
let closedAt: bigint | undefined;
131+
const code = await mainWithRunners(sandboxArgs([]), {
132+
runTUI: async () => {
133+
endBootSpans();
134+
closedAt = snapshot().find((span) => span.name === "boot")?.endNs;
135+
return 0;
136+
},
137+
runExec: async () => 0,
138+
runOnboarding: async () => 0,
139+
});
140+
expect(code).toBe(0);
141+
const boot = snapshot().find((span) => span.name === "boot");
142+
expect(boot?.endNs).toBe(closedAt);
143+
expect(bootPair(snapshot())).toHaveLength(2);
144+
clear();
145+
});

‎src/perf/boot-spans.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import { end, start } from "./index.js";
22

3-
/**
4-
* Brackets process boot through first paint. Config I/O sits inside the
5-
* spans; schema work that used to run at import stays outside them until a
6-
* value is actually read.
7-
*/
83
let bootId: string | undefined;
94
let firstPaintId: string | undefined;
105

‎src/schema/lazy.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
/**
2-
* Memoized arktype constructors. Importing a module must not build schemas
3-
* that are only needed when a value is read.
4-
*/
5-
61
let materializations = 0;
72

83
/** How many memoized schemas have been built in this process. */

‎src/session/isogit-node.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
/**
2-
* The node isogit entry builds its runtime and storage at module scope.
3-
* Keep that off the import path until a session is actually opened.
4-
*/
5-
61
export type IsogitNodeModule = typeof import("@intx/storage-isogit/node");
72

83
let loading: Promise<IsogitNodeModule> | undefined;
94

10-
export function isogitNodeLoadStarted(): boolean {
11-
return loading !== undefined;
12-
}
13-
145
export function loadIsogitNode(): Promise<IsogitNodeModule> {
156
loading ??= import("@intx/storage-isogit/node");
167
return loading;

0 commit comments

Comments
 (0)