mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
The Bun single-file executable idled at 378 MiB RSS on macOS (306 MiB
with `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS=1`) because it parsed all 1463
provider executors at startup, read every embedded catalog file into
memory, evaluated optional dependencies it never used, and kept the
module-graph pages pinned for the process lifetime. This branch removes
each of those costs in its own commit, measured before and after, and
lands the lazy-schema executable at 100 to 167 MiB depending on the
platform, with the default mode roughly halved. No API body, ETag,
status code, error text, SDK/CLI wire shape or Cloudflare Workers
behaviour changes, and no new environment variable is added.
Resident memory after `GET /health` (medians of 3 to 5 alternating
rounds, same harness for both binaries, `main` = d11f0e5f):
| platform | measure | default before | default after | lazy before |
lazy after |
|---|---|---|---|---|---|
| macOS darwin-arm64, native | `ps` RSS | 378.0 MiB | 293.1 MiB | 306.5
MiB | 166.8 MiB |
| Debian 13 linux-x64, native | VmRSS | 343.6 MB | 183.8 MB | 269.8 MB |
99.5 MB |
| Linux arm64, Docker | VmRSS | 325.2 MB | 179.0 MB | 283.1 MB | 109.9
MB |
| Windows 11 x64, native | WorkingSet | 345.1 MiB | 225.5 MiB | 259.5
MiB | 113.6 MiB |
Startup to the first `/health` also drops from about 520 ms to 212 ms on
macOS and from 823 ms to 379 ms on linux-x64. On Linux the peak (VmHWM)
falls as well (370.6 to 305.1 MB default, 301.9 to 197.9 MB lazy), so a
cgroup limit sees the improvement too. macOS `ps` counts 55 to 66 MiB of
freed-but-resident allocator pages and the executable's clean `__TEXT`
pages, which is why the default mode stays above 256 MiB there while the
same binary sits at 184 MB on Linux.
What each commit does, with the macOS delta it was measured with:
- `perf(binary)`: _scripts/build-binary.ts_ passes `splitting: true` to
`Bun.build`, so the 1463 provider executors reached through `import()`
in _src/providers/registry.generated.ts_ become embedded chunks parsed
on first use instead of one 30 MB entry module parsed at startup.
Default 378.7 to 324.2 MiB, lazy 306.6 to 240.8 MiB. The smoke script
now POSTs a no-auth `quickchart` action so every target in the
build-binary matrix proves a chunk loads at runtime.
- `perf(catalog)`: `npm run generate:catalog` also writes
_catalog/apps-index.json_ (the catalog without schemas, 4.9 MB, written
atomically) and the executable embeds it. In lazy mode startup reads
that one file instead of all 1463 provider files, which on the
executable kept 55 MB of embedded pages resident. Lazy 240.1 to 182.5
MiB, default untouched. The index rides on the existing lazy flag, a
missing index falls back to the old scan with a warning, and a stale
index refuses to start with a message naming the fix.
_scripts/ensure-generated.ts_ regenerates when the index is missing or
older than the provider files, so existing checkouts regenerate once.
- `perf(server)`: `@aws-sdk/client-s3`, `pg`, `jose`,
`@modelcontextprotocol/server` with `zod`, and
`@scalar/hono-api-reference` load only when their feature is configured
or first requested, through a one-slot `PromiseCache` (the former
`IsolatePromiseCache`, moved to _src/core/promise-cache.ts_).
`createRuntimeJwtVerifier()` becomes async. Workers keep evaluating them
at app creation through `preloadOptionalServerModules()`, and a new
guard test fails if a static import pins any of them back into the
startup graph. Default 324.9 to 305.0 MiB, lazy 181.6 to 167.1 MiB.
- `perf(catalog)`: `providerSummariesJson` is kept as UTF-8 bytes
instead of a JS string (the catalog contains code points above U+00FF,
so JavaScriptCore stored the 6.5 MB body as 15 MB of UTF-16). The ETag
is still computed from the string, so `W/"62be35-aa331d99"` and the body
bytes are unchanged. Retained JSC heap 26.4 to 17.2 MiB in lazy mode.
- `perf(catalog)`: default mode reads provider files four at a time
instead of `Promise.all` over all 1463, which bounded the parse peak the
allocator kept resident. Default 305.9 to 292.8 MiB, startup +9 ms.
- `perf(server)`: `main()` in _src/server/index.ts_ resolves once
`serve()` has requested the listener and a promise chain owns shutdown,
so the entry module finishes evaluating. Bun issues its one-time
`madvise(MADV_DONTNEED)` over the embedded module graph only at that
point, and the top-level `await main()` had kept it from ever firing.
Linux only: 254.7 to 180.2 MB default and 175.4 to 110.3 MB lazy on the
same binary, RssFile 116 to 41 MB, macOS unchanged.
- `build(binary)` and `chore(binary)`: the smoke script reads the lazy
flag with the server's truthiness rule, asserts the second instance on a
port conflict exits with an address-in-use error, and no longer
hardcodes the MCP tool count. `assertBuildInputs()` refuses to build
when _dist/web/catalog_ (the Workers static chunks written by
_scripts/copy-catalog-assets.ts_) exists, because `compile.assets`
embeds _dist/web_ wholesale and that directory silently added 31 MB to a
binary during this work. Lint ignores are anchored to `/catalog` and the
documented executable size range moves to 150 to 175 MiB (every target
grows by the 4.9 MB index).
Two behaviours of the executable change without a flag, both to match
`node src/server/index.ts`, and both are documented in
_docs/single-binary.md_: a listen failure such as `EADDRINUSE` now exits
with code 1 instead of hanging inside Bun's entry-module wait, and an
uncaught exception thrown while serving now ends the process with exit
code 1 instead of being printed and survived. The other un-flagged
deltas are internal timing: the first use of a provider parses its chunk
(2 to 20 ms once per provider), the first `/mcp` request loads the MCP
module (about 20 to 30 ms once per process), and default-mode startup
pays about 9 ms for the batched reads.
`BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE=1` is Bun's own escape
hatch for the page-release hint.
Not done here, on purpose: pre-compressing `/api/providers` or streaming
`/api/actions` (net zero for the startup metric and both need a flag),
minifying the bundle (about 2 MiB after splitting), and packing the
catalog into a single embedded blob (Bun still touches about one page
per embedded file, roughly 18 MB on macOS and 35 MB on Linux).
Verified on every commit with `npm run fix-check`, `npm test` (184
files, 1789 tests), a darwin-arm64 build and `scripts/smoke-binary.ts`
in default and lazy mode. The final binaries were additionally smoked
natively on Debian 13 x64, Windows 11 x64 and macOS, and `GET
/api/providers` returns the same ETag and byte-identical body on every
platform and mode as the `main` build.
---------
Signed-off-by: Kevin Cui <[email protected]>
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
import { join, relative, resolve } from "node:path";
|
|
import { catalogIndexFileName } from "../src/catalog-index.ts";
|
|
|
|
// Build the single-file server executables with Bun.
|
|
//
|
|
// Usage: `bun scripts/build-binary.ts [target-name ...]` (normally through `npm run build:binary`, which
|
|
// regenerates the catalog and builds the web console first). No arguments builds every target.
|
|
|
|
interface BinaryTarget {
|
|
/** Suffix of the output file: `dist/open-connector-<name>` (Bun appends `.exe` for Windows targets). */
|
|
name: string;
|
|
target: Bun.Build.CompileTarget;
|
|
}
|
|
|
|
const rootDir = resolve(import.meta.dirname, "..");
|
|
const bunVersionFile = join(rootDir, ".bun-version");
|
|
const targets: BinaryTarget[] = [
|
|
{ name: "linux-x64", target: "bun-linux-x64" },
|
|
{ name: "linux-arm64", target: "bun-linux-arm64" },
|
|
{ name: "darwin-x64", target: "bun-darwin-x64" },
|
|
{ name: "darwin-arm64", target: "bun-darwin-arm64" },
|
|
{ name: "windows-x64", target: "bun-windows-x64" },
|
|
{ name: "windows-arm64", target: "bun-windows-arm64" },
|
|
];
|
|
|
|
assertPinnedBunVersion();
|
|
assertBuildInputs();
|
|
for (const target of selectTargets(process.argv.slice(2))) {
|
|
await buildTarget(target);
|
|
}
|
|
|
|
/** `.bun-version` is the single owner of the pinned Bun version; CI installs it and local builds must match it. */
|
|
function assertPinnedBunVersion(): void {
|
|
const pinnedVersion = readFileSync(bunVersionFile, "utf8").trim();
|
|
if (Bun.version !== pinnedVersion) {
|
|
fail(
|
|
`Bun ${Bun.version} is running but .bun-version pins ${pinnedVersion}. Install it with: curl -fsSL https://bun.sh/install | bash -s "bun-v${pinnedVersion}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The embedded directories are generated; refuse to build a binary that would ship an empty catalog or console, or
|
|
* the Cloudflare Workers catalog chunks on top of the catalog it already embeds.
|
|
*/
|
|
function assertBuildInputs(): void {
|
|
const problems: string[] = [];
|
|
if (listFiles(join(rootDir, "catalog/apps"), ".json").length === 0) {
|
|
problems.push("catalog/apps contains no .json files");
|
|
}
|
|
if (!existsSync(join(rootDir, "catalog", catalogIndexFileName))) {
|
|
problems.push(`catalog/${catalogIndexFileName} is missing`);
|
|
}
|
|
if (listFiles(join(rootDir, "migrations"), ".sql").length === 0) {
|
|
problems.push("migrations contains no .sql files");
|
|
}
|
|
if (listFiles(join(rootDir, "migrations/postgresql"), ".sql").length === 0) {
|
|
problems.push("migrations/postgresql contains no .sql files");
|
|
}
|
|
if (!existsSync(join(rootDir, "dist/web/index.html"))) {
|
|
problems.push("dist/web/index.html is missing");
|
|
}
|
|
if (problems.length > 0) {
|
|
fail(
|
|
`Build inputs are missing:\n - ${problems.join("\n - ")}\nRun "npm run build:binary" so the catalog and the web console are generated before the binary is built.`,
|
|
);
|
|
}
|
|
// scripts/copy-catalog-assets.ts writes the Workers catalog chunks under dist/web, and compile.assets embeds
|
|
// dist/web wholesale, so a leftover copy would ship the whole catalog a second time inside the executable.
|
|
if (existsSync(join(rootDir, "dist/web/catalog"))) {
|
|
fail(
|
|
'dist/web/catalog exists (Cloudflare Workers catalog chunks written by scripts/copy-catalog-assets.ts) and would be embedded into the binary. Remove that directory or rebuild the console with "npm run build:web".',
|
|
);
|
|
}
|
|
}
|
|
|
|
function listFiles(directory: string, extension: string): string[] {
|
|
try {
|
|
return readdirSync(directory).filter((name) => name.endsWith(extension));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function selectTargets(names: string[]): BinaryTarget[] {
|
|
if (names.length === 0) {
|
|
return targets;
|
|
}
|
|
|
|
return names.map((name) => {
|
|
const target = targets.find((candidate) => candidate.name === name);
|
|
if (!target) {
|
|
fail(`Unknown target "${name}". Valid targets: ${targets.map((candidate) => candidate.name).join(", ")}.`);
|
|
}
|
|
|
|
return target;
|
|
});
|
|
}
|
|
|
|
async function buildTarget(binaryTarget: BinaryTarget): Promise<void> {
|
|
const outfile = join(rootDir, "dist", `open-connector-${binaryTarget.name}`);
|
|
// Bun appends .exe to Windows outputs; clear both spellings so an earlier build can never be mistaken for this one.
|
|
rmSync(outfile, { force: true });
|
|
rmSync(`${outfile}.exe`, { force: true });
|
|
|
|
const result = await runBunBuild(binaryTarget.target, outfile);
|
|
// Code splitting keeps every chunk inside the executable, so exactly one output is expected. A future Bun that
|
|
// reports chunks as outputs must not have signDarwinBinary sign, or the size log measure, a chunk.
|
|
if (result.outputs.length !== 1) {
|
|
fail(
|
|
`Bun.build produced ${result.outputs.length} outputs for ${binaryTarget.name}; expected the single executable.`,
|
|
);
|
|
}
|
|
const [artifact] = result.outputs;
|
|
|
|
if (binaryTarget.target.startsWith("bun-darwin-")) {
|
|
signDarwinBinary(artifact.path);
|
|
}
|
|
|
|
const sizeMiB = statSync(artifact.path).size / (1024 * 1024);
|
|
console.log(`built ${relative(rootDir, artifact.path)} (${sizeMiB.toFixed(1)} MiB)`);
|
|
}
|
|
|
|
async function runBunBuild(target: Bun.Build.CompileTarget, outfile: string): Promise<Bun.BuildOutput> {
|
|
let result: Bun.BuildOutput;
|
|
try {
|
|
result = await Bun.build({
|
|
entrypoints: [join(rootDir, "src/server/index.ts")],
|
|
target: "bun",
|
|
format: "esm",
|
|
// Every provider executor is reached only through the `import()` in src/providers/registry.generated.ts. With
|
|
// splitting those imports become chunks embedded next to the entry module (/$bunfs/root/chunk-<hash>.js, or
|
|
// B:/~BUN/root/chunk-<hash>.js on Windows; Bun rewrites the paths per target) that JavaScriptCore reads, parses
|
|
// and keeps resident only when a provider is first used, instead of one ~30 MB entry module parsed at startup.
|
|
// compile.outfile still produces exactly one file; no chunk files are written to dist/.
|
|
splitting: true,
|
|
// ali-oss depends on urllib, which lazily `require("proxy-agent")`, an optional peer dependency that is not
|
|
// installed here. The bundler cannot resolve it, so it stays a runtime require that is never reached.
|
|
external: ["proxy-agent"],
|
|
// Bun inlines `process.env.NODE_ENV` at compile time ("development" unless defined). src/server/logger.ts then
|
|
// loads the pino-pretty worker transport, which cannot run inside a standalone executable. Every other
|
|
// environment variable is still read at runtime.
|
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
compile: {
|
|
target,
|
|
outfile,
|
|
// Each directory is embedded under its basename next to the bundle (migrations/, apps/ and web/), and the
|
|
// catalog index file under its basename beside them (apps-index.json). catalog/apps rather than catalog/: an
|
|
// interrupted `npm run generate:catalog` leaves catalog/.apps-<pid>-<ts> temp files behind, and they must
|
|
// never end up inside a release.
|
|
assets: [
|
|
join(rootDir, "migrations"),
|
|
join(rootDir, "catalog/apps"),
|
|
join(rootDir, "catalog", catalogIndexFileName),
|
|
join(rootDir, "dist/web"),
|
|
],
|
|
// `node src/server/index.ts` reads neither .env nor bunfig.toml; keep the binary's configuration surface the same.
|
|
autoloadDotenv: false,
|
|
autoloadBunfig: false,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
// Bun.build rejects with an AggregateError whose `errors` hold the BuildMessage / ResolveMessage entries.
|
|
if (error instanceof AggregateError) {
|
|
for (const message of error.errors) {
|
|
console.error(describeBuildMessage(message));
|
|
}
|
|
fail(`Bundling ${target} failed.`);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
for (const message of result.logs) {
|
|
console.error(describeBuildMessage(message));
|
|
}
|
|
if (!result.success) {
|
|
fail(`Bundling ${target} failed.`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function describeBuildMessage(message: unknown): string {
|
|
if (message instanceof BuildMessage || message instanceof ResolveMessage) {
|
|
const position = message.position;
|
|
const location = position ? ` (${position.file}:${position.line}:${position.column})` : "";
|
|
return `${message.level}: ${message.message}${location}`;
|
|
}
|
|
|
|
return String(message);
|
|
}
|
|
|
|
/**
|
|
* Bun 1.4.0 writes an ad-hoc signature whose last page hash is wrong (oven-sh/bun#39837, fixed upstream but
|
|
* unreleased). macOS 27 refuses to start such a binary (SIGKILL at exec), so re-sign it in place. codesign only
|
|
* exists on macOS; darwin outputs built elsewhere are re-signed on the macOS smoke runner instead.
|
|
*/
|
|
function signDarwinBinary(output: string): void {
|
|
const displayPath = relative(rootDir, output);
|
|
if (process.platform !== "darwin") {
|
|
console.warn(
|
|
`warning: ${displayPath} still carries Bun's invalid ad-hoc signature because codesign is only available on macOS; run "codesign --force --sign - ${displayPath}" on macOS 27 or later before executing it.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const result = spawnSync("codesign", ["--force", "--sign", "-", output], { stdio: "inherit" });
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
fail(
|
|
`codesign failed for ${displayPath} (${result.signal ? `signal ${result.signal}` : `exit code ${result.status}`}).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function fail(message: string): never {
|
|
console.error(message);
|
|
process.exit(1);
|
|
}
|