283 lines
14 KiB
Python
283 lines
14 KiB
Python
"""Opt-in host SDK contract check. Pass only absolute runtime paths."""
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
HELLO = {
|
|
"kind": "hello", "version": "dever-component-1", "port": "example.Text",
|
|
"schema": "fixture-schema-v1", "adapter": "example.TextAdapter",
|
|
"capabilities": [], "operations": ["text.render", "text.fail", "number.echo", "text.stubborn"],
|
|
"setting": {"prefix": "hello "},
|
|
}
|
|
|
|
|
|
def frame(message):
|
|
body = json.dumps(message, separators=(",", ":")).encode()
|
|
return struct.pack(">I", len(body)) + body
|
|
|
|
|
|
def read_exact(stream, size):
|
|
result = bytearray()
|
|
while len(result) < size:
|
|
part = stream.read(size - len(result))
|
|
if not part:
|
|
raise AssertionError("worker closed protocol stream")
|
|
result.extend(part)
|
|
return bytes(result)
|
|
|
|
|
|
def receive(process):
|
|
deadline = threading.Timer(3, process.kill)
|
|
deadline.start()
|
|
try:
|
|
size = struct.unpack(">I", read_exact(process.stdout, 4))[0]
|
|
assert 0 < size <= 16 * 1024 * 1024
|
|
return json.loads(read_exact(process.stdout, size))
|
|
finally:
|
|
deadline.cancel()
|
|
|
|
|
|
def send(process, message):
|
|
process.stdin.write(frame(message))
|
|
process.stdin.flush()
|
|
|
|
|
|
def run_case(command, *, keep_input_open=False):
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, HELLO)
|
|
ready = receive(process)
|
|
assert ready == {key: value for key, value in HELLO.items() if key != "setting"} | {"kind": "ready"}, ready
|
|
send(process, {"kind": "health", "id": 0})
|
|
assert receive(process) == {"kind": "health", "id": 0}
|
|
send(process, {"kind": "call", "id": 1, "operation": "text.render", "payload": {"text": "world", "delay_ms": 0}})
|
|
assert receive(process) == {"kind": "result", "id": 1, "payload": {"value": "hello world"}}
|
|
send(process, {"kind": "call", "id": 2, "operation": "text.fail", "payload": {"text": "x", "delay_ms": 0}})
|
|
assert receive(process) == {"kind": "error", "id": 2, "error": "example.rejected", "payload": {"reason": "rejected"}}
|
|
send(process, {"kind": "call", "id": 3, "operation": "text.render", "payload": {"text": "late", "delay_ms": 500}})
|
|
send(process, {"kind": "cancel", "id": 3})
|
|
assert receive(process) == {"kind": "error", "id": 3, "error": "dever.cancelled", "payload": None}
|
|
time.sleep(0.6)
|
|
send(process, {"kind": "call", "id": 4, "operation": "text.render", "payload": {"text": "again", "delay_ms": 0}})
|
|
assert receive(process) == {"kind": "result", "id": 4, "payload": {"value": "hello again"}}
|
|
for request_id, number in enumerate((9007199254740993, 9223372036854775807, -9223372036854775808), 5):
|
|
send(process, {"kind": "call", "id": request_id, "operation": "number.echo", "payload": {"number": number}})
|
|
assert receive(process) == {"kind": "result", "id": request_id, "payload": {"number": number}}
|
|
for request_id in (8, 9):
|
|
send(process, {"kind": "call", "id": request_id, "operation": "text.render", "payload": {"text": "cancel", "delay_ms": 500}})
|
|
send(process, {"kind": "cancel", "id": request_id})
|
|
assert receive(process) == {"kind": "error", "id": request_id, "error": "dever.cancelled", "payload": None}
|
|
send(process, {"kind": "shutdown"})
|
|
assert receive(process) == {"kind": "shutdown"}
|
|
# Python must finalize its reader without relying on the host's EOF timing.
|
|
if not keep_input_open:
|
|
process.stdin.close()
|
|
assert process.wait(timeout=3) == 0, process.stderr.read().decode()
|
|
except Exception as error:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
raise AssertionError(f"worker case failed: {error}; stderr={process.stderr.read().decode()}") from error
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def run_queued_shutdown(command):
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, HELLO)
|
|
assert receive(process)["kind"] == "ready"
|
|
messages = [
|
|
{"kind": "call", "id": 1, "operation": "text.render", "payload": {"text": "cancel", "delay_ms": 500}},
|
|
{"kind": "shutdown"},
|
|
*[{"kind": "health", "id": 0} for _ in range(4)],
|
|
]
|
|
process.stdin.write(b"".join(frame(message) for message in messages))
|
|
process.stdin.flush()
|
|
assert receive(process) == {"kind": "shutdown"}
|
|
assert process.wait(timeout=3) == 0, process.stderr.read().decode()
|
|
assert process.stderr.read() == b"", "shutdown left task/transport diagnostics"
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def run_typed_case(command, *, keep_input_open=False):
|
|
hello = {
|
|
"kind": "hello", "version": "dever-component-1", "port": "example.Typed",
|
|
"schema": "typed-fixture-v1", "adapter": "example.Managed",
|
|
"capabilities": [], "operations": ["render"], "setting": {"prefix": "hello "},
|
|
}
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, hello)
|
|
assert receive(process)["kind"] == "ready"
|
|
message = {"name": "Ada", "number": 9223372036854775807, "labels": ["a", "b"]}
|
|
send(process, {"kind": "call", "id": 1, "operation": "render", "payload": {"message": message}})
|
|
assert receive(process) == {"kind": "result", "id": 1, "payload": {"text": "hello Ada9223372036854775807"}}
|
|
message["name"] = "reject"
|
|
send(process, {"kind": "call", "id": 2, "operation": "render", "payload": {"message": message}})
|
|
assert receive(process) == {"kind": "error", "id": 2, "error": "example.Rejected", "payload": {"reason": "rejected"}}
|
|
send(process, {"kind": "shutdown"})
|
|
assert receive(process) == {"kind": "shutdown"}
|
|
if not keep_input_open:
|
|
process.stdin.close()
|
|
assert process.wait(timeout=3) == 0, process.stderr.read().decode()
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, hello)
|
|
assert receive(process)["kind"] == "ready"
|
|
send(process, {"kind": "call", "id": 1, "operation": "render", "payload": {
|
|
"message": {"name": "Ada", "number": 9223372036854775808, "labels": []},
|
|
}})
|
|
process.stdin.close()
|
|
assert process.wait(timeout=3) != 0
|
|
assert process.stdout.read() == b""
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def run_rejection(command, body):
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process:
|
|
try:
|
|
process.stdin.write(struct.pack(">I", len(body)) + body)
|
|
process.stdin.flush()
|
|
process.stdin.close()
|
|
assert process.wait(timeout=3) != 0
|
|
assert process.stdout.read() == b""
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def run_protocol_rejection(command, body, declared_size=None):
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, HELLO)
|
|
assert receive(process)["kind"] == "ready"
|
|
process.stdin.write(struct.pack(">I", len(body) if declared_size is None else declared_size) + body)
|
|
process.stdin.flush()
|
|
process.stdin.close()
|
|
assert process.wait(timeout=3) != 0
|
|
assert process.stdout.read() == b""
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def run_unresponsive_handler(command, final_message):
|
|
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) as process:
|
|
try:
|
|
send(process, HELLO)
|
|
assert receive(process)["kind"] == "ready"
|
|
send(process, {"kind": "call", "id": 1, "operation": "text.stubborn", "payload": {"text": "late", "delay_ms": 0}})
|
|
time.sleep(0.1)
|
|
send(process, final_message)
|
|
if final_message["kind"] == "cancel":
|
|
send(process, {"kind": "call", "id": 2, "operation": "text.render", "payload": {"text": "new", "delay_ms": 0}})
|
|
send(process, {"kind": "cancel", "id": 2})
|
|
send(process, {"kind": "shutdown"})
|
|
assert process.wait(timeout=3) != 0
|
|
assert process.stdout.read() == b""
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=3)
|
|
|
|
|
|
def runtime(path):
|
|
if path is None:
|
|
return None
|
|
executable = Path(path)
|
|
if not executable.is_absolute() or not executable.is_file():
|
|
raise SystemExit(f"runtime path must be an existing absolute file: {path}")
|
|
return str(executable)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--python")
|
|
parser.add_argument("--node")
|
|
parser.add_argument("--go")
|
|
args = parser.parse_args()
|
|
with tempfile.TemporaryDirectory(prefix="dever-sdk-check-") as directory:
|
|
selected = {
|
|
"Python": [runtime(args.python), "-B", str(ROOT / "sdk/python/example.py")] if args.python else None,
|
|
"JavaScript": [runtime(args.node), str(ROOT / "sdk/javascript/example.mjs")] if args.node else None,
|
|
}
|
|
if args.go:
|
|
go = runtime(args.go)
|
|
binary = str(Path(directory) / "go-worker")
|
|
build_environment = {
|
|
"GOCACHE": str(Path(directory) / "go-cache"),
|
|
"GOPATH": str(Path(directory) / "go-path"),
|
|
"GOENV": "off", "GOTOOLCHAIN": "local", "GOPROXY": "off", "GOSUMDB": "off",
|
|
}
|
|
subprocess.run([go, "build", "-o", binary, "./example"], cwd=ROOT / "sdk/go", env=build_environment, check=True)
|
|
selected["Go"] = [binary]
|
|
else:
|
|
selected["Go"] = None
|
|
manifest = str(ROOT / "test/external-sdk/typed_contract.json")
|
|
typed = {
|
|
"Python": [runtime(args.python), "-B", str(ROOT / "test/external-sdk/typed_worker.py"), manifest] if args.python else None,
|
|
"JavaScript": [runtime(args.node), str(ROOT / "test/external-sdk/typed_worker.mjs"), manifest] if args.node else None,
|
|
}
|
|
if args.go:
|
|
typed_binary = str(Path(directory) / "go-typed-worker")
|
|
subprocess.run([go, "build", "-o", typed_binary, str(ROOT / "test/external-sdk/typed_worker.go")], cwd=ROOT / "sdk/go", env=build_environment, check=True)
|
|
typed["Go"] = [typed_binary, manifest]
|
|
else:
|
|
typed["Go"] = None
|
|
for language, command in selected.items():
|
|
if command is None:
|
|
print(f"{language}: unavailable (no absolute runtime path supplied)")
|
|
continue
|
|
run_case(command, keep_input_open=language == "Python")
|
|
if language == "Python":
|
|
run_queued_shutdown(command)
|
|
run_rejection(command, json.dumps({**HELLO, "schema": "wrong"}).encode())
|
|
run_rejection(command, (json.dumps(HELLO)[:-1] + ',"schema":"duplicate"}').encode())
|
|
run_rejection(command, (json.dumps(HELLO)[:-1] + ',"extra":true}').encode())
|
|
run_rejection(command, b"\xff")
|
|
call = {"kind": "call", "id": 1, "operation": "text.render", "payload": {"text": "x", "delay_ms": 0}}
|
|
run_protocol_rejection(command, json.dumps({**call, "id": 2}).encode())
|
|
run_protocol_rejection(command, json.dumps({**call, "operation": "unknown"}).encode())
|
|
run_protocol_rejection(command, (json.dumps(call)[:-2] + ',"text":"duplicate"}}').encode())
|
|
run_protocol_rejection(command, (json.dumps(call)[:-1] + ',"extra":true}').encode())
|
|
run_protocol_rejection(command, b'{"kind":"call","id":1,"operation":"text.render","payload":NaN}')
|
|
run_protocol_rejection(command, b'{"kind":"call","id":1,"operation":"text.render","payload":1e999}')
|
|
nested = 0
|
|
for _ in range(64):
|
|
nested = [nested]
|
|
run_protocol_rejection(command, json.dumps({**call, "payload": nested}).encode())
|
|
run_protocol_rejection(command, json.dumps({**call, "payload": [0] * 65_537}).encode())
|
|
run_protocol_rejection(command, b"", declared_size=16 * 1024 * 1024 + 1)
|
|
run_unresponsive_handler(command, {"kind": "cancel", "id": 1})
|
|
run_unresponsive_handler(command, {"kind": "shutdown"})
|
|
run_typed_case(typed[language], keep_input_open=language == "Python")
|
|
print(f"{language}: handshake, health, result, business error, cancel, shutdown and rejection passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|