Cron launches commands at matching calendar times without inherently waiting for earlier invocations. Seventy-second work scheduled every minute can therefore overlap. Treat triggering, concurrency, and successful completion as separate responsibilities.
Read fields as calendar selections
A user crontab entry contains minute, hour, day of month, month, day of week, then command. Asterisks select all field values. A minute step such as */15 selects values within each hour, not fifteen minutes since completion. Steps that unevenly divide a field’s range can produce unequal boundary gaps.
In Debian/Vixie format, when day of month and day of week are both restricted, either matching day permits execution; minute, hour, and month constraints still apply. Month and weekday are not alternatives. Verify representative dates before installing the expression.
Timezone, daylight-saving changes, and downtime affect scheduling. Implementations handle skipped or repeated times differently; Debian documents special handling for certain small clock changes and fixed-time jobs. This experiment neither changes the clock nor tests transitions. Calendar triggers are not durable queues guaranteeing replay of missed runs.
When it breaks
Overlapping jobs can duplicate side effects, compete for resources, or overwrite files. Explicitly enforce any single-invocation policy: nonblocking advisory locks permit recorded skips; blocking locks can leave waiting processes. Neither provides exactly-once business effects or repairs partial operations.
A local file lock only coordinates participants using the same relevant locking facility. It does not automatically serialize jobs on unrelated hosts, and network filesystem semantics require separate examination. Keep the lock held for the work’s lifetime, and make skipped work visible. Silently dropping every later trigger while one worker hangs can look like a healthy schedule unless completion age is monitored.
Cron is not universally log-free. Debian logs job starts to syslog by default and offers additional logging controls. Command output may be mailed when a working mail path exists, but a minimal container often lacks that path. Record useful start, completion, failure, and skip outcomes through an intentional destination. A start message proves dispatch, not completion of the application’s task.
Check it quickly
Repair draft checked 2026-10-19: this revision was checked statically and with offline command/file-state doubles only. No cron, crontab, account, or signal operation was executed. Technical and operational verification remain blocked; the measurements below belong to the original fixture, not this revision.
Only a separately authorized, freshly provisioned disposable Debian 12 lab with Python 3.11+, cron 3.0pl1-162 and procps is suitable for a future run. It must have no host mounts, existing root table, cron daemon, enabled unrelated schedules in /etc/crontab, /etc/cron.d or any user’s spool (including periodic-directory dispatch), or concurrent administrators. Root is a privilege prerequisite, not isolation proof. Save as cron-overlap.py; the future command is python3 cron-overlap.py --isolated-disposable-lab. That flag acknowledges an externally verified prerequisite and cannot establish containment. Allow roughly two minutes for real calendar ticks, and discard the entire lab afterward.
The test table disables mail with MAILTO. Setup is inside the cleanup boundary, including a failed table installation or daemon startup. A failed listing is accepted as absence only for the pinned package’s exact English absent-table diagnostic; permission and command errors abort. Workers publish complete per-event JSON files by same-directory replacement. Cleanup revokes a positive run permit before removing schedules and stopping the owned daemon, then waits at most five seconds for the workers’ lifetime lock and completion records. Each cleanup step reports its own error without skipping later steps; private-file removal is skipped after earlier cleanup errors; deletion itself can fail partway. This checks admitted work, not the exit of every cron wrapper or late-start process. It is not a containment or crash-durability proof.
from pathlib import Path
from tempfile import mkdtemp
import fcntl
import json
import os
import shutil
import subprocess
import sys
import time
def command(arguments, **options):
return subprocess.run(arguments, capture_output=True, text=True, timeout=5,
env={**os.environ, 'LC_ALL': 'C'}, **options)
def table_absent(result):
# Debian cron 3.0pl1-162: other listing failures must abort.
return (result.returncode == 1 and result.stdout == ''
and result.stderr == 'no crontab for root\n')
def remove_table():
current = command(['crontab', '-l'])
if table_absent(current):
return
current.check_returncode()
command(['crontab', '-r']).check_returncode()
if not table_absent(command(['crontab', '-l'])):
raise RuntimeError('could not verify table removal')
def attempt(errors, name, operation):
try:
operation()
except Exception as error:
errors.append(f'{name}: {error!r}')
def stop_daemon(daemon, errors):
if daemon is None:
return
attempt(errors, 'terminate cron', daemon.terminate)
try:
daemon.wait(timeout=5)
except subprocess.TimeoutExpired:
attempt(errors, 'kill owned cron', daemon.kill)
attempt(errors, 'reap cron', lambda: daemon.wait(timeout=5))
except Exception as error:
errors.append(f'wait cron: {error!r}')
def read_events(root):
return sorted((json.loads(path.read_text()) for path in root.glob('*.json')),
key=lambda event: event['monotonic'])
def wait_workers(root):
# Workers hold a shared lifetime lock before checking the run permit or publishing.
with (root / 'lifetime.lock').open() as gate:
deadline = time.monotonic() + 5
while True:
try:
fcntl.flock(gate, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError('workers did not release lifetime lock')
time.sleep(0.1)
events = read_events(root)
started = {e['pid'] for e in events if e['event'] == 'start'}
ended = {e['pid'] for e in events if e['event'] == 'end'}
if not started <= ended:
raise RuntimeError('a started worker has no completion record')
worker_source = r'''from pathlib import Path
import fcntl,json,os,sys,time
root=Path(__file__).parent
kind=sys.argv[1]
def record(event):
destination=root/f'{os.getpid()}-{event}.json'
temporary=destination.with_suffix('.tmp')
temporary.write_text(json.dumps({'event':event,'kind':kind,
'pid':os.getpid(),'monotonic':time.monotonic()}))
temporary.replace(destination)
def main():
with (root/'lifetime.lock').open() as gate:
fcntl.flock(gate,fcntl.LOCK_SH)
if not (root/'run-permit').exists(): return
lock=None
try:
if kind=='locked':
lock=open(root/'job.lock','a')
try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:
record('skip')
return
record('start')
try:
until=time.monotonic()+70
while time.monotonic()<until and (root/'run-permit').exists():
time.sleep(0.1)
finally: record('end')
finally:
if lock is not None: lock.close()
main()
'''
def main():
# This flag is an operator prerequisite, NOT evidence of containment.
if sys.argv[1:] != ['--isolated-disposable-lab']:
raise SystemExit('requires a separately verified disposable Debian 12 lab')
if os.geteuid() != 0:
raise RuntimeError('root is required, but does not prove isolation')
probe = command(['pgrep', '-x', 'cron'])
if probe.returncode != 1 or probe.stdout or probe.stderr:
raise RuntimeError(f'cron absence not established: {probe!r}')
if not table_absent(command(['crontab', '-l'])):
raise RuntimeError('expected absent root table; existing table or listing error')
root = Path(mkdtemp(prefix='cron-overlap-', dir='/tmp'))
daemon = None
table_attempted = False
try:
(root / 'lifetime.lock').touch()
(root / 'run-permit').touch()
worker = root / 'worker.py'
worker.write_text(worker_source)
table = 'MAILTO=""\n' + ''.join(f'* * * * * /usr/bin/python3 {worker} {kind} >>{root}/worker.log 2>&1\n'
for kind in ('plain', 'locked'))
table_attempted = True
command(['crontab', '-'], input=table).check_returncode()
daemon = subprocess.Popen(['cron', '-f'], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
deadline = time.monotonic() + 140
while time.monotonic() < deadline:
if daemon.poll() is not None:
raise RuntimeError('cron exited')
events = read_events(root)
plain = [e for e in events if (e['kind'], e['event']) == ('plain', 'start')]
locked = [e for e in events if (e['kind'], e['event']) == ('locked', 'start')]
skipped = [e for e in events if (e['kind'], e['event']) == ('locked', 'skip')]
if len(plain) >= 2 and skipped:
break
time.sleep(0.2)
else:
raise AssertionError('two real cron ticks were not observed')
assert len(plain) == 2 and len(locked) == 1 and len(skipped) == 1
assert plain[1]['monotonic'] - plain[0]['monotonic'] < 70
assert not any(e['event'] == 'end' and e['pid'] == plain[0]['pid'] for e in events)
print(json.dumps({'actual_cron_starts': len(plain), 'overlap_observed': True,
'seconds_between_starts': round(plain[1]['monotonic']-plain[0]['monotonic'], 3),
'locked_starts': len(locked), 'locked_skips': len(skipped)}), flush=True)
finally:
original = sys.exc_info()[1]
errors = []
attempt(errors, 'revoke run permit', lambda: (root / 'run-permit').unlink(missing_ok=True))
if table_attempted:
attempt(errors, 'remove root table', remove_table)
stop_daemon(daemon, errors)
attempt(errors, 'wait for workers', lambda: wait_workers(root))
# Retain private files after failed cleanup; discard the whole lab.
if not errors:
attempt(errors, 'remove private files', lambda: shutil.rmtree(root))
if errors:
message = 'cleanup incomplete; discard lab: ' + '; '.join(errors)
if original is not None:
original.add_note(message)
else:
raise RuntimeError(message)
if __name__ == '__main__':
main()
Two every-minute entries run workers that would last seventy seconds. The plain worker has no job-exclusion lock; both branches share only the cleanup lifetime lock. The locked worker uses a shared file and a nonblocking exclusive flock, recording a skip when acquisition fails. Each event includes the worker PID and a monotonic timestamp, so distinct invocations and their overlap can be checked directly.
The original fixture’s historical Debian cron 3.0pl1-162 run recorded two plain starts, 60.003 seconds apart, before its first invocation ended. The guarded worker started once and recorded one skip. That original run reported all assertions passing and cleanup via its stop file. This revision instead revokes a positive permit and has not reproduced those runtime results. The experiment verifies local overlap and explicit exclusion; it does not implement durable retries, distributed locking, recovery after reboot, or exactly-once effects. Select those policies from the job’s actual business contract instead of assuming the calendar expression supplies them.
Sources
Debian 12: crontab(5), calendar fields, steps and the two day fields, checked 2026-09-13.
Debian 12: cron(8), daemon execution, logging and clock-change behavior, checked 2026-09-13.
Linux: flock(2), advisory locks and nonblocking acquisition, checked 2026-09-13.
Python 3.11 subprocess, timeout and child cleanup semantics, checked 2026-10-19.
Debian cron 3.0pl1-162 crontab.c, distinguish absent-table diagnostics from other failures, checked 2026-10-19.
Python 3.11 os.replace, atomic same-filesystem publication, checked 2026-10-19.