fix(signal): honor managed setup lifecycle

This commit is contained in:
jesse-merhi
2026-08-11 16:02:00 +10:00
parent c342a06ef3
commit 534a8713d2
4 changed files with 119 additions and 8 deletions
@@ -145,4 +145,38 @@ describe("Signal managed setup validation wiring", () => {
}),
);
});
it("propagates setup cancellation without entering readiness recovery", async () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
account: "+15555550123",
transport: { kind: "managed-native", httpPort: 8080 },
},
},
};
const { queued, credentialValues } = await prepareLocal(cfg);
const abort = new AbortController();
const reason = new DOMException("setup cancelled", "AbortError");
mocks.probeManagedSignalSetup.mockImplementationOnce(async (params) => {
expect(params.abortSignal).toBe(abort.signal);
abort.abort(reason);
params.abortSignal?.throwIfAborted();
return { ok: false };
});
await expect(
runSetupWizardFinalize({
finalize: signalSetupWizard.finalize,
cfg,
credentialValues,
prompter: queued.prompter,
runtime: createRuntimeEnv({ throwOnExit: false }),
options: { abortSignal: abort.signal },
}),
).rejects.toBe(reason);
expect(queued.select).toHaveBeenCalledTimes(1);
expect(queued.note).not.toHaveBeenCalledWith(expect.any(String), "Signal setup");
});
});
@@ -26,9 +26,14 @@ const mocks = vi.hoisted(() => ({
isExited: () => false,
}),
),
waitForReady: vi.fn(async (params: { check: () => Promise<SignalTransportProbeResult> }) => {
await params.check();
}),
waitForReady: vi.fn(
async (params: {
abortSignal?: AbortSignal;
check: () => Promise<SignalTransportProbeResult>;
}) => {
await params.check();
},
),
}));
vi.mock("openclaw/plugin-sdk/transport-ready-runtime", () => ({
@@ -114,6 +119,34 @@ describe("probeManagedSignalSetup", () => {
expect(mocks.spawnDaemon).not.toHaveBeenCalled();
});
it.each([
{
name: "localhost connection for an IPv4 loopback bind",
candidate: { ...transport, url: "http://localhost:8080" },
},
{
name: "IPv4 loopback connection for a localhost bind",
candidate: { ...transport, httpHost: "localhost", url: "http://127.0.0.1:8080" },
},
])("does not re-probe the managed bind through $name", async ({ candidate }) => {
const cfg = {
channels: { signal: { accounts: { work: { account, transport: candidate } } } },
} as OpenClawConfig;
await expect(
probeManagedSignalSetup(createParams(cfg, candidate, account)),
).resolves.toMatchObject({ ok: true });
expect(mocks.probeTransport).toHaveBeenCalledOnce();
expect(mocks.probeTransport).toHaveBeenCalledWith(
expect.objectContaining({
transport: expect.objectContaining({
url: `http://${candidate.httpHost}:8080`,
}),
}),
);
});
it("uses owner-known proof for the exact daemon registered by the monitor lifecycle", async () => {
const cfg = {
channels: { signal: { accounts: { work: { account, transport } } } },
@@ -209,6 +242,22 @@ describe("probeManagedSignalSetup", () => {
expect(mocks.stop).toHaveBeenCalledOnce();
});
it("passes setup cancellation to readiness and rethrows its reason after cleanup", async () => {
const abort = new AbortController();
const reason = new DOMException("setup cancelled", "AbortError");
mocks.waitForReady.mockImplementationOnce(async ({ abortSignal }) => {
expect(abortSignal).toBe(abort.signal);
abort.abort(reason);
});
await expect(
probeManagedSignalSetup({ ...createParams(), abortSignal: abort.signal }),
).rejects.toBe(reason);
expect(mocks.spawnDaemon).toHaveBeenCalledOnce();
expect(mocks.stop).toHaveBeenCalledOnce();
});
it("returns the actual final-port bind category without spawning", async () => {
mocks.assertBindAvailable.mockRejectedValueOnce(
new Error("address unavailable (EADDRNOTAVAIL)"),
@@ -12,6 +12,7 @@ import {
type SignalManagedNativeTransport,
type SignalTransportProbeResult,
} from "./setup-transport.js";
import { isSignalManagedNativeConnectionUrlForBind } from "./transport-policy.js";
import { buildSignalTransportHttpUrl } from "./transport-url.js";
type ResolvedManagedSignalTransport = Extract<
@@ -59,8 +60,10 @@ async function probeManagedBind(params: {
resolved: ResolvedManagedSignalTransport;
account: string;
accountBinding: "selected-account" | "owner-known-bound-account";
abortSignal?: AbortSignal;
}): Promise<SignalTransportProbeResult> {
return await probeSignalTransport({
params.abortSignal?.throwIfAborted();
const result = await probeSignalTransport({
cfg: params.cfg,
accountId: params.accountId,
transport: {
@@ -73,6 +76,8 @@ async function probeManagedBind(params: {
nativeAccountBinding: params.accountBinding,
timeoutMs: 1_000,
}).catch((error: unknown) => ({ ok: false, error: String(error) }));
params.abortSignal?.throwIfAborted();
return result;
}
async function probeSeparateConnectionUrl(params: {
@@ -81,18 +86,28 @@ async function probeSeparateConnectionUrl(params: {
transport: SignalManagedNativeTransport;
resolved: ResolvedManagedSignalTransport;
account: string;
abortSignal?: AbortSignal;
}): Promise<SignalTransportProbeResult> {
const bindUrl = buildSignalTransportHttpUrl(params.resolved.httpHost, params.resolved.httpPort);
if (params.resolved.baseUrl === bindUrl) {
params.abortSignal?.throwIfAborted();
if (
isSignalManagedNativeConnectionUrlForBind({
...params.transport,
url: params.resolved.baseUrl,
httpHost: params.resolved.httpHost,
httpPort: params.resolved.httpPort,
})
) {
return { ok: true, status: 200, error: null };
}
return await probeSignalTransport({
const result = await probeSignalTransport({
cfg: params.cfg,
accountId: params.accountId,
transport: params.transport,
account: params.account,
timeoutMs: 1_000,
}).catch((error: unknown) => ({ ok: false, error: String(error) }));
params.abortSignal?.throwIfAborted();
return result;
}
export async function probeManagedSignalSetup(params: {
@@ -104,7 +119,9 @@ export async function probeManagedSignalSetup(params: {
reusableConfiguredTransport?: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
abortSignal?: AbortSignal;
}): Promise<SignalTransportProbeResult> {
params.abortSignal?.throwIfAborted();
const resolved = resolveSignalTransport(params.transport);
if (resolved.kind !== "managed-native") {
throw new Error("Signal setup did not resolve a managed signal-cli transport.");
@@ -157,6 +174,7 @@ export async function probeManagedSignalSetup(params: {
httpHost: resolved.httpHost,
httpPort: resolved.httpPort,
});
params.abortSignal?.throwIfAborted();
const spawnedDaemon = spawnSignalDaemon({
cliPath: resolved.cliPath,
...(resolved.configPath ? { configPath: resolved.configPath } : {}),
@@ -176,6 +194,7 @@ export async function probeManagedSignalSetup(params: {
logAfterMs: 10_000,
logIntervalMs: 10_000,
pollIntervalMs: 150,
...(params.abortSignal ? { abortSignal: params.abortSignal } : {}),
runtime: params.runtime,
check: async () => {
if (spawnedDaemon.isExited()) {
@@ -189,18 +208,26 @@ export async function probeManagedSignalSetup(params: {
return result;
},
});
params.abortSignal?.throwIfAborted();
if (result.ok) {
result = await probeSeparateConnectionUrl({ ...params, resolved });
}
return result;
} catch (error) {
params.abortSignal?.throwIfAborted();
result = { ok: false, error: String(error) };
return result;
} finally {
try {
await daemon?.stop();
} finally {
progress.stop(result.ok ? "Signal setup validated." : "Signal setup validation failed.");
progress.stop(
params.abortSignal?.aborted
? "Signal setup validation cancelled."
: result.ok
? "Signal setup validated."
: "Signal setup validation failed.",
);
}
}
}
+1
View File
@@ -332,6 +332,7 @@ export const signalSetupWizard: ChannelSetupWizard = {
params.credentialValues[signalSetupStateKeys.managedReuseTransport],
runtime: params.runtime,
prompter: params.prompter,
...(params.options?.abortSignal ? { abortSignal: params.options.abortSignal } : {}),
});
if (probe.ok) {
if (params.credentialValues[SIGNAL_LINK_COMPLETED_INPUT_KEY] === "true") {