11 Commits

Author SHA1 Message Date
6f832cd67b validation: cover nil/None return from lua_func/py_func
Two new steps per language: function returning nothing and function
returning explicit nil/None. Both tagged $(test)_PASS — they would
have failed before the lua nil fix (Lua side reported nil result as
error). Python side already worked but is covered for parity.
2026-05-17 18:13:03 +02:00
ff46886865 lua_func: nil return is not an error
_handle_request was using the 1st pcall return as the error
discriminator, so any Lua function returning nothing (e.g. long_wait
in the example) was reported as failed. Discriminate on the 2nd
return (err) instead, and encode nil result as cjson.null so the
returned_value field stays present in the JSON-RPC response.
2026-05-17 18:04:51 +02:00
50d183d191 removed lua param, useless. 2026-05-17 10:43:25 +02:00
2177715641 examples: long_wait py_func/lua_func to exercise Stop
Two extra steps in example_simple.tum that sleep for 10s, used to
verify that pressing Stop interrupts engaged blocking steps.
2026-05-17 10:42:49 +02:00
a728f561be Make Stop interrupt blocking steps promptly
console.read_until polls a should_stop callback in 0.2s chunks across
all protocols. py_func/lua_func override stop() to tear down the worker
and wake the parent RPC wait. json_rpc adapters honor should_stop too.
Engaged leaf steps now report FAILURE on stop (sleep no-dialog was
silently SUCCESS).
2026-05-17 10:42:40 +02:00
116e528a7d Simplify the Start Stop Pause process (v-and-v/testium#20) 2026-05-16 13:36:18 +02:00
cc744e17a1 Adding ensurepip verification for the build environnement (required by venv) 2026-05-16 13:29:37 +02:00
ab39b49558 now the release note and the manual are copied into dist with build_all 2026-05-13 21:24:35 +02:00
95275c4418 Merge branch 'main' of ssh://git.beafrancois.fr:8328/v-and-v/testium 2026-05-13 14:09:41 +02:00
dee8d4a682 generic design elements 2026-05-10 17:41:43 +02:00
e726d47547 generic design elements 2026-05-10 17:40:52 +02:00
25 changed files with 345 additions and 162 deletions

View File

@@ -1,4 +1,4 @@
# Testium — Claude Context # Testium — Design Context
## What is testium ## What is testium

View File

@@ -1,13 +1,17 @@
#!/bin/bash #!/bin/bash
# Build every distribution channel of testium, in order: # Build every distribution channel of testium, in order:
# 1. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name) # 1. Manual PDF -> dist/testium-manual-<v>.pdf
# 2. PyInstaller binary -> dist/testium-<v> # 2. Wheel -> dist/testium-<v>-py3-none-any.whl (PEP 427 name)
# 3. Flatpak bundle -> dist/testium-<v>.flatpak # 3. PyInstaller binary -> dist/testium-<v>
# 4. AppImage -> dist/Testium-<v>-x86_64.AppImage (original name) # 4. Flatpak bundle -> dist/testium-<v>.flatpak
# 5. AppImage -> dist/Testium-<v>-x86_64.AppImage (original name)
# release_note.txt is copied to dist/ up front (with a warning if it has no
# entry for the current version).
#
# All artifacts are collected (copied) under <repo>/dist/. Original outputs in # All artifacts are collected (copied) under <repo>/dist/. Original outputs in
# src/dist/, package/*/dist/ are left in place. The wheel and AppImage keep # src/dist/, package/*/dist/, doc/manual/ are left in place. Wheel and AppImage
# their original names (which already contain the version); pyinstaller and # keep their original names (which already contain the version); manual,
# flatpak are renamed to a normalized testium-<version>(.suff) form. # pyinstaller and flatpak are renamed to testium(-manual)-<version>(.suff).
# #
# Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked # Re-uses scripts/build_env.sh and scripts/set_env.sh — the same pair invoked
# by run.sh — so the venv at test/tmp/.venv stays the single source of Python # by run.sh — so the venv at test/tmp/.venv stays the single source of Python
@@ -22,6 +26,15 @@ VERSION=$(cat "$SCRIPT_DIR/src/VERSION")
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
mkdir -p "$DIST_DIR" mkdir -p "$DIST_DIR"
# Release note: copy it to dist/ and warn (but don't fail) if it has no entry
# for the current version.
RELEASE_NOTE_SRC="$SCRIPT_DIR/release_note.txt"
RELEASE_NOTE="$DIST_DIR/release_note.txt"
cp -f "$RELEASE_NOTE_SRC" "$RELEASE_NOTE"
if ! grep -qE "^version $VERSION([^.0-9]|$)" "$RELEASE_NOTE_SRC"; then
echo "WARNING: release_note.txt has no entry for version $VERSION." >&2
fi
export PY_VENV_NAME=".venv" export PY_VENV_NAME=".venv"
export PY_VENV_DIR="$SCRIPT_DIR/test/tmp/$PY_VENV_NAME" export PY_VENV_DIR="$SCRIPT_DIR/test/tmp/$PY_VENV_NAME"
export REQ_PATH="$SCRIPT_DIR/src/requirements.txt" export REQ_PATH="$SCRIPT_DIR/src/requirements.txt"
@@ -39,8 +52,15 @@ step() {
echo "================================================================" echo "================================================================"
} }
# 1. Wheel — PEP 427 name kept (already contains version) # 1. Manual PDF
step "1/4 Wheel (version $VERSION)" step "1/5 Manual PDF (version $VERSION)"
bash "$SCRIPT_DIR/doc/manual/sphinx/build_doc.sh"
MANUAL_SRC="$SCRIPT_DIR/doc/manual/testium_manual.pdf"
MANUAL="$DIST_DIR/testium-manual-${VERSION}.pdf"
cp -f "$MANUAL_SRC" "$MANUAL"
# 2. Wheel — PEP 427 name kept (already contains version)
step "2/5 Wheel (version $VERSION)"
( (
cd "$SCRIPT_DIR/src" cd "$SCRIPT_DIR/src"
rm -rf dist build *.egg-info rm -rf dist build *.egg-info
@@ -50,15 +70,15 @@ WHEEL_SRC=$(ls -1t "$SCRIPT_DIR/src/dist"/*.whl | head -1)
WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")" WHEEL="$DIST_DIR/$(basename "$WHEEL_SRC")"
cp -f "$WHEEL_SRC" "$WHEEL" cp -f "$WHEEL_SRC" "$WHEEL"
# 2. PyInstaller binary # 3. PyInstaller binary
step "2/4 PyInstaller binary (version $VERSION)" step "3/5 PyInstaller binary (version $VERSION)"
bash "$SCRIPT_DIR/package/pyinstaller/build.sh" bash "$SCRIPT_DIR/package/pyinstaller/build.sh"
PYI_SRC="$SCRIPT_DIR/package/pyinstaller/dist/testium" PYI_SRC="$SCRIPT_DIR/package/pyinstaller/dist/testium"
PYI_BIN="$DIST_DIR/testium-${VERSION}" PYI_BIN="$DIST_DIR/testium-${VERSION}"
cp -f "$PYI_SRC" "$PYI_BIN" cp -f "$PYI_SRC" "$PYI_BIN"
# 3. Flatpak bundle # 4. Flatpak bundle
step "3/4 Flatpak bundle (version $VERSION)" step "4/5 Flatpak bundle (version $VERSION)"
( (
cd "$SCRIPT_DIR/package/flatpak" cd "$SCRIPT_DIR/package/flatpak"
bash build.sh bash build.sh
@@ -67,8 +87,8 @@ FLATPAK_SRC="$SCRIPT_DIR/package/flatpak/testium.flatpak"
FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak" FLATPAK_BUNDLE="$DIST_DIR/testium-${VERSION}.flatpak"
cp -f "$FLATPAK_SRC" "$FLATPAK_BUNDLE" cp -f "$FLATPAK_SRC" "$FLATPAK_BUNDLE"
# 4. AppImage # 5. AppImage
step "4/4 AppImage (version $VERSION)" step "5/5 AppImage (version $VERSION)"
( (
cd "$SCRIPT_DIR/package/appimage" cd "$SCRIPT_DIR/package/appimage"
bash build.sh bash build.sh
@@ -79,7 +99,9 @@ cp -f "$APPIMAGE_SRC" "$APPIMAGE"
chmod +x "$APPIMAGE" chmod +x "$APPIMAGE"
step "All packages built" step "All packages built"
printf " manual : %s\n" "$MANUAL"
printf " wheel : %s\n" "$WHEEL" printf " wheel : %s\n" "$WHEEL"
printf " pyinstaller : %s\n" "$PYI_BIN" printf " pyinstaller : %s\n" "$PYI_BIN"
printf " flatpak : %s\n" "$FLATPAK_BUNDLE" printf " flatpak : %s\n" "$FLATPAK_BUNDLE"
printf " appimage : %s\n" "$APPIMAGE" printf " appimage : %s\n" "$APPIMAGE"
printf " release_note : %s\n" "$RELEASE_NOTE"

View File

@@ -20,6 +20,22 @@ main:
param: param:
- 123 - 123
- py_func:
name: python long wait
doc: The purpose of this step is to try the tasks "stop" interruption
file: utils.py
func_name: long_wait
param:
- 10
- lua_func:
name: lua long wait
doc: The purpose of this step is to try the tasks "stop" interruption
file: lua_func.lua
func_name: long_wait
param:
- 10
- sleep: - sleep:
name: sleep item name: sleep item
dialog: true dialog: true

View File

@@ -1,4 +1,5 @@
tm = require("tm") tm = require("tm")
socket = require("socket")
local module = {} local module = {}
@@ -7,4 +8,8 @@ function module.func_to_be_executed(param)
return param return param
end end
function module.long_wait(sec)
socket.sleep(sec)
end
return module return module

View File

@@ -17,18 +17,3 @@ plot_log_path: /tmp/testium_plot/$(testrun_date)/$(testrun_time)/
python_path_Windows: C:\Users\François\Applications\Python313\python.exe python_path_Windows: C:\Users\François\Applications\Python313\python.exe
python_path_Linux: $(home)/tmp/tum_venv/bin/python3 python_path_Linux: $(home)/tmp/tum_venv/bin/python3
# lua_bin_Windows: C:\Lua\5.1
# lua_bin_Linux: /usr/bin/lua
LUA_PATH_Linux: /usr/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;/usr/lib/lua/5.4/?.lua;/usr/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua;/home/francois/.luarocks/share/lua/5.4/?.lua;/home/francois/.luarocks/share/lua/5.4/?/init.lua
LUA_CPATH_Linux: /usr/local/lib/lua/5.4/?.so;/usr/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;/usr/lib/lua/5.4/loadall.so;./?.so;/home/francois/.luarocks/lib/lua/5.4/?.so
PATH_Linux:
LUA_PATH_Windows: ;.\?.lua;C:\Lua\5.1\lua\?.lua;C:\Lua\5.1\lua\?\init.lua;C:\Lua\5.1\?.lua;C:\Lua\5.1\?\init.lua;C:\Lua\5.1\lua\?.luac
LUA_CPATH_Windows: .\?.dll;C:\Lua\5.1\?.dll;C:\Lua\5.1\loadall.dll;C:\Lua\5.1\clibs\?.dll;C:\Lua\5.1\clibs\loadall.dll;.\?51.dll;C:\Lua\5.1\?51.dll;C:\Lua\5.1\clibs\?51.dll
PATH_Windows: ""
lua_env:
PATH: $(PATH_$(os))
LUA_PATH: $(LUA_PATH_$(os))
LUA_CPATH: $(LUA_CPATH_$(os))

View File

@@ -1,3 +1,5 @@
from time import sleep
def dummy_exit(useless1, useless2): def dummy_exit(useless1, useless2):
return True return True
@@ -11,3 +13,6 @@ def funcToBeExecuted (bla):
def funcToBeExecuted2 (bla): def funcToBeExecuted2 (bla):
print(bla) print(bla)
return blo return blo
def long_wait (sec):
sleep(sec)

Binary file not shown.

View File

@@ -20,6 +20,12 @@ if [ "$?" -ne 0 ]; then
echo "venv must be installed on the host distribution." echo "venv must be installed on the host distribution."
exit -1 exit -1
fi fi
# Check if venv is installed
python3 -c "import ensurepip"
if [ "$?" -ne 0 ]; then
echo "ensurepip must be installed on the host distribution."
exit -1
fi
# Install the virtual environment if needed # Install the virtual environment if needed
if [ ! -d "$PY_VENV_DIR" ]; then if [ ! -d "$PY_VENV_DIR" ]; then

View File

@@ -11,6 +11,7 @@ import threading
from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL from telnetlib3 import Telnet, DO, WILL, WONT, TTYPE, IAC, SB, SE, theNULL
TIMEOUT_NULL = 0.000001 TIMEOUT_NULL = 0.000001
STOP_POLL_INTERVAL = 0.2
class BytesStore(object): class BytesStore(object):
@@ -123,12 +124,14 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# c = '' # c = ''
return c return c
def read_until(self, match, timeout=None, return_data=False, mute=False): def read_until(self, match, timeout=None, return_data=False, mute=False, should_stop=None):
""" """
read until the string 'match is found read until the string 'match is found
If timeout is not set (None), this function runs indefinitely If timeout is not set (None), this function runs indefinitely
If timeout is set to zero, this function returns immediately If timeout is set to zero, this function returns immediately
If mute is set to True the characters read from the console will not be displayed If mute is set to True the characters read from the console will not be displayed
If should_stop is a callable, it is polled between reads (every STOP_POLL_INTERVAL
at most) and the loop exits early — like a timeout — when it returns True.
If function fails (because of a timeout) it will return a 'status' integer set to -1 If function fails (because of a timeout) it will return a 'status' integer set to -1
otherwise it will return 0. otherwise it will return 0.
@@ -139,13 +142,6 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
status = -1 status = -1
if not match: if not match:
raise ValueError('match parameter can not be empty') raise ValueError('match parameter can not be empty')
# replace all '\r' by '\n' as any '\r' read will undergo the same replacement
# match = match.replace('\r\n', '\n')
# match = match.replace('\r', '')
# update the console timeout in conformity with what is required.
self.set_read_timeout(timeout)
if timeout is None: if timeout is None:
timeout = 1000000 timeout = 1000000
@@ -159,6 +155,7 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# buffer is empty # buffer is empty
# Otherwise we are waiting for the timeout to rise # Otherwise we are waiting for the timeout to rise
if timeout < TIMEOUT_NULL: if timeout < TIMEOUT_NULL:
self.set_read_timeout(0)
data = self.readchar(0) data = self.readchar(0)
while (status < 0) and ((data is not None) and (data != b'')): while (status < 0) and ((data is not None) and (data != b'')):
@@ -191,16 +188,21 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
# Timeout different than zero # Timeout different than zero
else: else:
# Poll in short chunks so a stop request is honored within
# STOP_POLL_INTERVAL, regardless of the per-protocol blocking
# behavior of readchar().
self.set_read_timeout(STOP_POLL_INTERVAL)
time_is_out = threading.Event() time_is_out = threading.Event()
timer = threading.Timer(timeout, lambda: time_is_out.set()) timer = threading.Timer(timeout, lambda: time_is_out.set())
timer.start() timer.start()
# We are waiting for the timeout to rise try:
while (status < 0) and (not time_is_out.is_set()):
if should_stop is not None and should_stop():
break
while (status < 0) and (not time_is_out.isSet()): data = self.readchar(STOP_POLL_INTERVAL)
data = self.readchar(timeout)
if data is not None: if data is not None:
data = self._compute_char(data) data = self._compute_char(data)
if data != '': if data != '':
@@ -210,7 +212,6 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
search_deque.append(data) search_deque.append(data)
if search_deque == match_deque: if search_deque == match_deque:
timer.cancel()
status = 0 status = 0
if (not mute) and (data != '\n'): if (not mute) and (data != '\n'):
self.string_buffer += '\n' self.string_buffer += '\n'
@@ -224,6 +225,8 @@ A {classname}.close() is missing somewhere in your code !'.format(classname=type
date_str = str(datetime.now()).split('.')[0].split(' ')[1] date_str = str(datetime.now()).split('.')[0].split(' ')[1]
self.string_buffer = '[{} {}]'.format(date_str, self.name) self.string_buffer = '[{} {}]'.format(date_str, self.name)
finally:
timer.cancel()
if return_data: if return_data:
return status, read_data return status, read_data

View File

@@ -20,9 +20,26 @@ class TestItem:
def test_run(f): def test_run(f):
@wraps(f) @wraps(f)
def wrapper(self): def wrapper(self):
if not self.skipped: if self.skipped:
if self.enabled: self.result.set(TestValue.NORUN, "test skipped")
print("Test is skipped.")
return self.result
if not self.enabled:
self.result.set(TestValue.NORUN, "test disabled")
print("Test is disabled.")
return self.result
self.run_test_init() self.run_test_init()
while self._is_paused:
sleep(0.2)
if self.isStopped() :
self.result.set(TestValue.NORUN, "test stopped")
print("Test is Stopped.")
self._is_stopped = False # Restore state for next run
return self.result
# Conditional execution # Conditional execution
raw_condition = self._prms.getParam( raw_condition = self._prms.getParam(
"condition", default=None, processed=False "condition", default=None, processed=False
@@ -57,15 +74,10 @@ def test_run(f):
self.result.set(TestValue.NORUN, msg) self.result.set(TestValue.NORUN, msg)
self.result.reported = {"input_condition": msg} self.result.reported = {"input_condition": msg}
self.run_test_end() self.run_test_end()
else:
self.result.set(TestValue.NORUN, "test disabled")
print("Test is disabled.")
else:
self.result.set(TestValue.NORUN, "test skipped")
print("Test is skipped.")
return self.result return self.result
return wrapper return wrapper
@@ -255,8 +267,6 @@ class TestItem:
self._sendStatusStarted() self._sendStatusStarted()
if self._is_breakpoint: if self._is_breakpoint:
self._is_paused = True self._is_paused = True
while self._is_paused:
sleep(0.2)
if self.is_container: if self.is_container:
self.report.incLevel() self.report.incLevel()
@@ -274,9 +284,6 @@ class TestItem:
if self.is_container: if self.is_container:
self.report.decLevel() self.report.decLevel()
while self._is_paused:
sleep(0.2)
# Post evaluation of the test result # Post evaluation of the test result
self.process_result() self.process_result()
# expected_result treatment # expected_result treatment
@@ -311,6 +318,7 @@ class TestItem:
self.report.addTest(self, self.result, rk) self.report.addTest(self, self.result, rk)
self._sendStatusFinished() self._sendStatusFinished()
def process_result(self): def process_result(self):
if self._post_eval is None: if self._post_eval is None:
return return

View File

@@ -307,11 +307,17 @@ class TestItemConsoleReadUntil(TestItemConsoleAction):
try: try:
status, data = cons.read_until( status, data = cons.read_until(
ru, timeout=read_timeout, return_data=True, mute=mute ru, timeout=read_timeout, return_data=True, mute=mute,
should_stop=self.isStopped,
) )
if status == 0: if status == 0:
self.result.set(TestValue.SUCCESS) self.result.set(TestValue.SUCCESS)
self.result.value = data self.result.value = data
elif self.isStopped():
self.result.set(
result=TestValue.FAILURE,
message="Console read aborted on stop request",
)
else: else:
self.result.set(result=TestValue.FAILURE, message="No matching text") self.result.set(result=TestValue.FAILURE, message="No matching text")
if mute: if mute:

View File

@@ -105,6 +105,7 @@ class TestItemJSRPCActionQuery(TestItemAction):
jrpc_id = randint(1, (2**32) - 1) jrpc_id = randint(1, (2**32) - 1)
send_only = self._prms.expanse(self._send_only) send_only = self._prms.expanse(self._send_only)
timeout = self._prms.expanse(self._timeout) timeout = self._prms.expanse(self._timeout)
self.token.set_should_stop(self.isStopped)
try: try:
success, result = self.token.query( success, result = self.token.query(
meth, obj, jrpc_id, send_only, timeout=timeout meth, obj, jrpc_id, send_only, timeout=timeout
@@ -146,6 +147,7 @@ class TestItemJSRPCActionReceive(TestItemAction):
def execute(self): def execute(self):
timeout = self._prms.expanse(self._timeout) timeout = self._prms.expanse(self._timeout)
jrpc_id = self._prms.expanse(self._jrpc_id) jrpc_id = self._prms.expanse(self._jrpc_id)
self.token.set_should_stop(self.isStopped)
try: try:
success, result = self.token.receive(jrpc_id, timeout) success, result = self.token.receive(jrpc_id, timeout)

View File

@@ -2,10 +2,11 @@ import json
import socket import socket
import re import re
import struct import struct
import time
from runtime.tum_except import ETUMRuntimeError from runtime.tum_except import ETUMRuntimeError
import api.testium as tm import api.testium as tm
from api.console import Console from api.console import Console, STOP_POLL_INTERVAL
def is_ip_address(address): def is_ip_address(address):
@@ -45,9 +46,16 @@ class JrpcAdapter:
self._jrpc_version = version self._jrpc_version = version
self._mute = mute self._mute = mute
self._timeout = timeout self._timeout = timeout
# Optional callable polled by _receive() implementations to abort
# waits early when the test is being stopped. Set by the test item
# action before each query/receive call.
self._should_stop = None
if not (version == "1.0" or version == "2.0"): if not (version == "1.0" or version == "2.0"):
raise ETUMRuntimeError("Invalid JSONRPC version passed.") raise ETUMRuntimeError("Invalid JSONRPC version passed.")
def set_should_stop(self, cb):
self._should_stop = cb
@property @property
def timeout(self): def timeout(self):
return self._timeout return self._timeout
@@ -249,13 +257,23 @@ class JrpcUdpAdapter(JrpcAdapter):
print(f" | sent to @{self._server}:{self._snd_port}") print(f" | sent to @{self._server}:{self._snd_port}")
def _receive(self, timeout: float) -> str: def _receive(self, timeout: float) -> str:
# Poll in short chunks so a stop request is honored within
# configures the reception timeout # STOP_POLL_INTERVAL.
self.sock.settimeout(timeout) self.sock.settimeout(STOP_POLL_INTERVAL)
deadline = time.monotonic() + float(timeout)
# Receives the answer from the server data = None
addr = None
while True:
if self._should_stop is not None and self._should_stop():
raise ETUMRuntimeError("JSONRPC udp receive aborted on stop request.")
try: try:
data, addr = self.sock.recvfrom(self._bufsize) data, addr = self.sock.recvfrom(self._bufsize)
break
except socket.timeout:
if time.monotonic() >= deadline:
raise ETUMRuntimeError(
"JSONRPC udp answer took too long. Try to increase the timeout."
)
# In case of buffer overload we chose to complain # In case of buffer overload we chose to complain
if len(data) >= self._bufsize: if len(data) >= self._bufsize:
@@ -271,10 +289,6 @@ class JrpcUdpAdapter(JrpcAdapter):
print(f" | UDP answer: '{res}'") print(f" | UDP answer: '{res}'")
print(f" | received from @{addr[0]}:{addr[1]}") print(f" | received from @{addr[0]}:{addr[1]}")
except socket.timeout:
raise ETUMRuntimeError(
"JSONRPC udp answer took too long. Try to increase the timeout."
)
return res return res
def _build_query(self, method: str, obj, jrpc_id: int): def _build_query(self, method: str, obj, jrpc_id: int):
@@ -339,11 +353,16 @@ class JrpcConsoleAdapter(JrpcAdapter):
def _receive(self, timeout: float) -> str: def _receive(self, timeout: float) -> str:
status, data = self._cons.read_until( status, data = self._cons.read_until(
self._endswith, timeout, return_data=True, mute=self._mute self._endswith, timeout, return_data=True, mute=self._mute,
should_stop=self._should_stop,
) )
# if we did not receive anything, we complain # if we did not receive anything, we complain
if not status == 0: if not status == 0:
if self._should_stop is not None and self._should_stop():
raise ETUMRuntimeError(
f"JSONRPC console receive aborted on stop request."
)
raise ETUMRuntimeError( raise ETUMRuntimeError(
f"The '{self._cons.name}' console did not answer in the requested time." f"The '{self._cons.name}' console did not answer in the requested time."
) )

View File

@@ -45,6 +45,18 @@ class TestItemLuaFunc(TestItem):
tm.setgd(_LUA_FUNC_CONTEXTS_KEY, contexts) tm.setgd(_LUA_FUNC_CONTEXTS_KEY, contexts)
return contexts[ctx_id], True return contexts[ctx_id], True
def stop(self):
super().stop()
# Tear down the worker so any in-flight func_call returns promptly.
# join() clears _rpc/_process so a subsequent item reusing the same
# context_id can restart the engine cleanly.
try:
engine, _ = self._get_engine()
engine.stop()
engine.join()
except Exception:
pass
@test_run @test_run
def execute(self): def execute(self):
self.result.set( self.result.set(
@@ -96,8 +108,14 @@ Is the lua environnment well defined in the "LUA_PATH" and "LUA_CPATH" variables
return return
except ConnectionAbortedError:
self.result.set(TestValue.FAILURE, "lua_func aborted on stop request")
print("lua_func aborted on stop request.")
except: except:
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
if self.isStopped():
self.result.set(TestValue.FAILURE, "lua_func aborted on stop request")
else:
self.result.set( self.result.set(
TestValue.FAILURE, TestValue.FAILURE,
'Unrecoverable "lua_func" item error from {}'.format(self.func_name), 'Unrecoverable "lua_func" item error from {}'.format(self.func_name),

View File

@@ -45,6 +45,18 @@ class TestItemPyFunc(TestItem):
tm.setgd(_PY_FUNC_CONTEXTS_KEY, contexts) tm.setgd(_PY_FUNC_CONTEXTS_KEY, contexts)
return contexts[ctx_id], True return contexts[ctx_id], True
def stop(self):
super().stop()
# Tear down the worker so any in-flight func_call returns promptly.
# join() clears _rpc/_process so a subsequent item reusing the same
# context_id can restart the engine cleanly.
try:
engine, _ = self._get_engine()
engine.stop()
engine.join()
except Exception:
pass
@test_run @test_run
def execute(self): def execute(self):
self.result.set( self.result.set(
@@ -94,8 +106,14 @@ python_bin = {tm.gd("python_bin", "no python path defined")}"""
return return
except ConnectionAbortedError:
self.result.set(TestValue.FAILURE, "py_func aborted on stop request")
print("py_func aborted on stop request.")
except: except:
traceback.print_exception(*sys.exc_info()) traceback.print_exception(*sys.exc_info())
if self.isStopped():
self.result.set(TestValue.FAILURE, "py_func aborted on stop request")
else:
self.result.set( self.result.set(
TestValue.FAILURE, TestValue.FAILURE,
'Unrecoverable "py_func" item error from {}'.format(self.func_name), 'Unrecoverable "py_func" item error from {}'.format(self.func_name),

View File

@@ -80,4 +80,7 @@ class TestItemSleep(TestItem):
end_time = _time.time() + float(timeout) end_time = _time.time() + float(timeout)
while _time.time() < end_time and not self._is_stopped: while _time.time() < end_time and not self._is_stopped:
sleep(min(0.05, end_time - _time.time())) sleep(min(0.05, end_time - _time.time()))
if self._is_stopped:
self.result.set(TestValue.FAILURE, 'Sleep aborted on stop request')
else:
self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout))) self.result.set(TestValue.SUCCESS, 'Sleep %s sec' % (str(timeout)))

View File

@@ -146,4 +146,12 @@ class LuaProcessBase:
""" """
if self._rpc is not None: if self._rpc is not None:
self._rpc.stop() self._rpc.stop()
# Force-kill the worker if it's still running. Needed when user code
# in the worker is stuck and won't notice the parent closing the RPC
# socket on its own.
if self._process is not None and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass

View File

@@ -123,3 +123,11 @@ class PyProcessBase:
def stop(self): def stop(self):
if self._rpc is not None: if self._rpc is not None:
self._rpc.stop() self._rpc.stop()
# Force-kill the worker if it's still running. Needed when user code
# in the worker is stuck (e.g. sleep, blocking I/O) and won't notice
# the parent closing the RPC socket on its own.
if self._process is not None and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass

View File

@@ -41,8 +41,7 @@ end
--- INTERNAL: Handle requests from the client --- INTERNAL: Handle requests from the client
function JSONRPC:_handle_request(req) function JSONRPC:_handle_request(req)
local method = self.methods[req.method] local method = self.methods[req.method]
local ok, ret local ok, ret, err
local res, err
if not method then if not method then
if req.id then self:_send_error(req.id, string.format("Method '%s' not registered in lua server")) end if req.id then self:_send_error(req.id, string.format("Method '%s' not registered in lua server")) end
return return
@@ -52,15 +51,18 @@ function JSONRPC:_handle_request(req)
-- Only send response if it's not a Notification (notifications have no ID) -- Only send response if it's not a Notification (notifications have no ID)
if req.id then if req.id then
if ok then if not ok then
res = ret -- pcall trapped a runtime error in the method itself.
if res == nil then self:_send_error(req.id, tostring(ret))
elseif err ~= nil then
-- Method ran but signaled a logical error via its 2nd return.
self:_send_error(req.id, tostring(err)) self:_send_error(req.id, tostring(err))
else else
self:_send({ jsonrpc = "2.0", result = { returned_value = res }, id = req.id }) -- Success. A user function returning nothing yields ret==nil;
end -- encode it as JSON null so "returned_value" stays present.
else local val = ret
self:_send_error(req.id, tostring(err)) if val == nil then val = json.null end
self:_send({ jsonrpc = "2.0", result = { returned_value = val }, id = req.id })
end end
end end
end end

View File

@@ -176,7 +176,7 @@ class TestRunner:
w.actionOpenTest.setDisabled(True) w.actionOpenTest.setDisabled(True)
w.actionExit.setDisabled(True) w.actionExit.setDisabled(True)
icon = QtGui.QIcon() icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap(icon_prefix() + "/pause.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) icon.addPixmap(QtGui.QPixmap(icon_prefix() + "/pause2.png"), QtGui.QIcon.Normal, QtGui.QIcon.On)
w.actionStart_test.setIcon(icon) w.actionStart_test.setIcon(icon)
w.actionStart_test.setText("Pause test") w.actionStart_test.setText("Pause test")
w.actionPreferences.setDisabled(True) w.actionPreferences.setDisabled(True)

View File

@@ -200,6 +200,7 @@ class JsonRpcConnection:
Raises: Raises:
TimeoutError: If no response is received within `timeout`. TimeoutError: If no response is received within `timeout`.
ConnectionAbortedError: If stop() was called while waiting.
""" """
req_id = next(self.id_gen) req_id = next(self.id_gen)
@@ -214,7 +215,12 @@ class JsonRpcConnection:
self.pending.pop(req_id, None) self.pending.pop(req_id, None)
raise TimeoutError("Timeout JSON-RPC") raise TimeoutError("Timeout JSON-RPC")
return self.pending.pop(req_id)["response"] entry = self.pending.pop(req_id)
if entry["response"] is None:
# Woken by stop() (or by a malformed dispatch) rather than by a
# real response — abort the call so callers don't block further.
raise ConnectionAbortedError("JSON-RPC client stopped")
return entry["response"]
def print_info(self, msg): def print_info(self, msg):
if self.dbg_out is not None: if self.dbg_out is not None:
@@ -223,6 +229,10 @@ class JsonRpcConnection:
def stop(self): def stop(self):
if self.running: if self.running:
self.running = False self.running = False
# Wake any in-flight call() so it doesn't sit on its (default 1h)
# timeout. The response stays None and call() raises ConnectionAbortedError.
for entry in list(self.pending.values()):
entry["event"].set()
def join(self): def join(self):
self.recv_thread.join() self.recv_thread.join()

View File

@@ -49,4 +49,12 @@ function module.test_delgd()
return 0 return 0
end end
function module.return_nothing()
-- Returns no value: ret is nil but no error.
end
function module.return_explicit_nil()
return nil
end
return module return module

View File

@@ -186,6 +186,18 @@
file: $(test_path)$(psep)lua_func.lua file: $(test_path)$(psep)lua_func.lua
func_name: test_delgd func_name: test_delgd
- lua_func:
name: function returning nothing should succeed
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: return_nothing
- lua_func:
name: function returning explicit nil should succeed
key: $(test)_PASS
file: $(test_path)$(psep)lua_func.lua
func_name: return_explicit_nil
- group: - group:
name: context_id tests name: context_id tests
steps: steps:

View File

@@ -54,3 +54,10 @@ def test_delgd():
tm.delgd("_py_delgd_test") tm.delgd("_py_delgd_test")
assert tm.gd("_py_delgd_test", None) is None assert tm.gd("_py_delgd_test", None) is None
return 0 return 0
def return_nothing():
# Falls off the end: implicit None return, no error.
pass
def return_explicit_none():
return None

View File

@@ -196,6 +196,18 @@
file: $(test_path)$(psep)py_func.py file: $(test_path)$(psep)py_func.py
func_name: test_delgd func_name: test_delgd
- py_func:
name: function returning nothing should succeed
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: return_nothing
- py_func:
name: function returning explicit None should succeed
key: $(test)_PASS
file: $(test_path)$(psep)py_func.py
func_name: return_explicit_none
- group: - group:
name: context_id tests name: context_id tests
steps: steps: