#568·cron

Timer fires with stale time after process restored from snapshot (e.g. Windows Fast Startup)

Author: liruohrhCreated Apr 25, 2026Updated Apr 25, 2026

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

xml
<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

go
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.