diff --git a/internal/clock/clock.go b/internal/clock/clock.go index a18cf93e..0955f4ba 100644 --- a/internal/clock/clock.go +++ b/internal/clock/clock.go @@ -361,7 +361,7 @@ func (c *Clock) ProcessEventsWhile(ctx context.Context, f func() bool) error { func (c *Clock) processEventsWhile(ctx context.Context, f func() bool, name string) error { errs := make([]error, 0, 1+c.pendingEvents*2) - errs = append(errs, c.RunAll()) // Run microtasks first + errs = append(errs, c.runDueTasks()...) c.logger().Debug("Clock.processEventsWhile", "pendingCount", c.pendingEvents, "this", c) for f() { c.logger().Debug("Clock.ProcessEvents: waiting") @@ -372,7 +372,7 @@ func (c *Clock) processEventsWhile(ctx context.Context, f func() bool, name stri c.logger(). Debug("clock.ProcessEvent: processed", "pendingCount", c.pendingEvents, log.ErrAttr(err)) errs = append(errs, err) - errs = append(errs, c.RunAll()) + errs = append(errs, c.runDueTasks()...) case <-ctx.Done(): return fmt.Errorf("Clock.%s: timeout waiting for event", name) } @@ -380,6 +380,16 @@ func (c *Clock) processEventsWhile(ctx context.Context, f func() bool, name stri return errors.Join(errs...) } +// runDueTasks drains microtasks and any timers whose scheduled time has +// already passed, but leaves future-scheduled timers alone. This is what +// ProcessEvents needs: progress real async work without fast-forwarding +// through unrelated long-delay timeouts. +func (c *Clock) runDueTasks() []error { + return c.runWhile(func() bool { + return len(c.tasks) > 0 && !c.tasks[0].time.After(c.Time) + }) +} + func (c *Clock) generateHandle() TaskHandle { c.nextHandle++ return c.nextHandle diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go index d67f009a..07d736c2 100644 --- a/internal/clock/clock_test.go +++ b/internal/clock/clock_test.go @@ -217,6 +217,34 @@ func (s *ClockTestSuite) TestProcessEvents() { s.Assert().Equal(1, count, "count before processEvents") } +// ProcessEvents waits for pending async work (microtasks, fetches, posted +// events), not unrelated future timers. A long setTimeout armed before the +// call must not fire — otherwise frameworks like htmx, which arm a defensive +// abort timer on every request, get their requests cancelled mid-flight. +func (s *ClockTestSuite) TestProcessEventsDoesNotFireFutureTimers() { + ctx, cancel := context.WithTimeout(s.T().Context(), time.Millisecond) + defer cancel() + + c := clock.New(clock.OfIsoString("2025-02-01T12:00:00Z")) + var fired bool + c.SetTimeout(wrapTask(func() { fired = true }), 60*time.Second) + + var eventCount int + e := c.BeginEvent() + go func() { + e.AddEvent(func() error { eventCount++; return nil }) + }() + + s.Require().NoError(c.ProcessEvents(ctx)) + s.Assert().Equal(1, eventCount, "posted event should be processed") + s.Assert().False(fired, "future timer must not fire from ProcessEvents") + s.Assert().Equal(feb1st2025_noon_milli, c.Time.UnixMilli(), "clock should not advance") + + // The timer is still pending and fires on explicit Advance. + s.Require().NoError(c.Advance(60 * time.Second)) + s.Assert().True(fired, "timer fires when its scheduled time is reached") +} + func (s *ClockTestSuite) TestProcessEventsUntil() { var count int