mirror of https://github.com/openclaw/openclaw.git
Fix launcher startup regressions (#48501)
* Fix launcher startup regressions * Fix CI follow-up regressions * Fix review follow-ups * Fix workflow audit shell inputs * Handle require resolve gaxios misses
This commit is contained in:
parent
a53030a7f2
commit
313e5bb58b
|
|
@ -319,6 +319,12 @@ jobs:
|
|||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Smoke test CLI launcher help
|
||||
run: node openclaw.mjs --help
|
||||
|
||||
- name: Smoke test CLI launcher status json
|
||||
run: node openclaw.mjs status --json --timeout 1
|
||||
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
|
||||
|
|
@ -452,11 +458,20 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
BASE="$(
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as fh:
|
||||
event = json.load(fh)
|
||||
|
||||
if os.environ["GITHUB_EVENT_NAME"] == "push":
|
||||
print(event["before"])
|
||||
else:
|
||||
print(event["pull_request"]["base"]["sha"])
|
||||
PY
|
||||
)"
|
||||
|
||||
mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml')
|
||||
if [ "${#workflow_files[@]}" -eq 0 ]; then
|
||||
|
|
|
|||
|
|
@ -62,6 +62,32 @@ const cases = [
|
|||
},
|
||||
];
|
||||
|
||||
function formatFixGuidance(testCase, details) {
|
||||
const command = `node ${testCase.args.join(" ")}`;
|
||||
const guidance = [
|
||||
"[startup-memory] Fix guidance",
|
||||
`Case: ${testCase.label}`,
|
||||
`Command: ${command}`,
|
||||
"Next steps:",
|
||||
`1. Run \`${command}\` locally on the built tree.`,
|
||||
"2. If this is an RSS overage, compare the startup import graph against the last passing commit and look for newly eager imports, bootstrap side effects, or plugin loading on the command path.",
|
||||
"3. If this is a non-zero exit, inspect the first transitive import/config error in stderr and fix that root cause before re-checking memory.",
|
||||
"LLM prompt:",
|
||||
`"OpenClaw startup-memory CI failed for '${testCase.label}'. Analyze this failure, identify the first runtime/import side effect that makes startup heavier or broken, and propose the smallest safe patch. Failure output:\n${details}"`,
|
||||
];
|
||||
return `${guidance.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function formatFailure(testCase, message, details = "") {
|
||||
const trimmedDetails = details.trim();
|
||||
const sections = [message];
|
||||
if (trimmedDetails) {
|
||||
sections.push(trimmedDetails);
|
||||
}
|
||||
sections.push(formatFixGuidance(testCase, trimmedDetails || message));
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
function parseMaxRssMb(stderr) {
|
||||
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
||||
const lastMatch = matches.at(-1);
|
||||
|
|
@ -120,18 +146,27 @@ function runCase(testCase) {
|
|||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${testCase.label} exited with ${String(result.status)}\n${stderr.trim() || result.stdout || ""}`,
|
||||
formatFailure(
|
||||
testCase,
|
||||
`${testCase.label} exited with ${String(result.status)}`,
|
||||
stderr.trim() || result.stdout || "",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (maxRssMb == null) {
|
||||
throw new Error(`${testCase.label} did not report max RSS\n${stderr.trim()}`);
|
||||
throw new Error(formatFailure(testCase, `${testCase.label} did not report max RSS`, stderr));
|
||||
}
|
||||
if (matrixBootstrapWarning) {
|
||||
throw new Error(`${testCase.label} triggered Matrix crypto bootstrap during startup`);
|
||||
throw new Error(
|
||||
formatFailure(testCase, `${testCase.label} triggered Matrix crypto bootstrap during startup`),
|
||||
);
|
||||
}
|
||||
if (maxRssMb > testCase.limitMb) {
|
||||
throw new Error(
|
||||
`${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`,
|
||||
formatFailure(
|
||||
testCase,
|
||||
`${testCase.label} used ${maxRssMb.toFixed(1)} MB RSS (limit ${testCase.limitMb} MB)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ if (
|
|||
} else {
|
||||
const { installGaxiosFetchCompat } = await import("./infra/gaxios-fetch-compat.js");
|
||||
|
||||
installGaxiosFetchCompat();
|
||||
await installGaxiosFetchCompat();
|
||||
process.title = "openclaw";
|
||||
ensureOpenClawExecMarkerOnProcess();
|
||||
installProcessWarningFilter();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function runLegacyCliEntry(argv: string[] = process.argv): Promise<
|
|||
import("./cli/run-main.js"),
|
||||
]);
|
||||
|
||||
installGaxiosFetchCompat();
|
||||
await installGaxiosFetchCompat();
|
||||
await runCli(argv);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,25 @@ import { HttpsProxyAgent } from "https-proxy-agent";
|
|||
import { ProxyAgent } from "undici";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__";
|
||||
|
||||
describe("gaxios fetch compat", () => {
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE);
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses native fetch without defining window or importing node-fetch", async () => {
|
||||
type MockRequestConfig = RequestInit & {
|
||||
fetchImplementation?: typeof fetch;
|
||||
responseType?: string;
|
||||
url: string;
|
||||
};
|
||||
let MockGaxiosCtor!: new () => {
|
||||
request(config: MockRequestConfig): Promise<{ data: string } & object>;
|
||||
};
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => {
|
||||
return new Response("ok", {
|
||||
headers: { "content-type": "text/plain" },
|
||||
|
|
@ -18,16 +29,30 @@ describe("gaxios fetch compat", () => {
|
|||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.doMock("node-fetch", () => {
|
||||
throw new Error("node-fetch should not load");
|
||||
});
|
||||
class MockGaxios {
|
||||
_defaultAdapter!: (config: MockRequestConfig) => Promise<Response>;
|
||||
|
||||
async request(config: MockRequestConfig) {
|
||||
const response = await this._defaultAdapter(config);
|
||||
return {
|
||||
...(response as object),
|
||||
data: await response.text(),
|
||||
};
|
||||
}
|
||||
}
|
||||
MockGaxiosCtor = MockGaxios;
|
||||
|
||||
MockGaxios.prototype._defaultAdapter = async (config: MockRequestConfig) => {
|
||||
const fetchImplementation = config.fetchImplementation ?? fetch;
|
||||
return await fetchImplementation(config.url, config);
|
||||
};
|
||||
(globalThis as Record<string, unknown>)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = MockGaxios;
|
||||
|
||||
const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js");
|
||||
const { Gaxios } = await import("gaxios");
|
||||
|
||||
installGaxiosFetchCompat();
|
||||
await installGaxiosFetchCompat();
|
||||
|
||||
const res = await new Gaxios().request({
|
||||
const res = await new MockGaxiosCtor().request({
|
||||
responseType: "text",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
|
@ -37,6 +62,25 @@ describe("gaxios fetch compat", () => {
|
|||
expect("window" in globalThis).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a legacy window fetch shim when gaxios is unavailable", async () => {
|
||||
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
vi.stubGlobal("fetch", vi.fn<typeof fetch>());
|
||||
Reflect.deleteProperty(globalThis as object, "window");
|
||||
(globalThis as Record<string, unknown>)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = null;
|
||||
const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js");
|
||||
|
||||
try {
|
||||
await expect(installGaxiosFetchCompat()).resolves.toBeUndefined();
|
||||
expect((globalThis as { window?: { fetch?: typeof fetch } }).window?.fetch).toBe(fetch);
|
||||
await expect(installGaxiosFetchCompat()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
Reflect.deleteProperty(globalThis as object, "window");
|
||||
if (originalWindowDescriptor) {
|
||||
Object.defineProperty(globalThis, "window", originalWindowDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("translates proxy agents into undici dispatchers for native fetch", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => {
|
||||
return new Response("ok", {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createRequire } from "node:module";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { Gaxios } from "gaxios";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Dispatcher } from "undici";
|
||||
import { Agent as UndiciAgent, ProxyAgent } from "undici";
|
||||
|
||||
|
|
@ -27,10 +28,16 @@ type TlsAgentLike = {
|
|||
};
|
||||
|
||||
type GaxiosPrototype = {
|
||||
_defaultAdapter: (this: Gaxios, config: GaxiosFetchRequestInit) => Promise<unknown>;
|
||||
_defaultAdapter: (this: unknown, config: GaxiosFetchRequestInit) => Promise<unknown>;
|
||||
};
|
||||
|
||||
let installState: "not-installed" | "installed" = "not-installed";
|
||||
type GaxiosConstructor = {
|
||||
prototype: GaxiosPrototype;
|
||||
};
|
||||
|
||||
const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__";
|
||||
|
||||
let installState: "not-installed" | "installing" | "shimmed" | "installed" = "not-installed";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
|
|
@ -161,6 +168,78 @@ function buildDispatcher(init: GaxiosFetchRequestInit, url: URL): Dispatcher | u
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function isModuleNotFoundError(err: unknown): err is NodeJS.ErrnoException {
|
||||
return isRecord(err) && (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND");
|
||||
}
|
||||
|
||||
function hasGaxiosConstructorShape(value: unknown): value is GaxiosConstructor {
|
||||
return (
|
||||
typeof value === "function" &&
|
||||
"prototype" in value &&
|
||||
isRecord(value.prototype) &&
|
||||
typeof value.prototype._defaultAdapter === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function getTestGaxiosConstructorOverride(): GaxiosConstructor | null | undefined {
|
||||
const testGlobal = globalThis as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(testGlobal, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE)) {
|
||||
return undefined;
|
||||
}
|
||||
const override = testGlobal[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE];
|
||||
if (override === null) {
|
||||
return null;
|
||||
}
|
||||
if (hasGaxiosConstructorShape(override)) {
|
||||
return override;
|
||||
}
|
||||
throw new Error("invalid gaxios test constructor override");
|
||||
}
|
||||
|
||||
function isDirectGaxiosImportMiss(err: unknown): boolean {
|
||||
if (!isModuleNotFoundError(err)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
typeof err.message === "string" &&
|
||||
(err.message.includes("Cannot find package 'gaxios'") ||
|
||||
err.message.includes("Cannot find module 'gaxios'"))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadGaxiosConstructor(): Promise<GaxiosConstructor | null> {
|
||||
const testOverride = getTestGaxiosConstructorOverride();
|
||||
if (testOverride !== undefined) {
|
||||
return testOverride;
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolvedPath = require.resolve("gaxios");
|
||||
const mod = await import(pathToFileURL(resolvedPath).href);
|
||||
const candidate = isRecord(mod) ? mod.Gaxios : undefined;
|
||||
if (!hasGaxiosConstructorShape(candidate)) {
|
||||
throw new Error("gaxios: missing Gaxios export");
|
||||
}
|
||||
return candidate;
|
||||
} catch (err) {
|
||||
if (isDirectGaxiosImportMiss(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function installLegacyWindowFetchShim(): void {
|
||||
if (
|
||||
typeof globalThis.fetch !== "function" ||
|
||||
typeof (globalThis as Record<string, unknown>).window !== "undefined"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(globalThis as Record<string, unknown>).window = { fetch: globalThis.fetch };
|
||||
}
|
||||
|
||||
export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch {
|
||||
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit;
|
||||
|
|
@ -186,27 +265,41 @@ export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fet
|
|||
};
|
||||
}
|
||||
|
||||
export function installGaxiosFetchCompat(): void {
|
||||
if (installState === "installed" || typeof globalThis.fetch !== "function") {
|
||||
export async function installGaxiosFetchCompat(): Promise<void> {
|
||||
if (installState !== "not-installed" || typeof globalThis.fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const prototype = Gaxios.prototype as unknown as GaxiosPrototype;
|
||||
const originalDefaultAdapter = prototype._defaultAdapter;
|
||||
const compatFetch = createGaxiosCompatFetch();
|
||||
installState = "installing";
|
||||
|
||||
prototype._defaultAdapter = function patchedDefaultAdapter(
|
||||
this: Gaxios,
|
||||
config: GaxiosFetchRequestInit,
|
||||
): Promise<unknown> {
|
||||
if (config.fetchImplementation) {
|
||||
return originalDefaultAdapter.call(this, config);
|
||||
try {
|
||||
const Gaxios = await loadGaxiosConstructor();
|
||||
if (!Gaxios) {
|
||||
installLegacyWindowFetchShim();
|
||||
installState = "shimmed";
|
||||
return;
|
||||
}
|
||||
return originalDefaultAdapter.call(this, {
|
||||
...config,
|
||||
fetchImplementation: compatFetch,
|
||||
});
|
||||
};
|
||||
|
||||
installState = "installed";
|
||||
const prototype = Gaxios.prototype;
|
||||
const originalDefaultAdapter = prototype._defaultAdapter;
|
||||
const compatFetch = createGaxiosCompatFetch();
|
||||
|
||||
prototype._defaultAdapter = function patchedDefaultAdapter(
|
||||
this: unknown,
|
||||
config: GaxiosFetchRequestInit,
|
||||
): Promise<unknown> {
|
||||
if (config.fetchImplementation) {
|
||||
return originalDefaultAdapter.call(this, config);
|
||||
}
|
||||
return originalDefaultAdapter.call(this, {
|
||||
...config,
|
||||
fetchImplementation: compatFetch,
|
||||
});
|
||||
};
|
||||
|
||||
installState = "installed";
|
||||
} catch (err) {
|
||||
installState = "not-installed";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
async function makeLauncherFixture(fixtureRoots: string[]): Promise<string> {
|
||||
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launcher-"));
|
||||
fixtureRoots.push(fixtureRoot);
|
||||
await fs.copyFile(
|
||||
path.resolve(process.cwd(), "openclaw.mjs"),
|
||||
path.join(fixtureRoot, "openclaw.mjs"),
|
||||
);
|
||||
await fs.mkdir(path.join(fixtureRoot, "dist"), { recursive: true });
|
||||
return fixtureRoot;
|
||||
}
|
||||
|
||||
describe("openclaw launcher", () => {
|
||||
const fixtureRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
fixtureRoots.splice(0).map(async (fixtureRoot) => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces transitive entry import failures instead of masking them as missing dist", async () => {
|
||||
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
|
||||
await fs.writeFile(
|
||||
path.join(fixtureRoot, "dist", "entry.js"),
|
||||
'import "missing-openclaw-launcher-dep";\nexport {};\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("missing-openclaw-launcher-dep");
|
||||
expect(result.stderr).not.toContain("missing dist/entry.(m)js");
|
||||
});
|
||||
|
||||
it("keeps the friendly launcher error for a truly missing entry build output", async () => {
|
||||
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
|
||||
|
||||
const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("missing dist/entry.(m)js");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue