Skip to content

Commit 77c65a5

Browse files
authored
Merge pull request #738 from workglow-dev/claude/optimistic-goldberg-gzrguj-triggers
fix(triggers): stop-event, change-baseline, hung-poll and skip-log defects
2 parents 90d0261 + a120fde commit 77c65a5

8 files changed

Lines changed: 555 additions & 25 deletions

File tree

packages/test/src/test/trigger/IntervalTrigger.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import type { ITriggerFireContext } from "@workglow/triggers";
88
import { IntervalTrigger, TriggerConfigurationError } from "@workglow/triggers";
9+
import type { ILogger } from "@workglow/util";
10+
import { getLogger, setLogger } from "@workglow/util";
911
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
1012

1113
import { advanceFakeTimers, flushAsyncWork } from "../helpers/advanceFakeTimers";
@@ -26,6 +28,38 @@ function createGate(): Gate {
2628
return { promise, open };
2729
}
2830

31+
interface WarnRecord {
32+
readonly message: string;
33+
readonly meta: Record<string, unknown> | undefined;
34+
}
35+
36+
/**
37+
* Installs a logger that records `warn` calls; returns the sink and a restore fn.
38+
* Built from scratch rather than spread from the installed logger, whose methods
39+
* live on a class prototype and would be lost.
40+
*/
41+
function captureWarnings(): { warnings: WarnRecord[]; restore: () => void } {
42+
const warnings: WarnRecord[] = [];
43+
const previous = getLogger();
44+
const noop = (): void => {};
45+
const logger: ILogger = {
46+
debug: noop,
47+
info: noop,
48+
warn: (message: string, meta?: Record<string, unknown>) => {
49+
warnings.push({ message, meta });
50+
},
51+
error: noop,
52+
fatal: noop,
53+
child: () => logger,
54+
time: noop,
55+
timeEnd: noop,
56+
group: noop,
57+
groupEnd: noop,
58+
};
59+
setLogger(logger);
60+
return { warnings, restore: () => setLogger(previous) };
61+
}
62+
2963
describe("IntervalTrigger", () => {
3064
beforeEach(() => {
3165
vi.useFakeTimers();
@@ -286,6 +320,93 @@ describe("IntervalTrigger", () => {
286320
});
287321
});
288322

323+
describe("skip logging", () => {
324+
test("a contiguous run of skips logs once, then a count when it ends", async () => {
325+
// A wedged handler at a 1s period would otherwise write 3,600 identical
326+
// warnings an hour, forever.
327+
const { warnings, restore } = captureWarnings();
328+
try {
329+
const trigger = new IntervalTrigger({ intervalMs: PERIOD });
330+
const gate = createGate();
331+
const skips: number[] = [];
332+
trigger.on("skip", (scheduledAt) => skips.push(scheduledAt));
333+
trigger.start(async () => {
334+
await gate.promise;
335+
});
336+
337+
await advanceFakeTimers(PERIOD);
338+
await advanceFakeTimers(PERIOD * 4);
339+
340+
// The EVENT still fires per dropped tick; only the log is collapsed.
341+
expect(skips).toHaveLength(4);
342+
expect(warnings).toHaveLength(1);
343+
expect(warnings[0]?.message).toContain("skipped");
344+
345+
gate.open();
346+
await flushAsyncWork();
347+
await advanceFakeTimers(PERIOD);
348+
349+
expect(warnings).toHaveLength(2);
350+
expect(warnings[1]?.meta?.skipped).toBe(4);
351+
352+
await trigger.stop();
353+
} finally {
354+
restore();
355+
}
356+
});
357+
358+
test("an isolated skip logs once and adds no summary", async () => {
359+
// The ordinary intermittent case must not have its log volume doubled.
360+
const { warnings, restore } = captureWarnings();
361+
try {
362+
const trigger = new IntervalTrigger({ intervalMs: PERIOD });
363+
const gate = createGate();
364+
const skips: number[] = [];
365+
trigger.on("skip", (scheduledAt) => skips.push(scheduledAt));
366+
trigger.start(async () => {
367+
await gate.promise;
368+
});
369+
370+
await advanceFakeTimers(PERIOD * 2);
371+
expect(skips).toHaveLength(1);
372+
373+
gate.open();
374+
await flushAsyncWork();
375+
await advanceFakeTimers(PERIOD);
376+
377+
expect(warnings).toHaveLength(1);
378+
379+
await trigger.stop();
380+
} finally {
381+
restore();
382+
}
383+
});
384+
385+
test("stopping mid-skip still reports the skip count", async () => {
386+
const { warnings, restore } = captureWarnings();
387+
try {
388+
const trigger = new IntervalTrigger({ intervalMs: PERIOD });
389+
const gate = createGate();
390+
const skips: number[] = [];
391+
trigger.on("skip", (scheduledAt) => skips.push(scheduledAt));
392+
trigger.start(async () => {
393+
await gate.promise;
394+
});
395+
396+
await advanceFakeTimers(PERIOD * 4);
397+
expect(skips).toHaveLength(3);
398+
399+
const stopping = trigger.stop();
400+
expect(warnings.map((warning) => warning.meta?.skipped)).toContain(3);
401+
402+
gate.open();
403+
await stopping;
404+
} finally {
405+
restore();
406+
}
407+
});
408+
});
409+
289410
test("a period past the host timer ceiling is served in chunks", async () => {
290411
// A delay over 2^31-1 ms overflows a host timer and fires IMMEDIATELY, so
291412
// an unchunked wait would turn a very long period into a hot loop.
@@ -515,6 +636,35 @@ describe("IntervalTrigger", () => {
515636
await trigger.stop();
516637
});
517638

639+
test("a restart during stop() does not emit stop while the trigger is running", async () => {
640+
// An observer that sees `stop` while `running` is true concludes the
641+
// trigger is dead and stops watching it — while it is actively firing.
642+
const trigger = new IntervalTrigger({ intervalMs: PERIOD });
643+
const gate = createGate();
644+
const runningAtStop: boolean[] = [];
645+
trigger.on("stop", () => runningAtStop.push(trigger.running));
646+
647+
trigger.start(async () => {
648+
await gate.promise;
649+
});
650+
await advanceFakeTimers(PERIOD);
651+
652+
// NOT awaited: the gated gen-1 handler keeps the old run draining.
653+
const stopping = trigger.stop();
654+
trigger.start(() => {});
655+
656+
gate.open();
657+
await stopping;
658+
659+
// Gen 1 drained, but gen 2 owns the trigger.
660+
expect(runningAtStop).toEqual([]);
661+
expect(trigger.running).toBe(true);
662+
663+
// ...and the real stop still reports itself.
664+
await trigger.stop();
665+
expect(runningAtStop).toEqual([false]);
666+
});
667+
518668
test("a restart during stop() does not emit spurious skips (skip policy)", async () => {
519669
// The first generation's handler is still gated when the second starts.
520670
// Overlap state belongs to a RUN, not to the trigger: if the new

0 commit comments

Comments
 (0)