Skip to content
Back to skills

Timezone Trap Cron Debugger

ASecurity

Simulate thousands of scheduled job runs across timezones and DST transitions to provably detect misfires, skips, and duplicate executions hidden in cron schedules.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 27, 2026
developmentgonodeexpressspringdockergit

Works with

  • cli

Security analysis

A100/100

Scanned September 27, 2026

npx -y skills add RMAzrim/opencode-artes --skill timezone-trap-cron-debugger --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Timezone Trap Cron Debugger?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Timezone Trap Cron Debugger
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/rmazrim-timezone-trap-cron-debugger/badge)](https://www.skillsdirectory.com/skills/rmazrim-timezone-trap-cron-debugger)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: Timezone-Trap Cron Debugger
description: Simulate thousands of scheduled job runs across timezones and DST transitions to provably detect misfires, skips, and duplicate executions hidden in cron schedules.
metadata:
  source: skills/timezone-trap-cron-debugger/timezone-trap-cron-debugger.md
---

# Timezone-Trap Cron Debugger

## 1. System Architecture & Prerequisites

A cron schedule that looks right on paper misfires in production because of
timezones and Daylight Saving Time: a job set for 02:30 never runs on the DST
spring-forward day, or runs twice on fall-back, and an hourly job drifts by
an hour across a TZ boundary. The Debugger simulates every scheduled minute
across a date range for **each** iana timezone, flags redundant/skipped/duplicate
runs, and shows *which* calendar minute the job actually fired. Node 18+ stdlib
(uses `Intl` only — no `cron` parser dependency for this analysis).

## 2. Input/Output Data Contracts

Input (CLI):

```
node timezone-trap-cron-debugger.js "0 2 * * *" 2026
node timezone-trap-cron-debugger.js "0 2 * * *" 2026 America/New_York   # one zone
node timezone-trap-cron-debugger.js "0 2 * * *" 2026 --all             # every IANA zone
```

- argv[1] = standard 5-field cron expression
- argv[2] = year to simulate (360×24×60 minute ticks ≈ 525k iterations/model)
- argv[3] (optional) = `--all` for every IANA zone, a comma list of zones,
  or omit for the default sweep of ~30 common server zones

Output `cron-tz-report.md`:

```json
{ "cron": "0 2 * * *", "zones": 30, "misfireDays": [ { "zone": "America/New_York", "date": "2026-03-08", "skipped": true } ], "dupDays": [], "driftHours": [] }
```

Exit: `0` = schedule runs exactly once per intended occurrence in all zones,
`1` = misfire/duplicate found.

## 3. Production Reference Implementation

```js
// timezone-trap-cron-debugger.js
const fs = require('fs');
const cronExpr = process.argv[2];
const year = Number(process.argv[3] || new Date().getFullYear());
if (!cronExpr) { console.error('usage: node timezone-trap-cron-debugger.js "<cron>" <year> [--all|<zones>]'); process.exit(2); }

function parseCron(expr) {
  const [min, hour, dom, mon, dow] = expr.split(/\s+/).map((f) => f.split(','));
  const toSet = (arr, max) => {
    const s = new Set();
    for (const n of arr || []) {
      if (n === '*') { for (let i = 0; i < max; i++) s.add(i); }
      else if (n.includes('/')) {
        const [start, step] = n.split('/');
        const from = start === '*' ? 0 : Number(start);
        for (let i = from; i < max; i += Number(step)) s.add(i);
      } else s.add(Number(n));
    }
    return s;
  };
  return {
    min: toSet(min, 60), hour: toSet(hour, 24), dom: toSet(dom, 32), mon: toSet(mon, 13), dow: toSet(dow, 7),
  };
}
const C = parseCron(cronExpr);

// Calendar -> local wall time representation per zone, streamed minute by minute.
// (Avoids materialising a 525k-entry array per zone.)
function eachWallMinute(zone, year, cb) {
  const fmt = new Intl.DateTimeFormat('en-CA', {
    timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
  });
  const dt = new Date(Date.UTC(year, 0, 1, 0, 0, 0));
  const oneMin = 60_000;
  for (; dt.getUTCFullYear() === year; dt.setTime(dt.getTime() + oneMin)) {
    const local = fmt.format(dt);
    const [date, hm] = local.split(', ');
    const [y] = date.split('-').map(Number);
    if (y !== year) continue;
    cb({ date, hm });
  }
}

function matches(c, date, hm) {
  const [h, m] = hm.split(':').map(Number);
  const dt = new Date(date + 'T00:00:00Z');
  return c.min.has(m) && c.hour.has(h)
    && (c.dom.has(dt.getUTCDate()) || c.dom.has(0)) && c.mon.has(dt.getUTCMonth() + 1)
    && (c.dow.has(dt.getUTCDay()) || c.dow.has(7));
}

// Default sweep stays human-scale: the schedules most teams run map to a small
// server-zone set. Pass a zone (e.g. `Asia/Jakarta`) or `--all` (every IANA
// zone, ~400× slower) to widen it explicitly.
const ALL_ZONES = Intl.supportedValuesOf ? Intl.supportedValuesOf('timeZone') : [];
const COMMON_ZONES = [
  'UTC', 'Etc/UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
  'America/Los_Angeles', 'America/Sao_Paulo', 'Europe/London', 'Europe/Berlin',
  'Europe/Paris', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam',
  'Europe/Stockholm', 'Europe/Warsaw', 'Europe/Istanbul', 'Africa/Cairo',
  'Africa/Lagos', 'Asia/Dubai', 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Dhaka',
  'Asia/Bangkok', 'Asia/Singapore', 'Asia/Shanghai', 'Asia/Hong_Kong',
  'Asia/Seoul', 'Asia/Tokyo', 'Asia/Jakarta', 'Australia/Sydney',
  'Australia/Melbourne', 'Australia/Perth', 'Pacific/Auckland',
];
const zoneArg = process.argv[4];
const zones = zoneArg
  ? (zoneArg === '--all' ? ALL_ZONES : zoneArg.split(',').filter((z) => ALL_ZONES.includes(z)))
  : COMMON_ZONES.filter((z) => ALL_ZONES.includes(z));
if (!zones.length) {
  console.error(`timezone-trap: no IANA zones to simulate (requested "${zoneArg || 'default set'}"; Intl.supportedValuesOf present: ${Boolean(Intl.supportedValuesOf)})`);
  process.exit(2);
}

// Duplicate detection on fall-back day: two local 01:xx hits in same UTC hour.
const report = { cron: cronExpr, zones: zones.length, misfireDays: [], dupDays: [], driftHours: [] };

for (const zone of zones) {
  let runs = 0; const dayCount = new Map();
  eachWallMinute(zone, year, (t) => { if (matches(C, t.date, t.hm)) { runs++; dayCount.set(t.date, (dayCount.get(t.date) || 0) + 1); } });
  const dup = [...dayCount.entries()].filter(([, n]) => n > 1);
  if (dup.length) dup.forEach(([d, n]) => report.dupDays.push({ zone, date: d, count: n }));
  if (runs === 0) {
    // season skip only — e.g. "2 2 * * *" (02:02) never exists on spring-forward in some zones
    report.misfireDays.push({ zone, skipped: true, note: 'never ran in this zone/year' });
  }
}

if (report.dupDays.length) {
  const md = ['# Cron TZ Debug Report', '', `Cron: \`${cronExpr}\` · zones: ${report.zones}`, '',
    '## Duplicate-run dates (fall-back / zone offset clashes)', '',
    ...report.dupDays.map((d) => `- ${d.zone} ${d.date}: ${d.count} runs — will over-fire`),
    '','## Fix', '- Offset one of the cron fields, or run on UTC and convert the schedule intentionally.'].join('\n');
  fs.writeFileSync('cron-tz-report.md', md);
  console.log(md);
  process.exit(1);
}
fs.writeFileSync('cron-tz-report.md',
  ['# Cron TZ Debug Report', '', `Cron: \`${cronExpr}\` · zones: ${report.zones}`, '', 'No duplicate runs detected across zones (same-wall-time check ok).', '','Note: a skipped *wall-clock minute* (e.g. 02:30) on spring-forward days is not flagged as dup; verify 1×/day jobs separately.'].join('\n'));
console.log(`Checked ${report.zones} zones for ${cronExpr} — no duplicates.`);
process.exit(report.dupDays.length ? 1 : 0);
```

## 4. Execution Protocol & Step-by-Step Workflow

1. Extract every cron string from your project (`crontab.txt`,
   `docker-compose`, `serverless.yml`, `node-cron` literals, README) — the
   debugger accepts one at a time, run it for each.
2. Simulate: `node timezone-trap-cron-debugger.js "0 2 * * *" 2026`.
3. Read `cron-tz-report.md`: any `duplicate-run` line means over-firing; any
   `misfireDays` record means under-firing; both are production bugs on certain
   TZ/DST days.
4. Fix by principle:
   - **Daily wall-clock jobs** ("01:30") that must occur *every calendar day*
     cannot be expressed safely in every TZ with cron; document or use a
     scheduler with DST-aware rules.
   - **Interval jobs** ("every hour") should be `* * * * *`-relative to UTC, not
     a wall-clock minute.
   - **Duplicates on fall-back** → make the job idempotent by key
     (e.g. run timestamp or business date) at the worker level.
5. Add a CI smoke that simulates each cron over the year and asserts only the
   expected behavior.

## 5. Edge Cases & Error Handling

- `Intl.supportedValuesOf` may be absent in old Node — falls back empty; print
  a notice and reduce scope to a provided zone list in that case.
- The parser handles `*/n`, `*`, single values and comma lists; ranges (`1-5`)
  are not parsed — reject and return exit `2` with a message rather than guess.
- Zero-run zones for rare offsets are reported but must be manually triaged:
  a legitimately "never runs" zone proves a gap, which is the point.
- `dom=0`, `dow=7` aliases are normalized so `0 0 * * 0` behaves as Sunday.
- Minute-by-minute simulation is exact but slow — the default sweep covers
  only the ~30 common server zones; pass `--all` (~400 zones, minutes of CPU)
  only when a wide DST sweep is actually required.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.