;;; Guile Knots
;;; Copyright © 2020 Christopher Baines <mail@cbaines.net>
;;;
;;; This file is part of Guile Knots.
;;;
;;; The Guile Knots 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 Guile Knots 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 guix-data-service.  If not, see
;;; <http://www.gnu.org/licenses/>.

(define-module (knots timeout)
  #:use-module (srfi srfi-71)
  #:use-module (ice-9 match)
  #:use-module (ice-9 atomic)
  #:use-module (ice-9 exceptions)
  #:use-module (ice-9 ports internal)
  #:use-module (ice-9 suspendable-ports)
  #:use-module (fibers)
  #:use-module (fibers timers)
  #:use-module (fibers channels)
  #:use-module (fibers scheduler)
  #:use-module (fibers operations)
  #:export (with-fibers-timeout

            wait-until-port-readable-operation
            wait-until-port-writable-operation

            &port-timeout-error
            port-timeout-error?

            &port-read-timeout-error
            port-read-timeout-error?

            &port-write-timeout-error
            port-write-timeout-error?

            with-port-timeouts))

(define* (with-fibers-timeout thunk #:key
                              timeout
                              (on-timeout
                               (const *unspecified*)))
  "Run THUNK in a new fiber and return its values, waiting TIMEOUT
seconds for it to finish.  If THUNK does not complete within TIMEOUT
seconds, the ON-TIMEOUT procedure is called and with-fibers-timeout
returns the result of ON-TIMEOUT instead.

If THUNK raises an exception it is re-raised in the calling fiber."
  (let ((channel (make-channel)))
    (spawn-fiber
     (lambda ()
       (with-exception-handler
           (lambda (exn)
             (perform-operation
              (choice-operation
               (put-operation channel (cons 'exception exn))
               (sleep-operation timeout))))
         (lambda ()
           (call-with-values thunk
             (lambda vals
               (perform-operation
                (choice-operation
                 (put-operation channel vals)
                 (sleep-operation timeout))))))
         #:unwind? #t)))

    (match (perform-operation
            (choice-operation
             (get-operation channel)
             (wrap-operation (sleep-operation timeout)
                             (const 'timeout))))
      ('timeout
       (on-timeout))
      (('exception . exn)
       (raise-exception exn))
      (vals
       (apply values vals)))))

(define &port-timeout-error
  (make-exception-type '&port-timeout-error
                       &external-error
                       '(thunk port)))

(define make-port-timeout-error
  (record-constructor &port-timeout-error))

(define port-timeout-error?
  (exception-predicate &port-timeout-error))
(set-procedure-property! port-timeout-error? 'documentation
  "Return @code{#t} if OBJ is a @code{&port-timeout-error}.")

(define &port-read-timeout-error
  (make-exception-type '&port-read-timeout-error
                       &port-timeout-error
                       '()))

(define make-port-read-timeout-error
  (record-constructor &port-read-timeout-error))

(define port-read-timeout-error?
  (exception-predicate &port-read-timeout-error))
(set-procedure-property! port-read-timeout-error? 'documentation
  "Return @code{#t} if OBJ is a @code{&port-read-timeout-error}.")

(define &port-write-timeout-error
  (make-exception-type '&port-write-timeout-error
                       &port-timeout-error
                       '()))

(define make-port-write-timeout-error
  (record-constructor &port-write-timeout-error))

(define port-write-timeout-error?
  (exception-predicate &port-write-timeout-error))
(set-procedure-property! port-write-timeout-error? 'documentation
  "Return @code{#t} if OBJ is a @code{&port-write-timeout-error}.")

(define (readable? port)
  "Test if PORT is readable."
  (= 1 (port-poll port "r" 0)))

(define (writable? port)
  "Test if PORT is writable."
  (= 1 (port-poll port "w" 0)))

(define (make-wait-operation ready? schedule-when-ready port
                             port-ready-fd this-procedure)
  (make-base-operation #f
                       (lambda _
                         (and (ready? port) values))
                       (lambda (flag sched resume)
                         (define (commit)
                           (match (atomic-box-compare-and-swap! flag 'W 'S)
                             ('W (resume values))
                             ('C (commit))
                             ('S #f)))
                         (schedule-when-ready
                          sched (port-ready-fd port) commit))))

(define (wait-until-port-readable-operation port)
  "Make an operation that will succeed when PORT is readable."
  (unless (input-port? port)
    (error "refusing to wait forever for input on non-input port"))
  (make-wait-operation readable? schedule-task-when-fd-readable port
                       port-read-wait-fd
                       wait-until-port-readable-operation))

(define (wait-until-port-writable-operation port)
  "Make an operation that will succeed when PORT is writable."
  (unless (output-port? port)
    (error "refusing to wait forever for output on non-output port"))
  (make-wait-operation writable? schedule-task-when-fd-writable port
                       port-write-wait-fd
                       wait-until-port-writable-operation))

(define* (with-port-timeouts thunk
                             #:key timeout
                             (read-timeout timeout)
                             (write-timeout timeout))
  "Run THUNK with per-operation I/O timeouts on all ports.  If any
read or write blocks for longer than the given number of seconds, an
exception is raised.

@code{#:timeout} sets both read and write timeouts.
@code{#:read-timeout} and @code{#:write-timeout} specify the timeout
for reads and writes respectively.  All three default to @code{#f} (no
timeout).

This procedure works both with fibers, and without fibers by using the
poll system call with a timeout.

On read timeout, raises @code{&port-read-timeout-error}.  On write
timeout, raises @code{&port-write-timeout-error}.  Both carry the
@code{thunk} and @code{port} fields from @code{&port-timeout-error}."
  (define (no-fibers-wait thunk port mode timeout)
    (define poll-timeout-ms 200)

    ;; When the GC runs, it restarts the poll syscall, but the timeout
    ;; remains unchanged! When the timeout is longer than the time
    ;; between the syscall restarting, I think this renders the
    ;; timeout useless. Therefore, this code uses a short timeout, and
    ;; repeatedly calls poll while watching the clock to see if it has
    ;; timed out overall.
    (let ((timeout-internal
           (+ (get-internal-real-time)
              (* internal-time-units-per-second timeout))))
      (let loop ((poll-value
                  (port-poll port mode poll-timeout-ms)))
        (if (= poll-value 0)
            (if (> (get-internal-real-time)
                   timeout-internal)
                (raise-exception
                 (if (string=? mode "r")
                     (make-port-read-timeout-error thunk port)
                     (make-port-write-timeout-error thunk port)))
                (loop (port-poll port mode poll-timeout-ms)))
            poll-value))))

  (parameterize
      ((current-read-waiter
        (lambda (port)
          (if (current-scheduler)
              (perform-operation
               (choice-operation
                (wait-until-port-readable-operation port)
                (wrap-operation
                 (sleep-operation read-timeout)
                 (lambda ()
                   (raise-exception
                    (make-port-read-timeout-error thunk port))))))
              (no-fibers-wait thunk port "r" read-timeout))))
       (current-write-waiter
        (lambda (port)
          (if (current-scheduler)
              (perform-operation
               (choice-operation
                (wait-until-port-writable-operation port)
                (wrap-operation
                 (sleep-operation write-timeout)
                 (lambda ()
                   (raise-exception
                    (make-port-write-timeout-error thunk port))))))
              (no-fibers-wait thunk port "w" write-timeout)))))
    (thunk)))
