mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
perf: reduce gateway startup overhead
This commit is contained in:
@@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.
|
||||
- Network proxy: preserve target TLS hostname validation for Node HTTPS requests routed through the managed HTTP proxy, so Discord-style CONNECT traffic no longer validates certificates against the local proxy host. Fixes #74809. (#76442) Thanks @jesse-merhi and @abnershang.
|
||||
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
|
||||
- Gateway/performance: cache per-run verbose-level session reads, skip a redundant `lsof` scan in `gateway --force` when no listener was killed, and make the Gateway startup benchmark print usage for `--help`.
|
||||
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
|
||||
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.
|
||||
- Gateway/models: keep read-only `models.list` fallbacks on persisted/current metadata and configured rows while using static auth checks, so missing `models.json` files no longer runtime-load provider discovery or stall gateway after restart. Fixes #76382; refs #76360 and #75707. Thanks @trojy13, @RayWoo, @AnathemaOfficial, and @vincentkoc.
|
||||
|
||||
@@ -159,6 +159,10 @@ function hasFlag(flag: string): boolean {
|
||||
return process.argv.includes(flag);
|
||||
}
|
||||
|
||||
function hasHelpFlag(): boolean {
|
||||
return hasFlag("--help") || hasFlag("-h");
|
||||
}
|
||||
|
||||
function parseRepeatableFlag(flag: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < process.argv.length; index += 1) {
|
||||
@@ -206,6 +210,28 @@ function parseOptions(): CliOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`OpenClaw Gateway startup benchmark
|
||||
|
||||
Usage:
|
||||
pnpm test:startup:gateway -- [options]
|
||||
node --import tsx scripts/bench-gateway-startup.ts [options]
|
||||
|
||||
Options:
|
||||
--case <id> Specific case id to run; repeatable
|
||||
--entry <path> Gateway CLI entry file (default: ${DEFAULT_ENTRY})
|
||||
--runs <n> Measured runs per case (default: ${DEFAULT_RUNS})
|
||||
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
|
||||
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
|
||||
--output <path> Write machine-readable JSON to a file
|
||||
--json Emit machine-readable JSON
|
||||
--help, -h Show this text
|
||||
|
||||
Case ids:
|
||||
${GATEWAY_CASES.map((benchCase) => `${benchCase.id} (${benchCase.name})`).join("\n ")}
|
||||
`);
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].toSorted((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
@@ -796,6 +822,11 @@ function printResult(result: CaseResult): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (hasHelpFlag()) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const options = parseOptions();
|
||||
const results: CaseResult[] = [];
|
||||
for (const benchCase of options.cases) {
|
||||
|
||||
@@ -36,8 +36,9 @@ const {
|
||||
|
||||
describe("agent runner helpers", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.loadSessionStoreMock.mockClear();
|
||||
hoisted.scheduleFollowupDrainMock.mockClear();
|
||||
vi.useRealTimers();
|
||||
hoisted.loadSessionStoreMock.mockReset();
|
||||
hoisted.scheduleFollowupDrainMock.mockReset();
|
||||
});
|
||||
|
||||
it("detects audio payloads from mediaUrl/mediaUrls", () => {
|
||||
@@ -71,6 +72,30 @@ describe("agent runner helpers", () => {
|
||||
expect(shouldEmitOutput()).toBe(true);
|
||||
});
|
||||
|
||||
it("caches session verbose reads briefly while still refreshing live changes", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
hoisted.loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:main": { verboseLevel: "full" },
|
||||
});
|
||||
const shouldEmitOutput = createShouldEmitToolOutput({
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: "/tmp/store.json",
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
|
||||
expect(shouldEmitOutput()).toBe(true);
|
||||
hoisted.loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:main": { verboseLevel: "off" },
|
||||
});
|
||||
expect(shouldEmitOutput()).toBe(true);
|
||||
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledOnce();
|
||||
|
||||
vi.setSystemTime(1_251);
|
||||
expect(shouldEmitOutput()).toBe(false);
|
||||
expect(hoisted.loadSessionStoreMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back when store read fails or session value is invalid", () => {
|
||||
hoisted.loadSessionStoreMock.mockImplementation(() => {
|
||||
throw new Error("boom");
|
||||
|
||||
@@ -21,7 +21,9 @@ type VerboseGateParams = {
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
};
|
||||
|
||||
function resolveCurrentVerboseLevel(params: VerboseGateParams): VerboseLevel | undefined {
|
||||
const VERBOSE_GATE_SESSION_REFRESH_MS = 250;
|
||||
|
||||
function readCurrentVerboseLevel(params: VerboseGateParams): VerboseLevel | undefined {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -37,14 +39,34 @@ function resolveCurrentVerboseLevel(params: VerboseGateParams): VerboseLevel | u
|
||||
}
|
||||
}
|
||||
|
||||
function createCurrentVerboseLevelResolver(
|
||||
params: VerboseGateParams,
|
||||
): () => VerboseLevel | undefined {
|
||||
let cachedLevel: VerboseLevel | undefined;
|
||||
let cachedAtMs = Number.NEGATIVE_INFINITY;
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return undefined;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - cachedAtMs < VERBOSE_GATE_SESSION_REFRESH_MS) {
|
||||
return cachedLevel;
|
||||
}
|
||||
cachedLevel = readCurrentVerboseLevel(params);
|
||||
cachedAtMs = now;
|
||||
return cachedLevel;
|
||||
};
|
||||
}
|
||||
|
||||
function createVerboseGate(
|
||||
params: VerboseGateParams,
|
||||
shouldEmit: (level: VerboseLevel) => boolean,
|
||||
): () => boolean {
|
||||
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||
const fallbackVerbose = params.resolvedVerboseLevel;
|
||||
const resolveCurrentVerboseLevel = createCurrentVerboseLevelResolver(params);
|
||||
return () => {
|
||||
return shouldEmit(resolveCurrentVerboseLevel(params) ?? fallbackVerbose);
|
||||
return shouldEmit(resolveCurrentVerboseLevel() ?? fallbackVerbose);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -276,6 +276,10 @@ export async function forceFreePortAndWait(
|
||||
killed = killPortWithFuser(port, "SIGTERM");
|
||||
}
|
||||
|
||||
if (killed.length === 0) {
|
||||
return { killed, waitedMs: 0, escalatedToSigkill: false };
|
||||
}
|
||||
|
||||
const checkBusy = async (): Promise<boolean> =>
|
||||
useFuserFallback ? isPortBusy(port) : listPortListeners(port).length > 0;
|
||||
|
||||
|
||||
@@ -59,6 +59,23 @@ describe("gateway --force helpers", () => {
|
||||
expect(listPortListeners(18789)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-scan lsof when no listeners were killed", async () => {
|
||||
(execFileSync as unknown as Mock).mockImplementation(() => {
|
||||
const err = new Error("no matches") as NodeJS.ErrnoException & { status?: number };
|
||||
err.status = 1; // lsof uses exit 1 for no matches
|
||||
throw err;
|
||||
});
|
||||
|
||||
const result = await forceFreePortAndWait(18789, { timeoutMs: 500, intervalMs: 100 });
|
||||
|
||||
expect(result).toEqual({
|
||||
killed: [],
|
||||
waitedMs: 0,
|
||||
escalatedToSigkill: false,
|
||||
});
|
||||
expect(execFileSync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("throws when lsof missing", () => {
|
||||
(execFileSync as unknown as Mock).mockImplementation(() => {
|
||||
const err = new Error("not found") as NodeJS.ErrnoException;
|
||||
|
||||
23
test/scripts/bench-gateway-startup.test.ts
Normal file
23
test/scripts/bench-gateway-startup.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("gateway startup benchmark script", () => {
|
||||
it("prints help without running benchmark cases", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "scripts/bench-gateway-startup.ts", "--help"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("OpenClaw Gateway startup benchmark");
|
||||
expect(result.stdout).toContain("--case <id>");
|
||||
expect(result.stdout).toContain("default (gateway default)");
|
||||
expect(result.stdout).not.toContain("[gateway-startup-bench]");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user