Initial Dever 0.0.1
This commit is contained in:
commit
81233d098c
1479 files changed
+163782
No files matched your search
@@ -0,0 +1,82 @@
|
||||
#!/bin/sh
|
||||
# 干净容器验收;先上传归档、checksum、项目夹具和 test/dever-tests/fixtures/tls。
|
||||
set -eu
|
||||
cd /work
|
||||
for tool in rustc cargo rustup gcc cc ld; do
|
||||
if command -v "$tool"; then
|
||||
echo "unexpected preinstalled compiler: $tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
test ! -f /usr/include/stdio.h
|
||||
cat /etc/os-release
|
||||
if [ "${1:-}" = --installed ]; then
|
||||
mv "$2" /work/dever
|
||||
else
|
||||
archive=${1:-dever-0.0.1-linux-x86_64-development.tar.xz}
|
||||
sha256sum -c "$archive.sha256"
|
||||
python3 -m tarfile -e "$archive" --filter=data
|
||||
fi
|
||||
validation_path=$PATH
|
||||
export PATH="/work/dever/bin:$PATH"
|
||||
dever new hello
|
||||
dever check hello
|
||||
dever fmt hello --check
|
||||
dever test hello
|
||||
dever run hello -- hello.greeting.greet '{"name":"Dever"}' > hello.json
|
||||
grep -F '"data":"Hello, Dever"' hello.json
|
||||
dever build hello --output /work/hello-app
|
||||
mv dever 'dever moved'
|
||||
export PATH="/work/dever moved/bin:$validation_path"
|
||||
dever build hello --output /work/hello-moved
|
||||
./hello-moved hello.greeting.greet '{"name":"moved"}' > moved.json
|
||||
grep -F '"data":"Hello, moved"' moved.json
|
||||
mkdir http database
|
||||
dever build fixtures/http-application --output /work/http/app
|
||||
dever build fixtures/database --output /work/database/app
|
||||
mkdir database/config
|
||||
cat > database/config/setting.json <<'JSON'
|
||||
{"database":{"default":{"type":"sqlite","path":"data/account.db","max_connections":1,"max_page_size":20}}}
|
||||
JSON
|
||||
./database/app demo.account.create '{"name":"offline"}' > created.json
|
||||
grep -F '"name":"offline"' created.json
|
||||
./database/app demo.account.get '{"id":1}' > fetched.json
|
||||
cmp created.json fetched.json
|
||||
if ./database/app demo.account.explode '{"name":"rollback","value":9223372036854775807}' > rollback.json 2> rollback.log; then
|
||||
echo 'transaction fault unexpectedly succeeded' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F 'integer arithmetic fault' rollback.log
|
||||
./database/app demo.account.count '{}' > count.json
|
||||
grep -F '"data":1' count.json
|
||||
# 全部运行库特性须在裁剪后的 vendor 中仍能离线解析和编译,包括 PostgreSQL、Job、Worker。
|
||||
toolchain_root=$(dirname "$(dever skill path)")
|
||||
python3 - "$toolchain_root" <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
runtime = Path(sys.argv[1]) / 'rust/src/runtime'
|
||||
features = sorted(tomllib.loads((runtime / 'Cargo.toml').read_text())['features'])
|
||||
project = Path('/work/runtime-all')
|
||||
(project / 'src').mkdir(parents=True)
|
||||
(project / 'src/main.rs').write_text('fn main() {}\n')
|
||||
(project / 'Cargo.toml').write_text(
|
||||
'[package]\nname = "all-runtime-features"\nversion = "0.0.0"\nedition = "2024"\n'
|
||||
'[workspace]\n[dependencies]\n'
|
||||
f'dever-runtime = {{ path = {json.dumps(str(runtime))}, features = {json.dumps(features)} }}\n'
|
||||
)
|
||||
PY
|
||||
"$toolchain_root/toolchain/bin/cargo" build --offline --release \
|
||||
--manifest-path /work/runtime-all/Cargo.toml --target-dir "$toolchain_root/rust/target"
|
||||
"$toolchain_root/rust/target/release/all-runtime-features"
|
||||
echo 'ALL_RUNTIME_FEATURES_PASS'
|
||||
# 隐藏整个开发包后,正式二进制依然必须正常执行。
|
||||
mv 'dever moved' hidden-toolchain
|
||||
./hello-app hello.greeting.greet '{"name":"standalone"}' > standalone.json
|
||||
grep -F '"data":"Hello, standalone"' standalone.json
|
||||
echo 'BUILD_AND_DATABASE_PASS'
|
||||
python3 /work/transport.py
|
||||
wc -c /work/hello-app /work/http/app /work/database/app
|
||||
echo 'CLEAN_DEVELOPMENT_PASS'
|
||||
@@ -0,0 +1,60 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: dever-development-check
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 86400
|
||||
activeDeadlineSeconds: 7200
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dever-development-check
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: check
|
||||
image: docker.io/library/python@sha256:aaf08c5490d52cd92bc27142a5215b1e47355a5eaf9adfad2a0fbc022d0b5490
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- 'while [ ! -f /work/start ]; do sleep 1; done; exec /bin/sh /work/check.sh'
|
||||
env:
|
||||
- name: CARGO_BUILD_JOBS
|
||||
value: "4"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 4Gi
|
||||
limits:
|
||||
cpu: "4"
|
||||
memory: 8Gi
|
||||
ephemeral-storage: 12Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
volumeMounts:
|
||||
- name: work
|
||||
mountPath: /work
|
||||
volumes:
|
||||
- name: work
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: dever-development-check
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app: dever-development-check
|
||||
policyTypes: [Egress]
|
||||
egress: []
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# 真实下载与共享缓存验收;准备完成后由调用方关闭本 Job 的外网再跑通用开发验收。
|
||||
set -euo pipefail
|
||||
cd /work
|
||||
export SHELL=/bin/bash
|
||||
for certificate in root.pem server.pem server-key.pem; do
|
||||
test -f "/work/tls/$certificate" || { echo "Missing TLS fixture: $certificate" >&2; exit 1; }
|
||||
done
|
||||
for tool in rustc cargo rustup gcc cc ld; do
|
||||
if command -v "$tool"; then
|
||||
echo "unexpected preinstalled compiler: $tool" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
python3 /work/server.py > server.log 2>&1 &
|
||||
server=$!
|
||||
finish() {
|
||||
local code=$?
|
||||
if (( code )); then
|
||||
echo "ONLINE_VALIDATION_FAILED: $code" >&2
|
||||
for log in retry.log first-run.err first-test.err wait.log http/server-False.log http/server-True.log server.log; do
|
||||
if [[ -f $log ]]; then printf '\n%s\n' "$log"; tail -n 20 "$log"; fi
|
||||
done
|
||||
fi
|
||||
kill "$server"
|
||||
wait "$server" || true
|
||||
}
|
||||
trap finish EXIT
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
import time
|
||||
for attempt in range(100):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', 18080), timeout=1):
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError('local release server did not start')
|
||||
PY
|
||||
url=http://127.0.0.1:18080
|
||||
prefix='/work/install/dever tools'
|
||||
export RUSTUP_DIST_SERVER=$url
|
||||
touch /work/reject-environment
|
||||
curl -fsSL "$url/install" | timeout 30 bash -s -- --prefix "$prefix" --release-url "$url" > install.log 2>&1
|
||||
cat install.log
|
||||
if grep -Ei 'rustc|cargo|private Rust' install.log; then exit 1; fi
|
||||
test "$("$prefix/bin/dever" --version)" = 'dever 0.0.1'
|
||||
"$prefix/bin/dever" new editing
|
||||
"$prefix/bin/dever" check editing
|
||||
"$prefix/bin/dever" fmt editing --check
|
||||
test ! -e "$prefix/versions/0.0.1/toolchain/ready"
|
||||
# 安装已返回且编辑命令可用;首次编译须报告真实下载失败,不能假成功。
|
||||
if "$prefix/bin/dever" build editing --output /work/rejected-app > failed-build.log 2>&1; then
|
||||
echo 'build unexpectedly succeeded without an environment' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F 'Dever environment preparation failed' failed-build.log
|
||||
grep -F '404' "$prefix/versions/0.0.1/toolchain/prepare.log"
|
||||
test ! -e /work/rejected-app
|
||||
rm /work/reject-environment
|
||||
touch /work/hold-environment
|
||||
# 用真实归档前缀模拟上次断线已下载的内容,服务端记录并验证 Range 恢复。
|
||||
native=$(basename /work/assets/dever-native-*.tar.xz)
|
||||
head -c 65536 "/work/assets/$native" > "$prefix/cache/downloads/$native.part"
|
||||
curl -fsSL "$url/install" | timeout 30 bash -s -- --prefix "$prefix" --release-url "$url" > retry.log 2>&1
|
||||
"$prefix/bin/dever" run editing -- hello.greeting.greet '{"name":"background"}' > first-run.log 2> first-run.err &
|
||||
running=$!
|
||||
"$prefix/bin/dever" test editing > first-test.log 2> first-test.err &
|
||||
testing=$!
|
||||
bash assets/install --prefix "$prefix" --release-url "$url" --wait > wait.log 2>&1 &
|
||||
waiting=$!
|
||||
for attempt in {1..100}; do
|
||||
if [[ -f /work/parallel-downloads ]]; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
test -f /work/parallel-downloads
|
||||
kill -0 "$running" "$testing" "$waiting"
|
||||
test ! -e "$prefix/versions/0.0.1/toolchain/ready"
|
||||
if flock -n "$prefix/.prepare.lock" true; then
|
||||
echo 'preparation lock was not held' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo BACKGROUND_EDITING_AND_PARALLEL_DOWNLOAD_PASS
|
||||
rm /work/hold-environment
|
||||
wait "$running"
|
||||
wait "$testing"
|
||||
wait "$waiting"
|
||||
cat first-run.log first-run.err first-test.log
|
||||
grep -F '"data":"Hello, background"' first-run.log
|
||||
test "$(cat /work/resumed-offset)" = 65536
|
||||
test -f "$prefix/versions/0.0.1/toolchain/ready"
|
||||
if grep -Ei 'rustc|cargo|rust-std' first-run.err first-test.err wait.log; then exit 1; fi
|
||||
echo RESUME_AND_CONCURRENT_BUILD_PASS
|
||||
test "$(grep -c 'dever.*env' "$HOME/.bashrc")" -eq 1
|
||||
first=$(find "$prefix/toolchains" -type f -printf '%P %s %T@\n' | sort | sha256sum)
|
||||
curl -fsSL "$url/install" | bash -s -- --prefix "$prefix" --release-url "$url" --wait
|
||||
second=$(find "$prefix/toolchains" -type f -printf '%P %s %T@\n' | sort | sha256sum)
|
||||
test "$first" = "$second"
|
||||
test "$(grep -c 'dever.*env' "$HOME/.bashrc")" -eq 1
|
||||
# 校验失败不能切换现有命令,下载错误不得伪报成功。
|
||||
active=$(readlink "$prefix/bin/dever")
|
||||
cp assets/dever-0.0.1-linux-x86_64.tar.xz assets/dever-0.0.2-linux-x86_64.tar.xz
|
||||
printf '%064d dever-0.0.2-linux-x86_64.tar.xz\n' 0 > assets/dever-0.0.2-linux-x86_64.tar.xz.sha256
|
||||
if bash assets/install --prefix "$prefix" --release-url "$url" --version 0.0.2 > rejected.log 2>&1; then
|
||||
echo 'corrupt release unexpectedly installed' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -F FAILED rejected.log
|
||||
test "$(readlink "$prefix/bin/dever")" = "$active"
|
||||
test ! -e "$prefix/versions/0.0.2"
|
||||
test -z "$(find "$prefix" -maxdepth 1 -name '.install.*' -type d -print)"
|
||||
du -sk "$prefix" "$prefix/cache/cargo" "$prefix/toolchains"
|
||||
echo ONLINE_INSTALL_PASS
|
||||
touch /work/online-ready
|
||||
while [ ! -f /work/offline-start ]; do sleep 1; done
|
||||
bash /work/check.sh --installed "$prefix"
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: dever-online-check
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 86400
|
||||
activeDeadlineSeconds: 7200
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: dever-online-check
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: check
|
||||
image: git.dever.cc/platform/workspace@sha256:795ba3591832e16622bc949adae46fb907eacb54499ba3f80dae708361bc2752
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
while [ ! -f /work/start ]; do sleep 1; done
|
||||
exec bash /work/online.sh
|
||||
env:
|
||||
- name: CARGO_BUILD_JOBS
|
||||
value: "4"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
ephemeral-storage: 4Gi
|
||||
limits:
|
||||
cpu: "4"
|
||||
memory: 8Gi
|
||||
ephemeral-storage: 12Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
volumeMounts:
|
||||
- name: work
|
||||
mountPath: /work
|
||||
volumes:
|
||||
- name: work
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""仅为安装验收控制下载边界;工具链内容仍来自官方源。"""
|
||||
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
WORK = Path('/work')
|
||||
lock = threading.Lock()
|
||||
active = 0
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
global active
|
||||
environment = self.path.startswith(('/dist/', '/dever-native-'))
|
||||
if environment and (WORK / 'reject-environment').exists():
|
||||
self.send_error(404, 'intentional preparation failure')
|
||||
return
|
||||
archive = environment and self.path.endswith('.tar.xz')
|
||||
if archive:
|
||||
with lock:
|
||||
active += 1
|
||||
if active >= 2:
|
||||
(WORK / 'parallel-downloads').touch()
|
||||
try:
|
||||
while (WORK / 'hold-environment').exists():
|
||||
time.sleep(0.1)
|
||||
self.download()
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
else:
|
||||
self.download()
|
||||
|
||||
def download(self):
|
||||
if self.path.startswith('/dist/'):
|
||||
self.send_response(302)
|
||||
self.send_header('Location', 'https://static.rust-lang.org' + self.path)
|
||||
self.end_headers()
|
||||
return
|
||||
resume = self.headers.get('Range')
|
||||
if resume and self.path.startswith('/dever-native-'):
|
||||
offset = int(resume.removeprefix('bytes=').removesuffix('-'))
|
||||
path = Path(self.translate_path(self.path))
|
||||
size = path.stat().st_size
|
||||
(WORK / 'resumed-offset').write_text(str(offset))
|
||||
self.send_response(206)
|
||||
self.send_header('Content-Range', f'bytes {offset}-{size - 1}/{size}')
|
||||
self.send_header('Content-Length', str(size - offset))
|
||||
self.end_headers()
|
||||
with path.open('rb') as source:
|
||||
source.seek(offset)
|
||||
self.copyfile(source, self.wfile)
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
|
||||
ThreadingHTTPServer(('127.0.0.1', 18080), partial(Handler, directory='/work/assets')).serve_forever()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""在验收容器的回环接口验证已脱离开发包的 HTTP/TLS 产物。"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
root = Path("/work/http")
|
||||
(root / "config").mkdir(exist_ok=True)
|
||||
certificates = Path("/work/tls")
|
||||
for encrypted in (False, True):
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
settings = {"http": {"host": "127.0.0.1", "port": port},
|
||||
"sites": {"public": {"path": "", "hosts": ["localhost"]}}}
|
||||
if encrypted:
|
||||
settings["http"]["tls"] = {"certificate": str(certificates / "server.pem"),
|
||||
"key": str(certificates / "server-key.pem")}
|
||||
(root / "config/setting.json").write_text(json.dumps(settings))
|
||||
with (root / f"server-{encrypted}.log").open("w") as log:
|
||||
server = subprocess.Popen([str(root / "app")], stdout=log, stderr=log)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
assert server.poll() is None, (root / f"server-{encrypted}.log").read_text()
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
assert time.monotonic() < deadline, "HTTP server startup timed out"
|
||||
time.sleep(0.05)
|
||||
if encrypted:
|
||||
context = ssl.create_default_context(cafile=str(certificates / "root.pem"))
|
||||
client = http.client.HTTPSConnection("localhost", port, context=context, timeout=5)
|
||||
else:
|
||||
client = http.client.HTTPConnection("localhost", port, timeout=5)
|
||||
try:
|
||||
client.request("GET", "/demo/greeting/greet?name=Dever")
|
||||
response = client.getresponse()
|
||||
assert response.status == 200, response.status
|
||||
assert json.load(response)["data"] == "Hello, Dever"
|
||||
finally:
|
||||
client.close()
|
||||
finally:
|
||||
server.terminate()
|
||||
server.wait(timeout=10)
|
||||
print("HTTP_TLS_PASS")
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBmTCCAT+gAwIBAgIUYx/Ad4tJiQ4uakAPbpXu5Senv2YwCgYIKoZIzj0EAwIw
|
||||
GjEYMBYGA1UEAwwPRGV2ZXItVGVzdC1Sb290MB4XDTI2MDkxMjA4MTY1MFoXDTM2
|
||||
MDkwOTA4MTY1MFowGjEYMBYGA1UEAwwPRGV2ZXItVGVzdC1Sb290MFkwEwYHKoZI
|
||||
zj0CAQYIKoZIzj0DAQcDQgAECOux92VpsFwb2YOTRs6UATfp7HPm+JeCTMkJNXPo
|
||||
rqLZa4Vi/71dLQzNFxxsXxqFyf4oBYoCD0XMEicxYUgnO6NjMGEwHQYDVR0OBBYE
|
||||
FIo/Vdu7qm5dIikkwg6A0yRp1YzVMB8GA1UdIwQYMBaAFIo/Vdu7qm5dIikkwg6A
|
||||
0yRp1YzVMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49
|
||||
BAMCA0gAMEUCICmmB/eG055dU7F9OP7K9BUxOkFnYNtzIpYJfd0VwG6LAiEA4oiP
|
||||
pnep4mLGHAnvsucvvqDA5SVdHzYh22OYPdSgNeY=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYl2wcdhzZMZrQxSV
|
||||
ClBRv8AQqRyuJf5bSVXw5w6XLWmhRANCAAROwX6ZplJMfQicbaDXSZ7qoLPGDdr6
|
||||
EMtvQvlJdwVI44/NVX/lIS2dhF0h1GxKAippeSiLj9+Srh2mmg7+zEvK
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBdjCCARugAwIBAgIBATAKBggqhkjOPQQDAjAaMRgwFgYDVQQDDA9EZXZlci1U
|
||||
ZXN0LVJvb3QwHhcNMjYwOTEyMDgxNjUwWhcNMzYwOTA5MDgxNjUwWjAUMRIwEAYD
|
||||
VQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAROwX6ZplJM
|
||||
fQicbaDXSZ7qoLPGDdr6EMtvQvlJdwVI44/NVX/lIS2dhF0h1GxKAippeSiLj9+S
|
||||
rh2mmg7+zEvKo1gwVjAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFEc4
|
||||
R7JUtHLl16fHXxBvGDz0qwnKMB8GA1UdIwQYMBaAFIo/Vdu7qm5dIikkwg6A0yRp
|
||||
1YzVMAoGCCqGSM49BAMCA0kAMEYCIQCzl7jB3XrkRoQXTXMoi9j1OzmCPO3UXLr5
|
||||
eDq/F8G/twIhALgVU/5C4hx6hpVtlHUmd49EibizuPlNeaCjRPB1WqDM
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,98 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { promisify } = require('node:util');
|
||||
const run = promisify(require('node:child_process').execFile);
|
||||
|
||||
test('generated flowcharts render offline and source clicks retain the actual location', async () => {
|
||||
assert.ok(process.env.DEVER_TEST_BIN);
|
||||
assert.ok(process.env.DEVER_BROWSER_TOOLS, 'directory containing existing playwright-core and @sparticuz/chromium');
|
||||
const tools = Module.createRequire(path.join(process.env.DEVER_BROWSER_TOOLS, '..', 'package.json'));
|
||||
const { chromium: playwright } = tools('playwright-core');
|
||||
const chromium = tools('@sparticuz/chromium').default;
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dever flow browser '));
|
||||
const extension = path.resolve(__dirname, '../../editor/vscode');
|
||||
const project = path.join(root, 'project');
|
||||
const subscriptions = [];
|
||||
let browser;
|
||||
const originalLoad = Module._load;
|
||||
try {
|
||||
await run(process.env.DEVER_TEST_BIN, ['new', project]);
|
||||
await run(process.env.DEVER_TEST_BIN, ['inspect', project, '--links']);
|
||||
const uri = filename => ({ fsPath: filename, toString: () => pathToFileURL(filename).toString() });
|
||||
const panels = [];
|
||||
const vscode = {
|
||||
Uri: { joinPath: (base, ...parts) => uri(path.join(base.fsPath, ...parts)) },
|
||||
ViewColumn: { Beside: 2 },
|
||||
window: { createWebviewPanel() {
|
||||
const panel = { dispose() {}, onDidDispose() {}, webview: {
|
||||
cspSource: 'https://dever-preview.test',
|
||||
asWebviewUri: uri => `https://dever-preview.test/${path.basename(uri.fsPath)}`,
|
||||
onDidReceiveMessage: () => ({ dispose() {} })
|
||||
} };
|
||||
panels.push(panel);
|
||||
return panel;
|
||||
} }
|
||||
};
|
||||
Module._load = function(request, ...rest) { return request === 'vscode' ? vscode : originalLoad.call(this, request, ...rest); };
|
||||
const { openFlowPreview } = require('../../editor/vscode/dist/flow-preview.cjs');
|
||||
Module._load = originalLoad;
|
||||
browser = await playwright.launch({ executablePath: await chromium.executablePath(), args: chromium.args, headless: true });
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', error => console.error('Browser error:', error.message));
|
||||
page.on('console', message => { if (message.type() === 'error') console.error('Browser console:', message.text()); });
|
||||
const scripts = new Map(await Promise.all(['flow-browser.js', 'mermaid.js'].map(async name => [`https://dever-preview.test/${name}`, await fs.readFile(path.join(extension, 'dist', name), 'utf8')])));
|
||||
const messages = [];
|
||||
await page.exposeFunction('deverMessage', message => messages.push(message));
|
||||
await page.route('**/*', route => scripts.has(route.request().url())
|
||||
? route.fulfill({ body: scripts.get(route.request().url()), contentType: 'text/javascript' }) : route.abort());
|
||||
const projects = [project];
|
||||
for (const relative of ['test/rust/fixtures/auth', 'test/rust/fixtures/adapters', 'skills/assets/examples/command', 'skills/assets/examples/external-lib']) {
|
||||
const copy = path.join(root, path.basename(relative));
|
||||
await fs.cp(path.resolve(__dirname, '../..', relative), copy, { recursive: true });
|
||||
await run(process.env.DEVER_TEST_BIN, ['inspect', copy, '--links']);
|
||||
projects.push(copy);
|
||||
}
|
||||
if (process.env.DEVER_FLOW_PROJECT) projects.push(process.env.DEVER_FLOW_PROJECT);
|
||||
const targets = [];
|
||||
for (const owner of projects) {
|
||||
for await (const filename of fs.glob('module/*/*/link.md', { cwd: owner })) targets.push({ filename: path.join(owner, filename), owner });
|
||||
}
|
||||
let count = 0;
|
||||
for (const { filename, owner } of targets) {
|
||||
console.log(`Rendering ${filename}`);
|
||||
await openFlowPreview({ subscriptions, extensionUri: uri(extension) }, uri(filename), owner);
|
||||
const html = panels.at(-1).webview.html;
|
||||
const expected = (html.match(/class="language-mermaid"/g) || []).length;
|
||||
const nonce = /nonce="([^"]+)"/.exec(html)[1];
|
||||
const bridge = `<script nonce="${nonce}">window.acquireVsCodeApi = () => ({postMessage: message => window.deverMessage(message)});</script>`;
|
||||
await page.goto('about:blank');
|
||||
await page.setContent(html.replace('<body ', `${bridge}<body `));
|
||||
assert.equal(await page.evaluate(() => typeof globalThis.mermaid?.render), 'function');
|
||||
await page.waitForSelector('body[data-rendered="true"]', { timeout: 60000 });
|
||||
assert.deepEqual(await page.locator('.dever-flow-error').allTextContents(), [], filename);
|
||||
assert.equal(await page.locator('.dever-flow > svg').count(), expected, filename);
|
||||
assert.ok(expected > 0, filename);
|
||||
count += expected;
|
||||
const source = page.locator('.dever-flow a').first();
|
||||
const href = await source.getAttribute('href') || await source.getAttribute('xlink:href');
|
||||
await source.click();
|
||||
await page.waitForTimeout(20);
|
||||
assert.deepEqual(messages.at(-1), { type: 'open', href });
|
||||
assert.match(href, /\.dever(?:\.md)?#L\d+,\d+$/);
|
||||
if (process.env.DEVER_FLOW_SCREENSHOT && path.basename(path.dirname(filename)) === 'audit') {
|
||||
await page.screenshot({ path: process.env.DEVER_FLOW_SCREENSHOT, fullPage: true });
|
||||
}
|
||||
}
|
||||
console.log(`Rendered ${count} real generated diagrams in ${targets.length} pages without network dependencies`);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
await browser?.close();
|
||||
for (const subscription of subscriptions) subscription.dispose();
|
||||
await fs.rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const extension = path.join(root, 'editor/vscode');
|
||||
const extensionRequire = createRequire(path.join(extension, 'package.json'));
|
||||
const textmate = extensionRequire('vscode-textmate');
|
||||
const oniguruma = extensionRequire('vscode-oniguruma');
|
||||
const cliArguments = process.argv.slice(2);
|
||||
assert.equal(cliArguments.length, 2, 'pass --markdown-grammar followed by the real VS Code grammar path');
|
||||
assert.equal(cliArguments[0], '--markdown-grammar');
|
||||
const markdownPath = path.resolve(cliArguments[1]);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'package.json'), 'utf8'));
|
||||
const configuration = JSON.parse(fs.readFileSync(path.join(extension, 'language-configuration.json'), 'utf8'));
|
||||
|
||||
async function loadGrammars() {
|
||||
const wasm = fs.readFileSync(extensionRequire.resolve('vscode-oniguruma/release/onig.wasm'));
|
||||
await oniguruma.loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength));
|
||||
const catalog = new Map();
|
||||
for (const contribution of manifest.contributes.grammars) {
|
||||
const filename = path.join(extension, contribution.path);
|
||||
catalog.set(contribution.scopeName, textmate.parseRawGrammar(fs.readFileSync(filename, 'utf8'), filename));
|
||||
}
|
||||
const markdown = textmate.parseRawGrammar(fs.readFileSync(markdownPath, 'utf8'), markdownPath);
|
||||
assert.equal(markdown.scopeName, 'text.html.markdown');
|
||||
catalog.set(markdown.scopeName, markdown);
|
||||
const registry = new textmate.Registry({
|
||||
onigLib: Promise.resolve({
|
||||
createOnigScanner: patterns => new oniguruma.OnigScanner(patterns),
|
||||
createOnigString: value => new oniguruma.OnigString(value)
|
||||
}),
|
||||
loadGrammar: scope => catalog.get(scope) || null,
|
||||
getInjections: scope => manifest.contributes.grammars
|
||||
.filter(contribution => contribution.injectTo?.includes(scope))
|
||||
.map(contribution => contribution.scopeName)
|
||||
});
|
||||
return {
|
||||
source: await registry.loadGrammarWithConfiguration('source.dever', 1, {}),
|
||||
markdown: await registry.loadGrammarWithConfiguration('text.html.markdown', 2, {
|
||||
embeddedLanguages: { 'meta.embedded.block.dever': 1 },
|
||||
tokenTypes: { 'meta.embedded.block.dever': 0 }
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const grammars = loadGrammars();
|
||||
|
||||
function tokenize(grammar, document) {
|
||||
let state = textmate.INITIAL;
|
||||
return document.split(/\r\n|\r|\n/).map(line => {
|
||||
const encoded = grammar.tokenizeLine2(line, state);
|
||||
const result = grammar.tokenizeLine(line, state);
|
||||
state = result.ruleStack;
|
||||
return { line, tokens: result.tokens, encoded: encoded.tokens };
|
||||
});
|
||||
}
|
||||
|
||||
function tokenAt(rows, line, fragment) {
|
||||
const row = rows[line];
|
||||
const offset = row.line.indexOf(fragment);
|
||||
assert.ok(offset >= 0, `line ${line + 1} does not contain ${fragment}`);
|
||||
const token = row.tokens.find(candidate => candidate.startIndex <= offset && candidate.endIndex > offset);
|
||||
assert.ok(token, `no token for ${fragment} on line ${line + 1}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
function hasScope(rows, line, fragment, scope) {
|
||||
const actual = tokenAt(rows, line, fragment).scopes;
|
||||
assert.ok(actual.includes(scope), `${fragment}: expected ${scope}, received ${actual.join(' ')}`);
|
||||
}
|
||||
|
||||
function sourceFiles(directory, suffix) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const filename = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(filename, suffix) : filename.endsWith(suffix) ? [filename] : [];
|
||||
});
|
||||
}
|
||||
|
||||
test('types, fields and named function inputs/outputs receive Dever scopes', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'type Profile {',
|
||||
' id: Int?',
|
||||
' name: Text',
|
||||
'}',
|
||||
'greet(name: Text) (message: Text) {',
|
||||
' message = app.format(name)',
|
||||
'}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 0, 'Profile', 'entity.name.type.dever');
|
||||
hasScope(rows, 1, 'id', 'variable.other.member.dever');
|
||||
hasScope(rows, 1, 'Int', 'support.type.dever');
|
||||
hasScope(rows, 4, 'greet', 'entity.name.function.dever');
|
||||
hasScope(rows, 4, 'name', 'variable.other.member.dever');
|
||||
hasScope(rows, 4, 'message', 'variable.other.member.dever');
|
||||
hasScope(rows, 5, 'app', 'entity.name.namespace.dever');
|
||||
hasScope(rows, 5, 'format', 'entity.name.function.dever');
|
||||
});
|
||||
|
||||
test('typed local declarations and direct JSON capture retain ordinary scopes', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'read(payload: Json) (value: Text) {',
|
||||
' input: CreateInput = dever.json.decode(payload)',
|
||||
' decoded: DecodeResult = result(dever.json.decode(payload))',
|
||||
' value = input.name',
|
||||
'}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 1, 'input', 'variable.other.member.dever');
|
||||
hasScope(rows, 1, 'CreateInput', 'entity.name.type.dever');
|
||||
hasScope(rows, 1, 'decode', 'entity.name.function.dever');
|
||||
hasScope(rows, 2, 'DecodeResult', 'entity.name.type.dever');
|
||||
hasScope(rows, 2, 'result', 'entity.name.function.dever');
|
||||
});
|
||||
|
||||
test('API, Model, Job and external Adapter declarations are covered', async () => {
|
||||
const source = (await grammars).source;
|
||||
for (const method of ['get', 'post', 'put', 'delete', 'cmd', 'rest']) {
|
||||
const rows = tokenize(source, `${method} profile = app.profile`);
|
||||
hasScope(rows, 0, method, 'keyword.control.api.dever');
|
||||
}
|
||||
const rows = tokenize(source, [
|
||||
'public get profile = app.profile',
|
||||
' private owner user_id: user.account.model.id = dever.auth.userId()',
|
||||
'index(user_id, created_at)',
|
||||
'sql ownedArticles(id: Int) (articles: List<Article>) {',
|
||||
'job send(message: Text) () fails app.Error {',
|
||||
'schedule send_every_minute {',
|
||||
'external npm "worker.js" {}'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'public', 'storage.modifier.dever');
|
||||
hasScope(rows, 1, 'owner', 'storage.modifier.dever');
|
||||
hasScope(rows, 2, 'index', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 3, 'sql', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 4, 'job', 'storage.modifier.dever');
|
||||
hasScope(rows, 4, 'fails', 'storage.modifier.dever');
|
||||
hasScope(rows, 5, 'schedule', 'keyword.other.declaration.dever');
|
||||
hasScope(rows, 6, 'npm', 'keyword.other.external.dever');
|
||||
});
|
||||
|
||||
test('hash comments, quoted text and escapes do not leak into subsequent lines', async () => {
|
||||
const rows = tokenize((await grammars).source, [
|
||||
'# type Fake and return',
|
||||
String.raw`message = "中文 # and type \n \u{4E2D} \""`,
|
||||
String.raw`invalid = "\q"`,
|
||||
'broken = "unfinished',
|
||||
'type Next { value: Text }'
|
||||
].join('\n'));
|
||||
hasScope(rows, 0, 'type', 'comment.line.number-sign.dever');
|
||||
hasScope(rows, 1, '#', 'string.quoted.double.dever');
|
||||
hasScope(rows, 1, '\\n', 'constant.character.escape.dever');
|
||||
hasScope(rows, 1, '\\u{4E2D}', 'constant.character.escape.dever');
|
||||
hasScope(rows, 2, '\\q', 'invalid.illegal.escape.dever');
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 4, 'type').scopes.includes('string.quoted.double.dever'));
|
||||
});
|
||||
|
||||
test('integer division is an operator, not a JavaScript line comment', async () => {
|
||||
const rows = tokenize((await grammars).source, 'count = 12 // 3\namount = 1.25e-2\nvalid = true and not false');
|
||||
hasScope(rows, 0, '//', 'keyword.operator.dever');
|
||||
hasScope(rows, 0, '3', 'constant.numeric.dever');
|
||||
assert.ok(!tokenAt(rows, 0, '3').scopes.some(scope => scope.startsWith('comment.')));
|
||||
hasScope(rows, 1, '1.25e-2', 'constant.numeric.dever');
|
||||
hasScope(rows, 2, 'true', 'constant.language.dever');
|
||||
hasScope(rows, 2, 'and', 'keyword.operator.logical.dever');
|
||||
});
|
||||
|
||||
test('excluded control-flow syntax is not presented as a valid function', async () => {
|
||||
const rows = tokenize((await grammars).source, 'if(value)\nreturn value\nimport other\npackage main');
|
||||
for (const [line, keyword] of ['if', 'return', 'import', 'package'].entries()) {
|
||||
hasScope(rows, line, keyword, 'invalid.illegal.keyword.dever');
|
||||
}
|
||||
});
|
||||
|
||||
test('Markdown fences reuse source.dever while prose keeps Markdown scopes', async () => {
|
||||
for (const marker of ['```', '~~~']) {
|
||||
for (const language of ['dever', 'typescript dever']) {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'# 文档', '', '## 类型', `${marker}${language}`,
|
||||
'type Profile { name: Text }', marker, '', '正文 type Profile'
|
||||
].join('\n'));
|
||||
hasScope(rows, 4, 'type', 'storage.type.dever');
|
||||
hasScope(rows, 4, 'Text', 'support.type.dever');
|
||||
hasScope(rows, 4, 'Profile', 'meta.embedded.block.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded tokens carry the Dever language ID and are not string tokens', async () => {
|
||||
const rows = tokenize((await grammars).markdown, '```dever\ntype Profile {}\n```');
|
||||
const metadata = rows[1].encoded[1];
|
||||
assert.equal(metadata & 0xff, 1);
|
||||
assert.equal((metadata >>> 8) & 3, 0);
|
||||
});
|
||||
|
||||
test('fence closing honors its character and minimum opening length', async () => {
|
||||
const rows = tokenize((await grammars).markdown, [
|
||||
'````dever', 'type First {}', '```', 'type Second {}',
|
||||
'~~~~', 'type Third {}', '`````', '正文 type Outside'
|
||||
].join('\n'));
|
||||
for (const line of [1, 3, 5]) hasScope(rows, line, 'type', 'storage.type.dever');
|
||||
assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever'));
|
||||
const waves = tokenize((await grammars).markdown, '~~~dever\ntype Inside {}\n~~~~\n正文 type Outside');
|
||||
assert.ok(!tokenAt(waves, 3, 'type').scopes.includes('source.dever'));
|
||||
});
|
||||
|
||||
test('valid indentation and CRLF work without taking over other language fences', async () => {
|
||||
const rows = tokenize((await grammars).markdown, ' ```dever\r\ntype Profile {}\r\n ```\r\n正文');
|
||||
hasScope(rows, 1, 'type', 'storage.type.dever');
|
||||
for (const language of ['typescript', 'javascript', 'dever extra', 'typescript dever']) {
|
||||
const other = tokenize((await grammars).markdown, `\`\`\`${language}\ntype Profile {}\n\`\`\``);
|
||||
assert.ok(!tokenAt(other, 1, 'type').scopes.includes('source.dever'), language);
|
||||
}
|
||||
});
|
||||
|
||||
test('quotes, lists, showcase fences, indented code and comments retain Markdown scopes', async () => {
|
||||
for (const [document, line] of [
|
||||
['> ```dever\n> type Quoted {}\n> ```', 1],
|
||||
['- 示例\n\n ```dever\n type Nested {}\n ```', 3],
|
||||
['````markdown\n```dever\ntype Showcase {}\n```\n````', 2],
|
||||
[' ```dever\n type Indented {}\n ```', 1],
|
||||
['<!--\n```dever\ntype Comment {}\n```\n-->', 2]
|
||||
]) {
|
||||
const rows = tokenize((await grammars).markdown, document);
|
||||
assert.ok(!tokenAt(rows, line, 'type').scopes.includes('source.dever'), document);
|
||||
}
|
||||
});
|
||||
|
||||
test('language configuration uses Dever comments, pairs and indentation', () => {
|
||||
assert.equal(configuration.comments.lineComment, '#');
|
||||
assert.equal(configuration.comments.blockComment, undefined);
|
||||
assert.deepEqual(configuration.brackets, [['{', '}'], ['[', ']'], ['(', ')']]);
|
||||
const increase = new RegExp(configuration.indentationRules.increaseIndentPattern);
|
||||
const decrease = new RegExp(configuration.indentationRules.decreaseIndentPattern);
|
||||
for (const line of ['greet(name: Text) (message: Text) {', ' values = [', 'type Profile { # 字段']) {
|
||||
assert.ok(increase.test(line), line);
|
||||
}
|
||||
for (const line of ['# example {', 'message = "literal {"', 'message = "value" # {']) {
|
||||
assert.ok(!increase.test(line), line);
|
||||
}
|
||||
assert.ok(decrease.test(' }'));
|
||||
assert.ok(decrease.test(' ]'));
|
||||
assert.ok(configuration.autoClosingPairs.every(pair => pair.notIn.includes('string') && pair.notIn.includes('comment')));
|
||||
assert.deepEqual(manifest.contributes.languages[0].extensions, ['.dever']);
|
||||
const injection = manifest.contributes.grammars.find(contribution => contribution.injectTo?.includes('text.html.markdown'));
|
||||
assert.deepEqual(injection.embeddedLanguages, { 'meta.embedded.block.dever': 'dever' });
|
||||
assert.deepEqual(injection.tokenTypes, { 'meta.embedded.block.dever': 'other' });
|
||||
assert.equal(manifest.main, './extension.cjs');
|
||||
assert.equal(manifest.dependencies, undefined);
|
||||
});
|
||||
|
||||
test('maintained plain and Markdown CMS files tokenize without illegal tokens', async () => {
|
||||
const loaded = await grammars;
|
||||
for (const [directory, suffix, grammar] of [
|
||||
['examples/cms/dever', '.dever', loaded.source]
|
||||
]) {
|
||||
const files = sourceFiles(path.join(root, directory), suffix);
|
||||
assert.ok(files.length > 0, directory);
|
||||
let highlighted = 0;
|
||||
for (const filename of files) {
|
||||
const rows = tokenize(grammar, fs.readFileSync(filename, 'utf8'));
|
||||
let fileHighlighted = 0;
|
||||
for (const row of rows) {
|
||||
for (const token of row.tokens) {
|
||||
assert.ok(!token.scopes.some(scope => scope.startsWith('invalid.illegal.')), `${filename}: ${row.line}`);
|
||||
if (token.scopes.includes('source.dever')) fileHighlighted += 1;
|
||||
}
|
||||
}
|
||||
assert.ok(fileHighlighted > 0, `${filename} must actually enter the Dever grammar`);
|
||||
highlighted += fileHighlighted;
|
||||
}
|
||||
assert.ok(highlighted > files.length, `${directory} must actually enter the Dever grammar`);
|
||||
process.stdout.write(`CMS ${directory}: ${files.length} files, ${highlighted} Dever tokens\n`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const Module = require('node:module');
|
||||
const { pathToFileURL, fileURLToPath } = require('node:url');
|
||||
|
||||
// VS Code 是外部边界;源码关系由真实候选编译器生成,不在测试中伪造索引。
|
||||
class Uri {
|
||||
constructor(url) { this.url = url; }
|
||||
static file(value) { return new Uri(pathToFileURL(value)); }
|
||||
static parse(value) { return new Uri(new URL(value)); }
|
||||
static joinPath(base, ...segments) { return Uri.file(path.join(base.fsPath, ...segments)); }
|
||||
static from(value) {
|
||||
const url = new URL(`${value.scheme}:/`);
|
||||
url.pathname = value.path;
|
||||
if (value.query) url.search = value.query;
|
||||
return new Uri(url);
|
||||
}
|
||||
get scheme() { return this.url.protocol.slice(0, -1); }
|
||||
get fsPath() { return this.scheme === 'file' ? fileURLToPath(this.url) : this.url.pathname; }
|
||||
get query() { return decodeURIComponent(this.url.search.slice(1)); }
|
||||
with(value) { const url = new URL(this.url); if (value.fragment !== undefined) url.hash = value.fragment; if (value.query !== undefined) url.search = value.query; return new Uri(url); }
|
||||
toString() { return this.url.toString(); }
|
||||
}
|
||||
class Range {
|
||||
constructor(first, second, third, fourth) {
|
||||
this.start = typeof first === 'number' ? { line: first, character: second } : first;
|
||||
this.end = typeof first === 'number' ? { line: third, character: fourth } : second;
|
||||
}
|
||||
}
|
||||
class EventEmitter {
|
||||
callbacks = new Set();
|
||||
event = callback => { this.callbacks.add(callback); return { dispose: () => this.callbacks.delete(callback) }; };
|
||||
fire(value) { for (const callback of this.callbacks) callback(value); }
|
||||
dispose() { this.callbacks.clear(); }
|
||||
}
|
||||
|
||||
test('generated dispatch remains in call hierarchy without hijacking source definitions', async () => {
|
||||
const run = require('node:util').promisify(require('node:child_process').execFile);
|
||||
const { at, relations } = require('../../editor/vscode/graph.cjs');
|
||||
for (const name of ['auth', 'adapters']) {
|
||||
const root = path.resolve(__dirname, '../rust/fixtures', name);
|
||||
const result = await run(process.env.DEVER_TEST_BIN, ['inspect', root, '--json'], { maxBuffer: 8 * 1024 * 1024 });
|
||||
const index = JSON.parse(result.stdout);
|
||||
if (name === 'auth') {
|
||||
for (const call of index.calls.filter(call => call.kind === 'http')) {
|
||||
assert.deepEqual(at(index, call.site.file, call.site.start).map(symbol => symbol.id), [call.callee]);
|
||||
assert.ok(relations(index, call.caller, false).some(group => group.calls.some(edge => edge.kind === 'verify')));
|
||||
}
|
||||
const rest = index.symbols.find(symbol => symbol.kind === 'rest');
|
||||
assert.deepEqual(at(index, rest.declarations[0].file, rest.declarations[0].start).map(symbol => symbol.id), [rest.id]);
|
||||
} else {
|
||||
const port = index.symbols.find(symbol => symbol.name === 'demo.value.port.calculate');
|
||||
assert.deepEqual(at(index, port.declarations[0].file, port.declarations[0].start).map(symbol => symbol.id), [port.id]);
|
||||
assert.equal(relations(index, port.id, false).filter(group => group.calls.some(edge => edge.kind === 'adapter')).length, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real compiler powers navigation, documentation hover and saved-source refresh', async () => {
|
||||
assert.ok(process.env.DEVER_TEST_BIN, 'set DEVER_TEST_BIN to the built candidate compiler');
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dever editor '));
|
||||
const sourcePath = path.join(root, 'main.dever');
|
||||
const code = 'label(name: Text) (text: Text) {\n text = name\n}\nfirst(name: Text) (text: Text) {\n text = "😀" + label(name) + label("另")\n}\nsecond(name: Text) (text: Text) {\n text = label(name)\n}\n';
|
||||
const source = code;
|
||||
await fs.writeFile(sourcePath, source);
|
||||
const providers = {};
|
||||
const commands = {};
|
||||
const executed = [];
|
||||
const panels = [];
|
||||
const opened = [];
|
||||
const documents = new Map();
|
||||
const saved = new EventEmitter();
|
||||
const disposable = () => ({ dispose() {} });
|
||||
const context = { subscriptions: [], extensionUri: Uri.file(path.resolve(__dirname, '../../editor/vscode')) };
|
||||
function document(uri, text) {
|
||||
return {
|
||||
uri, languageId: uri.fsPath.endsWith('.md') ? 'markdown' : 'dever', isDirty: false, text,
|
||||
offsetAt(position) {
|
||||
return this.text.split('\n').slice(0, position.line).reduce((offset, line) => offset + line.length + 1, 0) + position.character;
|
||||
},
|
||||
getText(range) { return range ? this.text.slice(this.offsetAt(range.start), this.offsetAt(range.end)) : this.text; }
|
||||
};
|
||||
}
|
||||
const vscode = {
|
||||
Uri, Range, EventEmitter,
|
||||
MarkdownString: class {
|
||||
value = '';
|
||||
appendMarkdown(text) { this.value += text; return this; }
|
||||
appendCodeblock(text, language) { this.value += '```' + language + '\n' + text + '\n```\n'; return this; }
|
||||
},
|
||||
Hover: class { constructor(contents) { this.contents = contents; } },
|
||||
Location: class { constructor(uri, range) { Object.assign(this, { uri, range }); } },
|
||||
CodeLens: class { constructor(range, command) { Object.assign(this, { range, command }); } },
|
||||
CallHierarchyItem: class { constructor(kind, name, detail, uri, range, selectionRange) { Object.assign(this, { kind, name, detail, uri, range, selectionRange }); } },
|
||||
CallHierarchyIncomingCall: class { constructor(from, fromRanges) { Object.assign(this, { from, fromRanges }); } },
|
||||
CallHierarchyOutgoingCall: class { constructor(to, fromRanges) { Object.assign(this, { to, fromRanges }); } },
|
||||
SymbolKind: { Function: 11 },
|
||||
ViewColumn: { One: 1, Beside: 2 },
|
||||
window: {
|
||||
createOutputChannel: () => ({ appendLine() {}, dispose() {} }),
|
||||
showErrorMessage(message) { throw new Error(message); },
|
||||
async showTextDocument(document, options) { opened.push({ document, options }); },
|
||||
createWebviewPanel(type, title, column, options) {
|
||||
const disposed = new EventEmitter();
|
||||
const panel = { type, title, column, options, dispose() { disposed.fire(); }, onDidDispose: disposed.event,
|
||||
webview: { cspSource: 'https://webview.test', asWebviewUri: uri => uri,
|
||||
onDidReceiveMessage(callback) { panel.message = callback; return disposable(); } }
|
||||
};
|
||||
panels.push(panel);
|
||||
return panel;
|
||||
}
|
||||
},
|
||||
commands: { registerCommand(name, callback) { commands[name] = callback; return disposable(); }, executeCommand: async (...values) => { executed.push(values); } },
|
||||
languages: {},
|
||||
workspace: {
|
||||
isTrusted: true,
|
||||
get textDocuments() { return [...documents.values()]; },
|
||||
getWorkspaceFolder: () => ({ uri: Uri.file(root) }),
|
||||
getConfiguration: () => ({ get: () => [process.env.DEVER_TEST_BIN] }),
|
||||
onDidSaveTextDocument: saved.event, onDidChangeConfiguration: disposable, onDidCloseTextDocument: disposable,
|
||||
createFileSystemWatcher: () => ({ onDidChange: disposable, onDidCreate: disposable, onDidDelete: disposable, dispose() {} }),
|
||||
registerTextDocumentContentProvider(scheme, provider) { providers[scheme] = provider; return disposable(); },
|
||||
async openTextDocument(uri) {
|
||||
const key = uri.toString();
|
||||
if (!documents.has(key)) {
|
||||
const text = uri.scheme === 'file' ? await fs.readFile(uri.fsPath, 'utf8') : await providers[uri.scheme].provideTextDocumentContent(uri);
|
||||
documents.set(key, document(uri, text));
|
||||
}
|
||||
return documents.get(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const name of ['Definition', 'Reference', 'CallHierarchy', 'CodeLens', 'Hover']) {
|
||||
vscode.languages[`register${name}Provider`] = (_, provider) => { providers[name] = provider; return disposable(); };
|
||||
}
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function(request, ...rest) { return request === 'vscode' ? vscode : originalLoad.call(this, request, ...rest); };
|
||||
try {
|
||||
const { registerNavigation } = require('../../editor/vscode/navigation.cjs');
|
||||
registerNavigation(context);
|
||||
Module._load = originalLoad;
|
||||
const doc = await vscode.workspace.openTextDocument(Uri.file(sourcePath));
|
||||
const callLine = source.split('\n').findIndex(line => line.includes('"😀"'));
|
||||
const position = { line: callLine, character: source.split('\n')[callLine].indexOf('label') + 1 };
|
||||
const definitions = await providers.Definition.provideDefinition(doc, position);
|
||||
assert.equal(definitions.length, 1);
|
||||
assert.equal(definitions[0].uri.fsPath, sourcePath);
|
||||
assert.equal(definitions[0].range.start.line, source.split('\n').findIndex(line => line.startsWith('label(')));
|
||||
const references = await providers.Reference.provideReferences(doc, position, { includeDeclaration: false });
|
||||
assert.equal(references.length, 3);
|
||||
assert.equal(references[0].range.start.character, source.split('\n')[callLine].indexOf('label'));
|
||||
const [target] = await providers.CallHierarchy.prepareCallHierarchy(doc, position);
|
||||
const incoming = await providers.CallHierarchy.provideCallHierarchyIncomingCalls(target);
|
||||
assert.equal(incoming.length, 2);
|
||||
assert.deepEqual(incoming.map(call => call.fromRanges.length).sort(), [1, 2]);
|
||||
const outgoing = await providers.CallHierarchy.provideCallHierarchyOutgoingCalls(incoming[0].from);
|
||||
assert.equal(outgoing[0].to.name, 'main.label');
|
||||
const lenses = await providers.CodeLens.provideCodeLenses(doc);
|
||||
assert.match(lenses[0].command.title, /2 个调用方 · 3 处调用/);
|
||||
const preview = Uri.from({ scheme: 'dever-calls', path: '/calls.md', query: JSON.stringify({ root, name: 'main.label' }) });
|
||||
const markdown = await providers['dever-calls'].provideTextDocumentContent(preview);
|
||||
assert.match(markdown, /## 定义/);
|
||||
assert.ok(markdown.includes('```dever'));
|
||||
assert.match(markdown, /\| 函数 \| 领域/);
|
||||
assert.match(markdown, /main.first/);
|
||||
assert.match(markdown, /main.second/);
|
||||
assert.ok(markdown.includes(`#L${callLine + 1}`));
|
||||
await commands['dever.showCalls'](root, 'main.label');
|
||||
const callsPanel = panels.at(-1);
|
||||
assert.match(callsPanel.webview.html, /<table>/);
|
||||
assert.match(callsPanel.webview.html, /<a href="file:/);
|
||||
const sourceHref = /<a href="(file:[^"]+)"/.exec(callsPanel.webview.html)[1];
|
||||
await callsPanel.message({ type: 'open', href: sourceHref });
|
||||
assert.equal(opened.at(-1).document.uri.fsPath, sourcePath);
|
||||
await callsPanel.message({ type: 'open', href: 'command:dever.showCalls?' + encodeURIComponent(JSON.stringify([root, 'main.first'])) });
|
||||
assert.match(callsPanel.webview.html, /<h1>main.first<\/h1>/);
|
||||
callsPanel.dispose();
|
||||
doc.isDirty = true;
|
||||
assert.deepEqual(await providers.Definition.provideDefinition(doc, position), []);
|
||||
doc.isDirty = false;
|
||||
doc.text = source.replace('text = label(name)', 'text = name');
|
||||
await fs.writeFile(sourcePath, doc.text);
|
||||
saved.fire(doc);
|
||||
assert.equal((await providers.Reference.provideReferences(doc, position, { includeDeclaration: false })).length, 2);
|
||||
doc.text = source.replace('label(name)', 'missing(name)');
|
||||
await fs.writeFile(sourcePath, doc.text);
|
||||
saved.fire(doc);
|
||||
await assert.rejects(providers.Definition.provideDefinition(doc, position), /unresolved|unknown|not found/);
|
||||
|
||||
const project = path.join(root, 'application');
|
||||
const run = require('node:util').promisify(require('node:child_process').execFile);
|
||||
await run(process.env.DEVER_TEST_BIN, ['new', project]);
|
||||
await run(process.env.DEVER_TEST_BIN, ['inspect', project, '--links']);
|
||||
const app = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/app.dever')));
|
||||
vscode.window.activeTextEditor = { document: app, selection: { active: { line: 0, character: 1 } } };
|
||||
const hover = await providers.Hover.provideHover(app, { line: 0, character: 1 });
|
||||
assert.match(hover.contents[0].value, /greet\(name: Text\) \(message: Text\)/);
|
||||
assert.match(hover.contents[0].value, /按原样保留/);
|
||||
assert.match(hover.contents[0].value, /hello\.greeting\.api\.greet/);
|
||||
assert.match(hover.contents[0].value, /command:dever.showCalls/);
|
||||
const api = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/api.dever')));
|
||||
assert.match((await providers.Hover.provideHover(api, { line: 0, character: api.getText().indexOf('app.greet') + 5 })).contents[0].value, /按原样保留/);
|
||||
const topic = await vscode.workspace.openTextDocument(Uri.file(path.join(project, 'module/hello/greeting/doc/greeting.md')));
|
||||
topic.text = topic.text.replace('按原样保留', '输入不做改写');
|
||||
topic.isDirty = true;
|
||||
await assert.rejects(providers.Hover.provideHover(app, { line: 0, character: 1 }), /保存/);
|
||||
await fs.writeFile(topic.uri.fsPath, topic.text);
|
||||
topic.isDirty = false;
|
||||
saved.fire(topic);
|
||||
assert.match((await providers.Hover.provideHover(app, { line: 0, character: 1 })).contents[0].value, /输入不做改写/);
|
||||
await commands['dever.previewDocumentation'](project, 'hello.greeting.greet');
|
||||
assert.equal(executed.at(-1)[0], 'markdown.showPreviewToSide');
|
||||
await commands['dever.openDomainLinks']();
|
||||
const panel = panels.at(-1);
|
||||
assert.equal(panel.type, 'dever.links');
|
||||
assert.match(panel.webview.html, /language-mermaid/);
|
||||
assert.match(panel.webview.html, /flowchart TD/);
|
||||
assert.match(panel.webview.html, /flow-browser\.js/);
|
||||
await panel.message({ type: 'open', href: 'app.dever#L15,3' });
|
||||
assert.equal(opened.at(-1).document.uri.fsPath, app.uri.fsPath);
|
||||
assert.deepEqual(opened.at(-1).options.selection.start, { line: 14, character: 2 });
|
||||
const anchor = /id="(flow-[^"]+)"/.exec(panel.webview.html)[1];
|
||||
await panel.message({ type: 'open', href: `link.md#${anchor}` });
|
||||
assert.ok(panel.webview.html.includes(`data-anchor="${anchor}"`));
|
||||
await assert.rejects(panel.message({ type: 'open', href: 'https://example.invalid/' }), /只打开项目/);
|
||||
await assert.rejects(panel.message({ type: 'open', href: '../../../../outside.dever' }), /超出当前项目/);
|
||||
topic.text = topic.text.replace('## hello.greeting.greet', '## hello.greeting.missing');
|
||||
await fs.writeFile(topic.uri.fsPath, topic.text);
|
||||
saved.fire(topic);
|
||||
const invalidDocumentation = await providers.Hover.provideHover(app, { line: 0, character: 1 });
|
||||
assert.match(invalidDocumentation.contents[0].value, /说明校验未通过/);
|
||||
assert.match(invalidDocumentation.contents[0].value, /greet\(name: Text\)/);
|
||||
await assert.rejects(commands['dever.openDomainLinks'](project, 'hello.greeting.greet'), /unknown documentation symbol/);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
for (const subscription of context.subscriptions) subscription.dispose();
|
||||
for (const name of ['navigation', 'inspection', 'reading', 'hover']) {
|
||||
delete require.cache[require.resolve(`../../editor/vscode/${name}.cjs`)];
|
||||
}
|
||||
delete require.cache[require.resolve('../../editor/vscode/dist/flow-preview.cjs')];
|
||||
await fs.rm(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "dever_ffi.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
extern long long fixture_triple(long long value);
|
||||
|
||||
static int invoke(void *context, dever_ffi_slice operation, dever_ffi_slice payload, dever_ffi_output *output) {
|
||||
(void)context;
|
||||
if (operation.size == 6 && memcmp(operation.data, "reject", 6) == 0) {
|
||||
const char *error = "{\"error\":\"example.Rejected\",\"payload\":null}";
|
||||
memcpy(output->data, error, strlen(error));
|
||||
output->size = strlen(error);
|
||||
return DEVER_FFI_ERROR;
|
||||
}
|
||||
if (operation.size == 10 && memcmp(operation.data, "bad_length", 10) == 0) {
|
||||
output->size = output->capacity + 1;
|
||||
return DEVER_FFI_OK;
|
||||
}
|
||||
if (operation.size == 5 && memcmp(operation.data, "fault", 5) == 0) return DEVER_FFI_FAULT;
|
||||
if (payload.size >= 64) return DEVER_FFI_FAULT;
|
||||
char input[64];
|
||||
memcpy(input, payload.data, (size_t)payload.size);
|
||||
input[payload.size] = 0;
|
||||
long long value = 0;
|
||||
if (sscanf(input, "{\"value\":%lld}", &value) != 1 || value > 1000000 || value < -1000000) return DEVER_FFI_FAULT;
|
||||
int size = snprintf(output->data, (size_t)output->capacity, "{\"value\":%lld}", fixture_triple(value));
|
||||
if (size < 0 || (uint64_t)size >= output->capacity) return DEVER_FFI_FAULT;
|
||||
output->size = (uint64_t)size;
|
||||
return DEVER_FFI_OK;
|
||||
}
|
||||
|
||||
static void closed(void *context) {
|
||||
(void)context;
|
||||
fputs("closed\n", stderr);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const dever_ffi_handler handler = {DEVER_FFI_ABI_V1, NULL, NULL, invoke, closed};
|
||||
return dever_ffi_serve(&handler);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,121 @@
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
COMPILER = None
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = {
|
||||
"port": "example.Native", "adapter": "example.C", "schema": "ffi-test-v1",
|
||||
"capabilities": [], "operations": ["triple", "reject", "fault", "bad_length"],
|
||||
}
|
||||
HELLO = MANIFEST | {"kind": "hello", "version": "dever-component-1", "setting": {}}
|
||||
|
||||
|
||||
def frame(message):
|
||||
data = json.dumps(message, separators=(",", ":")).encode()
|
||||
return struct.pack(">I", len(data)) + data
|
||||
|
||||
|
||||
def responses(data):
|
||||
values = []
|
||||
while data:
|
||||
if len(data) < 4:
|
||||
raise AssertionError("truncated frame")
|
||||
size = struct.unpack(">I", data[:4])[0]
|
||||
if not 0 < size <= 16 * 1024 * 1024 or size > len(data) - 4:
|
||||
raise AssertionError("invalid frame size")
|
||||
values.append(json.loads(data[4:4 + size]))
|
||||
data = data[4 + size:]
|
||||
return values
|
||||
|
||||
|
||||
class NativeFfi(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="dever-c-ffi-")
|
||||
cls.addClassCleanup(cls.temporary.cleanup)
|
||||
cls.directory = Path(cls.temporary.name)
|
||||
(cls.directory / "lib").mkdir()
|
||||
library = cls.directory / "library.c"
|
||||
library.write_text("long long fixture_triple(long long value) { return value * 3; }\n")
|
||||
subprocess.run([COMPILER, "-shared", "-fPIC", "-Wl,-soname,libfixture.so", str(library), "-o", str(cls.directory / "lib/libfixture.so")], check=True, timeout=30)
|
||||
cls.binary = cls.directory / "worker"
|
||||
subprocess.run([
|
||||
COMPILER, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "sdk/c"),
|
||||
str(ROOT / "sdk/c/dever_ffi.c"), str(ROOT / "test/external-sdk/c_ffi_adapter.c"),
|
||||
"-L", str(cls.directory / "lib"), "-lfixture", "-Wl,-rpath,$ORIGIN/lib", "-o", str(cls.binary),
|
||||
], check=True, timeout=30)
|
||||
(cls.directory / "contract.json").write_text(json.dumps(MANIFEST, indent=2))
|
||||
|
||||
def execute(self, messages, suffix=b""):
|
||||
result = subprocess.run([str(self.binary)], input=b"".join(frame(message) for message in messages) + suffix, cwd=self.directory, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, env={})
|
||||
return result, responses(result.stdout)
|
||||
|
||||
def test_c_abi_calls_a_shared_library_and_preserves_protocol_lifecycle(self):
|
||||
result, values = self.execute([
|
||||
HELLO, {"kind": "health", "id": 0},
|
||||
{"kind": "call", "id": 1, "operation": "triple", "payload": {"value": 7}},
|
||||
{"kind": "cancel", "id": 1},
|
||||
{"kind": "call", "id": 2, "operation": "reject", "payload": {}},
|
||||
{"kind": "call", "id": 3, "operation": "triple", "payload": {"value": -9}},
|
||||
{"kind": "shutdown"},
|
||||
])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(values, [
|
||||
MANIFEST | {"kind": "ready", "version": "dever-component-1"},
|
||||
{"kind": "health", "id": 0},
|
||||
{"kind": "result", "id": 1, "payload": {"value": 21}},
|
||||
{"kind": "error", "id": 2, "error": "example.Rejected", "payload": None},
|
||||
{"kind": "result", "id": 3, "payload": {"value": -27}},
|
||||
{"kind": "shutdown"},
|
||||
])
|
||||
self.assertEqual(result.stderr, b"closed\n")
|
||||
|
||||
def test_identity_cannot_be_supplied_by_the_caller(self):
|
||||
for changed in [{"schema": "wrong"}, {"capabilities": ["gpu"]}, {"port": "wrong"}, {"operations": ["other"]}]:
|
||||
with self.subTest(changed=changed):
|
||||
result, values = self.execute([HELLO | changed])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(values, [])
|
||||
self.assertNotIn(b"closed", result.stderr)
|
||||
|
||||
def test_invalid_calls_and_native_results_fail_closed(self):
|
||||
for call in [
|
||||
{"id": 2, "operation": "triple"},
|
||||
{"id": 1, "operation": "missing"},
|
||||
{"id": 1, "operation": "bad_length"},
|
||||
{"id": 1, "operation": "fault"},
|
||||
{"id": -1, "operation": "triple"},
|
||||
{"id": 9223372036854775808, "operation": "triple"},
|
||||
]:
|
||||
with self.subTest(call=call):
|
||||
result, values = self.execute([HELLO, {"kind": "call", "payload": {"value": 7}} | call])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(len(values), 1)
|
||||
self.assertIn(b"closed\n", result.stderr)
|
||||
|
||||
def test_truncated_oversized_and_malformed_frames_fail_closed(self):
|
||||
for suffix in [b"\x00", struct.pack(">I", 0), struct.pack(">I", 16777217), struct.pack(">I", 5) + b"{}", frame({"kind": "shutdown", "extra": 1})]:
|
||||
with self.subTest(suffix=suffix):
|
||||
result, values = self.execute([HELLO], suffix)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(len(values), 1)
|
||||
for malformed in [b"{", b'{"kind":"call","id":1e}', b'{"kind":"\\q"}', b"[" * 65 + b"]" * 65, b'{"kind":"shutdown"}\x00']:
|
||||
result, values = self.execute([HELLO], struct.pack(">I", len(malformed)) + malformed)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(len(values), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cc", required=True)
|
||||
options = parser.parse_args()
|
||||
if not Path(options.cc).is_absolute() or not Path(options.cc).is_file():
|
||||
parser.error("--cc must be an explicit absolute compiler file")
|
||||
COMPILER = options.cc
|
||||
unittest.main(argv=[__file__], verbosity=2)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"port": "example.Typed",
|
||||
"adapter": "example.Managed",
|
||||
"ecosystem": "exec",
|
||||
"entry": "test/typed",
|
||||
"schema": "typed-fixture-v1",
|
||||
"libs": [],
|
||||
"capabilities": [],
|
||||
"operations": ["render"],
|
||||
"setting": {"type": "record", "fields": {"prefix": {"type": "text"}}},
|
||||
"inputs": {"render": {"type": "record", "fields": {
|
||||
"message": {"type": "record", "fields": {
|
||||
"name": {"type": "text"}, "number": {"type": "int64"},
|
||||
"labels": {"type": "list", "value": {"type": "text"}}
|
||||
}}
|
||||
}}},
|
||||
"outputs": {"render": {"type": "record", "fields": {"text": {"type": "text"}}}},
|
||||
"errors": {"example.Rejected": {"type": "record", "fields": {"reason": {"type": "text"}}}}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
component "dever-component"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bytes, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var manifest component.Manifest
|
||||
if err = json.Unmarshal(bytes, &manifest); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
worker, err := component.NewFromManifest(manifest)
|
||||
if err == nil {
|
||||
err = worker.RegisterHandlers(map[string]func(context.Context, any, any) (any, error){
|
||||
"render": func(_ context.Context, payload any, setting any) (any, error) {
|
||||
message := payload.(map[string]any)["message"].(map[string]any)
|
||||
if message["name"] == "reject" {
|
||||
return nil, component.BusinessError{Identity: "example.Rejected", Payload: map[string]any{"reason": "rejected"}}
|
||||
}
|
||||
prefix := setting.(map[string]any)["prefix"].(string)
|
||||
return map[string]any{"text": prefix + message["name"].(string) + fmt.Sprint(message["number"])}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
if err == nil {
|
||||
err = worker.Serve(os.Stdin, os.Stdout)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "typed worker:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { BusinessError, Worker, main } from '../../sdk/javascript/dever_component.mjs';
|
||||
|
||||
const manifest = JSON.parse(readFileSync(process.argv[2], 'utf8'));
|
||||
const worker = Worker.fromManifest(manifest, {
|
||||
render: async (payload, setting) => {
|
||||
const message = payload.message;
|
||||
if (message.name === 'reject') throw new BusinessError('example.Rejected', { reason: 'rejected' });
|
||||
return { text: setting.prefix + message.name + String(message.number) };
|
||||
},
|
||||
});
|
||||
await main(worker);
|
||||
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "sdk/python"))
|
||||
from dever_component import BusinessError, Worker, main
|
||||
|
||||
|
||||
async def render(payload, setting):
|
||||
message = payload["message"]
|
||||
if message["name"] == "reject":
|
||||
raise BusinessError("example.Rejected", {"reason": "rejected"})
|
||||
return {"text": setting["prefix"] + message["name"] + str(message["number"])}
|
||||
|
||||
|
||||
manifest = json.loads(Path(sys.argv[1]).read_text())
|
||||
main(Worker.from_manifest(manifest, {"render": render}))
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/build"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type selectedPackage struct {
|
||||
Name string `json:"name"`
|
||||
Files []string `json:"files"`
|
||||
Imports []string `json:"imports"`
|
||||
Embed map[string][]string `json:"embed"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 4 {
|
||||
fail(errors.New("usage: analyze <goos> <goarch> <directory>"))
|
||||
}
|
||||
ctx := build.Default
|
||||
ctx.GOOS, ctx.GOARCH = os.Args[1], os.Args[2]
|
||||
ctx.CgoEnabled = false
|
||||
ctx.BuildTags = nil
|
||||
pkg, err := ctx.ImportDir(os.Args[3], 0)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
if len(pkg.CgoFiles)+len(pkg.SFiles)+len(pkg.CFiles)+len(pkg.CXXFiles)+len(pkg.SwigFiles)+len(pkg.SwigCXXFiles) != 0 {
|
||||
fail(errors.New("managed Go Worker does not support cgo, assembly, or native source"))
|
||||
}
|
||||
if len(pkg.GoFiles) == 0 {
|
||||
fail(errors.New("no Go source selected for target"))
|
||||
}
|
||||
embed := make(map[string][]string, len(pkg.EmbedPatterns))
|
||||
for _, rawPattern := range pkg.EmbedPatterns {
|
||||
includeHidden := strings.HasPrefix(rawPattern, "all:")
|
||||
pattern := strings.TrimPrefix(rawPattern, "all:")
|
||||
matches, err := filepath.Glob(filepath.Join(os.Args[3], filepath.FromSlash(pattern)))
|
||||
if err != nil || len(matches) == 0 {
|
||||
fail(fmt.Errorf("Go embed pattern %q has no matches", rawPattern))
|
||||
}
|
||||
for _, match := range matches {
|
||||
err = filepath.WalkDir(match, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return errors.New("Go embed contains a symbolic link")
|
||||
}
|
||||
if !includeHidden && path != match && (strings.HasPrefix(entry.Name(), ".") || strings.HasPrefix(entry.Name(), "_")) {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(os.Args[3], path)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return errors.New("Go embed escapes package directory")
|
||||
}
|
||||
embed[rawPattern] = append(embed[rawPattern], filepath.ToSlash(relative))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
if len(embed[rawPattern]) == 0 {
|
||||
fail(fmt.Errorf("Go embed pattern %q has no embeddable files", rawPattern))
|
||||
}
|
||||
sort.Strings(embed[rawPattern])
|
||||
}
|
||||
result := selectedPackage{Name: pkg.Name, Files: pkg.GoFiles, Imports: pkg.Imports, Embed: embed}
|
||||
if err := json.NewEncoder(os.Stdout).Encode(result); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//go:build ignore
|
||||
|
||||
// make_pack.go creates private native/cross fixtures. It is not a signed release maker.
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 && len(os.Args) != 4 {
|
||||
fatal(errors.New("usage: go run make_pack.go <absolute-go-binary> <output-runtime.pack> [linux-aarch64]"))
|
||||
}
|
||||
goos, goarch := runtime.GOOS, runtime.GOARCH
|
||||
if len(os.Args) == 4 {
|
||||
if os.Args[3] != "linux-aarch64" || runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||
fatal(errors.New("cross fixture supports only Linux x86_64 to linux-aarch64"))
|
||||
}
|
||||
goarch = "arm64"
|
||||
}
|
||||
goBinary, err := filepath.Abs(os.Args[1])
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
outputPack, err := filepath.Abs(os.Args[2])
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp("", "dever-go-pack-")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
toolDir := filepath.Join(filepath.Dir(goBinary), "..", "pkg", "tool", runtime.GOOS+"_"+runtime.GOARCH)
|
||||
goRoot := filepath.Clean(filepath.Join(filepath.Dir(goBinary), ".."))
|
||||
analyzer := filepath.Join(root, "analyze")
|
||||
build := exec.Command(goBinary, "build", "-p=1", "-o", analyzer, "./test/go-managed/analyze")
|
||||
build.Env = []string{"GOROOT=" + goRoot, "GOTOOLCHAIN=local", "GOWORK=off", "GO111MODULE=off", "CGO_ENABLED=0", "GOCACHE=" + filepath.Join(filepath.Dir(outputPack), "author-cache"), "GOMODCACHE=" + filepath.Join(root, "modules")}
|
||||
if output, err := build.CombinedOutput(); err != nil {
|
||||
fatal(fmt.Errorf("build Go source selector: %w: %s", err, output))
|
||||
}
|
||||
list := exec.Command(goBinary, "list", "-p=1", "-deps", "-export", "-f", "{{if .Export}}{{.ImportPath}} {{.Export}}{{end}}", "std")
|
||||
list.Env = append(build.Env, "GOOS="+goos, "GOARCH="+goarch)
|
||||
output, err := list.Output()
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("list fixed-target standard library: %w", err))
|
||||
}
|
||||
stdlib := make(map[string]string)
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) == 2 {
|
||||
stdlib[fields[0]] = fields[1]
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if len(stdlib) == 0 || stdlib["runtime"] == "" {
|
||||
fatal(errors.New("standard library export map is incomplete"))
|
||||
}
|
||||
files := map[string]string{
|
||||
"bin/compile": filepath.Join(toolDir, "compile"),
|
||||
"bin/link": filepath.Join(toolDir, "link"),
|
||||
"bin/analyze": analyzer,
|
||||
}
|
||||
if goarch != runtime.GOARCH {
|
||||
tools := crossTools(goBinary, goRoot, root, goarch, build.Env)
|
||||
files["bin/compile"], files["bin/link"] = tools[0], tools[1]
|
||||
}
|
||||
var imports []string
|
||||
for path, archive := range stdlib {
|
||||
files["stdlib/"+path+".a"] = archive
|
||||
imports = append(imports, path)
|
||||
}
|
||||
sort.Strings(imports)
|
||||
var importcfg strings.Builder
|
||||
for _, path := range imports {
|
||||
fmt.Fprintf(&importcfg, "packagefile %s=stdlib/%s.a\n", path, path)
|
||||
}
|
||||
version := exec.Command(goBinary, "version")
|
||||
version.Env = []string{"GOROOT=" + goRoot, "GOTOOLCHAIN=local"}
|
||||
versionText, err := version.Output()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
deverOS, deverArch := runtime.GOOS, runtime.GOARCH
|
||||
if deverOS == "darwin" {
|
||||
deverOS = "macos"
|
||||
}
|
||||
if deverArch == "amd64" {
|
||||
deverArch = "x86_64"
|
||||
} else if deverArch == "arm64" {
|
||||
deverArch = "aarch64"
|
||||
}
|
||||
target := deverOS + "-" + deverArch
|
||||
if len(os.Args) == 4 {
|
||||
target = os.Args[3]
|
||||
}
|
||||
manifest, err := json.Marshal(map[string]any{
|
||||
"format": "dever-worker-runtime-v1", "ecosystem": "go", "target": target,
|
||||
"build": map[string]any{"host": deverOS + "-" + deverArch, "compiler": "bin/compile", "linker": "bin/link", "analyzer": "bin/analyze", "stdlib_importcfg": "stdlib/importcfg", "goos": goos, "goarch": goarch, "go_version": strings.TrimSpace(string(versionText))},
|
||||
})
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
outputFile, err := os.Create(outputPack)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
defer outputFile.Close()
|
||||
gzipWriter := gzip.NewWriter(outputFile)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
writeBytes(tarWriter, "dever-runtime.json", manifest)
|
||||
writeBytes(tarWriter, "stdlib/importcfg", []byte(importcfg.String()))
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
input, err := os.Open(files[name])
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
info, err := input.Stat()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
mode := int64(0644)
|
||||
if strings.HasPrefix(name, "bin/") {
|
||||
mode = 0755
|
||||
}
|
||||
if err := tarWriter.WriteHeader(&tar.Header{Name: name, Size: info.Size(), Mode: mode, Typeflag: tar.TypeReg}); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(tarWriter, input); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
input.Close()
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The product clears the environment. Bake the output architecture into private
|
||||
// host tools through an overlay; never edit the author's installed Go SDK.
|
||||
func crossTools(goBinary, goRoot, root, architecture string, environment []string) []string {
|
||||
original := filepath.Join(goRoot, "src", "internal", "buildcfg", "zbootstrap.go")
|
||||
contents, err := os.ReadFile(original)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
const declaration = "const defaultGOARCH = runtime.GOARCH"
|
||||
if strings.Count(string(contents), declaration) != 1 {
|
||||
fatal(errors.New("Go SDK default architecture declaration differs"))
|
||||
}
|
||||
override := filepath.Join(root, "zbootstrap.go")
|
||||
updated := strings.Replace(string(contents), declaration, "const defaultGOARCH = "+fmt.Sprintf("%q", architecture), 1)
|
||||
if err := os.WriteFile(override, []byte(updated), 0600); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
overlay := filepath.Join(root, "overlay.json")
|
||||
encoded, err := json.Marshal(map[string]any{"Replace": map[string]string{original: override}})
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(overlay, encoded, 0600); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
var outputs []string
|
||||
for _, tool := range []string{"compile", "link"} {
|
||||
output := filepath.Join(root, tool)
|
||||
command := exec.Command(goBinary, "build", "-p=1", "-overlay", overlay, "-o", output, "cmd/"+tool)
|
||||
command.Env = environment
|
||||
if bytes, err := command.CombinedOutput(); err != nil {
|
||||
fatal(fmt.Errorf("build cross %s: %w: %s", tool, err, bytes))
|
||||
}
|
||||
outputs = append(outputs, output)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
func writeBytes(writer *tar.Writer, name string, data []byte) {
|
||||
if err := writer.WriteHeader(&tar.Header{Name: name, Size: int64(len(data)), Mode: 0644, Typeflag: tar.TypeReg}); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[2] / "sdk/python-runtime.py"
|
||||
SPECIFICATION = importlib.util.spec_from_file_location("python_runtime", SOURCE)
|
||||
runtime = importlib.util.module_from_spec(SPECIFICATION)
|
||||
SPECIFICATION.loader.exec_module(runtime)
|
||||
|
||||
|
||||
class PythonRuntimeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
|
||||
def archive(self, names):
|
||||
path = self.root / "python.tar.gz"
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
for name in names:
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = 4
|
||||
archive.addfile(member, io.BytesIO(b"test"))
|
||||
return path
|
||||
|
||||
def test_only_runtime_files_are_selected(self):
|
||||
for name in ["python/bin/python3.13", "python/lib/libpython3.13.so.1.0",
|
||||
"python/lib/python3.13/LICENSE.txt", "python/lib/python3.13/asyncio/tasks.py"]:
|
||||
self.assertTrue(runtime.selected(name, "3.13"), name)
|
||||
for name in ["python/bin/python3", "python/bin/pip3", "python/include/python3.13/Python.h",
|
||||
"python/lib/python3.13/site-packages/pip/__init__.py",
|
||||
"python/lib/python3.13/test/test_os.py", "python/lib/python3.13/__pycache__/os.pyc"]:
|
||||
self.assertFalse(runtime.selected(name, "3.13"), name)
|
||||
|
||||
def test_wheel_tags_include_compatible_abis_without_claiming_newer_glibc(self):
|
||||
tags = runtime.wheel_tags("3.13", "linux-x86_64", (2, 17))
|
||||
self.assertEqual(tags[0], "cp313-cp313-manylinux_2_17_x86_64")
|
||||
self.assertEqual(len(tags), len(set(tags)))
|
||||
for tag in ["cp313-cp313-manylinux2014_x86_64", "cp39-abi3-manylinux_2_17_x86_64",
|
||||
"cp38-abi3-manylinux1_x86_64", "cp313-none-manylinux_2_17_x86_64",
|
||||
"cp313-cp313-linux_x86_64", "py313-none-any", "py3-none-any", "py38-none-any"]:
|
||||
self.assertIn(tag, tags)
|
||||
for tag in ["cp314-abi3-manylinux_2_17_x86_64", "cp312-cp312-manylinux_2_17_x86_64",
|
||||
"cp313-cp313-manylinux_2_28_x86_64"]:
|
||||
self.assertNotIn(tag, tags)
|
||||
arm_tags = runtime.wheel_tags("3.13", "linux-aarch64", (2, 17))
|
||||
self.assertIn("cp39-abi3-manylinux2014_aarch64", arm_tags)
|
||||
self.assertIn("cp313-cp313-linux_aarch64", arm_tags)
|
||||
self.assertFalse(any("manylinux1_" in tag or "manylinux2010_" in tag for tag in arm_tags))
|
||||
with self.assertRaises(ValueError):
|
||||
runtime.wheel_tags("3.13", "linux-aarch64", (2, 16))
|
||||
|
||||
def test_extraction_rejects_traversal_and_duplicates(self):
|
||||
for names in [["python/../escape"], ["/python/bin/python3.13"],
|
||||
["python/bin/python3.13", "python/bin/python3.13"]]:
|
||||
with self.subTest(names=names), self.assertRaises(ValueError):
|
||||
runtime.extract(self.archive(names), self.root / "output", "3.13")
|
||||
|
||||
def test_wrong_hash_does_not_create_output(self):
|
||||
archive = self.archive([])
|
||||
output = self.root / "output"
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
runtime.prepare(archive, "0" * 64, "3.13.5", "linux-x86_64", (2, 17), SOURCE, output)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_failure_cleans_owned_output_and_preserves_existing_output(self):
|
||||
archive = self.archive([])
|
||||
output = self.root / "output"
|
||||
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError, "interpreter or license"):
|
||||
runtime.prepare(archive, digest, "3.13.5", "linux-x86_64", (2, 17), SOURCE, output)
|
||||
self.assertFalse(output.exists())
|
||||
output.mkdir()
|
||||
(output / "keep").write_text("existing")
|
||||
with self.assertRaises(FileExistsError):
|
||||
runtime.prepare(archive, digest, "3.13.5", "linux-x86_64", (2, 17), SOURCE, output)
|
||||
self.assertEqual((output / "keep").read_text(), "existing")
|
||||
|
||||
def test_newer_glibc_is_rejected_before_python_executes(self):
|
||||
binary = self.root / "python"
|
||||
binary.write_bytes(b"\x7fELF")
|
||||
text = "Machine: Advanced Micro Devices X86-64\nName: GLIBC_2.38\n"
|
||||
result = subprocess.CompletedProcess([], 0, text, "")
|
||||
with patch.object(runtime.subprocess, "run", return_value=result):
|
||||
with self.assertRaisesRegex(ValueError, "GLIBC_2.38"):
|
||||
runtime.check_elf(binary, SOURCE, "linux-x86_64", (2, 17))
|
||||
result.stdout = "Machine: AArch64\nName: GLIBC_2.17\n"
|
||||
with patch.object(runtime.subprocess, "run", return_value=result):
|
||||
with self.assertRaisesRegex(ValueError, "wrong target"):
|
||||
runtime.check_elf(binary, SOURCE, "linux-x86_64", (2, 17))
|
||||
result.stdout = "Machine: Advanced Micro Devices X86-64\nName: GLIBC_PRIVATE\n"
|
||||
with patch.object(runtime.subprocess, "run", return_value=result):
|
||||
with self.assertRaisesRegex(ValueError, "private glibc"):
|
||||
runtime.check_elf(binary, SOURCE, "linux-x86_64", (2, 17))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,56 @@
|
||||
# 性能测试
|
||||
|
||||
主入口是 `test/rust/performance.rs`,比较真实 Dever 生成程序和 Rust 实现。Go 与 Web 框架对照使用本目录的 Python 驱动。先测改动涉及的功能,完整矩阵用于阶段检查。
|
||||
|
||||
## 运行
|
||||
|
||||
在仓库的 `rust/` 目录执行,使用锁定工具链:
|
||||
|
||||
```sh
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --storage
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --map-values
|
||||
cargo +1.95.0 bench --locked --offline --bench performance -- --json
|
||||
```
|
||||
|
||||
| 参数 | 工作负载 |
|
||||
| --- | --- |
|
||||
| 不传参数 | 基础内核 |
|
||||
| `--storage`、`--map-values` | Map/List 的构造、查找、借用和筛选 |
|
||||
| `--collections`、`--handlers`、`--text`、`--bytes` | 集合处理、函数传递、文本和字节 |
|
||||
| `--numeric`、`--decimal-ops`、`--decimal-sum` | 数值与十进制计算 |
|
||||
| `--application`、`--query` | CMD、JSON 输入输出和 query 解码 |
|
||||
| `--json`、`--json-document` | JSON 与动态文档 |
|
||||
| `--resources`、`--tasks` | 文件、流、任务调度和取消 |
|
||||
| `--database`、`--database-postgres` | 数据库操作,PostgreSQL 需独立测试配置 |
|
||||
| `--websocket-protocol` | 独立协议对端的消息传输 |
|
||||
| `--websocket` | 共享运行库的 WebSocket 生成适配 |
|
||||
|
||||
其他诊断开关见主入口及 `performance/extended/`、`performance/transport/`。比较程序保存在 `rust/target/programs/release/`,使用 `--build-only` 可只构建,再单独运行生成的程序。
|
||||
|
||||
Python 驱动从仓库根使用,先查看参数:
|
||||
|
||||
```sh
|
||||
python3 test/performance/kernel_go.py --help
|
||||
python3 test/performance/http_go.py --help
|
||||
python3 test/performance/cms.py --help
|
||||
```
|
||||
|
||||
这些测试可能编译程序、启动回环服务或访问测试数据库;地址和工具路径使用现有测试配置,不能指向业务数据。
|
||||
|
||||
## 公平比较
|
||||
|
||||
- 使用相同语义、输入、精度、依赖、编译选项和资源限制。Dever 的错误处理、复制和任务清理,参照也须完成。
|
||||
- 绑定可用 CPU,避免同时构建或跑其他基准。记录频率、节流和负载,保留正向与反向执行顺序。
|
||||
- 提前固定轮次和预热次数,保留全部样本、绝对耗时、失败次数及源码/二进制摘要,不测到通过才停。
|
||||
- 独立 Rust 参照用于比较实现;同表、同函数或共享运行库仅用于诊断生成层与测量波动。
|
||||
- 报告修改前后及相对参照的变化。一次快结果不能代表稳定提速,一个样例不能代表整门语言。
|
||||
|
||||
## 网络与内存
|
||||
|
||||
网络结果须同时检查响应内容、错误、丢弃请求和实际测量时长,不能只看 QPS。客户端与服务端分别限制 CPU;并发数相同不等于负载相同。
|
||||
|
||||
`--memory` 构建分配诊断产物,探针位于 `test/rust/performance/memory.c`。分配次数、存活字节、RSS/PSS 和 cgroup 内存分别报告;插桩结果不作为普通性能耗时。
|
||||
|
||||
## 保存结果
|
||||
|
||||
原始数据放在忽略的 `target/performance/` 或测试输出目录,与源码提交和编译器摘要对应。README 只维护测试方法,不追加逐轮成绩和修改日志。
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Measure real typed/raw JSON API requests with isolated native applications."""
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from build import fingerprint
|
||||
from process import process_sample
|
||||
from settings import write_private_json
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/api-json"
|
||||
|
||||
|
||||
def configuration(port):
|
||||
return {
|
||||
"http": {"host": "127.0.0.1", "port": port},
|
||||
"runtime": {"mode": "api", "shutdown_ms": 2000},
|
||||
"log": {"level": "error"},
|
||||
"database": {"default": {"type": "sqlite", "path": "data/control.db",
|
||||
"max_connections": 2}},
|
||||
"auth": {"providers": {"session": {
|
||||
"verify": "benchmark.auth.verify", "jwtSecret": secrets.token_hex(32),
|
||||
"cookie": "benchmark_session"}}},
|
||||
"sites": {"test": {"path": "", "auth": "session", "hosts": ["localhost"],
|
||||
"origin": "https://localhost"}},
|
||||
}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
project = output / "project"
|
||||
shutil.copytree(FIXTURE / "benchmark", project / "module/benchmark")
|
||||
(project / "config").mkdir()
|
||||
write_private_json(project / "config/setting.json", configuration(8080))
|
||||
artifacts = {}
|
||||
for label, compiler in (("before", arguments.baseline_compiler),
|
||||
("after", arguments.compiler)):
|
||||
compiler = compiler.resolve()
|
||||
executable = output / label
|
||||
started = time.monotonic()
|
||||
with (output / f"{label}.build.log").open("x") as log:
|
||||
subprocess.run([compiler, "build", project, "--output", executable],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=900)
|
||||
artifacts[label] = {"path": str(executable), **fingerprint(executable),
|
||||
"compiler": {"path": str(compiler), **fingerprint(compiler)},
|
||||
"build_seconds": time.monotonic() - started,
|
||||
"runtime_manifests": {
|
||||
str(path.relative_to(compiler.parent)): fingerprint(path)
|
||||
for path in sorted((compiler.parent / "runtime").glob("*/manifest.json"))}}
|
||||
if arguments.with_peers:
|
||||
go_binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", go_binary,
|
||||
FIXTURE.resolve() / "peer.go"], check=True, timeout=180)
|
||||
artifacts["go"] = {"path": str(go_binary), **fingerprint(go_binary),
|
||||
"compiler": subprocess.check_output(["go", "version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "peer.go"),
|
||||
"scope": "native codec/transport baseline; not full Dever API semantics"}
|
||||
built = subprocess.run(["cargo", "build", "--offline", "--locked", "--release",
|
||||
"-p", "dever-tests", "--example", "api-json-peer",
|
||||
"--message-format=json"], capture_output=True, text=True,
|
||||
timeout=600)
|
||||
(output / "rust.build.log").write_text(built.stderr)
|
||||
built.check_returncode()
|
||||
executables = [message["executable"] for line in built.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "api-json-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected one native Rust API peer")
|
||||
rust_binary = output / "rust"
|
||||
shutil.copy2(executables[0], rust_binary)
|
||||
artifacts["rust"] = {"path": str(rust_binary), **fingerprint(rust_binary),
|
||||
"compiler": subprocess.check_output(["rustc", "--version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "peer.rs"),
|
||||
"profile": "release: opt-level=3, fat LTO, codegen-units=1",
|
||||
"scope": "native codec/transport baseline; not full Dever API semantics"}
|
||||
if arguments.with_runtime_peer:
|
||||
runtime = arguments.compiler.resolve().parent / "runtime/linux-x86_64"
|
||||
if not (runtime / "libc.a").is_file() or not (runtime / "crt1.o").is_file():
|
||||
raise ValueError("Rust runtime peer requires the compiler's Linux x86_64 runtime inputs")
|
||||
command = ["cargo", "rustc", "--offline", "--locked", "--profile", "runtime-pack",
|
||||
"-p", "dever-runtime", "--features", "api,crypto,external,sqlite",
|
||||
"--example", "api-json-runtime-peer", "--message-format=json", "--",
|
||||
"-C", "target-feature=+crt-static", "-C", "relocation-model=static",
|
||||
"-C", f"link-arg=-B{runtime}/", "-C", f"link-arg=-L{runtime}/",
|
||||
"-C", f"link-arg=-Wl,-Map,{output / 'rust-runtime.link.map'}"]
|
||||
built = subprocess.run(command, capture_output=True, text=True, timeout=600)
|
||||
(output / "rust-runtime.build.log").write_text(built.stderr)
|
||||
built.check_returncode()
|
||||
executables = [message["executable"] for line in built.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "api-json-runtime-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected one Rust runtime API peer")
|
||||
runtime_binary = output / "rust-runtime"
|
||||
shutil.copy2(executables[0], runtime_binary)
|
||||
artifacts["rust_runtime"] = {
|
||||
"path": str(runtime_binary), **fingerprint(runtime_binary),
|
||||
"compiler": subprocess.check_output(["rustc", "--version"], text=True).strip(),
|
||||
"source": fingerprint(FIXTURE / "runtime-peer.rs"), "command": command,
|
||||
"runtime_inputs": {name: fingerprint(runtime / name) for name in
|
||||
["crt1.o", "crti.o", "crtbeginT.o", "crtend.o", "crtn.o", "libc.a", "libm.a", "libgcc.a", "libgcc_eh.a"]},
|
||||
"profile": "runtime-pack: opt-level=3, fat LTO, static executable, codegen-units=1",
|
||||
"scope": "shared Dever runtime contract with native Rust values and codecs; not an independent runtime",
|
||||
}
|
||||
manifest = {
|
||||
"artifacts": artifacts,
|
||||
"sources": {str(path.relative_to(FIXTURE)): fingerprint(path)
|
||||
for path in sorted(FIXTURE.rglob("*.dever"))},
|
||||
}
|
||||
write_private_json(output / "manifest.json", manifest)
|
||||
print(output / "manifest.json", flush=True)
|
||||
|
||||
|
||||
def request(client, route, body, expected):
|
||||
started = time.perf_counter_ns()
|
||||
client.request("POST", f"/benchmark/echo/{route}", body,
|
||||
{"Host": "localhost", "Content-Type": "application/json"})
|
||||
response = client.getresponse()
|
||||
encoded = response.read()
|
||||
elapsed = time.perf_counter_ns() - started
|
||||
if response.status != 200 or json.loads(encoded) != {
|
||||
"code": 0, "message": "ok", "data": expected}:
|
||||
raise AssertionError(f"API response mismatch: HTTP {response.status}")
|
||||
return elapsed
|
||||
|
||||
|
||||
def percentile(values, fraction):
|
||||
return sorted(values)[min(len(values) - 1, int(len(values) * fraction))] / 1000
|
||||
|
||||
|
||||
def measure(artifact, route, payload, arguments):
|
||||
expected = payload["payload" if route == "echo" else "source"]
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
with tempfile.TemporaryDirectory(prefix="api-json-", dir=arguments.output) as temporary:
|
||||
directory = Path(temporary)
|
||||
executable = directory / "app"
|
||||
shutil.copy2(artifact["path"], executable)
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", configuration(port))
|
||||
with (directory / "stdout").open("w+") as stdout, \
|
||||
(directory / "stderr").open("w+") as stderr:
|
||||
process = subprocess.Popen(["taskset", "-c", str(arguments.server_cpu), executable],
|
||||
cwd=directory, stdout=stdout, stderr=stderr,
|
||||
start_new_session=True)
|
||||
client = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
try:
|
||||
deadline = time.monotonic() + 20
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"API exited before readiness: {process.returncode}")
|
||||
try:
|
||||
request(client, route, body, expected)
|
||||
break
|
||||
except (ConnectionError, http.client.HTTPException):
|
||||
client.close()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("API readiness timeout")
|
||||
time.sleep(0.02)
|
||||
for _ in range(50):
|
||||
request(client, route, body, expected)
|
||||
before = process_sample(process.pid)
|
||||
started = time.monotonic()
|
||||
latencies = [request(client, route, body, expected)
|
||||
for _ in range(arguments.requests)]
|
||||
elapsed = time.monotonic() - started
|
||||
after = process_sample(process.pid)
|
||||
result = {
|
||||
"requests": len(latencies), "request_bytes": len(body),
|
||||
"elapsed_seconds": elapsed,
|
||||
"p50_us": percentile(latencies, 0.5),
|
||||
"p95_us": percentile(latencies, 0.95),
|
||||
"p99_us": percentile(latencies, 0.99),
|
||||
"server_cpu_us_per_request":
|
||||
(after["cpu_seconds"] - before["cpu_seconds"]) * 1e6 / len(latencies),
|
||||
"rss_bytes": after["rss_bytes"],
|
||||
"rss_growth_bytes": after["rss_bytes"] - before["rss_bytes"],
|
||||
}
|
||||
except BaseException as error:
|
||||
stderr.flush()
|
||||
stderr.seek(0)
|
||||
error.add_note(stderr.read()[-4000:])
|
||||
raise
|
||||
finally:
|
||||
client.close()
|
||||
if process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=5)
|
||||
raise TimeoutError("API did not drain after SIGTERM")
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"API shutdown failed: {process.returncode}")
|
||||
return result
|
||||
|
||||
|
||||
def run(arguments):
|
||||
allowed = os.sched_getaffinity(0)
|
||||
if (arguments.server_cpu == arguments.client_cpu or
|
||||
not {arguments.server_cpu, arguments.client_cpu} <= allowed):
|
||||
raise ValueError("server and client require distinct allowed CPUs")
|
||||
if arguments.requests < 1 or arguments.repeats < 1:
|
||||
raise ValueError("requests and repeats must be positive")
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
for artifact in manifest["artifacts"].values():
|
||||
if fingerprint(Path(artifact["path"])) != {
|
||||
key: artifact[key] for key in ("bytes", "sha256")}:
|
||||
raise ValueError("API benchmark binary changed after build")
|
||||
arguments.output.mkdir(parents=True, exist_ok=False)
|
||||
os.sched_setaffinity(0, {arguments.client_cpu})
|
||||
labels = list(manifest["artifacts"])
|
||||
report = {"manifest": manifest, "server_cpu": arguments.server_cpu,
|
||||
"client_cpu": arguments.client_cpu, "samples": [], "summary": [],
|
||||
"environment": {"kernel": platform.release(), "machine": platform.machine(),
|
||||
"allowed_cpus": sorted(allowed), "python": platform.python_version()},
|
||||
"runner": fingerprint(Path(__file__)),
|
||||
"requests_per_process": arguments.requests, "repeats": arguments.repeats}
|
||||
try:
|
||||
for count in (1, 16, 128):
|
||||
row = {"id": 9007199254740993, "text": "雪😀\n\"" + "x" * 80,
|
||||
"weight": 1.25, "note": None}
|
||||
value = {"rows": [row] * count, "raw": {"large": 123456789012345678901234567890}}
|
||||
for route, argument in (("echo", "payload"), ("raw", "source")):
|
||||
for repeat in range(arguments.repeats):
|
||||
offset = repeat % len(labels)
|
||||
order = labels[offset:] + labels[:offset]
|
||||
for label in order:
|
||||
sample = measure(manifest["artifacts"][label], route,
|
||||
{argument: value}, arguments)
|
||||
report["samples"].append({"label": label, "route": route,
|
||||
"rows": count, "repeat": repeat, **sample})
|
||||
for label in labels:
|
||||
samples = [row for row in report["samples"]
|
||||
if row["label"] == label and row["route"] == route
|
||||
and row["rows"] == count]
|
||||
summary = {"label": label, "route": route, "rows": count}
|
||||
for metric in ("p50_us", "p95_us", "p99_us",
|
||||
"server_cpu_us_per_request", "rss_bytes"):
|
||||
summary[metric] = statistics.median(sample[metric] for sample in samples)
|
||||
report["summary"].append(summary)
|
||||
print(json.dumps(summary), flush=True)
|
||||
for artifact in manifest["artifacts"].values():
|
||||
if fingerprint(Path(artifact["path"])) != {
|
||||
key: artifact[key] for key in ("bytes", "sha256")}:
|
||||
raise ValueError("API benchmark binary changed during measurement")
|
||||
report["passed"] = True
|
||||
finally:
|
||||
write_private_json(arguments.output / "report.json", report)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare = commands.add_parser("build")
|
||||
prepare.add_argument("--compiler", type=Path, required=True)
|
||||
prepare.add_argument("--baseline-compiler", type=Path, required=True)
|
||||
prepare.add_argument("--output", type=Path, required=True)
|
||||
prepare.add_argument("--with-peers", action="store_true")
|
||||
prepare.add_argument("--with-runtime-peer", action="store_true")
|
||||
prepare.set_defaults(action=build)
|
||||
measure = commands.add_parser("run")
|
||||
measure.add_argument("--manifest", type=Path, required=True)
|
||||
measure.add_argument("--output", type=Path, required=True)
|
||||
measure.add_argument("--server-cpu", type=int, required=True)
|
||||
measure.add_argument("--client-cpu", type=int, required=True)
|
||||
measure.add_argument("--requests", type=int, default=1000)
|
||||
measure.add_argument("--repeats", type=int, default=7)
|
||||
measure.set_defaults(action=run)
|
||||
arguments = parser.parse_args()
|
||||
arguments.output = arguments.output.resolve()
|
||||
arguments.action(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Verify the API JSON fixture's observable contract before runtime comparison."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from api_json import configuration, request, write_private_json
|
||||
from build import fingerprint
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server(binary, output, settings):
|
||||
with tempfile.TemporaryDirectory(prefix="api-contract-", dir=output) as temporary:
|
||||
directory = Path(temporary)
|
||||
executable = directory / "app"
|
||||
shutil.copy2(binary, executable)
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
port = listener.getsockname()[1]
|
||||
settings = deepcopy(settings)
|
||||
settings["http"]["port"] = port
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", settings)
|
||||
with (directory / "stdout").open("w+") as stdout, (directory / "stderr").open("w+") as stderr:
|
||||
process = subprocess.Popen([executable], cwd=directory, stdout=stdout,
|
||||
stderr=stderr, start_new_session=True)
|
||||
client = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
||||
try:
|
||||
deadline = time.monotonic() + 30
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"API contract server exited: {process.returncode}")
|
||||
try:
|
||||
request(client, "raw", b'{"source":0}', 0)
|
||||
break
|
||||
except (ConnectionError, http.client.HTTPException):
|
||||
client.close()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("API contract server readiness timeout")
|
||||
time.sleep(0.05)
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
if process.poll() is None:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
raise
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"API contract server shutdown failed: {process.returncode}")
|
||||
|
||||
|
||||
def credential(settings):
|
||||
def encode(value):
|
||||
return base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()).rstrip(b"=")
|
||||
|
||||
now = int(time.time())
|
||||
payload = encode({"alg": "HS256", "typ": "JWT"}) + b"." + encode({
|
||||
"iss": "session", "sub": "fixture", "sid": "fixture-session", "aud": "test",
|
||||
"tenant": None, "iat": now, "exp": now + 600,
|
||||
})
|
||||
key = settings["auth"]["providers"]["session"]["jwtSecret"].encode()
|
||||
signature = base64.urlsafe_b64encode(hmac.digest(key, payload, "sha256")).rstrip(b"=")
|
||||
return (payload + b"." + signature).decode()
|
||||
|
||||
|
||||
def cases(settings):
|
||||
result = []
|
||||
row = '{"id":9007199254740993,"text":"雪😀\\n\\\"","weight":1.25,"note":null}'
|
||||
typed = '{"payload":{"rows":[' + row + '],"raw":{"large":123456789012345678901234567890}}}'
|
||||
|
||||
def add(name, body, status=200, route="raw", headers=None, method="POST", query=""):
|
||||
result.append({"name": name, "body": body.encode() if isinstance(body, str) else body,
|
||||
"status": status, "path": "/benchmark/echo/" + route + query,
|
||||
"headers": headers or [("Host", "localhost"), ("Content-Type", "application/json")],
|
||||
"method": method})
|
||||
|
||||
add("typed-unicode-exact-number", typed, route="echo")
|
||||
add("typed-optional-absent", typed.replace(',"note":null', ""), route="echo")
|
||||
add("typed-optional-text", typed.replace('"note":null', '"note":"雪"'), route="echo")
|
||||
for spelling in ["-0", "-9223372036854775808", "9223372036854775807"]:
|
||||
add("typed-int-" + spelling, typed.replace("9007199254740993", spelling), route="echo")
|
||||
for spelling in ["1.0", "1e0", "9223372036854775808", "-9223372036854775809", "true"]:
|
||||
add("typed-invalid-int-" + spelling, typed.replace("9007199254740993", spelling), 400, "echo")
|
||||
add("typed-nonfinite", typed.replace("1.25", "1e999"), 400, "echo")
|
||||
add("typed-unknown-field", typed.replace('"weight":', '"extra":0,"weight":'), 400, "echo")
|
||||
add("typed-missing-field", typed.replace('"weight":1.25,', ""), 400, "echo")
|
||||
add("typed-duplicate-escaped-field", typed.replace('"weight":', '"we\\u0069ght":0,"weight":'), 400, "echo")
|
||||
add("typed-invalid-optional", typed.replace('"note":null', '"note":false'), 400, "echo")
|
||||
for name, value in [("number", "1e999"), ("null", "null"), ("array", "[1,true,null]"),
|
||||
("unicode", '"\\ud83d\\ude00"'), ("object", '{"large":123456789012345678901234567890}')]:
|
||||
add("raw-" + name, '{"source":' + value + '}')
|
||||
for name, body in [("duplicate", '{"source":{"key":1,"k\\u0065y":2}}'),
|
||||
("surrogate", '{"source":"\\ud800"}'),
|
||||
("trailing-comma", '{"source":[0,]}'),
|
||||
("root-duplicate", '{"source":0,"source":1}'),
|
||||
("root-unknown", '{"source":0,"extra":1}'),
|
||||
("root-missing", '{}'), ("trailing-root", '{"source":0}[]'),
|
||||
("invalid-utf8", b'{"source":"\xff"}')]:
|
||||
add(name, body, 400)
|
||||
for depth, status in [(63, 200), (64, 400)]:
|
||||
add(f"depth-{depth}", '{"source":' + '[' * depth + '0' + ']' * depth + '}', status)
|
||||
for count, status in [(65534, 200), (65535, 400)]:
|
||||
add(f"elements-{count}", '{"source":[' + ','.join(['0'] * count) + ']}', status)
|
||||
add("wrong-method", '{}', 405, method="GET")
|
||||
add("unknown-route", '{}', 404, route="missing")
|
||||
add("query-on-post", '{"source":0}', 400, query="?value=1")
|
||||
add("wrong-content-type", '{}', 400, headers=[("Host", "localhost"), ("Content-Type", "text/plain")])
|
||||
add("duplicate-content-type", '{}', 400, headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Content-Type", "application/json")])
|
||||
add("wrong-host", '{}', 401, headers=[("Host", "other.invalid"), ("Content-Type", "application/json")])
|
||||
add("invalid-bearer", '{}', 401, headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Authorization", "Bearer invalid")])
|
||||
add("valid-bearer", typed, route="echo", headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Authorization", "Bearer " + credential(settings))])
|
||||
add("public-expired-cookie", '{"source":0}', headers=[("Host", "localhost"), ("Content-Type", "application/json"), ("Cookie", "benchmark_session=invalid")])
|
||||
return result
|
||||
|
||||
|
||||
def verify(dever, rust, output):
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
artifacts = {label: {"path": str(binary.resolve()), **fingerprint(binary)}
|
||||
for label, binary in [("dever", dever), ("rust_runtime", rust)]}
|
||||
settings = configuration(8080)
|
||||
report = {"artifacts": artifacts, "cases": [], "passed": False}
|
||||
try:
|
||||
with ExitStack() as stack:
|
||||
clients = {label: stack.enter_context(server(Path(artifact["path"]), output, settings))
|
||||
for label, artifact in artifacts.items()}
|
||||
for case in cases(settings):
|
||||
observed = {}
|
||||
for label, client in clients.items():
|
||||
client.putrequest(case["method"], case["path"], skip_host=True, skip_accept_encoding=True)
|
||||
for name, value in case["headers"]:
|
||||
client.putheader(name, value)
|
||||
client.putheader("Content-Length", str(len(case["body"])))
|
||||
client.endheaders(case["body"])
|
||||
response = client.getresponse()
|
||||
body = response.read()
|
||||
observed[label] = (response.status, response.getheader("Content-Type"), response.getheader("Allow"), body)
|
||||
client.close()
|
||||
passed = all(value[0] == case["status"] for value in observed.values()) and len(set(observed.values())) == 1
|
||||
report["cases"].append({"name": case["name"], "expected_status": case["status"], "passed": passed,
|
||||
"observed": {label: {"status": value[0], "body_sha256": hashlib.sha256(value[3]).hexdigest(), "bytes": len(value[3])}
|
||||
for label, value in observed.items()}})
|
||||
if not passed:
|
||||
raise AssertionError(f"API contract differs for {case['name']}: " + repr({label: (value[0], value[3][:240]) for label, value in observed.items()}))
|
||||
for artifact in artifacts.values():
|
||||
assert fingerprint(Path(artifact["path"])) == {key: artifact[key] for key in ["bytes", "sha256"]}
|
||||
report["passed"] = True
|
||||
finally:
|
||||
write_private_json(output / "report.json", report)
|
||||
print(json.dumps({"passed": True, "cases": len(report["cases"])}), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dever", type=Path, required=True)
|
||||
parser.add_argument("--rust", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
verify(arguments.dever, arguments.rust, arguments.output)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""构建可搬运的基准产物;构建耗时与运行测量分开。"""
|
||||
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from settings import PEER_CONFIGURATION, stage_peer, write_private_json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
TLS = ROOT / "test/dever-tests/fixtures/tls"
|
||||
SETTING_PATH = ROOT / "config/setting.json"
|
||||
POSTGRES_TEST_CONNECTION = "postgres_test"
|
||||
APPLICATION_CONFIGURATION = "llvm-cmd-v1"
|
||||
ENTRIES = {
|
||||
"runtime": ("sync_idle", "async_idle", "tasks", "tasks_on_worker", "channels", "cancel"),
|
||||
"http": ("http", "https", "http2", "https2"),
|
||||
"live": ("tcp", "ws", "sse"),
|
||||
}
|
||||
ORM_ENTRIES = ("orm_idle", "orm_crud", "orm_list", "orm_cursor", "orm_stream",
|
||||
"orm_pool", "orm_http")
|
||||
RUNTIME_PROFILES = {
|
||||
"base": ("runtime-api", "runtime-external"),
|
||||
"sqlite": ("runtime-api", "runtime-external", "runtime-sqlite"),
|
||||
"postgres": ("runtime-api", "runtime-external", "runtime-postgres"),
|
||||
"both": ("runtime-api", "runtime-external", "runtime-sqlite", "runtime-postgres"),
|
||||
}
|
||||
BENCHMARK_COMMAND = "benchmark.command.run"
|
||||
CONFIG_FUNCTIONS = {
|
||||
"runtime": ("lifetime", "idleSampleMs", "pending", "channelCapacity", "input", "pendingInput"),
|
||||
"http": ("lifetime", "limits", "http2Limits", "largeBody", "certificate", "key"),
|
||||
"live": (),
|
||||
"orm": ("lifetime", "ormInput", "ormRows", "ormConcurrency", "ormCancelWork"),
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as binary:
|
||||
digest = hashlib.file_digest(binary, "sha256").hexdigest()
|
||||
return {"sha256": digest, "bytes": path.stat().st_size}
|
||||
|
||||
|
||||
def postgres_test_connection(document):
|
||||
try:
|
||||
connection = document["database"][POSTGRES_TEST_CONNECTION]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ValueError(
|
||||
f"config/setting.json must define database.{POSTGRES_TEST_CONNECTION}"
|
||||
) from error
|
||||
if not isinstance(connection, dict):
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION} must be an object")
|
||||
if connection.get("type") != "postgres":
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION} must use type 'postgres'")
|
||||
max_connections = connection.get("max_connections")
|
||||
if type(max_connections) is not int or not 1 <= max_connections <= 256:
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.max_connections must be an explicit 1..256 integer"
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def postgres_case_url(document, case_name):
|
||||
template = postgres_test_connection(document).get("url")
|
||||
if not isinstance(template, str):
|
||||
raise ValueError(f"database.{POSTGRES_TEST_CONNECTION}.url must be Text")
|
||||
parsed = urlsplit(template)
|
||||
database = parsed.path.removeprefix("/")
|
||||
if (template.count("{case}") != 1 or "{case}" not in database
|
||||
or not database or "/" in database):
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.url must contain one {{case}} placeholder "
|
||||
"in its database name"
|
||||
)
|
||||
if parsed.scheme not in ("postgres", "postgresql"):
|
||||
raise ValueError(
|
||||
f"database.{POSTGRES_TEST_CONNECTION}.url must use postgres:// or postgresql://"
|
||||
)
|
||||
return template.replace("{case}", case_name)
|
||||
|
||||
|
||||
def postgres_database_case(output, case_name):
|
||||
run = hashlib.sha256(str(output.resolve()).encode()).hexdigest()[:12]
|
||||
return f"{run}_{case_name.replace('-', '_')}"
|
||||
|
||||
|
||||
def load_postgres_setting(path=SETTING_PATH):
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
document = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot load '{path}': {error}") from error
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError(f"'{path}' must contain a JSON object")
|
||||
connections = document.get("database")
|
||||
if connections is None:
|
||||
return None
|
||||
if not isinstance(connections, dict):
|
||||
raise ValueError(f"'{path}' database must be an object")
|
||||
if POSTGRES_TEST_CONNECTION not in connections:
|
||||
return None
|
||||
postgres_case_url(document, "validation")
|
||||
return document
|
||||
|
||||
|
||||
def configuration(args, postgres_setting=None):
|
||||
settings = {
|
||||
"workers": args.workers,
|
||||
"connections": args.connections,
|
||||
"pending": args.pending,
|
||||
"iterations": args.iterations,
|
||||
"channel_capacity": 64,
|
||||
"timeout_ms": 2000,
|
||||
"lifetime_ms": 3_600_000,
|
||||
"idle_sample_ms": 3000,
|
||||
"task_capacity": max(args.pending + 16, 2 * args.connections + 16),
|
||||
"max_blocking_threads": 2,
|
||||
"http2_streams": args.http2_streams,
|
||||
"http2_stream_window_bytes": args.http2_stream_window_bytes,
|
||||
"http2_connection_window_bytes": args.http2_connection_window_bytes,
|
||||
"orm_iterations": args.orm_iterations,
|
||||
"orm_rows": args.orm_rows,
|
||||
"orm_concurrency": args.orm_concurrency,
|
||||
"orm_cancel_work": args.orm_cancel_work,
|
||||
"orm_sqlite_connections": args.orm_sqlite_connections,
|
||||
"orm_postgres_connections": (
|
||||
postgres_test_connection(postgres_setting)["max_connections"]
|
||||
if postgres_setting is not None else None
|
||||
),
|
||||
}
|
||||
if settings["task_capacity"] > 65536:
|
||||
raise ValueError("task capacity exceeds the language limit")
|
||||
return settings
|
||||
|
||||
|
||||
def config_source(settings, fixture):
|
||||
names = CONFIG_FUNCTIONS[fixture]
|
||||
functions = {
|
||||
"lifetime": ("Int", str(settings["lifetime_ms"])),
|
||||
"idleSampleMs": ("Int", str(settings["idle_sample_ms"])),
|
||||
"pending": ("Int", str(settings["pending"])),
|
||||
"channelCapacity": ("Int", str(settings["channel_capacity"])),
|
||||
"input": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["iterations"])})'),
|
||||
"pendingInput": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["pending"])})'),
|
||||
"ormInput": ("Bytes", f'dever.bytes.fromText({json.dumps("x" * settings["orm_iterations"])})'),
|
||||
"ormRows": ("Bytes", f'dever.bytes.fromText({json.dumps("r" * settings["orm_rows"])})'),
|
||||
"ormConcurrency": ("Int", str(settings["orm_concurrency"])),
|
||||
"ormCancelWork": ("Int", str(settings["orm_cancel_work"])),
|
||||
"limits": ("dever.http.Limits", "dever.http.Limits {\nheader_bytes = 8192\n"
|
||||
f'body_bytes = 65536\ntimeout_ms = {settings["timeout_ms"]}\n'
|
||||
f'connections = {settings["connections"]}\nhttp2 = null\n}}'),
|
||||
"http2Limits": ("dever.http.Limits", "dever.http.Limits {\nheader_bytes = 8192\n"
|
||||
f'body_bytes = 65536\ntimeout_ms = {settings["timeout_ms"]}\n'
|
||||
f'connections = {settings["connections"]}\nhttp2 = dever.http.Http2Limits {{\n'
|
||||
f'streams = {settings["http2_streams"]}\n'
|
||||
f'stream_window_bytes = {settings["http2_stream_window_bytes"]}\n'
|
||||
f'connection_window_bytes = {settings["http2_connection_window_bytes"]}\n}}\n}}'),
|
||||
}
|
||||
if "certificate" in names:
|
||||
functions["certificate"] = ("Bytes", f'dever.bytes.fromText({json.dumps((TLS / "server.pem").read_text())})')
|
||||
if "key" in names:
|
||||
functions["key"] = ("Bytes", f'dever.bytes.fromText({json.dumps((TLS / "server-key.pem").read_text())})')
|
||||
source = ""
|
||||
for name in names:
|
||||
if name == "largeBody":
|
||||
continue
|
||||
kind, expression = functions[name]
|
||||
source += f"\n{name}() (result: {kind}) {{\nresult = {expression}\n}}\n"
|
||||
if "largeBody" in names:
|
||||
source += '\ndouble(value: Int, text: Text) (result: Text) { result = text + text }\n'
|
||||
source += '\nlargeBody() (result: Bytes) {\nresult = dever.bytes.fromText(reduce(double, '
|
||||
source += '[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], "x"))\n}\n'
|
||||
return source
|
||||
|
||||
|
||||
def stage_application_command(module, capability):
|
||||
command = module / "benchmark/command"
|
||||
command.mkdir(parents=True, exist_ok=True)
|
||||
(command / "app.dever").write_text(
|
||||
f"run() (result: Bool) {{\n result = {capability}()\n}}\n"
|
||||
)
|
||||
(command / "api.dever").write_text("cmd run = app.run\n")
|
||||
|
||||
|
||||
def stage_benchmark_support(module, settings, fixture):
|
||||
if fixture in ("runtime", "orm"):
|
||||
bench = module / "benchmark/bench/app.dever"
|
||||
bench.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(FIXTURES / "bench.dever", bench)
|
||||
if CONFIG_FUNCTIONS[fixture]:
|
||||
config = module / "benchmark/config/app.dever"
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.write_text(config_source(settings, fixture))
|
||||
|
||||
|
||||
def database_setting(driver, settings, postgres_setting=None, case_name=None):
|
||||
if driver == "sqlite":
|
||||
database = {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/orm.db",
|
||||
"max_connections": settings["orm_sqlite_connections"],
|
||||
"max_page_size": max(32, min(settings["orm_rows"], 10_000)),
|
||||
}
|
||||
elif driver == "postgres":
|
||||
if postgres_setting is None or case_name is None:
|
||||
raise ValueError("PostgreSQL requires config/setting.json")
|
||||
database = deepcopy(postgres_test_connection(postgres_setting))
|
||||
database["url"] = postgres_case_url(postgres_setting, case_name)
|
||||
else:
|
||||
raise ValueError(f"unknown database driver: {driver}")
|
||||
document = ({name: deepcopy(postgres_setting[name])
|
||||
for name in ("http", "log") if name in postgres_setting}
|
||||
if driver == "postgres" else {})
|
||||
document.setdefault("http", {})
|
||||
document.setdefault("log", {})
|
||||
document["database"] = {"default": database}
|
||||
document["auth"] = {"providers": {"benchmark": {
|
||||
"verify": "benchmark.item.verify",
|
||||
"jwtSecret": secrets.token_urlsafe(48),
|
||||
"ttlSeconds": 3600,
|
||||
}}}
|
||||
document["sites"] = {"benchmark": {"path": "", "auth": "benchmark"}}
|
||||
return document
|
||||
|
||||
|
||||
def profile_database_setting(profile):
|
||||
if profile == "base":
|
||||
return {}
|
||||
sqlite = {
|
||||
"type": "sqlite",
|
||||
"path": "data/db/profile.db",
|
||||
"max_connections": 1,
|
||||
"max_page_size": 100,
|
||||
}
|
||||
if profile == "sqlite":
|
||||
return {"database": {"default": sqlite}}
|
||||
if profile not in ("postgres", "both"):
|
||||
raise ValueError(f"unknown runtime profile: {profile}")
|
||||
postgres = {
|
||||
"type": "postgres",
|
||||
"url": "postgres://build-only.invalid/dever_profile",
|
||||
"tls": "disabled",
|
||||
"max_connections": 1,
|
||||
}
|
||||
if profile == "postgres":
|
||||
return {"database": {"default": postgres}}
|
||||
return {"database": {"default": sqlite, "postgres_profile": postgres}}
|
||||
|
||||
|
||||
def dependency_packages(text):
|
||||
return sorted({line.removesuffix(" (*)") for line in text.splitlines() if line})
|
||||
|
||||
|
||||
def runtime_dependencies(cargo, features):
|
||||
command = [cargo, "tree", "--offline", "--locked", "-p", "dever-backend-bridge",
|
||||
"--no-default-features", "--prefix", "none"]
|
||||
if features:
|
||||
command.extend(("--features", ",".join(features)))
|
||||
return dependency_packages(subprocess.check_output(command, cwd=ROOT, text=True))
|
||||
|
||||
|
||||
def prepare_native_project(destination, fixture, settings):
|
||||
module = destination / "module"
|
||||
domain = module / "benchmark" / fixture
|
||||
domain.mkdir(parents=True)
|
||||
shutil.copyfile(FIXTURES / fixture / "app.dever", domain / "app.dever")
|
||||
for role in ("port", "adapter", "api"):
|
||||
shutil.copyfile(FIXTURES / fixture / f"{role}.dever",
|
||||
domain / f"{role}.dever")
|
||||
stage_benchmark_support(module, settings, fixture)
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(config / "setting.json",
|
||||
native_runtime_setting(settings) if fixture == "live" else {})
|
||||
|
||||
|
||||
def prepare_project(destination, fixture, settings, driver, postgres_setting=None):
|
||||
module = destination / "module"
|
||||
domain = module / "benchmark/item"
|
||||
domain.mkdir(parents=True)
|
||||
shutil.copyfile(FIXTURES / fixture / "app.dever", domain / "app.dever")
|
||||
shutil.copyfile(FIXTURES / fixture / "app/model/item.dever", domain / "model.dever")
|
||||
if fixture == "orm":
|
||||
shutil.copyfile(FIXTURES / fixture / "app/api.dever", domain / "api.dever")
|
||||
stage_benchmark_support(module, settings, fixture)
|
||||
stage_application_command(module, "benchmark.item.ormIdle")
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(
|
||||
config / "setting.json",
|
||||
database_setting(driver, settings, postgres_setting, "build_only"),
|
||||
)
|
||||
(destination / "data").mkdir()
|
||||
|
||||
|
||||
def prepare_profile_project(destination, setting):
|
||||
shutil.copytree(FIXTURES / "profile", destination / "module")
|
||||
config = destination / "config"
|
||||
config.mkdir()
|
||||
write_private_json(config / "setting.json", setting)
|
||||
(destination / "data").mkdir()
|
||||
|
||||
|
||||
def prepare_cms_project(destination, source):
|
||||
shutil.copytree(ROOT / "examples/cms" / source, destination)
|
||||
|
||||
|
||||
def native_runtime_setting(settings):
|
||||
return {"adapter": {"benchmark.live": {"setting": {
|
||||
"limits": {
|
||||
"header_bytes": 8192,
|
||||
"body_bytes": 65536,
|
||||
"timeout_ms": settings["timeout_ms"],
|
||||
"connections": settings["connections"],
|
||||
"http2": None,
|
||||
},
|
||||
"lifetime_ms": settings["lifetime_ms"],
|
||||
}}}}
|
||||
|
||||
|
||||
def record_binary(manifest, name, binary, started, **metadata):
|
||||
manifest["binaries"][name] = {
|
||||
**fingerprint(binary),
|
||||
"build_seconds": time.monotonic() - started,
|
||||
**metadata,
|
||||
}
|
||||
|
||||
|
||||
def build_profile_report(args, manifest, log):
|
||||
report = {"source": "profile/benchmark/command/app.dever", "profiles": {}}
|
||||
for profile, features in RUNTIME_PROFILES.items():
|
||||
item = {"runtime_features": list(features),
|
||||
"dependencies": runtime_dependencies(args.cargo, features)}
|
||||
print(f"build profile-{profile}", flush=True)
|
||||
binary_name = f"profile-{profile}"
|
||||
binary = args.output.resolve() / binary_name
|
||||
started = time.monotonic()
|
||||
project = args.output.resolve() / "source" / f"profile-{profile}"
|
||||
setting = profile_database_setting(profile)
|
||||
prepare_profile_project(project, setting)
|
||||
subprocess.run(
|
||||
[args.compiler.resolve(), "build", project, "--output", binary],
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
check=True,
|
||||
)
|
||||
record_binary(manifest, binary_name, binary, started,
|
||||
profile=profile, fixture="profile")
|
||||
item.update({"status": "built", "binary": fingerprint(binary)})
|
||||
report["profiles"][profile] = item
|
||||
path = args.output.resolve() / "profile-report.json"
|
||||
path.write_text(json.dumps(report, indent=2) + "\n")
|
||||
manifest["files"]["profile-report.json"] = fingerprint(path)
|
||||
|
||||
|
||||
def build(args):
|
||||
has_protocols = args.selection in ("all", "protocols")
|
||||
if args.with_go and not has_protocols:
|
||||
raise ValueError("--with-go requires protocol fixtures")
|
||||
postgres_setting = load_postgres_setting() if args.selection == "all" else None
|
||||
settings = configuration(args, postgres_setting)
|
||||
destination = args.output.resolve()
|
||||
destination.mkdir(parents=True, exist_ok=False)
|
||||
compiler = args.compiler.resolve()
|
||||
cargo = shutil.which(str(args.cargo))
|
||||
if cargo is None:
|
||||
raise ValueError(f"cannot find Cargo command: {args.cargo}")
|
||||
args.cargo = Path(cargo)
|
||||
peers = ({name: getattr(args, name).resolve()
|
||||
for name in ("network_bench", "live_bench", "async_bench")}
|
||||
if has_protocols else {})
|
||||
required_tools = (compiler, *peers.values())
|
||||
for executable in required_tools:
|
||||
if not executable.is_file():
|
||||
raise ValueError(f"build the executable first: {executable}")
|
||||
# 在昂贵构建之前拒绝旧环境变量协议产物;这里只读配置,不启动网络。
|
||||
for name, peer in peers.items():
|
||||
if name == "async_bench":
|
||||
continue # The finite Rust reference takes explicit positional arguments.
|
||||
with tempfile.TemporaryDirectory(prefix="dever-peer-config-") as temporary:
|
||||
executable = stage_peer(peer, Path(temporary), {"workers": 2})
|
||||
result = subprocess.run([executable, "check-config"], capture_output=True,
|
||||
text=True, check=True, timeout=5)
|
||||
if json.loads(result.stdout) != {"workers": 2}:
|
||||
raise ValueError(f"benchmark peer does not implement {PEER_CONFIGURATION}: {peer}")
|
||||
manifest = {
|
||||
"peer_configuration": PEER_CONFIGURATION,
|
||||
"application_configuration": APPLICATION_CONFIGURATION,
|
||||
"selection": args.selection,
|
||||
"settings": settings,
|
||||
"cargo": subprocess.check_output([cargo, "-V"], text=True),
|
||||
"compiler": fingerprint(compiler),
|
||||
"optimization": "LLVM O2 application objects, LLD static link, selected prebuilt runtime archive",
|
||||
"runtime_scheduling": "Dever uses production runtime defaults; workers/task_capacity configure Rust references; workers also sets Go GOMAXPROCS when selected",
|
||||
"binaries": {},
|
||||
"files": {},
|
||||
}
|
||||
with (destination / "build.log").open("w") as log:
|
||||
for fixture, entries in (ENTRIES.items() if has_protocols else ()):
|
||||
source = destination / "source" / fixture
|
||||
prepare_native_project(source, fixture, settings)
|
||||
print(f"build {fixture}", flush=True)
|
||||
started = time.monotonic()
|
||||
executable = source / "app"
|
||||
subprocess.run([compiler, "build", source, "--output", executable],
|
||||
stdout=log, stderr=log, check=True)
|
||||
for entry in entries:
|
||||
binary = destination / entry
|
||||
shutil.copy2(executable, binary)
|
||||
record_binary(manifest, entry, binary, started, profile="base", fixture=fixture,
|
||||
command=f"benchmark.{fixture}.{entry}", shared_application=fixture)
|
||||
drivers = ()
|
||||
if args.selection == "all":
|
||||
drivers = ("sqlite", "postgres") if postgres_setting is not None else ("sqlite",)
|
||||
for driver in drivers:
|
||||
project = destination / "source" / f"orm-{driver}"
|
||||
try:
|
||||
prepare_project(project, "orm", settings, driver, postgres_setting)
|
||||
subprocess.run([compiler, "fmt", project], stdout=log, stderr=log, check=True)
|
||||
for entry in ORM_ENTRIES:
|
||||
name = f"{driver}-{entry.removeprefix('orm_')}"
|
||||
print(f"build {name}", flush=True)
|
||||
binary = destination / name
|
||||
started = time.monotonic()
|
||||
method = "ormSeed" if entry == "orm_http" else "orm" + entry.removeprefix("orm_").capitalize()
|
||||
command_entry = f"benchmark.item.{method}"
|
||||
stage_application_command(project / "module", command_entry)
|
||||
subprocess.run([compiler, "build", project, "--output", binary],
|
||||
stdout=log, stderr=log, check=True)
|
||||
record_binary(manifest, name, binary, started, profile=driver, fixture="orm",
|
||||
entry=entry)
|
||||
finally:
|
||||
if driver == "postgres":
|
||||
(project / "config/setting.json").unlink(missing_ok=True)
|
||||
cms_sources = {"all": ("dever",), "cms-profiles": ("dever",),
|
||||
"profiles": (), "protocols": ()}[args.selection]
|
||||
for source in cms_sources:
|
||||
cms_project = destination / "source" / f"cms-{source}"
|
||||
prepare_cms_project(cms_project, source)
|
||||
config_name = f"source/cms-{source}/config/setting.json"
|
||||
manifest["files"][config_name] = fingerprint(cms_project / "config/setting.json")
|
||||
binary_name = "cms"
|
||||
print(f"build cms-{source}", flush=True)
|
||||
cms_binary = destination / binary_name
|
||||
started = time.monotonic()
|
||||
subprocess.run([compiler, "build", cms_project, "--output", cms_binary],
|
||||
stdout=log, stderr=log, check=True)
|
||||
record_binary(manifest, binary_name, cms_binary, started,
|
||||
profile="sqlite", fixture="cms", source=source)
|
||||
if args.selection != "protocols":
|
||||
build_profile_report(args, manifest, log)
|
||||
for name, peer in peers.items():
|
||||
shutil.copy2(peer, destination / name)
|
||||
manifest["binaries"][name] = fingerprint(destination / name)
|
||||
if has_protocols:
|
||||
shutil.copytree(TLS, destination / "tls")
|
||||
for path in sorted((destination / "tls").rglob("*")):
|
||||
if path.is_file():
|
||||
name = path.relative_to(destination).as_posix()
|
||||
manifest["files"][name] = fingerprint(path)
|
||||
if args.with_go:
|
||||
binary = destination / "go_http"
|
||||
source = FIXTURES / "http/peer.go"
|
||||
with (destination / "go.build.log").open("w") as log:
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", binary, source],
|
||||
stdout=log, stderr=log, check=True, timeout=120)
|
||||
manifest["binaries"]["go_http"] = fingerprint(binary)
|
||||
manifest["go"] = {"compiler": subprocess.check_output(["go", "version"], text=True).strip(),
|
||||
"source": fingerprint(source), "profile": "go build -trimpath -ldflags=-s -w"}
|
||||
(destination / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(f"artifacts: {destination}", flush=True)
|
||||
@@ -0,0 +1,675 @@
|
||||
"""验收 CMS 的真实身份、租户、发布链及专属内存预算。"""
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import deepcopy
|
||||
import http.client
|
||||
from http.cookies import SimpleCookie
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Event
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from build import ROOT, fingerprint
|
||||
from process import Budget, Process, observe, summarize, resource_trend
|
||||
from settings import write_private_json
|
||||
|
||||
FORMATS = ("dever",)
|
||||
ARTICLE_PATH = "/news/article/admin/manage"
|
||||
ARTICLE_LIST_PATH = f"{ARTICLE_PATH}/list?size=100"
|
||||
REVISION_PATH = "/content/revision/admin/manage/create"
|
||||
SESSION_PATH = "/user/account/{site}/session/login"
|
||||
PUBLICATIONS_PATH = "/news/article/front/browse/list?size=100"
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def execute(command, directory, log_name, *, envelope=False, timeout=180):
|
||||
child = subprocess.Popen([str(value) for value in command], cwd=directory,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
||||
failure = None
|
||||
try:
|
||||
stdout, stderr = child.communicate(timeout=timeout)
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
try:
|
||||
signal_group(child.pid, signal.SIGTERM)
|
||||
stdout, stderr = child.communicate(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
signal_group(child.pid, signal.SIGKILL)
|
||||
stdout, stderr = child.communicate(timeout=3)
|
||||
(directory / f"{log_name}.stdout.log").write_bytes(stdout)
|
||||
(directory / f"{log_name}.stderr.log").write_bytes(stderr)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
require(child.returncode == 0, f"{log_name} exited {child.returncode}: {directory}")
|
||||
if envelope:
|
||||
return decode_response(stdout, 200)
|
||||
|
||||
|
||||
def signal_group(pid, signal_number):
|
||||
try:
|
||||
os.killpg(pid, signal_number)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def decode_response(body, status):
|
||||
document = json.loads(body)
|
||||
require(isinstance(document, dict) and set(document) == {"code", "message", "data"},
|
||||
f"unexpected response envelope (HTTP {status})")
|
||||
require(status == 200 and document["code"] == 0,
|
||||
f"request failed: HTTP {status}, code={document['code']}, {document['message']}")
|
||||
return document["data"]
|
||||
|
||||
|
||||
def copy_cms_project(source, destination):
|
||||
destination.mkdir()
|
||||
for name in ("config", "module", "test"):
|
||||
shutil.copytree(source / name, destination / name)
|
||||
|
||||
|
||||
def build_cases(compiler, output):
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
cases = {}
|
||||
for source_format in FORMATS:
|
||||
source = ROOT / "examples/cms" / source_format
|
||||
project = output / source_format
|
||||
copy_cms_project(source, project)
|
||||
started = time.monotonic()
|
||||
execute([compiler, "check", project], project, "check")
|
||||
execute([compiler, "build", project, "--output", project / "cms-app"], project, "build", timeout=900)
|
||||
executable = project / "cms-app"
|
||||
cases[source_format] = {
|
||||
"executable": str(executable), "config": str(project / "config/setting.json"),
|
||||
"fingerprint": fingerprint(executable),
|
||||
"build_seconds": time.monotonic() - started,
|
||||
}
|
||||
manifest = {"format": "dever-cms-publish-v1", "cases": cases}
|
||||
write_private_json(output / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def available_port():
|
||||
with socket.socket() as reservation:
|
||||
reservation.bind(("127.0.0.1", 0))
|
||||
return reservation.getsockname()[1]
|
||||
|
||||
|
||||
def isolated_settings(settings, port):
|
||||
settings = deepcopy(settings)
|
||||
settings["runtime"]["mode"] = "api"
|
||||
settings["http"] = {"host": "127.0.0.1", "port": port}
|
||||
settings["auth"]["providers"]["session"]["jwtSecret"] = secrets.token_hex(32)
|
||||
for site in settings["sites"].values():
|
||||
site["origin"] = f"http://127.0.0.1:{port}"
|
||||
site["hosts"] = ["127.0.0.1"]
|
||||
database = settings["database"]["default"]
|
||||
if database["type"] == "sqlite":
|
||||
database["path"] = "data/db/platform.db"
|
||||
database["tenant_directory"] = "data/tenants"
|
||||
elif database["type"] == "postgres":
|
||||
require(bool(database.get("url")) and bool(database.get("tenant_database_prefix")),
|
||||
"PostgreSQL CMS acceptance requires an explicit isolated control database and tenant prefix")
|
||||
else:
|
||||
raise RuntimeError("CMS acceptance supports only SQLite and PostgreSQL")
|
||||
settings["log"]["level"] = "error"
|
||||
return settings
|
||||
|
||||
|
||||
class CmsClient:
|
||||
def __init__(self, port):
|
||||
self.connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
self.origin = f"http://127.0.0.1:{port}"
|
||||
self.cookies = {}
|
||||
self.timings = {}
|
||||
self.record_timings = True
|
||||
|
||||
def request(self, method, path, payload=None, *, site=None, expected=200):
|
||||
headers = {"Content-Type": "application/json", "Origin": self.origin}
|
||||
if site in self.cookies:
|
||||
headers["Cookie"] = self.cookies[site]
|
||||
body = None
|
||||
if payload is not None:
|
||||
if method in ("GET", "DELETE"):
|
||||
query = {name: value if isinstance(value, str) else json.dumps(value)
|
||||
for name, value in payload.items()}
|
||||
path += ("&" if "?" in path else "?") + urlencode(query)
|
||||
else:
|
||||
body = json.dumps(payload)
|
||||
started = time.monotonic()
|
||||
self.connection.request(method, path, body, headers)
|
||||
response = self.connection.getresponse()
|
||||
body = response.read()
|
||||
route = path.split("?")[0]
|
||||
label = f"{method} {route}" + (f" expected {expected}" if expected != 200 else "")
|
||||
if self.record_timings:
|
||||
self.timings.setdefault(label, []).append(
|
||||
(time.monotonic() - started) * 1000)
|
||||
require(response.status == expected,
|
||||
f"{method} {path}: HTTP {response.status}, expected {expected}: {body!r}")
|
||||
if expected != 200:
|
||||
document = json.loads(body)
|
||||
require(document.get("code") != 0, "rejected request returned success")
|
||||
return None
|
||||
data = decode_response(body, response.status)
|
||||
if path.endswith("/login"):
|
||||
cookie = SimpleCookie()
|
||||
for name, value in response.getheaders():
|
||||
if name.lower() == "set-cookie":
|
||||
cookie.load(value)
|
||||
require(bool(cookie), "login did not return a session cookie")
|
||||
self.cookies[site] = "; ".join(f"{name}={value.value}" for name, value in cookie.items())
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.connection.close()
|
||||
|
||||
|
||||
def wait_ready(process, client):
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
process.sample()
|
||||
require(process.child.poll() is None, f"CMS exited before readiness: {process.directory}")
|
||||
try:
|
||||
client.request("GET", ARTICLE_LIST_PATH, expected=401)
|
||||
return time.monotonic() - process.started
|
||||
except (ConnectionError, http.client.HTTPException, OSError):
|
||||
client.close()
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError(f"CMS readiness timeout: {process.directory}")
|
||||
|
||||
|
||||
def login_client(client, identity, password, tenant_key):
|
||||
for site in ("admin", "front"):
|
||||
data = client.request("POST", SESSION_PATH.format(site=site), {
|
||||
"credential_id": identity["credential_id"], "password": password, "tenant": tenant_key,
|
||||
}, site=site)
|
||||
require(data["account"]["id"] == identity["user_id"]
|
||||
and data["tenant_id"] == identity["tenant_id"] and data["site"] == site,
|
||||
"login changed the trusted identity or tenant/site")
|
||||
|
||||
|
||||
def publish_workload(client, process, identity, password, articles, *,
|
||||
tenant_key="bench", title_prefix="Article"):
|
||||
login_client(client, identity, password, tenant_key)
|
||||
titles = []
|
||||
ids = []
|
||||
for index in range(articles):
|
||||
slug, title = f"bench-{index}", f"{title_prefix} {index}"
|
||||
created = client.request("POST", f"{ARTICLE_PATH}/create", {
|
||||
"slug": f" {slug.upper()} ", "title": f" {title} ", "body": "Draft body",
|
||||
}, site="admin")
|
||||
require(created["slug"] == slug and created["title"] == title,
|
||||
"article creation did not normalize input")
|
||||
require("author_id" not in created and "status" not in created,
|
||||
"article creation leaked private owner or publication state")
|
||||
changed = client.request("POST", REVISION_PATH, {
|
||||
"selected_article_id": created["id"], "expected_version": created["version"],
|
||||
"title": f" {title} published ", "body": "Published body",
|
||||
}, site="admin")
|
||||
expected_title = f"{title} published"
|
||||
require(changed["title"] == expected_title
|
||||
and changed["article_id"] == created["id"]
|
||||
and changed["version"] == created["version"] + 1,
|
||||
"revision did not preserve normalized content and advance the article version")
|
||||
published = client.request("POST", f"{ARTICLE_PATH}/publish", {
|
||||
"article_id": created["id"],
|
||||
}, site="admin")
|
||||
require(published["slug"] == slug and published["title"] == expected_title
|
||||
and published["body"] == "Published body",
|
||||
"publication did not reflect the stored article")
|
||||
titles.append(expected_title)
|
||||
ids.append(created["id"])
|
||||
process.sample()
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
require(listing["total"] == articles
|
||||
and sorted(value["title"] for value in listing["items"]) == sorted(titles),
|
||||
"front query did not return every published article exactly once")
|
||||
client.request("POST", f"{ARTICLE_PATH}/publish", {"article_id": ids[0]},
|
||||
site="admin", expected=409)
|
||||
return {"articles": articles, "titles": sorted(titles), "duplicate_publish_status": 409}
|
||||
|
||||
|
||||
def provision_identity(executable, directory, tenant_key):
|
||||
password = secrets.token_urlsafe(24)
|
||||
identity = execute([executable, "user.account.bootstrap", json.dumps({
|
||||
"tenant_key": tenant_key, "tenant_name": f"Benchmark {tenant_key}",
|
||||
"email": f"{tenant_key}@example.invalid", "display_name": "Benchmark",
|
||||
"password": password,
|
||||
})], directory, f"bootstrap-{tenant_key}", envelope=True)
|
||||
execute([executable, "--tenant", identity["tenant_id"], "dever.tenant.migrate", "null"],
|
||||
directory, f"migrate-{tenant_key}")
|
||||
for site in ("admin", "front"):
|
||||
execute([executable, "--tenant", identity["tenant_id"], "dever.auth.owner",
|
||||
json.dumps({"site": site, "user_id": identity["user_id"]})],
|
||||
directory, f"owner-{tenant_key}-{site}")
|
||||
return identity, password
|
||||
|
||||
|
||||
def latency_summary(timings):
|
||||
result = {}
|
||||
for action, samples in timings.items():
|
||||
values = sorted(samples)
|
||||
result[action] = {"samples": len(values), "p50_ms": values[len(values) // 2],
|
||||
"max_ms": values[-1]}
|
||||
return result
|
||||
|
||||
|
||||
def catalog_key(catalog, component, domain, site, action, method):
|
||||
matches = [entry["key"] for entry in catalog
|
||||
if (entry["component"], entry["domain"], entry["site"],
|
||||
entry["action"], entry["method"]) == (component, domain, site, action, method)]
|
||||
require(len(matches) == 1 and bool(matches[0]),
|
||||
f"permission catalog must uniquely identify {component}.{domain}.{site}.{action}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def run_postgres_rbac(executable, directory, port, owner, identity):
|
||||
catalog = owner.request("GET", "/user/authorization/admin/manage/permissions", site="admin")
|
||||
front_catalog = owner.request("GET", "/user/authorization/front/manage/permissions", site="front")
|
||||
permissions = {
|
||||
"admin_read": catalog_key(catalog, "news", "article", "admin", "list", "GET"),
|
||||
"admin_permissions": catalog_key(catalog, "user", "authorization", "admin", "permissions", "GET"),
|
||||
"front_list": catalog_key(front_catalog, "news", "article", "front", "list", "GET"),
|
||||
}
|
||||
require(len(set(permissions.values())) == 3, "RBAC acceptance reused a permission key")
|
||||
password = secrets.token_urlsafe(24)
|
||||
member = execute([executable, "user.account.fixture_member", json.dumps({
|
||||
"selected_tenant_id": identity["tenant_id"], "email": f"member-{secrets.token_hex(8)}@example.invalid",
|
||||
"display_name": "Acceptance member", "password": password,
|
||||
})], directory, "rbac-member", envelope=True)
|
||||
require(isinstance(member["user_id"], int) and not isinstance(member["user_id"], bool)
|
||||
and member["user_id"] > 0 and member["user_id"] != identity["user_id"]
|
||||
and member["tenant_id"] == identity["tenant_id"],
|
||||
"RBAC acceptance did not create a distinct member in the owner's tenant")
|
||||
probes = (("admin", ARTICLE_LIST_PATH),
|
||||
("admin", "/user/authorization/admin/manage/permissions"),
|
||||
("front", PUBLICATIONS_PATH))
|
||||
client = CmsClient(port)
|
||||
try:
|
||||
login_client(client, member, password, "tenant_a")
|
||||
for site, path in probes:
|
||||
client.request("GET", path, site=site, expected=403)
|
||||
roles = (("admin", "fixture-reader", "admin_read"),
|
||||
("admin", "fixture-catalog", "admin_permissions"),
|
||||
("front", "fixture-reader", "front_list"))
|
||||
for site, role_id, permission in roles:
|
||||
base = f"/user/authorization/{site}/manage"
|
||||
owner.request("POST", f"{base}/save", {
|
||||
"id": role_id, "name": role_id, "all_permissions": False,
|
||||
"permission_keys": [permissions[permission]],
|
||||
}, site=site)
|
||||
owner.request("POST", f"{base}/grant", {
|
||||
"user_id": member["user_id"], "role_id": role_id,
|
||||
}, site=site)
|
||||
for site, path in probes:
|
||||
client.request("GET", path, site=site)
|
||||
owner.request("DELETE", "/user/authorization/admin/manage/revoke", {
|
||||
"user_id": member["user_id"], "role_id": "fixture-reader",
|
||||
}, site="admin")
|
||||
client.request("GET", ARTICLE_LIST_PATH, site="admin", expected=403)
|
||||
client.request("GET", probes[1][1], site="admin")
|
||||
client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
for site, role_id, path in (("admin", "fixture-catalog", probes[1][1]),
|
||||
("front", "fixture-reader", PUBLICATIONS_PATH)):
|
||||
owner.request("DELETE", f"/user/authorization/{site}/manage/revoke", {
|
||||
"user_id": member["user_id"], "role_id": role_id,
|
||||
}, site=site)
|
||||
client.request("GET", path, site=site, expected=403)
|
||||
finally:
|
||||
client.close()
|
||||
return {"member_user_id": member["user_id"], "permissions": permissions,
|
||||
"before_grant_denied": True, "both_admin_roles_allowed": True,
|
||||
"revoked_role_denied": True, "second_role_still_allowed": True,
|
||||
"front_same_named_role_still_allowed": True}
|
||||
|
||||
|
||||
class ReadLatency:
|
||||
"""固定内存的对数直方图;分位数报告桶上界,最大相邻桶比例约1.011。"""
|
||||
|
||||
def __init__(self):
|
||||
self.buckets = [0] * 2048
|
||||
self.count = 0
|
||||
|
||||
def add(self, milliseconds):
|
||||
index = math.ceil(math.log2(1 + milliseconds) * 64)
|
||||
require(0 <= index < len(self.buckets), "CMS latency exceeds histogram bounds")
|
||||
self.buckets[index] += 1
|
||||
self.count += 1
|
||||
|
||||
def merge(self, other):
|
||||
self.buckets = [left + right for left, right in zip(self.buckets, other.buckets)]
|
||||
self.count += other.count
|
||||
|
||||
def percentile(self, fraction):
|
||||
threshold = math.ceil(self.count * fraction)
|
||||
count = 0
|
||||
for index, samples in enumerate(self.buckets):
|
||||
count += samples
|
||||
if count >= threshold:
|
||||
return 2 ** (index / 64) - 1
|
||||
raise ValueError("empty latency histogram")
|
||||
|
||||
|
||||
def concurrent_reads(port, cookies, expected, process, *, concurrency, seconds, interval):
|
||||
"""Each closed-loop client keeps its own connection and verifies every reply."""
|
||||
stop = Event()
|
||||
started = time.monotonic()
|
||||
deadline = started + seconds
|
||||
first = len(process.samples)
|
||||
|
||||
def read_publications():
|
||||
client = CmsClient(port)
|
||||
client.cookies = cookies.copy()
|
||||
client.record_timings = False
|
||||
latency = ReadLatency()
|
||||
try:
|
||||
while not stop.is_set() and time.monotonic() < deadline:
|
||||
request_started = time.monotonic()
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
require(listing == expected, "concurrent CMS read changed publication results")
|
||||
latency.add((time.monotonic() - request_started) * 1000)
|
||||
return latency
|
||||
except BaseException:
|
||||
stop.set()
|
||||
raise
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as clients:
|
||||
futures = [clients.submit(read_publications) for _ in range(concurrency)]
|
||||
try:
|
||||
while any(not future.done() for future in futures):
|
||||
process.check()
|
||||
observe([process], interval, interval)
|
||||
elapsed = time.monotonic() - started
|
||||
latency = ReadLatency()
|
||||
for future in futures:
|
||||
latency.merge(future.result())
|
||||
finally:
|
||||
stop.set()
|
||||
require(latency.count > 0, "concurrent CMS read completed without a successful request")
|
||||
require(elapsed >= seconds, "concurrent CMS read ended before the measurement window")
|
||||
process.sample()
|
||||
return {"mode": "closed_loop_authenticated_read", "concurrency": concurrency,
|
||||
"requested_seconds": seconds, "elapsed_seconds": elapsed,
|
||||
"succeeded": latency.count, "errors": 0, "result_checked_on_every_request": True,
|
||||
"actual_success_qps": latency.count / elapsed,
|
||||
"latency_measurement": "request and reply validation; logarithmic bucket upper bound, 64 buckets per octave",
|
||||
"request_latency_ms": {name: latency.percentile(fraction)
|
||||
for name, fraction in (("p50", .5), ("p95", .95), ("p99", .99))},
|
||||
"resources": summarize(process.samples[first:]),
|
||||
"trend": resource_trend(process.samples[first:]) if len(process.samples[first:]) >= 4 else None}
|
||||
|
||||
|
||||
def sqlite_pool_descriptor_limits(settings, directory):
|
||||
database = settings["database"]["default"]
|
||||
require(database["type"] == "sqlite", "CMS read benchmark requires isolated SQLite")
|
||||
databases = [directory / database["path"]]
|
||||
databases.extend((directory / database["tenant_directory"]).glob("*.db"))
|
||||
# 每个连接可持有主文件与WAL;同进程各连接共用一个SHM句柄。
|
||||
return {str(path.resolve()) + suffix: limit for path in databases
|
||||
for suffix, limit in (("", database["max_connections"]),
|
||||
("-wal", database["max_connections"]), ("-shm", 1))}
|
||||
|
||||
|
||||
def validate_read_descriptor_recovery(load):
|
||||
before = Counter(load["descriptors_before"].values())
|
||||
after = Counter(load["descriptors_after"].values())
|
||||
for path, limit in load["pooled_descriptor_limits"].items():
|
||||
require(after[path] <= limit, f"CMS SQLite descriptor count exceeds pool limit: {path}")
|
||||
before.pop(path, None)
|
||||
after.pop(path, None)
|
||||
require(before == after,
|
||||
f"CMS read descriptors did not recover: added={dict(after - before)}, missing={dict(before - after)}")
|
||||
|
||||
|
||||
def run_case(case, directory, *, articles, cgroup_parent, memory_mib,
|
||||
idle_seconds=0.2, interval=0.05, server_cpu=None, cpu_quota=None,
|
||||
concurrency=0, read_seconds=5):
|
||||
executable = Path(case["executable"])
|
||||
require(fingerprint(executable) == case["fingerprint"], "CMS artifact fingerprint changed")
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
target = directory / "cms-app"
|
||||
shutil.copy2(executable, target)
|
||||
(directory / "config").mkdir()
|
||||
(directory / "data").mkdir()
|
||||
port = available_port()
|
||||
settings = isolated_settings(json.loads(Path(case["config"]).read_text()), port)
|
||||
write_private_json(directory / "config/setting.json", settings)
|
||||
identity, password = provision_identity(target, directory, "bench")
|
||||
budget = Budget(cgroup_parent, memory_mib, cpu_quota)
|
||||
client = CmsClient(port)
|
||||
failure = None
|
||||
result = {}
|
||||
try:
|
||||
with Process([target], directory / "server", budget=budget,
|
||||
cpus=(server_cpu,) if server_cpu is not None else None) as process:
|
||||
result["raw"] = process.samples
|
||||
ready_seconds = wait_ready(process, client)
|
||||
started = time.monotonic()
|
||||
contract = publish_workload(client, process, identity, password, articles)
|
||||
elapsed = time.monotonic() - started
|
||||
result.update(contract=contract, ready_seconds=ready_seconds,
|
||||
workload_seconds=elapsed, published_articles_per_second=articles / elapsed,
|
||||
latency=latency_summary(client.timings))
|
||||
if concurrency:
|
||||
expected = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
# 控制连接不参与长读负载,先关闭,避免其空闲超时改变基线。
|
||||
client.close()
|
||||
observe([process], 0.3, interval)
|
||||
descriptors_before = process.descriptors()
|
||||
pooled_limits = sqlite_pool_descriptor_limits(settings, directory)
|
||||
read_load = {"descriptors_before": descriptors_before,
|
||||
"pooled_descriptor_limits": pooled_limits}
|
||||
result["concurrent_reads"] = read_load
|
||||
read_load.update(concurrent_reads(port, client.cookies, expected, process,
|
||||
concurrency=concurrency, seconds=read_seconds, interval=interval))
|
||||
observe([process], 0.3, interval)
|
||||
descriptors_after = process.descriptors()
|
||||
read_load.update(descriptors_after=descriptors_after,
|
||||
fd_delta_after_disconnect=len(descriptors_after) - len(descriptors_before))
|
||||
validate_read_descriptor_recovery(read_load)
|
||||
observe([process], idle_seconds, interval)
|
||||
process.sample()
|
||||
process.check()
|
||||
require(process.child.poll() is None, "CMS exited during the publish workload")
|
||||
resources = summarize(process.samples)
|
||||
result.update(resources=resources, budget_before_stop=budget.sample())
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
finally:
|
||||
client.close()
|
||||
try:
|
||||
budget.close()
|
||||
except BaseException as error:
|
||||
if failure is None:
|
||||
failure = error
|
||||
else:
|
||||
failure.add_note(f"cannot close owned CMS budget: {error}")
|
||||
result["cgroup"] = budget.final
|
||||
if failure is None and budget.final is not None and (
|
||||
budget.final["events"]["oom"] or budget.final["events"]["oom_kill"]):
|
||||
failure = RuntimeError("CMS exceeded its memory budget")
|
||||
result["status"] = "failed" if failure else "passed"
|
||||
if failure is not None:
|
||||
result["error"] = str(failure)
|
||||
write_private_json(directory / "report.json", result)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
return result
|
||||
|
||||
|
||||
def run_postgres_case(project, output, compiler, *, articles, rbac_fixture=False):
|
||||
# 构建、临时密钥和端口只写测试副本,调用者的项目配置始终保持不变。
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-postgres-") as temporary:
|
||||
staged = Path(temporary) / "project"
|
||||
copy_cms_project(project, staged)
|
||||
return run_staged_postgres_case(staged, output, compiler, articles=articles,
|
||||
rbac_fixture=rbac_fixture)
|
||||
|
||||
|
||||
def run_staged_postgres_case(project, output, compiler, *, articles, rbac_fixture):
|
||||
require(1 <= articles <= 100, "PostgreSQL CMS acceptance requires 1..100 articles")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"status": "running", "source": "dever", "tenants": {},
|
||||
"scope": "PostgreSQL HTTP owner flow and physical database tenant isolation; not non-owner RBAC revocation"}
|
||||
if rbac_fixture:
|
||||
report["scope"] = "PostgreSQL HTTP owner flow, physical database tenant isolation and non-owner multi-role/site grant-revoke"
|
||||
clients = {}
|
||||
try:
|
||||
setting_path = project / "config/setting.json"
|
||||
initial = json.loads(setting_path.read_text())
|
||||
require(initial["database"]["default"]["type"] == "postgres",
|
||||
"postgres-case requires a PostgreSQL project configuration")
|
||||
port = available_port()
|
||||
write_private_json(setting_path, isolated_settings(initial, port), replace=True)
|
||||
execute([compiler, "check", project], output, "check")
|
||||
executable = project / "cms-app"
|
||||
execute([compiler, "build", project, "--output", executable], output, "build", timeout=900)
|
||||
report["binary"] = fingerprint(executable)
|
||||
identities = {key: provision_identity(executable, output, key)
|
||||
for key in ("tenant_a", "tenant_b")}
|
||||
require(identities["tenant_a"][0]["tenant_id"] != identities["tenant_b"][0]["tenant_id"],
|
||||
"PostgreSQL acceptance reused a tenant identity")
|
||||
clients = {key: CmsClient(port) for key in identities}
|
||||
with Process([executable], output / "server") as process:
|
||||
report["ready_seconds"] = wait_ready(process, clients["tenant_a"])
|
||||
for key, (identity, password) in identities.items():
|
||||
contract = publish_workload(clients[key], process, identity, password, articles,
|
||||
tenant_key=key, title_prefix=key)
|
||||
permissions = clients[key].request(
|
||||
"GET", "/user/authorization/admin/manage/permissions", site="admin")
|
||||
require(bool(permissions), "CMS permission catalog is empty")
|
||||
report["tenants"][key] = {"tenant_id": identity["tenant_id"],
|
||||
"user_id": identity["user_id"], "contract": contract}
|
||||
for key, client in clients.items():
|
||||
listing = client.request("GET", PUBLICATIONS_PATH, site="front")
|
||||
contract = report["tenants"][key]["contract"]
|
||||
require(listing["total"] == articles
|
||||
and sorted(value["title"] for value in listing["items"]) == contract["titles"],
|
||||
"PostgreSQL tenant query leaked another tenant's articles")
|
||||
require(sorted(value["slug"] for value in listing["items"])
|
||||
== sorted(f"bench-{index}" for index in range(articles)),
|
||||
"PostgreSQL tenants did not preserve the same slug namespace")
|
||||
report["tenants"][key]["slugs"] = sorted(value["slug"] for value in listing["items"])
|
||||
identity, password = identities["tenant_a"]
|
||||
clients["tenant_a"].request("POST", SESSION_PATH.format(site="admin"), {
|
||||
"credential_id": identity["credential_id"], "password": password, "tenant": "tenant_b",
|
||||
}, site="admin", expected=401)
|
||||
front_cookie = clients["tenant_a"].cookies["front"]
|
||||
clients["tenant_a"].cookies["front"] = clients["tenant_a"].cookies["admin"]
|
||||
clients["tenant_a"].request("GET", PUBLICATIONS_PATH, site="front", expected=401)
|
||||
clients["tenant_a"].cookies["front"] = front_cookie
|
||||
if rbac_fixture:
|
||||
report["rbac"] = run_postgres_rbac(executable, output, port,
|
||||
clients["tenant_a"], identity)
|
||||
process.sample()
|
||||
process.check()
|
||||
require(process.child.poll() is None, "PostgreSQL CMS exited during tenant acceptance")
|
||||
report["resources"] = summarize(process.samples)
|
||||
report["cross_site_cookie_rejected"] = True
|
||||
report["cross_tenant_login_rejected"] = True
|
||||
report["status"] = "passed"
|
||||
except BaseException as error:
|
||||
report["status"] = "failed"
|
||||
report["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for client in clients.values():
|
||||
client.close()
|
||||
write_private_json(output / "report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def run_cases(manifest, output, *, articles, cgroup_parent, memory_mib,
|
||||
concurrency=0, read_seconds=5):
|
||||
require(manifest["format"] == "dever-cms-publish-v1", "unexpected CMS manifest format")
|
||||
require(1 <= articles <= 100, "articles must be 1..100")
|
||||
require(0 <= concurrency <= 64, "read concurrency must be 0..64")
|
||||
require(math.isfinite(read_seconds) and 0.1 <= read_seconds <= 3600,
|
||||
"read seconds must be finite and within 0.1..3600")
|
||||
require((memory_mib is None) == (cgroup_parent is None),
|
||||
"memory budget and cgroup parent must be provided together")
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
report = {"format": manifest["format"], "memory_mib": memory_mib,
|
||||
"scope": "owned loopback HTTP/1, sequential publish, isolated SQLite; not a saturation result",
|
||||
"cases": {}}
|
||||
if concurrency:
|
||||
report["scope"] = "owned loopback HTTP/1, sequential publish and closed-loop authenticated reads, isolated SQLite"
|
||||
try:
|
||||
for source_format in FORMATS:
|
||||
report["cases"][source_format] = run_case(manifest["cases"][source_format],
|
||||
output / source_format, articles=articles,
|
||||
cgroup_parent=cgroup_parent, memory_mib=memory_mib,
|
||||
concurrency=concurrency, read_seconds=read_seconds)
|
||||
report["status"] = "passed"
|
||||
except BaseException as error:
|
||||
report["status"] = "failed"
|
||||
report["error"] = str(error)
|
||||
raise
|
||||
finally:
|
||||
for source_format in FORMATS:
|
||||
case_report = output / source_format / "report.json"
|
||||
if source_format not in report["cases"] and case_report.is_file():
|
||||
report["cases"][source_format] = json.loads(case_report.read_text())
|
||||
write_private_json(output / "report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
actions = parser.add_subparsers(dest="action", required=True)
|
||||
build = actions.add_parser("build")
|
||||
build.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
|
||||
build.add_argument("--output", type=Path, required=True)
|
||||
run = actions.add_parser("run")
|
||||
run.add_argument("--manifest", type=Path, required=True)
|
||||
run.add_argument("--output", type=Path, required=True)
|
||||
run.add_argument("--articles", type=int, default=16)
|
||||
run.add_argument("--cgroup-parent", type=Path)
|
||||
run.add_argument("--memory-mib", type=int, choices=(64, 128))
|
||||
run.add_argument("--concurrency", type=int, default=0,
|
||||
help="0 disables load; otherwise 1..64 independent authenticated read clients")
|
||||
run.add_argument("--read-seconds", type=float, default=5)
|
||||
postgres = actions.add_parser("postgres-case")
|
||||
postgres.add_argument("--project", type=Path, required=True)
|
||||
postgres.add_argument("--output", type=Path, required=True)
|
||||
postgres.add_argument("--compiler", type=Path, default=ROOT / "target/debug/dever")
|
||||
postgres.add_argument("--articles", type=int, default=2)
|
||||
postgres.add_argument("--rbac-fixture", action="store_true",
|
||||
help="run test-only member CMD provided by the Rust PostgreSQL fixture")
|
||||
args = parser.parse_args()
|
||||
if args.action == "build":
|
||||
result = build_cases(args.compiler.resolve(strict=True), args.output.resolve())
|
||||
elif args.action == "postgres-case":
|
||||
report = run_postgres_case(args.project.resolve(strict=True), args.output.resolve(),
|
||||
args.compiler.resolve(strict=True), articles=args.articles,
|
||||
rbac_fixture=args.rbac_fixture)
|
||||
result = {"status": report["status"], "report": str(args.output.resolve() / "report.json")}
|
||||
else:
|
||||
result = run_cases(json.loads(args.manifest.read_text()), args.output.resolve(),
|
||||
articles=args.articles, cgroup_parent=args.cgroup_parent,
|
||||
memory_mib=args.memory_mib, concurrency=args.concurrency,
|
||||
read_seconds=args.read_seconds)
|
||||
result = {"status": result["status"], "memory_mib": result["memory_mib"],
|
||||
"report": str(args.output / "report.json")}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""同 Pod 两个隔离容器:server 执行固定矩阵,client 只负责施压。
|
||||
|
||||
构建产物与本文件、process.py、settings.py 放入 /work;两个角色通过 emptyDir
|
||||
交接有限测试命令。测量采用闭环长连接,延迟含收发和正文验证,不含建连时间。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from process import Process, summarize
|
||||
|
||||
ROOT = Path("/work")
|
||||
|
||||
|
||||
def write(path, value):
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def counters():
|
||||
return {name: (Path("/sys/fs/cgroup") / name).read_text()
|
||||
for name in ("cpu.max", "cpu.stat", "memory.max", "memory.events")}
|
||||
|
||||
|
||||
def client():
|
||||
request = ROOT / "request.json"
|
||||
while not (ROOT / "finished").exists():
|
||||
if not request.exists():
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
item = json.loads(request.read_text())
|
||||
request.unlink()
|
||||
directory = ROOT / item["name"]
|
||||
before = counters()
|
||||
with Process([ROOT / "bin/rust-http", "load", item["port"], item["path"],
|
||||
item["concurrency"], item["seconds"]], directory, cpus={2, 3, 4, 5}) as process:
|
||||
deadline = time.monotonic() + item["seconds"] + 30
|
||||
while process.child.poll() is None and time.monotonic() < deadline:
|
||||
process.sample()
|
||||
time.sleep(0.2)
|
||||
if process.child.poll() is None:
|
||||
raise TimeoutError(f"load client timed out: {item}")
|
||||
result = {"exit_code": process.child.returncode, "stdout": process.text(),
|
||||
"stderr": (directory / "stderr.log").read_text(),
|
||||
"resources": summarize(process.samples), "samples": process.samples,
|
||||
"cgroup_before": before, "cgroup_after": counters()}
|
||||
write(directory / "result.json", result)
|
||||
|
||||
|
||||
def load(process, name, port, path, concurrency, seconds):
|
||||
result_path = ROOT / name / "result.json"
|
||||
write(ROOT / "request.json", {"name": name, "port": port, "path": path,
|
||||
"concurrency": concurrency, "seconds": seconds})
|
||||
deadline = time.monotonic() + seconds + 40
|
||||
while not result_path.exists() and time.monotonic() < deadline:
|
||||
process.sample()
|
||||
time.sleep(0.2)
|
||||
result = json.loads(result_path.read_text())
|
||||
if result["exit_code"] == 0:
|
||||
result["measurement"] = json.loads(result["stdout"])
|
||||
return result
|
||||
|
||||
|
||||
def probe(port):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
||||
try:
|
||||
connection.request("GET", "/json")
|
||||
response = connection.getresponse()
|
||||
return response.status == 200 and response.read() == b'{"ok":true}'
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def command(language, capacity=2048):
|
||||
if language == "dever":
|
||||
return [ROOT / "bin" / ("dever-http" if capacity == 2048 else "dever-overload")]
|
||||
if language == "rust":
|
||||
return [ROOT / "bin/rust-http", "serve", capacity]
|
||||
binary = {"go": "go-http", "axum": "axum-http", "fiber": "fiber-http"}[language]
|
||||
return [ROOT / "bin" / binary, capacity]
|
||||
|
||||
|
||||
def measure(language, path, concurrency, seconds, name):
|
||||
with Process(command(language), ROOT / name / "server", cpus={0, 1}) as process:
|
||||
port = process.ready()
|
||||
if not probe(port):
|
||||
raise RuntimeError(f"smoke failed: {name}")
|
||||
warmup = load(process, name + "/warmup", port, path, concurrency, 2)
|
||||
process.samples.clear()
|
||||
before = counters()
|
||||
result = load(process, name + "/load", port, path, concurrency, seconds)
|
||||
after = counters()
|
||||
recovered = probe(port)
|
||||
record = {"name": name, "language": language, "path": path,
|
||||
"concurrency": concurrency, "seconds": seconds,
|
||||
"warmup": warmup, "client": result,
|
||||
"server": summarize(process.samples), "samples": process.samples,
|
||||
"server_alive": process.child.poll() is None, "recovery_probe": recovered,
|
||||
"stderr": (process.directory / "stderr.log").read_text(),
|
||||
"cgroup_before": before, "cgroup_after": after}
|
||||
write(ROOT / name / "result.json", record)
|
||||
with (ROOT / "results.jsonl").open("a") as output:
|
||||
output.write(json.dumps(record) + "\n")
|
||||
print(json.dumps({"name": name, "measurement": result.get("measurement"),
|
||||
"rss_mib": record["server"]["rss_high_water_bytes"] / 1048576,
|
||||
"server_cores": record["server"]["cpu_cores_sampled"],
|
||||
"client_cores": result["resources"]["cpu_cores_sampled"],
|
||||
"recovery": recovered}), flush=True)
|
||||
for phase in (warmup, result):
|
||||
measurement = phase.get("measurement", {})
|
||||
if phase["exit_code"] != 0 or measurement.get("errors") != 0 or not measurement.get("succeeded"):
|
||||
raise RuntimeError(f"load failed; retained result: {name}")
|
||||
if not record["server_alive"] or not recovered:
|
||||
raise RuntimeError(f"server did not recover: {name}")
|
||||
|
||||
|
||||
def overload(language):
|
||||
name = "overload/" + language
|
||||
with Process(command(language, 8192), ROOT / name / "server", cpus={0, 1}) as process:
|
||||
port = process.ready()
|
||||
connections = []
|
||||
successful = 0
|
||||
failed = 0
|
||||
started = time.monotonic()
|
||||
try:
|
||||
for _ in range(4500):
|
||||
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
||||
connections.append(connection)
|
||||
connection.request("GET", "/json")
|
||||
response = connection.getresponse()
|
||||
if response.status != 200 or response.read() != b'{"ok":true}':
|
||||
failed += 1
|
||||
break
|
||||
successful += 1
|
||||
except (OSError, http.client.HTTPException):
|
||||
failed += 1
|
||||
held_seconds = time.monotonic() - started
|
||||
process.sample()
|
||||
for connection in connections:
|
||||
connection.close()
|
||||
time.sleep(6)
|
||||
recovered = probe(port)
|
||||
record = {"language": language, "connections_target": 4500,
|
||||
"successful_connections": successful, "failed": failed,
|
||||
"open_seconds": held_seconds, "server_alive": process.child.poll() is None,
|
||||
"recovered_after_release": recovered, "stdout": process.text(),
|
||||
"stderr": (process.directory / "stderr.log").read_text(), "samples": process.samples}
|
||||
write(ROOT / name / "result.json", record)
|
||||
print(json.dumps(record), flush=True)
|
||||
|
||||
|
||||
def server(mode, suite):
|
||||
languages = ["dever", "axum", "fiber"] if suite == "frameworks" else ["dever", "rust", "go"]
|
||||
if mode == "smoke":
|
||||
for language in languages:
|
||||
for path in ("/json", "/echo"):
|
||||
measure(language, path, 4, 1, f"smoke/{language}/{path[1:]}")
|
||||
return
|
||||
manifest = {"suite": suite, "languages": languages,
|
||||
"protocol": "HTTP/1.1", "client": "closed loop; one outstanding request per connection",
|
||||
"server_cpus": [0, 1], "client_cpus": [2, 3, 4, 5],
|
||||
"replicates": 3, "seconds": 12, "warmup_seconds": 2,
|
||||
"server_capacity": 2048, "concurrency": [64, 256, 1024],
|
||||
"paths": {"/json": "GET; 11-byte JSON", "/echo": "POST; 65536-byte body returned unchanged"},
|
||||
"hashes": {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in (ROOT / "bin").iterdir()}}
|
||||
write(ROOT / "manifest.json", manifest)
|
||||
for replicate in range(3):
|
||||
for path in ("/json", "/echo"):
|
||||
for concurrency in (64, 256, 1024):
|
||||
for language in languages[replicate:] + languages[:replicate]:
|
||||
name = f"matrix/{replicate}/{path[1:]}/{concurrency}/{language}"
|
||||
measure(language, path, concurrency, 12, name)
|
||||
for language in languages:
|
||||
measure(language, "/json", 1024, 60, "sustained/" + language)
|
||||
if suite == "core":
|
||||
overload(language)
|
||||
(ROOT / "finished").touch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("role", choices=("server", "client"))
|
||||
parser.add_argument("--mode", choices=("smoke", "matrix"), default="matrix")
|
||||
parser.add_argument("--suite", choices=("core", "frameworks"), default="core")
|
||||
arguments = parser.parse_args()
|
||||
if arguments.role == "client":
|
||||
client()
|
||||
else:
|
||||
try:
|
||||
server(arguments.mode, arguments.suite)
|
||||
finally:
|
||||
(ROOT / "finished").touch()
|
||||
@@ -0,0 +1,13 @@
|
||||
type Identity {
|
||||
id: Text
|
||||
user_id: model.id?
|
||||
tenant_id: model.id?
|
||||
}
|
||||
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
global type Auth {
|
||||
label: Text(1, 64)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
public post echo = app.echo
|
||||
|
||||
public post raw = app.raw
|
||||
@@ -0,0 +1,19 @@
|
||||
type Row {
|
||||
id: Int
|
||||
text: Text
|
||||
weight: Float
|
||||
note: Text?
|
||||
}
|
||||
|
||||
type Payload {
|
||||
rows: List<Row>
|
||||
raw: Json
|
||||
}
|
||||
|
||||
echo(payload: Payload) (value: Payload) {
|
||||
value = payload
|
||||
}
|
||||
|
||||
raw(source: Json) (value: Json) {
|
||||
value = source
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use dever_backend_bridge::{Target, emit_object};
|
||||
use dever_core::source::SourceMap;
|
||||
use dever_runtime::config::Settings;
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
let (project, output, ir) = match arguments.as_slice() {
|
||||
[project, output] => (project, output, None),
|
||||
[project, output, ir] => (project, output, Some(ir)),
|
||||
_ => {
|
||||
return Err("usage: api-json-object <project> <new-object-file> [new-ir-file]".into());
|
||||
}
|
||||
};
|
||||
let project = Path::new(project);
|
||||
let sources =
|
||||
SourceMap::load(&project.join("module")).map_err(|errors| format!("{errors:?}"))?;
|
||||
let settings = Settings::load_project(project).map_err(|error| error.to_string())?;
|
||||
let program = dever_core::check_with_settings(&sources, &settings).map_err(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>()
|
||||
})?;
|
||||
let errors = program.application_errors()?;
|
||||
if !errors.is_empty() {
|
||||
return Err(errors
|
||||
.iter()
|
||||
.map(|error| error.render(&sources))
|
||||
.collect::<String>());
|
||||
}
|
||||
let module = dever_core::llvm::emit_executable(&program, &sources)?;
|
||||
if let Some(ir) = ir {
|
||||
let mut ir = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(ir)
|
||||
.map_err(|error| error.to_string())?;
|
||||
ir.write_all(module.as_bytes())
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
let object = emit_object(&module, Target::LinuxX86_64)?;
|
||||
let mut output = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(output)
|
||||
.map_err(|error| error.to_string())?;
|
||||
output.write_all(&object).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type row struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Weight float64 `json:"weight"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Rows []row `json:"rows"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func handle(writer http.ResponseWriter, request *http.Request) {
|
||||
typed := request.URL.Path == "/benchmark/echo/echo"
|
||||
if request.Method != http.MethodPost || (!typed && request.URL.Path != "/benchmark/echo/raw") {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
body := http.MaxBytesReader(writer, request.Body, 16*1024*1024)
|
||||
defer body.Close()
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var result any
|
||||
var err error
|
||||
if typed {
|
||||
var input struct {
|
||||
Payload payload `json:"payload"`
|
||||
}
|
||||
err = decoder.Decode(&input)
|
||||
result = input.Payload
|
||||
} else {
|
||||
var input struct {
|
||||
Source json.RawMessage `json:"source"`
|
||||
}
|
||||
err = decoder.Decode(&input)
|
||||
result = input.Source
|
||||
}
|
||||
var extra any
|
||||
if err != nil || decoder.Decode(&extra) != io.EOF {
|
||||
http.Error(writer, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(envelope{0, "ok", result})
|
||||
if err != nil {
|
||||
http.Error(writer, "invalid response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write(encoded)
|
||||
}
|
||||
|
||||
func run() error {
|
||||
runtime.GOMAXPROCS(1)
|
||||
content, err := os.ReadFile("config/setting.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var settings struct {
|
||||
HTTP struct {
|
||||
Port uint16 `json:"port"`
|
||||
} `json:"http"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", settings.HTTP.Port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server := &http.Server{Handler: http.HandlerFunc(handle), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second}
|
||||
stopping := make(chan os.Signal, 1)
|
||||
signal.Notify(stopping, syscall.SIGTERM)
|
||||
defer signal.Stop(stopping)
|
||||
closed := make(chan error, 1)
|
||||
go func() {
|
||||
<-stopping
|
||||
context, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
closed <- server.Shutdown(context)
|
||||
}()
|
||||
if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return <-closed
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::{
|
||||
Request, Response, StatusCode, body::Incoming, server::conn::http1, service::service_fn,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Row {
|
||||
id: i64,
|
||||
text: String,
|
||||
weight: f64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Payload {
|
||||
rows: Vec<Row>,
|
||||
raw: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct TypedRequest {
|
||||
payload: Payload,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawRequest {
|
||||
source: Box<RawValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Envelope<Value> {
|
||||
code: u8,
|
||||
message: &'static str,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
async fn handle(request: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let typed = request.uri().path() == "/benchmark/echo/echo";
|
||||
if request.method() != hyper::Method::POST
|
||||
|| !typed && request.uri().path() != "/benchmark/echo/raw"
|
||||
{
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Full::new(Bytes::new()))
|
||||
.unwrap());
|
||||
}
|
||||
let body = Limited::new(request.into_body(), 16 * 1024 * 1024)
|
||||
.collect()
|
||||
.await;
|
||||
let encoded = body.map_err(|error| error.to_string()).and_then(|body| {
|
||||
let bytes = body.to_bytes();
|
||||
if typed {
|
||||
serde_json::from_slice::<TypedRequest>(&bytes).and_then(|value| {
|
||||
serde_json::to_vec(&Envelope {
|
||||
code: 0,
|
||||
message: "ok",
|
||||
data: value.payload,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
serde_json::from_slice::<RawRequest>(&bytes).and_then(|value| {
|
||||
serde_json::to_vec(&Envelope {
|
||||
code: 0,
|
||||
message: "ok",
|
||||
data: value.source,
|
||||
})
|
||||
})
|
||||
}
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
let (status, body) = match encoded {
|
||||
Ok(body) => (StatusCode::OK, body),
|
||||
Err(_) => (StatusCode::BAD_REQUEST, Vec::new()),
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Full::new(Bytes::from(body)))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async {
|
||||
let settings: serde_json::Value = serde_json::from_slice(&std::fs::read("config/setting.json")?)?;
|
||||
let port = u16::try_from(settings["http"]["port"].as_u64().ok_or("missing port")?)?;
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).await?;
|
||||
let mut terminate = signal(SignalKind::terminate())?;
|
||||
let (shutdown, stopping) = watch::channel(false);
|
||||
let mut connections = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = terminate.recv() => break,
|
||||
Some(result) = connections.join_next(), if !connections.is_empty() => { result?; },
|
||||
accepted = listener.accept() => {
|
||||
let (stream, _) = accepted?;
|
||||
stream.set_nodelay(true)?;
|
||||
let mut stopping = stopping.clone();
|
||||
connections.spawn(async move {
|
||||
let connection = http1::Builder::new().serve_connection(TokioIo::new(stream), service_fn(handle));
|
||||
tokio::pin!(connection);
|
||||
tokio::select! {
|
||||
_ = &mut connection => {},
|
||||
_ = stopping.changed() => {
|
||||
connection.as_mut().graceful_shutdown();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), connection).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
shutdown.send(true)?;
|
||||
while let Some(result) = connections.join_next().await { result?; }
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use dever_runtime::api;
|
||||
use dever_runtime::application::Session;
|
||||
use dever_runtime::auth;
|
||||
use dever_runtime::config::RuntimeProfile;
|
||||
use dever_runtime::http::{Request, Response};
|
||||
use dever_runtime::wire::decode::{self, Fields, Reader};
|
||||
use dever_runtime::wire::{Encoded, Encoder};
|
||||
use dever_runtime::{lifecycle, task};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Row {
|
||||
id: i64,
|
||||
text: String,
|
||||
weight: f64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl Fields for Row {
|
||||
fn count(&self) -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
match name {
|
||||
"id" => Ok(0),
|
||||
"text" => Ok(1),
|
||||
"weight" => Ok(2),
|
||||
"note" => Ok(3),
|
||||
_ => Err("unknown wire record field".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, index: usize) -> bool {
|
||||
index != 3
|
||||
}
|
||||
|
||||
fn read(&mut self, index: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
match index {
|
||||
0 => self.id = reader.int()?,
|
||||
1 => self.text = reader.text()?.into_owned(),
|
||||
2 => self.weight = reader.float()?,
|
||||
3 => {
|
||||
reader.nullable(&mut |reader| {
|
||||
self.note = Some(reader.text()?.into_owned());
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
_ => return Err("invalid wire field index".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Payload {
|
||||
rows: Vec<Row>,
|
||||
raw: Option<Encoded>,
|
||||
}
|
||||
|
||||
impl Fields for Payload {
|
||||
fn count(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
match name {
|
||||
"rows" => Ok(0),
|
||||
"raw" => Ok(1),
|
||||
_ => Err("unknown wire record field".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, _: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn read(&mut self, index: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
match index {
|
||||
0 => reader.list(&mut |reader| {
|
||||
let mut row = Row::default();
|
||||
reader.record(&mut row)?;
|
||||
self.rows.push(row);
|
||||
Ok(())
|
||||
}),
|
||||
1 => {
|
||||
self.raw = Some(reader.json()?);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("invalid wire field index".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Payload {
|
||||
fn encode(self) -> Result<Encoded, String> {
|
||||
let mut encoder = Encoder::default();
|
||||
encoder.compiled_record(2, |encoder| {
|
||||
encoder.compiled_key("\"rows\":")?;
|
||||
encoder.begin_array()?;
|
||||
for row in self.rows {
|
||||
encoder.compiled_record(4, |encoder| {
|
||||
encoder.compiled_key("\"id\":")?;
|
||||
encoder.int(row.id)?;
|
||||
encoder.compiled_key(",\"text\":")?;
|
||||
encoder.text(&row.text)?;
|
||||
encoder.compiled_key(",\"weight\":")?;
|
||||
encoder.float(row.weight)?;
|
||||
encoder.compiled_key(",\"note\":")?;
|
||||
match row.note {
|
||||
Some(note) => encoder.text(¬e),
|
||||
None => encoder.null(),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
encoder.end()?;
|
||||
encoder.compiled_key(",\"raw\":")?;
|
||||
encoder.encoded(&self.raw.ok_or("missing required wire field")?)
|
||||
})?;
|
||||
encoder.finish()
|
||||
}
|
||||
}
|
||||
|
||||
enum Value {
|
||||
Typed(Payload),
|
||||
Raw(Encoded),
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
typed: bool,
|
||||
value: Option<Value>,
|
||||
}
|
||||
|
||||
impl Fields for Arguments {
|
||||
fn count(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn index(&self, name: &str) -> Result<usize, String> {
|
||||
if name == if self.typed { "payload" } else { "source" } {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err("unknown wire record field".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn required(&self, _: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn read(&mut self, _: usize, reader: &mut Reader<'_, '_>) -> Result<(), String> {
|
||||
self.value = Some(if self.typed {
|
||||
let mut payload = Payload::default();
|
||||
reader.record(&mut payload)?;
|
||||
Value::Typed(payload)
|
||||
} else {
|
||||
Value::Raw(reader.json()?)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(mut request: Request, typed: bool) -> Response {
|
||||
if let Err(error) = auth::require_components(&[], &[]).await {
|
||||
return api::auth_error(error);
|
||||
}
|
||||
if let Err(error) = api::upload::read_body(&mut request).await {
|
||||
return api::invalid_input(error);
|
||||
}
|
||||
let body = match api::Inputs::json_body(&request) {
|
||||
Ok(body) => body,
|
||||
Err(error) => return api::invalid_input(error),
|
||||
};
|
||||
let mut arguments = Arguments { typed, value: None };
|
||||
if let Err(error) = decode::read(body, &mut |reader| reader.record(&mut arguments)) {
|
||||
return api::invalid_input(api::InputError(error));
|
||||
}
|
||||
let encoded = match arguments.value {
|
||||
Some(Value::Typed(payload)) => payload.encode(),
|
||||
Some(Value::Raw(value)) => Ok(value),
|
||||
None => Err("missing required wire field".into()),
|
||||
};
|
||||
match encoded {
|
||||
Ok(value) => {
|
||||
if api::commit_response_metadata().is_err() {
|
||||
return api::internal_error();
|
||||
}
|
||||
api::success(value)
|
||||
}
|
||||
Err(_) => api::internal_error(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn route(request: Request) -> Result<Response, String> {
|
||||
let typed = match request.target.split('?').next().unwrap_or("") {
|
||||
"/benchmark/echo/echo" => true,
|
||||
"/benchmark/echo/raw" => false,
|
||||
_ => return Ok(api::not_found()),
|
||||
};
|
||||
if request.method != "POST" {
|
||||
return Ok(api::method_not_allowed(&["POST"]));
|
||||
}
|
||||
let prepared = match auth::prepare(&[], &request, true) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return Ok(api::auth_error(error)),
|
||||
};
|
||||
let identity = prepared.claims().map(|claims| auth::Identity {
|
||||
id: claims.subject.clone(),
|
||||
user_id: None,
|
||||
tenant_id: None,
|
||||
});
|
||||
Ok(
|
||||
match auth::scope(prepared, identity, call(request, typed)).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => api::auth_error(error),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let session = Session::load(
|
||||
RuntimeProfile {
|
||||
sqlite: true,
|
||||
postgres: false,
|
||||
},
|
||||
&[(None, "benchmark", false)],
|
||||
&[],
|
||||
)?;
|
||||
session.initialize_authorization(false)?;
|
||||
let cleanup = session.clone();
|
||||
task::run_entry_with_typed_cleanup(
|
||||
task::RuntimeConfig::default(),
|
||||
session.clone().scope(async move {
|
||||
session
|
||||
.databases()
|
||||
.prepare()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
lifecycle::configure(true, false)?;
|
||||
lifecycle::run(api::serve(route)).await
|
||||
}),
|
||||
|result| async move {
|
||||
let closed = cleanup.close().await;
|
||||
result.and(closed)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "runtime.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
extern long long dever_test_allocations(void);
|
||||
|
||||
static void text_clone(const void *source, void *target) {
|
||||
*(void **)target = dever_rt_v1_text_retain(*(void *const *)source);
|
||||
}
|
||||
|
||||
static void text_drop(void *value) {
|
||||
dever_rt_v1_text_release(*(void **)value);
|
||||
*(void **)value = NULL;
|
||||
}
|
||||
|
||||
static uint8_t text_equal(const void *left, const void *right) {
|
||||
return dever_rt_v1_text_equal(*(void *const *)left, *(void *const *)right);
|
||||
}
|
||||
|
||||
static const DeverRtType text_type = {
|
||||
sizeof(void *), _Alignof(void *), text_clone, text_drop, text_equal, NULL
|
||||
};
|
||||
|
||||
static uint32_t input_field(void *inputs, void *out, DeverRtBuffer *error) {
|
||||
const DeverRtWireName name = {(const uint8_t *)"source", 6};
|
||||
uint8_t present = 0;
|
||||
return dever_rt_v1_cmd_input_field(inputs, &name, 1, out, &present, error);
|
||||
}
|
||||
|
||||
static uint32_t decode(const void *reader, void *out, DeverRtBuffer *error) {
|
||||
return dever_rt_v1_wire_read_raw(reader, out, error);
|
||||
}
|
||||
|
||||
static uint32_t encode(const void *value, void *encoder, DeverRtBuffer *error) {
|
||||
return dever_rt_v1_wire_encoder_json(encoder, *(void *const *)value, error);
|
||||
}
|
||||
|
||||
static uint32_t operation(unsigned phase, void *body, void *source, void **out, DeverRtBuffer *error) {
|
||||
if (phase == 0) return dever_rt_v1_cmd_decode(body, &text_type, input_field, out, error);
|
||||
if (phase == 1) return dever_rt_v1_wire_decode(source, &text_type, decode, out, error);
|
||||
if (phase == 2) return dever_rt_v1_wire_encode(&source, &text_type, encode, out, error);
|
||||
return dever_rt_v1_json_from_text(source, out, error);
|
||||
}
|
||||
|
||||
int dever_test_run(void) {
|
||||
const char *setting = getenv("DEVER_WIRE_BENCH_ROWS");
|
||||
size_t rows = setting == NULL ? 128 : (size_t)strtoul(setting, NULL, 10);
|
||||
int wide = getenv("DEVER_WIRE_BENCH_WIDE") != NULL;
|
||||
if (rows != 1 && rows != 16 && rows != 128 && !(wide && rows == 1024)) return 1;
|
||||
const char *row = "{\"id\":9007199254740993,\"text\":\"雪😀\\n\\\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"weight\":1.25,\"note\":null}";
|
||||
const char *suffix = "],\"raw\":{\"large\":123456789012345678901234567890}}";
|
||||
size_t capacity = rows * (strlen(row) + 1) + strlen(suffix) + 64;
|
||||
char *raw = malloc(capacity);
|
||||
if (raw == NULL) return 2;
|
||||
strcpy(raw, wide ? "{" : "{\"rows\":[");
|
||||
for (size_t index = 0; index < rows; ++index) {
|
||||
if (index != 0) strcat(raw, ",");
|
||||
if (wide) {
|
||||
char field[64];
|
||||
snprintf(field, sizeof(field), "\"field_%zu\":%zu", index, index);
|
||||
strcat(raw, field);
|
||||
} else {
|
||||
strcat(raw, row);
|
||||
}
|
||||
}
|
||||
strcat(raw, wide ? "}" : suffix);
|
||||
size_t length = strlen(raw);
|
||||
DeverRtBuffer error = {0};
|
||||
void *source = NULL, *body = NULL;
|
||||
if (dever_rt_v1_text_new((const uint8_t *)raw, length, &source, &error)) return 3;
|
||||
if (getenv("DEVER_WIRE_BENCH_VALIDATED") != NULL) {
|
||||
void *validated = NULL;
|
||||
if (dever_rt_v1_wire_decode(source, &text_type, decode, &validated, &error)) return 11;
|
||||
dever_rt_v1_text_release(source);
|
||||
source = validated;
|
||||
}
|
||||
char *request = malloc(length + 12);
|
||||
if (request == NULL) return 4;
|
||||
snprintf(request, length + 12, "{\"source\":%s}", raw);
|
||||
if (dever_rt_v1_text_new((const uint8_t *)request, strlen(request), &body, &error)) return 5;
|
||||
free(request);
|
||||
for (unsigned phase = 0; phase < 4; ++phase) {
|
||||
void *output = NULL;
|
||||
if (operation(phase, body, source, &output, &error)) return 6;
|
||||
DeverRtBuffer bytes = {0};
|
||||
if (dever_rt_v1_text_export(output, &bytes) || bytes.len != length || memcmp(bytes.ptr, raw, length) != 0) return 7;
|
||||
dever_rt_v1_buffer_free(bytes.ptr, bytes.len);
|
||||
dever_rt_v1_text_release(output);
|
||||
struct timespec started, finished;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &started)) return 8;
|
||||
long long allocations = dever_test_allocations();
|
||||
const size_t iterations = 500;
|
||||
for (size_t iteration = 0; iteration < iterations; ++iteration) {
|
||||
if (operation(phase, body, source, &output, &error)) return 9;
|
||||
dever_rt_v1_text_release(output);
|
||||
}
|
||||
allocations = dever_test_allocations() - allocations;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &finished)) return 10;
|
||||
int64_t elapsed = (int64_t)(finished.tv_sec - started.tv_sec) * INT64_C(1000000000)
|
||||
+ finished.tv_nsec - started.tv_nsec;
|
||||
printf("{\"phase\":%u,\"rows\":%zu,\"iterations\":%zu,\"elapsed_ns\":%lld,\"allocations\":%lld}\n",
|
||||
phase, rows, iterations, (long long)elapsed, allocations);
|
||||
}
|
||||
dever_rt_v1_text_release(source);
|
||||
dever_rt_v1_text_release(body);
|
||||
free(raw);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "runtime.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
extern long long dever_test_allocations(void);
|
||||
|
||||
#ifndef WIRE_TEXT_KEY
|
||||
#define WIRE_TEXT_KEY "text"
|
||||
#endif
|
||||
#ifndef WIRE_NUMBER_KEY
|
||||
#define WIRE_NUMBER_KEY "number"
|
||||
#endif
|
||||
#ifndef WIRE_RECORD
|
||||
#define WIRE_RECORD 0
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
void *text;
|
||||
int64_t rows;
|
||||
} Payload;
|
||||
|
||||
#if WIRE_RECORD != 2
|
||||
static const DeverRtWireName fields[] = {
|
||||
{(const uint8_t *)WIRE_TEXT_KEY, sizeof(WIRE_TEXT_KEY) - 1},
|
||||
{(const uint8_t *)WIRE_NUMBER_KEY, sizeof(WIRE_NUMBER_KEY) - 1}
|
||||
};
|
||||
#endif
|
||||
|
||||
static void payload_clone(const void *source, void *destination) {
|
||||
const Payload *input = source;
|
||||
Payload *output = destination;
|
||||
*output = *input;
|
||||
output->text = dever_rt_v1_text_retain(input->text);
|
||||
}
|
||||
|
||||
static void payload_drop(void *value) {
|
||||
Payload *payload = value;
|
||||
dever_rt_v1_text_release(payload->text);
|
||||
payload->text = NULL;
|
||||
}
|
||||
|
||||
static uint8_t payload_equal(const void *left, const void *right) {
|
||||
const Payload *first = left;
|
||||
const Payload *second = right;
|
||||
return first->rows == second->rows && dever_rt_v1_text_equal(first->text, second->text);
|
||||
}
|
||||
|
||||
static const DeverRtType payload_type = {
|
||||
sizeof(Payload), _Alignof(Payload), payload_clone, payload_drop, payload_equal, NULL
|
||||
};
|
||||
|
||||
#if WIRE_RECORD == 2
|
||||
static const DeverRtWireName prefixes[] = {
|
||||
{(const uint8_t *)"\"" WIRE_TEXT_KEY "\":", sizeof("\"" WIRE_TEXT_KEY "\":") - 1},
|
||||
{(const uint8_t *)",\"" WIRE_NUMBER_KEY "\":", sizeof(",\"" WIRE_NUMBER_KEY "\":") - 1}
|
||||
};
|
||||
static uint32_t encode_fields(const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
uint32_t status = dever_rt_v1_wire_encoder_compiled_key(writer, &prefixes[0], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_compiled_key(writer, &prefixes[1], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
return status;
|
||||
}
|
||||
#elif WIRE_RECORD
|
||||
static uint32_t encode_field(uint64_t index, const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
if (index == 0) return dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (index == 1) return dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
return 2;
|
||||
}
|
||||
#endif
|
||||
|
||||
static uint32_t encode(const void *value, void *writer, DeverRtBuffer *error) {
|
||||
const Payload *payload = value;
|
||||
uint32_t status = dever_rt_v1_wire_encoder_begin_array(writer, error);
|
||||
for (int64_t index = 0; index < payload->rows && status == 0; ++index) {
|
||||
#if WIRE_RECORD == 2
|
||||
status = dever_rt_v1_wire_encoder_compiled_record(writer, value, &payload_type, 2, encode_fields, error);
|
||||
#elif WIRE_RECORD
|
||||
status = dever_rt_v1_wire_encoder_record(writer, value, &payload_type, fields, 2, encode_field, error);
|
||||
#else
|
||||
status = dever_rt_v1_wire_encoder_begin_object(writer, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_key(writer, &fields[0], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_text(writer, payload->text, error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_key(writer, &fields[1], error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_int(writer, INT64_C(9007199254740993), error);
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_end(writer, error);
|
||||
#endif
|
||||
}
|
||||
if (status == 0) status = dever_rt_v1_wire_encoder_end(writer, error);
|
||||
return status;
|
||||
}
|
||||
|
||||
int dever_test_run(void) {
|
||||
const char *configured = getenv("DEVER_WIRE_BENCH_ROWS");
|
||||
int64_t rows = configured == NULL ? 128 : strtoll(configured, NULL, 10);
|
||||
if (rows != 4 && rows != 128) return 1;
|
||||
const char *text = "Unicode 雪😀 and newline\n";
|
||||
const char *row = "{\"" WIRE_TEXT_KEY "\":\"Unicode 雪😀 and newline\\n\",\"" WIRE_NUMBER_KEY "\":9007199254740993}";
|
||||
Payload payload = {NULL, rows};
|
||||
DeverRtBuffer error = {0};
|
||||
if (dever_rt_v1_text_new((const uint8_t *)text, strlen(text), &payload.text, &error)) return 2;
|
||||
void *output = NULL;
|
||||
if (dever_rt_v1_wire_encode(&payload, &payload_type, encode, &output, &error)) return 3;
|
||||
DeverRtBuffer bytes = {0};
|
||||
if (dever_rt_v1_text_export(output, &bytes)) return 4;
|
||||
size_t row_length = strlen(row);
|
||||
if (bytes.len != 1 + (row_length + 1) * (size_t)rows || bytes.ptr[0] != '[' || bytes.ptr[bytes.len - 1] != ']') return 5;
|
||||
for (int64_t index = 0; index < rows; ++index) {
|
||||
if (memcmp(bytes.ptr + 1 + (size_t)index * (row_length + 1), row, row_length) != 0) return 6;
|
||||
}
|
||||
dever_rt_v1_buffer_free(bytes.ptr, bytes.len);
|
||||
dever_rt_v1_text_release(output);
|
||||
struct timespec started, finished;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &started)) return 7;
|
||||
long long allocations = dever_test_allocations();
|
||||
const int64_t iterations = 500;
|
||||
for (int64_t iteration = 0; iteration < iterations; ++iteration) {
|
||||
if (dever_rt_v1_wire_encode(&payload, &payload_type, encode, &output, &error)) return 8;
|
||||
dever_rt_v1_text_release(output);
|
||||
}
|
||||
allocations = dever_test_allocations() - allocations;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &finished)) return 9;
|
||||
int64_t elapsed = (int64_t)(finished.tv_sec - started.tv_sec) * INT64_C(1000000000)
|
||||
+ finished.tv_nsec - started.tv_nsec;
|
||||
payload_drop(&payload);
|
||||
printf("{\"rows\":%lld,\"iterations\":%lld,\"elapsed_ns\":%lld,\"allocations\":%lld}\n",
|
||||
(long long)rows, (long long)iterations, (long long)elapsed, allocations);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
ready(port: Int) () {
|
||||
dever.io.println("READY|" + int.toText(port))
|
||||
}
|
||||
|
||||
pause(millis: Int) () {
|
||||
dever.task.sleep(millis)
|
||||
}
|
||||
|
||||
report(
|
||||
begin: Int,
|
||||
finish: Int,
|
||||
name: Text,
|
||||
count: Int,
|
||||
checksum: Int
|
||||
) () recover("基准失败输出由运行器检查") {
|
||||
dever.io.println(
|
||||
"SAMPLE|" + name + "|" + int.toText(count) + "|" + int.toText(finish - begin) + "|" + int.toText(
|
||||
checksum
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
long cmd run = app.run
|
||||
|
||||
long cmd continuous = app.continuous
|
||||
@@ -0,0 +1,39 @@
|
||||
type InputResult {
|
||||
Parsed(value: Decimal)
|
||||
error Invalid
|
||||
}
|
||||
|
||||
type State {
|
||||
total: Decimal
|
||||
factor: Decimal
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Text
|
||||
}
|
||||
|
||||
parsed(value: Decimal) (result: Decimal) { result = value }
|
||||
parsed(value: null) (result: Decimal) { fail(InputResult.Invalid) }
|
||||
|
||||
step(value: Int, state: State) (next: State) {
|
||||
next = State {
|
||||
total = state.total + decimal.fromInt(value) * state.factor
|
||||
factor = state.factor
|
||||
}
|
||||
}
|
||||
|
||||
run(ticks: Text, initial: Text, factor: Text) (sample: Sample) {
|
||||
bytes = dever.bytes.fromText(ticks)
|
||||
state = State { total = parsed(decimal.parse(initial))
|
||||
factor = parsed(decimal.parse(factor)) }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(step, bytes, state)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = decimal.toText(final.total) }
|
||||
}
|
||||
|
||||
continuous(ticks: Text, initial: Text, factor: Text) (samples: List<Sample>) {
|
||||
samples = [run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor), run(ticks, initial, factor)]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use dec::{Context, Decimal128, Rounding};
|
||||
use dever_runtime::number::DecimalValue;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
fn finite(context: &Context<Decimal128>, value: Decimal128) -> Decimal128 {
|
||||
let status = context.status();
|
||||
assert!(!status.division_by_zero() && !status.division_undefined());
|
||||
assert!(!status.overflow() && !status.underflow());
|
||||
assert!(!status.invalid_operation() && !status.invalid_context() && value.is_finite());
|
||||
value
|
||||
}
|
||||
|
||||
fn measure(mode: &str, ticks: &str, initial: &str, factor: &str) -> serde_json::Value {
|
||||
let bytes = black_box(ticks.as_bytes());
|
||||
let (checksum, elapsed) = if mode == "runtime" {
|
||||
let mut total = DecimalValue::parse(initial).expect("initial");
|
||||
let factor = DecimalValue::parse(factor).expect("factor");
|
||||
let begin = Instant::now();
|
||||
for value in bytes {
|
||||
let product = DecimalValue::from_int(i64::from(*value))
|
||||
.checked_mul(factor)
|
||||
.expect("multiply");
|
||||
total = total.checked_add(product).expect("add");
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(total.to_string(), elapsed)
|
||||
} else {
|
||||
assert_eq!(mode, "decimal128");
|
||||
let mut context = Context::<Decimal128>::default();
|
||||
let mut total = context.parse(initial).expect("initial");
|
||||
assert!(!context.status().inexact() && total.is_finite());
|
||||
context.clear_status();
|
||||
let factor = context.parse(factor).expect("factor");
|
||||
assert!(!context.status().inexact() && factor.is_finite());
|
||||
context.set_rounding(Rounding::HalfEven);
|
||||
let begin = Instant::now();
|
||||
for value in bytes {
|
||||
let item = Decimal128::from(i64::from(*value));
|
||||
context.clear_status();
|
||||
let product = context.mul(item, factor);
|
||||
let product = finite(&context, product);
|
||||
context.clear_status();
|
||||
let sum = context.add(total, product);
|
||||
total = finite(&context, sum);
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(context.reduce(total).to_string(), elapsed)
|
||||
};
|
||||
serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[3]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks");
|
||||
let initial = input["initial"].as_str().expect("initial");
|
||||
let factor = input["factor"].as_str().expect("factor");
|
||||
let output = match arguments[2].as_str() {
|
||||
"run" => measure(&arguments[1], ticks, initial, factor),
|
||||
"continuous" => serde_json::Value::Array(
|
||||
(0..10)
|
||||
.map(|_| measure(&arguments[1], ticks, initial, factor))
|
||||
.collect(),
|
||||
),
|
||||
_ => panic!("unknown measurement"),
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
type Content {
|
||||
body: Bytes
|
||||
kind: Text
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (
|
||||
value: dever.system.Listener
|
||||
) {
|
||||
value = listener
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Failed(message)) (value: dever.system.Listener) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Bound(port)) (value: Int) {
|
||||
value = port
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Failed(message)) (value: Int) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
paused(result: dever.time.SleepResult.Done) () {}
|
||||
|
||||
paused(result: dever.time.SleepResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
closed(result: dever.io.CloseResult.Closed) () {}
|
||||
|
||||
closed(result: dever.io.CloseResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
serverTls(result: dever.tls.ServerResult.Ready(tls)) (value: dever.system.ServerTls) {
|
||||
value = tls
|
||||
}
|
||||
|
||||
serverTls(result: dever.tls.ServerResult.Failed(message)) (
|
||||
value: dever.system.ServerTls
|
||||
) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
responseValue(content: Content) (result: dever.http.Response) {
|
||||
result = dever.http.Response {
|
||||
status = 200
|
||||
headers = [dever.http.header("Content-Type", content.kind)]
|
||||
body = content.body
|
||||
}
|
||||
}
|
||||
|
||||
route(request: dever.http.Request, large: Bytes) (response: dever.http.Response) {
|
||||
response = responseValue(select(request.target, large))
|
||||
}
|
||||
|
||||
select(path: "/plain", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("hello")
|
||||
kind = "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: "/json", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("{\"ok\":true}")
|
||||
kind = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: "/bytes", large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = large
|
||||
kind = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
select(path: other, large: Bytes) (result: Content) {
|
||||
result = Content {
|
||||
body = dever.bytes.fromText("unknown benchmark path")
|
||||
kind = "text/plain"
|
||||
}
|
||||
}
|
||||
|
||||
serve(
|
||||
listener: dever.system.Listener,
|
||||
tls: null,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes
|
||||
) () {
|
||||
dever.system.httpServe(route, listener, limits, large)
|
||||
}
|
||||
|
||||
serve(
|
||||
listener: dever.system.Listener,
|
||||
tls: dever.system.ServerTls,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes
|
||||
) () {
|
||||
dever.system.httpServeTls(route, listener, limits, tls, large)
|
||||
}
|
||||
|
||||
worker(
|
||||
tls: dever.system.ServerTls?,
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int
|
||||
) () {
|
||||
listener = listening(result(dever.net.listen("127.0.0.1", 0)))
|
||||
port = bound(result(dever.net.port(listener)))
|
||||
server = run(serve(listener, tls, limits, large))
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
paused(result(dever.task.sleep(lifetime)))
|
||||
closed(result(dever.net.closeListener(listener)))
|
||||
stop(server)
|
||||
}
|
||||
|
||||
port.plain(limits: dever.http.Limits, large: Bytes, lifetime: Int) () {
|
||||
worker(null, limits, large, lifetime)
|
||||
}
|
||||
|
||||
port.secure(
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int,
|
||||
certificate: Bytes,
|
||||
key: Bytes
|
||||
) () {
|
||||
worker(
|
||||
serverTls(result(dever.tls.server(certificate, key))),
|
||||
limits,
|
||||
large,
|
||||
lifetime
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
cmd http = app.http
|
||||
|
||||
cmd https = app.https
|
||||
|
||||
cmd http2 = app.http2
|
||||
|
||||
cmd https2 = app.https2
|
||||
@@ -0,0 +1,35 @@
|
||||
type WorkerFailure {
|
||||
error Failed(message: Text)
|
||||
}
|
||||
|
||||
http() (result: Bool) {
|
||||
port.plain(config.limits(), config.largeBody(), config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
https() (result: Bool) {
|
||||
port.secure(
|
||||
config.limits(),
|
||||
config.largeBody(),
|
||||
config.lifetime(),
|
||||
config.certificate(),
|
||||
config.key()
|
||||
)
|
||||
result = true
|
||||
}
|
||||
|
||||
http2() (result: Bool) {
|
||||
port.plain(config.http2Limits(), config.largeBody(), config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
https2() (result: Bool) {
|
||||
port.secure(
|
||||
config.http2Limits(),
|
||||
config.largeBody(),
|
||||
config.lifetime(),
|
||||
config.certificate(),
|
||||
config.key()
|
||||
)
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 独立 Axum 路由;用官方 Hyper 入口统一连接、报文和超时上限。
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::DefaultBodyLimit,
|
||||
http::header,
|
||||
routing::{get, post},
|
||||
};
|
||||
use hyper_util::{
|
||||
rt::{TokioIo, TokioTimer},
|
||||
service::TowerToHyperService,
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{net::TcpListener, sync::Semaphore};
|
||||
|
||||
async fn serve(capacity: usize) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/json",
|
||||
get(async |_: Bytes| {
|
||||
(
|
||||
[(header::CONTENT_TYPE, "application/json")],
|
||||
"{\"ok\":true}",
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/echo",
|
||||
post(async |body: Bytes| ([(header::CONTENT_TYPE, "application/octet-stream")], body)),
|
||||
)
|
||||
.layer(DefaultBodyLimit::max(65536));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let slots = Arc::new(Semaphore::new(capacity));
|
||||
println!("READY|{}", listener.local_addr()?.port());
|
||||
loop {
|
||||
let permit = slots.clone().acquire_owned().await?;
|
||||
let (stream, _) = listener.accept().await?;
|
||||
stream.set_nodelay(true)?;
|
||||
let service = TowerToHyperService::new(router.clone());
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let mut builder = hyper::server::conn::http1::Builder::new();
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.header_read_timeout(Duration::from_secs(5))
|
||||
.max_buf_size(8192);
|
||||
if let Err(error) = builder
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
{
|
||||
if !error.is_incomplete_message() && !error.is_closed() {
|
||||
eprintln!("connection: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let capacity = std::env::args().nth(1).unwrap().parse()?;
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(serve(capacity))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
route(request: dever.http.Request) (response: dever.http.Response) {
|
||||
response = selected(request.target, request.body)
|
||||
}
|
||||
|
||||
selected(path: "/json", body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 200, headers = [dever.http.header("content-type", "application/json")], body = dever.bytes.fromText("{\"ok\":true}") }
|
||||
}
|
||||
selected(path: "/echo", body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 200, headers = [dever.http.header("content-type", "application/octet-stream")], body = body }
|
||||
}
|
||||
selected(path: other, body: Bytes) (response: dever.http.Response) {
|
||||
response = dever.http.Response { status = 404, headers = [], body = dever.bytes.fromText("") }
|
||||
}
|
||||
|
||||
serve(connections: Int) () {
|
||||
listener = dever.net.listen("127.0.0.1", 0)
|
||||
port = dever.net.port(listener)
|
||||
limits = dever.http.defaultLimits()
|
||||
limits.connections = connections
|
||||
limits.body_bytes = 65536
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
dever.http.serve(route, listener, limits)
|
||||
}
|
||||
|
||||
main() () {
|
||||
serve(2048)
|
||||
}
|
||||
|
||||
overload() () {
|
||||
serve(8192)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type listener struct {
|
||||
net.Listener
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
type connection struct {
|
||||
net.Conn
|
||||
slots chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (value *connection) Close() error {
|
||||
err := value.Conn.Close()
|
||||
value.once.Do(func() { <-value.slots })
|
||||
return err
|
||||
}
|
||||
|
||||
func (value *listener) Accept() (net.Conn, error) {
|
||||
value.slots <- struct{}{}
|
||||
stream, err := value.Listener.Accept()
|
||||
if err != nil {
|
||||
<-value.slots
|
||||
return nil, err
|
||||
}
|
||||
return &connection{Conn: stream, slots: value.slots}, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
capacity, err := strconv.Atoi(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
runtime.GOMAXPROCS(2)
|
||||
bound, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
server := http.Server{
|
||||
ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192,
|
||||
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
defer request.Body.Close()
|
||||
body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, 65536))
|
||||
if err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch request.URL.RequestURI() {
|
||||
case "/json":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Write([]byte(`{"ok":true}`))
|
||||
case "/echo":
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(body)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}),
|
||||
}
|
||||
fmt.Printf("READY|%d\n", bound.Addr().(*net.TCPAddr).Port)
|
||||
if err := server.Serve(&listener{Listener: bound, slots: make(chan struct{}, capacity)}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 独立 Hyper 服务及三种语言共用的 HTTP/1.1 客户端,不链接 Dever 运行库。
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full, Limited};
|
||||
use hyper::{Request, Response, body::Incoming, service::service_fn};
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use std::{convert::Infallible, sync::Arc, time::Duration};
|
||||
use tokio::{net::TcpListener, sync::{Barrier, Semaphore}, time::Instant};
|
||||
|
||||
async fn route(request: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let path = request.uri().path().to_owned();
|
||||
let body = match Limited::new(request.into_body(), 65536).collect().await {
|
||||
Ok(body) => body.to_bytes(),
|
||||
Err(_) => return Ok(Response::builder().status(400).body(Full::new(Bytes::new())).unwrap()),
|
||||
};
|
||||
let (status, kind, body) = match path.as_str() {
|
||||
"/json" => (200, "application/json", Bytes::from_static(b"{\"ok\":true}")),
|
||||
"/echo" => (200, "application/octet-stream", body),
|
||||
_ => (404, "text/plain", Bytes::new()),
|
||||
};
|
||||
Ok(Response::builder().status(status).header("content-type", kind).body(Full::new(body)).unwrap())
|
||||
}
|
||||
|
||||
async fn serve(capacity: usize) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let slots = Arc::new(Semaphore::new(capacity));
|
||||
println!("READY|{}", listener.local_addr()?.port());
|
||||
loop {
|
||||
let permit = slots.clone().acquire_owned().await?;
|
||||
let (stream, _) = listener.accept().await?;
|
||||
stream.set_nodelay(true)?;
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
let mut builder = hyper::server::conn::http1::Builder::new();
|
||||
builder.timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(5)).max_buf_size(8192);
|
||||
if let Err(error) = builder.serve_connection(TokioIo::new(stream), service_fn(route)).await {
|
||||
if !error.is_incomplete_message() && !error.is_closed() {
|
||||
eprintln!("connection: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(port: u16, path: String, concurrency: usize, seconds: f64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let payload = if path == "/echo" { Bytes::from(vec![b'x'; 65536]) } else { Bytes::new() };
|
||||
let expected = if path == "/echo" { payload.clone() } else { Bytes::from_static(b"{\"ok\":true}") };
|
||||
let kind = if path == "/echo" { "application/octet-stream" } else { "application/json" };
|
||||
let barrier = Arc::new(Barrier::new(concurrency + 1));
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..concurrency {
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
|
||||
stream.set_nodelay(true)?;
|
||||
let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await?;
|
||||
let driver = tokio::spawn(connection);
|
||||
let barrier = barrier.clone();
|
||||
let payload = payload.clone();
|
||||
let expected = expected.clone();
|
||||
let path = path.clone();
|
||||
workers.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let deadline = Instant::now() + Duration::from_secs_f64(seconds);
|
||||
let mut samples = Vec::new();
|
||||
let mut errors = 0_u64;
|
||||
while Instant::now() < deadline {
|
||||
let request = Request::builder().method(if path == "/echo" { "POST" } else { "GET" })
|
||||
.uri(&path).header("host", "localhost").body(Full::new(payload.clone())).unwrap();
|
||||
let started = Instant::now();
|
||||
let exchange = async {
|
||||
let response = sender.send_request(request).await.map_err(|error| error.to_string())?;
|
||||
if response.status() != 200 || response.headers().get("content-type").and_then(|value| value.to_str().ok()) != Some(kind) {
|
||||
return Err("status or content type differs".to_owned());
|
||||
}
|
||||
let body = response.into_body().collect().await.map_err(|error| error.to_string())?.to_bytes();
|
||||
if body != expected { return Err("response body differs".to_owned()); }
|
||||
Ok(())
|
||||
};
|
||||
match tokio::time::timeout(Duration::from_secs(5), exchange).await {
|
||||
Ok(Ok(())) => samples.push(started.elapsed().as_micros() as u64),
|
||||
result => {
|
||||
eprintln!("request: {result:?}");
|
||||
errors += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(sender);
|
||||
driver.abort();
|
||||
let _ = driver.await;
|
||||
(samples, errors)
|
||||
}));
|
||||
}
|
||||
let started = Instant::now();
|
||||
barrier.wait().await;
|
||||
let mut samples = Vec::new();
|
||||
let mut errors = 0;
|
||||
for worker in workers {
|
||||
let (mut values, failed) = worker.await?;
|
||||
samples.append(&mut values);
|
||||
errors += failed;
|
||||
}
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
samples.sort_unstable();
|
||||
let percentile = |percent: usize| -> Option<f64> {
|
||||
if samples.is_empty() { None } else { Some(samples[(samples.len() * percent).div_ceil(100) - 1] as f64 / 1000.0) }
|
||||
};
|
||||
println!("{}", serde_json::json!({"concurrency":concurrency,"seconds":seconds,"elapsed_seconds":elapsed,
|
||||
"succeeded":samples.len(),"errors":errors,"qps":samples.len() as f64 / elapsed,
|
||||
"p50_ms":percentile(50),"p95_ms":percentile(95),"p99_ms":percentile(99)}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let server = arguments[0] == "serve";
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(if server { 2 } else { 4 }).enable_all().build()?;
|
||||
if server {
|
||||
runtime.block_on(serve(arguments[1].parse()?))
|
||||
} else {
|
||||
runtime.block_on(load(arguments[1].parse()?, arguments[2].clone(), arguments[3].parse()?, arguments[4].parse()?))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
capacity, err := strconv.Atoi(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
runtime.GOMAXPROCS(2)
|
||||
app := fiber.New(fiber.Config{
|
||||
Concurrency: capacity,
|
||||
BodyLimit: 65536,
|
||||
ReadBufferSize: 8192,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
CaseSensitive: true,
|
||||
StrictRouting: true,
|
||||
})
|
||||
app.Get("/json", func(context fiber.Ctx) error {
|
||||
context.Set("Content-Type", "application/json")
|
||||
return context.SendString(`{"ok":true}`)
|
||||
})
|
||||
app.Post("/echo", func(context fiber.Ctx) error {
|
||||
context.Set("Content-Type", "application/octet-stream")
|
||||
return context.Send(context.BodyRaw())
|
||||
})
|
||||
bound, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("READY|%d\n", bound.Addr().(*net.TCPAddr).Port)
|
||||
if err := app.Listener(bound, fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Workers int `json:"workers"`
|
||||
Connections int `json:"connections"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
LifetimeMS int `json:"lifetime_ms"`
|
||||
Certificate string `json:"cert_path"`
|
||||
Key string `json:"key_path"`
|
||||
}
|
||||
|
||||
type limitedListener struct {
|
||||
net.Listener
|
||||
slots chan struct{}
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type limitedConnection struct {
|
||||
net.Conn
|
||||
slots chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (connection *limitedConnection) Close() error {
|
||||
err := connection.Conn.Close()
|
||||
connection.once.Do(func() { <-connection.slots })
|
||||
return err
|
||||
}
|
||||
|
||||
func (listener *limitedListener) Accept() (net.Conn, error) {
|
||||
connection, err := listener.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case listener.slots <- struct{}{}:
|
||||
return &limitedConnection{Conn: connection, slots: listener.slots}, nil
|
||||
case <-listener.closed:
|
||||
connection.Close()
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (listener *limitedListener) Close() error {
|
||||
listener.once.Do(func() { close(listener.closed) })
|
||||
return listener.Listener.Close()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if len(os.Args) != 2 || (os.Args[1] != "http" && os.Args[1] != "https") {
|
||||
return errors.New("usage: go_http http|https")
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(filepath.Dir(executable), "config", "setting.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var document struct {
|
||||
Benchmark settings `json:"benchmark"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &document); err != nil {
|
||||
return err
|
||||
}
|
||||
config := document.Benchmark
|
||||
if config.Workers < 1 || config.Connections < 1 || config.TimeoutMS < 1 || config.LifetimeMS < 1 {
|
||||
return errors.New("benchmark requires positive workers, connections, timeout_ms and lifetime_ms")
|
||||
}
|
||||
runtime.GOMAXPROCS(config.Workers)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bounded := &limitedListener{Listener: listener, slots: make(chan struct{}, config.Connections), closed: make(chan struct{})}
|
||||
defer bounded.Close()
|
||||
large := bytes.Repeat([]byte{'x'}, 65536)
|
||||
deadline := time.Duration(config.TimeoutMS) * time.Millisecond
|
||||
server := &http.Server{
|
||||
ReadTimeout: deadline, ReadHeaderTimeout: deadline, WriteTimeout: deadline,
|
||||
IdleTimeout: deadline, MaxHeaderBytes: 8192,
|
||||
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){},
|
||||
Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body := http.MaxBytesReader(writer, request.Body, 65536)
|
||||
defer body.Close()
|
||||
if _, err := io.Copy(io.Discard, body); err != nil {
|
||||
http.Error(writer, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch request.URL.RequestURI() {
|
||||
case "/plain":
|
||||
writer.Header().Set("Content-Type", "text/plain")
|
||||
io.WriteString(writer, "hello")
|
||||
case "/json":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(writer, `{"ok":true}`)
|
||||
case "/bytes":
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Write(large)
|
||||
default:
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
}),
|
||||
}
|
||||
timer := time.AfterFunc(time.Duration(config.LifetimeMS)*time.Millisecond, func() { server.Close() })
|
||||
defer timer.Stop()
|
||||
fmt.Printf("READY|%d\n", listener.Addr().(*net.TCPAddr).Port)
|
||||
if os.Args[1] == "https" {
|
||||
err = server.ServeTLS(bounded, config.Certificate, config.Key)
|
||||
} else {
|
||||
err = server.Serve(bounded)
|
||||
}
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
plain(limits: dever.http.Limits, large: Bytes, lifetime: Int) () fails app.WorkerFailure
|
||||
|
||||
secure(
|
||||
limits: dever.http.Limits,
|
||||
large: Bytes,
|
||||
lifetime: Int,
|
||||
certificate: Bytes,
|
||||
key: Bytes
|
||||
) () fails app.WorkerFailure
|
||||
@@ -0,0 +1 @@
|
||||
long cmd run = app.run
|
||||
@@ -0,0 +1,56 @@
|
||||
type State {
|
||||
source: Json
|
||||
update: Bool
|
||||
checksum: Int
|
||||
}
|
||||
|
||||
run(source: Json, update: Bool, ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
initial = State {
|
||||
source = source
|
||||
update = update
|
||||
checksum = 0
|
||||
}
|
||||
output = transform(source, update)
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(step, input, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = final.checksum
|
||||
output = output
|
||||
}
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Int
|
||||
output: Text
|
||||
}
|
||||
|
||||
step(unused: Int, state: State) (next: State) {
|
||||
output = transform(state.source, state.update)
|
||||
next = State {
|
||||
source = state.source
|
||||
update = state.update
|
||||
checksum = state.checksum + length(dever.bytes.fromText(output))
|
||||
}
|
||||
}
|
||||
|
||||
transform(source: Json, update: false) (output: Text) {
|
||||
output = selected(dever.json.propertyValue(source, "data"))
|
||||
}
|
||||
|
||||
transform(source: Json, update: true) (output: Text) {
|
||||
output = dever.json.toText(
|
||||
dever.json.setProperty(source, "active", dever.json.boolean(true))
|
||||
)
|
||||
}
|
||||
|
||||
selected(value: Json) (output: Text) {
|
||||
output = dever.json.toText(value)
|
||||
}
|
||||
|
||||
selected(value: null) (output: Text) {
|
||||
output = "null"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type input struct {
|
||||
Source json.RawMessage `json:"source"`
|
||||
Update bool `json:"update"`
|
||||
Ticks string `json:"ticks"`
|
||||
}
|
||||
|
||||
type sample struct {
|
||||
Elapsed int64 `json:"elapsed_ns"`
|
||||
Checksum int `json:"checksum"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func transform(source []byte, update bool) []byte {
|
||||
decoder := json.NewDecoder(bytes.NewReader(source))
|
||||
decoder.UseNumber()
|
||||
var object map[string]any
|
||||
if err := decoder.Decode(&object); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var selected any = object["data"]
|
||||
if update {
|
||||
object["active"] = true
|
||||
selected = object
|
||||
}
|
||||
output, err := json.Marshal(selected)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func main() {
|
||||
var arguments input
|
||||
if len(os.Args) != 2 {
|
||||
panic("expected one JSON input")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(os.Args[1]), &arguments); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !json.Valid(arguments.Source) {
|
||||
panic("invalid source")
|
||||
}
|
||||
output := transform(arguments.Source, arguments.Update)
|
||||
checksum := 0
|
||||
begin := time.Now()
|
||||
for range []byte(arguments.Ticks) {
|
||||
checksum += len(transform(arguments.Source, arguments.Update))
|
||||
}
|
||||
result := sample{time.Since(begin).Nanoseconds(), checksum, string(output)}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, value::RawValue};
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Input {
|
||||
source: Box<RawValue>,
|
||||
update: bool,
|
||||
ticks: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Sample {
|
||||
elapsed_ns: u128,
|
||||
checksum: usize,
|
||||
output: String,
|
||||
}
|
||||
|
||||
fn transform(source: &str, update: bool) -> String {
|
||||
let mut value: Value = serde_json::from_str(source).expect("valid JSON input");
|
||||
let object = value.as_object_mut().expect("object input");
|
||||
if update {
|
||||
object.insert("active".into(), Value::Bool(true));
|
||||
serde_json::to_string(&value).expect("serializable object")
|
||||
} else {
|
||||
serde_json::to_string(object.get("data").expect("data field")).expect("serializable value")
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let argument = std::env::args().nth(1).expect("one JSON argument");
|
||||
let input: Input = serde_json::from_str(&argument).expect("valid input fields");
|
||||
let output = transform(input.source.get(), input.update);
|
||||
let mut checksum = 0;
|
||||
let started = Instant::now();
|
||||
for _ in input.ticks.bytes() {
|
||||
checksum += transform(black_box(input.source.get()), input.update).len();
|
||||
}
|
||||
let sample = Sample {
|
||||
elapsed_ns: started.elapsed().as_nanos(),
|
||||
checksum,
|
||||
output,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&sample).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
long cmd record_update = app.recordUpdate
|
||||
|
||||
long cmd continuous_record_update = app.continuousRecordUpdate
|
||||
|
||||
long cmd integer = app.integer
|
||||
|
||||
long cmd record_inspect = app.recordInspect
|
||||
|
||||
long cmd continuous_record_inspect = app.continuousRecordInspect
|
||||
|
||||
long cmd map_lookup = app.mapLookup
|
||||
|
||||
long cmd continuous_map_lookup = app.continuousMapLookup
|
||||
|
||||
long cmd map_text_lookup = app.mapTextLookup
|
||||
|
||||
long cmd continuous_map_text_lookup = app.continuousMapTextLookup
|
||||
|
||||
long cmd text_equal = app.textEqual
|
||||
|
||||
long cmd continuous_text_equal = app.continuousTextEqual
|
||||
|
||||
long cmd text_contains = app.textContains
|
||||
|
||||
long cmd continuous_text_contains = app.continuousTextContains
|
||||
|
||||
long cmd float = app.float
|
||||
|
||||
long cmd bytes_concat = app.bytesConcat
|
||||
|
||||
long cmd text_concat = app.textConcat
|
||||
|
||||
long cmd list = app.list
|
||||
|
||||
long cmd map = app.map
|
||||
|
||||
long cmd list_read = app.listRead
|
||||
|
||||
long cmd map_read = app.mapRead
|
||||
|
||||
long cmd list_record = app.listRecord
|
||||
|
||||
long cmd list_record_read = app.listRecordRead
|
||||
|
||||
long cmd continuous_integer = app.continuousInteger
|
||||
|
||||
long cmd continuous_float = app.continuousFloat
|
||||
|
||||
long cmd continuous_list = app.continuousList
|
||||
|
||||
long cmd continuous_map = app.continuousMap
|
||||
|
||||
long cmd continuous_list_read = app.continuousListRead
|
||||
|
||||
long cmd continuous_map_read = app.continuousMapRead
|
||||
|
||||
long cmd continuous_list_record = app.continuousListRecord
|
||||
|
||||
long cmd continuous_list_record_read = app.continuousListRecordRead
|
||||
|
||||
long cmd continuous_text_concat = app.continuousTextConcat
|
||||
|
||||
long cmd continuous_bytes_concat = app.continuousBytesConcat
|
||||
|
||||
long cmd call_read = app.callRead
|
||||
|
||||
long cmd continuous_call_read = app.continuousCallRead
|
||||
@@ -0,0 +1,393 @@
|
||||
type Lookup {
|
||||
values: Map<Int, Int>
|
||||
index: Int
|
||||
total: Int
|
||||
}
|
||||
|
||||
selected(value: Int) (answer: Int) { answer = value }
|
||||
selected(value: null) (answer: Int) { answer = 0 }
|
||||
|
||||
lookup(value: Int, state: Lookup) (next: Lookup) {
|
||||
found = selected(get(state.values, state.index))
|
||||
next = Lookup { values = state.values
|
||||
index = state.index + 1
|
||||
total = state.total + found }
|
||||
}
|
||||
|
||||
mapLookup(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
values = reduce(putValue, input, {})
|
||||
initial = Lookup { values = values
|
||||
index = 0
|
||||
total = 0 }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(lookup, input, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = final.total }
|
||||
}
|
||||
|
||||
continuousMapLookup(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapLookup, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type TextLookup {
|
||||
values: Map<Text, Int>
|
||||
total: Int
|
||||
}
|
||||
|
||||
type TextPair {
|
||||
left: Text
|
||||
right: Text
|
||||
}
|
||||
|
||||
textEntry(entry: MapEntry<Int, Int>, state: Map<Text, Int>) (next: Map<Text, Int>) {
|
||||
next = put(state, "lookup-key-" + int.toText(entry.key), entry.value)
|
||||
}
|
||||
|
||||
textKey(entry: MapEntry<Text, Int>) (key: Text) { key = entry.key }
|
||||
|
||||
textLookup(key: Text, state: TextLookup) (next: TextLookup) {
|
||||
found = selected(get(state.values, key))
|
||||
next = TextLookup { values = state.values
|
||||
total = state.total + found }
|
||||
}
|
||||
|
||||
mapTextLookup(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
values = reduce(textEntry, entries(reduce(putValue, input, {})), {})
|
||||
keys = each(textKey, entries(values))
|
||||
initial = TextLookup { values = values
|
||||
total = 0 }
|
||||
begin = dever.time.monotonicNanos()
|
||||
final = reduce(textLookup, keys, initial)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = final.total + length(keys) - length(input) }
|
||||
}
|
||||
|
||||
continuousMapTextLookup(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapTextLookup, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
textPair(value: Int) (pair: TextPair) {
|
||||
left = "compare-value-" + int.toText(value)
|
||||
pair = TextPair { left = left
|
||||
right = text.lower(text.upper(left)) }
|
||||
}
|
||||
|
||||
matchedValue(matched: true) (value: Int) { value = 120 }
|
||||
matchedValue(matched: false) (value: Int) { value = 0 }
|
||||
|
||||
equalTextPair(row: TextPair, total: Int) (next: Int) {
|
||||
next = total + matchedValue(row.left == row.right)
|
||||
}
|
||||
|
||||
containsTextPair(row: TextPair, total: Int) (next: Int) {
|
||||
next = total + matchedValue(text.contains(row.left, row.right))
|
||||
}
|
||||
|
||||
textEqual(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
rows = each(textPair, input)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(equalTextPair, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + length(rows) - length(input) }
|
||||
}
|
||||
|
||||
continuousTextEqual(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textEqual, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
textContains(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
rows = each(textPair, input)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(containsTextPair, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + length(rows) - length(input) }
|
||||
}
|
||||
|
||||
continuousTextContains(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textContains, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type Sample {
|
||||
elapsed_ns: Int
|
||||
checksum: Int
|
||||
}
|
||||
|
||||
type FloatSample {
|
||||
elapsed_ns: Int
|
||||
checksum: Float
|
||||
}
|
||||
|
||||
addFloat(value: Int, state: Float) (next: Float) {
|
||||
next = state + float.fromInt(value)
|
||||
}
|
||||
|
||||
float(ticks: Text) (sample: FloatSample) {
|
||||
bytes = dever.bytes.fromText(ticks)
|
||||
zero = float.fromInt(0)
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = reduce(addFloat, bytes, zero)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = FloatSample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
joinBytes(value: Bytes, state: Bytes) (next: Bytes) {
|
||||
next = dever.bytes.concat(state, value)
|
||||
}
|
||||
|
||||
bytesConcat(ticks: Text) (sample: Sample) {
|
||||
chunks = each(dever.bytes.fromText, text.split(ticks, ""))
|
||||
empty = dever.bytes.fromText("")
|
||||
begin = dever.time.monotonicNanos()
|
||||
output = reduce(joinBytes, chunks, empty)
|
||||
end = dever.time.monotonicNanos()
|
||||
total = reduce(add, output, 0)
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
joinText(value: Text, state: Text) (next: Text) {
|
||||
next = state + value
|
||||
}
|
||||
|
||||
textConcat(ticks: Text) (sample: Sample) {
|
||||
chunks = text.split(ticks, ",")
|
||||
begin = dever.time.monotonicNanos()
|
||||
output = reduce(joinText, chunks, "")
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = reduce(add, dever.bytes.fromText(output), 0)
|
||||
}
|
||||
}
|
||||
|
||||
add(value: Int, state: Int) (next: Int) {
|
||||
next = state + value
|
||||
}
|
||||
|
||||
appendValue(value: Int, state: List<Int>) (next: List<Int>) {
|
||||
next = append(state, value)
|
||||
}
|
||||
|
||||
putValue(value: Int, state: Map<Int, Int>) (next: Map<Int, Int>) {
|
||||
key = length(state)
|
||||
next = put(state, key, value)
|
||||
}
|
||||
|
||||
mapped(entry: MapEntry<Int, Int>) (value: Int) {
|
||||
value = entry.value
|
||||
}
|
||||
|
||||
integer(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(add, input, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = value
|
||||
}
|
||||
}
|
||||
|
||||
list(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(appendValue, input, [])
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = sum(value)
|
||||
}
|
||||
}
|
||||
|
||||
map(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
value = reduce(putValue, input, {})
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample {
|
||||
elapsed_ns = end - begin
|
||||
checksum = sum(each(mapped, entries(value)))
|
||||
}
|
||||
}
|
||||
|
||||
listRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(appendValue, dever.bytes.fromText(ticks), [])
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(values)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
mapRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(putValue, dever.bytes.fromText(ticks), {})
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(mapped, entries(values))
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
type Record {
|
||||
first: Int
|
||||
second: Int
|
||||
third: Int
|
||||
}
|
||||
|
||||
appendRecord(value: Int, state: List<Record>) (next: List<Record>) {
|
||||
next = append(state, Record { first = value
|
||||
second = 0
|
||||
third = 0 })
|
||||
}
|
||||
|
||||
recordValue(row: Record) (value: Int) {
|
||||
value = row.first + row.second + row.third
|
||||
}
|
||||
|
||||
listRecord(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
values = reduce(appendRecord, input, [])
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(recordValue, values) }
|
||||
}
|
||||
|
||||
listRecordRead(ticks: Text) (sample: Sample) {
|
||||
values = reduce(appendRecord, dever.bytes.fromText(ticks), [])
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(recordValue, values)
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total }
|
||||
}
|
||||
|
||||
continuousInteger(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(integer, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousFloat(ticks: Text) (samples: List<FloatSample>) {
|
||||
samples = each(float, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousList(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(list, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousMap(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(map, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousMapRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(mapRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRecord(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRecord, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousListRecordRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(listRecordRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousTextConcat(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(textConcat, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
continuousBytesConcat(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(bytesConcat, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type OwnedRecord {
|
||||
name: Text
|
||||
tags: List<Text>
|
||||
count: Int
|
||||
}
|
||||
|
||||
ownedRecord(value: Int) (row: OwnedRecord) {
|
||||
name = "row-value-" + int.toText(value)
|
||||
row = OwnedRecord { name = name
|
||||
tags = [name]
|
||||
count = value - length(name) - 1 }
|
||||
}
|
||||
|
||||
inspectOwned(row: OwnedRecord) (value: Int) {
|
||||
value = row.count + length(row.name) + length(row.tags)
|
||||
}
|
||||
|
||||
forwardOwned(row: OwnedRecord) (value: Int) {
|
||||
value = inspectOwned(row)
|
||||
}
|
||||
|
||||
callRead(ticks: Text) (sample: Sample) {
|
||||
rows = each(ownedRecord, dever.bytes.fromText(ticks))
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = sum(forwardOwned, rows)
|
||||
end = dever.time.monotonicNanos()
|
||||
preserved = length(rows)
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = total + preserved - length(rows) }
|
||||
}
|
||||
|
||||
continuousCallRead(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(callRead, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
type Accumulator {
|
||||
values: List<Int>
|
||||
count: Int
|
||||
}
|
||||
|
||||
appendField(value: Int, state: Accumulator) (next: Accumulator) {
|
||||
values = append(state.values, value)
|
||||
next = Accumulator { values = values
|
||||
count = state.count + 1 }
|
||||
}
|
||||
|
||||
recordUpdate(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
state = reduce(appendField, input, Accumulator { values = []
|
||||
count = 0 })
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(state.values) + state.count - length(state.values) }
|
||||
}
|
||||
|
||||
continuousRecordUpdate(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(recordUpdate, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
|
||||
appendInspected(value: Int, state: Accumulator) (next: Accumulator) {
|
||||
count = length(state.values) + 1
|
||||
values = append(state.values, value)
|
||||
next = Accumulator { values = values
|
||||
count = count }
|
||||
}
|
||||
|
||||
recordInspect(ticks: Text) (sample: Sample) {
|
||||
input = dever.bytes.fromText(ticks)
|
||||
begin = dever.time.monotonicNanos()
|
||||
state = reduce(appendInspected, input, Accumulator { values = []
|
||||
count = 0 })
|
||||
end = dever.time.monotonicNanos()
|
||||
sample = Sample { elapsed_ns = end - begin
|
||||
checksum = sum(state.values) + state.count - length(state.values) }
|
||||
}
|
||||
|
||||
continuousRecordInspect(ticks: Text) (samples: List<Sample>) {
|
||||
samples = each(recordInspect, [ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks, ticks])
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::hint::black_box;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Fault {
|
||||
message: String,
|
||||
locations: Vec<&'static str>,
|
||||
causes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Fault {
|
||||
fn overflow(location: &'static str) -> Self {
|
||||
Self {
|
||||
message: "Int overflow".into(),
|
||||
locations: vec![location],
|
||||
causes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(mut self, location: &'static str) -> Self {
|
||||
self.locations.push(location);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct Record {
|
||||
first: i64,
|
||||
second: i64,
|
||||
third: i64,
|
||||
}
|
||||
|
||||
struct OwnedRecord {
|
||||
name: String,
|
||||
tags: Vec<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn inspect_owned(row: &OwnedRecord) -> Result<i64, Fault> {
|
||||
let partial = row
|
||||
.count
|
||||
.checked_add(row.name.chars().count() as i64)
|
||||
.ok_or_else(|| Fault::overflow("inspect_owned"))?;
|
||||
partial
|
||||
.checked_add(row.tags.len() as i64)
|
||||
.ok_or_else(|| Fault::overflow("inspect_owned"))
|
||||
}
|
||||
|
||||
fn forward_owned(row: &OwnedRecord) -> Result<i64, Fault> {
|
||||
inspect_owned(row).map_err(|error| error.at("forward_owned"))
|
||||
}
|
||||
|
||||
fn owned_sum(rows: &[OwnedRecord]) -> Result<i64, Fault> {
|
||||
let mut total = 0_i64;
|
||||
for row in rows {
|
||||
let projected = forward_owned(row).map_err(|error| error.at("call_read"))?;
|
||||
total = total
|
||||
.checked_add(projected)
|
||||
.ok_or_else(|| Fault::overflow("call_read"))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn add(total: i64, value: i64) -> Result<i64, Fault> {
|
||||
total
|
||||
.checked_add(value)
|
||||
.ok_or_else(|| Fault::overflow("add"))
|
||||
}
|
||||
|
||||
fn record_value(row: &Record) -> Result<i64, Fault> {
|
||||
let partial = row
|
||||
.first
|
||||
.checked_add(row.second)
|
||||
.ok_or_else(|| Fault::overflow("record_value"))?;
|
||||
partial
|
||||
.checked_add(row.third)
|
||||
.ok_or_else(|| Fault::overflow("record_value"))
|
||||
}
|
||||
|
||||
fn record_sum(values: &[Record]) -> Result<i64, Fault> {
|
||||
let mut total = 0i64;
|
||||
for row in values {
|
||||
let projected = record_value(row).map_err(|error| error.at("record_sum"))?;
|
||||
total = total
|
||||
.checked_add(projected)
|
||||
.ok_or_else(|| Fault::overflow("record_sum"))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn measure(operation: &str, ticks: &str) -> Result<(i64, Duration), Fault> {
|
||||
let source = black_box(ticks.as_bytes());
|
||||
match operation {
|
||||
"call_read" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let name = format!("row-value-{value}");
|
||||
OwnedRecord {
|
||||
count: i64::from(*value) - name.chars().count() as i64 - 1,
|
||||
tags: vec![name.clone()],
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let begin = Instant::now();
|
||||
let total = owned_sum(borrowed)?;
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
Ok((total, elapsed))
|
||||
}
|
||||
"integer" => {
|
||||
let begin = Instant::now();
|
||||
let mut total = 0;
|
||||
for value in source {
|
||||
total = add(total, i64::from(*value)).map_err(|error| error.at("integer"))?;
|
||||
}
|
||||
Ok((black_box(total), begin.elapsed()))
|
||||
}
|
||||
"list_record_read" => {
|
||||
let mut values = Vec::new();
|
||||
for value in source {
|
||||
values.push(Record {
|
||||
first: i64::from(*value),
|
||||
second: 0,
|
||||
third: 0,
|
||||
});
|
||||
}
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = record_sum(borrowed)?;
|
||||
black_box(total);
|
||||
drop(values);
|
||||
Ok((total, begin.elapsed()))
|
||||
}
|
||||
_ => panic!("unknown checked kernel"),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_errors() {
|
||||
let fault = owned_sum(&[OwnedRecord {
|
||||
name: "overflow".into(),
|
||||
tags: vec!["preserved".into()],
|
||||
count: i64::MAX,
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.message, "Int overflow");
|
||||
assert_eq!(
|
||||
fault.locations,
|
||||
["inspect_owned", "forward_owned", "call_read"]
|
||||
);
|
||||
assert!(fault.causes.is_empty());
|
||||
assert_eq!(add(i64::MAX, 0).unwrap(), i64::MAX);
|
||||
assert_eq!(add(i64::MIN, 0).unwrap(), i64::MIN);
|
||||
for (left, right) in [(i64::MAX, 1), (i64::MIN, -1)] {
|
||||
let fault = add(left, right).unwrap_err().at("integer");
|
||||
assert_eq!(fault.message, "Int overflow");
|
||||
assert_eq!(fault.locations, ["add", "integer"]);
|
||||
assert!(fault.causes.is_empty());
|
||||
}
|
||||
let fault = record_sum(&[Record {
|
||||
first: i64::MAX,
|
||||
second: 1,
|
||||
third: 0,
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.locations, ["record_value", "record_sum"]);
|
||||
let fault = record_sum(&[
|
||||
Record {
|
||||
first: i64::MAX,
|
||||
second: 0,
|
||||
third: 0,
|
||||
},
|
||||
Record {
|
||||
first: 1,
|
||||
second: 0,
|
||||
third: 0,
|
||||
},
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(fault.locations, ["record_sum"]);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
if arguments[1] == "--verify-errors" {
|
||||
verify_errors();
|
||||
println!("checked overflow and error frames verified");
|
||||
return;
|
||||
}
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[2]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks string");
|
||||
let (operation, count) = arguments[1]
|
||||
.strip_prefix("continuous_")
|
||||
.map_or((arguments[1].as_str(), 1), |operation| (operation, 10));
|
||||
let mut samples = Vec::new();
|
||||
for _ in 0..count {
|
||||
match measure(operation, ticks) {
|
||||
Ok((checksum, elapsed)) => samples
|
||||
.push(serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})),
|
||||
Err(error) => {
|
||||
eprintln!("{error:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = if count == 1 {
|
||||
samples.pop().unwrap()
|
||||
} else {
|
||||
serde_json::Value::Array(samples)
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type input struct {
|
||||
Ticks string `json:"ticks"`
|
||||
}
|
||||
type sample struct {
|
||||
Elapsed int64 `json:"elapsed_ns"`
|
||||
Checksum int64 `json:"checksum"`
|
||||
}
|
||||
type record struct {
|
||||
First int64
|
||||
Second int64
|
||||
Third int64
|
||||
}
|
||||
|
||||
type ownedRecord struct {
|
||||
Name string
|
||||
Tags []string
|
||||
Count int64
|
||||
}
|
||||
|
||||
func inspectOwned(row ownedRecord) int64 {
|
||||
return checkedAdd(checkedAdd(row.Count, int64(utf8.RuneCountInString(row.Name))), int64(len(row.Tags)))
|
||||
}
|
||||
|
||||
func forwardOwned(row ownedRecord) int64 {
|
||||
return inspectOwned(row)
|
||||
}
|
||||
|
||||
func checkedAdd(total, value int64) int64 {
|
||||
if value > 0 && total > math.MaxInt64-value {
|
||||
panic("overflow")
|
||||
}
|
||||
return total + value
|
||||
}
|
||||
|
||||
func measure(operation string, ticks string) sample {
|
||||
source := []byte(ticks)
|
||||
total := int64(0)
|
||||
elapsed := int64(0)
|
||||
switch operation {
|
||||
case "record_update":
|
||||
var values []int64
|
||||
count := int64(0)
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
count = checkedAdd(count, 1)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
total = checkedAdd(total, count) - int64(len(values))
|
||||
case "record_inspect":
|
||||
var values []int64
|
||||
count := int64(0)
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
count = checkedAdd(int64(len(values)), 1)
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
total = checkedAdd(total, count) - int64(len(values))
|
||||
case "map_lookup":
|
||||
values := make(map[int64]int64, len(source))
|
||||
for index, value := range source {
|
||||
values[int64(index)] = int64(value)
|
||||
}
|
||||
index := int64(0)
|
||||
begin := time.Now()
|
||||
for range source {
|
||||
total = checkedAdd(total, values[index])
|
||||
index = checkedAdd(index, 1)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "text_equal", "text_contains":
|
||||
rows := make([][2]string, len(source))
|
||||
for index, value := range source {
|
||||
left := "compare-value-" + strconv.Itoa(int(value))
|
||||
rows[index] = [2]string{left, strings.ToLower(strings.ToUpper(left))}
|
||||
}
|
||||
begin := time.Now()
|
||||
if operation == "text_equal" {
|
||||
for _, row := range rows {
|
||||
matched := int64(0)
|
||||
if row[0] == row[1] {
|
||||
matched = 120
|
||||
}
|
||||
total = checkedAdd(total, matched)
|
||||
}
|
||||
} else {
|
||||
for _, row := range rows {
|
||||
matched := int64(0)
|
||||
if strings.Contains(row[0], row[1]) {
|
||||
matched = 120
|
||||
}
|
||||
total = checkedAdd(total, matched)
|
||||
}
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
runtime.KeepAlive(rows)
|
||||
total = checkedAdd(total, int64(len(rows))) - int64(len(source))
|
||||
case "map_text_lookup":
|
||||
keys := make([]string, len(source))
|
||||
values := make(map[string]int64, len(source))
|
||||
for index, value := range source {
|
||||
key := "lookup-key-" + strconv.Itoa(index)
|
||||
keys[index] = key
|
||||
values[key] = int64(value)
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, key := range keys {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
runtime.KeepAlive(values)
|
||||
runtime.KeepAlive(keys)
|
||||
total = checkedAdd(total, int64(len(keys))) - int64(len(source))
|
||||
case "call_read":
|
||||
rows := make([]ownedRecord, 0, len(source))
|
||||
for _, value := range source {
|
||||
name := "row-value-" + strconv.Itoa(int(value))
|
||||
rows = append(rows, ownedRecord{name, []string{name}, int64(value) - int64(utf8.RuneCountInString(name)) - 1})
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, row := range rows {
|
||||
total = checkedAdd(total, forwardOwned(row))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "float":
|
||||
floating := 0.0
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
floating += float64(value)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
total = int64(floating)
|
||||
case "bytes_concat":
|
||||
chunks := make([][]byte, len(source))
|
||||
for index := range source {
|
||||
chunks[index] = source[index : index+1]
|
||||
}
|
||||
var output []byte
|
||||
begin := time.Now()
|
||||
for _, chunk := range chunks {
|
||||
output = append(output, chunk...)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range output {
|
||||
total = checkedAdd(total, int64(value))
|
||||
}
|
||||
case "text_concat":
|
||||
chunks := strings.Split(ticks, ",")
|
||||
begin := time.Now()
|
||||
var output strings.Builder
|
||||
for _, chunk := range chunks {
|
||||
output.WriteString(chunk)
|
||||
}
|
||||
joined := output.String()
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for index := 0; index < len(joined); index++ {
|
||||
total = checkedAdd(total, int64(joined[index]))
|
||||
}
|
||||
case "integer":
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
total = checkedAdd(total, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "list":
|
||||
var values []int64
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
case "map":
|
||||
values := make(map[int64]int64)
|
||||
var order []int64
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
key := int64(len(values))
|
||||
values[key] = int64(value)
|
||||
order = append(order, key)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, key := range order {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
case "list_read":
|
||||
var values []int64
|
||||
for _, value := range source {
|
||||
values = append(values, int64(value))
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, value)
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "map_read":
|
||||
values := make(map[int64]int64)
|
||||
var order []int64
|
||||
for _, value := range source {
|
||||
key := int64(len(values))
|
||||
values[key] = int64(value)
|
||||
order = append(order, key)
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, key := range order {
|
||||
total = checkedAdd(total, values[key])
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
case "list_record":
|
||||
var values []record
|
||||
begin := time.Now()
|
||||
for _, value := range source {
|
||||
values = append(values, record{int64(value), 0, 0})
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, checkedAdd(checkedAdd(value.First, value.Second), value.Third))
|
||||
}
|
||||
case "list_record_read":
|
||||
var values []record
|
||||
for _, value := range source {
|
||||
values = append(values, record{int64(value), 0, 0})
|
||||
}
|
||||
begin := time.Now()
|
||||
for _, value := range values {
|
||||
total = checkedAdd(total, checkedAdd(checkedAdd(value.First, value.Second), value.Third))
|
||||
}
|
||||
elapsed = time.Since(begin).Nanoseconds()
|
||||
default:
|
||||
panic("unknown kernel")
|
||||
}
|
||||
return sample{elapsed, total}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var arguments input
|
||||
if err := json.Unmarshal([]byte(os.Args[2]), &arguments); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var result any
|
||||
if operation, continuous := strings.CutPrefix(os.Args[1], "continuous_"); continuous {
|
||||
samples := make([]sample, 0, 10)
|
||||
for range 10 {
|
||||
samples = append(samples, measure(operation, arguments.Ticks))
|
||||
}
|
||||
result = samples
|
||||
} else {
|
||||
result = measure(os.Args[1], arguments.Ticks)
|
||||
}
|
||||
output, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use indexmap::IndexMap;
|
||||
use std::hint::black_box;
|
||||
use std::time::Instant;
|
||||
|
||||
struct Record {
|
||||
first: i64,
|
||||
second: i64,
|
||||
third: i64,
|
||||
}
|
||||
|
||||
struct OwnedRecord {
|
||||
name: String,
|
||||
tags: Vec<String>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn inspect_owned(row: &OwnedRecord) -> i64 {
|
||||
add(
|
||||
add(row.count, row.name.chars().count() as i64),
|
||||
row.tags.len() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn forward_owned(row: &OwnedRecord) -> i64 {
|
||||
inspect_owned(row)
|
||||
}
|
||||
|
||||
fn add(total: i64, value: i64) -> i64 {
|
||||
total.checked_add(value).expect("integer overflow")
|
||||
}
|
||||
|
||||
fn record_value(row: &Record) -> i64 {
|
||||
add(add(row.first, row.second), row.third)
|
||||
}
|
||||
|
||||
fn measure(operation: &str, ticks: &str) -> serde_json::Value {
|
||||
let source = black_box(ticks.as_bytes());
|
||||
let (checksum, elapsed) = match operation {
|
||||
"record_update" => {
|
||||
let begin = Instant::now();
|
||||
let mut values = Vec::new();
|
||||
let mut count = 0;
|
||||
for value in source {
|
||||
values.push(i64::from(*value));
|
||||
count = add(count, 1);
|
||||
}
|
||||
black_box((&values, count));
|
||||
let elapsed = begin.elapsed();
|
||||
let total = values.iter().fold(0, |total, value| add(total, *value));
|
||||
let checksum = add(total, count)
|
||||
.checked_sub(values.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"record_inspect" => {
|
||||
let begin = Instant::now();
|
||||
let mut values = Vec::new();
|
||||
let mut count = 0;
|
||||
for value in source {
|
||||
count = add(values.len() as i64, 1);
|
||||
values.push(i64::from(*value));
|
||||
}
|
||||
black_box((&values, count));
|
||||
let elapsed = begin.elapsed();
|
||||
let total = values.iter().fold(0, |total, value| add(total, *value));
|
||||
let checksum = add(total, count)
|
||||
.checked_sub(values.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"map_lookup" => {
|
||||
let values = source
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (index as i64, i64::from(*value)))
|
||||
.collect::<IndexMap<_, _>>();
|
||||
let borrowed = black_box(&values);
|
||||
let mut index = 0;
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
for _ in source {
|
||||
total = add(total, borrowed.get(&index).copied().unwrap_or(0));
|
||||
index = add(index, 1);
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&values);
|
||||
(total, elapsed)
|
||||
}
|
||||
"text_equal" | "text_contains" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let left = format!("compare-value-{value}");
|
||||
let right = left.to_uppercase().to_lowercase();
|
||||
(left, right)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
if operation == "text_equal" {
|
||||
for (left, right) in borrowed {
|
||||
total = add(total, if left == right { 120 } else { 0 });
|
||||
}
|
||||
} else {
|
||||
for (left, right) in borrowed {
|
||||
total = add(
|
||||
total,
|
||||
if left.contains(right.as_str()) {
|
||||
120
|
||||
} else {
|
||||
0
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
let checksum = add(total, rows.len() as i64)
|
||||
.checked_sub(source.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"map_text_lookup" => {
|
||||
// 与 Dever 使用相同的拥有键和插入顺序;HashMap<&str, _> 是不同的数据结构。
|
||||
let values = source
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| (format!("lookup-key-{index}"), i64::from(*value)))
|
||||
.collect::<IndexMap<_, _>>();
|
||||
let keys = values.keys().cloned().collect::<Vec<_>>();
|
||||
let borrowed = black_box(&values);
|
||||
let mut total = 0;
|
||||
let begin = Instant::now();
|
||||
for key in &keys {
|
||||
total = add(total, borrowed.get(key.as_str()).copied().unwrap_or(0));
|
||||
}
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box((&keys, &values));
|
||||
let checksum = add(total, keys.len() as i64)
|
||||
.checked_sub(source.len() as i64)
|
||||
.expect("integer overflow");
|
||||
(checksum, elapsed)
|
||||
}
|
||||
"call_read" => {
|
||||
let rows = source
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let name = format!("row-value-{value}");
|
||||
OwnedRecord {
|
||||
count: i64::from(*value) - name.chars().count() as i64 - 1,
|
||||
tags: vec![name.clone()],
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let borrowed = black_box(&rows);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed
|
||||
.iter()
|
||||
.fold(0, |total, row| add(total, forward_owned(row)));
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
black_box(&rows);
|
||||
(total, elapsed)
|
||||
}
|
||||
"float" => {
|
||||
let begin = Instant::now();
|
||||
let total = source
|
||||
.iter()
|
||||
.fold(0.0, |total, value| total + f64::from(*value));
|
||||
black_box(total);
|
||||
let elapsed = begin.elapsed();
|
||||
(total as i64, elapsed)
|
||||
}
|
||||
"bytes_concat" => {
|
||||
let chunks = source.chunks(1).map(<[u8]>::to_vec).collect::<Vec<_>>();
|
||||
let begin = Instant::now();
|
||||
let mut output = Vec::new();
|
||||
for chunk in &chunks {
|
||||
output.extend_from_slice(chunk);
|
||||
}
|
||||
black_box(&output);
|
||||
let elapsed = begin.elapsed();
|
||||
drop(chunks);
|
||||
let total = output
|
||||
.iter()
|
||||
.fold(0, |total, byte| add(total, i64::from(*byte)));
|
||||
(total, elapsed)
|
||||
}
|
||||
"text_concat" => {
|
||||
let chunks = std::str::from_utf8(source)
|
||||
.unwrap()
|
||||
.split(',')
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let begin = Instant::now();
|
||||
let mut output = String::new();
|
||||
for chunk in &chunks {
|
||||
output.push_str(chunk);
|
||||
}
|
||||
black_box(&output);
|
||||
let elapsed = begin.elapsed();
|
||||
drop(chunks);
|
||||
let total = output
|
||||
.bytes()
|
||||
.fold(0, |total, byte| add(total, i64::from(byte)));
|
||||
(total, elapsed)
|
||||
}
|
||||
"integer" => {
|
||||
let begin = Instant::now();
|
||||
let total = source
|
||||
.iter()
|
||||
.fold(0, |total, value| add(total, i64::from(*value)));
|
||||
(black_box(total), begin.elapsed())
|
||||
}
|
||||
"list" | "list_read" => {
|
||||
let mut values = Vec::new();
|
||||
let begin = Instant::now();
|
||||
values.extend(source.iter().map(|value| i64::from(*value)));
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed.iter().fold(0, |total, value| add(total, *value));
|
||||
black_box(total);
|
||||
let traversal = begin.elapsed();
|
||||
drop(values);
|
||||
(
|
||||
total,
|
||||
if operation == "list" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
"list_record" | "list_record_read" => {
|
||||
let mut values = Vec::new();
|
||||
let begin = Instant::now();
|
||||
for value in source {
|
||||
values.push(Record {
|
||||
first: i64::from(*value),
|
||||
second: 0,
|
||||
third: 0,
|
||||
});
|
||||
}
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed
|
||||
.iter()
|
||||
.fold(0, |total, row| add(total, record_value(row)));
|
||||
black_box(total);
|
||||
let traversal = begin.elapsed();
|
||||
drop(values);
|
||||
(
|
||||
total,
|
||||
if operation == "list_record" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
"map" | "map_read" => {
|
||||
let mut values = IndexMap::new();
|
||||
let begin = Instant::now();
|
||||
for value in source {
|
||||
let key = i64::try_from(values.len()).expect("map size");
|
||||
values.insert(key, i64::from(*value));
|
||||
}
|
||||
let construction = begin.elapsed();
|
||||
let borrowed = black_box(&values);
|
||||
let begin = Instant::now();
|
||||
let total = borrowed.values().fold(0, |total, value| add(total, *value));
|
||||
black_box(total);
|
||||
drop(values);
|
||||
let traversal = begin.elapsed();
|
||||
(
|
||||
total,
|
||||
if operation == "map" {
|
||||
construction
|
||||
} else {
|
||||
traversal
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => panic!("unknown kernel"),
|
||||
};
|
||||
serde_json::json!({"checksum": checksum, "elapsed_ns": elapsed.as_nanos()})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let arguments = std::env::args().collect::<Vec<_>>();
|
||||
let input: serde_json::Value = serde_json::from_str(&arguments[2]).expect("input object");
|
||||
let ticks = input["ticks"].as_str().expect("ticks string");
|
||||
let output = if let Some(operation) = arguments[1].strip_prefix("continuous_") {
|
||||
serde_json::Value::Array((0..10).map(|_| measure(operation, ticks)).collect())
|
||||
} else {
|
||||
measure(&arguments[1], ticks)
|
||||
};
|
||||
println!("{output}");
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
setting {
|
||||
limits: dever.http.Limits
|
||||
lifetime_ms: Int
|
||||
}
|
||||
|
||||
type Service {
|
||||
Tcp
|
||||
Live
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Listening(listener)) (
|
||||
value: dever.system.Listener
|
||||
) {
|
||||
value = listener
|
||||
}
|
||||
|
||||
listening(result: dever.net.ListenResult.Failed(message)) (value: dever.system.Listener) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Bound(port)) (value: Int) {
|
||||
value = port
|
||||
}
|
||||
|
||||
bound(result: dever.net.PortResult.Failed(message)) (value: Int) {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
written(result: dever.io.WriteResult.Done) () {}
|
||||
|
||||
written(result: dever.io.WriteResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
closed(result: dever.io.CloseResult.Closed) () {}
|
||||
|
||||
closed(result: dever.io.CloseResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
paused(result: dever.time.SleepResult.Done) () {}
|
||||
|
||||
paused(result: dever.time.SleepResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
tcpChunk(event: dever.io.ReadEvent.Chunk(bytes), socket: dever.system.Socket) () recover("TCP 连接错误由运行器检查") {
|
||||
written(result(dever.net.write(socket, bytes)))
|
||||
}
|
||||
|
||||
tcpChunk(event: dever.io.ReadEvent.Failed(message), socket: dever.system.Socket) () recover("TCP 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
tcpStream(
|
||||
result: dever.net.ReadStreamResult.Streaming(stream),
|
||||
socket: dever.system.Socket
|
||||
) () {
|
||||
each(tcpChunk, stream, socket)
|
||||
}
|
||||
|
||||
tcpStream(
|
||||
result: dever.net.ReadStreamResult.Failed(message),
|
||||
socket: dever.system.Socket
|
||||
) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
tcpConnection(
|
||||
event: dever.net.ConnectResult.Connected(socket),
|
||||
limits: dever.http.Limits
|
||||
) () recover("TCP 连接错误由运行器检查") {
|
||||
written(result(dever.net.timeout(socket, limits.timeout_ms)))
|
||||
tcpStream(result(dever.net.chunks(socket, 4096)), socket)
|
||||
closed(result(dever.net.close(socket)))
|
||||
}
|
||||
|
||||
tcpConnection(
|
||||
event: dever.net.ConnectResult.Failed(message),
|
||||
limits: dever.http.Limits
|
||||
) () recover("TCP 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
tcpServer(listener: dever.system.Listener, limits: dever.http.Limits) () {
|
||||
stream = dever.net.connections(listener)
|
||||
parallelEach(tcpConnection, stream, limits.connections, limits)
|
||||
}
|
||||
|
||||
websocketMessage(
|
||||
event: dever.websocket.MessageEvent.Read(message),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
websocketEcho(message, socket)
|
||||
}
|
||||
|
||||
websocketMessage(
|
||||
event: dever.websocket.MessageEvent.Failed(message),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
blocking(dever.io.println("ERROR|" + message))
|
||||
}
|
||||
|
||||
websocketEcho(
|
||||
message: dever.websocket.Message.Text(value),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
written(result(dever.websocket.send(socket, dever.websocket.Message.Text(value))))
|
||||
}
|
||||
|
||||
websocketEcho(
|
||||
message: dever.websocket.Message.Binary(value),
|
||||
socket: dever.system.WebSocket
|
||||
) () recover("WebSocket 连接错误由运行器检查") {
|
||||
written(result(dever.websocket.send(socket, dever.websocket.Message.Binary(value))))
|
||||
}
|
||||
|
||||
websocketEcho(message: other, socket: dever.system.WebSocket) () recover("WebSocket 连接错误由运行器检查") {}
|
||||
|
||||
websocketLimits() (result: dever.websocket.Limits) {
|
||||
result = dever.websocket.Limits {
|
||||
message_bytes = 4096
|
||||
idle_ms = setting.lifetime_ms
|
||||
write_ms = setting.limits.timeout_ms
|
||||
}
|
||||
}
|
||||
|
||||
websocketConnection(result: dever.websocket.ConnectResult.Connected(socket)) () {
|
||||
each(websocketMessage, dever.websocket.messages(socket), socket)
|
||||
}
|
||||
|
||||
websocketConnection(result: dever.websocket.ConnectResult.Failed(message)) () {
|
||||
fail(app.WorkerFailure.Failed(message))
|
||||
}
|
||||
|
||||
route(request: dever.http.Request, reply: dever.system.HttpReply) () {
|
||||
livePath(request.target, reply)
|
||||
}
|
||||
|
||||
livePath(target: "/ws", reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
websocketConnection(result(dever.websocket.accept(reply, websocketLimits())))
|
||||
}
|
||||
|
||||
livePath(target: "/events", reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
written(result(dever.sse.start(reply, [])))
|
||||
written(
|
||||
result(
|
||||
dever.sse.send(
|
||||
reply,
|
||||
dever.sse.Event {
|
||||
event = "live"
|
||||
data = "ready"
|
||||
id = "1"
|
||||
retry_ms = null
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
paused(result(dever.task.sleep(setting.lifetime_ms)))
|
||||
}
|
||||
|
||||
livePath(target: other, reply: dever.system.HttpReply) () recover("实时连接错误由运行器检查") {
|
||||
written(
|
||||
result(
|
||||
dever.http.respond(
|
||||
reply,
|
||||
dever.http.Response {
|
||||
status = 404
|
||||
headers = []
|
||||
body = dever.bytes.fromText("unknown live benchmark path")
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
liveLimits() (result: dever.http.LiveLimits) {
|
||||
result = dever.http.LiveLimits {
|
||||
chunk_bytes = 4096
|
||||
idle_ms = setting.lifetime_ms
|
||||
heartbeat_ms = 100
|
||||
}
|
||||
}
|
||||
|
||||
liveServer(listener: dever.system.Listener) () {
|
||||
dever.system.httpServeLive(route, listener, setting.limits, liveLimits())
|
||||
}
|
||||
|
||||
serve(service: Service.Tcp, listener: dever.system.Listener) () {
|
||||
tcpServer(listener, setting.limits)
|
||||
}
|
||||
|
||||
serve(service: Service.Live, listener: dever.system.Listener) () {
|
||||
liveServer(listener)
|
||||
}
|
||||
|
||||
worker(service: Service) () {
|
||||
listener = listening(result(dever.net.listen("127.0.0.1", 0)))
|
||||
port = bound(result(dever.net.port(listener)))
|
||||
server = run(serve(service, listener))
|
||||
blocking(dever.io.println("READY|" + int.toText(port)))
|
||||
paused(result(dever.task.sleep(setting.lifetime_ms)))
|
||||
stop(server)
|
||||
closed(result(dever.net.closeListener(listener)))
|
||||
}
|
||||
|
||||
port.tcp() () {
|
||||
worker(Service.Tcp)
|
||||
}
|
||||
|
||||
port.live() () {
|
||||
worker(Service.Live)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
cmd tcp = app.tcp
|
||||
|
||||
cmd ws = app.ws
|
||||
|
||||
cmd sse = app.sse
|
||||
@@ -0,0 +1,18 @@
|
||||
type WorkerFailure {
|
||||
error Failed(message: Text)
|
||||
}
|
||||
|
||||
tcp() (result: Bool) {
|
||||
port.tcp()
|
||||
result = true
|
||||
}
|
||||
|
||||
ws() (result: Bool) {
|
||||
port.live()
|
||||
result = true
|
||||
}
|
||||
|
||||
sse() (result: Bool) {
|
||||
port.live()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
tcp() () fails app.WorkerFailure
|
||||
|
||||
live() () fails app.WorkerFailure
|
||||
@@ -0,0 +1,144 @@
|
||||
type Identity {
|
||||
id: Text
|
||||
user_id: model.id?
|
||||
tenant_id: model.id?
|
||||
}
|
||||
|
||||
verify(claims: dever.auth.Claims) (identity: Identity) {
|
||||
identity = Identity {
|
||||
id = claims.subject
|
||||
user_id = null
|
||||
tenant_id = null
|
||||
}
|
||||
}
|
||||
|
||||
ready() () {
|
||||
blocking(bench.ready(0))
|
||||
}
|
||||
|
||||
populateOne(value: Int) () {
|
||||
created = model.create({
|
||||
value = value
|
||||
label = "fixture"
|
||||
})
|
||||
}
|
||||
|
||||
populate() () {
|
||||
each(populateOne, config.ormRows())
|
||||
}
|
||||
|
||||
crudOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
created = model.create({
|
||||
value = value
|
||||
label = "created"
|
||||
})
|
||||
changed = model.update(created.id, { label = "updated" })
|
||||
removed = model.delete(created.id)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "crud", 1, changed + removed))
|
||||
}
|
||||
|
||||
listOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
page = model.list({
|
||||
order = id.asc
|
||||
page = 1
|
||||
size = 32
|
||||
})
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "list", 1, page.total + length(page.items)))
|
||||
}
|
||||
|
||||
cursorOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
page = model.cursor({
|
||||
order = id.asc
|
||||
size = 32
|
||||
})
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "cursor", 1, length(page.items)))
|
||||
}
|
||||
|
||||
countItem(value: model.Item, total: Int) (next: Int) {
|
||||
next = total + 1
|
||||
}
|
||||
|
||||
streamOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
rows = model.stream({
|
||||
order = id.asc
|
||||
size = 16
|
||||
})
|
||||
total = reduce(countItem, rows, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "stream", 1, total))
|
||||
}
|
||||
|
||||
poolOnce(value: Int) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
total = model.count()
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "pool", 1, total))
|
||||
}
|
||||
|
||||
transaction fillCancelledTransaction() () {
|
||||
created = model.create({
|
||||
value = 1
|
||||
label = "cancelled"
|
||||
})
|
||||
delayed = model.delay(config.ormCancelWork())
|
||||
}
|
||||
|
||||
ormIdle() (result: Bool) {
|
||||
total = model.count()
|
||||
blocking(bench.ready(total))
|
||||
bench.pause(config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormCrud() (result: Bool) {
|
||||
ready()
|
||||
each(crudOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormList() (result: Bool) {
|
||||
populate()
|
||||
ready()
|
||||
each(listOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormCursor() (result: Bool) {
|
||||
populate()
|
||||
ready()
|
||||
each(cursorOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormStream() (result: Bool) {
|
||||
ready()
|
||||
each(streamOnce, config.ormInput())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormPool() (result: Bool) {
|
||||
ready()
|
||||
parallelEach(poolOnce, config.ormInput(), config.ormConcurrency())
|
||||
result = true
|
||||
}
|
||||
|
||||
ormSeed() (result: Bool) {
|
||||
populate()
|
||||
result = true
|
||||
}
|
||||
|
||||
count() (total: Int) {
|
||||
total = model.count()
|
||||
}
|
||||
|
||||
cancel() (result: Bool) {
|
||||
fillCancelledTransaction()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public get count = app.count
|
||||
public post cancel = app.cancel
|
||||
cmd idle = app.ormIdle
|
||||
cmd crud = app.ormCrud
|
||||
cmd list = app.ormList
|
||||
cmd cursor = app.ormCursor
|
||||
cmd stream = app.ormStream
|
||||
cmd pool = app.ormPool
|
||||
cmd seed = app.ormSeed
|
||||
@@ -0,0 +1,13 @@
|
||||
type DelayResult {
|
||||
total: Int
|
||||
}
|
||||
|
||||
global type Item {
|
||||
value: Int index
|
||||
label: Text(1, 32)
|
||||
}
|
||||
|
||||
sql delay(work: Int) (result: DelayResult) {
|
||||
sqlite = "WITH RECURSIVE counter(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM counter WHERE value < ?1) SELECT SUM(value) AS total FROM counter"
|
||||
postgres = "SELECT SUM(value)::bigint AS total FROM generate_series(1::bigint, $1::bigint) AS value"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cmd run = app.run
|
||||
@@ -0,0 +1,4 @@
|
||||
run() (result: Bool) {
|
||||
dever.io.println("profile")
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
port.blockingPause(millis: Int) () {
|
||||
dever.time.sleep(millis)
|
||||
}
|
||||
|
||||
port.reportError(message: Text) () {
|
||||
dever.io.println("ERROR|" + message)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
cmd sync_idle = app.syncIdle
|
||||
|
||||
cmd async_idle = app.asyncIdle
|
||||
|
||||
cmd tasks = app.tasks
|
||||
|
||||
cmd tasks_on_worker = app.tasksOnWorker
|
||||
|
||||
cmd channels = app.channels
|
||||
|
||||
cmd cancel = app.cancel
|
||||
@@ -0,0 +1,132 @@
|
||||
type Parking {
|
||||
ready: Channel<Int>
|
||||
gate: Channel<Int>
|
||||
}
|
||||
|
||||
syncIdle() (result: Bool) {
|
||||
bench.ready(0)
|
||||
port.blockingPause(config.lifetime())
|
||||
result = true
|
||||
}
|
||||
|
||||
idle() () {
|
||||
blocking(bench.ready(0))
|
||||
bench.pause(config.lifetime())
|
||||
}
|
||||
|
||||
asyncIdle() (result: Bool) {
|
||||
idle()
|
||||
result = true
|
||||
}
|
||||
|
||||
identity(value: Int) (result: Int) {
|
||||
result = value
|
||||
}
|
||||
|
||||
taskSum(value: Int, state: Int) (result: Int) {
|
||||
job = run(identity(value))
|
||||
result = state + wait(job)
|
||||
}
|
||||
|
||||
sum(value: Int, state: Int) (result: Int) {
|
||||
result = state + value
|
||||
}
|
||||
|
||||
taskSample(index: Int, input: Bytes) () {
|
||||
begin = dever.time.monotonicNanos()
|
||||
checksum = reduce(taskSum, input, 0)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "tasks", length(input), checksum))
|
||||
}
|
||||
|
||||
sendByte(value: Int, output: Channel<Int>) () {
|
||||
send(output, value)
|
||||
}
|
||||
|
||||
produce(input: Bytes, output: Channel<Int>) () {
|
||||
each(sendByte, input, output)
|
||||
close(output)
|
||||
}
|
||||
|
||||
channelSample(index: Int, input: Bytes) () {
|
||||
output = channel(Int, config.channelCapacity())
|
||||
begin = dever.time.monotonicNanos()
|
||||
producer = run(produce(input, output))
|
||||
checksum = reduce(sum, stream(output), 0)
|
||||
wait(producer)
|
||||
end = dever.time.monotonicNanos()
|
||||
blocking(bench.report(begin, end, "channels", length(input), checksum))
|
||||
}
|
||||
|
||||
waitOne(value: Int, parking: Parking) () {
|
||||
send(parking.ready, value)
|
||||
signaled(receive(parking.gate))
|
||||
}
|
||||
|
||||
signaled(value: Int) () {}
|
||||
|
||||
signaled(value: null) () {
|
||||
blocking(port.reportError("channel closed before expected signal"))
|
||||
}
|
||||
|
||||
hold(parking: Parking) () {
|
||||
parallelEach(waitOne, config.pendingInput(), config.pending(), parking)
|
||||
}
|
||||
|
||||
started(value: Int, ready: Channel<Int>) () {
|
||||
signaled(receive(ready))
|
||||
}
|
||||
|
||||
cancelWorker() () {
|
||||
parking = Parking {
|
||||
ready = channel(Int, config.pending())
|
||||
gate = channel(Int, 1)
|
||||
}
|
||||
job = run(hold(parking))
|
||||
each(started, config.pendingInput(), parking.ready)
|
||||
blocking(bench.ready(config.pending()))
|
||||
bench.pause(config.idleSampleMs())
|
||||
begin = dever.time.monotonicNanos()
|
||||
stop(job)
|
||||
end = dever.time.monotonicNanos()
|
||||
close(parking.ready)
|
||||
close(parking.gate)
|
||||
blocking(bench.report(begin, end, "cancel", config.pending(), config.pending()))
|
||||
}
|
||||
|
||||
taskWorker() () {
|
||||
input = config.input()
|
||||
blocking(bench.ready(0))
|
||||
each(taskSample, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], input)
|
||||
}
|
||||
|
||||
channelWorker() () {
|
||||
input = config.input()
|
||||
blocking(bench.ready(0))
|
||||
each(channelSample, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], input)
|
||||
}
|
||||
|
||||
tasks() (result: Bool) {
|
||||
taskWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
nestedTaskWorker() () {
|
||||
job = run(taskWorker())
|
||||
wait(job)
|
||||
}
|
||||
|
||||
tasksOnWorker() (result: Bool) {
|
||||
nestedTaskWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
channels() (result: Bool) {
|
||||
channelWorker()
|
||||
result = true
|
||||
}
|
||||
|
||||
cancel() (result: Bool) {
|
||||
cancelWorker()
|
||||
result = true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
blockingPause(millis: Int) () fails dever.time.SleepResult
|
||||
|
||||
reportError(message: Text) () fails dever.io.WriteResult
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Compare native Dever JSON operations with Go and Rust; build separately."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/json"
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as source:
|
||||
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
||||
return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
module = output / "module/benchmark/json"
|
||||
module.mkdir(parents=True)
|
||||
for name in ("app.dever", "api.dever"):
|
||||
shutil.copy2(FIXTURE / name, module / name)
|
||||
(output / "config").mkdir()
|
||||
(output / "config/setting.json").write_text("{}\n")
|
||||
artifacts = {}
|
||||
for label, compiler in (("dever", arguments.compiler),
|
||||
("dever_before", arguments.baseline_compiler)):
|
||||
if compiler is None:
|
||||
continue
|
||||
binary = output / label
|
||||
with (output / (label + ".build.log")).open("w") as log:
|
||||
subprocess.run([compiler, "build", str(output), "--output", str(binary)],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
artifacts[label] = fingerprint(binary)
|
||||
artifacts[label]["compiler"] = fingerprint(Path(compiler).resolve())
|
||||
binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", str(binary),
|
||||
str(FIXTURE.resolve() / "peer.go")], check=True)
|
||||
artifacts["go"] = fingerprint(binary)
|
||||
artifacts["go"]["compiler"] = subprocess.check_output(["go", "version"], text=True).strip()
|
||||
command = ["cargo", "build", "--offline", "--locked", "--release", "-p", "dever-tests",
|
||||
"--example", "json-peer", "--message-format=json"]
|
||||
process = subprocess.run(command, cwd=FIXTURE.resolve().parents[3],
|
||||
capture_output=True, text=True, check=True, timeout=300)
|
||||
(output / "rust.build.log").write_text(process.stderr)
|
||||
executables = [message["executable"] for line in process.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "json-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected exactly one native Rust JSON peer")
|
||||
binary = output / "rust"
|
||||
shutil.copy2(executables[0], binary)
|
||||
artifacts["rust"] = fingerprint(binary)
|
||||
artifacts["rust"]["compiler"] = subprocess.check_output(["rustc", "--version"], text=True).strip()
|
||||
artifacts["rust"]["source"] = fingerprint(FIXTURE.resolve() / "peer.rs")
|
||||
artifacts["rust"]["profile"] = "release: opt-level=3, fat LTO, codegen-units=1"
|
||||
(output / "manifest.json").write_text(json.dumps(artifacts, indent=2) + "\n")
|
||||
print(output / "manifest.json")
|
||||
|
||||
|
||||
def measure(command):
|
||||
with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
|
||||
process = subprocess.Popen(command, stdout=stdout, stderr=stderr, close_fds=False)
|
||||
deadline = time.monotonic() + 120
|
||||
high_water = 0
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
process_root = Path("/proc") / str(process.pid)
|
||||
if (process_root / "exe").resolve() == Path(command[0]).resolve():
|
||||
for line in (process_root / "status").read_text().splitlines():
|
||||
if line.startswith("VmHWM:"):
|
||||
high_water = max(high_water, int(line.split()[1]))
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
pass
|
||||
waited, status, _ = os.wait4(process.pid, os.WNOHANG)
|
||||
if waited:
|
||||
process.returncode = os.waitstatus_to_exitcode(status)
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("benchmark exceeded 120 seconds")
|
||||
time.sleep(0.005)
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
stdout.seek(0)
|
||||
stderr.seek(0)
|
||||
errors = stderr.read().decode()
|
||||
if process.returncode or errors:
|
||||
raise RuntimeError(errors or "benchmark failed")
|
||||
result = json.loads(stdout.read())
|
||||
if "code" in result:
|
||||
if result["code"] != 0:
|
||||
raise RuntimeError("Dever benchmark returned failure")
|
||||
result = result["data"]
|
||||
if high_water == 0:
|
||||
raise RuntimeError("process ended before native RSS could be sampled")
|
||||
result["sampled_hwm_kib"] = high_water
|
||||
return result
|
||||
|
||||
|
||||
def run(arguments):
|
||||
if arguments.output.exists():
|
||||
raise FileExistsError(arguments.output)
|
||||
if arguments.cpu is not None:
|
||||
if arguments.cpu not in os.sched_getaffinity(0):
|
||||
raise ValueError("requested CPU is outside the allowed affinity")
|
||||
os.sched_setaffinity(0, {arguments.cpu})
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
for artifact in manifest.values():
|
||||
actual = fingerprint(Path(artifact["path"]))
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed after build")
|
||||
report = {"artifacts": manifest, "cpu_affinity": sorted(os.sched_getaffinity(0)),
|
||||
"order": "rotate implementations each repeat within each workload",
|
||||
"samples": [], "summary": []}
|
||||
row = {"zeta": "field", "id": 9007199254740993, "alpha": "x" * 80,
|
||||
"enabled": True, "values": [1, None, "escaped\nvalue"]}
|
||||
for rows in (4, 40, 160):
|
||||
source = {"zeta": {"caption": "form"}, "data": {"rows": [row] * rows},
|
||||
"active": False, "alpha": "end"}
|
||||
payload_bytes = len(json.dumps(source, separators=(",", ":")).encode())
|
||||
for update in (False, True):
|
||||
expected = dict(source, active=True) if update else source["data"]
|
||||
groups = {label: [] for label in manifest}
|
||||
labels = list(manifest)
|
||||
for repeat in range(arguments.repeats):
|
||||
offset = repeat % len(labels)
|
||||
for label in labels[offset:] + labels[:offset]:
|
||||
artifact = manifest[label]
|
||||
iterations = (arguments.baseline_iterations if label == "dever_before"
|
||||
else arguments.iterations)
|
||||
payload = json.dumps({"source": source, "update": update,
|
||||
"ticks": "x" * iterations}, separators=(",", ":"))
|
||||
command = [artifact["path"]]
|
||||
if label.startswith("dever"):
|
||||
command.append("benchmark.json.run")
|
||||
sample = measure(command + [payload])
|
||||
if (json.loads(sample["output"]) != expected or
|
||||
sample["checksum"] != len(sample["output"].encode()) * iterations or
|
||||
sample["elapsed_ns"] <= 0):
|
||||
raise AssertionError("JSON output or checksum mismatch")
|
||||
sample.pop("output")
|
||||
sample.update(label=label, update=update, bytes=payload_bytes,
|
||||
iterations=iterations, repeat=repeat,
|
||||
us_per_operation=sample["elapsed_ns"] / iterations / 1000)
|
||||
groups[label].append(sample)
|
||||
report["samples"].append(sample)
|
||||
for label, samples in groups.items():
|
||||
summary = {"label": label, "operation": "set" if update else "get",
|
||||
"bytes": payload_bytes,
|
||||
"median_us": statistics.median(item["us_per_operation"] for item in samples),
|
||||
"sampled_hwm_kib": max(item["sampled_hwm_kib"] for item in samples)}
|
||||
report["summary"].append(summary)
|
||||
print(json.dumps(summary), flush=True)
|
||||
with arguments.output.open("x") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
|
||||
|
||||
def positive(value):
|
||||
parsed = int(value)
|
||||
if not 1 <= parsed <= 10000:
|
||||
raise argparse.ArgumentTypeError("expected 1..10000")
|
||||
return parsed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
building = commands.add_parser("build")
|
||||
building.add_argument("--compiler", required=True)
|
||||
building.add_argument("--baseline-compiler")
|
||||
building.add_argument("--output", type=Path, required=True)
|
||||
running = commands.add_parser("run")
|
||||
running.add_argument("--manifest", type=Path, required=True)
|
||||
running.add_argument("--output", type=Path, required=True)
|
||||
running.add_argument("--iterations", type=positive, default=2000)
|
||||
running.add_argument("--baseline-iterations", type=positive, default=1)
|
||||
running.add_argument("--repeats", type=positive, default=5)
|
||||
running.add_argument("--cpu", type=int)
|
||||
arguments = parser.parse_args()
|
||||
(build if arguments.command == "build" else run)(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Compare current Dever collection kernels with independent Go and Rust programs."""
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures/kernel"
|
||||
CORE = Path(__file__).resolve().parents[2]
|
||||
CASES = {"integer": (16384, 65536), "float": (16384, 65536),
|
||||
"list": (512, 1024, 2048, 4096),
|
||||
"map": (512, 1024, 2048, 4096),
|
||||
"list_read": (512, 1024, 2048, 4096),
|
||||
"list_record": (512, 1024, 2048, 4096),
|
||||
"list_record_read": (512, 1024, 2048, 4096),
|
||||
"record_update": (512, 1024, 2048, 4096),
|
||||
"record_inspect": (512, 1024, 2048, 4096),
|
||||
"call_read": (512, 1024, 2048, 4096),
|
||||
"map_read": (512, 1024, 2048, 4096),
|
||||
"map_lookup": (512, 1024, 2048, 4096),
|
||||
"map_text_lookup": (512, 1024, 2048, 4096),
|
||||
"text_equal": (512, 1024, 2048, 4096),
|
||||
"text_contains": (512, 1024, 2048, 4096),
|
||||
"text_concat": (512, 2048, 8192, 32768),
|
||||
"bytes_concat": (512, 2048, 8192, 32768)}
|
||||
|
||||
|
||||
def fingerprint(path):
|
||||
with path.open("rb") as source:
|
||||
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
||||
return {"path": str(path), "bytes": path.stat().st_size, "sha256": digest}
|
||||
|
||||
|
||||
def source_identity():
|
||||
files = [CORE / "rust" / name for name in
|
||||
("Cargo.toml", "Cargo.lock", "rust-toolchain.toml")]
|
||||
for directory in ("rust/src", "lib", "sdk"):
|
||||
files.extend(path for path in (CORE / directory).rglob("*")
|
||||
if path.is_file() and path.suffix in
|
||||
{".rs", ".toml", ".md", ".dever", ".py", ".c", ".h", ".js", ".mjs", ".json", ".go"})
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(files):
|
||||
digest.update(str(path.relative_to(CORE)).encode() + b"\0")
|
||||
digest.update(fingerprint(path)["sha256"].encode() + b"\n")
|
||||
return {"sha256": digest.hexdigest(), "files": len(files)}
|
||||
|
||||
|
||||
def build(arguments):
|
||||
tree = source_identity()
|
||||
output = arguments.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
module = output / "module/benchmark/kernel"
|
||||
module.mkdir(parents=True)
|
||||
for name in ("app.dever", "api.dever"):
|
||||
shutil.copy2(FIXTURE / name, module / name)
|
||||
(output / "config").mkdir()
|
||||
(output / "config/setting.json").write_text("{}\n")
|
||||
artifacts = {}
|
||||
for label, compiler in (("dever", arguments.compiler),
|
||||
("dever_before", arguments.baseline_compiler)):
|
||||
if compiler is None:
|
||||
continue
|
||||
binary = output / label
|
||||
with (output / (label + ".build.log")).open("w") as log:
|
||||
subprocess.run([compiler, "build", str(output), "--output", str(binary)],
|
||||
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=300)
|
||||
artifacts[label] = fingerprint(binary)
|
||||
artifacts[label]["compiler"] = fingerprint(Path(compiler).resolve())
|
||||
artifacts[label]["source_tree"] = tree
|
||||
generated = output / (label + ".rs")
|
||||
with generated.open("w") as source:
|
||||
subprocess.run([compiler, "emit", str(output)], stdout=source,
|
||||
check=True, timeout=120)
|
||||
artifacts[label]["generated"] = fingerprint(generated)
|
||||
binary = output / "go"
|
||||
subprocess.run(["go", "build", "-trimpath", "-ldflags=-s -w", "-o", str(binary),
|
||||
str(FIXTURE.resolve() / "peer.go")], check=True, timeout=120)
|
||||
artifacts["go"] = fingerprint(binary)
|
||||
artifacts["go"]["compiler"] = subprocess.check_output(["go", "version"], text=True).strip()
|
||||
peer = output / "rust-peer"
|
||||
peer.mkdir()
|
||||
shutil.copy2(FIXTURE / "peer.rs", peer / "main.rs")
|
||||
core = FIXTURE.resolve().parents[3]
|
||||
shutil.copy2(core / "rust/rust-toolchain.toml", peer / "rust-toolchain.toml")
|
||||
(peer / "Cargo.toml").write_text(
|
||||
'[package]\nname = "kernel-peer"\nversion = "0.0.1"\nedition = "2024"\n'
|
||||
'[workspace]\n[[bin]]\nname = "kernel-peer"\npath = "main.rs"\n'
|
||||
'[dependencies]\nserde_json = "=1.0.145"\nindexmap = "=2.14.0"\n'
|
||||
'[profile.release]\nopt-level = 3\nlto = "fat"\ncodegen-units = 1\n'
|
||||
'overflow-checks = true\nstrip = "symbols"\n')
|
||||
subprocess.run(["cargo", "generate-lockfile", "--offline"], cwd=peer,
|
||||
check=True, timeout=120)
|
||||
command = ["cargo", "build", "--offline", "--locked", "--release",
|
||||
"--bin", "kernel-peer", "--message-format=json"]
|
||||
process = subprocess.run(command, cwd=peer,
|
||||
capture_output=True, text=True, check=True, timeout=300)
|
||||
(output / "rust.build.log").write_text(process.stderr)
|
||||
executables = [message["executable"] for line in process.stdout.splitlines()
|
||||
if (message := json.loads(line)).get("reason") == "compiler-artifact"
|
||||
and message.get("executable") and message["target"]["name"] == "kernel-peer"]
|
||||
if len(executables) != 1:
|
||||
raise RuntimeError("expected exactly one native Rust peer")
|
||||
binary = output / "rust"
|
||||
shutil.copy2(executables[0], binary)
|
||||
artifacts["rust"] = fingerprint(binary)
|
||||
artifacts["rust"]["compiler"] = subprocess.check_output(
|
||||
["rustc", "--version"], cwd=peer, text=True).strip()
|
||||
artifacts["rust"]["profile"] = "release: opt-level=3, fat LTO, codegen-units=1"
|
||||
artifacts["rust"]["source"] = fingerprint(peer / "main.rs")
|
||||
artifacts["rust"]["lock"] = fingerprint(peer / "Cargo.lock")
|
||||
artifacts["rust"]["map"] = "ordered IndexMap with owned keys, matching Dever"
|
||||
if source_identity() != tree:
|
||||
raise RuntimeError("sources changed during build; rebuild the comparison")
|
||||
(output / "manifest.json").write_text(json.dumps(artifacts, indent=2) + "\n")
|
||||
print(output / "manifest.json")
|
||||
|
||||
|
||||
def run(arguments):
|
||||
if arguments.output.exists():
|
||||
raise FileExistsError(arguments.output)
|
||||
if arguments.cpu is not None:
|
||||
if arguments.cpu not in os.sched_getaffinity(0):
|
||||
raise ValueError("requested CPU is outside the allowed affinity")
|
||||
os.sched_setaffinity(0, {arguments.cpu})
|
||||
manifest = json.loads(arguments.manifest.read_text())
|
||||
if arguments.candidate not in manifest:
|
||||
raise ValueError("candidate is missing from the manifest")
|
||||
if arguments.parity_with is not None and arguments.parity_with not in manifest:
|
||||
raise ValueError("parity comparator is missing from the manifest")
|
||||
for artifact in manifest.values():
|
||||
actual = fingerprint(Path(artifact["path"]))
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed after build")
|
||||
identities = [artifact["source_tree"] for artifact in manifest.values()
|
||||
if "source_tree" in artifact]
|
||||
if identities and any(identity != source_identity() for identity in identities):
|
||||
raise ValueError("current sources differ from the build; rebuild before measuring")
|
||||
report = {"artifacts": manifest, "cpu_affinity": sorted(os.sched_getaffinity(0)),
|
||||
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"manifest": fingerprint(arguments.manifest), "repeats": arguments.repeats,
|
||||
"samples_per_process": 10 if arguments.continuous else 1,
|
||||
"measurement": "continuous" if arguments.continuous else "cold",
|
||||
"samples": [], "growth": {}}
|
||||
operations = getattr(arguments, "operations", None) or list(CASES)
|
||||
if len(set(operations)) != len(operations) or any(operation not in CASES for operation in operations):
|
||||
raise ValueError("kernel selection contains duplicate or unknown operations")
|
||||
if arguments.check_growth and not any(operation in operations for operation in ("list", "map", "list_record")):
|
||||
raise ValueError("growth checking requires a collection construction operation")
|
||||
report["operations"] = operations
|
||||
with tempfile.TemporaryDirectory(prefix="kernel-run-", dir=arguments.output.resolve().parent) as temporary:
|
||||
execution = stage_execution(manifest, Path(temporary))
|
||||
report["execution"] = execution
|
||||
measure(arguments, report)
|
||||
|
||||
|
||||
def stage_execution(manifest, directory):
|
||||
for label, artifact in manifest.items():
|
||||
if label.startswith("dever"):
|
||||
configuration = Path(artifact["path"]).parent / "config/setting.json"
|
||||
if json.loads(configuration.read_text()) != {}:
|
||||
raise ValueError("kernel benchmark requires an empty application configuration")
|
||||
configuration = directory / "config/setting.json"
|
||||
configuration.parent.mkdir()
|
||||
configuration.write_text("{}\n")
|
||||
artifacts = {}
|
||||
for index, (label, artifact) in enumerate(manifest.items()):
|
||||
binary = directory / f"program-{index:04d}"
|
||||
method = "hardlink"
|
||||
try:
|
||||
os.link(artifact["path"], binary)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(artifact["path"], binary)
|
||||
method = "copy"
|
||||
actual = fingerprint(binary)
|
||||
if any(actual[field] != artifact[field] for field in ("bytes", "sha256")):
|
||||
raise ValueError("benchmark artifact changed during staging")
|
||||
artifacts[label] = {**actual, "method": method}
|
||||
return {"artifacts": artifacts, "configuration": fingerprint(configuration)}
|
||||
|
||||
|
||||
def measure(arguments, report):
|
||||
manifest = report["artifacts"]
|
||||
operations = report["operations"]
|
||||
executables = report["execution"]["artifacts"]
|
||||
for operation in operations:
|
||||
sizes = CASES[operation]
|
||||
for count in sizes:
|
||||
ticks = ",".join(["x"] * count) if operation == "text_concat" else "x" * count
|
||||
continuous = arguments.continuous
|
||||
payload = json.dumps({"ticks": ticks})
|
||||
timings = {label: [] for label in manifest}
|
||||
process_timings = {label: [] for label in manifest}
|
||||
process_elapsed = {label: [] for label in manifest}
|
||||
for repeat in range(arguments.repeats):
|
||||
labels = list(manifest)
|
||||
offset = repeat % len(labels)
|
||||
labels = labels[offset:] + labels[:offset]
|
||||
for label in labels:
|
||||
action = (("benchmark.kernel." if label.startswith("dever") else "")
|
||||
+ ("continuous_" if continuous else "") + operation)
|
||||
started = time.perf_counter_ns()
|
||||
process = subprocess.run([executables[label]["path"], action, payload],
|
||||
capture_output=True, text=True, timeout=120, check=True)
|
||||
process_elapsed[label].append((time.perf_counter_ns() - started) / 1_000_000)
|
||||
if process.stderr:
|
||||
raise RuntimeError(process.stderr)
|
||||
value = json.loads(process.stdout)
|
||||
if label.startswith("dever"):
|
||||
if value["code"] != 0:
|
||||
raise RuntimeError("Dever benchmark returned failure")
|
||||
value = value["data"]
|
||||
values = value if continuous else [value]
|
||||
if not isinstance(values, list) or len(values) != (10 if continuous else 1):
|
||||
raise AssertionError("kernel sample count mismatch")
|
||||
if any(sample["checksum"] != count * 120 or sample["elapsed_ns"] <= 0
|
||||
for sample in values):
|
||||
raise AssertionError("kernel output or checksum mismatch")
|
||||
measured = [sample["elapsed_ns"] / 1_000_000 for sample in values]
|
||||
process_timings[label].append(measured)
|
||||
timings[label].extend(measured[1:] if continuous else measured)
|
||||
item = {"operation": operation, "count": count, "samples_ms": timings,
|
||||
"process_samples_ms": process_timings,
|
||||
"process_elapsed_ms": process_elapsed,
|
||||
"first_sample_median_ms": {
|
||||
label: statistics.median(values[0] for values in processes)
|
||||
for label, processes in process_timings.items()},
|
||||
"median_ms": {label: statistics.median(values) for label, values in timings.items()}}
|
||||
report["samples"].append(item)
|
||||
print(json.dumps(item), flush=True)
|
||||
for operation in ("list", "map", "list_record"):
|
||||
if operation not in operations:
|
||||
continue
|
||||
samples = [item for item in report["samples"] if item["operation"] == operation]
|
||||
report["growth"][operation] = {
|
||||
label: samples[-1]["median_ms"][label] / samples[0]["median_ms"][label]
|
||||
for label in manifest}
|
||||
if arguments.parity_with is not None:
|
||||
cases = [{
|
||||
"operation": item["operation"],
|
||||
"count": item["count"],
|
||||
"ratio": item["median_ms"][arguments.candidate] / item["median_ms"][arguments.parity_with],
|
||||
"passed": item["median_ms"][arguments.candidate] <= item["median_ms"][arguments.parity_with],
|
||||
} for item in report["samples"]]
|
||||
report["parity"] = {
|
||||
"candidate": arguments.candidate,
|
||||
"comparator": arguments.parity_with,
|
||||
"passed": all(item["passed"] for item in cases),
|
||||
"cases": cases,
|
||||
}
|
||||
with arguments.output.open("x") as output:
|
||||
json.dump(report, output, indent=2)
|
||||
output.write("\n")
|
||||
if arguments.check_growth and any(values[arguments.candidate] > 24 for values in report["growth"].values()):
|
||||
raise AssertionError("8x input exceeded 24x collection construction time; inspect raw samples")
|
||||
if arguments.parity_with is not None and not report["parity"]["passed"]:
|
||||
failures = [item for item in report["parity"]["cases"] if not item["passed"]]
|
||||
raise AssertionError(
|
||||
f"{len(failures)} of {len(cases)} cases did not reach {arguments.parity_with} parity; "
|
||||
f"full samples and ratios saved to {arguments.output}"
|
||||
)
|
||||
|
||||
|
||||
def repeats(value):
|
||||
parsed = int(value)
|
||||
if not 3 <= parsed <= 25:
|
||||
raise argparse.ArgumentTypeError("expected 3..25 repetitions")
|
||||
return parsed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
building = commands.add_parser("build")
|
||||
building.add_argument("--compiler", required=True)
|
||||
building.add_argument("--baseline-compiler")
|
||||
building.add_argument("--output", type=Path, required=True)
|
||||
running = commands.add_parser("run")
|
||||
running.add_argument("--manifest", type=Path, required=True)
|
||||
running.add_argument("--output", type=Path, required=True)
|
||||
running.add_argument("--repeats", type=repeats, default=5)
|
||||
running.add_argument("--continuous", action="store_true",
|
||||
help="retain ten calls per process; compare calls 2..10 separately from the first")
|
||||
running.add_argument("--cpu", type=int)
|
||||
running.add_argument("--check-growth", action="store_true")
|
||||
running.add_argument("--operations", nargs="+", choices=tuple(CASES),
|
||||
help="select diagnostic kernels; omitted runs the complete matrix")
|
||||
running.add_argument("--parity-with", choices=("go", "rust"))
|
||||
running.add_argument("--candidate", default="dever")
|
||||
arguments = parser.parse_args()
|
||||
(build if arguments.command == "build" else run)(arguments)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""长连接对端的阶段校验与采样;进程预算由统一运行器管理。"""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from process import Process, summarize
|
||||
|
||||
PHASES = ("connecting", "connected", "closing", "disconnected")
|
||||
SETTLE_SECONDS = 0.3
|
||||
PROCESS_MARGIN_SECONDS = 10
|
||||
|
||||
|
||||
def phase_sequence(cycles):
|
||||
return [(cycle, phase) for cycle in range(cycles) for phase in PHASES]
|
||||
|
||||
|
||||
def lifecycle_seconds(cycles, hold_seconds, timeout_ms):
|
||||
timeout_seconds = timeout_ms / 1000
|
||||
# Each cycle has independent connect, hold-overrun and close deadlines.
|
||||
return cycles * (hold_seconds + 3 * timeout_seconds + SETTLE_SECONDS) + PROCESS_MARGIN_SECONDS
|
||||
|
||||
|
||||
def stable_phase(before, after):
|
||||
return before[-1] if before and before == after else None
|
||||
|
||||
|
||||
def parse_progress(text, cycles):
|
||||
phases = []
|
||||
result = None
|
||||
# stdout is sampled while its writer is alive; only consume complete lines.
|
||||
for line in text.splitlines(keepends=True):
|
||||
if not line.endswith("\n"):
|
||||
continue
|
||||
if result is not None:
|
||||
raise ValueError("live benchmark output continued after its final result")
|
||||
if line.startswith("PHASE|"):
|
||||
_, cycle, phase = line.strip().split("|")
|
||||
phases.append((int(cycle), phase))
|
||||
elif line.startswith("{"):
|
||||
result = json.loads(line)
|
||||
else:
|
||||
raise ValueError(f"unexpected live benchmark output: {line.strip()}")
|
||||
if phases != phase_sequence(cycles)[:len(phases)]:
|
||||
raise ValueError("live benchmark phases are missing, duplicated or out of order")
|
||||
return phases, result
|
||||
|
||||
|
||||
def validate_result(result, protocol, connections, cycles):
|
||||
counts = ("attempted_connections", "connected", "disconnected", "messages", "errors")
|
||||
if result is None or any(type(result[key]) is not int or result[key] < 0 for key in counts):
|
||||
raise ValueError("missing or invalid live counters")
|
||||
expected = connections * cycles
|
||||
if (result["protocol"] != protocol or result["connections"] != connections or result["cycles"] != cycles
|
||||
or any(result[key] != expected for key in counts[:3])
|
||||
or result["messages"] < expected or result["errors"] != 0 or result["elapsed_ms"] <= 0):
|
||||
raise ValueError("live benchmark did not validate and close all requested connections")
|
||||
|
||||
|
||||
def load_live(args, process, port, protocol, connections, timeout_ms):
|
||||
scheme, path = {"tcp": ("tcp", "/"), "ws": ("ws", "/ws"), "sse": ("http", "/events")}[protocol]
|
||||
hold_ms = round(args.duration * 1000)
|
||||
command = [args.artifacts / "live_bench", protocol, f"{scheme}://127.0.0.1:{port}{path}",
|
||||
connections, args.cycles, hold_ms, timeout_ms]
|
||||
phases = {phase: [] for phase in phase_sequence(args.cycles)}
|
||||
with Process(command, process.directory / "load", cpus=args.client_cpus,
|
||||
peer_settings={"workers": args.client_workers}) as client:
|
||||
deadline = time.monotonic() + lifecycle_seconds(args.cycles, args.duration, timeout_ms)
|
||||
while client.child.poll() is None:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("long connection client exceeded its bounded lifecycle")
|
||||
progress_before, _ = parse_progress(client.text(), args.cycles)
|
||||
first = len(process.samples)
|
||||
process.sample()
|
||||
process.check()
|
||||
if process.child.poll() is not None:
|
||||
raise RuntimeError("server exited during long connection load")
|
||||
progress_after, _ = parse_progress(client.text(), args.cycles)
|
||||
if phase := stable_phase(progress_before, progress_after):
|
||||
phases[phase].extend(process.samples[first:])
|
||||
client.sample()
|
||||
time.sleep(args.interval)
|
||||
client.check()
|
||||
progress, result = parse_progress(client.text(), args.cycles)
|
||||
if progress != phase_sequence(args.cycles):
|
||||
raise ValueError("long connection client ended before all phases")
|
||||
validate_result(result, protocol, connections, args.cycles)
|
||||
# Connection setup/close can finish between polls. Only the two explicit
|
||||
# hold windows require observations; retain setup samples when present.
|
||||
observations = []
|
||||
for (cycle, phase), samples in phases.items():
|
||||
if phase in ("connected", "disconnected") and len(samples) < 2:
|
||||
raise ValueError(f"sampling interval missed {cycle}/{phase} window")
|
||||
observations.append({"cycle": cycle, "phase": phase,
|
||||
"resources": summarize(samples) if samples else None,
|
||||
"last": samples[-1] if samples else None, "raw": samples})
|
||||
return {"result": result, "phases": observations,
|
||||
"client_resources": summarize(client.samples), "client_raw": client.samples}
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Linux 进程与可选专属 cgroup 的生命周期和采样。"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from settings import stage_peer
|
||||
|
||||
|
||||
def key_values(path):
|
||||
return {fields[0].rstrip(":"): fields[1] for line in path.read_text().splitlines()
|
||||
if len(fields := line.split()) >= 2}
|
||||
|
||||
|
||||
def process_sample(pid):
|
||||
root = Path("/proc") / str(pid)
|
||||
status = key_values(root / "status")
|
||||
if "VmRSS" not in status:
|
||||
raise ProcessLookupError(f"process {pid} exited during sampling")
|
||||
memory = key_values(root / "smaps_rollup")
|
||||
# comm 字段可含空格和括号;从最后一个右括号之后解析固定 stat 字段。
|
||||
stat = (root / "stat").read_text().rsplit(")", 1)[1].split()
|
||||
switches = {"voluntary_ctxt_switches": 0, "nonvoluntary_ctxt_switches": 0}
|
||||
for thread in (root / "task").iterdir():
|
||||
try:
|
||||
thread_status = key_values(thread / "status")
|
||||
except FileNotFoundError:
|
||||
continue # A completed blocking thread can disappear during a sample.
|
||||
for name in switches:
|
||||
switches[name] += int(thread_status[name])
|
||||
return {
|
||||
"time": time.monotonic(),
|
||||
"rss_bytes": int(status["VmRSS"]) * 1024,
|
||||
"rss_high_water_bytes": int(status["VmHWM"]) * 1024,
|
||||
"pss_bytes": int(memory["Pss"]) * 1024,
|
||||
"threads": int(status["Threads"]),
|
||||
"fds": len(list((root / "fd").iterdir())),
|
||||
"cpu_seconds": (int(stat[11]) + int(stat[12])) / os.sysconf("SC_CLK_TCK"),
|
||||
**switches,
|
||||
}
|
||||
|
||||
|
||||
class Budget:
|
||||
def __init__(self, parent, memory_mib, cpu_quota):
|
||||
self.path = None
|
||||
self.final = None
|
||||
if parent is None:
|
||||
if memory_mib is not None or cpu_quota is not None:
|
||||
raise ValueError("memory/cpu quotas require --cgroup-parent")
|
||||
return
|
||||
self.path = parent.resolve() / ("dever-perf-" + uuid.uuid4().hex)
|
||||
self.path.mkdir()
|
||||
try:
|
||||
if memory_mib is not None:
|
||||
(self.path / "memory.max").write_text(str(memory_mib * 1024 * 1024))
|
||||
(self.path / "memory.swap.max").write_text("0")
|
||||
if cpu_quota is not None:
|
||||
(self.path / "cpu.max").write_text(f"{round(cpu_quota * 100000)} 100000")
|
||||
except BaseException:
|
||||
self.path.rmdir()
|
||||
raise
|
||||
|
||||
def sample(self):
|
||||
if self.path is None:
|
||||
return None
|
||||
return {
|
||||
"current_bytes": int((self.path / "memory.current").read_text()),
|
||||
"peak_bytes": int((self.path / "memory.peak").read_text()),
|
||||
"events": {k: int(v) for k, v in key_values(self.path / "memory.events").items()},
|
||||
"cpu": {k: int(v) for k, v in key_values(self.path / "cpu.stat").items()},
|
||||
"max": (self.path / "memory.max").read_text().strip(),
|
||||
"cpu_max": (self.path / "cpu.max").read_text().strip(),
|
||||
}
|
||||
|
||||
def close(self):
|
||||
if self.path is not None:
|
||||
try:
|
||||
self.final = self.sample()
|
||||
except OSError as error:
|
||||
try:
|
||||
self.path.rmdir()
|
||||
except OSError as cleanup_error:
|
||||
error.add_note(f"cannot remove owned cgroup {self.path}: {cleanup_error}")
|
||||
raise
|
||||
else:
|
||||
self.path.rmdir()
|
||||
|
||||
|
||||
class Process:
|
||||
def __init__(self, command, directory, *, peer_settings=None, cpus=None, budget=None):
|
||||
self.command = [str(arg) for arg in command]
|
||||
self.directory = directory
|
||||
self.budget = budget
|
||||
self.samples = []
|
||||
self.directory.mkdir(parents=True, exist_ok=False)
|
||||
if peer_settings is not None:
|
||||
self.command[0] = str(stage_peer(command[0], directory, peer_settings))
|
||||
self.stdout = (directory / "stdout.log").open("w")
|
||||
self.stderr = (directory / "stderr.log").open("w")
|
||||
self.started = time.monotonic()
|
||||
|
||||
def prepare_child():
|
||||
# 运行器单线程;只对即将 exec 的自有子进程设置 CPU/cgroup。
|
||||
if cpus is not None:
|
||||
os.sched_setaffinity(0, cpus)
|
||||
if budget is not None and budget.path is not None:
|
||||
(budget.path / "cgroup.procs").write_text(str(os.getpid()))
|
||||
|
||||
try:
|
||||
self.child = subprocess.Popen(self.command, stdout=self.stdout, stderr=self.stderr,
|
||||
preexec_fn=prepare_child, start_new_session=True)
|
||||
except BaseException:
|
||||
self.stdout.close()
|
||||
self.stderr.close()
|
||||
raise
|
||||
|
||||
def text(self):
|
||||
return (self.directory / "stdout.log").read_text()
|
||||
|
||||
def descriptors(self):
|
||||
descriptors = {}
|
||||
for path in (Path("/proc") / str(self.child.pid) / "fd").iterdir():
|
||||
try:
|
||||
descriptors[path.name] = os.readlink(path)
|
||||
except FileNotFoundError:
|
||||
continue # A descriptor may close while /proc is being read.
|
||||
return descriptors
|
||||
|
||||
def sample(self):
|
||||
if self.child.poll() is not None:
|
||||
return
|
||||
try:
|
||||
sample = process_sample(self.child.pid)
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
# Linux can release /proc memory fields before waitpid reports exit.
|
||||
# This observation is unavailable; check() still owns exit status.
|
||||
return
|
||||
except PermissionError as failure:
|
||||
try:
|
||||
self.child.wait(timeout=0.1)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise failure
|
||||
return
|
||||
sample["elapsed_seconds"] = sample.pop("time") - self.started
|
||||
if self.budget is not None:
|
||||
sample["cgroup"] = self.budget.sample()
|
||||
self.samples.append(sample)
|
||||
|
||||
def ready(self, timeout=10):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
self.sample()
|
||||
for line in self.text().splitlines():
|
||||
if line.startswith("ERROR|"):
|
||||
raise RuntimeError(line)
|
||||
if line.startswith("READY|"):
|
||||
self.ready_seconds = time.monotonic() - self.started
|
||||
return int(line.split("|")[1])
|
||||
if self.child.poll() is not None:
|
||||
raise RuntimeError(f"process exited before readiness: {self.directory}")
|
||||
time.sleep(0.005)
|
||||
raise TimeoutError(f"readiness timeout: {self.directory}")
|
||||
|
||||
def check(self):
|
||||
code = self.child.poll()
|
||||
if code is not None and code != 0:
|
||||
raise RuntimeError(f"process exited {code}: {self.directory}")
|
||||
if "ERROR|" in self.text() or (self.directory / "stderr.log").stat().st_size:
|
||||
raise RuntimeError(f"benchmark reported an error: {self.directory}")
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self.child.poll() is None:
|
||||
try:
|
||||
os.killpg(self.child.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# 子进程可能恰好在 poll 与 signal 之间自然退出。
|
||||
pass
|
||||
try:
|
||||
self.child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(self.child.pid, signal.SIGKILL)
|
||||
self.child.wait(timeout=3)
|
||||
finally:
|
||||
self.stdout.close()
|
||||
self.stderr.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
|
||||
def observe(processes, seconds, interval):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
for process in processes:
|
||||
process.sample()
|
||||
process.check()
|
||||
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
if not samples:
|
||||
raise ValueError("process ended before any resource sample")
|
||||
result = {"samples": len(samples)}
|
||||
for metric in ("rss_bytes", "pss_bytes", "threads", "fds"):
|
||||
values = sorted(sample[metric] for sample in samples)
|
||||
result[metric + "_median"] = values[len(values) // 2]
|
||||
result[metric + "_max"] = max(values)
|
||||
result["rss_high_water_bytes"] = max(sample["rss_high_water_bytes"] for sample in samples)
|
||||
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
|
||||
cpu = samples[-1]["cpu_seconds"] - samples[0]["cpu_seconds"]
|
||||
result["cpu_seconds_sampled"] = cpu
|
||||
result["cpu_cores_sampled"] = cpu / elapsed if elapsed > 0 else None
|
||||
for name in ("voluntary_ctxt_switches", "nonvoluntary_ctxt_switches"):
|
||||
result[name + "_sampled"] = samples[-1][name] - samples[0][name]
|
||||
return result
|
||||
|
||||
|
||||
def resource_trend(samples):
|
||||
"""报告四段中位数与后半程增长,避免将启动分配误记为长期泄漏。"""
|
||||
if len(samples) < 4:
|
||||
raise ValueError("resource trend requires at least four observations")
|
||||
windows = [samples[len(samples) * index // 4:len(samples) * (index + 1) // 4]
|
||||
for index in range(4)]
|
||||
medians = [statistics.median(sample["rss_bytes"] for sample in window) for window in windows]
|
||||
elapsed = samples[-1]["elapsed_seconds"] - samples[0]["elapsed_seconds"]
|
||||
return {"observed_seconds": elapsed, "rss_quarter_medians_bytes": medians,
|
||||
"rss_late_growth_bytes": medians[-1] - medians[2],
|
||||
"rss_late_growth_bytes_per_second": (medians[-1] - medians[2]) / (elapsed / 4) if elapsed > 0 else None,
|
||||
"fds_first": samples[0]["fds"], "fds_last": samples[-1]["fds"]}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,59 @@
|
||||
"""基准配置与隔离 peer 布置;配置只来自 config/setting.json。"""
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PEER_CONFIGURATION = "setting-json-v1"
|
||||
|
||||
|
||||
def write_private_json(path, value, *, replace=False):
|
||||
path = Path(path)
|
||||
temporary = None
|
||||
if replace:
|
||||
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-")
|
||||
else:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w") as output:
|
||||
os.fchmod(output.fileno(), 0o600)
|
||||
json.dump(value, output, indent=2)
|
||||
output.write("\n")
|
||||
if temporary is not None:
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def stage_peer(executable, directory, settings):
|
||||
"""独立配置共用不可变程序;测量期间不得重写输入二进制。"""
|
||||
executable = Path(executable).resolve(strict=True)
|
||||
destination = directory / executable.name
|
||||
(directory / "config").mkdir()
|
||||
write_private_json(directory / "config/setting.json", {"benchmark": settings})
|
||||
try:
|
||||
os.link(executable, destination)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EXDEV:
|
||||
raise
|
||||
# 配置目录可以位于另一文件系统;这种情况下硬链接不可用。
|
||||
with destination.open("xb") as output, executable.open("rb") as source:
|
||||
shutil.copyfileobj(source, output)
|
||||
shutil.copymode(executable, destination)
|
||||
return destination.resolve()
|
||||
|
||||
|
||||
def configured_peer(name, setting_path=ROOT / "config/setting.json"):
|
||||
document = json.loads(setting_path.read_text())
|
||||
section = document.get("performance") if isinstance(document, dict) else None
|
||||
value = section.get(name) if isinstance(section, dict) else None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"config/setting.json must define performance.{name} as a binary path")
|
||||
# 仓库设置中的相对路径以项目根目录为基准。
|
||||
path = Path(value)
|
||||
return (setting_path.parent.parent / path).resolve(strict=True)
|
||||
@@ -0,0 +1,272 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from cms import (CmsClient, ReadLatency, concurrent_reads, decode_response, execute, isolated_settings, latency_summary,
|
||||
provision_identity, publish_workload, run_postgres_case, run_postgres_rbac)
|
||||
from settings import write_private_json
|
||||
|
||||
|
||||
class CmsMeasurementTests(unittest.TestCase):
|
||||
def test_identity_provisioning_uses_current_tenant_commands(self):
|
||||
identity = {"tenant_id": 19, "user_id": 31, "credential_id": 7}
|
||||
executable = Path("owned-app")
|
||||
with patch("cms.execute", side_effect=[identity, None, None, None]) as execute_command:
|
||||
actual, password = provision_identity(executable, Path("owned-output"), "example")
|
||||
self.assertEqual(actual, identity)
|
||||
self.assertTrue(password)
|
||||
calls = execute_command.call_args_list
|
||||
self.assertEqual(calls[0].args[0][1], "user.account.bootstrap")
|
||||
self.assertEqual(calls[1].args[0],
|
||||
[executable, "--tenant", 19, "dever.tenant.migrate", "null"])
|
||||
for call, site in zip(calls[2:], ("admin", "front")):
|
||||
self.assertEqual(call.args[0][:4],
|
||||
[executable, "--tenant", 19, "dever.auth.owner"])
|
||||
self.assertEqual(json.loads(call.args[0][4]), {"site": site, "user_id": 31})
|
||||
|
||||
def test_long_read_histogram_is_bounded_and_reports_upper_bounds(self):
|
||||
latency = ReadLatency()
|
||||
for _ in range(10000):
|
||||
latency.add(3)
|
||||
other = ReadLatency()
|
||||
other.add(100)
|
||||
latency.merge(other)
|
||||
self.assertEqual(len(latency.buckets), 2048)
|
||||
self.assertEqual(latency.count, 10001)
|
||||
self.assertGreaterEqual(latency.percentile(.99), 3)
|
||||
self.assertLess(latency.percentile(.99), 3.05)
|
||||
client = CmsClient(12345)
|
||||
client.record_timings = False
|
||||
client.connection = SimpleNamespace(request=Mock(), getresponse=Mock(return_value=SimpleNamespace(
|
||||
status=200, read=Mock(return_value=b'{"code":0,"message":"ok","data":1}'))), close=Mock())
|
||||
try:
|
||||
self.assertEqual(client.request("GET", "/test"), 1)
|
||||
self.assertEqual(client.timings, {})
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_concurrent_read_failure_closes_clients_and_cannot_be_counted_as_success(self):
|
||||
expected = {"total": 1, "items": [{"title": "published"}]}
|
||||
for reply in ({"total": 0, "items": []}, RuntimeError("HTTP 403")):
|
||||
with self.subTest(reply=reply):
|
||||
opened = []
|
||||
|
||||
def client(_port):
|
||||
request = Mock(side_effect=reply) if isinstance(reply, Exception) else Mock(return_value=reply)
|
||||
instance = SimpleNamespace(cookies={}, timings={}, request=request, close=Mock())
|
||||
opened.append(instance)
|
||||
return instance
|
||||
|
||||
process = SimpleNamespace(samples=[], check=Mock())
|
||||
with patch("cms.CmsClient", side_effect=client), patch("cms.observe"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
concurrent_reads(12345, {"front": "test-only-cookie"}, expected, process,
|
||||
concurrency=2, seconds=.1, interval=.01)
|
||||
self.assertEqual(len(opened), 2)
|
||||
for instance in opened:
|
||||
instance.close.assert_called_once()
|
||||
|
||||
def test_delete_inputs_use_query_instead_of_json_body(self):
|
||||
client = CmsClient(12345)
|
||||
response = SimpleNamespace(status=200, read=Mock(return_value=b'{"code":0,"message":"ok","data":true}'))
|
||||
connection = SimpleNamespace(request=Mock(), getresponse=Mock(return_value=response), close=Mock())
|
||||
client.connection = connection
|
||||
try:
|
||||
self.assertIs(client.request("DELETE", "/revoke", {"user_id": 3, "role_id": "fixture reader"}), True)
|
||||
sent = connection.request.call_args.args
|
||||
self.assertEqual(sent[:3], ("DELETE", "/revoke?user_id=3&role_id=fixture+reader", None))
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
def test_postgres_rbac_checks_catalog_roles_and_same_session_revocation(self):
|
||||
catalog = [
|
||||
{"component": "news", "domain": "article", "site": "admin", "action": "list",
|
||||
"method": "GET", "key": "news.article.admin.list"},
|
||||
{"component": "user", "domain": "authorization", "site": "admin", "action": "permissions",
|
||||
"method": "GET", "key": "user.authorization.admin.permissions"},
|
||||
{"component": "news", "domain": "article", "site": "front", "action": "list",
|
||||
"method": "GET", "key": "news.article.front.list"},
|
||||
]
|
||||
identity = {"user_id": 1, "tenant_id": 2}
|
||||
member = {"user_id": 3, "tenant_id": 2, "credential_id": 4}
|
||||
login = [{"account": {"id": 3}, "tenant_id": 2, "site": site}
|
||||
for site in ("admin", "front")]
|
||||
for failure in (False, True):
|
||||
with self.subTest(failure=failure):
|
||||
owner = SimpleNamespace(request=Mock(side_effect=[
|
||||
[entry for entry in catalog if entry["site"] == "admin"],
|
||||
[entry for entry in catalog if entry["site"] == "front"],
|
||||
] + [True] * 9))
|
||||
responses = login + ([RuntimeError("missing denial")] if failure else [None] * 11)
|
||||
client = SimpleNamespace(request=Mock(side_effect=responses), close=Mock())
|
||||
with patch("cms.execute", return_value=member), patch("cms.CmsClient", return_value=client):
|
||||
if failure:
|
||||
with self.assertRaisesRegex(RuntimeError, "missing denial"):
|
||||
run_postgres_rbac(Path("owned-app"), Path("owned-output"), 12345, owner, identity)
|
||||
else:
|
||||
result = run_postgres_rbac(Path("owned-app"), Path("owned-output"), 12345, owner, identity)
|
||||
self.assertEqual(result["member_user_id"], 3)
|
||||
self.assertEqual(set(result["permissions"]), {"admin_read", "admin_permissions", "front_list"})
|
||||
self.assertNotIn("password", result)
|
||||
calls = client.request.call_args_list[2:]
|
||||
self.assertEqual([call.kwargs.get("expected", 200) for call in calls],
|
||||
[403, 403, 403, 200, 200, 200, 403, 200, 200, 403, 403])
|
||||
saves = [call for call in owner.request.call_args_list if call.args[1].endswith("/save")]
|
||||
self.assertEqual(owner.request.call_args_list[1].kwargs["site"], "front")
|
||||
self.assertEqual(saves[0].args[2]["id"], saves[2].args[2]["id"])
|
||||
self.assertNotEqual(saves[0].kwargs["site"], saves[2].kwargs["site"])
|
||||
client.close.assert_called_once()
|
||||
|
||||
def test_postgres_case_keeps_source_configuration_on_build_failure(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-source-") as temporary:
|
||||
directory = Path(temporary)
|
||||
source = directory / "project"
|
||||
source.mkdir()
|
||||
for name in ("config", "module", "test"):
|
||||
(source / name).mkdir()
|
||||
original = {
|
||||
"runtime": {"mode": "all"},
|
||||
"database": {"default": {
|
||||
"type": "postgres", "url": "postgres://fixture@127.0.0.1:12345/control",
|
||||
"tenant_database_prefix": "dever_test_owned",
|
||||
}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "original"}}},
|
||||
"sites": {}, "log": {"level": "info"},
|
||||
}
|
||||
setting = source / "config/setting.json"
|
||||
write_private_json(setting, original)
|
||||
before = setting.read_bytes()
|
||||
staged_paths = []
|
||||
|
||||
def fail_check(command, *_args, **_kwargs):
|
||||
staged = command[2]
|
||||
staged_paths.append(staged)
|
||||
self.assertNotEqual(staged, source)
|
||||
changed = json.loads((staged / "config/setting.json").read_text())
|
||||
self.assertEqual(changed["runtime"]["mode"], "api")
|
||||
self.assertNotEqual(changed["auth"]["providers"]["session"]["jwtSecret"], "original")
|
||||
raise RuntimeError("owned check failed")
|
||||
|
||||
output = directory / "result"
|
||||
with patch("cms.execute", side_effect=fail_check):
|
||||
with self.assertRaisesRegex(RuntimeError, "owned check failed"):
|
||||
run_postgres_case(source, output, directory / "compiler", articles=2)
|
||||
self.assertEqual(setting.read_bytes(), before)
|
||||
self.assertFalse((source / "cms-app").exists())
|
||||
self.assertTrue(staged_paths)
|
||||
self.assertFalse(staged_paths[0].exists())
|
||||
self.assertEqual(json.loads((output / "report.json").read_text())["status"], "failed")
|
||||
|
||||
def test_private_config_replacement_is_atomic_and_keeps_exclusive_creation(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-setting-") as temporary:
|
||||
directory = Path(temporary)
|
||||
setting = directory / "setting.json"
|
||||
write_private_json(setting, {"original": True})
|
||||
with self.assertRaises(FileExistsError):
|
||||
write_private_json(setting, {"overwrite": True})
|
||||
with self.assertRaises(TypeError):
|
||||
write_private_json(setting, {"invalid": object()}, replace=True)
|
||||
self.assertEqual(json.loads(setting.read_text()), {"original": True})
|
||||
self.assertEqual(list(directory.iterdir()), [setting])
|
||||
write_private_json(setting, {"postgres": True}, replace=True)
|
||||
self.assertEqual(json.loads(setting.read_text()), {"postgres": True})
|
||||
self.assertEqual(setting.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(list(directory.iterdir()), [setting])
|
||||
|
||||
def test_envelope_failure_cannot_count_as_publication(self):
|
||||
self.assertEqual(decode_response(b'{"code":0,"message":"ok","data":{"id":1}}', 200),
|
||||
{"id": 1})
|
||||
for document, status in (({"code": 0, "message": "ok", "data": {}}, 500),
|
||||
({"code": 409, "message": "conflict", "data": None}, 200),
|
||||
({"id": 1}, 200)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
decode_response(json.dumps(document).encode(), status)
|
||||
|
||||
def test_isolation_preserves_site_contract_without_mutating_input(self):
|
||||
source = {
|
||||
"runtime": {"mode": "all"}, "http": {"port": 8080},
|
||||
"database": {"default": {"type": "sqlite", "path": "old.db"}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "example", "verify": "user.account.verify"}}},
|
||||
"sites": {"admin": {"path": "admin", "auth": "session", "origin": "https://example.invalid"}},
|
||||
"log": {"level": "info"},
|
||||
}
|
||||
staged = isolated_settings(source, 12345)
|
||||
self.assertEqual(source["runtime"]["mode"], "all")
|
||||
self.assertEqual(source["database"]["default"]["path"], "old.db")
|
||||
self.assertEqual(staged["runtime"]["mode"], "api")
|
||||
self.assertEqual(staged["sites"]["admin"]["path"], "admin")
|
||||
self.assertEqual(staged["sites"]["admin"]["origin"], "http://127.0.0.1:12345")
|
||||
self.assertEqual(staged["sites"]["admin"]["hosts"], ["127.0.0.1"])
|
||||
self.assertNotEqual(staged["auth"]["providers"]["session"]["jwtSecret"], "example")
|
||||
self.assertEqual(staged["auth"]["providers"]["session"]["verify"], "user.account.verify")
|
||||
self.assertEqual(source["log"]["level"], "info")
|
||||
self.assertEqual(staged["log"]["level"], "error")
|
||||
source["database"]["default"]["type"] = "postgres"
|
||||
with self.assertRaises(RuntimeError):
|
||||
isolated_settings(source, 12345)
|
||||
|
||||
def test_postgres_isolation_preserves_explicit_control_database_and_prefix(self):
|
||||
source = {
|
||||
"runtime": {"mode": "all"},
|
||||
"database": {"default": {
|
||||
"type": "postgres", "url": "postgres://fixture@127.0.0.1:12345/control",
|
||||
"tenant_database_prefix": "dever_test_owned", "tls": "disabled",
|
||||
}},
|
||||
"auth": {"providers": {"session": {"jwtSecret": "example"}}},
|
||||
"sites": {"admin": {"origin": "https://example.invalid"}},
|
||||
"log": {"level": "info"},
|
||||
}
|
||||
staged = isolated_settings(source, 23456)
|
||||
self.assertEqual(staged["database"], source["database"])
|
||||
self.assertEqual(staged["http"], {"host": "127.0.0.1", "port": 23456})
|
||||
self.assertNotIn("path", staged["database"]["default"])
|
||||
source["database"]["default"].pop("tenant_database_prefix")
|
||||
with self.assertRaises(RuntimeError):
|
||||
isolated_settings(source, 23456)
|
||||
|
||||
def test_publish_workload_uses_tenant_and_title_parameters(self):
|
||||
identity = {"credential_id": 11, "user_id": 3, "tenant_id": 2}
|
||||
publication = {"slug": "bench-0", "title": "Tenant A 0 published",
|
||||
"body": "Published body"}
|
||||
client = SimpleNamespace(request=Mock(side_effect=[
|
||||
{"account": {"id": 3}, "tenant_id": 2, "site": "admin"},
|
||||
{"account": {"id": 3}, "tenant_id": 2, "site": "front"},
|
||||
{"id": 1, "slug": "bench-0", "title": "Tenant A 0", "version": 1},
|
||||
{"id": 7, "article_id": 1, "title": "Tenant A 0 published", "version": 2},
|
||||
publication, {"total": 1, "items": [publication]}, None,
|
||||
]))
|
||||
process = SimpleNamespace(sample=Mock())
|
||||
contract = publish_workload(client, process, identity, "fixture-password", 1,
|
||||
tenant_key="tenant_a", title_prefix="Tenant A")
|
||||
self.assertEqual(contract, {"articles": 1, "titles": ["Tenant A 0 published"],
|
||||
"duplicate_publish_status": 409})
|
||||
for call in client.request.call_args_list[:2]:
|
||||
self.assertEqual(call.args[2]["tenant"], "tenant_a")
|
||||
revision = client.request.call_args_list[3]
|
||||
self.assertEqual(revision.args[:2], ("POST", "/content/revision/admin/manage/create"))
|
||||
self.assertEqual(revision.args[2]["expected_version"], 1)
|
||||
self.assertEqual(revision.args[2]["selected_article_id"], 1)
|
||||
self.assertEqual(process.sample.call_count, 1)
|
||||
|
||||
def test_small_workload_reports_samples_without_implying_p99(self):
|
||||
self.assertEqual(latency_summary({"publish": [4, 1, 3]}),
|
||||
{"publish": {"samples": 3, "p50_ms": 3, "max_ms": 4}})
|
||||
|
||||
def test_command_timeout_retains_output_and_reaps_its_owned_process(self):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-cms-timeout-") as temporary:
|
||||
directory = Path(temporary)
|
||||
command = [sys.executable, "-u", "-c",
|
||||
"import time,sys; print('starting'); print('diagnostic',file=sys.stderr); time.sleep(10)"]
|
||||
with self.assertRaises(subprocess.TimeoutExpired):
|
||||
execute(command, directory, "timeout", timeout=0.2)
|
||||
self.assertEqual((directory / "timeout.stdout.log").read_text(), "starting\n")
|
||||
self.assertEqual((directory / "timeout.stderr.log").read_text(), "diagnostic\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""用独立 Python 对端检查 Rust 长连接客户端的正文与生命周期判定。"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socketserver
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from live import parse_progress, phase_sequence, validate_result
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
@contextmanager
|
||||
def serving(server):
|
||||
with server:
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1]
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join(timeout=2)
|
||||
if thread.is_alive():
|
||||
raise RuntimeError("owned test listener did not stop")
|
||||
|
||||
|
||||
class Echo(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
self.request.settimeout(2)
|
||||
while payload := self.request.recv(4096):
|
||||
response = b"!" * len(payload) if self.server.wrong_body else payload
|
||||
self.request.sendall(response)
|
||||
if self.server.wrong_body:
|
||||
return
|
||||
|
||||
|
||||
class InvalidEvent(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
body = b"event: live\nid: 1\ndata: wrong\n\n"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# The client aborts sibling sessions after the first deliberate mismatch.
|
||||
return
|
||||
|
||||
|
||||
class SilentEvent(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"event: live\nid: 1\ndata: ready\n\n")
|
||||
self.wfile.flush()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
class LiveClientTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory(prefix="dever-live-client-")
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.executable = stage_peer(configured_peer("live_peer"), Path(directory.name), {"workers": 1})
|
||||
|
||||
def execute(self, protocol, port, path):
|
||||
scheme = "http" if protocol == "sse" else protocol
|
||||
return subprocess.run([self.executable, protocol, f"{scheme}://127.0.0.1:{port}{path}", "2", "2", "300", "1000"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
|
||||
def test_tcp_reconnect_and_exact_echo(self):
|
||||
listener = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Echo)
|
||||
listener.wrong_body = False
|
||||
with serving(listener) as port:
|
||||
result = self.execute("tcp", port, "/")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
phases, counts = parse_progress(result.stdout, 2)
|
||||
self.assertEqual(phases, phase_sequence(2))
|
||||
validate_result(counts, "tcp", 2, 2)
|
||||
|
||||
def test_tcp_wrong_body_cannot_be_counted_as_held(self):
|
||||
listener = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Echo)
|
||||
listener.wrong_body = True
|
||||
with serving(listener) as port:
|
||||
result = self.execute("tcp", port, "/")
|
||||
self.assert_failure(result)
|
||||
|
||||
def test_sse_wrong_event_cannot_pass_connection_readiness(self):
|
||||
with serving(ThreadingHTTPServer(("127.0.0.1", 0), InvalidEvent)) as port:
|
||||
result = self.execute("sse", port, "/events")
|
||||
self.assert_failure(result)
|
||||
|
||||
def test_sse_silent_hold_cannot_pass_heartbeat_validation(self):
|
||||
with serving(ThreadingHTTPServer(("127.0.0.1", 0), SilentEvent)) as port:
|
||||
result = self.execute("sse", port, "/events")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
counts = json.loads(result.stdout.splitlines()[-1])
|
||||
self.assertGreater(counts["errors"], 0)
|
||||
self.assertIn("PHASE|0|connected\n", result.stdout)
|
||||
self.assertNotIn("PHASE|0|closing\n", result.stdout)
|
||||
|
||||
def assert_failure(self, result):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
counts = json.loads(result.stdout.splitlines()[-1])
|
||||
self.assertGreater(counts["errors"], 0)
|
||||
self.assertNotIn("PHASE|0|connected\n", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""用独立的 Python HTTP 对端验证负载计数;只占用自有随机回环端口。"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from process import Process
|
||||
from run import validate_load
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TLS = ROOT / "test/dever-tests/fixtures/tls"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def peer(body):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
worker = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
|
||||
worker.start()
|
||||
try:
|
||||
yield server.server_port
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=2)
|
||||
if worker.is_alive():
|
||||
raise RuntimeError("owned HTTP peer did not stop")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def benchmark_service(executable, mode):
|
||||
settings = {"workers": 1, "connections": 4, "task_capacity": 128,
|
||||
"timeout_ms": 1000, "lifetime_ms": 5000, "http2_streams": 16,
|
||||
"http2_stream_window_bytes": 65535, "http2_connection_window_bytes": 262144,
|
||||
"cert_path": str(TLS / "server.pem"), "key_path": str(TLS / "server-key.pem")}
|
||||
with tempfile.TemporaryDirectory(prefix="dever-h2-bench-") as directory:
|
||||
with Process([executable, mode], Path(directory) / "server", peer_settings=settings) as process:
|
||||
yield process.ready()
|
||||
process.check()
|
||||
|
||||
|
||||
class NetworkMeasurementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory(prefix="dever-network-client-")
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.executable = stage_peer(configured_peer("network_peer"), Path(directory.name), {"workers": 1})
|
||||
|
||||
def load(self, port):
|
||||
executable = self.executable
|
||||
output = subprocess.run([str(executable), "load", "h1", f"http://localhost:{port}",
|
||||
"/plain", "20", "0.2", "1", "1", "500", "true", "-"],
|
||||
capture_output=True, text=True, timeout=5, check=True)
|
||||
self.assertEqual(output.stderr, "")
|
||||
result = json.loads(output.stdout)
|
||||
validate_load(result)
|
||||
self.assertEqual(result["scheduled"], 4)
|
||||
self.assertGreaterEqual(result["elapsed_seconds"], 0.2)
|
||||
return result
|
||||
|
||||
def test_success_requires_exact_body_and_full_window(self):
|
||||
with peer(b"hello") as port:
|
||||
result = self.load(port)
|
||||
self.assertEqual(result["succeeded"], 4)
|
||||
self.assertEqual(result["errors"], 0)
|
||||
self.assertEqual(result["dropped"], 0)
|
||||
self.assertEqual(result["latency_samples"], 4)
|
||||
self.assertEqual(result["http_version"], "h1")
|
||||
self.assertEqual(result["physical_connections"], 1)
|
||||
self.assertEqual(result["streams_per_connection"], 1)
|
||||
self.assertEqual(result["request_slots"], 1)
|
||||
|
||||
def test_wrong_body_is_an_error_with_no_success_latency(self):
|
||||
with peer(b"wrong") as port:
|
||||
result = self.load(port)
|
||||
self.assertEqual(result["succeeded"], 0)
|
||||
self.assertEqual(result["errors"], 4)
|
||||
self.assertEqual(result["latency_samples"], 0)
|
||||
self.assertIsNone(result["latency_ms"])
|
||||
|
||||
def test_http2_uses_one_physical_connection_for_sixteen_request_slots(self):
|
||||
executable = self.executable
|
||||
for mode, transport in (("runtime-http2", "http"), ("hyper-http2", "http"),
|
||||
("runtime-https2", "https"), ("hyper-https2", "https")):
|
||||
with self.subTest(mode=mode), benchmark_service(executable, mode) as port:
|
||||
output = subprocess.run(
|
||||
[str(executable), "load", "h2", f"{transport}://localhost:{port}",
|
||||
"/bytes", "200", "0.1", "1", "16", "1000", "true", str(TLS / "root.pem")],
|
||||
capture_output=True, text=True, timeout=5, check=True)
|
||||
self.assertEqual(output.stderr, "")
|
||||
result = json.loads(output.stdout)
|
||||
validate_load(result)
|
||||
self.assertEqual(result["errors"], 0)
|
||||
self.assertGreater(result["succeeded"], 0)
|
||||
self.assertEqual(result["physical_connections"], 1)
|
||||
self.assertEqual(result["streams_per_connection"], 16)
|
||||
self.assertEqual(result["request_slots"], 16)
|
||||
self.assertEqual(result["successful_handshakes"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""只执行 peer 的配置检查入口,不创建网络或连接数据库。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from settings import configured_peer, stage_peer
|
||||
|
||||
|
||||
class PeerSettingsTests(unittest.TestCase):
|
||||
def check(self, peer, document):
|
||||
with tempfile.TemporaryDirectory(prefix="dever-peer-config-test-") as temporary:
|
||||
root = Path(temporary)
|
||||
executable = stage_peer(configured_peer(peer), root, {})
|
||||
setting = root / "config/setting.json"
|
||||
if document is None:
|
||||
setting.unlink()
|
||||
else:
|
||||
setting.write_text(json.dumps(document))
|
||||
# 不在 executable 目录启动,验证读取位置不受工作目录影响。
|
||||
return subprocess.run([executable, "check-config"], cwd=root.parent,
|
||||
capture_output=True, text=True, timeout=5)
|
||||
|
||||
def test_both_peers_use_the_external_integer_setting(self):
|
||||
for peer in ("network_peer", "live_peer"):
|
||||
for workers in (1, 4, 64):
|
||||
with self.subTest(peer=peer, workers=workers):
|
||||
output = self.check(peer, {"benchmark": {"workers": workers}})
|
||||
self.assertEqual(output.returncode, 0, output.stderr)
|
||||
self.assertEqual(json.loads(output.stdout), {"workers": workers})
|
||||
|
||||
def test_both_peers_reject_missing_malformed_and_out_of_range_configuration(self):
|
||||
invalid = [None, {}, [], {"benchmark": []}, {"benchmark": {"unknown": 1}}]
|
||||
invalid.extend({"benchmark": {"workers": value}} for value in (0, 65, -1, 1.5, True, "4", None))
|
||||
for peer in ("network_peer", "live_peer"):
|
||||
for document in invalid:
|
||||
with self.subTest(peer=peer, document=document):
|
||||
output = self.check(peer, document)
|
||||
self.assertNotEqual(output.returncode, 0)
|
||||
self.assertEqual(output.stdout, "")
|
||||
self.assertTrue(output.stderr.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
||||
//! 已收集参数直接交给解析器,保持子命令、校验与透传行为。
|
||||
|
||||
use super::parse;
|
||||
|
||||
#[test]
|
||||
fn supplied_arguments_preserve_owned_command_and_forwarded_values() {
|
||||
let arguments = ["run", "project", "--", "--tenant", "7", "demo.value", "{}"].map(String::from);
|
||||
let command = arguments[0].as_ptr();
|
||||
let parsed = parse(arguments.into_iter()).unwrap().unwrap();
|
||||
assert_eq!(parsed.command.as_ptr(), command);
|
||||
assert_eq!(parsed.source, std::path::Path::new("project"));
|
||||
assert_eq!(parsed.arguments, ["--tenant", "7", "demo.value", "{}"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplied_arguments_keep_help_tenant_and_build_validation() {
|
||||
for arguments in [vec![], vec!["--help"], vec!["--version"]] {
|
||||
assert!(
|
||||
parse(arguments.into_iter().map(String::from))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let tenant = parse(
|
||||
["tenant", "migrate", "project", "7"]
|
||||
.map(String::from)
|
||||
.into_iter(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(tenant.command, "run");
|
||||
assert_eq!(
|
||||
tenant.arguments,
|
||||
["--tenant", "7", "dever.tenant.migrate", "null"]
|
||||
);
|
||||
for arguments in [
|
||||
vec!["build", "source"],
|
||||
vec!["build", "source", "--output", "a", "--output", "b"],
|
||||
vec!["check", "source", "--output", "a"],
|
||||
vec!["run", "source", "--target", "linux-x86_64"],
|
||||
vec!["run", "source", "--entry"],
|
||||
vec!["unknown"],
|
||||
] {
|
||||
assert!(parse(arguments.into_iter().map(String::from)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use dever::compiler::check;
|
||||
use dever::syntax::source::Source;
|
||||
|
||||
#[path = "compiler/application/mod.rs"]
|
||||
mod application;
|
||||
#[path = "compiler/auth/mod.rs"]
|
||||
mod auth;
|
||||
#[path = "compiler/database/mod.rs"]
|
||||
mod database;
|
||||
#[path = "compiler/job/mod.rs"]
|
||||
mod job;
|
||||
|
||||
#[path = "compiler/api.rs"]
|
||||
mod api;
|
||||
|
||||
#[path = "compiler/bytes.rs"]
|
||||
mod bytes;
|
||||
#[path = "compiler/calendar.rs"]
|
||||
mod calendar;
|
||||
#[path = "compiler/choice.rs"]
|
||||
mod choice;
|
||||
#[path = "compiler/collection.rs"]
|
||||
mod collection;
|
||||
#[path = "compiler/crypto.rs"]
|
||||
mod crypto;
|
||||
#[path = "compiler/failure.rs"]
|
||||
mod failure;
|
||||
#[path = "compiler/format.rs"]
|
||||
mod format;
|
||||
#[path = "compiler/handler.rs"]
|
||||
mod handler;
|
||||
#[path = "compiler/http.rs"]
|
||||
mod http;
|
||||
#[path = "compiler/id.rs"]
|
||||
mod id;
|
||||
#[path = "compiler/io/mod.rs"]
|
||||
mod io;
|
||||
#[path = "compiler/json.rs"]
|
||||
mod json;
|
||||
#[path = "compiler/net.rs"]
|
||||
mod net;
|
||||
#[path = "compiler/numeric/mod.rs"]
|
||||
mod numeric;
|
||||
#[path = "compiler/package.rs"]
|
||||
mod package;
|
||||
#[path = "compiler/port/mod.rs"]
|
||||
mod port;
|
||||
#[path = "compiler/standard.rs"]
|
||||
mod standard;
|
||||
#[path = "compiler/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "compiler/task/mod.rs"]
|
||||
mod task;
|
||||
#[path = "compiler/tenant.rs"]
|
||||
mod tenant;
|
||||
#[path = "compiler/upload.rs"]
|
||||
mod upload;
|
||||
#[path = "compiler/usage.rs"]
|
||||
mod usage;
|
||||
#[path = "compiler/uuid.rs"]
|
||||
mod uuid;
|
||||
|
||||
fn checked(
|
||||
text: &str,
|
||||
) -> Result<dever::compiler::model::Program, dever::syntax::diagnostic::Diagnostic> {
|
||||
let source = Source::new(0, "case.dever", text.into());
|
||||
check::check(vec![dever::syntax::parse(&source)?], &["main".into()])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overlap_gaps_and_recursion() {
|
||||
assert_eq!(
|
||||
checked("pick(value: Int < 0) (answer: Int) { answer = 1 }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C009"
|
||||
);
|
||||
assert_eq!(checked("pick(value: Int <= 0) (answer: Int) { answer = 1 }\npick(value: Int >= 0) (answer: Int) { answer = 2 }").unwrap_err().code, "C009");
|
||||
assert_eq!(
|
||||
checked("cycle() (answer: Int) { answer = cycle() }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert!(checked("pick(value: Int < 0) (answer: Int) { answer = 1 }\npick(value: other) (answer: Int) { answer = 2 }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_uninitialized_outputs_and_overflow() {
|
||||
assert!(checked("main() (answer: Int) { value = answer }").is_err());
|
||||
assert!(checked("main() (answer: Int) {}").is_err());
|
||||
assert!(checked("main() (answer: Int) { answer = 9223372036854775807 + 1 }").is_err());
|
||||
assert!(checked("main() (answer: Int) { answer = -9223372036854775808 }").is_ok());
|
||||
assert!(checked("main() (answer: Int) { answer = 9223372036854775806 + (1 + 1) }").is_err());
|
||||
assert!(checked("main() (answer: Decimal) { answer = 1.0 / (2.0 - 2.0) }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_record_and_list_types() {
|
||||
assert!(checked("type Row { name: Text\n count: Int }\nmain() (answer: Row) { answer = Row { name = \"雪\"\n count = sum([1, 2, 3]) } }").is_ok());
|
||||
assert!(
|
||||
checked("type Row { count: Int }\nmain() (answer: Row) { answer = Row { wrong = 1 } }")
|
||||
.is_err()
|
||||
);
|
||||
assert!(checked("main() (answer: List<Int>) { answer = [1, true] }").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_original_naming_constraints() {
|
||||
assert_eq!(checked("read_value() () {}").unwrap_err().code, "C010");
|
||||
assert_eq!(checked("Wrong() () {}").unwrap_err().code, "C010");
|
||||
assert_eq!(checked("type bad { count: Int }").unwrap_err().code, "C010");
|
||||
assert_eq!(checked("main() () { Bad = 1 }").unwrap_err().code, "C010");
|
||||
assert!(checked("type HttpClient { user_id: Int }\nreadValue(input_value: Int) (output_value: Int) { output_value = input_value }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit compiler cost probe: fixed 15 rounds, run without concurrent CPU work"]
|
||||
fn compiler_reduction_costs() {
|
||||
use dever::compiler::project::Project;
|
||||
use std::{hint::black_box, path::Path, time::Instant};
|
||||
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../test/rust/fixtures");
|
||||
for (label, count) in [("medium", 32), ("large", 512)] {
|
||||
let mut text = "copyText(value: Text) (answer: Text) { answer = value }\n".to_owned();
|
||||
for index in 0..count {
|
||||
let mut body =
|
||||
format!("case{index}(value: Text) (answer: Text) {{\nanswer = copyText(value)\n");
|
||||
for _ in 0..15 {
|
||||
body.push_str("answer = copyText(answer)\n");
|
||||
}
|
||||
body.push_str("}\n");
|
||||
text.push_str(&body);
|
||||
}
|
||||
let source = Source::new(0, "cost.dever", text);
|
||||
let mut project = Project::load(&fixtures.join("core.dever")).unwrap();
|
||||
project.sources[0] = Source::new(0, project.sources[0].path.clone(), source.text.clone());
|
||||
let packages = vec!["main".to_owned()];
|
||||
let expected_project_functions = project.check().unwrap().functions.len();
|
||||
assert!(expected_project_functions > count);
|
||||
println!(
|
||||
"audit_compiler input={label} functions={} calls={} bytes={} project_bytes={} rounds=15 profile=test",
|
||||
count + 1,
|
||||
count * 16,
|
||||
source.text.len(),
|
||||
project.sources[0].text.len()
|
||||
);
|
||||
for round in 1..=15 {
|
||||
let started = Instant::now();
|
||||
let module = dever::syntax::parse(black_box(&source)).unwrap();
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(module.declarations.len(), count + 1);
|
||||
println!("audit_compiler case={label} phase=parse round={round} ns={elapsed}");
|
||||
|
||||
let started = Instant::now();
|
||||
let program = check::check(vec![module], black_box(&packages)).unwrap();
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(program.functions.len(), count + 1);
|
||||
black_box(&program);
|
||||
println!("audit_compiler case={label} phase=check round={round} ns={elapsed}");
|
||||
drop(program);
|
||||
|
||||
let started = Instant::now();
|
||||
let program = black_box(&project).check().unwrap();
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
assert_eq!(program.functions.len(), expected_project_functions);
|
||||
black_box(&program);
|
||||
println!("audit_compiler case={label} phase=project round={round} ns={elapsed}");
|
||||
}
|
||||
}
|
||||
let project = Project::load(&fixtures.join("tenant")).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
let roots = program.entries().collect::<Vec<_>>();
|
||||
assert!(!roots.is_empty());
|
||||
println!(
|
||||
"audit_compiler input=tenant roots={} repeats=64 rounds=15",
|
||||
roots.len()
|
||||
);
|
||||
for round in 1..=15 {
|
||||
let started = Instant::now();
|
||||
let mut required = 0usize;
|
||||
for _ in 0..64 {
|
||||
for root in &roots {
|
||||
let access = black_box(&program).tenant_access(black_box(*root));
|
||||
required += usize::from(access.required);
|
||||
black_box(access);
|
||||
}
|
||||
}
|
||||
let elapsed = started.elapsed().as_nanos();
|
||||
println!(
|
||||
"audit_compiler case=tenant phase=access round={round} ns={elapsed} required={required}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::{checked, package::packages};
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_uses_declared_contracts_not_private_bodies_or_inferred_constants() {
|
||||
let snapshot = |body: &str| {
|
||||
checked(&format!("public value(input: Int) (answer: Int) {{ answer = helper(input) }}\nhelper(input: Int) (answer: Int) {{ {body} }}")).unwrap().api_snapshot()
|
||||
};
|
||||
let first = snapshot("answer = 1");
|
||||
assert_eq!(
|
||||
first,
|
||||
"dever-api 5\nfunction main.value(Int) (answer: Int []) pure=false recover=None effects=[] errors=[] recoveries={}\n"
|
||||
);
|
||||
assert_eq!(first, snapshot("answer = input + 2"));
|
||||
let constrained =
|
||||
checked("public value(input: Int) (answer: Int >= 0) { answer = 1 }").unwrap();
|
||||
assert!(
|
||||
constrained
|
||||
.api_snapshot()
|
||||
.contains("answer: Int [0..9223372036854775807]")
|
||||
);
|
||||
let difference = constrained.check_api(&first).unwrap_err();
|
||||
assert!(
|
||||
difference.contains("- function main.value")
|
||||
&& difference.contains("+ function main.value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_tracks_public_shape_construction_failure_and_transitive_recovery() {
|
||||
let snapshot = |key: &str| {
|
||||
checked(&format!(
|
||||
"public type Session {{ private key: {key}\nlabel: Text }}"
|
||||
))
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
let private = snapshot("Int");
|
||||
assert_eq!(private, snapshot("Text"));
|
||||
assert_eq!(
|
||||
private,
|
||||
"dever-api 5\ntype main.Session record construct=private {label: Text []}\n"
|
||||
);
|
||||
let exposed = checked("public type Session { key: Int\nlabel: Text }").unwrap();
|
||||
assert!(
|
||||
exposed
|
||||
.check_api(&private)
|
||||
.unwrap_err()
|
||||
.contains("construct=public")
|
||||
);
|
||||
let failing = checked("public type Problem { error Bad(message: Text) }\npublic value() () { fail(Problem.Bad(\"bad\")) }").unwrap();
|
||||
let baseline = failing.api_snapshot();
|
||||
assert!(baseline.contains("errors=[main.Problem.Bad]"));
|
||||
assert!(baseline.contains("type main.Problem error Bad(message: Text [])"));
|
||||
let snapshot = |reason: &str| {
|
||||
checked(&format!(
|
||||
"public value() () {{ helper() }}\nhelper() () recover(\"{reason}\") {{}}"
|
||||
))
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
assert_ne!(snapshot("use zero"), snapshot("use one"));
|
||||
assert!(snapshot("use zero").contains("recoveries={\"use zero\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_snapshot_is_stable_across_app_topics_and_binds_command_inputs() {
|
||||
let snapshot = |topic: &str, input: &str| {
|
||||
packages(&[
|
||||
(
|
||||
topic,
|
||||
&format!("value({input}: Int) (answer: Int) {{ answer = {input} }}"),
|
||||
),
|
||||
("shop.account.api", "cmd value = app.value"),
|
||||
])
|
||||
.unwrap()
|
||||
.api_snapshot()
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot("shop.account.app", "input"),
|
||||
snapshot("shop.account.app.read", "input")
|
||||
);
|
||||
assert_ne!(
|
||||
snapshot("shop.account.app", "input"),
|
||||
snapshot("shop.account.app", "renamed")
|
||||
);
|
||||
assert!(
|
||||
snapshot("shop.account.app", "input")
|
||||
.contains("cmd shop.account.value = shop.account.value(input: Int [])")
|
||||
);
|
||||
let program = checked("public apply(action: handler(input: Int) (answer: Int), input: Int) (answer: Int) { answer = action(input) }").unwrap();
|
||||
assert!(
|
||||
program
|
||||
.api_snapshot()
|
||||
.contains("function main.apply(handler(Int [])(answer: Int []), Int)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_handler_snapshot_keeps_constraints_and_ignores_specializations() {
|
||||
let source = "public apply(action: handler(input: Int >= 0) (answer: Int >= 0)) (answer: Int >= 0) { answer = action(1) }";
|
||||
let original = checked(source).unwrap().api_snapshot();
|
||||
assert!(
|
||||
original.contains(
|
||||
"handler(Int [0..9223372036854775807])(answer: Int [0..9223372036854775807])"
|
||||
)
|
||||
);
|
||||
let called = checked(&format!("{source}\nnonnegative(input: Int) (answer: Int >= 0) {{ answer = 0 }}\nmain() (answer: Int) {{ answer = apply(nonnegative) }}")).unwrap();
|
||||
assert_eq!(original, called.api_snapshot());
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use super::package::packages;
|
||||
|
||||
#[test]
|
||||
fn fixed_wire_budget_proof_retains_dynamic_checks_at_schema_limits() {
|
||||
for (levels, fields, input_fixed, output_fixed) in [
|
||||
(63, 1, true, true),
|
||||
(64, 1, false, false),
|
||||
(14, 2, true, true),
|
||||
(15, 2, true, false),
|
||||
(16, 2, false, false),
|
||||
] {
|
||||
let mut app = String::new();
|
||||
let mut kind = "Int".to_owned();
|
||||
for level in 0..levels {
|
||||
let members = if fields == 1 {
|
||||
format!("value: {kind}")
|
||||
} else {
|
||||
format!("first: {kind}\nsecond: {kind}")
|
||||
};
|
||||
app.push_str(&format!("type Layer{level} {{ {members} }}\n"));
|
||||
kind = format!("Layer{level}");
|
||||
}
|
||||
app.push_str(&format!(
|
||||
"run(value: {kind}) (answer: {kind}) {{ answer = value }}"
|
||||
));
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::decode_fixed(input)"),
|
||||
input_fixed,
|
||||
"{levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.contains("input.decode(dever_runtime::wire::decode_fixed)"),
|
||||
input_fixed,
|
||||
"HTTP {levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.matches("wire::response_fixed(&result)").count(),
|
||||
usize::from(output_fixed),
|
||||
"{levels}/{fields}"
|
||||
);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::response_fixed_bytes(&result)"),
|
||||
output_fixed,
|
||||
"HTTP {levels}/{fields}"
|
||||
);
|
||||
}
|
||||
for (kind, decoder, encoder) in [
|
||||
("List<Int>", "decode_shaped", "response"),
|
||||
("Json", "decode_json", "response_with"),
|
||||
("List<Json>", "decode", "response_with"),
|
||||
("List<Text>?", "decode_shaped", "response"),
|
||||
] {
|
||||
let app = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
if decoder == "decode_json" {
|
||||
assert!(emitted.contains("let args: Args0 = decode_args0(input)?;"));
|
||||
assert!(emitted.contains("input.decode(decode_httpargs0)"));
|
||||
assert_eq!(emitted.matches("wire::decode_json(input,").count(), 2);
|
||||
assert_eq!(
|
||||
emitted.contains("wire::decode(input)"),
|
||||
kind == "List<Json>"
|
||||
);
|
||||
} else {
|
||||
assert!(!emitted.contains("wire::decode_json("));
|
||||
assert!(
|
||||
emitted.contains(&format!("wire::{decoder}(input)")),
|
||||
"{kind}"
|
||||
);
|
||||
assert!(
|
||||
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
assert!(emitted.contains(&format!("wire::{encoder}(")), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_budget_includes_fixed_records_and_optional_json() {
|
||||
let program = packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
"type Row { payload: Json?\nnote: Text? }\nrun(row: Row?, other: Json, label: Text) (answer: Row?, extra: Json, name: Text) { answer = row\nextra = other\nname = label }",
|
||||
),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert_eq!(generated.matches("wire::decode_json(input,").count(), 2);
|
||||
assert!(generated.contains("budget.open()?; budget.value(3)?;"));
|
||||
assert!(generated.contains("budget.open()?; budget.value(2)?;"));
|
||||
assert!(generated.contains("if let Some(item) = (&(args).field0).as_ref()"));
|
||||
assert!(generated.contains("budget.json(item)?;"));
|
||||
assert!(generated.contains("budget.json(&(args).field1)?;"));
|
||||
assert!(!generated.contains("Json::from_validated_raw"));
|
||||
assert!(!generated.contains("Json::deserialize_validated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_input_with_any_typed_list_keeps_whole_input_validation() {
|
||||
for kind in ["List<Int>", "List<Json>", "List<Json?>?"] {
|
||||
for nested in [false, true] {
|
||||
let (definition, kind) = if nested {
|
||||
(format!("type Row {{ values: {kind} }}\n"), "Row?")
|
||||
} else {
|
||||
(String::new(), kind)
|
||||
};
|
||||
let app = format!(
|
||||
"{definition}run(payload: Json, other: {kind}) (answer: Json, extra: {kind}) {{ answer = payload\nextra = other }}"
|
||||
);
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(generated.contains("let args: Args0 = dever_runtime::wire::decode(input)?;"));
|
||||
assert!(generated.contains("input.decode(dever_runtime::wire::decode)"));
|
||||
assert!(!generated.contains("wire::decode_json("));
|
||||
assert!(!generated.contains("budget."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_wire_depth_is_proved_before_skipping_validation() {
|
||||
for (levels, decoder) in [(63, "decode_shaped"), (64, "decode")] {
|
||||
let kind = format!("{}Int{}", "List<".repeat(levels), ">".repeat(levels));
|
||||
let app = format!("run(value: {kind}) (answer: Int) {{ answer = length(value) }}");
|
||||
let program = packages(&[
|
||||
("shop.account.app", &app),
|
||||
(
|
||||
"shop.account.api",
|
||||
"cmd run = app.run\npublic post run = app.run",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let emitted = dever::compiler::emit::application::generate(&program);
|
||||
assert!(
|
||||
emitted.contains(&format!("wire::{decoder}(input)")),
|
||||
"{levels}"
|
||||
);
|
||||
assert!(
|
||||
emitted.contains(&format!("input.decode(dever_runtime::wire::{decoder})")),
|
||||
"{levels}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_entries_bind_typed_apps_and_keep_protected_entries_closed() {
|
||||
assert!(packages(&[("main", "get(value: Int) (answer: Int) { answer = value }")]).is_ok());
|
||||
let app = "echo(value: Int) (answer: Int) { answer = value }";
|
||||
let program = packages(&[
|
||||
("shop.account.app", app),
|
||||
(
|
||||
"shop.account.api.admin.session",
|
||||
"public get status = app.echo",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(program.http[0].path, "/shop/account/admin/session/status");
|
||||
assert_eq!(program.http[0].topic, "admin/session");
|
||||
assert_eq!(program.http[0].method, "GET");
|
||||
for (declaration, code) in [
|
||||
("get status = app.echo", "C014"),
|
||||
("authenticated get status = app.echo", "C014"),
|
||||
(
|
||||
"public get status = app.echo\npublic get status = app.echo",
|
||||
"C002",
|
||||
),
|
||||
("public get status = other.echo", "C006"),
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.app", app), ("shop.account.api", declaration)])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
code
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
(
|
||||
"shop.account.app",
|
||||
"echo(value: List<Int>) (answer: List<Int>) { answer = value }"
|
||||
),
|
||||
("shop.account.api", "public get status = app.echo")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_wire_rejects_nested_constrained_records() {
|
||||
for kind in ["Port", "List<Port?>", "Envelope"] {
|
||||
let app = format!(
|
||||
"type Port {{ value: Int >= 1 }}\ntype Envelope {{ ports: List<Port?> }}\nrun(value: {kind}) (answer: {kind}) {{ answer = value }}"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C011"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_topics_share_app_contracts_and_domain_dependencies() {
|
||||
let program = packages(&[
|
||||
("shop.account.app.read", "type User { name: Text }\nread(name: Text) (answer: User) { answer = User { name = domain.label(name) } }"),
|
||||
("shop.account.app.write", "echo(user: User) (answer: User) { answer = user }"),
|
||||
("shop.account.domain", "label(name: Text) (answer: Text) { answer = name + \"!\" }"),
|
||||
("shop.account.api", "cmd read = app.read\ncmd echo = app.echo"),
|
||||
]).unwrap();
|
||||
assert_eq!(program.commands.len(), 2);
|
||||
assert_eq!(program.commands[0].name, "shop.account.read");
|
||||
assert!(
|
||||
program
|
||||
.functions
|
||||
.iter()
|
||||
.any(|function| function.signature.name == "label")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_app_fields_share_topic_ownership_and_reject_other_domains() {
|
||||
let declaration = "type Session { private key: Text\nlabel: Text }";
|
||||
assert!(packages(&[
|
||||
("shop.account.app.types", declaration),
|
||||
("shop.account.app.write", "make(key: Text) (answer: Session) { answer = Session { key = key, label = \"name\" } }"),
|
||||
("shop.account.app.read", "read(value: Session) (answer: Text) { value.key = value.key + \"!\"\nanswer = value.key }"),
|
||||
]).is_ok());
|
||||
for operation in [
|
||||
"answer = value.key",
|
||||
"value.key = \"changed\"\nanswer = value.label",
|
||||
"value = shop.account.Session { key = \"hidden\", label = \"name\" }\nanswer = value.label",
|
||||
] {
|
||||
let caller = format!("read(value: shop.account.Session) (answer: Text) {{ {operation} }}");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", declaration),
|
||||
("shop.other.app", &caller)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_wire_rejects_nested_private_records_in_both_directions() {
|
||||
for kind in ["Session", "List<Session?>", "Envelope"] {
|
||||
for function in [
|
||||
format!("run(value: {kind}) () {{}}"),
|
||||
format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}"),
|
||||
] {
|
||||
let app = format!(
|
||||
"type Session {{ private key: Text }}\ntype Envelope {{ sessions: List<Session?> }}\n{function}"
|
||||
);
|
||||
let error = packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C006", "{error:?}");
|
||||
assert!(error.message.contains("wire boundary"), "{error:?}");
|
||||
}
|
||||
}
|
||||
let app = "type Session { private key: Text }\nrun() (answer: Session) { answer = Session { key = \"hidden\" } }";
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_role_access_cannot_be_bypassed_with_qualified_names() {
|
||||
for (source, target) in [
|
||||
("shop.first.domain", "shop.second.run"),
|
||||
("shop.first.domain", "app.run"),
|
||||
("shop.first.app", "shop.second.domain.hidden"),
|
||||
] {
|
||||
let caller = format!("entry() (answer: Int) {{ answer = {target}() }}");
|
||||
let result = packages(&[
|
||||
(source, &caller),
|
||||
("shop.first.app.extra", "run() (answer: Int) { answer = 1 }"),
|
||||
("shop.second.app", "run() (answer: Int) { answer = 2 }"),
|
||||
(
|
||||
"shop.second.domain",
|
||||
"hidden() (answer: Int) { answer = 3 }",
|
||||
),
|
||||
]);
|
||||
assert_eq!(result.unwrap_err().code, "C006", "{source}: {target}");
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "type Visible { secret: domain.Hidden }"),
|
||||
("shop.account.domain", "type Hidden { value: Int }"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.app", "public run() () {}")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("shop.account.api", "run() () {}")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "cmd run = app.run")]).unwrap_err().code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_secret_inputs_use_the_existing_input_codec_without_output_access() {
|
||||
for kind in ["Secret", "Secret?", "List<Secret>"] {
|
||||
let app = format!("run(value: {kind}) (answer: Bool) {{ answer = true }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
let leaking = format!("run(value: {kind}) (answer: {kind}) {{ answer = value }}");
|
||||
assert!(
|
||||
packages(&[
|
||||
("shop.account.app", &leaking),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_reject_ambiguous_targets_and_invalid_wire_contracts() {
|
||||
for app in [
|
||||
"run() () {}\nrun(value: Int) () {}",
|
||||
"run(action: handler() ()) () { action() }",
|
||||
"type State { Ready(value: Int) }\nrun(value: State) (answer: State) { answer = value }",
|
||||
"type State { Ready\n error Failed }\nrun(value: State) (answer: State) { answer = value }",
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", app),
|
||||
("shop.account.api", "cmd run = app.run")
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C005"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}"),
|
||||
("shop.account.api", "cmd run = app.run\ncmd run = app.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C002"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}"),
|
||||
("shop.account.api", "cmd run = shop.account.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_entry_reachability_and_domain_cycles_are_checked() {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app", "run() () {}\nunused() () {}"),
|
||||
("shop.account.api", "cmd run = app.run"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C016"
|
||||
);
|
||||
assert_eq!(packages(&[
|
||||
("shop.first.app", "run() (answer: Int) { answer = second.read() }\nread() (answer: Int) { answer = 1 }"),
|
||||
("shop.second.app", "read() (answer: Int) { answer = first.read() }"),
|
||||
("shop.first.api", "cmd run = app.run"),
|
||||
]).unwrap_err().code, "C007");
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("shop.account.app.read", "run() () {}"),
|
||||
("shop.account.app.write", "run() () {}"),
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C013"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use super::{APP, ERROR, packages};
|
||||
|
||||
const CLOCK: &str = "public type ClockResult { Read(value: Int)\nerror Failed(message: Text) }\npublic unixMillis() (value: Int) { value = read(dever.system.timeUnixMillis()) }\nread(result: ClockResult.Read(number)) (value: Int) { value = number }\nread(result: ClockResult.Failed(message)) (value: Int) { fail(ClockResult.Failed(message)) }\npublic type SleepResult { Done\nerror Failed(message: Text) }\npublic sleep(millis: Int) () { slept(dever.system.timeSleep(millis)) }\nslept(result: SleepResult.Done) () {}\nslept(result: SleepResult.Failed(message)) () { fail(SleepResult.Failed(message)) }";
|
||||
const TASK: &str = "public sleep(millis: Int) () { slept(dever.system.taskSleep(millis)) }\nslept(result: dever.time.SleepResult.Done) () {}\nslept(result: dever.time.SleepResult.Failed(message)) () { fail(dever.time.SleepResult.Failed(message)) }";
|
||||
|
||||
#[test]
|
||||
fn provider_expiration_reads_clock_but_cannot_wait() {
|
||||
for (operation, error) in [
|
||||
("current = dever.time.unixMillis()", None),
|
||||
("dever.time.sleep(1)\ncurrent = 0", Some("C013")),
|
||||
("dever.task.sleep(1)\ncurrent = 0", Some("C014")),
|
||||
] {
|
||||
let app = APP
|
||||
.replace(
|
||||
"account = model.first",
|
||||
&format!("{operation}\naccount = model.first"),
|
||||
)
|
||||
.replace(
|
||||
"session == claims.session",
|
||||
"session == claims.session and expires_at > current",
|
||||
);
|
||||
let result = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("dever.time", CLOCK),
|
||||
("dever.task", TASK),
|
||||
(
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text\nsession: Text\nexpires_at: Int }",
|
||||
),
|
||||
("demo.account.app", &app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
]);
|
||||
match error {
|
||||
Some(code) => assert_eq!(result.unwrap_err().code, code, "{operation}"),
|
||||
None => {
|
||||
let program = result.unwrap();
|
||||
let effects = program.functions[program.verifiers[0].function]
|
||||
.signature
|
||||
.effects;
|
||||
assert!(effects.clock);
|
||||
assert!(!effects.timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_session_expiration_contract_checks_without_dropping_the_deadline() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../skills/assets/examples/http-rest");
|
||||
let project = dever::compiler::project::Project::load(&root).unwrap();
|
||||
let program = project.check().unwrap();
|
||||
assert!(
|
||||
program
|
||||
.verifiers
|
||||
.iter()
|
||||
.any(|verifier| { program.functions[verifier.function].signature.effects.clock })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_checks_every_model_loaded_by_a_relation() {
|
||||
let model =
|
||||
"global type Account { subject: Text\nsession: Text\nprofile_id: demo.profile.model.id }";
|
||||
let app = APP.replace(
|
||||
"session == claims.session })",
|
||||
"session == claims.session, with = [profile] })",
|
||||
);
|
||||
for global in [true, false] {
|
||||
let profile = format!(
|
||||
"{}type Profile {{ name: Text }}",
|
||||
if global { "global " } else { "" }
|
||||
);
|
||||
let result = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("demo.account.model", model),
|
||||
("demo.profile.model", &profile),
|
||||
("demo.account.app", &app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
]);
|
||||
if global {
|
||||
result.unwrap();
|
||||
} else {
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.contains("Provider verify cannot read a tenant Model")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::{database::ERROR, package::packages};
|
||||
use dever::{compiler::model::Program, syntax::diagnostic::Diagnostic};
|
||||
|
||||
#[path = "../authorization/mod.rs"]
|
||||
mod capability;
|
||||
mod effects;
|
||||
mod request;
|
||||
mod rest;
|
||||
|
||||
const APP: &str = "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (identity: Identity) { account = model.first({ where = subject == claims.subject and session == claims.session })\nidentity = identity(account) }\nidentity(account: model.Account) (identity: Identity) { identity = Identity { id = account.subject, user_id = account.id, tenant_id = null } }\nidentity(account: null) (identity: Identity) { fail(dever.api.Error.Unauthorized) }\nhealth() (value: Bool) { value = true }";
|
||||
|
||||
fn checked(model: &str, app: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
("demo.account.model", model),
|
||||
("demo.account.app", app),
|
||||
("demo.account.api", "public get health = app.health"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_identity_is_a_compiled_app_entry_with_global_reads() {
|
||||
let program = checked("global type Account { subject: Text\nsession: Text }", APP).unwrap();
|
||||
let verifier = &program.verifiers[0];
|
||||
assert_eq!(verifier.name, "demo.account.verify");
|
||||
assert!(program.models[0].global);
|
||||
assert!(program.entries().any(|entry| entry == verifier.function));
|
||||
assert!(program.api_snapshot().contains("global=true"));
|
||||
assert_eq!(
|
||||
checked("type Account { subject: Text\nsession: Text }", APP)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C014"
|
||||
);
|
||||
assert_eq!(
|
||||
packages(&[("main", "global type Value { number: Int }")])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cannot_write_or_replace_nominal_identity_with_permissions() {
|
||||
let model = "global type Account { subject: Text\nsession: Text }";
|
||||
let writing = APP.replace(
|
||||
"model.first({ where = subject == claims.subject and session == claims.session })",
|
||||
"model.create({ subject = claims.subject, session = claims.session })",
|
||||
);
|
||||
assert_eq!(checked(model, &writing).unwrap_err().code, "C014");
|
||||
let invalid = APP
|
||||
.replace("user_id: model.id?", "user_id: Int?")
|
||||
.replace("user_id = account.id", "user_id = 1");
|
||||
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
|
||||
let invalid = APP
|
||||
.replace(
|
||||
"tenant_id: model.id?",
|
||||
"tenant_id: model.id?\npermissions: List<Text>",
|
||||
)
|
||||
.replace("tenant_id = null", "tenant_id = null, permissions = []");
|
||||
assert_eq!(checked(model, &invalid).unwrap_err().code, "C005");
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::packages;
|
||||
|
||||
const ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden }";
|
||||
const AUTH: &str = "public id() (value: Text) { value = dever.system.authId() }";
|
||||
|
||||
#[test]
|
||||
fn identity_cannot_escape_to_public_commands_domains_or_tasks() {
|
||||
for api in ["public get me = app.me", "cmd me = app.me"] {
|
||||
let error = packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", AUTH),
|
||||
(
|
||||
"demo.user.app",
|
||||
"me() (value: Text) { value = dever.auth.id() }",
|
||||
),
|
||||
("demo.user.api", api),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{api}: {error:?}");
|
||||
}
|
||||
for (package, source, code) in [
|
||||
(
|
||||
"demo.user.domain",
|
||||
"me() (value: Text) { value = dever.auth.id() }",
|
||||
"C014",
|
||||
),
|
||||
(
|
||||
"main",
|
||||
"main() (value: Text) { task = run(dever.auth.id())\nvalue = wait(task) }",
|
||||
"C013",
|
||||
),
|
||||
(
|
||||
"main",
|
||||
"main() (value: Text) { value = dever.system.authId() }",
|
||||
"C006",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", AUTH),
|
||||
(package, source)
|
||||
])
|
||||
.unwrap_err()
|
||||
.code,
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_cannot_reenter_request_identity() {
|
||||
let error = packages(&[
|
||||
("dever.api", ERROR),
|
||||
("dever.auth", "public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }\npublic id() (value: Text) { value = dever.system.authId() }"),
|
||||
("demo.user.model", "global type User { name: Text }"),
|
||||
("demo.user.app", "type Identity { id: Text\nuser_id: model.id?\ntenant_id: model.id? }\nverify(claims: dever.auth.Claims) (value: Identity) { value = Identity { id = dever.auth.id(), user_id = null, tenant_id = null } }\nhealth() (value: Bool) { value = true }"),
|
||||
("demo.user.api", "public get health = app.health"),
|
||||
]).unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::*;
|
||||
|
||||
fn rest(model: &str, domain: &str) -> Result<Program, Diagnostic> {
|
||||
packages(&[
|
||||
("dever.database", ERROR),
|
||||
("dever.api", "public type Error { error Unauthorized }"),
|
||||
(
|
||||
"dever.auth",
|
||||
"public type Claims { subject: Text\nsession: Text\ntenant: Text?\nsite: Text }",
|
||||
),
|
||||
(
|
||||
"text",
|
||||
"public trim(input: Text) (value: Text) pure { value = input }",
|
||||
),
|
||||
(
|
||||
"demo.account.model",
|
||||
"global type Account { subject: Text\nsession: Text }",
|
||||
),
|
||||
("demo.account.model.profile", model),
|
||||
("demo.account.domain", domain),
|
||||
("demo.account.app", APP),
|
||||
(
|
||||
"demo.account.api.admin.session",
|
||||
"public get health = app.health",
|
||||
),
|
||||
("demo.account.api.admin.profile", "rest model.profile"),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_wire_reuses_schema_proof_without_skipping_json_validation() {
|
||||
for (kind, decoder) in [
|
||||
("Text", "decode_fixed"),
|
||||
("Int?", "decode_fixed"),
|
||||
("Json", "decode"),
|
||||
("Json?", "decode"),
|
||||
] {
|
||||
let program = rest(&format!("type Profile {{ value: {kind} }}"), "").unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(!generated.contains("wire::decode_json("));
|
||||
for operation in ["Create", "Replace"] {
|
||||
assert!(
|
||||
generated.contains(&format!(
|
||||
"let args: Rest{operation}0 = input.decode(dever_runtime::wire::{decoder})"
|
||||
)),
|
||||
"{kind}/{operation}"
|
||||
);
|
||||
}
|
||||
if kind == "Json" {
|
||||
assert!(
|
||||
generated.contains("field0: Box<dever_runtime::wire::serde_json::value::RawValue>")
|
||||
);
|
||||
assert!(generated.contains("Json::from_validated_raw(decoded.field0)"));
|
||||
} else if kind == "Json?" {
|
||||
assert!(
|
||||
generated.contains(
|
||||
"field0: Option<Box<dever_runtime::wire::serde_json::value::RawValue>>"
|
||||
)
|
||||
);
|
||||
assert!(generated.contains(
|
||||
"decoded.field0.map(dever_runtime::wire::json::Json::from_validated_raw)"
|
||||
));
|
||||
} else {
|
||||
assert!(!generated.contains("Json::deserialize_validated"));
|
||||
assert!(!generated.contains("Json::deserialize_optional_validated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_output_projects_only_public_fields() {
|
||||
let program = rest(
|
||||
"type Profile { value: Json\nprivate secret: Text create = \"hidden\"\nextra: Json? }",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
let view = generated
|
||||
.split_once("impl RestView0<'_> {")
|
||||
.unwrap()
|
||||
.1
|
||||
.split_once("struct RestItems0")
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(view.contains("field2"));
|
||||
assert!(!view.contains("field3"));
|
||||
assert!(view.contains("field4"));
|
||||
assert!(!view.contains("secret"));
|
||||
assert!(!view.contains("serialize_field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_defaults_keep_their_typed_decoder() {
|
||||
for (kind, value, decoder) in [
|
||||
("Json", r#""{}""#, "deserialize_validated"),
|
||||
("Json?", "null", "deserialize_optional_validated"),
|
||||
] {
|
||||
let program = rest(
|
||||
&format!("type Profile {{ value: {kind} default {value} }}"),
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let generated = dever::compiler::emit::application::generate(&program);
|
||||
assert!(generated.contains(&format!("Json::{decoder}")));
|
||||
assert!(generated.contains("#[serde(default=\"rest_create_0_default0\")]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_bindings_use_checked_functions_and_preserve_owner_identity() {
|
||||
let program = rest("type Profile { private owner user_id: model.id = dever.auth.userId()\nname: Text unique create = domain.normalize(input) replace = text.trim(input) search = input\nprivate source: Text create = \"source\" replace = \"replaced\" }","normalize(input: Text) (value: Text) pure { value = input }").unwrap();
|
||||
let entry = &program.rest[0];
|
||||
assert_eq!(entry.owner.as_ref().unwrap().position, 2);
|
||||
assert!(!entry.read.contains(&2));
|
||||
assert_eq!(
|
||||
entry.create.iter().filter(|binding| binding.input).count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(entry.search.as_ref().unwrap().position, 3);
|
||||
assert!(
|
||||
entry
|
||||
.functions()
|
||||
.all(|function| program.entries().any(|entry| entry == function))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_rejects_unsafe_field_sources_and_ambiguous_search() {
|
||||
for model in [
|
||||
"type Profile { private name: Text create = input }",
|
||||
"type Profile { owner id_value: Int? = dever.auth.userId()\nname: Text }",
|
||||
"type Profile { owner first_id: Int = dever.auth.userId()\nowner second_id: Int = dever.auth.tenantId()\nname: Text }",
|
||||
"type Profile { name: Text search = input }",
|
||||
"type Profile { name: Text index search = \"fixed\" }",
|
||||
"type Profile { name: Text index search = input\nother_name: Text index search = input }",
|
||||
"type Profile { private required: Text\nname: Text }",
|
||||
"type Profile { name: Text create = domain.normalize(input) }",
|
||||
] {
|
||||
let error = rest(
|
||||
model,
|
||||
if model.contains("domain.normalize") {
|
||||
"normalize(input: Text) (value: Text) { value = input }"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{model}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_search_reserves_pagination_names_without_banning_storage_fields() {
|
||||
for name in ["page", "size"] {
|
||||
let model = format!("type Profile {{ {name}: Int index search = input }}");
|
||||
let error = rest(&model, "").unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"REST search field cannot use reserved pagination names"
|
||||
);
|
||||
let model = format!("type Profile {{ {name}: Int index }}");
|
||||
assert!(
|
||||
rest(&model, "").is_ok(),
|
||||
"{name} remains a valid storage field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_preserves_required_private_fields_with_only_a_create_source() {
|
||||
let program = rest(
|
||||
"type Profile { private created_by: Text create = \"creator\"\nname: Text }",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
let entry = &program.rest[0];
|
||||
let source = entry
|
||||
.create
|
||||
.iter()
|
||||
.find(|binding| binding.position == 2)
|
||||
.unwrap();
|
||||
assert!(source.function.is_some());
|
||||
assert!(!source.input);
|
||||
assert!(!entry.read.contains(&2));
|
||||
assert!(entry.replace.iter().all(|binding| binding.position != 2));
|
||||
let error = rest(
|
||||
"type Profile { private created_by: Text replace = \"replacement\"\nname: Text }",
|
||||
"",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014");
|
||||
assert_eq!(
|
||||
error.message,
|
||||
"REST field is private and required without a source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_field_metadata_does_not_escape_into_handler_contracts() {
|
||||
assert!(packages(&[("main","apply(callback: handler(value: Int create = input) (answer: Int)) (answer: Int) { answer = callback(1) }")]).is_err());
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use super::{APP, ERROR};
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
const API_ERROR: &str = "public type Error { error Invalid\nerror Unauthorized\nerror Forbidden\nerror NotFound\nerror Conflict\nerror TooManyRequests }";
|
||||
|
||||
fn checked(app: &str, api: &str) -> Result<Program, Diagnostic> {
|
||||
let app = format!("{APP}\n{app}");
|
||||
let api = format!("{api}\npublic get health = app.health");
|
||||
let inputs = [
|
||||
("dever.database", "database.dever", ERROR),
|
||||
("dever.api", "api.dever", API_ERROR),
|
||||
(
|
||||
"dever.auth",
|
||||
"values.dever",
|
||||
include_str!("../../../../lib/dever/auth/values.dever"),
|
||||
),
|
||||
(
|
||||
"dever.auth",
|
||||
"capability.dever",
|
||||
include_str!("../../../../lib/dever/auth/capability.dever"),
|
||||
),
|
||||
(
|
||||
"demo.account.model",
|
||||
"model.dever",
|
||||
"global type Account { subject: Text\nsession: Text }",
|
||||
),
|
||||
("demo.account.app", "app.dever", app.as_str()),
|
||||
("demo.account.api", "api.dever", api.as_str()),
|
||||
];
|
||||
let sources = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, path, text))| Source::new(index, *path, (*text).into()))
|
||||
.collect::<Vec<_>>();
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(name, _, _)| (*name).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_operations_keep_original_types_and_write_effects() {
|
||||
let program = checked("", "").unwrap();
|
||||
for function in program.functions.iter().filter(|function| {
|
||||
matches!(
|
||||
function.signature.name.as_str(),
|
||||
"register_capability" | "remove_capability"
|
||||
)
|
||||
}) {
|
||||
let effects = function.signature.effects;
|
||||
assert!(effects.identity && effects.authorization_write && effects.database_write);
|
||||
}
|
||||
let error = checked(
|
||||
"register(id: Text) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C005");
|
||||
let error = checked(
|
||||
"register(id: Uuid) (value: Bool) { value = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C005");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_writes_require_protected_http_and_keep_transaction_rules() {
|
||||
for api in [
|
||||
"public post register = app.register",
|
||||
"authenticated post register = app.register",
|
||||
"get register = app.register",
|
||||
"cmd register = app.register",
|
||||
"long post register = app.register",
|
||||
] {
|
||||
let error = checked(
|
||||
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
api,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{api}: {error:?}");
|
||||
}
|
||||
let program = checked(
|
||||
"transaction register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"long post register = app.register",
|
||||
)
|
||||
.unwrap();
|
||||
assert_tenant_requirement(&program, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_role_query_is_authenticated_read_only_and_not_a_tenant_selector() {
|
||||
let app =
|
||||
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id) }";
|
||||
for api in [
|
||||
"get protected = app.protected",
|
||||
"authenticated get protected = app.protected",
|
||||
] {
|
||||
let program = checked(app, api).unwrap();
|
||||
assert_tenant_requirement(&program, true);
|
||||
}
|
||||
for api in [
|
||||
"public get protected = app.protected",
|
||||
"cmd protected = app.protected",
|
||||
] {
|
||||
assert_eq!(checked(app, api).unwrap_err().code, "C014");
|
||||
}
|
||||
assert_eq!(checked(
|
||||
"protected(user_id: Int) (value: Bool) { value = dever.auth.hasProtectedRoles(user_id, 42) }",
|
||||
"get protected = app.protected",
|
||||
).unwrap_err().code, "C005");
|
||||
}
|
||||
|
||||
fn assert_tenant_requirement(program: &Program, required: bool) {
|
||||
let entry = program
|
||||
.http
|
||||
.iter()
|
||||
.find(|entry| entry.access != dever::compiler::model::auth::Access::Public)
|
||||
.unwrap();
|
||||
assert_eq!(program.tenant_access(entry.function).required, required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_write_entries_cannot_replace_the_required_tenant() {
|
||||
for (app, api) in [
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
),
|
||||
(
|
||||
"remove(id: Uuid) () { dever.auth.removeCapability(id) }",
|
||||
"delete remove = app.remove",
|
||||
),
|
||||
] {
|
||||
assert_tenant_requirement(&checked(app, api).unwrap(), true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_requirement_follows_reachable_calls_and_static_handlers() {
|
||||
for (app, required) in [
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = helper(id) }\nhelper(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"register(id: Uuid) (key: Text) { key = apply(dever.auth.registerCapability, id) }\napply(action: handler(id: Uuid) (key: Text), id: Uuid) (key: Text) { key = action(id) }",
|
||||
true,
|
||||
),
|
||||
] {
|
||||
assert_tenant_requirement(
|
||||
&checked(app, "post register = app.register").unwrap(),
|
||||
required,
|
||||
);
|
||||
}
|
||||
let error = checked(
|
||||
"register(id: Uuid) (key: Text) { key = \"unused\" }\nunused(id: Uuid) (key: Text) { key = dever.auth.registerCapability(id) }",
|
||||
"post register = app.register",
|
||||
).unwrap_err();
|
||||
assert_eq!(error.code, "C016");
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use dever::compiler::{check, model::Program};
|
||||
use dever::syntax::{diagnostic::Diagnostic, source::Source};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let sources = sources(text);
|
||||
let modules = sources
|
||||
.iter()
|
||||
.map(dever::syntax::parse)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&["main".into(), "dever.bytes".into(), "text".into()],
|
||||
)
|
||||
}
|
||||
|
||||
fn sources(text: &str) -> [Source; 3] {
|
||||
[
|
||||
Source::new(0, "main.dever", text.into()),
|
||||
Source::new(
|
||||
1,
|
||||
"lib/dever/bytes.dever",
|
||||
include_str!("../../../lib/dever/bytes.dever").into(),
|
||||
),
|
||||
Source::new(
|
||||
2,
|
||||
"lib/text.dever",
|
||||
include_str!("../../../lib/text.dever").into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn generated_entry(text: &str) -> String {
|
||||
let mut program = linked(text).unwrap();
|
||||
let entry = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
// 改名不改变内建操作身份,防止优化依赖 concat/from_text 的函数名称。
|
||||
for (index, function) in program.functions.iter_mut().enumerate() {
|
||||
function.signature.name = format!("renamed_{index}");
|
||||
}
|
||||
let generated = dever::compiler::emit::generate(&program, &sources(text), &[entry].into());
|
||||
let start = generated.find(&format!("pub fn function{entry}(")).unwrap();
|
||||
generated[start..]
|
||||
.split("\npub ")
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_bytes_keep_their_shared_owner() {
|
||||
let generated = generated_entry("main(bytes: Bytes) (answer: Bytes) { answer = bytes }");
|
||||
assert!(
|
||||
generated.contains("arg0: &dever_runtime::Bytes"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(generated.contains(".to_owned()"), "{generated}");
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_byte_views_are_limited_to_non_escaping_iteration() {
|
||||
let generated = generated_entry(
|
||||
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) }",
|
||||
);
|
||||
assert!(generated.contains(".as_bytes()"), "{generated}");
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
let generated = generated_entry(
|
||||
"add(item: Int, total: Int) (next: Int) { next = total + item }\nmain(text: Text) (answer: Int) { bytes = dever.bytes.fromText(text)\nanswer = reduce(add, bytes, 0) + length(bytes) }",
|
||||
);
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
for body in [
|
||||
"main(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = bytes }",
|
||||
"identity(bytes: Bytes) (answer: Bytes) { answer = bytes }\nmain(text: Text) (answer: Bytes) { bytes = dever.bytes.fromText(text)\nanswer = identity(bytes) }",
|
||||
] {
|
||||
let generated = generated_entry(body);
|
||||
assert!(generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_separator_parts_are_fused_only_when_fully_concatenated() {
|
||||
let join = "joinPart(item: Bytes, state: Bytes) (answer: Bytes) { answer = dever.bytes.concat(state, item) }\n";
|
||||
let generated = generated_entry(
|
||||
&(join.to_owned()
|
||||
+ "main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = reduce(joinPart, parts, seed) }"),
|
||||
);
|
||||
assert!(
|
||||
generated.contains("state.extend_from_slice(sequence)"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(!generated.contains("copy_from_slice"), "{generated}");
|
||||
assert!(!generated.contains("collect::<Vec<_>>()"), "{generated}");
|
||||
for body in [
|
||||
"main(text: Text) (answer: List<Bytes>) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\nanswer = parts }",
|
||||
"main(text: Text, seed: Bytes) (answer: Bytes, count: Int) { parts = each(dever.bytes.fromText, text.split(text, \"\"))\ncount = length(parts)\nanswer = reduce(joinPart, parts, seed) }",
|
||||
"main(text: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, \",\"))\nanswer = reduce(joinPart, parts, seed) }",
|
||||
"main(text: Text, separator: Text, seed: Bytes) (answer: Bytes) { parts = each(dever.bytes.fromText, text.split(text, separator))\nanswer = reduce(joinPart, parts, seed) }",
|
||||
] {
|
||||
let generated = generated_entry(&(join.to_owned() + body));
|
||||
assert!(generated.contains("copy_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_text_concat_allocates_only_the_final_buffer() {
|
||||
for literal in [r#""""#, r#""prefix""#, r#""雪\u{0}🙂""#] {
|
||||
let generated = generated_entry(&format!(
|
||||
"main(suffix: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(dever.bytes.fromText({literal}), suffix) }}"
|
||||
));
|
||||
assert!(
|
||||
generated.contains("value::bytes::concat(prefix, suffix)"),
|
||||
"{generated}"
|
||||
);
|
||||
assert!(!generated.contains(".to_vec()"), "{generated}");
|
||||
assert!(!generated.contains("extend_from_slice"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_text_and_owned_byte_accumulators_keep_their_existing_paths() {
|
||||
for (source, operation) in [
|
||||
(
|
||||
"main(text: Text, suffix: Bytes) (answer: Bytes) { answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
|
||||
"Bytes::copy_from_slice",
|
||||
),
|
||||
(
|
||||
"main(suffix: Bytes) (answer: Bytes) { text = \"owned\"\n answer = dever.bytes.concat(dever.bytes.fromText(text), suffix) }",
|
||||
".into_bytes()",
|
||||
),
|
||||
(
|
||||
"main(suffix: Bytes) (answer: Bytes) { bytes = dever.bytes.fromText(\"owned\")\n answer = dever.bytes.concat(bytes, suffix) }",
|
||||
"bytes.extend_from_slice(suffix)",
|
||||
),
|
||||
] {
|
||||
let generated = generated_entry(source);
|
||||
assert!(generated.contains(operation), "{generated}");
|
||||
assert!(
|
||||
!generated.contains("value::bytes::concat(prefix, suffix)"),
|
||||
"{generated}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_keep_their_declared_collection_contract() {
|
||||
for source in [
|
||||
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: List<Int>) { answer = each(identity, bytes) }",
|
||||
"add(item: Int, state: Int) (next: Int) { next = item + state }\nmain(bytes: Bytes) (answer: Int) { answer = reduce(add, bytes, 0) }",
|
||||
"main(values: List<Text>) (answer: List<Bytes>) { answer = each(dever.bytes.fromText, values) }",
|
||||
"main(bytes: Bytes?) (answer: Bytes?) { answer = bytes }",
|
||||
] {
|
||||
let result = linked(source);
|
||||
assert!(result.is_ok(), "{source}: {result:?}");
|
||||
}
|
||||
for source in [
|
||||
"main(bytes: Bytes) (answer: Int) { answer = sum(bytes) }",
|
||||
"identity(item: Int) (answer: Int) { answer = item }\nmain(bytes: Bytes) (answer: Int) { answer = sum(identity, bytes) }",
|
||||
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: List<Int>) { answer = filter(yes, bytes) }",
|
||||
"yes(item: Int) (answer: Bool) { answer = true }\nmain(bytes: Bytes) (answer: Int?) { answer = find(yes, bytes) }",
|
||||
"identity(item: Text) (answer: Text) { answer = item }\nmain(bytes: Bytes) (answer: List<Text>) { answer = each(identity, bytes) }",
|
||||
"main(bytes: Bytes) (answer: List<Int>) { answer = bytes }",
|
||||
"main(values: List<Int>) (answer: Bytes) { answer = values }",
|
||||
"main(values: Map<Bytes, Int>) () {}",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_reductions_append_without_copying_the_accumulator() {
|
||||
for (kind, body, method) in [
|
||||
("Text", "state + item", "push_str"),
|
||||
(
|
||||
"Bytes",
|
||||
"dever.bytes.concat(state, item)",
|
||||
"extend_from_slice",
|
||||
),
|
||||
] {
|
||||
let source = format!(
|
||||
"appendPiece(item: {kind}, state: {kind}) (answer: {kind}) {{ answer = {body} }}\nmain(values: List<{kind}>, initial: {kind}) (answer: {kind}) {{ answer = reduce(appendPiece, values, initial) }}"
|
||||
);
|
||||
let generated = generated_entry(&source);
|
||||
let iteration = generated.split("for item in sequence").nth(1).unwrap();
|
||||
assert!(
|
||||
iteration.contains(&format!("state.{method}(item)")),
|
||||
"{generated}"
|
||||
);
|
||||
for copy in [".clone()", ".to_owned()", ".to_vec()"] {
|
||||
assert!(!iteration.contains(copy), "{generated}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_buffer_seeds_do_not_roundtrip_through_immutable_bytes() {
|
||||
for literal in [r#""""#, r#""seed""#, r#""雪\u{0}🙂""#] {
|
||||
let generated = generated_entry(&format!(
|
||||
"appendPiece(item: Bytes, state: Bytes) (answer: Bytes) {{ answer = dever.bytes.concat(state, item) }}\nmain(values: List<Bytes>) (answer: Bytes) {{ answer = reduce(appendPiece, values, dever.bytes.fromText({literal})) }}"
|
||||
));
|
||||
assert!(generated.contains("let mut state: Vec<u8>"), "{generated}");
|
||||
assert!(!generated.contains("Bytes::from_static"), "{generated}");
|
||||
assert!(!generated.contains("Vec::<u8>::from"), "{generated}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_helpers_and_intrinsics_are_private() {
|
||||
for source in [
|
||||
"main() (answer: Bytes) { answer = dever.system.bytesFromText(\"x\") }",
|
||||
"main() (answer: Bytes) { answer = dever.bytes.ready(dever.bytes.BufferResult.Ready(dever.bytes.fromText(\"x\"))) }",
|
||||
"main() (answer: Text) { answer = dever.bytes.decoded(dever.bytes.DecodeResult.Decoded(\"x\")) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C006", "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_errors_keep_nominal_identity_and_obligations() {
|
||||
assert_eq!(
|
||||
linked("main(bytes: Bytes) () { captured = result(dever.bytes.toText(bytes)) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
assert_eq!(linked("type Result { Decoded(text: Text)\n error Failed(message: Text) }\nmain(bytes: Bytes) (answer: Result) { answer = result(dever.bytes.toText(bytes)) }").unwrap_err().code, "C005");
|
||||
assert!(linked("main(bytes: Bytes) (answer: dever.bytes.DecodeResult) { answer = result(dever.bytes.toText(bytes)) }").is_ok());
|
||||
assert!(linked("type Result { Ready(text: Text)\n error Failed(message: Text) }\nidentity() (text: Text) { text = \"ok\" }\nmain() (answer: Result) { captured = result(identity())\n answer = captured }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_names_resolve_without_exposing_private_types() {
|
||||
let library = Source::new(1, "sample.dever", "type Hidden { value: Int }\npublic type Visible { value: Int }\npublic read(value: Int) (answer: Int) { answer = value }".into());
|
||||
for (source, accepted) in [
|
||||
(
|
||||
"main() (answer: sample.Visible) { answer = sample.Visible { value = sample.read(1) } }",
|
||||
true,
|
||||
),
|
||||
("main(value: sample.Hidden) () {}", false),
|
||||
("main() () { value = sample.Hidden { value = 1 } }", false),
|
||||
(
|
||||
"main() (answer: Visible) { answer = sample.Visible { value = 1 } }",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let source = Source::new(0, "main.dever", source.into());
|
||||
let result = check::check(
|
||||
vec![
|
||||
dever::syntax::parse(&source).unwrap(),
|
||||
dever::syntax::parse(&library).unwrap(),
|
||||
],
|
||||
&["main".into(), "sample".into()],
|
||||
);
|
||||
assert_eq!(result.is_ok(), accepted, "{result:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use dever::{
|
||||
compiler::{build, check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
("dever.time", include_str!("../../../lib/dever/time.dever")),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/calendar.dever"),
|
||||
),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/operations.dever"),
|
||||
),
|
||||
(
|
||||
"dever.time",
|
||||
include_str!("../../../lib/dever/time/clock.dever"),
|
||||
),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_types_are_distinct_and_only_clock_reads_have_effects() {
|
||||
let program = linked("main() (answer: Text) pure { instant = dever.time.parseDatetime(\"1970-01-01T00:00:00Z\")\n answer = dever.time.formatDatetime(dever.time.add(instant, dever.time.duration(1))) }").unwrap();
|
||||
let main = program
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
program.functions[main].signature.effects,
|
||||
Default::default()
|
||||
);
|
||||
assert_eq!(
|
||||
build::runtime_features(&program, &program.reachable([main])),
|
||||
["calendar"]
|
||||
);
|
||||
let clocks = linked("main() (value: Int) { value = dever.time.unixMillis() }").unwrap();
|
||||
let main = clocks
|
||||
.functions
|
||||
.iter()
|
||||
.position(|function| function.signature.name == "main")
|
||||
.unwrap();
|
||||
assert!(clocks.functions[main].signature.effects.clock);
|
||||
assert!(!clocks.functions[main].signature.effects.timer);
|
||||
assert!(!clocks.functions[main].signature.effects.suspend);
|
||||
assert!(build::runtime_features(&clocks, &clocks.reachable([main])).is_empty());
|
||||
for (source, code) in [
|
||||
("main() (value: DateTime) { value = 0 }", "C005"),
|
||||
(
|
||||
"main(value: DateTime) (answer: DateTime) { answer = dever.time.add(value, 1) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main(value: DateTime) (answer: Bool) { answer = value < value }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main() (value: DateTime) pure { value = dever.time.now() }",
|
||||
"C013",
|
||||
),
|
||||
("main() () { value = dever.system.timeDuration(1) }", "C006"),
|
||||
(
|
||||
"main() () { lost = result(dever.time.parseDate(\"bad\")) }",
|
||||
"C012",
|
||||
),
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, code, "{source}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::checked;
|
||||
|
||||
#[test]
|
||||
fn unconstrained_parameters_do_not_expand_clause_products() {
|
||||
let inputs = (0..40)
|
||||
.map(|position| format!("flag{position}: Bool"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let body = (0..40)
|
||||
.map(|position| format!("flag{position}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ");
|
||||
let ordinary = format!("choose({inputs}) (answer: Bool) {{ answer = {body} }}\n");
|
||||
assert!(checked(&ordinary).is_ok());
|
||||
let clause = |pattern: &str| {
|
||||
format!("choose(test: {pattern}, {inputs}) (answer: Bool) {{ answer = {body} }}\n")
|
||||
};
|
||||
assert!(checked(&(clause("true") + &clause("false"))).is_ok());
|
||||
assert_eq!(checked(&clause("true")).unwrap_err().code, "C009");
|
||||
assert_eq!(
|
||||
checked(&(clause("Bool") + &clause("false")))
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C009"
|
||||
);
|
||||
assert!(checked("type State { One\n Two }\nvalue(input: State, optional: Bool?) (answer: Bool) { answer = optional == null }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_coverage_and_payload_contracts() {
|
||||
let prefix = "type State { Idle\n Working(value: Int) }\n";
|
||||
let valid = "read(value: State.Idle) (answer: Int) { answer = 0 }\nread(value: State.Working(number)) (answer: Int) { answer = number }";
|
||||
assert!(checked(&(prefix.to_owned() + valid)).is_ok());
|
||||
for (body, code) in [
|
||||
(
|
||||
"read(value: State.Idle) (answer: Int) { answer = 0 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"read(value: State) (answer: Int) { answer = 0 }\nread(value: State.Idle) (answer: Int) { answer = 1 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working) (answer: Int) { answer = 0 }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working(first, second)) (answer: Int) { answer = first }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State.Working(value)) (answer: Int) { answer = 0 }",
|
||||
"C009",
|
||||
),
|
||||
(
|
||||
"main() (answer: State) { answer = State.Working() }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"main() (answer: State) { answer = State.Working(true) }",
|
||||
"C005",
|
||||
),
|
||||
(
|
||||
"read(value: State) (answer: Int) { answer = value.value }",
|
||||
"C005",
|
||||
),
|
||||
] {
|
||||
let error = checked(&(prefix.to_owned() + body)).unwrap_err();
|
||||
assert_eq!(error.code, code, "{body}: {error:?}");
|
||||
}
|
||||
let wildcard = "read(value: State.Working(_)) (answer: Int) { answer = 1 }\nread(value: other) (answer: Int) { answer = 0 }";
|
||||
assert!(checked(&(prefix.to_owned() + wildcard)).is_ok());
|
||||
assert_eq!(
|
||||
checked("type Cycle { Next(value: Cycle) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert_eq!(
|
||||
checked("type State { Item(value: Row) }\ntype Row { state: State }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_domains_and_complements_are_order_independent() {
|
||||
for clauses in [
|
||||
"read(value: Text) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }",
|
||||
"read(value: null) (answer: Int) { answer = -1 }\nread(value: Text) (answer: Int) { answer = length(value) }",
|
||||
"read(value: other) (answer: Int) { answer = length(value) }\nread(value: null) (answer: Int) { answer = -1 }\nread(value: \"x\") (answer: Int) { answer = 1 }",
|
||||
] {
|
||||
assert!(checked(clauses).is_ok(), "{clauses}");
|
||||
}
|
||||
for clauses in [
|
||||
"read(value: Text?) (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
|
||||
"read(value: \"x\") (answer: Int) { answer = 0 }\nread(value: null) (answer: Int) { answer = 1 }",
|
||||
] {
|
||||
assert_eq!(checked(clauses).unwrap_err().code, "C009");
|
||||
}
|
||||
assert!(checked("equal(value: Int?) (answer: Bool) { answer = value == null or null == value or value == 1 or 1 == value }").is_ok());
|
||||
assert!(checked("number() (answer: Decimal?) { answer = 1 }").is_ok());
|
||||
assert!(checked("number() (answer: Float?) { answer = 1.5 + 2.5 }").is_ok());
|
||||
assert!(checked("values() (answer: List<Int>?) { answer = [] }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_bindings_do_not_shadow_inputs_or_outputs() {
|
||||
for binding in ["value", "answer"] {
|
||||
let text = format!(
|
||||
"type State {{ Item(number: Int) }}\nread(value: State.Item({binding})) (answer: Int) {{ answer = 1 }}"
|
||||
);
|
||||
assert_eq!(checked(&text).unwrap_err().code, "C002");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use super::checked;
|
||||
|
||||
const HANDLERS: &str = "double(value: Int) (answer: Int) { answer = value * 2 }\npositive(value: Int) (answer: Bool) { answer = value > 0 }\nadd(value: Int, state: Int) (next: Int) { next = value + state }\nstop(value: Int, state: Int) (next: Int, stop: Bool) { next = value + state\n stop = next >= 3 }\n";
|
||||
const ERRORS: &str = "type Result { Ready(value: Int)\n error Failed(message: Text) }\nidentity(value: Result) (answer: Result) { answer = value }\nhandled(value: Result) (answer: Bool) recover(\"optional probe failure is explicitly discarded\") { answer = true }\ncontext(value: Int, state: Result) () recover(\"optional context probe is discarded\") {}\nkeep(value: Int, state: Result) (next: Result) { next = state }\nconsume(value: Result, state: Int) (next: Int) recover(\"count failed probes\") { next = state + 1 }\npartial(value: Result, state: Int) (next: Int, stop: Bool) recover(\"count failed probes\") { next = state + 1\n stop = true }\n";
|
||||
|
||||
#[test]
|
||||
fn checks_collection_handler_types_and_cycles() {
|
||||
for body in [
|
||||
"main() (answer: List<Int>) { answer = each(double, []) }",
|
||||
"main() (answer: List<Int>) { answer = filter(positive, [1, -1]) }",
|
||||
"main() (answer: Int?) { answer = find(positive, []) }",
|
||||
"main() (answer: Int) { answer = sum(double, []) }",
|
||||
"main() (answer: Float) { answer = sum([]) }",
|
||||
"main() (answer: Decimal?) { answer = sum([]) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [], 10) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(stop, [1, 2, 3], 0) }",
|
||||
] {
|
||||
let result = checked(&(HANDLERS.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
for body in [
|
||||
"main() (answer: List<Int>) { answer = each(positive, [1]) }",
|
||||
"main() (answer: List<Int>) { answer = filter(double, [1]) }",
|
||||
"main() (answer: Int?) { answer = find(positive, [\"one\"]) }",
|
||||
"main() (answer: Int) { answer = sum(positive, [1]) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [1], true) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(add, [1], 0) }",
|
||||
"main() (answer: Int) { answer = reduce(add, [1], 0, 1) }",
|
||||
"main() (answer: List<Int>) { double = 2\n answer = each(double, [1]) }",
|
||||
"main() () { handler = double }",
|
||||
] {
|
||||
assert!(checked(&(HANDLERS.to_owned() + body)).is_err(), "{body}");
|
||||
}
|
||||
assert_eq!(
|
||||
checked("cycle(value: Int) (answer: Int) { answer = sum(cycle, [value]) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C007"
|
||||
);
|
||||
assert_eq!(checked("first(value: Int) (answer: Int) { answer = sum(second, [value]) }\nsecond(value: Int) (answer: Int) { answer = first(value) }").unwrap_err().code, "C007");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_preserve_error_obligations() {
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: Int) { copies = each(identity, values)\n answer = length(copies) }",
|
||||
"main(values: List<Result>) (answer: Result?) { answer = find(handled, values) }",
|
||||
"main(values: List<Result>) (answer: Int) { answer = reduceUntil(partial, values, 0) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\"), Result.Failed(\"two\")], 0) }",
|
||||
"main(state: Result) () { each(context, [], state) }",
|
||||
"main(values: List<Int>, state: Result) () { each(context, values, state) }",
|
||||
"main(state: Result) (answer: Int) { kept = reduce(keep, [], state)\n answer = 1 }",
|
||||
"main() (answer: List<Result>) { lost = Result.Failed(\"lost\")\n answer = each(identity, []) }",
|
||||
] {
|
||||
let result = checked(&(ERRORS.to_owned() + body));
|
||||
assert_eq!(result.unwrap_err().code, "C012", "{body}");
|
||||
}
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }",
|
||||
"main(values: List<Result>) (answer: Int) { answer = reduce(consume, values, 0) }",
|
||||
"main(state: Result) () { each(context, [1], state) }",
|
||||
"main() (answer: Result?) { answer = find(handled, [Result.Failed(\"one\")]) }",
|
||||
"main() (answer: Int) { answer = reduceUntil(partial, [Result.Failed(\"one\")], 0) }",
|
||||
"main(state: Result) (answer: Result) { answer = reduce(keep, [], state) }",
|
||||
] {
|
||||
let result = checked(&(ERRORS.to_owned() + body));
|
||||
assert!(result.is_ok(), "{body}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_error_summaries_survive_calls_and_repeated_state() {
|
||||
let prefix = ERRORS.to_owned()
|
||||
+ "copy(values: List<Result>) (answer: List<Result>) { answer = each(identity, values) }\nreplace(value: Result, state: Result) (next: Result) recover(\"last probe replaces previous probe\") { next = value }\n";
|
||||
for body in [
|
||||
"main(values: List<Result>) (answer: Int) { copies = copy(values)\n answer = length(copies) }",
|
||||
"main(values: List<Result>, state: Result) (answer: Int) { last = reduce(replace, values, state)\n answer = 1 }",
|
||||
"main(state: Result) () { copies = copy([])\n count = length(copies) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(prefix.clone() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
let source = prefix
|
||||
+ "main(values: List<Result>, state: Result) (answer: Result) { answer = reduce(replace, values, state) }";
|
||||
assert!(checked(&source).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_traversal_cannot_transfer_unvisited_obligations() {
|
||||
let prefix = ERRORS.to_owned()
|
||||
+ "takeContext(value: Int, context: Result) (answer: Result) { answer = context }\nfirst(value: Result, state: Result) (next: Result, stop: Bool) recover(\"replace previous state\") { next = value\n stop = true }\n";
|
||||
for body in [
|
||||
"main(values: List<Int>, state: Result) (answer: List<Result>) { answer = each(takeContext, values, state) }",
|
||||
"main(values: List<Result>) (answer: Result) { answer = reduceUntil(first, values, Result.Ready(0)) }",
|
||||
] {
|
||||
assert_eq!(
|
||||
checked(&(prefix.clone() + body)).unwrap_err().code,
|
||||
"C012",
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
assert!(checked(&(prefix + "main(state: Result) (answer: List<Result>) { answer = each(takeContext, [1], state) }")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_record_fields_still_checks_discarded_failures() {
|
||||
let source = ERRORS.to_owned()
|
||||
+ "type Row { result: Result\n count: Int }\nreplace(value: Row) (answer: Row) { answer = value\n answer.result = Result.Ready(1)\n answer.count = 0 }";
|
||||
assert_eq!(checked(&source).unwrap_err().code, "C012");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_length_map_builds_reserve_once_and_update_in_place() {
|
||||
for (kind, item, value, sequence) in [
|
||||
("Bytes", "Int", "value", "values"),
|
||||
("List<Int>", "Int", "value", "values"),
|
||||
(
|
||||
"Map<Int, Int>",
|
||||
"MapEntry<Int, Int>",
|
||||
"value.value",
|
||||
"entries(values)",
|
||||
),
|
||||
] {
|
||||
let text = format!(
|
||||
"insert(value: {item}, state: Map<Int, Int>) (next: Map<Int, Int>) {{ next = put(state, length(state), {value}) }}\nmain(values: {kind}) (answer: Map<Int, Int>) {{ answer = reduce(insert, {sequence}, {{}}) }}"
|
||||
);
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text);
|
||||
let program = checked(&source.text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("pub fn ").next().unwrap();
|
||||
assert!(
|
||||
main.contains("Map::with_capacity(sequence.len())"),
|
||||
"{main}"
|
||||
);
|
||||
assert!(main.contains("state.insert("), "{main}");
|
||||
assert!(!main.contains("state.clone()"), "{main}");
|
||||
assert!(!main.contains(".to_owned()"), "{main}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_reductions_keep_a_single_growing_buffer() {
|
||||
let text = "type State { values: List<Int>\n count: Int }\nstep(value: Int, state: State) (next: State) { values = append(state.values, value)\n next = State { values = values\n count = state.count + 1 } }\nmain(input: Bytes) (answer: State) { answer = reduce(step, input, State { values = []\n count = 0 }) }";
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.into());
|
||||
let program = checked(text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("\npub ").next().unwrap();
|
||||
assert!(!main.contains(".clone()"), "{main}");
|
||||
let iteration = main.split("for byte in sequence").nth(1).unwrap();
|
||||
let iteration = iteration.split("Type0 {").next().unwrap();
|
||||
assert!(!iteration.contains(".reserve("), "{main}");
|
||||
assert!(iteration.contains(".push(element)"), "{main}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_selection_copies_only_matching_entries() {
|
||||
for (operation, result) in [
|
||||
("filter", "List<MapEntry<Text, Text>>"),
|
||||
("find", "MapEntry<Text, Text>?"),
|
||||
] {
|
||||
let text = format!(
|
||||
"matches(entry: MapEntry<Text, Text>, target: Text) (answer: Bool) {{ answer = entry.value == target }}\nmain(values: Map<Text, Text>, target: Text) (answer: {result}) {{ answer = {operation}(matches, entries(values), target) }}"
|
||||
);
|
||||
let source = dever::syntax::source::Source::new(0, "case.dever", text.clone());
|
||||
let program = checked(&text).unwrap();
|
||||
let generated = dever::compiler::emit::generate(&program, &[source], &[1].into());
|
||||
let main = generated.split("pub fn function1(").nth(1).unwrap();
|
||||
let main = main.split("\npub ").next().unwrap();
|
||||
let iteration = main.split("for (key, value) in sequence").nth(1).unwrap();
|
||||
let (inspection, selected) = iteration.split_once("if ").unwrap();
|
||||
assert!(!inspection.contains(".clone()"), "{main}");
|
||||
assert!(selected.contains("(key.clone(), value.clone())"), "{main}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use dever::{
|
||||
compiler::{check, model::Program},
|
||||
syntax::{diagnostic::Diagnostic, source::Source},
|
||||
};
|
||||
|
||||
fn linked(text: &str) -> Result<Program, Diagnostic> {
|
||||
let inputs = [
|
||||
("main", text),
|
||||
(
|
||||
"dever.crypto",
|
||||
include_str!("../../../lib/dever/crypto.dever"),
|
||||
),
|
||||
("dever.time", include_str!("../../../lib/dever/time.dever")),
|
||||
("dever.task", include_str!("../../../lib/dever/task.dever")),
|
||||
];
|
||||
let modules = inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, text))| {
|
||||
dever::syntax::parse(&Source::new(
|
||||
index,
|
||||
if index == 0 {
|
||||
"main.dever".into()
|
||||
} else {
|
||||
format!("library{index}.dever")
|
||||
},
|
||||
(*text).into(),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
check::check(
|
||||
modules,
|
||||
&inputs
|
||||
.iter()
|
||||
.map(|(package, _)| (*package).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_cannot_be_compared_converted_or_hidden_in_error_payloads() {
|
||||
for source in [
|
||||
"main(value: Secret) (answer: Bool) { answer = value == value }",
|
||||
"main(values: List<Secret>) (answer: Bool) { answer = values == values }",
|
||||
"main(value: Secret) (answer: Int) { answer = length(value) }",
|
||||
"main(value: Secret) (answer: Text) { answer = value }",
|
||||
"type Invalid { error Failed(value: Secret) }\nmain() () {}",
|
||||
"type Inner { key: Secret }\ntype Invalid { error Failed(values: List<Inner>) }\nmain() () {}",
|
||||
"main(values: Map<Secret, Int>) () {}",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C005", "{source}");
|
||||
}
|
||||
let error = linked("main() () { value = secret(\"password\") }").unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("compiler-owned Test source"));
|
||||
assert!(linked("main(value: Secret) (answer: Secret?) { answer = value }").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_randomness_and_password_work_keep_effect_boundaries() {
|
||||
for source in [
|
||||
"main() () pure { value = dever.crypto.token(16) }",
|
||||
"main(password: Secret) (hash: Text) { dever.task.sleep(0)\n hash = dever.crypto.passwordHash(password) }",
|
||||
"main(password: Secret, hash: Text) (valid: Bool) { dever.task.sleep(0)\n valid = dever.crypto.passwordVerify(password, hash) }",
|
||||
] {
|
||||
assert_eq!(linked(source).unwrap_err().code, "C013", "{source}");
|
||||
}
|
||||
assert!(linked("main(password: Secret) (hash: Text) { hash = blocking(dever.crypto.passwordHash(password)) }").is_ok());
|
||||
assert!(linked("main(key: Secret, bytes: Bytes) (hash: Bytes) pure { hash = dever.crypto.hmacSha256(key, bytes) }").is_ok());
|
||||
assert_eq!(
|
||||
linked("main() () { value = dever.system.cryptoToken(16) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C006"
|
||||
);
|
||||
assert_eq!(
|
||||
linked("main() () { lost = result(dever.crypto.token(0)) }")
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"C012"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_outputs_reject_nested_secret_types() {
|
||||
for source in [
|
||||
"read() (value: Secret?) { value = null }",
|
||||
"type Envelope { values: List<Secret> }\nread() (value: Envelope) { value = Envelope { values = [] } }",
|
||||
] {
|
||||
let module = dever::syntax::parse(&Source::new(0, "app.dever", source.into())).unwrap();
|
||||
let error = check::check(vec![module], &["shop.account.app".into()]).unwrap_err();
|
||||
assert_eq!(error.code, "C006");
|
||||
assert!(error.message.contains("Secret"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::{ERROR, packages};
|
||||
|
||||
#[test]
|
||||
fn text_policy_is_a_single_model_modifier_and_defaults_use_the_normalized_length() {
|
||||
use dever::compiler::model::persistence::{TextMode, Value};
|
||||
let program = packages(&[("demo.account.model", "type Account { name: Text(1, 2) lower default \" Ä \"\ncode: Text(2) upper default \" ß \"\nbody: Text raw default \" a \"\nnote: Text? }")]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
for (column, mode, expected) in [
|
||||
(2, TextMode::Lower, "ä"),
|
||||
(3, TextMode::Upper, "SS"),
|
||||
(4, TextMode::Raw, " a "),
|
||||
] {
|
||||
assert_eq!(columns[column].text, mode);
|
||||
assert!(matches!(&columns[column].default, Some(Value::Text(value)) if value == expected));
|
||||
}
|
||||
assert_eq!(columns[5].text, TextMode::Trim);
|
||||
for field in [
|
||||
"name: Text raw lower",
|
||||
"name: Text lower upper",
|
||||
"name: Text raw raw",
|
||||
"name: Int lower",
|
||||
"name: List<Text> raw",
|
||||
"name: Text(1, 2) default \" \"",
|
||||
"name: Text(1) upper default \"ß\"",
|
||||
"name: Text trim",
|
||||
] {
|
||||
assert!(
|
||||
packages(&[("demo.account.model", &format!("type Account {{ {field} }}"))]).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
assert!(packages(&[("demo.account.domain", "type Input { name: Text raw }")]).is_err());
|
||||
let seeded = packages(&[(
|
||||
"demo.account.model",
|
||||
"type Account { name: Text(1, 2) lower unique }\nseed { { name = \" Ä \" } }",
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(matches!(&seeded.models[0].seeds[0].values[0].1, Value::Text(value) if value == "ä"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_choice_options_preserve_labels_and_order() {
|
||||
let program = packages(&[
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { status: Status }"),
|
||||
("demo.account.app", "options() (values: Map<model.Status, Text>) { values = model.Status.options }"),
|
||||
]).unwrap();
|
||||
assert!(!program.functions[0].signature.effects.database);
|
||||
assert!(packages(&[("main", "type Status { Active }\npublic main() (values: Map<Text, Text>) { values = Status.options }")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_defaults_and_generated_columns_keep_static_types() {
|
||||
let program = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Status { Active = \"启用\"\nDisabled = \"禁用\" }\ntype Account { uid: Uuid generated unique\nstatus: Status default Status.Active\namount: Decimal(5, 2) default -12.30\nnote: Text? default null\nname: Text(1, 8) default \"雪\" }"),
|
||||
("demo.account.app", "create() (id: model.id) { row = model.create({})\nid = row.id }"),
|
||||
("demo.account.api", "cmd create = app.create"),
|
||||
]).unwrap();
|
||||
let columns = &program.models[0].columns;
|
||||
assert!(columns[2].generated);
|
||||
assert_eq!(
|
||||
columns[3].choice.as_ref().unwrap(),
|
||||
&[
|
||||
("Active".into(), "启用".into()),
|
||||
("Disabled".into(), "禁用".into())
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
matches!(&columns[4].default, Some(dever::compiler::model::persistence::Value::Text(value)) if value == "098770")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_defaults_and_generated_writes_fail_before_database_access() {
|
||||
for field in [
|
||||
"value: Decimal(5, 2) default 0.001",
|
||||
"value: Decimal(5, 2) default 1000",
|
||||
"value: Text(2) default \"三字符\"",
|
||||
"value: Int default 1.1",
|
||||
"value: Int default 9223372036854775808",
|
||||
"value: Float default 1e999",
|
||||
"value: Bool default null",
|
||||
"value: Uuid default \"broken\"",
|
||||
"value: Date default \"2025-02-29\"",
|
||||
"value: Time default \"24:00:00\"",
|
||||
"value: Json default \"{\\\"x\\\":1,\\\"x\\\":2}\"",
|
||||
"value: Int generated",
|
||||
"value: Uuid? generated",
|
||||
"value: Uuid generated default \"00112233-4455-6677-8899-aabbccddeeff\"",
|
||||
] {
|
||||
let model = format!("type Account {{ {field} }}");
|
||||
let error = packages(&[("demo.account.model", &model)]).unwrap_err();
|
||||
assert_eq!(error.code, "C014", "{field}: {error:?}");
|
||||
}
|
||||
for body in [
|
||||
"row = model.create({ uid = uid })",
|
||||
"row = model.update(id, { uid = uid })",
|
||||
"row = model.get(id)\nrow.uid = uid",
|
||||
] {
|
||||
let app = format!(
|
||||
"write(id: model.id, uid: Uuid) (value: model.id) {{ {body}\nvalue = row.id }}"
|
||||
);
|
||||
let error = packages(&[
|
||||
("dever.database", ERROR),
|
||||
("demo.account.model", "type Account { uid: Uuid generated }"),
|
||||
("demo.account.app", &app),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(error.code, "C014" | "C005"), "{body}: {error:?}");
|
||||
assert!(error.message.contains("read-only"), "{error:?}");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user