Jsonrpc: add new tests using Python subprocesses

Most of these tests are for the scontrol/"anxious continuation"
mechanism

The new ERT tests use Python subprocesses via stdin/stdout pipe as
JSONRPC endpoints.  A shared framing library lives in
jsonrpc-resources/common.py.

* test/lisp/jsonrpc-tests.el (jsonrpc--test-dir): New constant.
(jsonrpc--with-python-fixture): New macro.
(scontrol-remote-during-sync): New test.
(scontrol-anxious-nested): New test.
(scontrol-remote-error): New test.
(shutdown-clean-after-notification): New test.

* test/lisp/jsonrpc-resources/common.py: New file.

* test/lisp/jsonrpc-resources/server-remote-during-sync.py: New file.

* test/lisp/jsonrpc-resources/server-anxious-nested.py: New file.

* test/lisp/jsonrpc-resources/server-remote-error.py: New file.

* test/lisp/jsonrpc-resources/server-harakiri.py: New file.
This commit is contained in:
João Távora 2026-04-24 10:26:01 +01:00
parent 6800aba396
commit e09d8c4d78
6 changed files with 326 additions and 0 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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