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?))))