diff --git a/test/jupyter-test.el b/test/jupyter-test.el index 5f3a93c..37c4523 100644 --- a/test/jupyter-test.el +++ b/test/jupyter-test.el @@ -468,6 +468,58 @@ msg) (cons "idle" "foo"))))) +;;; Channels + +(ert-deftest jupyter-zmq-channel () + :tags '(channels zmq) + (let* ((port (car (jupyter-available-local-ports 1))) + (channel (jupyter-zmq-channel + :type :shell + :endpoint (format "tcp://127.0.0.1:%s" port)))) + (ert-info ("Starting the channel") + (should-not (jupyter-alive-p channel)) + (jupyter-start channel :identity "foo") + (should (jupyter-alive-p channel)) + (should (equal (zmq-socket-get (oref channel socket) + zmq-ROUTING-ID) + "foo"))) + (ert-info ("Stopping the channel") + (let ((sock (oref channel socket))) + (jupyter-stop channel) + (should-not (jupyter-alive-p channel)) + ;; Ensure the socket was disconnected + (should-error (zmq-send sock "foo" zmq-NOBLOCK) :type 'zmq-EAGAIN))))) + +(ert-deftest jupyter-hb-channel () + :tags '(channels) + (should (eq (oref (jupyter-hb-channel) type) :hb)) + (let* ((port (car (jupyter-available-local-ports 1))) + (channel (jupyter-hb-channel + :endpoint (format "tcp://127.0.0.1:%s" port) + :session (jupyter-session))) + (died-cb-called nil) + (jupyter-hb-max-failures 1)) + (oset channel time-to-dead 0.1) + (should-not (jupyter-alive-p channel)) + (should-not (jupyter-hb-beating-p channel)) + (should (oref channel paused)) + (oset channel beating t) + (jupyter-start channel) + (jupyter-hb-on-kernel-dead channel (lambda () (setq died-cb-called t))) + (should (jupyter-alive-p channel)) + ;; `jupyter-hb-unpause' needs to explicitly called + (should (oref channel paused)) + (jupyter-hb-unpause channel) + (sleep-for 0.2) + ;; It seems the timers are run after returning from the first `sleep-for' + ;; call. + (sleep-for 0.1) + (should (oref channel paused)) + (should-not (oref channel beating)) + (should died-cb-called) + (should (jupyter-alive-p channel)) + (should-not (jupyter-hb-beating-p channel)))) + ;;; GC (ert-deftest jupyter-weak-ref () @@ -517,6 +569,151 @@ (lambda (_) nil))) (should-error (jupyter-locate-python)))) +;; FIXME: Revisit after transition +(ert-deftest jupyter-kernel-process () + :tags '(kernel) + ;; TODO: `jupyter-do-interrupt' + (ert-info ("`jupyter-do-launch', `jupyter-do-shutdown'") + (cl-macrolet + ((confirm-shutdown-state + () + `(progn + (should-not (jupyter-alive-p kernel)) + (should (jupyter-kernel-spec kernel)) + (should-not (jupyter-kernel-session kernel)) + (should-not (process-live-p (jupyter-process kernel))))) + (confirm-launch-state + () + `(progn + (should (jupyter-alive-p kernel)) + (should (jupyter-kernel-spec kernel)) + (should (jupyter-kernel-session kernel)) + (should (process-live-p (jupyter-process kernel)))))) + (let ((kernel (jupyter-kernel-process + :spec (jupyter-guess-kernelspec "python")))) + (confirm-shutdown-state) + (jupyter-launch kernel) + (confirm-launch-state) + (jupyter-do-shutdown kernel) + (confirm-shutdown-state)))) +;; (let (called) +;; (let* ((plist '(:argv ["sleep" "60"] :env nil :interrupt_mode "signal")) +;; (kernel (jupyter-kernel +;; :spec (make-jupyter-kernelspec +;; :name "sleep" +;; :plist plist)))) +;; (let ((jupyter-long-timeout 0.01)) +;; (jupyter-launch kernel)) +;; (cl-letf (((symbol-function #'interrupt-process) +;; (lambda (&rest args) +;; (setq called t)))) +;; (jupyter-do-interrupt kernel)) +;; (should called) +;; (setq called nil) +;; (jupyter-do-shutdown kernel))) + ) + + + +;; (let ((kernel (jupyter--kernel-process +;; :spec (jupyter-guess-kernelspec "python")))) +;; (ert-info ("Session set after kernel starts") +;; (should-not (jupyter-kernel-alive-p kernel)) +;; (jupyter-start-kernel kernel) +;; (should (jupyter-kernel-alive-p kernel)) +;; (should (oref kernel session)) +;; (jupyter-kill-kernel kernel) +;; (should-not (jupyter-kernel-alive-p kernel))) +;; (ert-info ("Can we communicate?") +;; (let ((manager (jupyter-kernel-manager :kernel kernel))) +;; (jupyter-start-kernel manager) +;; (unwind-protect +;; (let ((jupyter-current-client +;; (jupyter-make-client manager 'jupyter-kernel-client))) +;; (jupyter-start-channels jupyter-current-client) +;; (unwind-protect +;; (progn +;; (jupyter-wait-until-startup jupyter-current-client) +;; (should (equal (jupyter-eval "1 + 1") "2"))) +;; (jupyter-stop-channels jupyter-current-client))) +;; (jupyter-shutdown-kernel manager))))) + +(ert-deftest jupyter-delete-connection-files () + :tags '(kernel process) + (let ((jupyter--kernel-processes + (cl-loop repeat 2 + collect (list nil (make-temp-file "jupyter-test"))))) + (jupyter-delete-connection-files) + (should-not + (cl-loop for (_ conn-file) in jupyter--kernel-processes + thereis (file-exists-p conn-file))))) + +(ert-deftest jupyter-kernel-process/connection-file-management () + :tags '(kernel process) + (let (jupyter--kernel-processes) + (pcase-let ((`(,kernelA ,kernelB) + (cl-loop + repeat 2 + collect (jupyter-kernel :spec "python")))) + (jupyter-launch kernelA) + (should (= (length jupyter--kernel-processes) 1)) + (unwind-protect + (pcase-let* ((`(,processA ,conn-fileA) (car jupyter--kernel-processes)) + (process-bufferA (process-buffer processA))) + (should (eq processA (jupyter-kernel-process-process kernelA))) + (jupyter-do-shutdown kernelA) + (should-not (process-live-p processA)) + (should (file-exists-p conn-fileA)) + (should (buffer-live-p process-bufferA)) + (jupyter-launch kernelB) + (should-not (buffer-live-p process-bufferA)) + (should-not (file-exists-p conn-fileA)) + (should (= (length jupyter--kernel-processes) 1)) + (pcase-let ((`(,processB ,conn-fileB) + (car jupyter--kernel-processes))) + (should-not (eq processA processB)) + (should-not (string= conn-fileA conn-fileB)))) + (jupyter-do-shutdown kernelA) + (jupyter-do-shutdown kernelB))))) + +(ert-deftest jupyter-kernel-process/on-unexpected-exit () + :tags '(kernel process) + (skip-unless nil) + (let ((kernel (jupyter-kernel :spec "python")) + called) + (jupyter-launch + kernel (lambda (kernel) + (setq called t))) + (let ((process (jupyter--kernel-process kernel))) + (kill-process process) + (sleep-for 0.01) + (should called)))) + +(ert-deftest jupyter-session-with-random-ports () + :tags '(kernel) + (let ((session (jupyter-session-with-random-ports))) + (should (jupyter-session-p session)) + (let ((process-exists + (cl-loop + for p in (process-list) + thereis (string= (process-name p) + "jupyter-session-with-random-ports")))) + (should-not process-exists)) + (cl-destructuring-bind (&key hb_port stdin_port + control_port shell_port iopub_port + &allow-other-keys) + (jupyter-session-conn-info session) + ;; Verify the ports are open for us + (cl-loop + for port in (list hb_port stdin_port + control_port shell_port iopub_port) + for proc = (make-network-process + :name "jupyter-test" + :server t + :host "127.0.0.1" + :service port) + do (delete-process proc))))) + (ert-deftest jupyter-expand-environment-variables () :tags '(kernel) (let ((process-environment @@ -582,6 +779,89 @@ ;;; Client +;; TODO: Different values of the session argument +;; +;; FIXME: Re-work after refactoring the kernelspec -> connectable kernel code paths. +(ert-deftest jupyter-comm-initialize () + :tags '(client init) + (skip-unless nil) + (jupyter-test-with-python-client client + (with-slots (session kcomm) client + (ert-info ("Client session") + (should (string= (jupyter-session-key session) + (plist-get conn-info :key))) + (should (equal (jupyter-session-conn-info session) + conn-info))) + (ert-info ("Heartbeat channel initialized") + (should (eq session (oref (oref kcomm hb) session))) + (should (string= (oref (oref kcomm hb) endpoint) + (format "tcp://127.0.0.1:%d" + (plist-get conn-info :hb_port))))) + (ert-info ("Shell, iopub, stdin initialized") + (cl-loop + for channel in '(:shell :iopub :stdin) + for port_sym = (intern (concat (symbol-name channel) "_port")) + do + (should (plist-member (plist-get channels channel) :alive-p)) + (should (plist-member (plist-get channels channel) :endpoint)) + (should + (string= (plist-get (plist-get channels channel) :endpoint) + (format "tcp://127.0.0.1:%d" + (plist-get conn-info port_sym)))))) + (ert-info ("Initialization stops any running channels") + (should-not (jupyter-channels-running-p client)) + (jupyter-start-channels client) + (should (jupyter-channels-running-p client)) + (jupyter-comm-initialize client conn-info) + (should-not (jupyter-channels-running-p client))) + (ert-info ("Invalid signature scheme") + (plist-put conn-info :signature_scheme "hmac-foo") + (should-error (jupyter-comm-initialize client conn-info)))))) + +(ert-deftest jupyter-write-connection-file () + :tags '(client) + (let* ((conn-info '(:kernel_name "python" + :transport "tcp" :ip "127.0.0.1" + :signature_scheme "hmac-sha256" + :key "00a2cadb-3da7-45d2-b394-dbd01b5f80eb" + :hb_port 45473 :stdin_port 40175 + :control_port 36301 + :shell_port 39263 :iopub_port 36731)) + (conn-file (jupyter-write-connection-file + (jupyter-session + :conn-info conn-info)))) + (should (file-exists-p conn-file)) + (should (string= (file-name-directory conn-file) (jupyter-runtime-directory))) + (should (equal (jupyter-read-plist conn-file) conn-info)))) + +;; FIXME: Revisit after transition +(ert-deftest jupyter-client-channels () + :tags '(client channels) + (skip-unless nil) + (ert-info ("Starting/stopping channels") + ;; FIXME: Without a new client, I'm getting + ;; + ;; (zmq-EFSM "Operation cannot be accomplished in current state") + ;; + ;; on the `jupyter-connect-repl' test pretty consistently. + (let ((jupyter-test-with-new-client t)) + (jupyter-test-with-python-client client + (jupyter-stop-channels client) + (cl-loop + for channel in '(:hb :shell :iopub :stdin) + for alive-p = (jupyter-alive-p client channel) + do (should-not alive-p)) + (jupyter-start-channels client) + (cl-loop + for channel in '(:hb :shell :iopub :stdin) + for alive-p = (jupyter-alive-p client channel) + do (should alive-p)) + (jupyter-stop-channels client) + (cl-loop + for channel in '(:hb :shell :iopub :stdin) + for alive-p = (jupyter-alive-p client channel) + do (should-not alive-p)))))) + (ert-deftest jupyter-inhibited-handlers () :tags '(client handlers) (jupyter-test-with-python-client client @@ -658,6 +938,182 @@ (should (jupyter-request-idle-p req)) (should (null jupyter-test-idle-sync-hook))))) +;;; IOloop + +(ert-deftest jupyter-ioloop-lifetime () + :tags '(ioloop) + (let ((ioloop (jupyter-ioloop)) + (jupyter-default-timeout 2)) + (should-not (process-live-p (oref ioloop process))) + (jupyter-ioloop-start ioloop #'ignore) + (should (equal (jupyter-ioloop-last-event ioloop) '(start))) + (with-slots (process) ioloop + (should (process-live-p process)) + (jupyter-ioloop-stop ioloop) + (should (equal (jupyter-ioloop-last-event ioloop) '(quit))) + (sleep-for 0.1) + (should-not (process-live-p process))))) + +(defvar jupyter-ioloop-test-handler-called nil + "Flag variable used for testing the `juyter-ioloop'.") + +(defun jupyter-test-ioloop-start (ioloop) + (jupyter-ioloop-start + ioloop (lambda (event) + (should (equal (cadr event) "message")) + (setq jupyter-ioloop-test-handler-called t)))) + +(ert-deftest jupyter-ioloop-wait-until () + :tags '(ioloop) + (let ((ioloop (jupyter-ioloop))) + (should-not (jupyter-ioloop-last-event ioloop)) + (jupyter-test-ioloop-start ioloop) + (should (equal (jupyter-ioloop-last-event ioloop) '(start))) + (jupyter-ioloop-stop ioloop))) + +(ert-deftest jupyter-ioloop-callbacks () + :tags '(ioloop) + (ert-info ("Callback added before starting the ioloop") + (let ((ioloop (jupyter-ioloop))) + (setq jupyter-ioloop-test-handler-called nil) + (jupyter-ioloop-add-callback ioloop + `(lambda () (zmq-prin1 (list 'test "message")))) + (jupyter-test-ioloop-start ioloop) + (jupyter-ioloop-stop ioloop) + (should jupyter-ioloop-test-handler-called))) + (ert-info ("Callback added after starting the ioloop") + (let ((ioloop (jupyter-ioloop))) + (setq jupyter-ioloop-test-handler-called nil) + (jupyter-test-ioloop-start ioloop) + (should (process-live-p (oref ioloop process))) + (jupyter-ioloop-add-callback ioloop + `(lambda () (zmq-prin1 (list 'test "message")))) + (jupyter-ioloop-wait-until ioloop 'test #'identity) + (jupyter-ioloop-stop ioloop) + (should jupyter-ioloop-test-handler-called)))) + +(ert-deftest jupyter-ioloop-setup () + :tags '(ioloop) + (let ((ioloop (jupyter-ioloop))) + (setq jupyter-ioloop-test-handler-called nil) + (jupyter-ioloop-add-setup ioloop + (zmq-prin1 (list 'test "message"))) + (jupyter-test-ioloop-start ioloop) + (jupyter-ioloop-stop ioloop) + (should jupyter-ioloop-test-handler-called))) + +(ert-deftest jupyter-ioloop-teardown () + :tags '(ioloop) + (let ((ioloop (jupyter-ioloop))) + (setq jupyter-ioloop-test-handler-called nil) + (jupyter-ioloop-add-teardown ioloop + (zmq-prin1 (list 'test "message"))) + (jupyter-test-ioloop-start ioloop) + (jupyter-ioloop-stop ioloop) + (should jupyter-ioloop-test-handler-called))) + +(ert-deftest jupyter-ioloop-add-event () + :tags '(ioloop) + (let ((ioloop (jupyter-ioloop))) + (setq jupyter-ioloop-test-handler-called nil) + (jupyter-ioloop-add-event ioloop test (data) + "Echo DATA back to the parent process." + (list 'test data)) + (jupyter-test-ioloop-start ioloop) + (jupyter-send ioloop 'test "message") + (jupyter-ioloop-stop ioloop) + (should jupyter-ioloop-test-handler-called))) + +(ert-deftest jupyter-channel-ioloop-send-event () + :tags '(ioloop) + (jupyter-test-channel-ioloop + (ioloop (jupyter-zmq-channel-ioloop)) + (cl-letf (((symbol-function #'jupyter-send) + (lambda (_channel _msg-type _msg msg-id) msg-id))) + (setq jupyter-channel-ioloop-session (jupyter-session :key "foo")) + (push (jupyter-zmq-channel :type :shell) jupyter-channel-ioloop-channels) + (let* ((msg-id (jupyter-new-uuid)) + (event `(list 'send :shell :execute-request '(msg) ,msg-id))) + (jupyter-test-ioloop-eval-event ioloop event) + (ert-info ("Return value to parent process") + (let ((result (read (buffer-string)))) + (should (equal result `(sent :shell ,msg-id))))))))) + +(ert-deftest jupyter-channel-ioloop-start-channel-event () + :tags '(ioloop) + (jupyter-test-channel-ioloop + (ioloop (jupyter-zmq-channel-ioloop)) + (setq jupyter-channel-ioloop-session (jupyter-session :key "foo")) + (let ((channel-endpoint "tcp://127.0.0.1:5555")) + (ert-info ("start-channel event creates channel") + (should (null jupyter-channel-ioloop-channels)) + (let ((event `(list 'start-channel :shell ,channel-endpoint))) + (jupyter-test-ioloop-eval-event ioloop event)) + (should-not (null jupyter-channel-ioloop-channels)) + (let ((channel (object-assoc :shell :type jupyter-channel-ioloop-channels))) + (should (jupyter-zmq-channel-p channel)))) + (let ((channel (object-assoc :shell :type jupyter-channel-ioloop-channels))) + (with-slots (type socket endpoint) channel + (ert-info ("Verify the requested channel was started") + (should (eq type :shell)) + (should (zmq-socket-p socket)) + (should (equal endpoint channel-endpoint)) + (should (equal (zmq-socket-get socket zmq-LAST-ENDPOINT) channel-endpoint)) + (ert-info ("Identity of socket matches session") + (should (equal (zmq-socket-get socket zmq-IDENTITY) + (jupyter-session-id jupyter-channel-ioloop-session))))) + (ert-info ("Ensure the channel was added to the poller") + ;; FIXME: Does it make sense to have this side effect as part of starting + ;; a channel? It makes it so that we don't reference any `zmq' functions + ;; in `jupyter-channel-ioloop'. + (should-error + (zmq-poller-add jupyter-ioloop-poller socket (list zmq-POLLIN)) + :type 'zmq-EINVAL))) + (ert-info ("Return value to parent process") + (let ((result (read (buffer-string)))) + (should (equal result `(start-channel :shell))))))))) + +(ert-deftest jupyter-channel-ioloop-stop-channel-event () + :tags '(ioloop) + (jupyter-test-channel-ioloop + (ioloop (jupyter-zmq-channel-ioloop)) + (setq jupyter-channel-ioloop-session (jupyter-session :key "foo")) + (let ((event `(list 'start-channel :shell "tcp://127.0.0.1:5556"))) + (jupyter-test-ioloop-eval-event ioloop event) + (erase-buffer)) + (let* ((channel (object-assoc :shell :type jupyter-channel-ioloop-channels)) + (socket (oref channel socket))) + (ert-info ("Verify the requested channel stops") + (should (jupyter-alive-p channel)) + (should (progn (zmq-poller-modify + jupyter-ioloop-poller + (oref channel socket) (list zmq-POLLIN zmq-POLLOUT)) + t)) + (jupyter-test-ioloop-eval-event ioloop `(list 'stop-channel :shell)) + (should-not (jupyter-alive-p channel))) + (ert-info ("Ensure the channel was removed from the poller") + (should-error + (zmq-poller-modify jupyter-ioloop-poller socket (list zmq-POLLIN)) + :type 'zmq-EINVAL)) + (ert-info ("Return value to parent process") + (let ((result (read (buffer-string)))) + (should (equal result `(stop-channel :shell)))))))) + +(ert-deftest jupyter-zmq-channel-ioloop-send-fast () + :tags '(ioloop queue) + (jupyter-test-with-python-client client + (let ((jupyter-current-client client)) + (jupyter-send client :execute-request :code "1 + 1") + (jupyter-send client :execute-request :code "1 + 1") + (jupyter-send client :execute-request :code "1 + 1") + (let ((req (jupyter-send client :execute-request :code "1 + 1"))) + (should + (equal + (jupyter-message-data + (jupyter-wait-until-received :execute-result req jupyter-long-timeout) + :text/plain) + "2")))))) + ;;; Completion (ert-deftest jupyter-completion-number-p () diff --git a/test/test-helper.el b/test/test-helper.el index 2bbbf95..aa29b45 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -26,6 +26,9 @@ ;;; Code: +(require 'zmq) +(require 'jupyter-zmq-channel-ioloop) +(require 'jupyter-kernel-process) (require 'jupyter-repl) (require 'jupyter-server) (require 'jupyter-org-client) @@ -258,6 +261,29 @@ running BODY." `(jupyter-test-with-kernel-repl "python" ,client ,@body)) +(defun jupyter-test-ioloop-eval-event (ioloop event) + (eval + `(progn + ,@(oref ioloop setup) + ,(jupyter-ioloop--event-dispatcher ioloop event)))) + +(defmacro jupyter-test-channel-ioloop (ioloop &rest body) + (declare (indent 1)) + (let ((var (car ioloop)) + (val (cadr ioloop))) + (with-temp-buffer + `(let* ((,var ,val) + (standard-output (current-buffer)) + (jupyter-channel-ioloop-channels nil) + (jupyter-channel-ioloop-session nil) + ;; Needed so that `jupyter-ioloop-environment-p' passes + (jupyter-ioloop-stdin t) + (jupyter-ioloop-poller (zmq-poller))) + (unwind-protect + (progn ,@body) + (zmq-poller-destroy jupyter-ioloop-poller) + (jupyter-ioloop-stop ,var)))))) + (defmacro jupyter-test-rest-api-request (bodyform &rest check-forms) "Replace the body of `url-retrieve*' with CHECK-FORMS, evaluate BODYFORM. For `url-retrieve', the callback will be called with a nil status." @@ -386,6 +412,29 @@ message contents." (jupyter-test-wait-until-idle-repl jupyter-current-client)) +(defun jupyter-test-conn-info-plist () + "Return a connection info plist suitable for testing." + (let* ((ports + (cl-loop + with ports = (jupyter-available-local-ports 5) + for c in '(:shell :hb :iopub :stdin :control) + collect c and collect (pop ports)))) + `(:shell_port + ,(plist-get ports :shell) + :key "8671b7e4-5656e6c9d24edfce81916780" + :hb_port + ,(plist-get ports :hb) + :kernel_name "python" + :control_port + ,(plist-get ports :control) + :signature_scheme "hmac-sha256" + :ip "127.0.0.1" + :stdin_port + ,(plist-get ports :stdin) + :transport "tcp" + :iopub_port + ,(plist-get ports :iopub)))) + (defun jupyter-test-text-has-property (prop val &optional positions) "Ensure PROP has VAL for text at POSITIONS. It is an error if any text not at POSITIONS has PROP. A nil value @@ -592,6 +641,14 @@ see the documentation on the --NotebookApp.password argument." (process-buffer (car jupyter-test-notebook)) (buffer-string))))))) +(defvar jupyter-test-zmq-sockets (make-hash-table :weakness 'key)) + +(advice-add 'zmq-socket + :around (lambda (&rest args) + (let ((sock (apply args))) + (prog1 sock + (puthash sock t jupyter-test-zmq-sockets))))) + ;; Do lots of cleanup to avoid core dumps on Travis due to epoll reconnect ;; attempts. (add-hook @@ -601,6 +658,12 @@ see the documentation on the --NotebookApp.password argument." (cl-loop for client in (jupyter-all-objects 'jupyter--clients) do (ignore-errors (jupyter-shutdown-kernel client))) - (ignore-errors (delete-process (car jupyter-test-notebook))))) + (ignore-errors (delete-process (car jupyter-test-notebook))) + (cl-loop + for sock being the hash-keys of jupyter-test-zmq-sockets do + (ignore-errors + (zmq-set-option sock zmq-LINGER 0) + (zmq-close sock))) + (ignore-errors (zmq-context-terminate (zmq-current-context))))) ;;; test-helper.el ends here