mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:54:33 +00:00
refactor: consolidate typing lifecycle and queue policy
This commit is contained in:
@@ -1,237 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { resolveConversationLabel } from "./conversation-label.js";
|
||||
import {
|
||||
formatChannelSelectionLine,
|
||||
listChatChannels,
|
||||
normalizeChatChannelId,
|
||||
} from "./registry.js";
|
||||
import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js";
|
||||
import { createTypingCallbacks } from "./typing.js";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("channel registry helpers", () => {
|
||||
it("normalizes aliases + trims whitespace", () => {
|
||||
expect(normalizeChatChannelId(" imsg ")).toBe("imessage");
|
||||
expect(normalizeChatChannelId("gchat")).toBe("googlechat");
|
||||
expect(normalizeChatChannelId("google-chat")).toBe("googlechat");
|
||||
expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc");
|
||||
expect(normalizeChatChannelId("telegram")).toBe("telegram");
|
||||
expect(normalizeChatChannelId("web")).toBeNull();
|
||||
expect(normalizeChatChannelId("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Telegram first in the default order", () => {
|
||||
const channels = listChatChannels();
|
||||
expect(channels[0]?.id).toBe("telegram");
|
||||
});
|
||||
|
||||
it("does not include MS Teams by default", () => {
|
||||
const channels = listChatChannels();
|
||||
expect(channels.some((channel) => channel.id === "msteams")).toBe(false);
|
||||
});
|
||||
|
||||
it("formats selection lines with docs labels + website extras", () => {
|
||||
const channels = listChatChannels();
|
||||
const first = channels[0];
|
||||
if (!first) {
|
||||
throw new Error("Missing channel metadata.");
|
||||
}
|
||||
const line = formatChannelSelectionLine(first, (path, label) =>
|
||||
[label, path].filter(Boolean).join(":"),
|
||||
);
|
||||
expect(line).not.toContain("Docs:");
|
||||
expect(line).toContain("/channels/telegram");
|
||||
expect(line).toContain("https://openclaw.ai");
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel targets", () => {
|
||||
it("ensureTargetId returns the candidate when it matches", () => {
|
||||
expect(
|
||||
ensureTargetId({
|
||||
candidate: "U123",
|
||||
pattern: /^[A-Z0-9]+$/i,
|
||||
errorMessage: "bad",
|
||||
}),
|
||||
).toBe("U123");
|
||||
});
|
||||
|
||||
it("ensureTargetId throws with the provided message on mismatch", () => {
|
||||
expect(() =>
|
||||
ensureTargetId({
|
||||
candidate: "not-ok",
|
||||
pattern: /^[A-Z0-9]+$/i,
|
||||
errorMessage: "Bad target",
|
||||
}),
|
||||
).toThrow(/Bad target/);
|
||||
});
|
||||
|
||||
it("requireTargetKind returns the target id when the kind matches", () => {
|
||||
const target = buildMessagingTarget("channel", "C123", "C123");
|
||||
expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123");
|
||||
});
|
||||
|
||||
it("requireTargetKind throws when the kind is missing or mismatched", () => {
|
||||
expect(() =>
|
||||
requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }),
|
||||
).toThrow(/Slack channel id is required/);
|
||||
const target = buildMessagingTarget("user", "U123", "U123");
|
||||
expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow(
|
||||
/Slack channel id is required/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConversationLabel", () => {
|
||||
const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [
|
||||
{
|
||||
name: "prefers ConversationLabel when present",
|
||||
ctx: { ConversationLabel: "Pinned Label", ChatType: "group" },
|
||||
expected: "Pinned Label",
|
||||
},
|
||||
{
|
||||
name: "prefers ThreadLabel over derived chat labels",
|
||||
ctx: {
|
||||
ThreadLabel: "Thread Alpha",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Ops",
|
||||
From: "telegram:group:42",
|
||||
},
|
||||
expected: "Thread Alpha",
|
||||
},
|
||||
{
|
||||
name: "uses SenderName for direct chats when available",
|
||||
ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" },
|
||||
expected: "Ada",
|
||||
},
|
||||
{
|
||||
name: "falls back to From for direct chats when SenderName is missing",
|
||||
ctx: { ChatType: "direct", From: "telegram:99" },
|
||||
expected: "telegram:99",
|
||||
},
|
||||
{
|
||||
name: "derives Telegram-like group labels with numeric id suffix",
|
||||
ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" },
|
||||
expected: "Ops id:42",
|
||||
},
|
||||
{
|
||||
name: "does not append ids for #rooms/channels",
|
||||
ctx: {
|
||||
ChatType: "channel",
|
||||
GroupSubject: "#general",
|
||||
From: "slack:channel:C123",
|
||||
},
|
||||
expected: "#general",
|
||||
},
|
||||
{
|
||||
name: "does not append ids when the base already contains the id",
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Family id:123@g.us",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
},
|
||||
expected: "Family id:123@g.us",
|
||||
},
|
||||
{
|
||||
name: "appends ids for WhatsApp-like group ids when a subject exists",
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Family",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
},
|
||||
expected: "Family id:123@g.us",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("createTypingCallbacks", () => {
|
||||
it("invokes start on reply start", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports start errors", async () => {
|
||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes stop on idle and reports stop errors", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
||||
const onStartError = vi.fn();
|
||||
const onStopError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(onStopError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends typing keepalive pings until idle cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_999);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates stop across idle and cleanup", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
callbacks.onCleanup?.();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
71
src/channels/conversation-label.test.ts
Normal file
71
src/channels/conversation-label.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { resolveConversationLabel } from "./conversation-label.js";
|
||||
|
||||
describe("resolveConversationLabel", () => {
|
||||
const cases: Array<{ name: string; ctx: MsgContext; expected: string }> = [
|
||||
{
|
||||
name: "prefers ConversationLabel when present",
|
||||
ctx: { ConversationLabel: "Pinned Label", ChatType: "group" },
|
||||
expected: "Pinned Label",
|
||||
},
|
||||
{
|
||||
name: "prefers ThreadLabel over derived chat labels",
|
||||
ctx: {
|
||||
ThreadLabel: "Thread Alpha",
|
||||
ChatType: "group",
|
||||
GroupSubject: "Ops",
|
||||
From: "telegram:group:42",
|
||||
},
|
||||
expected: "Thread Alpha",
|
||||
},
|
||||
{
|
||||
name: "uses SenderName for direct chats when available",
|
||||
ctx: { ChatType: "direct", SenderName: "Ada", From: "telegram:99" },
|
||||
expected: "Ada",
|
||||
},
|
||||
{
|
||||
name: "falls back to From for direct chats when SenderName is missing",
|
||||
ctx: { ChatType: "direct", From: "telegram:99" },
|
||||
expected: "telegram:99",
|
||||
},
|
||||
{
|
||||
name: "derives Telegram-like group labels with numeric id suffix",
|
||||
ctx: { ChatType: "group", GroupSubject: "Ops", From: "telegram:group:42" },
|
||||
expected: "Ops id:42",
|
||||
},
|
||||
{
|
||||
name: "does not append ids for #rooms/channels",
|
||||
ctx: {
|
||||
ChatType: "channel",
|
||||
GroupSubject: "#general",
|
||||
From: "slack:channel:C123",
|
||||
},
|
||||
expected: "#general",
|
||||
},
|
||||
{
|
||||
name: "does not append ids when the base already contains the id",
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Family id:123@g.us",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
},
|
||||
expected: "Family id:123@g.us",
|
||||
},
|
||||
{
|
||||
name: "appends ids for WhatsApp-like group ids when a subject exists",
|
||||
ctx: {
|
||||
ChatType: "group",
|
||||
GroupSubject: "Family",
|
||||
From: "whatsapp:group:123@g.us",
|
||||
},
|
||||
expected: "Family id:123@g.us",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
expect(resolveConversationLabel(testCase.ctx)).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
42
src/channels/registry.helpers.test.ts
Normal file
42
src/channels/registry.helpers.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatChannelSelectionLine,
|
||||
listChatChannels,
|
||||
normalizeChatChannelId,
|
||||
} from "./registry.js";
|
||||
|
||||
describe("channel registry helpers", () => {
|
||||
it("normalizes aliases + trims whitespace", () => {
|
||||
expect(normalizeChatChannelId(" imsg ")).toBe("imessage");
|
||||
expect(normalizeChatChannelId("gchat")).toBe("googlechat");
|
||||
expect(normalizeChatChannelId("google-chat")).toBe("googlechat");
|
||||
expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc");
|
||||
expect(normalizeChatChannelId("telegram")).toBe("telegram");
|
||||
expect(normalizeChatChannelId("web")).toBeNull();
|
||||
expect(normalizeChatChannelId("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Telegram first in the default order", () => {
|
||||
const channels = listChatChannels();
|
||||
expect(channels[0]?.id).toBe("telegram");
|
||||
});
|
||||
|
||||
it("does not include MS Teams by default", () => {
|
||||
const channels = listChatChannels();
|
||||
expect(channels.some((channel) => channel.id === "msteams")).toBe(false);
|
||||
});
|
||||
|
||||
it("formats selection lines with docs labels + website extras", () => {
|
||||
const channels = listChatChannels();
|
||||
const first = channels[0];
|
||||
if (!first) {
|
||||
throw new Error("Missing channel metadata.");
|
||||
}
|
||||
const line = formatChannelSelectionLine(first, (path, label) =>
|
||||
[label, path].filter(Boolean).join(":"),
|
||||
);
|
||||
expect(line).not.toContain("Docs:");
|
||||
expect(line).toContain("/channels/telegram");
|
||||
expect(line).toContain("https://openclaw.ai");
|
||||
});
|
||||
});
|
||||
39
src/channels/targets.test.ts
Normal file
39
src/channels/targets.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMessagingTarget, ensureTargetId, requireTargetKind } from "./targets.js";
|
||||
|
||||
describe("channel targets", () => {
|
||||
it("ensureTargetId returns the candidate when it matches", () => {
|
||||
expect(
|
||||
ensureTargetId({
|
||||
candidate: "U123",
|
||||
pattern: /^[A-Z0-9]+$/i,
|
||||
errorMessage: "bad",
|
||||
}),
|
||||
).toBe("U123");
|
||||
});
|
||||
|
||||
it("ensureTargetId throws with the provided message on mismatch", () => {
|
||||
expect(() =>
|
||||
ensureTargetId({
|
||||
candidate: "not-ok",
|
||||
pattern: /^[A-Z0-9]+$/i,
|
||||
errorMessage: "Bad target",
|
||||
}),
|
||||
).toThrow(/Bad target/);
|
||||
});
|
||||
|
||||
it("requireTargetKind returns the target id when the kind matches", () => {
|
||||
const target = buildMessagingTarget("channel", "C123", "C123");
|
||||
expect(requireTargetKind({ platform: "Slack", target, kind: "channel" })).toBe("C123");
|
||||
});
|
||||
|
||||
it("requireTargetKind throws when the kind is missing or mismatched", () => {
|
||||
expect(() =>
|
||||
requireTargetKind({ platform: "Slack", target: undefined, kind: "channel" }),
|
||||
).toThrow(/Slack channel id is required/);
|
||||
const target = buildMessagingTarget("user", "U123", "U123");
|
||||
expect(() => requireTargetKind({ platform: "Slack", target, kind: "channel" })).toThrow(
|
||||
/Slack channel id is required/,
|
||||
);
|
||||
});
|
||||
});
|
||||
55
src/channels/typing-lifecycle.ts
Normal file
55
src/channels/typing-lifecycle.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
type AsyncTick = () => Promise<void> | void;
|
||||
|
||||
export type TypingKeepaliveLoop = {
|
||||
tick: () => Promise<void>;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
isRunning: () => boolean;
|
||||
};
|
||||
|
||||
export function createTypingKeepaliveLoop(params: {
|
||||
intervalMs: number;
|
||||
onTick: AsyncTick;
|
||||
}): TypingKeepaliveLoop {
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let tickInFlight = false;
|
||||
|
||||
const tick = async () => {
|
||||
if (tickInFlight) {
|
||||
return;
|
||||
}
|
||||
tickInFlight = true;
|
||||
try {
|
||||
await params.onTick();
|
||||
} finally {
|
||||
tickInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (params.intervalMs <= 0 || timer) {
|
||||
return;
|
||||
}
|
||||
timer = setInterval(() => {
|
||||
void tick();
|
||||
}, params.intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
tickInFlight = false;
|
||||
};
|
||||
|
||||
const isRunning = () => timer !== undefined;
|
||||
|
||||
return {
|
||||
tick,
|
||||
start,
|
||||
stop,
|
||||
isRunning,
|
||||
};
|
||||
}
|
||||
88
src/channels/typing.test.ts
Normal file
88
src/channels/typing.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTypingCallbacks } from "./typing.js";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe("createTypingCallbacks", () => {
|
||||
it("invokes start on reply start", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(onStartError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports start errors", async () => {
|
||||
const start = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
|
||||
expect(onStartError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invokes stop on idle and reports stop errors", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockRejectedValue(new Error("stop"));
|
||||
const onStartError = vi.fn();
|
||||
const onStopError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(onStopError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends typing keepalive pings until idle cleanup", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
await callbacks.onReplyStart();
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_999);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(start).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
|
||||
callbacks.onIdle?.();
|
||||
await flushMicrotasks();
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000);
|
||||
expect(start).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates stop across idle and cleanup", async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined);
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
const onStartError = vi.fn();
|
||||
const callbacks = createTypingCallbacks({ start, stop, onStartError });
|
||||
|
||||
callbacks.onIdle?.();
|
||||
callbacks.onCleanup?.();
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTypingKeepaliveLoop } from "./typing-lifecycle.js";
|
||||
|
||||
export type TypingCallbacks = {
|
||||
onReplyStart: () => Promise<void>;
|
||||
onIdle?: () => void;
|
||||
@@ -14,8 +16,6 @@ export function createTypingCallbacks(params: {
|
||||
}): TypingCallbacks {
|
||||
const stop = params.stop;
|
||||
const keepaliveIntervalMs = params.keepaliveIntervalMs ?? 3_000;
|
||||
let keepaliveTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let keepaliveStartInFlight = false;
|
||||
let stopSent = false;
|
||||
|
||||
const fireStart = async () => {
|
||||
@@ -26,35 +26,20 @@ export function createTypingCallbacks(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const clearKeepalive = () => {
|
||||
if (!keepaliveTimer) {
|
||||
return;
|
||||
}
|
||||
clearInterval(keepaliveTimer);
|
||||
keepaliveTimer = undefined;
|
||||
keepaliveStartInFlight = false;
|
||||
};
|
||||
const keepaliveLoop = createTypingKeepaliveLoop({
|
||||
intervalMs: keepaliveIntervalMs,
|
||||
onTick: fireStart,
|
||||
});
|
||||
|
||||
const onReplyStart = async () => {
|
||||
stopSent = false;
|
||||
clearKeepalive();
|
||||
keepaliveLoop.stop();
|
||||
await fireStart();
|
||||
if (keepaliveIntervalMs <= 0) {
|
||||
return;
|
||||
}
|
||||
keepaliveTimer = setInterval(() => {
|
||||
if (keepaliveStartInFlight) {
|
||||
return;
|
||||
}
|
||||
keepaliveStartInFlight = true;
|
||||
void fireStart().finally(() => {
|
||||
keepaliveStartInFlight = false;
|
||||
});
|
||||
}, keepaliveIntervalMs);
|
||||
keepaliveLoop.start();
|
||||
};
|
||||
|
||||
const fireStop = () => {
|
||||
clearKeepalive();
|
||||
keepaliveLoop.stop();
|
||||
if (!stop || stopSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user