A focused cancellation propagation library for Common Lisp, providing deadlines, timeouts, and hierarchical cancellation.
cl-cancel provides hierarchical cancellation with deadlines and timeouts for Common Lisp applications. When a parent operation is cancelled, all child operations are automatically cancelled too.
- Hierarchical Cancellation: Parent cancellation propagates to all children
- Deadlines & Timeouts: Scheduled automatic cancellation with nanosecond precision
- Efficient Waiting: Semaphore-based blocking (no polling)
- Stream Integration: Automatic stream closure on cancellation for immediate I/O abort
- Thread-Safe: Lock-free operations where possible, careful lock ordering elsewhere
- Scalable: Single timer thread manages thousands of concurrent deadlines via min-heap
Common Lisp lacks native support for:
- Cancellation propagation - automatically cancelling child operations when parent is cancelled
- Deadline management - treating time limits as cancellation triggers
- Stream cancellation - aborting blocked I/O immediately when cancelled
cl-cancel provides these essential concurrency primitives.
;; Load the system
(asdf:load-system :cl-cancel)
;; Dependencies: bordeaux-threads, atomics, precise-time(use-package :cl-cancel)
;; Create a cancellable with a cancel function
(multiple-value-bind (ctx cancel)
(with-cancel (background))
;; In another thread
(bt:make-thread
(lambda ()
(loop
(check-cancellation ctx)
(do-work)))
:name "worker")
;; Cancel from main thread
(sleep 5)
(funcall cancel));; Timeout with automatic cleanup
(with-timeout-context (ctx 5.0) ; 5 second timeout
(fetch-data-from-slow-api))
;; Manual timeout management
(multiple-value-bind (ctx cancel)
(with-timeout (background) 5.0)
(unwind-protect
(fetch-data-from-slow-api)
(funcall cancel))) ; Always clean up;; Absolute deadline (e.g., end of business hours)
(let ((deadline (+ (get-current-time) (* 60 60)))) ; 1 hour from now
(with-deadline-context (ctx deadline)
(process-batch-job)));; Parent cancellation propagates to children
(with-timeout-context (parent 10.0)
(with-timeout-context (child1 5.0 parent)
(task-1)) ; Times out after 5s OR when parent times out
(with-timeout-context (child2 8.0 parent)
(task-2))) ; Times out after 8s OR when parent times outInstead of storing values in contexts, use Lisp's dynamic variables:
;; Define request-scoped specials
(defvar *request-id* nil)
(defvar *user-id* nil)
(defvar *trace-id* nil)
;; Bind them at request boundary
(defun handle-request (request)
(let ((*request-id* (generate-request-id))
(*user-id* (extract-user-id request))
(*trace-id* (extract-trace-id request)))
(with-timeout-context (ctx 30.0)
(process-request request))))
;; Access anywhere in the call stack
(defun log-message (msg)
(format t "[~A] [~A] ~A~%"
*request-id*
*trace-id*
msg))Benefits of using dynamic variables:
- Standard Lisp feature (no learning curve)
- Better IDE support (navigation, completion)
- Type declarations work (
(declaim (type string *request-id*))) - Compiler optimization opportunities
- Direct variable access (no lookup overhead)
Automatically close streams when cancelled, interrupting blocked I/O:
(defun fetch-http (url)
(with-timeout-context (ctx 5.0)
(let* ((socket (connect-to-host url))
(cancel-monitor (close-stream-on-cancel socket)))
(unwind-protect
(progn
(write-http-request socket url)
(read-http-response socket))
(funcall cancel-monitor)))))
;; Database query with cancellation
(defun query-db (query)
(with-deadline-context (ctx (+ (get-current-time) 30))
(let* ((conn (db-connect))
(cancel-monitor (close-stream-on-cancel conn)))
(unwind-protect
(db-execute conn query)
(funcall cancel-monitor)))))When the context times out or is cancelled:
close-stream-on-cancelimmediately closes the stream with:abort t- Blocked
read/writeoperations are interrupted - The operation returns (often with an error)
- Your cleanup code runs
(defvar *request-id* nil
"Current request ID")
(defun http-get-with-retry (url max-retries timeout)
"Fetch URL with retries, timeout per attempt, and request tracking"
(let ((*request-id* (make-uuid)))
(dotimes (attempt max-retries)
(handler-case
(with-timeout-context (ctx timeout)
(let* ((socket (connect-to-server url))
(cancel-monitor (close-stream-on-cancel socket)))
(unwind-protect
(progn
(format t "[~A] Attempt ~D~%" *request-id* (1+ attempt))
(send-request socket url)
(return-from http-get-with-retry
(read-response socket)))
(funcall cancel-monitor))))
(deadline-exceeded (e)
(format t "[~A] Timeout on attempt ~D~%" *request-id* (1+ attempt))
(when (= attempt (1- max-retries))
(error e)))
(cancelled (e)
(format t "[~A] Cancelled on attempt ~D~%" *request-id* (1+ attempt))
(error e))))))cancellable- Base class for all cancellablescancellable-context- A cancellable that can be explicitly cancelledbackground-context- A cancellable that is never cancelled
(background)→ Returns a never-cancelled cancellable(with-cancel parent)→ Create a cancellable with manual cancellation(with-timeout parent seconds)→ Create a cancellable with timeout(with-deadline parent absolute-time)→ Create a cancellable with deadline
(done-p cancellable)→ T if cancelled or deadline exceeded(cancelled-p cancellable)→ T if cancelled(deadline cancellable)→ Returns deadline or NIL(err cancellable)→ Returns error condition or NIL(cancel cancellable &optional error)→ Cancel cancellable and children
(check-cancellation &optional cancellable)→ Signal error if done(wait-until-done cancellable &optional timeout)→ Block until done(close-stream-on-cancel stream &optional cancellable)→ Monitor stream closure(get-current-time)→ Current time with nanosecond precision
*current-cancel-context*- The current cancellable (implicit parameter passing)
(with-cancel-context (var &optional parent) &body body)→ Auto-cleanup(with-timeout-context (var seconds &optional parent) &body body)→ Auto-cleanup(with-deadline-context (var deadline &optional parent) &body body)→ Auto-cleanup(with-cancellable (var cancellable) &body body)→ Bind to*current-cancel-context*
cancellation-error- Base class for all errorscancelled- Signaled when explicitly cancelleddeadline-exceeded- Signaled when deadline/timeout expires
-
Cancellation is about control flow, not data flow
- Use cancellables for propagating cancellation
- Use dynamic variables for propagating data
-
Deadlines are scheduled cancellations
- A timeout is just "cancel after N seconds"
- A deadline is "cancel at time T"
- Both use the same propagation mechanism
-
Explicit cleanup is better than implicit
- Always call the cancel function (use
unwind-protect) - Use convenience macros when appropriate
- Resource leaks are worse than verbose code
- Always call the cancel function (use
-
Immediate cancellation over polling
- Use
close-stream-on-cancelto abort I/O - Use
wait-until-doneto block efficiently - Avoid tight loops checking
done-p
- Use
- Cancellable creation: O(1) with parent registration
- Cancellation check: O(1) lock-free read + parent chain walk
- Cancellation propagation: O(children) - all children cancelled recursively
- Deadline management: O(log n) via min-heap, single timer thread for all deadlines
- Stream monitoring: One thread per monitored stream (lightweight, semaphore-blocked)
All operations are thread-safe:
- Lock-free reads where possible
- Careful lock ordering prevents deadlocks
- No parent method calls under child locks
- Atomic operations for initialization
(asdf:test-system :cl-cancel)- Go's context package: The inspiration for this library's cancellation semantics
- bordeaux-threads: Provides cross-implementation threading primitives
MIT License - see LICENSE file for details
Contributions welcome! Please ensure:
- All tests pass
- New features include tests
- Documentation is updated
- Thread safety is maintained
Anthony Green green@moxielogic.com