guix-commits
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[shepherd] 05/06: Add timer services.


From: Ludovic Courtès
Subject: [shepherd] 05/06: Add timer services.
Date: Wed, 20 Mar 2024 18:10:43 -0400 (EDT)

civodul pushed a commit to branch wip-timers
in repository shepherd.

commit 2d7b067a5f24ecd4fdd2558d1f5ad54db8ba63d2
Author: Ludovic Courtès <ludo@gnu.org>
AuthorDate: Sat Mar 16 00:04:00 2024 +0100

    Add timer services.
    
    * modules/shepherd/service/timer.scm,
    tests/services/timer.sh, tests/services/timer-events.scm: New files.
    * Makefile.am (dist_servicesub_DATA, TESTS): Add them.
    * modules/shepherd/service.scm (current-service)
    (default-service-directory, terminate-process): Export.
    * doc/shepherd.texi (Timers): New section.
    (Service De- and Constructors): Add cross-reference.
    (Service Examples): Add example.
    * po/POTFILES.in: Add ‘modules/shepherd/service/timer.scm’.
---
 Makefile.am                        |   9 +-
 doc/shepherd.texi                  | 111 ++++++++++
 modules/shepherd/service.scm       |  14 +-
 modules/shepherd/service/timer.scm | 440 +++++++++++++++++++++++++++++++++++++
 po/POTFILES.in                     |   1 +
 tests/services/timer-events.scm    |  90 ++++++++
 tests/services/timer.sh            |  88 ++++++++
 7 files changed, 748 insertions(+), 5 deletions(-)

diff --git a/Makefile.am b/Makefile.am
index 25f7429..62c3f5f 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -1,6 +1,6 @@
 # Makefile.am -- How to build and install the Shepherd.
 # Copyright © 2002, 2003 Wolfgang Jährling <wolfgang@pro-linux.de>
-# Copyright © 2013-2016, 2018-2020, 2022-2023 Ludovic Courtès <ludo@gnu.org>
+# Copyright © 2013-2016, 2018-2020, 2022-2024 Ludovic Courtès <ludo@gnu.org>
 # Copyright © 2018 Carlo Zancanaro <carlo@zancanaro.id.au>
 # Copyright © 2023 Oleg Pykhalov <go.wigust@gmail.com>
 #
@@ -49,7 +49,8 @@ nodist_shepherdsub_DATA =                     \
   modules/shepherd/system.scm
 dist_servicesub_DATA =                         \
   modules/shepherd/service/monitoring.scm      \
-  modules/shepherd/service/repl.scm
+  modules/shepherd/service/repl.scm            \
+  modules/shepherd/service/timer.scm
 
 shepherdgosubdir = $(guileobjectdir)/shepherd
 servicegosubdir = $(guileobjectdir)/shepherd/service
@@ -277,7 +278,9 @@ TESTS =                                             \
   tests/daemonize.sh                           \
   tests/eval-load.sh                           \
   tests/services/monitoring.sh                 \
-  tests/services/repl.sh
+  tests/services/repl.sh                       \
+  tests/services/timer.sh                      \
+  tests/services/timer-events.scm
 
 TEST_EXTENSIONS = .sh .scm
 EXTRA_DIST += $(TESTS)
diff --git a/doc/shepherd.texi b/doc/shepherd.texi
index 57230c3..2584b8f 100644
--- a/doc/shepherd.texi
+++ b/doc/shepherd.texi
@@ -652,6 +652,7 @@ These procedures may not be used in Guile processes other 
than
 * Interacting with Services::   Accessing service state.
 * Service De- and Constructors::  Commonly used ways of starting and
                                    stopping services.
+* Timers::                      Timed services.
 * The root Service::            The service that comes first.
 * Legacy GOOPS Interface::      Deprecated interface and how to migrate.
 * Service Examples::            Examples that show how services are used.
@@ -1141,6 +1142,7 @@ because in that case the @code{stop} method of the 
@code{<service>}
 arranges so that the service is not respawned.
 @end deffn
 
+@anchor{exec-command}
 The @code{make-forkexec-constructor} procedure builds upon the following
 procedures.
 
@@ -1401,6 +1403,94 @@ Similar to @code{make-system-constructor}, but returns 
@code{#f} if
 execution of the @var{command} was successful, @code{#t} if not.
 @end deffn
 
+@xref{Timers}, for other service constructors and destructors: those for
+timers.
+
+@node Timers
+@section Timers
+
+@cindex timers (timed services)
+@cindex timed services (timers)
+@cindex periodic services
+In addition to process, inetd-style, and systemd-style services
+discussed before, another type of service is available: @dfn{timers}.
+
+Timers, you guessed it, execute a command or call a procedure
+periodically.  They work like other services: they can be started,
+stopped, unloaded, and so on.  The constructor and destructor shown
+below let you define a timer; they are exported by @code{(shepherd
+service timer)}, so make sure to add a line like this to your
+configuration file if you'd like to use them:
+
+@lisp
+(use-modules (shepherd service timer))
+@end lisp
+
+@cindex calendar events, for timers
+@cindex timer periodicity
+Timers fire on calendar events: ``every Sunday at noon'', ``everyday at
+11AM and 8PM'', ``on the first of each month'', etc@.  If you ever used
+cron, mcron, or systemd timers, this is similar.  Calendar events are
+specified using the @code{calendar-event} procedure, which defines
+@emph{constraints} on each calendar component: months, days of week,
+hours, minutes, and so on.  Here are a few examples:
+
+@lisp
+;; Every Sunday and every Wednesday, at noon.
+(calendar-event #:hours '(12) #:minutes '(0)
+                #:days-of-week '(0 3))
+
+;; Everyday at 11AM and 8PM.
+(calendar-event #:hours '(11 20) #:minutes '(0))
+
+;; At 9:10AM on the first and fifteenth of each month.
+(calendar-event #:days-of-month '(1 15)
+                #:hours '(9) #:minutes '(10))
+@end lisp
+
+@deffn {Procedure} calendar-event [#:months any-month] @
+  [#:days-of-month any-day] [#:days-of-week #f] @
+  [#:hours any-hour] [#:minutes any-minute] @
+  [#:seconds '(0)]
+Return a calendar event that obeys the given constraints.
+@end deffn
+
+@deffn {Procedure} make-timer-constructor @var{event} @var{action} @
+  [#:wait-for-termination?]
+Return a procedure for use as the @code{start} method of a service.  The
+procedure will perform @var{action} at every occurrence of @code{event}, a
+calendar event as returned by @code{calendar-event}.  @var{action} may be
+either a command (returned by @code{command}) or a thunk; in the latter case,
+the thunk must be suspendable or it could block the whole shepherd process.
+
+When @var{wait-for-termination?} is true, wait until @var{action} has finished
+before considering executing it again; otherwise, perform @var{action}
+strictly on every occurrence of @var{event}, at the risk of having multiple
+instances running concurrently.
+@end deffn
+
+@deffn {Procedure} make-timer-destructor
+Return a procedure for the @code{stop} method of a service whose
+constructor was given by @code{make-timer-destructor}.
+@end deffn
+
+As we have seen above, @code{make-timer-destructor} can be passed a
+command to execute.  Those are specified using the @code{command}
+procedure below.
+
+@deffn {Procedure} command @var{line} @
+  [#:user #f] [#:group #f] @
+  [#:environment-variables (default-environment-variables)] @
+  [#:directory (default-service-directory)] @
+  [#:resource-limits '()]
+Return a new command for @var{line}, a program name and argument list, to be
+executed as @var{user} and @var{group}, with the given
+@var{environment-variables}, in @var{directory}, and with the given
+@var{resource-limits}.
+
+These arguments are the same as for @code{fork+exec-command} and related
+procedures (@pxref{exec-command, @code{fork+exec-command}}).
+@end deffn
 
 @c @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
 
@@ -1644,6 +1734,27 @@ passing it the client connection.  The 
@code{#:max-connections}
 parameter instructs @command{shepherd} to accept at most 10 simultaneous
 client connections.
 
+@cindex timer, example
+Let's now look at @dfn{timers}---services that run periodically, on
+chosen calendar events.  If you ever used the systemd timers or the
+venerable cron, this is similar.  The example below defines a timer that
+fires everyday at noon and runs the @command{updatedb} command as root
+(@pxref{Invoking updatedb,,, find, Finding Files}):
+
+@lisp
+(define updatedb
+  (service
+    '(updatedb)
+    #:start (make-timer-constructor
+              (calendar-event #:hours '(12) #:minutes (0))
+              (command '("/usr/bin/updatedb"
+                         "--prunepaths=/tmp")))
+    #:stop (make-timer-destructor)))
+
+(register-services (list updatedb))
+(start-in-the-background '(updatedb))
+@end lisp
+
 In these examples, we haven't discussed dependencies among
 services---the @code{#:requires} keyword of @code{<service>}---nor did
 we discuss systemd-style services.  These are extensions of what we've
diff --git a/modules/shepherd/service.scm b/modules/shepherd/service.scm
index 7ef2ff0..5530edd 100644
--- a/modules/shepherd/service.scm
+++ b/modules/shepherd/service.scm
@@ -79,6 +79,7 @@
             with-service-registry
             lookup-service
             service-name-count
+            current-service
 
             action?
 
@@ -97,6 +98,8 @@
             start-command
             spawn-command
             spawn-shell-command
+            terminate-process
+            record-process-exit-status
             %precious-signals
             register-services
             unregister-services
@@ -105,6 +108,7 @@
             default-respawn-delay
             default-service-termination-handler
             default-environment-variables
+            default-service-directory
             make-forkexec-constructor
             make-kill-destructor
             default-process-termination-grace-period
@@ -1885,6 +1889,13 @@ environment variable used for systemd-style \"socket 
activation\"."
                                                  log-input))
                pid)))))))
 
+(define (record-process-exit-status pid status)
+  "Record in the current service, if any, the exit STATUS of process PID."
+  (when (current-service)
+    (put-message (service-control (current-service))
+                 `(record-process-exit-status ,pid ,status))
+    #t))
+
 (define* (make-forkexec-constructor command
                                     #:key
                                     (user #f)
@@ -1980,8 +1991,7 @@ process is still running after @var{grace-period} 
seconds, send it
                                           #:grace-period grace-period)
                        (terminate-process (- pgid) signal
                                           #:grace-period grace-period))))
-      (put-message (service-control (current-service))
-                   `(record-process-exit-status ,pid ,status))
+      (record-process-exit-status pid status)
       #f)))
 
 (define (spawn-shell-command command)
diff --git a/modules/shepherd/service/timer.scm 
b/modules/shepherd/service/timer.scm
new file mode 100644
index 0000000..c549017
--- /dev/null
+++ b/modules/shepherd/service/timer.scm
@@ -0,0 +1,440 @@
+;; timer.scm -- Timer service.
+;; Copyright (C) 2024 Ludovic Courtès <ludo@gnu.org>
+;;
+;; This file is part of the GNU Shepherd.
+;;
+;; The GNU Shepherd is free software; you can redistribute it and/or modify it
+;; under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3 of the License, or (at
+;; your option) any later version.
+;;
+;; The GNU Shepherd is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with the GNU Shepherd.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (shepherd service timer)
+  #:use-module (shepherd service)
+  #:use-module (shepherd support)
+  #:use-module (shepherd comm)
+  #:use-module ((fibers) #:hide (sleep))
+  #:use-module (fibers channels)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-9)
+  #:use-module (srfi srfi-9 gnu)
+  #:use-module (srfi srfi-19)
+  #:use-module (srfi srfi-71)
+  #:use-module (ice-9 match)
+  #:export (calendar-event
+            calendar-event?
+            calendar-event-months
+            calendar-event-days-of-month
+            calendar-event-days-of-week
+            calendar-event-hours
+            calendar-event-minutes
+            calendar-event-seconds
+
+            next-calendar-event
+
+            command
+            command?
+            command-line
+            command-user
+            command-group
+            command-directory
+            command-resource-limits
+            command-environment-variables
+
+            make-timer-constructor
+            make-timer-destructor))
+
+;;; Commentary:
+;;;
+;;; This module implements constructors and destructors for "timers"--services
+;;; that invoke commands periodically.
+;;;
+;;; Code:
+
+(define-record-type <calendar-event>
+  (%calendar-event seconds minutes hours days-of-month months days-of-week)
+  calendar-event?
+  (seconds       calendar-event-seconds)
+  (minutes       calendar-event-minutes)
+  (hours         calendar-event-hours)
+  (days-of-month calendar-event-days-of-month)
+  (months        calendar-event-months)
+  (days-of-week  calendar-event-days-of-week))
+
+(define any-minute (iota 60))
+(define any-hour (iota 24))
+(define any-day-of-month (iota 31 1))
+(define any-month (iota 12 1))
+
+(define* (calendar-event #:key
+                         (seconds '(0))
+                         (minutes any-minute)
+                         (hours any-hour)
+                         days-of-week
+                         (days-of-month
+                          (and (not days-of-week) any-day-of-month))
+                         (months any-month))
+  "Return a calendar event that obeys the given constraints."
+  (%calendar-event seconds minutes hours days-of-month months days-of-week))
+
+(define-syntax-rule (define-date-setter name getter)
+  (define (name date value)
+    (set-field date (getter) value)))
+
+(define-date-setter set-date-nanosecond date-nanosecond)
+(define-date-setter set-date-second date-second)
+(define-date-setter set-date-minute date-minute)
+(define-date-setter set-date-hour date-hour)
+(define-date-setter set-date-day date-day)
+(define-date-setter set-date-month date-month)
+(define-date-setter set-date-year date-year)
+
+(define (increment-year date)
+  (set-date-year date (+ 1 (date-year date))))
+
+(define (increment-month date)
+  (if (< (date-month date) 12)
+      (set-date-month date (+ (date-month date) 1))
+      (set-date-month (increment-year date) 1)))
+
+(define (increment-day date)
+  (if (< (date-day date)
+         (days-in-month (date-month date) (date-year date)))
+      (set-date-day date (+ (date-day date) 1))
+      (set-date-day (increment-month date) 1)))
+
+(define (increment-hour date)
+  (if (< (date-hour date) 23)
+      (set-date-hour date (+ (date-hour date) 1))
+      (set-date-hour (increment-day date) 0)))
+
+(define (increment-minute date)
+  (if (< (date-minute date) 59)
+      (set-date-minute date (+ (date-minute date) 1))
+      (set-date-minute (increment-hour date) 0)))
+
+(define (increment-second date)
+  (if (< (date-second date) 59)
+      (set-date-second date (+ (date-second date) 1))
+      (set-date-second (increment-minute date) 0)))
+
+(define (days-in-month month year)
+  "Return the number of days in @var{month} of @var{year}."
+  (let* ((next-day (make-date 0 0 0 0
+                              1 (modulo (+ 1 month) 12)
+                              (if (= 12 month) (+ 1 year) year)
+                              0))
+         (time (date->time-utc next-day))
+         (date (time-utc->date
+                (make-time time-utc 0
+                           (- (time-second time) 3600))
+                0)))
+    (date-day date)))
+
+(define (sooner current max)
+  "Return a two-argument procedure that returns true when its first argument
+is closer to @var{current} than its second argument.  The distance to
+@var{current} is computed modulo @var{max}."
+  (define (distance value)
+    (modulo (- value current) max))
+
+  (lambda (value1 value2)
+    (< (distance value1) (distance value2))))
+
+(define (fit-month date months)
+  (let loop ((candidates (sort months
+                               (sooner (date-month date) 12))))
+    (match candidates
+      ((first . rest)
+       (if (and (= first (date-month date))
+                (> (date-day date) 1))
+           (loop rest)
+           (let ((next (if (>= first (date-month date))
+                           date
+                           (increment-year date))))
+             (set-date-month next first)))))))
+
+(define (fit-day date days)
+  (let loop ((candidates (sort days
+                               (sooner (date-day date)
+                                       (days-in-month (date-month date)
+                                                      (date-year date))))))
+    (match candidates
+      ((first . rest)
+       (if (and (= first (date-day date))
+                (> (date-hour date) 0))
+           (loop rest)
+           (let ((next (if (>= first (date-day date))
+                           date
+                           (increment-month date))))
+             (set-date-day next first)))))))
+
+(define (fit-hour date hours)
+  (let loop ((candidates (sort hours
+                               (sooner (date-hour date) 24))))
+    (match candidates
+      ((first . rest)
+       (if (and (= first (date-hour date))
+                (> (date-minute date) 0))
+           (loop rest)
+           (let ((next (if (>= first (date-hour date))
+                           date
+                           (increment-day date))))
+             (set-date-hour next first)))))))
+
+(define (fit-minute date minutes)
+  (let loop ((candidates (sort minutes
+                               (sooner (date-minute date) 60))))
+    (match candidates
+      ((first . rest)
+       (if (and (= first (date-minute date))
+                (> (date-second date) 0))
+           (loop rest)
+           (let ((next (if (>= first (date-minute date))
+                           date
+                           (increment-hour date))))
+             (set-date-minute next first)))))))
+
+(define (fit-second date seconds)
+  (let loop ((candidates (sort seconds
+                               (sooner (date-second date) 60))))
+    (match candidates
+      ((first . rest)
+       (if (and (= first (date-second date))
+                (> (date-nanosecond date) 0))
+           (loop rest)
+           (let ((next (if (>= first (date-second date))
+                           date
+                           (increment-minute date))))
+             (set-date-second next first)))))))
+
+(define (week-days->month-days week-days month year)
+  "Given @var{week-days}, a list of week-days (between 0 and 6, where 0 is
+Sunday), return the corresponding list of days in @var{month} of @var{year}."
+  (let loop ((date (make-date 0 0 0 0 1 month year 0))
+             (days '()))
+    (if (= (date-month date) month)
+        (loop (increment-day date)
+              (if (memv (date-week-day date) week-days)
+                  (cons (date-day date) days)
+                  days))
+        (reverse days))))
+
+(define (next-calendar-event event date)
+  "Return the date following @var{date} that matches @var{event}, a calendar
+event record."
+  (define (month date)
+    (if (memv (date-month date) (calendar-event-months event))
+        date
+        (fit-month date (calendar-event-months event))))
+
+  (define (day date)
+    (let ((days (append
+                 (or (calendar-event-days-of-month event) '())
+                 (match (calendar-event-days-of-week event)
+                   (#f
+                    '())
+                   (days (week-days->month-days days
+                                                (date-month date)
+                                                (date-year date)))))))
+      (if (memv (date-day date) days)
+          date
+          (fit-day date days))))
+
+  (define (hour date)
+    (if (memv (date-hour date) (calendar-event-hours event))
+        date
+        (fit-hour date (calendar-event-hours event))))
+
+  (define (minute date)
+    (if (memv (date-minute date) (calendar-event-minutes event))
+        date
+        (fit-minute date (calendar-event-minutes event))))
+
+  (define (second date)
+    (if (memv (date-second date) (calendar-event-seconds event))
+        date
+        (fit-second date (calendar-event-seconds event))))
+
+  (define (nanosecond date)
+    ;; Clear nanoseconds and jump to the next second.
+    (increment-second (set-date-nanosecond date 0)))
+
+  ;; TODO: Adjust timezone.
+  (month (day (hour (minute (second (nanosecond date)))))))
+
+(define (seconds-to-wait event)
+  "Return the number of seconds to wait before the next occurrence of
+@var{event} (the result is always greater than zero)."
+  (define (next-second time)
+    (make-time time-utc
+               (time-nanosecond time) (+ 1 (time-second time))))
+
+  (let* ((now (next-second (current-time time-utc)))
+         (then (next-calendar-event event (time-utc->date now))))
+    (+ 1 (time-second (time-difference (date->time-utc then) now)))))
+
+
+;;;
+;;; Timer services.
+;;;
+
+;; Timer value returned by 'make-timer-constructor'.
+(define-record-type <timer>
+  (timer channel event action)
+  timer?
+  (channel timer-channel)                         ;channel
+  (event   timer-event)                           ;<calendar-event>
+  (action  timer-action))                         ;<command> | procedure
+
+;; Command to be executed by a timer.
+(define-record-type <command>
+  (%command line user group environment-variables directory resource-limits)
+  command?
+  (line      command-line)
+  (user      command-user)
+  (group     command-group)
+  (environment-variables command-environment-variables)
+  (directory command-directory)
+  (resource-limits command-resource-limits))
+
+(define* (command line #:key user group
+                  (environment-variables (default-environment-variables))
+                  (directory (default-service-directory))
+                  (resource-limits '()))
+  "Return a new command for @var{line}, a program name and argument list, to be
+executed as @var{user} and @var{group}, with the given
+@var{environment-variables}, in @var{directory}, and with the given
+@var{resource-limits}."
+  (%command line user group environment-variables directory
+            resource-limits))
+
+(define (calendar-event->sexp event)
+  `(calendar-event (version 0)
+                   (seconds ,(calendar-event-seconds event))
+                   (minutes ,(calendar-event-minutes event))
+                   (hours ,(calendar-event-hours event))
+                   (days-of-month ,(calendar-event-days-of-month event))
+                   (days-of-week ,(calendar-event-days-of-week event))
+                   (months ,(calendar-event-months event))))
+
+(define (command->sexp command)
+  `(command (version 0)
+            (arguments ,(command-line command))
+            (user ,(command-user command))
+            (group ,(command-group command))
+            (environment-variables ,(command-environment-variables command))
+            (directory ,(command-directory command))
+            (resource-limits ,(command-resource-limits command))))
+
+(define-record-type-serializer (serializer-timer (timer <timer>))
+  ;; Serialize TIMER to clients can inspect it.
+  `(timer (version 0)
+          (event ,(match (timer-event timer)
+                    ((? calendar-event? event)
+                     (calendar-event->sexp event))
+                    (_ #f)))
+          (action ,(match (timer-action timer)
+                     ((? command? command) (command->sexp command))
+                     (_ 'procedure)))))
+
+(define sleep (@ (fibers) sleep))
+
+(define* (make-timer-constructor event action
+                                 #:key wait-for-termination?)
+  "Return a procedure for use as the @code{start} method of a service.  The
+procedure will perform @var{action} at every occurrence of @code{event}, a
+calendar event as returned by @code{calendar-event}.  @var{action} may be
+either a command (returned by @code{command}) or a thunk; in the latter case,
+the thunk must be suspendable or it could block the whole shepherd process.
+
+When @var{wait-for-termination?} is true, wait until @var{action} has finished
+before considering executing it again; otherwise, perform @var{action}
+strictly on every occurrence of @var{event}, at the risk of having multiple
+instances running concurrently."
+  (lambda ()
+    (let ((channel (make-channel))
+          (name (service-canonical-name (current-service))))
+      (spawn-fiber
+       (lambda ()
+         (let loop ((processes '())
+                    (termination #f))
+           (match (if (or termination
+                          (and (pair? processes) wait-for-termination?))
+                      (get-message channel)
+                      (get-message* channel (seconds-to-wait event)
+                                    'timeout))
+             (('terminate reply)
+              ;; Terminate this timer and its processes.  Send #t on REPLY
+              ;; when we're done.
+              (local-output
+               (l10n "Terminating timer '~a' with ~a process running."
+                     "Terminating timer '~a' with ~a processes running."
+                     (length processes))
+               name (length processes))
+              (for-each (lambda (pid)
+                          (terminate-process pid SIGHUP))
+                        processes)
+              ;; If there are processes left, keep going until they're gone.
+              (if (pair? processes)
+                  (loop processes reply)
+                  (put-message reply #t)))
+             (('process-terminated pid status)
+              ;; Process PID completed.
+              (local-output
+               (l10n "Process ~a of timer '~a' terminated with status ~a.")
+               pid name status)
+              (record-process-exit-status pid status)
+              (let ((processes (delv pid processes)))
+                (if (and termination (null? processes))
+                    (put-message termination #t)  ;done
+                    (loop processes termination))))
+             ('timeout
+              ;; Time to perform ACTION.
+              (if (command? action)
+                  (let ((pid status (start-command
+                                     (command-line action)
+                                     #:user (command-user action)
+                                     #:group (command-group action)
+                                     #:environment-variables
+                                     (command-environment-variables action)
+                                     #:directory (command-directory action)
+                                     #:resource-limits
+                                     (command-resource-limits action))))
+                    (spawn-fiber
+                     (lambda ()
+                       (let ((status (get-message status)))
+                         (put-message channel
+                                      `(process-terminated ,pid ,status)))))
+
+                    (local-output (l10n "Timer '~a' spawned process ~a.")
+                                  name pid)
+                    (loop (cons pid processes) termination))
+                  (begin
+                    (catch #t
+                      action
+                      (lambda (key . args)
+                        (local-output
+                         (l10n "Exception caught while calling action of
+timer '~a': ~s~%")
+                         name (cons key args))))
+                    (loop processes termination))))))))
+
+      (timer channel event action))))
+
+(define (make-timer-destructor)
+  "Return a procedure for the @code{stop} method of a service whose
+constructor was given by @code{make-timer-destructor}."
+  (lambda (timer)
+    (let ((reply (make-channel)))
+      (put-message (timer-channel timer) `(terminate ,reply))
+      ;; Wait until child processes have terminated.
+      (get-message reply))
+    #f))
diff --git a/po/POTFILES.in b/po/POTFILES.in
index e6e92d1..f53e6b0 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -8,5 +8,6 @@ modules/shepherd/support.scm
 modules/shepherd/service.scm
 modules/shepherd/service/monitoring.scm
 modules/shepherd/service/repl.scm
+modules/shepherd/service/timer.scm
 modules/shepherd/args.scm
 modules/shepherd.scm
diff --git a/tests/services/timer-events.scm b/tests/services/timer-events.scm
new file mode 100644
index 0000000..bdd10cc
--- /dev/null
+++ b/tests/services/timer-events.scm
@@ -0,0 +1,90 @@
+;; GNU Shepherd --- Test timer service.
+;; Copyright © 2024 Ludovic Courtès <ludo@gnu.org>
+;;
+;; This file is part of the GNU Shepherd.
+;;
+;; The GNU Shepherd is free software; you can redistribute it and/or modify it
+;; under the terms of the GNU General Public License as published by
+;; the Free Software Foundation; either version 3 of the License, or (at
+;; your option) any later version.
+;;
+;; The GNU Shepherd is distributed in the hope that it will be useful, but
+;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with the GNU Shepherd.  If not, see <http://www.gnu.org/licenses/>.
+
+(define-module (test-timer)
+  #:use-module (shepherd service timer)
+  #:use-module (srfi srfi-1)
+  #:use-module (srfi srfi-19)
+  #:use-module (srfi srfi-64))
+
+(test-begin "timer")
+
+(test-equal "next-calendar-event, leap year"
+  (make-date 0 0 00 12 29 02 2024 3600)
+  (next-calendar-event (calendar-event #:hours '(12) #:minutes '(0))
+                       (make-date 123456789 42 44 12 28 02 2024 3600)))
+
+(test-equal "next-calendar-event, non-leap year"
+  (make-date 0 0 00 12 01 03 2023 3600)
+  (next-calendar-event (calendar-event #:hours '(12) #:minutes '(0))
+                       (make-date 123456789 42 44 12 28 02 2023 3600)))
+
+(test-equal "next-calendar-event, same day"
+  (make-date 0 0 42 12 28 02 2024 3600)
+  (next-calendar-event (calendar-event #:hours '(12) #:minutes '(42))
+                       (make-date 123456789 42 09 12 28 02 2024 3600)))
+
+(test-equal "next-calendar-event, days of week"
+  `(,(make-date 0 0 30 12 02 03 2024 3600)
+    ,(make-date 0 0 30 18 02 03 2024 3600)
+    ,@(append-map (lambda (day)
+                    (list (make-date 0 0 30 06 day 03 2024 3600)
+                          (make-date 0 0 30 12 day 03 2024 3600)
+                          (make-date 0 0 30 18 day 03 2024 3600)))
+                  '(03 06 09)))
+  ;;      March 2024
+  ;; Su Mo Tu We Th Fr Sa
+  ;;                 1  2
+  ;;  3  4  5  6  7  8  9
+  ;; 10 11 12 13 14 15 16
+  ;; 17 18 19 20 21 22 23
+  ;; 24 25 26 27 28 29 30
+  ;; 31
+  (let ((event (calendar-event #:hours '(6 12 18)
+                               #:minutes '(30)
+                               ;; Sunday, Wednesday, Saturday
+                               #:days-of-week '(0 3 6))))
+    (let loop ((date (make-date 123456789 42 09 12
+                                ;; Start on Saturday, March 2nd.
+                                02 03 2024 3600))
+               (n 0)
+               (result '()))
+      (if (< n 11)
+          (let ((date (next-calendar-event event date)))
+            (loop date (+ 1 n) (cons date result)))
+          (reverse result)))))
+
+(test-equal "next-calendar-event, once everyday"
+  (append (map (lambda (day)
+                 (make-date 0 0 14 17 day 03 2024 3600))
+               (iota 31 1))
+          (map (lambda (day)
+                 (make-date 0 0 14 17 day 04 2024 3600))
+               (iota 14 1)))
+  (let ((event (calendar-event #:hours '(17)
+                               #:minutes '(14))))
+    (let loop ((date (make-date 123456789 42 09 12
+                                01 03 2024 3600))
+               (n 0)
+               (result '()))
+      (if (< n (+ 31 14))
+          (let ((date (next-calendar-event event date)))
+            (loop date (+ 1 n) (cons date result)))
+          (reverse result)))))
+
+(test-end "timer")
diff --git a/tests/services/timer.sh b/tests/services/timer.sh
new file mode 100644
index 0000000..7fac11c
--- /dev/null
+++ b/tests/services/timer.sh
@@ -0,0 +1,88 @@
+# GNU Shepherd --- Test timed services.
+# Copyright © 2024 Ludovic Courtès <ludo@gnu.org>
+#
+# This file is part of the GNU Shepherd.
+#
+# The GNU Shepherd is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or (at
+# your option) any later version.
+#
+# The GNU Shepherd is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with the GNU Shepherd.  If not, see <http://www.gnu.org/licenses/>.
+
+shepherd --version
+herd --version
+
+socket="t-socket-$$"
+conf="t-conf-$$"
+log="t-log-$$"
+pid="t-pid-$$"
+service_pid="t-service-pid-$$"
+
+herd="herd -s $socket"
+
+trap "cat $log || true; rm -f $socket $conf $log $service_pid;
+      test -f $pid && kill \`cat $pid\` || true; rm -f $pid" EXIT
+
+cat > "$conf" <<EOF
+(use-modules (shepherd service timer))
+
+(define endless
+  "echo Started endless timer.; echo \$\$ > $PWD/$service_pid; sleep 500")
+
+(define timers
+  (list (service '(timer-with-command)
+                #:start (make-timer-constructor
+                         (calendar-event #:seconds (iota 60))
+                         (command '("sh" "-c" "echo Hi from $PWD.")))
+                #:stop (make-timer-destructor))
+        (service '(timer-with-procedure)
+                 #:start (make-timer-constructor
+                         (calendar-event #:seconds (iota 60))
+                          (lambda () (display "Hello from procedure.\n")))
+                 #:stop (make-timer-destructor))
+        (service '(endless-timer)
+                 #:start (make-timer-constructor
+                          (calendar-event #:seconds (iota 60))
+                          (command (quasiquote ("sh" "-c" ,endless)))
+                          #:wait-for-termination? #t)
+                 #:stop (make-timer-destructor))))
+
+(register-services timers)
+EOF
+
+rm -f "$pid"
+shepherd -I -s "$socket" -c "$conf" -l "$log" --pid="$pid" &
+
+# Wait till it's ready.
+while ! test -f "$pid" ; do sleep 0.3 ; done
+
+shepherd_pid="`cat $pid`"
+
+$herd start timer-with-command
+sleep 2
+grep "Hi from " "$log"
+$herd status timer-with-command | grep "Hi from " # recent messages
+$herd stop timer-with-command
+
+$herd start timer-with-procedure
+sleep 2
+grep "Hello from procedure" "$log"
+
+rm -f "$service_pid"
+$herd start endless-timer
+sleep 2
+grep "Started endless timer" "$log"
+$herd status endless-timer | grep "Started endless timer" # recent messages
+kill -0 $(cat "$service_pid")
+$herd stop endless-timer
+kill -0 $(cat "$service_pid") && false
+grep "Process $(cat "$service_pid") of timer 'endless-timer' terminated" "$log"
+
+$herd stop root



reply via email to

[Prev in Thread] Current Thread [Next in Thread]