diff --git a/test/lisp/jsonrpc-resources/common.py b/test/lisp/jsonrpc-resources/common.py new file mode 100644 index 00000000000..37ae6c2b0f2 --- /dev/null +++ b/test/lisp/jsonrpc-resources/common.py @@ -0,0 +1,37 @@ +"""Common JSONRPC framing helpers for jsonrpc.el test servers.""" + +import json +import sys + + +def read_msg(): + """Read one Content-Length-framed JSON-RPC message from stdin.""" + headers = {} + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + text = line.decode('utf-8').rstrip('\r\n') + if not text: + break + if ':' in text: + k, _, v = text.partition(':') + headers[k.strip()] = v.strip() + n = int(headers.get('Content-Length', 0)) + if not n: + return None + return json.loads(sys.stdin.buffer.read(n).decode('utf-8')) + + +def write_msg(msg): + """Write one Content-Length-framed JSON-RPC message to stdout.""" + body = json.dumps(msg, ensure_ascii=False).encode('utf-8') + sys.stdout.buffer.write( + f'Content-Length: {len(body)}\r\n\r\n'.encode('utf-8') + body + ) + sys.stdout.buffer.flush() + + +def log(text): + """Write a log line to stderr.""" + print(f'[test-server] {text}', file=sys.stderr, flush=True) diff --git a/test/lisp/jsonrpc-resources/server-anxious-nested.py b/test/lisp/jsonrpc-resources/server-anxious-nested.py new file mode 100644 index 00000000000..34deb9a17fe --- /dev/null +++ b/test/lisp/jsonrpc-resources/server-anxious-nested.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Test server for scontrol-anxious-nested. + +Choreography (exercises the anxious-continuation mechanism): + + client -> server: taskA (id=1) + server -> client: callBack (id=1000) + server -> client: response taskA "done" <- anxious: arrives while followUp pending + client -> server: followUp (id=2) <- sent by rdispatcher for callBack + server -> client: response followUp "fw-ok" + client -> server: response callBack <- rdispatcher return value + +scontrol stack at deepest point: + ((:local 2) (:remote 1000) (:local 1)) + +The response-to-taskA arrives while (:local followUp-id) is the head, +so it is queued as an anxious continuation. When followUp completes, +the anxious entry is rescheduled via run-at-time and taskA resolves. +""" +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +from common import read_msg, write_msg, log + + +def main(): + while True: + msg = read_msg() + if msg is None: + break + mid = msg.get('id') + method = msg.get('method') + log(f'<- {method or "(response)"} id={mid}') + if method == 'harakiri': + log('-> harakiri: exiting cleanly') + break + elif method == 'taskA': + # Send callBack, then immediately respond to taskA without awaiting + # anything. The response-to-taskA will be queued as anxious on the + # client while its rdispatcher blocks waiting for followUp. + write_msg({'jsonrpc': '2.0', 'id': 1000, + 'method': 'callBack', 'params': {}}) + log('-> callBack id=1000') + write_msg({'jsonrpc': '2.0', 'id': mid, 'result': 'done'}) + log(f'-> (response taskA) id={mid}') + # followUp arrives next: the client's rdispatcher for callBack + # issues it as a nested sync request. + follow = read_msg() + fid = follow.get('id') if follow else None + log(f'<- followUp id={fid}') + write_msg({'jsonrpc': '2.0', 'id': fid, 'result': 'fw-ok'}) + log(f'-> (response followUp) id={fid}') + # Finally collect the callBack response (rdispatcher return value). + cb_resp = read_msg() + log(f'<- (response callBack) id={cb_resp.get("id") if cb_resp else None}') + + +if __name__ == '__main__': + main() diff --git a/test/lisp/jsonrpc-resources/server-harakiri.py b/test/lisp/jsonrpc-resources/server-harakiri.py new file mode 100644 index 00000000000..bed36ebf4ba --- /dev/null +++ b/test/lisp/jsonrpc-resources/server-harakiri.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Test server for shutdown-clean test. + +Waits for a 'harakiri' notification and then exits cleanly. +""" +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +from common import read_msg, log + + +def main(): + while True: + msg = read_msg() + if msg is None: + break + method = msg.get('method') + log(f'<- {method or "(response)"} id={msg.get("id")}') + if method == 'harakiri': + log('-> harakiri: exiting cleanly') + break + + +if __name__ == '__main__': + main() diff --git a/test/lisp/jsonrpc-resources/server-remote-during-sync.py b/test/lisp/jsonrpc-resources/server-remote-during-sync.py new file mode 100644 index 00000000000..4ced24b1ec7 --- /dev/null +++ b/test/lisp/jsonrpc-resources/server-remote-during-sync.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Test server for scontrol-remote-during-sync. + +Choreography (tests bug#80623): + + client -> server: taskA (id=1) + server -> client: showInfo (id=1000) <- before responding to taskA + server -> client: response taskA "done" + client -> server: response showInfo <- from rdispatcher + +The (:remote 1000) entry on scontrol defers the response-to-taskA +continuation until showInfo dispatch completes. +""" +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +from common import read_msg, write_msg, log + + +def main(): + while True: + msg = read_msg() + if msg is None: + break + mid = msg.get('id') + method = msg.get('method') + log(f'<- {method or "(response)"} id={mid}') + if method == 'harakiri': + log('-> harakiri: exiting cleanly') + break + elif method == 'taskA': + # Send showInfo request BEFORE responding to taskA. + write_msg({'jsonrpc': '2.0', 'id': 1000, + 'method': 'showInfo', 'params': {}}) + log('-> showInfo id=1000') + write_msg({'jsonrpc': '2.0', 'id': mid, 'result': 'done'}) + log(f'-> (response taskA) id={mid}') + # Collect the client's response to showInfo. + resp = read_msg() + log(f'<- (response showInfo) id={resp.get("id") if resp else None}') + + +if __name__ == '__main__': + main() diff --git a/test/lisp/jsonrpc-resources/server-remote-error.py b/test/lisp/jsonrpc-resources/server-remote-error.py new file mode 100644 index 00000000000..daf5c4a60d6 --- /dev/null +++ b/test/lisp/jsonrpc-resources/server-remote-error.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Test server for scontrol-remote-error. + +Choreography (anxious continuation survives an rdispatcher error): + + client -> server: taskA (id=1) + server -> client: badMethod (id=1000) <- rdispatcher signals jsonrpc-error + server -> client: response taskA "ok" <- anxious during badMethod dispatch + client -> server: error response badMethod {code: -32601} + +Even though the remote-request dispatch produces an error reply, the +anxious continuation for taskA must still fire and resolve to "ok". +""" +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +from common import read_msg, write_msg, log + + +def main(): + while True: + msg = read_msg() + if msg is None: + break + mid = msg.get('id') + method = msg.get('method') + log(f'<- {method or "(response)"} id={mid}') + if method == 'harakiri': + log('-> harakiri: exiting cleanly') + break + elif method == 'taskA': + # Send badMethod BEFORE responding to taskA; the client rdispatcher + # will signal a jsonrpc-error for it. + write_msg({'jsonrpc': '2.0', 'id': 1000, + 'method': 'badMethod', 'params': {}}) + log('-> badMethod id=1000') + write_msg({'jsonrpc': '2.0', 'id': mid, 'result': 'ok'}) + log(f'-> (response taskA) id={mid}') + # Collect the error response to badMethod. + err = read_msg() + log(f'<- (error response badMethod): {err}') + + +if __name__ == '__main__': + main() diff --git a/test/lisp/jsonrpc-tests.el b/test/lisp/jsonrpc-tests.el index ca26f3c5a30..0a7af1397e8 100644 --- a/test/lisp/jsonrpc-tests.el +++ b/test/lisp/jsonrpc-tests.el @@ -252,5 +252,125 @@ (should (eq 2 n-deferred-2)) (should (eq 0 (hash-table-count (jsonrpc--deferred-actions conn))))))) + +;;; Tests using Python subprocesses (scontrol / anxious mechanism) +;;; + +(defconst jsonrpc--test-dir + (file-name-directory (or load-file-name buffer-file-name)) + "Directory of this test file, captured at load time.") + +(cl-defmacro jsonrpc--with-python-fixture ((script conn &rest initargs) &body body) + "Start SCRIPT under python3 as a pipe subprocess, bind connection to CONN. +SCRIPT is a path relative to this file's directory. +INITARGS are passed to `make-instance' for `jsonrpc-process-connection'." + (declare (indent 1)) + `(let ((,conn nil)) + (unwind-protect + (progn + (setq ,conn + (make-instance + 'jsonrpc-process-connection + :name "jsonrpc-python-test" + :process (make-process + :name "jsonrpc-python-test" + :command (list "python3" + (expand-file-name + ,script + jsonrpc--test-dir)) + :connection-type 'pipe + :noquery t) + ,@initargs)) + (with-timeout (5 + (when ,conn + (let ((buf (jsonrpc--events-buffer ,conn))) + (when (buffer-live-p buf) + (if noninteractive + (progn + (message "contents of `%s':" (buffer-name buf)) + (princ (with-current-buffer buf (buffer-string)) + #'external-debugging-output)) + (message "Preserved for inspection: %s" + (buffer-name buf)))))) + (ert-fail "Test timed out after 5s")) + ,@body)) + (when ,conn + (ignore-errors + (jsonrpc-notify ,conn 'harakiri nil) + (kill-buffer (jsonrpc--events-buffer ,conn)) + (jsonrpc-shutdown ,conn)))))) + +(ert-deftest scontrol-remote-during-sync () + "Server sends a remote request before the sync-request response (bug#80623). +The (:remote ID) entry on scontrol defers the response-to-taskA throw +until the showInfo dispatch completes, preventing a spurious -32603." + (skip-unless (executable-find "python3")) + (skip-when (eq system-type 'windows-nt)) + (jsonrpc--with-python-fixture + ("jsonrpc-resources/server-remote-during-sync.py" conn + :request-dispatcher + (lambda (_conn method _params) + (pcase method + ('showInfo "ack") + (_ (error "unexpected method: %s" method))))) + (should (equal "done" (jsonrpc-request conn 'taskA [] :timeout 5))))) + +(ert-deftest scontrol-anxious-nested () + "Anxious continuation: rdispatcher issues a nested sync request. +The outer sync response arrives while the inner (followUp) is pending, +gets queued as anxious, and is rescheduled via run-at-time once the +inner completes. Exercises the full three-deep scontrol stack: + ((:local followUp-id) (:remote callBack-id) (:local taskA-id))" + (skip-unless (executable-find "python3")) + (skip-when (eq system-type 'windows-nt)) + (let (followup-result) + (jsonrpc--with-python-fixture + ("jsonrpc-resources/server-anxious-nested.py" conn + :request-dispatcher + (lambda (conn method _params) + (pcase method + ('callBack + (setq followup-result + (jsonrpc-request conn 'followUp [] :timeout 5)) + followup-result) + (_ (error "unexpected method: %s" method))))) + (should (equal "done" (jsonrpc-request conn 'taskA [] :timeout 5))) + (should (equal "fw-ok" followup-result))))) + +(ert-deftest scontrol-remote-error () + "Anxious continuation fires even when the rdispatcher signals a jsonrpc-error. +The (:remote ID) unwind-protect calls jsonrpc--continue regardless of +whether dispatch succeeded or produced an error reply." + (skip-unless (executable-find "python3")) + (skip-when (eq system-type 'windows-nt)) + (jsonrpc--with-python-fixture + ("jsonrpc-resources/server-remote-error.py" conn + :request-dispatcher + (lambda (_conn method _params) + (pcase method + ('badMethod + (signal 'jsonrpc-error + '((jsonrpc-error-message . "method not allowed") + (jsonrpc-error-code . -32601)))) + (_ (error "unexpected method: %s" method))))) + (should (equal "ok" (jsonrpc-request conn 'taskA [] :timeout 5))))) + +(ert-deftest shutdown-clean-after-notification () + "Server exits cleanly after harakiri notification. +`jsonrpc-shutdown' should not emit a \"Sentinel hasn't run\" warning." + (skip-unless (executable-find "python3")) + (skip-when (eq system-type 'windows-nt)) + (let (warned) + (cl-letf (((symbol-function 'jsonrpc--warn) + (lambda (fmt &rest args) + (setq warned (apply #'format fmt args))))) + (jsonrpc--with-python-fixture + ("jsonrpc-resources/server-harakiri.py" conn) + (jsonrpc-notify conn 'harakiri nil) + ;; Give the server time to exit before shutdown checks the sentinel. + (accept-process-output nil 0.3) + (jsonrpc-shutdown conn))) + (should-not warned))) + (provide 'jsonrpc-tests) ;;; jsonrpc-tests.el ends here