Skip to content
Closed
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
46 changes: 43 additions & 3 deletions deadline/deadline.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type Deadline struct {
deadline time.Time
state deadlineState
pending uint8
cbs map[int]func()
nextCbID int
}

// New creates new deadline timer.
Expand All @@ -47,11 +49,41 @@ func (d *Deadline) timeout() {
return
}

d.state = deadlineExceeded
done := d.done
d.fire()
d.mu.Unlock()
}

// AfterTimeout attaches a callback to the deadline.
// The added callback will be triggered when the deadline is met.
// The callback can be detached via the returned function.
// This function mimics context.AfterFunc behavior.
func (d *Deadline) AfterTimeout(cb func()) func() bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.state == deadlineExceeded {
go cb()

close(done)
return func() bool { return false }
}
d.nextCbID++
usedID := d.nextCbID
if d.cbs == nil {
d.cbs = map[int]func(){}
}
d.cbs[d.nextCbID] = cb
cancel := func() bool {
d.mu.Lock()
defer d.mu.Unlock()
if _, has := d.cbs[usedID]; has {
delete(d.cbs, usedID)

return true
}

return false
}

return cancel
}

// Set new deadline. Zero value means no deadline.
Expand Down Expand Up @@ -89,8 +121,16 @@ func (d *Deadline) Set(setTo time.Time) {
}

d.pending--
d.fire()
}

func (d *Deadline) fire() {
d.state = deadlineExceeded
close(d.done)
for _, cb := range d.cbs {
go cb()
}
clear(d.cbs)
Comment thread
noboruma marked this conversation as resolved.
}

// Done receives deadline signal.
Expand Down
83 changes: 83 additions & 0 deletions deadline/deadline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package deadline

import (
"context"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -93,6 +94,88 @@ func TestDeadline(t *testing.T) {
expectedCalls := []byte{0}
assert.Equal(t, expectedCalls, calls, "Wrong order of deadline signal")
})

t.Run("DeadlineAfterTimeoutExceedFuture", func(t *testing.T) {
now := time.Now()

d := New()
var trigger atomic.Int32
d.AfterTimeout(func() {
trigger.Add(1)
})
d.Set(now.Add(10 * time.Millisecond))
<-time.After(20 * time.Millisecond)
d.Set(now.Add(10 * time.Millisecond))
<-time.After(20 * time.Millisecond)
Comment on lines +106 to +109

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10/20ms margins will probably flake in CI. also double check that this is taking the expected path...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we should be able to control a fake clock but I have simply copied from above tests.
Maybe I can do better with channel synchronization instead, otherwise do you suggest higher timers?

@paulwe paulwe Sep 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usually 100-200ms is enough slack for oversubscribed gh test runners. if the numbers you chose are found elsewhere in the package i retract my suggestion though

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should start using synctest for time tests like this, it should be beind a 1.25 go build flag.


assert.Equal(t, int32(1), trigger.Load(), "Function triggered wrong number of time")
})

t.Run("DeadlineAfterTimeoutExceedPast", func(t *testing.T) {
now := time.Now()

d := New()
var trigger atomic.Int32
d.AfterTimeout(func() {
trigger.Add(1)
})
d.Set(now.Add(10 * time.Millisecond))
d.Set(now.Add(-10 * time.Millisecond))
<-time.After(20 * time.Millisecond)

assert.Equal(t, int32(1), trigger.Load(), "Function triggered wrong number of time")
})

t.Run("DeadlineAfterTimeoutAlreadyExceeded", func(t *testing.T) {
now := time.Now()

d := New()
var trigger atomic.Int32
d.Set(now.Add(10 * time.Millisecond))
d.Set(now.Add(-10 * time.Millisecond))
<-time.After(20 * time.Millisecond)
d.AfterTimeout(func() {
trigger.Add(1)
})
<-time.After(20 * time.Millisecond)

assert.Equal(t, int32(1), trigger.Load(), "Function triggered wrong number of time")
})

t.Run("DeadlineAfterTimeoutDetach", func(t *testing.T) {
now := time.Now()

d := New()
var trigger atomic.Int32
detach := d.AfterTimeout(func() {
trigger.Add(1)
})
ret := detach()
d.Set(now.Add(10 * time.Millisecond))
<-time.After(20 * time.Millisecond)

assert.Equal(t, int32(0), trigger.Load(), "Function triggered wrong number of time")
assert.Equal(t, true, ret, "The detach function returned wrong value")
})

t.Run("DeadlineAfterTimeoutMultiCallbacks", func(t *testing.T) {
now := time.Now()

d := New()
var trigger atomic.Int32
d.AfterTimeout(func() {
trigger.Add(1)
})
d.AfterTimeout(func() {
trigger.Add(1)
})
assert.Equal(t, int32(0), trigger.Load(), "Function triggered wrong number of time")

d.Set(now.Add(10 * time.Millisecond))
<-time.After(20 * time.Millisecond)

assert.Equal(t, int32(2), trigger.Load(), "Function triggered wrong number of time")
})
}

func sendOnDone(ctx context.Context, done <-chan struct{}, dest chan byte, val byte) {
Expand Down
Loading