Commit744102e0Recorded18 Mar 2026Repositorysigil-later

Redesign runner: per-task goroutines with exact sleep and cancellation

Message

Each task spawns its own goroutine that sleeps for the exact delay rather than polling. Tasks are cancellable via handles.

API:

  • later-after! — delay in seconds, returns cancellable handle
  • later-at! — absolute timestamp, returns cancellable handle
  • later-every! — cron schedule, returns cancellable handle
  • later-cancel! — prevent a pending task from firing

Runner integration tests verify tasks fire at sub-second intervals and that cancellation prevents execution.

Changed
 src/sigil/later/runner.sgl | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------
 test/test-runner.sgl       |  70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 143 insertions(+), 105 deletions(-)
Diff
src/sigil/later/runner.sglmodified
@@ -1,134 +1,102 @@
1
;;; (sigil later runner) - Async task runner for scheduled tasks.
2
;;;
−3
;;; Integrates with (sigil async) to run recurring and one-time tasks.
−4
;;; Use within a `with-async` block via `(go (later-run runner))`.
+3
;;; Each scheduled task spawns its own goroutine that sleeps until
+4
;;; the exact fire time, then executes the handler. Tasks are
+5
;;; cancellable via the returned handle.
6
;;;
7
;;; ```scheme
−7
;;; (let ((runner (make-later-runner)))
−8
;;; (later-every! runner "*/5 * * * *" "check-inbox"
+8
;;; (with-async
+9
;;; ;; Recurring: fires every 5 minutes
+10
;;; (later-every! "*/5 * * * *" "check-inbox"
11
;;; (lambda () (check-inbox)))
−10
;;; (later-once! runner (+ (current-second) 3600) "reminder"
−11
;;; (lambda () (send-reminder)))
−12
;;; (with-async (go (later-run runner))))
+12
;;;
+13
;;; ;; One-time: fires in 1 hour
+14
;;; (let ((handle (later-after! 3600 "reminder"
+15
;;; (lambda () (send-reminder)))))
+16
;;; ;; Cancel it before it fires:
+17
;;; (later-cancel! handle)))
18
;;; ```
19
20
(define-library (sigil later runner)
21
(import (sigil core)
22
(sigil struct)
23
(sigil string)
+24
(sigil math)
25
(sigil time)
26
(sigil async)
27
(sigil later))
−22
(export make-later-runner
−23
later-runner?
−24
later-every!
−25
later-once!
−26
later-remove!
−27
later-run
−28
later-tick)
+28
(export later-every!
+29
later-after!
+30
later-at!
+31
later-cancel!
+32
later-handle?
+33
later-handle-name
+34
later-handle-cancelled)
35
(begin
36
37
;; ============================================================
−32
;; Structs
+38
;; Handle
39
;; ============================================================
40
−35
(define-struct later-runner
−36
(tasks default: '() mutable: #t))
−37
−38
(define-struct later-task
+41
(define-struct later-handle
42
(name)
−40
(type) ;; 'every or 'once
−41
(schedule) ;; later-schedule for 'every, timestamp for 'once
−42
(handler) ;; (lambda () ...)
−43
(last-fired default: 0 mutable: #t))
+43
(cancelled default: #f mutable: #t))
44
45
;; ============================================================
−46
;; Task Registration
+46
;; Scheduling
47
;; ============================================================
48
−49
;;; Add a recurring task that fires on a cron schedule.
−50
(define (later-every! runner cron-str name handler)
−51
(: later-runner? string? string? procedure? -> void?)
+49
;;; Schedule a recurring task on a cron schedule.
+50
;;;
+51
;;; Spawns a goroutine that computes the next matching time via
+52
;;; `later-next`, sleeps until then, fires the handler, and repeats.
+53
;;; Returns a handle that can be cancelled.
+54
(define (later-every! cron-str name handler)
+55
(: string? string? procedure? -> later-handle?)
56
(let ((sched (parse-cron-string cron-str))
−53
(task (later-task
−54
name: name
−55
type: 'every
−56
schedule: sched
−57
handler: handler)))
−58
(later-runner-tasks-set! runner
−59
(cons task (later-runner-tasks runner)))))
−60
−61
;;; Add a one-time task that fires at a specific timestamp.
−62
(define (later-once! runner timestamp name handler)
−63
(: later-runner? number? string? procedure? -> void?)
−64
(let ((task (later-task
−65
name: name
−66
type: 'once
−67
schedule: timestamp
−68
handler: handler)))
−69
(later-runner-tasks-set! runner
−70
(cons task (later-runner-tasks runner)))))
−71
−72
;;; Remove a task by name.
−73
(define (later-remove! runner name)
−74
(: later-runner? string? -> void?)
−75
(later-runner-tasks-set! runner
−76
(filter (lambda (t) (not (string=? (later-task-name t) name)))
−77
(later-runner-tasks runner))))
+57
(handle (later-handle name: name)))
+58
(go (let loop ()
+59
(unless (later-handle-cancelled handle)
+60
(let ((next-time (later-next sched (current-second))))
+61
(when next-time
+62
(let ((delay (- next-time (current-second))))
+63
(when (> delay 0)
+64
(sleep delay)))
+65
(unless (later-handle-cancelled handle)
+66
(guard (exn (#t (display (format "Error in task ~a: ~a\n"
+67
name exn)
+68
(current-error-port))))
+69
(handler))
+70
(loop)))))))
+71
handle))
72
−79
;; ============================================================
−80
;; Execution
−81
;; ============================================================
−82
−83
;;; Run the scheduler loop. Call within (go ...) in a with-async block.
+73
;;; Schedule a one-time task after a delay (in seconds).
74
;;;
−85
;;; Checks every 30 seconds for tasks that should fire. Recurring
−86
;;; tasks fire at most once per minute (tracked by last-fired).
−87
;;; One-time tasks are removed after firing.
−88
(define (later-run runner)
−89
(: later-runner? -> void?)
−90
(let loop ()
−91
(later-tick runner)
−92
(sleep 30)
−93
(loop)))
+75
;;; Spawns a goroutine that sleeps for the delay, then fires.
+76
;;; Returns a cancellable handle.
+77
(define (later-after! delay-seconds name handler)
+78
(: number? string? procedure? -> later-handle?)
+79
(let ((handle (later-handle name: name)))
+80
(go (sleep delay-seconds)
+81
(unless (later-handle-cancelled handle)
+82
(guard (exn (#t (display (format "Error in task ~a: ~a\n"
+83
name exn)
+84
(current-error-port))))
+85
(handler))))
+86
handle))
87
−95
;;; Single check-and-fire pass. Useful for custom event loops.
−96
(define (later-tick runner)
−97
(: later-runner? -> void?)
−98
(let ((now (current-second))
−99
(now-minute (truncate-to-minute (current-second))))
−100
(let ((fired-once '()))
−101
(for-each
−102
(lambda (task)
−103
(cond
−104
;; Recurring: check cron match, fire at most once per minute
−105
((eq? (later-task-type task) 'every)
−106
(when (and (later-matches? (later-task-schedule task) now)
−107
(not (= (later-task-last-fired task) now-minute)))
−108
(later-task-last-fired-set! task now-minute)
−109
(guard (exn (#t (display (format "Error in task ~a: ~a\n"
−110
(later-task-name task)
−111
exn)
−112
(current-error-port))))
−113
((later-task-handler task)))))
−114
;; One-time: fire if past the scheduled time
−115
((eq? (later-task-type task) 'once)
−116
(when (>= now (later-task-schedule task))
−117
(guard (exn (#t (display (format "Error in task ~a: ~a\n"
−118
(later-task-name task)
−119
exn)
−120
(current-error-port))))
−121
((later-task-handler task)))
−122
(set! fired-once (cons (later-task-name task) fired-once))))))
−123
(later-runner-tasks runner))
−124
;; Remove fired one-time tasks
−125
(when (pair? fired-once)
−126
(later-runner-tasks-set! runner
−127
(filter (lambda (t)
−128
(not (member (later-task-name t) fired-once)))
−129
(later-runner-tasks runner)))))))
+88
;;; Schedule a one-time task at a specific Unix timestamp.
+89
;;;
+90
;;; Spawns a goroutine that sleeps until the target time, then fires.
+91
;;; Returns a cancellable handle.
+92
(define (later-at! timestamp name handler)
+93
(: number? string? procedure? -> later-handle?)
+94
(let* ((delay (- timestamp (current-second)))
+95
(delay (if (< delay 0) 0 delay)))
+96
(later-after! delay name handler)))
97
−131
;; Truncate a timestamp to the start of its minute.
−132
(define (truncate-to-minute ts)
−133
(let ((secs (exact (floor ts))))
−134
(- secs (modulo secs 60))))))
+98
;;; Cancel a scheduled task. If the task hasn't fired yet, it won't.
+99
;;; If it's already running, this has no effect on the current execution.
+100
(define (later-cancel! handle)
+101
(: later-handle? -> void?)
+102
(set-later-handle-cancelled! handle #t))))
test/test-runner.sgladded
@@ -0,0 +1,70 @@
+1
(import (sigil test)
+2
(sigil time)
+3
(sigil async)
+4
(sigil later runner))
+5
+6
;; ============================================================
+7
;; later-after! — one-time delayed tasks
+8
;; ============================================================
+9
+10
(test-group "later-after!"
+11
(test "fires tasks at correct intervals"
+12
(let ((results '()))
+13
(with-async
+14
(later-after! 0.1 "fast"
+15
(lambda () (set! results (cons "fast" results))))
+16
(later-after! 0.3 "medium"
+17
(lambda () (set! results (cons "medium" results))))
+18
(later-after! 0.5 "slow"
+19
(lambda () (set! results (cons "slow" results))))
+20
;; Wait long enough for all to fire
+21
(go (sleep 0.8)))
+22
;; All three should have fired, in order (reversed because cons)
+23
(assert-equal 3 (length results))
+24
(assert-equal "slow" (car results))
+25
(assert-equal "medium" (cadr results))
+26
(assert-equal "fast" (caddr results))))
+27
+28
(test "returns a handle"
+29
(let ((handle #f))
+30
(with-async
+31
(set! handle (later-after! 0.1 "test" (lambda () #t)))
+32
(go (sleep 0.2)))
+33
(assert-true (later-handle? handle))
+34
(assert-equal "test" (later-handle-name handle)))))
+35
+36
;; ============================================================
+37
;; later-cancel!
+38
;; ============================================================
+39
+40
(test-group "later-cancel!"
+41
(test "cancelled task does not fire"
+42
(let ((fired? #f))
+43
(with-async
+44
(let ((handle (later-after! 0.2 "cancel-me"
+45
(lambda () (set! fired? #t)))))
+46
(go (sleep 0.05)
+47
(later-cancel! handle)))
+48
(go (sleep 0.4)))
+49
(assert-false fired?)))
+50
+51
(test "non-cancelled task still fires"
+52
(let ((fired? #f))
+53
(with-async
+54
(later-after! 0.1 "keep-me"
+55
(lambda () (set! fired? #t)))
+56
(go (sleep 0.3)))
+57
(assert-true fired?))))
+58
+59
;; ============================================================
+60
;; later-at!
+61
;; ============================================================
+62
+63
(test-group "later-at!"
+64
(test "fires at specified timestamp"
+65
(let ((fired? #f))
+66
(with-async
+67
(later-at! (+ (current-second) 0.1) "at-test"
+68
(lambda () (set! fired? #t)))
+69
(go (sleep 0.3)))
+70
(assert-true fired?))))