Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions internal/clock/clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -372,14 +372,24 @@ 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)
}
}
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
Expand Down
28 changes: 28 additions & 0 deletions internal/clock/clock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down