mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
perf(cli): keep client diagnostics off shared state
Read client timeline flags from source config without preparing plugin state. Preserve foreground Gateway config isolation and normal command config ownership. The CLI process regression reduces cron list shared-source opens from five to zero online and offline while preserving timeline output. Raw Gateway calls remain at zero opens. Testbox validation passed 255 focused tests and the changed-file checks.
This commit is contained in:
@@ -143,6 +143,9 @@ When `timeline` is enabled only from config, the earliest config-loading spans
|
||||
are missing because OpenClaw has not read config yet; subsequent startup spans
|
||||
are captured normally.
|
||||
|
||||
Gateway client commands read timeline flags from source config without opening the shared
|
||||
state database. This also works when the Gateway is offline.
|
||||
|
||||
`OPENCLAW_DIAGNOSTICS=1`, `=all`, and `=*` also enable the timeline, since they
|
||||
enable every flag. Prefer the scoped `timeline` flag when you only want the
|
||||
JSONL artifact and not every other diagnostics flag.
|
||||
|
||||
@@ -31,7 +31,7 @@ export const EMPTY_STABILITY_SNAPSHOT = {
|
||||
summary: { byType: {} },
|
||||
};
|
||||
|
||||
export async function startCronListGateway(token: string): Promise<{ url: string }> {
|
||||
export async function startCronListGateway(token?: string): Promise<{ url: string }> {
|
||||
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
activeServers.add(wss);
|
||||
wss.on("connection", (ws) => {
|
||||
@@ -47,7 +47,7 @@ export async function startCronListGateway(token: string): Promise<{ url: string
|
||||
ws,
|
||||
frame.id,
|
||||
buildMinimalGatewayHelloOkPayload({
|
||||
methods: ["cron.list"],
|
||||
methods: ["cron.list", "cron.status"],
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
}),
|
||||
);
|
||||
@@ -65,6 +65,9 @@ export async function startCronListGateway(token: string): Promise<{ url: string
|
||||
deliveryPreviews: {},
|
||||
});
|
||||
}
|
||||
if (frame.method === "cron.status") {
|
||||
sendMinimalGatewayResponse(ws, frame.id, { enabled: true, jobs: 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
await once(wss, "listening");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { acquireGatewayLock } from "../infra/gateway-lock.js";
|
||||
import { sqliteWorkerPreloadEnv } from "../infra/sqlite-worker-preload.test-support.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
prepareGatewayCliFixture,
|
||||
runIsolatedGatewayCli,
|
||||
tempDirs,
|
||||
} from "./gateway-backed-exit.process.test-support.js";
|
||||
import {
|
||||
closeActiveGatewayServers,
|
||||
startCronListGateway,
|
||||
} from "./gateway-backed-exit.test-helpers.js";
|
||||
|
||||
it("keeps timeline diagnostics off shared state for online and offline local RPCs", async () => {
|
||||
const root = tempDirs.make("openclaw-cli-diagnostics-opens-");
|
||||
const gateway = await startCronListGateway();
|
||||
const port = Number(new URL(gateway.url).port);
|
||||
const { stateDir, configPath } = await prepareGatewayCliFixture(root, {
|
||||
mode: "local",
|
||||
port,
|
||||
auth: { mode: "none" },
|
||||
});
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ ...config, diagnostics: { flags: ["timeline"] } }),
|
||||
);
|
||||
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
||||
openOpenClawStateDatabase({ path: databasePath });
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const lock = await acquireGatewayLock({
|
||||
allowInTests: true,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir, OPENCLAW_CONFIG_PATH: configPath },
|
||||
port,
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
const opensPath = path.join(root, "opens.jsonl");
|
||||
const preloadPath = path.join(root, "count-opens.cjs");
|
||||
const timelinePath = path.join(root, "timeline.jsonl");
|
||||
await fs.writeFile(
|
||||
preloadPath,
|
||||
`const fs = require("node:fs");
|
||||
const sqlite = require("node:sqlite");
|
||||
const record = (event) => fs.appendFileSync(${JSON.stringify(opensPath)}, JSON.stringify(event) + "\\n");
|
||||
const open = fs.openSync;
|
||||
fs.openSync = function(file, flags, ...rest) {
|
||||
if (String(file) === ${JSON.stringify(databasePath)}) {
|
||||
record({ kind: "source", flags, stack: new Error().stack });
|
||||
}
|
||||
return open.call(this, file, flags, ...rest);
|
||||
};
|
||||
sqlite.DatabaseSync = new Proxy(sqlite.DatabaseSync, {
|
||||
construct(target, args, newTarget) {
|
||||
record({ kind: "sqlite", path: String(args[0]) });
|
||||
return Reflect.construct(target, args, newTarget);
|
||||
}
|
||||
});
|
||||
require("node:module").syncBuiltinESMExports();
|
||||
record({ kind: "preload" });
|
||||
`,
|
||||
);
|
||||
const counts: Record<string, unknown[]> = {};
|
||||
try {
|
||||
for (const online of [true, false]) {
|
||||
if (!online) {
|
||||
await closeActiveGatewayServers();
|
||||
await lock?.release();
|
||||
}
|
||||
for (const args of [
|
||||
["cron", "list"],
|
||||
["gateway", "call", "cron.status"],
|
||||
]) {
|
||||
await fs.writeFile(opensPath, "");
|
||||
await fs.writeFile(timelinePath, "");
|
||||
const result = await runIsolatedGatewayCli({
|
||||
args: [...args, "--json", ...(!online ? ["--timeout", "250"] : [])],
|
||||
root,
|
||||
stateDir,
|
||||
configPath,
|
||||
env: {
|
||||
...sqliteWorkerPreloadEnv(preloadPath),
|
||||
OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath,
|
||||
OPENCLAW_DIAGNOSTICS: undefined,
|
||||
},
|
||||
});
|
||||
expect(result, result.stderr).toMatchObject({ code: online ? 0 : 1, signal: null });
|
||||
if (online) {
|
||||
expect(JSON.parse(result.stdout)).toMatchObject(
|
||||
args[0] === "cron" ? { jobs: [] } : { enabled: true },
|
||||
);
|
||||
} else {
|
||||
expect(result.stdout + result.stderr).toContain("Gateway not reachable");
|
||||
}
|
||||
expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv");
|
||||
const events = (await fs.readFile(opensPath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { kind: string });
|
||||
expect(events).toContainEqual({ kind: "preload" });
|
||||
counts[`${online ? "online" : "offline"} ${args.join(" ")}`] = events.filter(
|
||||
(event) => event.kind !== "preload",
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(counts).toEqual({
|
||||
"online cron list": [],
|
||||
"online gateway call cron.status": [],
|
||||
"offline cron list": [],
|
||||
"offline gateway call cron.status": [],
|
||||
});
|
||||
} finally {
|
||||
await lock?.release();
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
||||
import type { ConfigSnapshotReadOptions } from "../config/io.types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
|
||||
import { flushDiagnosticsTimeline } from "../infra/diagnostics-timeline.js";
|
||||
import { createNewerSqliteSchemaVersionError } from "../infra/sqlite-user-version.js";
|
||||
import { setLoggerOverride } from "../logging/logger.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
@@ -22,6 +21,7 @@ import { captureEnv, withEnvAsync } from "../test-utils/env.js";
|
||||
import { ExpectedCliError } from "./failure-output.js";
|
||||
import { getGatewayRunRuntimeHooks } from "./gateway-cli/runtime-hooks.js";
|
||||
import type { RootHelpRenderOptions } from "./program/root-help.js";
|
||||
import { registerRunMainTimelineTests } from "./run-main.timeline.test-support.js";
|
||||
import { getPendingCliDisposers } from "./runtime-cleanup.js";
|
||||
import { registerSignalExitBarrier, waitForSignalExitBarriers } from "./signal-exit-barrier.js";
|
||||
|
||||
@@ -2398,32 +2398,12 @@ describe("runCli exit behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["worker", { observe: false, pluginValidation: "core-only" }],
|
||||
["run", { observe: false, skipPluginValidation: true }],
|
||||
])(
|
||||
"preserves node %s config ownership when startup tracing is enabled",
|
||||
async (subcommand, readOptions) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-node-timeline-"));
|
||||
const timelinePath = path.join(root, "timeline.jsonl");
|
||||
tryRouteCliMock.mockResolvedValueOnce(true);
|
||||
loadConfigMock.mockResolvedValueOnce({ diagnostics: { flags: ["timeline"] } });
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_DIAGNOSTICS: "", OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath },
|
||||
async () => {
|
||||
await runCli(["node", "openclaw", "node", subcommand]);
|
||||
},
|
||||
);
|
||||
expect(loadConfigMock).toHaveBeenCalledWith(readOptions);
|
||||
flushDiagnosticsTimeline();
|
||||
expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv");
|
||||
} finally {
|
||||
flushDiagnosticsTimeline();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
registerRunMainTimelineTests({
|
||||
runCli: (argv) => runCli(argv),
|
||||
loadConfigMock,
|
||||
readSourceConfigBestEffortMock,
|
||||
tryRouteCliMock,
|
||||
});
|
||||
|
||||
it.each([
|
||||
["root command", ["node", "openclaw", "update", "--dry-run", "--json"]],
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it, type Mock } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { flushDiagnosticsTimeline } from "../infra/diagnostics-timeline.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
|
||||
export function registerRunMainTimelineTests({
|
||||
runCli,
|
||||
loadConfigMock,
|
||||
readSourceConfigBestEffortMock,
|
||||
tryRouteCliMock,
|
||||
}: {
|
||||
runCli: (argv: string[]) => Promise<void>;
|
||||
loadConfigMock: Mock;
|
||||
readSourceConfigBestEffortMock: Mock;
|
||||
tryRouteCliMock: Mock;
|
||||
}): void {
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
it.each([
|
||||
["node", "worker"],
|
||||
["node", "run"],
|
||||
["gateway", "run"],
|
||||
])(
|
||||
"preserves %s %s config ownership when startup tracing is enabled",
|
||||
async (command, subcommand) => {
|
||||
const root = tempDirs.make("openclaw-node-timeline-");
|
||||
const timelinePath = path.join(root, "timeline.jsonl");
|
||||
if (command === "node") {
|
||||
tryRouteCliMock.mockResolvedValueOnce(true);
|
||||
}
|
||||
const readTimelineConfig =
|
||||
command === "gateway" ? loadConfigMock : readSourceConfigBestEffortMock;
|
||||
readTimelineConfig.mockResolvedValueOnce({
|
||||
diagnostics: { flags: ["timeline"] },
|
||||
});
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{ OPENCLAW_DIAGNOSTICS: "", OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath },
|
||||
async () => {
|
||||
await runCli(["node", "openclaw", command, subcommand]);
|
||||
},
|
||||
);
|
||||
if (command === "gateway") {
|
||||
expect(loadConfigMock).toHaveBeenCalledWith({
|
||||
observe: false,
|
||||
isolateEnv: true,
|
||||
pluginValidation: "core-only",
|
||||
});
|
||||
expect(readSourceConfigBestEffortMock).not.toHaveBeenCalled();
|
||||
} else if (subcommand === "run") {
|
||||
expect(loadConfigMock).toHaveBeenCalledWith({
|
||||
observe: false,
|
||||
skipPluginValidation: true,
|
||||
});
|
||||
} else {
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
}
|
||||
flushDiagnosticsTimeline();
|
||||
expect(await fs.readFile(timelinePath, "utf8")).toContain("cli.main.argv");
|
||||
} finally {
|
||||
flushDiagnosticsTimeline();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+6
-11
@@ -71,6 +71,7 @@ import type { CliHarnessCleanup } from "./runtime-cleanup-scope.js";
|
||||
import { closeCliResources, runCliDisposer } from "./runtime-cleanup.js";
|
||||
import { registerSignalExitBarrier, waitForSignalExitBarriers } from "./signal-exit-barrier.js";
|
||||
import {
|
||||
configureCliStartupDiagnostics,
|
||||
configureGatewayStartupTraceConsoleFormatting,
|
||||
createGatewayDispatchStartupTrace,
|
||||
} from "./startup-trace.js";
|
||||
@@ -1283,17 +1284,11 @@ async function runCliWithPreparedOutputMode(
|
||||
let unhandledRejectionHandlerInstalled = false;
|
||||
|
||||
try {
|
||||
const startupTraces = [startupTrace, options.additionalStartupTrace].filter(
|
||||
(trace): trace is ReturnType<typeof createGatewayDispatchStartupTrace> => Boolean(trace),
|
||||
);
|
||||
if (
|
||||
!isDatabaseInvocation &&
|
||||
(await Promise.all(startupTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some(
|
||||
Boolean,
|
||||
)
|
||||
) {
|
||||
const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig);
|
||||
await Promise.all(startupTraces.map((trace) => trace.configureDiagnosticsTimeline(config)));
|
||||
if (!isDatabaseInvocation) {
|
||||
await configureCliStartupDiagnostics(
|
||||
[startupTrace, options.additionalStartupTrace],
|
||||
isGatewayRunInvocation ? readBestEffortCliConfig : undefined,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isHelpOrVersionInvocation &&
|
||||
|
||||
@@ -325,6 +325,27 @@ export function createGatewayDispatchStartupTrace(
|
||||
};
|
||||
}
|
||||
|
||||
export async function configureCliStartupDiagnostics(
|
||||
traces: Array<ReturnType<typeof createGatewayDispatchStartupTrace> | undefined>,
|
||||
readConfig?: () => Promise<OpenClawConfig>,
|
||||
): Promise<void> {
|
||||
const activeTraces = traces.filter(
|
||||
(trace): trace is ReturnType<typeof createGatewayDispatchStartupTrace> => Boolean(trace),
|
||||
);
|
||||
if (
|
||||
!(await Promise.all(activeTraces.map((trace) => trace.requiresDiagnosticsConfig()))).some(
|
||||
Boolean,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const { withConsoleLogsRoutedToStderr } = await import("./json-output-mode.js");
|
||||
const { readSourceConfigBestEffort } = await import("../config/io.js");
|
||||
// Clients need authored flags; Gateway admission supplies its isolated config reader.
|
||||
const config = await withConsoleLogsRoutedToStderr(readConfig ?? readSourceConfigBestEffort);
|
||||
await Promise.all(activeTraces.map((trace) => trace.configureDiagnosticsTimeline(config)));
|
||||
}
|
||||
|
||||
export async function prepareGatewayStartupTraceConsoleFormatting(
|
||||
trace: ReturnType<typeof createGatewayDispatchStartupTrace>,
|
||||
): Promise<() => void> {
|
||||
|
||||
Reference in New Issue
Block a user