|
8 | 8 | } from "openclaw/plugin-sdk/plugin-state-test-runtime"; |
9 | 9 | import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; |
10 | 10 | import { defaultRuntime } from "openclaw/plugin-sdk/runtime"; |
11 | | -import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 11 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
12 | 12 | import { generateIdentity } from "../protocol/index.js"; |
| 13 | +import { runReefChannelLifecycle } from "./channel-lifecycle.js"; |
13 | 14 | import { reefPlugin } from "./channel.js"; |
14 | 15 | import { resolveReefConfig } from "./config-schema.js"; |
15 | 16 | import { resolveReefInboundDispatchContent } from "./inbound.js"; |
@@ -103,3 +104,96 @@ describe("Reef conversation directory", () => { |
103 | 104 | ).resolves.toEqual([{ kind: "user", id: "molty", name: "@molty's agent", handle: "@molty" }]); |
104 | 105 | }); |
105 | 106 | }); |
| 107 | + |
| 108 | +describe("Reef channel lifecycle", () => { |
| 109 | + function hangingInbox() { |
| 110 | + const seen: AbortSignal[] = []; |
| 111 | + let settled = false; |
| 112 | + const startInbox = (signal: AbortSignal) => { |
| 113 | + seen.push(signal); |
| 114 | + return new Promise<void>((resolve) => { |
| 115 | + const done = () => { |
| 116 | + settled = true; |
| 117 | + resolve(); |
| 118 | + }; |
| 119 | + if (signal.aborted) { |
| 120 | + done(); |
| 121 | + return; |
| 122 | + } |
| 123 | + signal.addEventListener("abort", done, { once: true }); |
| 124 | + }); |
| 125 | + }; |
| 126 | + return { startInbox, seen, isSettled: () => settled }; |
| 127 | + } |
| 128 | + |
| 129 | + it("keeps running when a periodic reconcile fails", async () => { |
| 130 | + const parent = new AbortController(); |
| 131 | + const inbox = hangingInbox(); |
| 132 | + const errors: unknown[] = []; |
| 133 | + let reconciles = 0; |
| 134 | + const lifecycle = runReefChannelLifecycle({ |
| 135 | + parentSignal: parent.signal, |
| 136 | + startInbox: inbox.startInbox, |
| 137 | + reconcile: async () => { |
| 138 | + reconciles += 1; |
| 139 | + throw new Error("rate_limited"); |
| 140 | + }, |
| 141 | + onReconcileError: (error) => errors.push(error), |
| 142 | + reconcileIntervalMs: 5, |
| 143 | + }); |
| 144 | + await vi.waitFor(() => { |
| 145 | + expect(reconciles).toBeGreaterThanOrEqual(2); |
| 146 | + }); |
| 147 | + expect(errors.length).toBeGreaterThanOrEqual(2); |
| 148 | + expect(inbox.isSettled()).toBe(false); |
| 149 | + parent.abort(); |
| 150 | + await lifecycle; |
| 151 | + expect(inbox.isSettled()).toBe(true); |
| 152 | + }); |
| 153 | + |
| 154 | + it("tears down the inbox loop before settling when a loop branch throws", async () => { |
| 155 | + const parent = new AbortController(); |
| 156 | + const inbox = hangingInbox(); |
| 157 | + // Simulate a non-transport crash escaping the lifecycle (reconcile errors |
| 158 | + // are contained, so throw from the error hook itself). |
| 159 | + const lifecycle = runReefChannelLifecycle({ |
| 160 | + parentSignal: parent.signal, |
| 161 | + startInbox: inbox.startInbox, |
| 162 | + reconcile: async () => { |
| 163 | + throw new Error("boom"); |
| 164 | + }, |
| 165 | + onReconcileError: () => { |
| 166 | + throw new Error("fatal"); |
| 167 | + }, |
| 168 | + reconcileIntervalMs: 5, |
| 169 | + }); |
| 170 | + await expect(lifecycle).rejects.toThrow("fatal"); |
| 171 | + // The rejection must not leave the inbox reconnect loop running: its |
| 172 | + // signal is aborted and its promise has settled before the caller resumes. |
| 173 | + expect(inbox.seen[0]?.aborted).toBe(true); |
| 174 | + expect(inbox.isSettled()).toBe(true); |
| 175 | + }); |
| 176 | +}); |
| 177 | + |
| 178 | +describe("Reef channel lifecycle abort inheritance", () => { |
| 179 | + it("settles immediately when the parent signal is already aborted", async () => { |
| 180 | + const parent = new AbortController(); |
| 181 | + parent.abort(); |
| 182 | + const seen: AbortSignal[] = []; |
| 183 | + await runReefChannelLifecycle({ |
| 184 | + parentSignal: parent.signal, |
| 185 | + startInbox: (signal) => { |
| 186 | + seen.push(signal); |
| 187 | + return signal.aborted |
| 188 | + ? Promise.resolve() |
| 189 | + : new Promise<void>((resolve) => { |
| 190 | + signal.addEventListener("abort", () => resolve(), { once: true }); |
| 191 | + }); |
| 192 | + }, |
| 193 | + reconcile: async () => {}, |
| 194 | + onReconcileError: () => {}, |
| 195 | + reconcileIntervalMs: 5, |
| 196 | + }); |
| 197 | + expect(seen[0]?.aborted).toBe(true); |
| 198 | + }); |
| 199 | +}); |
0 commit comments