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]>
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { readdir, stat } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { catalogIndexFileName } from "../src/catalog-index.ts";
|
|
|
|
const rootDir = process.cwd();
|
|
const generatedPaths = [
|
|
join(process.cwd(), "src/providers/registry.generated.ts"),
|
|
join(process.cwd(), "src/providers/registry.cloudflare.generated.ts"),
|
|
join(process.cwd(), "src/providers/action-contracts.generated.ts"),
|
|
];
|
|
const catalogDir = join(process.cwd(), "catalog/apps");
|
|
const catalogIndexFile = join(process.cwd(), "catalog", catalogIndexFileName);
|
|
const sourcePaths = [
|
|
join(rootDir, "src/core"),
|
|
join(rootDir, "src/providers"),
|
|
join(rootDir, "scripts/generate-catalog.ts"),
|
|
join(rootDir, "scripts/generate-provider-registry.ts"),
|
|
join(rootDir, "scripts/provider-source.ts"),
|
|
];
|
|
const generatedPathSet = new Set(generatedPaths);
|
|
|
|
const sourceMtimeMs = await newestMtimeMs(sourcePaths);
|
|
|
|
const [generatedFilesPresent, catalogFresh] = await Promise.all([
|
|
Promise.all(generatedPaths.map((path) => isFile(path))),
|
|
isFreshCatalog(sourceMtimeMs),
|
|
]);
|
|
// A fresh catalog proves all generated provider files were produced from the same source set.
|
|
if (!catalogFresh) {
|
|
runNodeScript("scripts/generate-catalog.ts");
|
|
} else if (generatedFilesPresent.some((present) => !present)) {
|
|
runNodeScript("scripts/generate-provider-registry.ts");
|
|
}
|
|
|
|
function runNodeScript(script: string): void {
|
|
const result = spawnSync("node", [script], {
|
|
cwd: process.cwd(),
|
|
stdio: "inherit",
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
|
|
async function isFile(path: string): Promise<boolean> {
|
|
try {
|
|
const stats = await stat(path);
|
|
return stats.isFile();
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return false;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function isFreshCatalog(sourceMtimeMs: number): Promise<boolean> {
|
|
try {
|
|
const [entries, services] = await Promise.all([
|
|
readdir(catalogDir, { withFileTypes: true }),
|
|
readProviderServices(),
|
|
]);
|
|
const jsonFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
|
|
if (jsonFiles.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const catalogServices = jsonFiles
|
|
.map((entry) => entry.name.slice(0, -".json".length))
|
|
.sort((a, b) => a.localeCompare(b));
|
|
if (
|
|
catalogServices.length !== services.length ||
|
|
catalogServices.some((service, index) => service !== services[index])
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const [indexMtimeMs, ...providerMtimes] = await Promise.all([
|
|
// A missing index throws ENOENT and is treated like a missing catalog, so the two are regenerated together.
|
|
stat(catalogIndexFile).then((stats) => stats.mtimeMs),
|
|
...jsonFiles.map(async (entry) => (await stat(join(catalogDir, entry.name))).mtimeMs),
|
|
]);
|
|
// The generator writes the index after the provider files; an older index was left behind by a generator run
|
|
// that did not write one and would be refused at startup in lazy mode.
|
|
return Math.min(indexMtimeMs, ...providerMtimes) >= sourceMtimeMs && indexMtimeMs >= Math.max(...providerMtimes);
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return false;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readProviderServices(): Promise<string[]> {
|
|
const entries = await readdir(join(rootDir, "src/providers"), { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
async function newestMtimeMs(paths: string[]): Promise<number> {
|
|
const mtimes = await Promise.all(paths.map((path) => newestPathMtimeMs(path)));
|
|
return Math.max(...mtimes);
|
|
}
|
|
|
|
async function newestPathMtimeMs(path: string): Promise<number> {
|
|
if (generatedPathSet.has(path)) {
|
|
return 0;
|
|
}
|
|
|
|
let stats;
|
|
try {
|
|
stats = await stat(path);
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return 0;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
if (!stats.isDirectory()) {
|
|
return stats.mtimeMs;
|
|
}
|
|
|
|
const entries = await readdir(path, { withFileTypes: true });
|
|
const childMtimes = await Promise.all(entries.map((entry) => newestPathMtimeMs(join(path, entry.name))));
|
|
return Math.max(stats.mtimeMs, ...childMtimes);
|
|
}
|
|
|
|
function isNotFoundError(error: unknown): boolean {
|
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
}
|