Skip to content

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

@liruohrh

Description

@liruohrh

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions