Foutafhandeling

This commit is contained in:
2026-10-22 21:37:45 +02:00
parent f7f4d440b8
commit 5f0d835501
3 changed files with 155 additions and 5 deletions
+136 -5
View File
@@ -86,9 +86,13 @@
;;; Copies media in bounded chunks, reporting bytes written without waiting ;;; Copies media in bounded chunks, reporting bytes written without waiting
;;; for a full buffer; slow network streams therefore still show progress. ;;; for a full buffer; slow network streams therefore still show progress.
(define (copy-media-port input output progress!) (define (copy-media-port input output progress! #:idle-timeout [idle-timeout 60])
(let ((buffer (make-bytes 65536))) (let ((buffer (make-bytes 65536)))
(let loop ((received 0)) (let loop ((received 0))
(unless (sync/timeout idle-timeout input)
(error 'player-agent
"Download timeout: no data received for ~a seconds (~a bytes received)"
idle-timeout received))
(let ((count (read-bytes-avail! buffer input))) (let ((count (read-bytes-avail! buffer input)))
(unless (eof-object? count) (unless (eof-object? count)
(write-bytes buffer output 0 count) (write-bytes buffer output 0 count)
@@ -224,7 +228,7 @@
'format (let ((decoder (hash-ref full-state 'decoder #f))) 'format (let ((decoder (hash-ref full-state 'decoder #f)))
(if decoder (format "~a" decoder) "")) (if decoder (format "~a" decoder) ""))
'volume logical-volume 'volume logical-volume
'error 'null)) 'error (hash-ref agent-state 'error 'null)))
(when (and pending-auto-music-id (when (and pending-auto-music-id
(number? audible-music-id) (number? audible-music-id)
(= pending-auto-music-id audible-music-id)) (= pending-auto-music-id audible-music-id))
@@ -257,7 +261,7 @@
;;; Downloads one protected media resource to a temporary local file. ;;; Downloads one protected media resource to a temporary local file.
;;; A failed download closes its port and removes its partial file. ;;; A failed download closes its port and removes its partial file.
(define (download-media! token filename size) (define (download-media-once! token filename size)
(let* ((started (current-inexact-monotonic-milliseconds)) (let* ((started (current-inexact-monotonic-milliseconds))
(extension (extension
(or (path-get-extension (string->path filename)) #"")) (or (path-get-extension (string->path filename)) #""))
@@ -306,6 +310,22 @@
started))) started)))
target))) target)))
;;; Retries interrupted downloads twice before reporting a final failure.
;;; Each attempt removes its partial file; the media token stays unacknowledged.
(define (download-media! token filename size)
(let loop ((attempt 1))
(with-handlers
((exn:fail?
(λ (exception)
(if (< attempt 3)
(begin
(warn-player-agent "Download attempt ~a failed for ~a: ~a; retrying"
attempt filename (exn-message exception))
(sleep 1)
(loop (+ attempt 1)))
(raise exception)))))
(download-media-once! token filename size))))
;;; Scopes media identity to its server, source path, size and modification time. ;;; Scopes media identity to its server, source path, size and modification time.
;;; Older servers without version metadata only reuse the same media token. ;;; Older servers without version metadata only reuse the same media token.
(define (command-cache-key data) (define (command-cache-key data)
@@ -543,12 +563,28 @@
(λ (exception) (λ (exception)
(warn-player-agent "Command failed: ~a" (warn-player-agent "Command failed: ~a"
(exn-message exception)) (exn-message exception))
(when (and audio
(equal? (hash-ref command 'action) "play"))
(with-handlers
((exn:fail?
(λ (stop-exception)
(warn-player-agent
"Could not stop audio after failed play: ~a"
(exn-message stop-exception)))))
(audio-stop! audio)))
(with-agent-state (with-agent-state
(λ () (λ ()
(set! loading-media-key #f) (set! loading-media-key #f)
(when (equal? (hash-ref command 'action) "play")
(set! agent-state
(hash-set* agent-state
'state "stopped"
'position 0)))
(trim-cache!))) (trim-cache!)))
(set-agent-error! (exn-message exception))))) (set-agent-error! (exn-message exception)))))
(clear-agent-error!) ;; A queued prefetch must not erase a failed play's error.
(unless (equal? (hash-ref command 'action) "prefetch")
(clear-agent-error!))
(execute-command! command)) (execute-command! command))
(set! acknowledged-command (hash-ref command 'id)) (set! acknowledged-command (hash-ref command 'id))
(set! executing-command-id 0) (set! executing-command-id 0)
@@ -609,7 +645,102 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(module+ test (module+ test
(require rackunit) (require rackunit
racket/async-channel
web-server/http
web-server/http/json
web-server/web-server
(prefix-in lift: web-server/dispatchers/dispatch-lift))
;; Exercise real polling after three truncated downloads. A queued prefetch
;; must preserve the error, and a subsequent Stop must still be processed.
(test-case "failed download reports stopped and accepts subsequent commands"
(let* ((ready (make-async-channel))
(done (make-semaphore 0))
(attempts 0)
(prefetch-attempts 0)
(reports (make-hash))
(runtime #f)
(stop-server
(serve
#:port 0 #:listen-ip "127.0.0.1"
#:confirmation-channel ready #:connection-close? #t
#:dispatch
(lift:make
(λ (request)
(let ((path (url->string (request-uri request))))
(cond
((equal? path "/api/agent/register")
(response/jsexpr (hasheq)))
((equal? path "/api/agent/poll")
(let* ((data (bytes->jsexpr (request-post-data/raw request)))
(ack (hash-ref data 'ack))
(command
(case ack
((0) (hasheq 'id 1 'action "play"
'data (hasheq 'mediaToken "broken"
'filename "broken.flac"
'mediaSize 8)))
((1) (hasheq 'id 2 'action "prefetch"
'data (hasheq 'mediaToken "next"
'filename "next.flac"
'mediaSize 4)))
((2) (hasheq 'id 3 'action "stop"))
(else 'null))))
(hash-set! reports ack (hash-ref data 'state))
(when (= ack 3) (semaphore-post done))
(response/jsexpr (hasheq 'command command))))
(else
(when (regexp-match? #rx"broken$" path)
(set! attempts (+ attempts 1)))
(when (regexp-match? #rx"next$" path)
(set! prefetch-attempts (+ prefetch-attempts 1)))
(let ((body (if (and (regexp-match? #rx"next$" path)
(= prefetch-attempts 1))
#"12" #"1234")))
(response/output
(λ (output) (write-bytes body output))
#:headers
(list (header #"Content-Length"
(string->bytes/utf-8
(number->string (bytes-length body)))))))))))))))
(dynamic-wind
void
(λ ()
(let ((port (async-channel-get ready)))
(when (exn? port) (raise port))
(set! runtime
(make-player-agent-runtime
(format "http://127.0.0.1:~a" port) "Test" (make-string 64 #\a)))
((player-agent-runtime-start! runtime))
(check-not-false (sync/timeout 15 done))
(check-equal? attempts 3)
(check-equal? prefetch-attempts 2)
(let ((failed (hash-ref reports 1 (hasheq)))
(prefetched (hash-ref reports 2 (hasheq)))
(stopped (hash-ref reports 3 (hasheq))))
(check-equal? (hash-ref failed 'state #f) "stopped")
(check-regexp-match #rx"received 4 of 8 bytes"
(hash-ref failed 'error ""))
(check-equal? (hash-ref failed 'download #f) 'null)
(check-equal? (hash-ref prefetched 'error #f)
(hash-ref failed 'error ""))
(check-equal? (hash-ref stopped 'error #f) 'null)
(check-true ((player-agent-runtime-running? runtime))))))
(λ ()
(when runtime ((player-agent-runtime-shutdown! runtime)))
(stop-server)))))
;; An open stream that stops delivering bytes must not occupy the worker forever.
(let-values (((input writer) (make-pipe)))
(dynamic-wind
void
(λ ()
(check-exn #rx"Download timeout: no data received"
(λ ()
(copy-media-port input (open-output-bytes) void
#:idle-timeout 0.02))))
(λ () (close-input-port input) (close-output-port writer))))
;; Progress must arrive before EOF, even when a slow server has sent much ;; Progress must arrive before EOF, even when a slow server has sent much
;; less than one buffer. Binary data must reach the output unchanged. ;; less than one buffer. Binary data must reach the output unchanged.
+12
View File
@@ -98,6 +98,7 @@
(download-panel #f) (download-panel #f)
(download-message #f) (download-message #f)
(download-gauge #f) (download-gauge #f)
(error-message #f)
(name-field #f) (name-field #f)
(server-field #f) (server-field #f)
(connect-button #f) (connect-button #f)
@@ -214,6 +215,11 @@
(not (string=? format-name ""))) (not (string=? format-name "")))
format-name format-name
""))))) "")))))
(when error-message
(let ((message (hash-ref snapshot 'error 'null)))
(send error-message show (string? message))
(send error-message set-label
(if (string? message) (cap200 message) ""))))
(when download-panel (when download-panel
(send download-panel show (hash? download)) (send download-panel show (hash? download))
(when (hash? download) (when (hash? download)
@@ -431,6 +437,12 @@
(range 100) (range 100)
(stretchable-width #t))) (stretchable-width #t)))
(send download-panel show #f) (send download-panel show #f)
(set! error-message
(new ellipsis-msg%
(parent playback-panel)
(label "")
[ellipsis 'right] [auto-resize #t] [stretchable-width #t]))
(send error-message show #f)
(set! quit-button (set! quit-button
(new button% (new button%
(parent controls) (parent controls)
+7
View File
@@ -351,6 +351,13 @@ During downloads, including prefetching the next track, the GUI shows the
filename and received megabytes. When the server supplies the file size, filename and received megabytes. When the server supplies the file size,
it also shows the total size, percentage and a progress bar. The display it also shows the total size, percentage and a progress bar. The display
updates every half second and disappears when the download finishes or fails. updates every half second and disappears when the download finishes or fails.
Interrupted downloads are retried twice, starting again from the beginning.
After 60 seconds without incoming media data, the agent aborts the attempt
instead of waiting indefinitely. If all three attempts fail, a failed play
returns to the stopped state and the GUI displays the error, including byte
counts for an incomplete file. Background prefetching and audio status updates
preserve this message; a new playback command clears it. Polling continues,
so subsequent commands can still be processed.
The GUI and its @tt{racket-tray} system tray support the same ten languages The GUI and its @tt{racket-tray} system tray support the same ten languages
based on the operating-system language, with English as fallback. Closing or based on the operating-system language, with English as fallback. Closing or
minimizing the window hides it in the tray. The tray menu restores the window minimizing the window hides it in the tray. The tray menu restores the window