






















@@ -22,6 +22,24 @@ const deviceIdentityState = vi.hoisted(() => ({
2222throwOnLoad: false,
2323}));
242425+const eventLoopReadyState = vi.hoisted(() => ({
26+calls: [] as Array<{ maxWaitMs?: number } | undefined>,
27+promise: null as Promise<{
28+ready: boolean;
29+elapsedMs: number;
30+maxDriftMs: number;
31+checks: number;
32+aborted: boolean;
33+}> | null,
34+result: {
35+ready: true,
36+elapsedMs: 0,
37+maxDriftMs: 0,
38+checks: 2,
39+aborted: false,
40+},
41+}));
42+2543let lastClientOptions: {
2644url?: string;
2745token?: string;
@@ -43,6 +61,7 @@ let lastRequestOptions: {
4361} | null = null;
4462type StartMode = "hello" | "close" | "silent" | "startup-retry-then-hello";
4563let startMode: StartMode = "hello";
64+let startCalls = 0;
4665let closeCode = 1006;
4766let closeReason = "";
4867let helloMethods: string[] | undefined = ["health", "secrets.resolve"];
@@ -81,6 +100,7 @@ vi.mock("./client.js", () => ({
81100return { ok: true };
82101}
83102start() {
103+startCalls += 1;
84104if (startMode === "hello") {
85105void lastClientOptions?.onHelloOk?.({
86106features: {
@@ -101,6 +121,16 @@ vi.mock("./client.js", () => ({
101121},
102122}));
103123124+vi.mock("./event-loop-ready.js", () => ({
125+waitForEventLoopReady: vi.fn(async (params?: { maxWaitMs?: number }) => {
126+eventLoopReadyState.calls.push(params);
127+if (eventLoopReadyState.promise) {
128+return await eventLoopReadyState.promise;
129+}
130+return eventLoopReadyState.result;
131+}),
132+}));
133+104134const {
105135 __testing,
106136 buildGatewayConnectionDetails,
@@ -134,6 +164,7 @@ class StubGatewayClient {
134164return { ok: true };
135165}
136166start() {
167+startCalls += 1;
137168if (startMode === "hello") {
138169void lastClientOptions?.onHelloOk?.({
139170features: {
@@ -161,7 +192,17 @@ function resetGatewayCallMocks() {
161192pickPrimaryLanIPv4.mockClear();
162193lastClientOptions = null;
163194lastRequestOptions = null;
195+eventLoopReadyState.calls = [];
196+eventLoopReadyState.promise = null;
197+eventLoopReadyState.result = {
198+ready: true,
199+elapsedMs: 0,
200+maxDriftMs: 0,
201+checks: 2,
202+aborted: false,
203+};
164204startMode = "hello";
205+startCalls = 0;
165206closeCode = 1006;
166207closeReason = "";
167208helloMethods = ["health", "secrets.resolve"];
@@ -570,52 +611,37 @@ describe("callGateway url resolution", () => {
570611expect(lastClientOptions?.clientDisplayName).toBeUndefined();
571612});
572613573-it("yields one event-loop turn before starting CLI pairing requests", async () => {
614+it("waits for event-loop readiness before starting CLI pairing requests", async () => {
574615setLocalLoopbackGatewayConfig();
575616576-let preConnectYieldRan = false;
577-let sawYieldBeforeStart = false;
578-setImmediate(() => {
579-preConnectYieldRan = true;
617+let resolveReady!: (result: {
618+ready: boolean;
619+elapsedMs: number;
620+maxDriftMs: number;
621+checks: number;
622+aborted: boolean;
623+}) => void;
624+eventLoopReadyState.promise = new Promise((resolve) => {
625+resolveReady = resolve;
580626});
581627582-__testing.setDepsForTests({
583-createGatewayClient: (opts) =>
584-({
585-async request(
586-method: string,
587-params: unknown,
588-requestOpts?: { expectFinal?: boolean; timeoutMs?: number | null },
589-) {
590-lastRequestOptions = { method, params, opts: requestOpts };
591-return { ok: true };
592-},
593-start() {
594-sawYieldBeforeStart = preConnectYieldRan;
595-opts.onHelloOk?.({
596-features: {
597-methods: helloMethods ?? [],
598-events: [],
599-},
600-} as unknown as Parameters<NonNullable<typeof opts.onHelloOk>>[0]);
601-},
602-stop() {},
603-}) as never,
604-getRuntimeConfig: getRuntimeConfig as unknown as () => OpenClawConfig,
605-loadOrCreateDeviceIdentity: () => deviceIdentityState.value,
606-resolveGatewayPort: resolveGatewayPort as unknown as (
607-cfg?: OpenClawConfig,
608-env?: NodeJS.ProcessEnv,
609-) => number,
610-});
611-612-await callGateway({
628+const promise = callGateway({
613629method: "device.pair.list",
614630mode: GATEWAY_CLIENT_MODES.CLI,
615631clientName: GATEWAY_CLIENT_NAMES.CLI,
616632});
617633618-expect(sawYieldBeforeStart).toBe(true);
634+await vi.waitFor(() => {
635+expect(eventLoopReadyState.calls).toHaveLength(1);
636+});
637+expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(10_000);
638+expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.CLI);
639+expect(startCalls).toBe(0);
640+641+resolveReady({ ready: true, elapsedMs: 0, maxDriftMs: 0, checks: 2, aborted: false });
642+await promise;
643+644+expect(startCalls).toBe(1);
619645});
620646});
621647@@ -896,6 +922,51 @@ describe("callGateway error details", () => {
896922});
897923});
898924925+it("charges event-loop readiness against the wrapper timeout", async () => {
926+startMode = "silent";
927+setLocalLoopbackGatewayConfig();
928+eventLoopReadyState.promise = new Promise(() => {});
929+930+vi.useFakeTimers();
931+let errMessage = "";
932+const promise = callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => {
933+errMessage = caught instanceof Error ? caught.message : String(caught);
934+});
935+936+await vi.waitFor(() => {
937+expect(eventLoopReadyState.calls).toHaveLength(1);
938+});
939+expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5);
940+expect(startCalls).toBe(0);
941+await vi.advanceTimersByTimeAsync(5);
942+await promise;
943+944+expect(startCalls).toBe(0);
945+expect(errMessage).toContain("gateway timeout after 5ms");
946+});
947+948+it("fails before connecting when event-loop readiness consumes the wrapper timeout", async () => {
949+startMode = "silent";
950+setLocalLoopbackGatewayConfig();
951+eventLoopReadyState.result = {
952+ready: false,
953+elapsedMs: 5,
954+maxDriftMs: 400,
955+checks: 1,
956+aborted: false,
957+};
958+959+await expect(callGateway({ method: "health", timeoutMs: 5 })).rejects.toMatchObject({
960+name: "GatewayTransportError",
961+kind: "timeout",
962+timeoutMs: 5,
963+});
964+expect(eventLoopReadyState.calls).toHaveLength(1);
965+expect(eventLoopReadyState.calls[0]?.maxWaitMs).toBe(5);
966+expect(lastClientOptions).not.toBeNull();
967+expect(startCalls).toBe(0);
968+});
969+899970it("keeps the default wrapper timeout aligned with configured handshake timeout", async () => {
900971startMode = "silent";
901972getRuntimeConfig.mockReturnValue({
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。