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