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 (from timer.C) = 2026-04-24T23:59:59+08:00 ← stale, from snapshot moment
time.Now() = 2026-04-25T09:02:49+08:00 ← actual current time
e.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 at 00:00:00 will fire ~1 second after boot instead of waiting until the next real midnight. Use 0 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
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.
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.Cis the stale timestamp from before thesnapshot, 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 timeSince
e.Nextis beforenow, the job is executed — 8 hours after it was supposed to run.Root Cause
The
run()loop uses the value received fromtimer.Casnow:
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.Cis the timestamp at the moment of firing, which is the stalesystem clock value right after restoration — not the real current time.
Fix
Compare against
time.Now()instead of the value fromtimer.C, and skip jobs that are lateby 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)
```
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.Afteror
time.AfterFuncwithout validating againsttime.Now()at execution time.Environment
Minimal Reproduction
At the end of Minimal Reproduction:
WinSW Configuration
Cron Expression
Runs daily at 01:00:00.
Minimal Reproduction Code
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.