Space multi-line command replies MESH_MULTI_SEND_DELAY_MS apart (v0.21.5)
CI / build + unit (pull_request) Failing after 21s

Production incident: '#rss get lublin' returned an array of lines and
CommandService sent each one back-to-back within the same second — on the real
mesh 3 of 4 items were dropped in RF (the owner saw only the last one).

CommandService.onRx now waits MESH_MULTI_SEND_DELAY_MS (new env, default 5000 =
5 s, read at construction like the other timing knobs) between consecutive
lines of an array reply — no delay before the first line. The delay sits in the
shared per-line loop, so both the respond() path and the legacy sendText
fallback are covered by one await.
This commit is contained in:
2026-09-26 10:29:21 +00:00
parent 3f6c02d71e
commit 1b768b14a7
5 changed files with 47 additions and 4 deletions
+26
View File
@@ -30,6 +30,7 @@ function makeService(
beforeEach(() => {
delete process.env.COMMAND_CHANNEL; // default channel 2 under test
process.env.MESH_MULTI_SEND_DELAY_MS = '0'; // no spacing under test unless a test opts in — the real default (5000) would slow the suite down
});
describe('CommandService.onRx', () => {
@@ -124,6 +125,31 @@ describe('CommandService.onRx', () => {
expect(mesh.sendText).not.toHaveBeenCalled();
});
it('spaces consecutive multi-line sends by MESH_MULTI_SEND_DELAY_MS — no delay before the first line', async () => {
process.env.MESH_MULTI_SEND_DELAY_MS = '10'; // short so the test stays fast
const sentAt: number[] = [];
const respond = vi.fn(async () => {
sentAt.push(Date.now());
return { id: 1 };
});
try {
const [svc, mesh] = makeService(undefined, respond);
svc.register('multi3', () => ['a: one', 'b: two', 'c: three']);
const t0 = Date.now();
await svc.onRx(2, '#multi3', 42, 9);
expect(mesh.respond).toHaveBeenCalledTimes(3);
// the first line goes out immediately (no delay before it) — only microtask-scale overhead between t0 and the first send
expect(sentAt[0] - t0).toBeLessThan(8);
// each subsequent line waits at least MESH_MULTI_SEND_DELAY_MS after the previous one landed
expect(sentAt[1] - sentAt[0]).toBeGreaterThanOrEqual(10);
expect(sentAt[2] - sentAt[1]).toBeGreaterThanOrEqual(10);
} finally {
delete process.env.MESH_MULTI_SEND_DELAY_MS;
}
});
it('sends each line of an array reply via the legacy sendText fallback when there is no packet id', async () => {
const [svc, mesh] = makeService();
svc.register('multi', () => ['one: first item', 'two: second item']);
+14 -2
View File
@@ -27,6 +27,11 @@ export class CommandService {
process.env.COMMAND_CHANNEL ?? '2',
10,
);
// spacing between consecutive multi-line reply messages — back-to-back packets get dropped in RF on a real mesh; each packet needs time to land. Read at construction like the other env config.
private readonly multiSendDelayMs = parseInt(
process.env.MESH_MULTI_SEND_DELAY_MS ?? '5000',
10,
);
// single registration point: the constructor registers every built-in command — add the next one there (or via register())
private readonly commands = new Map<string, CommandHandler>();
// prefix handlers own a whole family (`#rss`, `#rss add ...`) instead of one exact name
@@ -83,8 +88,15 @@ export class CommandService {
try {
const reply = await handler({ text: trimmed, from, id });
if (reply === undefined || reply === null) return; // void = no reply
// an array reply sends one message per line, all threaded to the same trigger packet id
for (const line of Array.isArray(reply) ? reply : [reply]) {
// an array reply sends one message per line, all threaded to the same trigger packet id — spaced so each packet has time to land in RF (no delay before the first line)
const lines = Array.isArray(reply) ? reply : [reply];
for (let i = 0; i < lines.length; i++) {
if (i > 0 && this.multiSendDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, this.multiSendDelayMs),
);
}
const line = lines[i];
if (id !== undefined && this.mesh) {
// respond() auto-routes each line: rides the original record's channel with replyId = id (threaded quote)
await this.mesh.respond(id, line);