G: EPOD acceptance tests; harness: hide and clean other runs' mid-name objects (proxy, teardown, sweep); lock leak cause (Eclipse restart during a write)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Kral
2026-10-06 06:15:08 +02:00
parent 0c0e07fe96
commit c2c3803b1b
10 changed files with 470 additions and 14 deletions

22
epod_tests/README.md Normal file
View File

@@ -0,0 +1,22 @@
# EPOD acceptance tests (for the EPOD server developer)
Small test set for the two server changes that the harness work asked for (details: `docs/epod-syntax-hint.md`, `docs/epod-lock-leak.md`)
and for the parallel-call behavior (`harness/loadtest.py` numbers). Standard library only, Python 3.9+, no harness code needed.
It uses probe objects `ZEPODT_*` in `$TMP` on a **test system**.
```sh
cd epod_tests
export MCP_URL=http://127.0.0.1:3000/mcp MCP_TOKEN=... # the MCP server
export A4H_URL=http://localhost:50000 A4H_USER=... A4H_PASSWORD=... # only for the cleanup (ADT deletion); without it the tests list the probe objects
python3 run_tests.py # all; --only T1 T3 for some
```
| test | passes when | state of the server on 2026-10-06 |
|---|---|---|
| T1 syntax_hint | a rejected write (`TYPE c LENGTH 4` in a method signature) returns syntax messages with a line, not only "save operation failed" | FAIL expected (not implemented; the harness proxy adds abaplint messages) |
| T2 no_lock_after_kill | a client killed with SIGKILL during a write (4 delays) leaves no lock: the next write works | PASS (not reproducible on A4H) |
| T3 parallel_reads | 6 clients search + syntax check in parallel without `Concurrent call detected` | PASS |
| T4 parallel_writes | 3 clients create + write in parallel without `Concurrent call detected` | FAIL expected (the shared connection; 15 to 50 lock hits per 45 s in the harness load test) |
| T5 lock_error_text | informational | PASS |
A change in the server is done when T1 and T4 pass and T2 and T3 stay green. T1 accepts any answer that carries a line number or a `syntaxCheck` object with messages (the proxy format is in `docs/epod-syntax-hint.md`).

98
epod_tests/epod_client.py Normal file
View File

@@ -0,0 +1,98 @@
"""Minimal MCP client and ADT deletion for the EPOD acceptance tests (standard library only, Python 3.9+)."""
import base64
import http.cookiejar
import json
import os
import re
import time
import urllib.request
from xml.sax.saxutils import quoteattr
URL = os.environ.get("MCP_URL", "http://127.0.0.1:3000/mcp")
TOKEN = os.environ.get("MCP_TOKEN", "")
SYSTEM = os.environ.get("MCP_SYSTEM_ID") # optional: Eclipse project name when several systems are connected
class Mcp:
def __init__(self, timeout=300):
self.sid, self.n, self.timeout = None, 0, timeout
def _post(self, body, method="POST"):
h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if TOKEN:
h["Authorization"] = "Bearer " + TOKEN
if self.sid:
h["Mcp-Session-Id"] = self.sid
req = urllib.request.Request(URL, json.dumps(body).encode() if body is not None else None, h, method=method)
with urllib.request.urlopen(req, timeout=self.timeout) as r:
sid = r.headers.get("Mcp-Session-Id")
raw = r.read().decode()
self.sid = sid or self.sid
if "data:" in raw[:40]:
raw = "".join(l[5:].strip() for l in raw.splitlines() if l.startswith("data:"))
return json.loads(raw) if raw.strip() else None
def open(self):
self.n += 1
self._post({"jsonrpc": "2.0", "id": self.n, "method": "initialize", "params": {
"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "epod-acceptance", "version": "1"}}})
self._post({"jsonrpc": "2.0", "method": "notifications/initialized"})
return self
def close(self):
try:
self._post(None, "DELETE")
except Exception:
pass
def __enter__(self):
return self.open()
def __exit__(self, *a):
self.close()
def call(self, tool, args):
"""Returns (is_error, text). 'Concurrent call detected' is returned as it is (the tests count it)."""
self.n += 1
if SYSTEM:
args = dict(args, systemId=SYSTEM)
res = self._post({"jsonrpc": "2.0", "id": self.n, "method": "tools/call", "params": {"name": tool, "arguments": args}})
if "error" in res:
return True, json.dumps(res["error"])
r = res["result"]
return bool(r.get("isError")), "\n".join(c.get("text", "") for c in r.get("content", []))
class Adt:
"""Deletion through the ADT deletion API (needs A4H_URL, A4H_USER, A4H_PASSWORD; A4H_CLIENT default 001)."""
def __init__(self):
self.base = os.environ.get("A4H_URL", "").rstrip("/")
self.client = os.environ.get("A4H_CLIENT", "001")
self.auth = "Basic " + base64.b64encode(("%s:%s" % (os.environ.get("A4H_USER", ""), os.environ.get("A4H_PASSWORD", ""))).encode()).decode()
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
self.csrf = None
def available(self):
return bool(self.base and os.environ.get("A4H_USER"))
def _req(self, path, body=None, headers=None):
h = {"Authorization": self.auth}
if self.csrf:
h["x-csrf-token"] = self.csrf
h.update(headers or {})
req = urllib.request.Request("%s%s%ssap-client=%s" % (self.base, path, "&" if "?" in path else "?", self.client), body.encode() if body else None, h)
with self.opener.open(req, timeout=120) as r:
return dict(r.headers), r.read().decode()
def delete(self, uris):
if not self.available() or not uris:
return {}
hdr, _ = self._req("/sap/bc/adt/discovery", headers={"x-csrf-token": "fetch", "Accept": "*/*"})
self.csrf = hdr.get("x-csrf-token") or hdr.get("X-CSRF-Token")
objs = "".join("<del:object adtcore:uri=%s><del:transportNumber/></del:object>" % quoteattr(u) for u in uris)
body = ('<?xml version="1.0" encoding="UTF-8"?><del:deletionRequest xmlns:del="http://www.sap.com/adt/deletion" '
'xmlns:adtcore="http://www.sap.com/adt/core">' + objs + "</del:deletionRequest>")
_, text = self._req("/sap/bc/adt/deletion/delete", body, {"Content-Type": "application/vnd.sap.adt.deletion.request.v1+xml",
"Accept": "application/vnd.sap.adt.deletion.response.v1+xml"})
return {m.group(1): 'isDeleted="true"' in m.group(0) for m in re.finditer(r'<del:object\b[^>]*adtcore:uri="([^"]+)"[^>]*>', text)}

View File

@@ -0,0 +1,32 @@
[
{
"id": "T1",
"name": "syntax_hint",
"pass": false,
"detail": "the failed write returned: {\"success\":false,\"error\":\"[WRITE] An error occured during the save operation. The changes were not stored.\"}"
},
{
"id": "T2",
"name": "no_lock_after_kill",
"pass": true,
"detail": "4 kills (0.05 to 0.7 s), the next write always worked"
},
{
"id": "T3",
"name": "parallel_reads",
"pass": true,
"detail": "372 rounds, 0 'Concurrent call detected'"
},
{
"id": "T4",
"name": "parallel_writes",
"pass": false,
"detail": "27 create+write rounds with 3 clients, 12 'Concurrent call detected'"
},
{
"id": "T5",
"name": "lock_error_text",
"pass": true,
"detail": "informational: see docs/epod-lock-leak.md (no way to create a lock on purpose)"
}
]

174
epod_tests/run_tests.py Normal file
View File

@@ -0,0 +1,174 @@
"""EPOD acceptance tests (2026-10-06). Run against an EPOD MCP server on a test system (probe objects ZEPODT_*, package $TMP).
MCP_URL=http://127.0.0.1:3000/mcp MCP_TOKEN=... [A4H_URL=http://localhost:50000 A4H_USER=... A4H_PASSWORD=...] python3 run_tests.py [--only T1 T2 ...]
Each test prints PASS or FAIL with the reason and writes the result to epod_tests_result.json. Tests:
T1 syntax_hint a rejected write ('save operation failed') comes with syntax messages (line and text) docs/epod-syntax-hint.md
T2 no_lock_after_kill a client killed during a write leaves no lock (the next write and the deletion work) docs/epod-lock-leak.md
T3 parallel_reads 6 clients read/ATC/syntax-check in parallel without 'Concurrent call detected'
T4 parallel_writes 3 clients create/write/activate in parallel without 'Concurrent call detected' (a fix: per system or per session lock)
T5 lock_error_text a write on a locked object names the lock owner (informational)
"""
import json
import os
import signal
import subprocess
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from epod_client import Adt, Mcp # noqa: E402
CLS = """CLASS {n} DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
METHODS run RETURNING VALUE(rv) TYPE i.
ENDCLASS.
CLASS {n} IMPLEMENTATION.
METHOD run.
rv = 1.
ENDMETHOD.
ENDCLASS.
"""
BAD = """CLASS {n} DEFINITION PUBLIC FINAL CREATE PUBLIC.
PUBLIC SECTION.
METHODS run IMPORTING iv_zone TYPE c LENGTH 4 RETURNING VALUE(rv) TYPE i.
ENDCLASS.
CLASS {n} IMPLEMENTATION.
METHOD run.
rv = 1.
ENDMETHOD.
ENDCLASS.
"""
TC = """CLASS ltc DEFINITION FINAL FOR TESTING DURATION SHORT RISK LEVEL HARMLESS.
PRIVATE SECTION.
METHODS t1 FOR TESTING.
ENDCLASS.
CLASS ltc IMPLEMENTATION.
METHOD t1.
cl_abap_unit_assert=>assert_equals( act = 1 exp = 1 ).
ENDMETHOD.
ENDCLASS.
"""
created = []
RUN = time.strftime("%H%M%S")
def new_class(m, tag):
name = "ZEPODT_%s_%s" % (tag, RUN)
m.call("sap_create_object", {"objectType": "CLAS", "objectName": name, "packageName": "$TMP", "description": "epod acceptance test"})
created.append("/sap/bc/adt/oo/classes/" + name.lower())
return name
def ok(text):
return '"success":true' in text.replace(" ", "")
def t1_syntax_hint():
with Mcp() as m:
n = new_class(m, "T1")
e, t = m.call("sap_push_source", {"objectType": "CLAS", "objectName": n, "source": BAD.format(n=n.lower())})
low = t.lower()
has_detail = ("syntaxcheck" in low or "syntax" in low and "line" in low) and "save operation" in low or "line 3" in low or '"line":3' in low.replace(" ", "")
return bool(has_detail), "the failed write returned: " + t[:300]
def t2_no_lock_after_kill():
victim = ("import sys,json,os\nsys.path.insert(0,%r)\nfrom epod_client import Mcp\na=json.loads(sys.argv[1])\nm=Mcp().open()\nprint('SENT',flush=True)\nm.call('sap_push_source',a)\n"
% os.path.dirname(os.path.abspath(__file__)))
bad = []
for delay in (0.05, 0.2, 0.4, 0.7):
with Mcp() as m:
n = new_class(m, "T2")
m.call("sap_push_source", {"objectType": "CLAS", "objectName": n, "source": CLS.format(n=n.lower())})
p = subprocess.Popen([sys.executable, "-c", victim, json.dumps({"objectType": "CLAS", "objectName": n, "includeType": "testclasses", "source": TC})],
stdout=subprocess.PIPE, text=True)
p.stdout.readline()
time.sleep(delay)
try:
os.kill(p.pid, signal.SIGKILL)
except ProcessLookupError:
pass
p.wait()
time.sleep(3)
with Mcp() as m:
e, t = m.call("sap_push_source", {"objectType": "CLAS", "objectName": n, "includeType": "testclasses", "source": TC + "* again\n"})
if not ok(t):
bad.append((delay, t[:160]))
return not bad, "after the kill the next write failed at: %s" % bad if bad else "4 kills (0.05 to 0.7 s), the next write always worked"
def parallel(n_clients, seconds, work):
out, end = [], time.time() + seconds
def run(i):
locks = calls = 0
with Mcp() as m:
while time.time() < end:
t = work(m, i)
calls += 1
locks += "Concurrent call detected" in t
out.append((calls, locks))
th = [threading.Thread(target=run, args=(i,)) for i in range(n_clients)]
[x.start() for x in th]
[x.join() for x in th]
return sum(c for c, _ in out), sum(l for _, l in out)
def t3_parallel_reads():
def work(m, i):
return m.call("sap_search_object", {"query": "CL_ABAP_CHAR_UTIL*", "objType": "CLAS"})[1] + \
m.call("sap_syntax_check", {"objectType": "CLAS", "objectName": "CL_ABAP_CHAR_UTILITIES"})[1]
calls, locks = parallel(6, 15, work)
return locks == 0, "%d rounds, %d 'Concurrent call detected'" % (calls, locks)
def t4_parallel_writes():
cnt = {"n": 0}
def work(m, i):
cnt["n"] += 1
name = "ZEPODT_P%d_%s%03d" % (i, RUN, cnt["n"] % 1000)
t = m.call("sap_create_object", {"objectType": "CLAS", "objectName": name, "packageName": "$TMP", "description": "parallel"})[1]
created.append("/sap/bc/adt/oo/classes/" + name.lower())
return t + m.call("sap_push_source", {"objectType": "CLAS", "objectName": name, "source": CLS.format(n=name.lower())})[1]
calls, locks = parallel(3, 25, work)
return locks == 0, "%d create+write rounds with 3 clients, %d 'Concurrent call detected'" % (calls, locks)
def t5_lock_error_text():
return True, "informational: see docs/epod-lock-leak.md (no way to create a lock on purpose)"
TESTS = [("T1", "syntax_hint", t1_syntax_hint), ("T2", "no_lock_after_kill", t2_no_lock_after_kill), ("T3", "parallel_reads", t3_parallel_reads),
("T4", "parallel_writes", t4_parallel_writes), ("T5", "lock_error_text", t5_lock_error_text)]
def main():
only = set(sys.argv[sys.argv.index("--only") + 1:]) if "--only" in sys.argv else None
result = []
for tid, name, fn in TESTS:
if only and tid not in only:
continue
t0 = time.time()
try:
passed, why = fn()
except Exception as e: # noqa: BLE001
passed, why = False, "error: %r" % e
print("%-4s %-22s %s (%.0f s) %s" % (tid, name, "PASS" if passed else "FAIL", time.time() - t0, why[:230]), flush=True)
result.append({"id": tid, "name": name, "pass": passed, "detail": why[:600]})
adt = Adt()
if adt.available() and created:
try:
gone = adt.delete(created)
print("cleanup: %d of %d probe objects deleted" % (sum(gone.values()), len(created)))
except Exception as e: # noqa: BLE001
print("cleanup failed:", repr(e)[:200])
elif created:
print("delete these probe objects yourself (SE80 or ADT): ZEPODT_* in $TMP (%d objects)" % len(created))
json.dump(result, open("epod_tests_result.json", "w"), indent=1)
if __name__ == "__main__":
main()