Timer fires with stale time after process restored from snapshot (e.g. Windows Fast Startup)
Problem
When running as a Windows service with Fast Startup enabled, the OS snapshots the process state
on shutdown and restores it on the next boot. After restoration, the Go runtime immediately fires
any overdue timers, but the time value sent to timer.C is the stale timestamp from before the
snapshot, not the actual current time.
This causes cron jobs to execute unexpectedly on every boot, even though the scheduled time has long passed.
Example
Machine shuts down at 19:00. Cron job is scheduled for 01:00. Machine boots at 09:00.
After restoration:
now(fromtimer.C) =2026-04-24T23:59:59+08:00← stale, from snapshot momenttime.Now()=2026-04-25T09:02:49+08:00← actual current timee.Next=2026-04-25T01:00:00+08:00← scheduled time
Since e.Next is before now, the job is executed — 8 hours after it was supposed to run.
Root Cause
The run() loop uses the value received from timer.C as now:
go case now = <-timer.C: now = now.In(c.location)
When the process is restored from a snapshot, the Go runtime fires overdue timers immediately,
but the value written to timer.C is the timestamp at the moment of firing, which is the stale
system clock value right after restoration — not the real current time.
Fix
Compare against time.Now() instead of the value from timer.C, and skip jobs that are late
by more than a configurable threshold:
```go case now = <-timer.C: now = now.In(c.location) now2 := time.Now().In(c.location) c.logger.Info("wake", "now", now, "now2", now2)
// Run every entry whose next time was less than now
for _, e := range c.entries {
if e.Next.After(now) || e.Next.IsZero() {
break
}
c.logger.Info("wake", "now", now, "now2", now2, "next", e.Next)
lateBy := now2.Sub(e.Next)
if c.skipIfLateBy == 0 || lateBy < c.skipIfLateBy {
c.startJob(e.WrappedJob)
e.Prev = e.Next
e.Next = e.Schedule.Next(now2)
c.logger.Info("run", "now", now, "now2", now2, "entry", e.ID, "next", e.Next)
} else {
e.Prev = e.Next
e.Next = e.Schedule.Next(now2)
c.logger.Info("run skipped for lateBy",
"now", now, "now2", now2,
"skipIfLateBy", c.skipIfLateBy,
"lateBy", lateBy,
"entry", e.ID, "next", e.Next,
)
}
}
```
A new option SkipIfLateBy(d time.Duration) is introduced to configure the threshold.
The same issue exists in gocron and likely any other Go cron library that relies on time.After
or time.AfterFunc without validating against time.Now() at execution time.
Environment
- Windows 11 with Fast Startup enabled
- Running as a Windows service via WinSW v2.12.0
- github.com/robfig/cron/v3 v3.0.1
Minimal Reproduction
At the end of Minimal Reproduction:
Tip for quick verification: Since the snapshot restores the system clock at
23:59:59(time.Timer use this), a job scheduled at00:00:00will fire ~1 second after boot instead of waiting until the next real midnight. Use0 0 0 * * *to reproduce the issue quickly without waiting.
WinSW Configuration
<service>
<id>{{issue}}</id>
<name>{{issue}}</name>
<description>{{issue}}</description>
<executable>{{executable}}</executable>
<workingdirectory>{{workingdirectory}}</workingdirectory>
<stoptimeout>30 sec</stoptimeout>
<delayedAutoStart>true</delayedAutoStart>
<onfailure action="restart" delay="5000" />
<arguments>"0 0 0 * * *"</arguments>
</service>Cron Expression
0 0 1 * * *Runs daily at 01:00:00.
Minimal Reproduction Code
package main
import (
"fmt"
"time"
"github.com/robfig/cron/v3"
)
func main() {
c := cron.New(cron.WithSeconds())
c.AddFunc("0 0 1 * * *", func() {
fmt.Println("job triggered at:", time.Now())
})
c.Start()
select {}
}Run as a Windows service via WinSW with Fast Startup enabled. Shut down the machine before 01:00 and boot after 01:00. The job will fire immediately on boot despite the scheduled time having passed.
Source: robfig/cron