diff --git a/apps/starry/java-web/README.md b/apps/starry/java-web/README.md new file mode 100644 index 0000000000..387bf22465 --- /dev/null +++ b/apps/starry/java-web/README.md @@ -0,0 +1,88 @@ +# java-web — JEE framework carpet + +Industrial-grade, on-target test of a set of real JEE/JVM frameworks, run by **OpenJDK 17** +on StarryOS across all four architectures (x86_64 / aarch64 / riscv64 / loongarch64). + +Each module is a self-contained carpet that exercises one framework's public API surface +(dozens-to-hundreds of exact-value assertions; HTTP servers are driven over real IPv4 loopback +with `HttpURLConnection`, ORMs run against an in-memory database). A module prints an anchored +`*_DONE` marker only when its internal fail count is zero; `run-jweb.sh` runs every module +and emits `TEST PASSED` only when every attempted module passes. + +## Source-only, reproducible build + +This app commits **only source + manifests** — no compiled framework `.jar` and no native +`.so`. `prebuild.sh` (the merged java-lang / java-jse model): + +1. fetches every third-party dependency from **Maven Central by sha256** into a cache + (`JAVA_DL_ROOT`), re-used network-free on later runs; +2. compiles the six carpet classes in-prebuild with the **host** `javac --release 17` from + `programs/carpets/*.java` (arch-independent bytecode, cached across the four arches); +3. stages a full per-arch JDK17 + `carpets.jar` + the dependency jars into the per-app rootfs + (grown grow-only to 2.5G); each module runs with `-Xint -Xmx512m`. + +A **clean checkout can run every architecture end-to-end** — each arch's JDK17 (and every +dependency) is provisioned by a download; nothing is expected to pre-exist in a private cache. +`programs/SOURCES.md` records the coordinate + sha256 of every fetched dependency and the +provenance of the JDK17 and the sqlite JNI. A developer who already has the assets points +`JAVA_DL_ROOT` at their cache and every fetch short-circuits. + +## JDK17 per arch (StarryOS runs both musl and glibc) + +StarryOS is libc-agnostic, so any prebuilt JDK17 of the matching major version works regardless +of the libc it was built against: + +- **x86_64 / aarch64** — Alpine v3.22/community `openjdk17-*` apks (musl). Run directly by the + default command below. +- **loongarch64** — Alpine edge/community `openjdk17-loongarch-*` apks (musl). Run directly. +- **riscv64** — a **downloadable prebuilt glibc JDK17** (Adoptium Temurin `17.0.19+10`), because + **Alpine ships no riscv64 openjdk17** (only openjdk21/25 for riscv64). `prebuild.sh` stages a + small **real Debian glibc runtime closure** so the JDK's own `ld-linux-riscv64-lp64d.so.1` + interpreter resolves its `libc.so.6` references; the JDK statically bundles zlib/libstdc++/libgcc, + so `libc6` is its entire external closure. This is the same "download a glibc JDK + stage a real + Debian glibc runtime" mechanism the merged `java-lang` app uses for its riscv64 JDK23 cell, and + is identical to the merged `java-jse` case. (BellSoft Liberica generic-glibc and the Debian apt + `openjdk-17` riscv64 build are equivalent sources of the same JDK.) + +## Run + +``` +cargo xtask starry app qemu -t java-web --arch x86_64 +cargo xtask starry app qemu -t java-web --arch aarch64 +cargo xtask starry app qemu -t java-web --arch riscv64 +cargo xtask starry app qemu -t java-web --arch loongarch64 +``` + +## Coverage + +| module | framework | dimension | marker | +|:--|:--|:--|:--| +| jetty | Eclipse Jetty 11.0.21 | embedded HTTP server (handlers / routing / methods / status-body-header assertions over loopback) | `JETTY_DONE` | +| netty | Netty 4.1.112 | ByteBuf, EmbeddedChannel codec/handler unit tests, real loopback TCP echo + HTTP-codec server | `NETTY_DONE` | +| mybatis | MyBatis 3.5.16 | SqlSessionFactory / mappers / annotations / dynamic SQL / batch / transactions over an in-memory DB | `MYBATIS_DONE` | +| hibernate | Hibernate ORM 6.4.4 / JPA 3.1 | SessionFactory / entities / CRUD / HQL-JPQL / Criteria / relationships / paging over an in-memory DB | `HIBERNATE_DONE` | +| r2dbc | R2DBC 1.0 | reactive ConnectionFactory / Statement / Result, deterministic subscription, transactions | `R2DBC_DONE` | +| war | Jakarta Servlet 5.0.2 | a real `.war` (servlet + `web.xml`) compiled + deployed into an embedded Jetty container, hit over loopback HTTP | `WAR_DONE` | + +The carpet sources live in `programs/carpets/`; the framework libraries are fetched from Maven +Central by `prebuild.sh` (see `programs/SOURCES.md`). + +## sqlite-jdbc native JNI, per arch (MyBatis + Hibernate) + +MyBatis and Hibernate run over sqlite-jdbc 3.46.1.3: + +- **x86_64 / aarch64** — the jar bundles a musl JNI (`Linux-Musl/…`); the driver self-extracts it. +- **riscv64** — the rv64 JDK17 is the prebuilt **glibc** build, so the matching JNI is the jar's + own bundled **glibc riscv64 native** (`org/sqlite/native/Linux/riscv64/libsqlitejdbc.so`). + `prebuild.sh` extracts it from the sha256-pinned jar (no extra download, no cross-build), stages + it, and points `org.sqlite.lib.path` at it. +- **loongarch64** — the upstream jar ships **no** loongarch64 native at all (neither glibc nor + musl) and no vendor prebuilds one, so `prebuild.sh` **cross-compiles the musl loongarch64 JNI + in-prebuild from xerial/sqlite-jdbc's official source** (reproducing xerial's own native build: + `NativeDB.c` + the official SQLite amalgamation, `loongarch64-linux-musl-gcc`). It is + reproducible (both source inputs are sha256-pinned; the small C lib compiles in ~1 min) and + needs the loong musl cross-toolchain; see `programs/SOURCES.md`. The JNI is provisioned on all + four arches (x86_64 / aarch64 jar-bundled musl, riscv64 jar-bundled glibc extracted, loongarch64 + cross-built musl); if it cannot be provisioned the prebuild fails hard. **All six carpets always + run and are counted — a missing or unloadable JNI is a real FAIL, never a skip** (`run-jweb.sh` + requires `PASS==TOTAL==6`). The jetty / netty / r2dbc / war carpets do not use sqlite. diff --git a/apps/starry/java-web/build-aarch64-unknown-none-softfloat.toml b/apps/starry/java-web/build-aarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..dd9dd08903 --- /dev/null +++ b/apps/starry/java-web/build-aarch64-unknown-none-softfloat.toml @@ -0,0 +1,13 @@ +features = [ + "ax-feat/display", + "ax-feat/rtc", + "ax-driver/virtio-blk", + "ax-driver/virtio-net", + "ax-driver/virtio-gpu", + "ax-driver/virtio-input", + "ax-driver/virtio-socket", + "starry-kernel/input", + "starry-kernel/vsock", +] +log = "Warn" +target = "aarch64-unknown-none-softfloat" diff --git a/apps/starry/java-web/build-loongarch64-unknown-none-softfloat.toml b/apps/starry/java-web/build-loongarch64-unknown-none-softfloat.toml new file mode 100644 index 0000000000..1787fbb30e --- /dev/null +++ b/apps/starry/java-web/build-loongarch64-unknown-none-softfloat.toml @@ -0,0 +1,14 @@ +target = "loongarch64-unknown-none-softfloat" +log = "Warn" +features = [ + "ax-feat/display", + "ax-feat/rtc", + "ax-driver/serial", + "ax-driver/virtio-blk", + "ax-driver/virtio-net", + "ax-driver/virtio-gpu", + "ax-driver/virtio-input", + "ax-driver/virtio-socket", + "starry-kernel/input", + "starry-kernel/vsock", +] diff --git a/apps/starry/java-web/build-riscv64gc-unknown-none-elf.toml b/apps/starry/java-web/build-riscv64gc-unknown-none-elf.toml new file mode 100644 index 0000000000..61c1214bfa --- /dev/null +++ b/apps/starry/java-web/build-riscv64gc-unknown-none-elf.toml @@ -0,0 +1,14 @@ +features = [ + "ax-feat/display", + "ax-feat/rtc", + "ax-driver/serial", + "ax-driver/virtio-blk", + "ax-driver/virtio-net", + "ax-driver/virtio-gpu", + "ax-driver/virtio-input", + "ax-driver/virtio-socket", + "starry-kernel/input", + "starry-kernel/vsock", +] +log = "Warn" +target = "riscv64gc-unknown-none-elf" diff --git a/apps/starry/java-web/build-x86_64-unknown-none.toml b/apps/starry/java-web/build-x86_64-unknown-none.toml new file mode 100644 index 0000000000..a8c92b85d8 --- /dev/null +++ b/apps/starry/java-web/build-x86_64-unknown-none.toml @@ -0,0 +1,9 @@ +target = "x86_64-unknown-none" +log = "Warn" +features = [ + "ax-driver/virtio-blk", + "ax-driver/virtio-net", + "ax-driver/virtio-gpu", + "ax-driver/virtio-input", + "ax-driver/virtio-socket", +] diff --git a/apps/starry/java-web/prebuild.sh b/apps/starry/java-web/prebuild.sh new file mode 100755 index 0000000000..528b5b3cf4 --- /dev/null +++ b/apps/starry/java-web/prebuild.sh @@ -0,0 +1,555 @@ +#!/usr/bin/env bash +# prebuild.sh — provision the JEE framework carpet for StarryOS. +# +# A set of real JEE/JVM frameworks — Jetty (embedded HTTP over loopback), Netty +# (EmbeddedChannel unit tests + real TCP echo / HTTP-codec loopback servers), MyBatis / +# Hibernate (ORM over an in-memory SQLite DB via sqlite-jdbc), R2DBC (reactive SPI over +# in-memory H2) and a real .war deployment (Jakarta Servlet in an embedded Jetty) — each run +# on-target by OpenJDK 17 with an anchored self-check marker. +# +# ── SOURCE-ONLY REPO, REPRODUCIBLE BUILD (the java-lang / java-jse model) ─────────────── +# The source tree keeps ONLY source + manifests: the carpet .java under programs/carpets/, +# this script, and the pinned dependency coordinates below (Maven groupId:artifactId:version +# + sha256). NO compiled framework .jar and NO native .so are committed. Exactly like the +# merged java-lang / java-jse cases, prebuild: +# (a) FETCHES every third-party dependency jar from Maven Central BY sha256 into a cache +# (JAVA_DL_ROOT), re-used network-free on later runs; +# (b) COMPILES the six carpet classes IN-PREBUILD with `javac --release 17` from the +# committed programs/carpets/*.java, the fetched deps on the classpath, producing +# carpets.jar (arch-independent bytecode); +# (c) STAGES carpets.jar + the fetched dependency jars into the overlay at /root/jweb{,/libs}. +# The compile uses the HOST javac (not the staged target-arch JDK17, whose javac is a +# riscv64/loongarch64/aarch64 binary that cannot exec on an x86_64 build host); the emitted +# `--release 17` bytecode is identical for every target arch. carpets.jar is cached so the +# four per-arch prebuild runs compile it at most once. +# +# ── Per-arch JDK17 source — a fresh checkout provisions EVERY arch with a download ────── +# StarryOS is libc-agnostic (it runs BOTH musl and glibc binaries), so any prebuilt JDK17 +# with matching major version works, regardless of the libc it was built against: +# x86_64 / aarch64 : Alpine v3.22/community openjdk17 apks (musl native) +# loongarch64 : Alpine edge/community openjdk17-loongarch apks (musl native) +# riscv64 : Adoptium Temurin 17.0.19+10 prebuilt GLIBC tarball (downloadable), +# bridged by a staged real Debian glibc runtime closure so the JDK's own +# ld-linux interp resolves its libc.so.6 references (stage_glibc_runtime_rv). +# Alpine ships NO riscv64 openjdk17 (only openjdk21/25 for riscv64), so the riscv64 cell uses a +# downloadable prebuilt GLIBC JDK17 instead of a musl one. This is the SAME "download a glibc +# JDK + stage a real Debian glibc runtime closure" mechanism the merged java-lang case uses for +# its riscv64 JDK23 cell (BellSoft generic-glibc bridged by the same libc6 deb), and is +# identical to the merged java-jse case. BellSoft Liberica generic-glibc and the Debian apt +# openjdk-17 riscv64 build ship the same JDK and would work identically. +# +# ── NATIVE sqlite-jdbc JNI (.so), per arch (MyBatis + Hibernate) ─────────────────────── +# x86_64 / aarch64 : the xerial sqlite-jdbc jar BUNDLES a musl JNI at +# org/sqlite/native/Linux-Musl/{x86_64,aarch64}/libsqlitejdbc.so; the +# driver self-extracts + dlopens it at run time (run-jweb.sh sets no +# lib.path), so nothing is staged and nothing is committed. +# riscv64 : the rv64 JDK17 is the prebuilt GLIBC build, so the matching JNI is the +# sqlite-jdbc jar's OWN bundled GLIBC riscv64 native +# (org/sqlite/native/Linux/riscv64/libsqlitejdbc.so). prebuild extracts it +# from the already-fetched, sha256-pinned jar (fully reproducible, no extra +# download, no cross-build) and stages it at /root/jweb/native. +# loongarch64 : the upstream jar ships NO loongarch64 native at all (neither glibc nor +# musl), and the loong JDK17 is Alpine-musl, so a musl loongarch64 JNI is +# CROSS-COMPILED IN-PREBUILD from xerial/sqlite-jdbc's OWN C source +# (NativeDB.c) + the official SQLite amalgamation — exactly as xerial's +# Makefile builds it (same feature flags) — with loongarch64-linux-musl-gcc. +# Both source inputs are sha256-pinned so a clean checkout reproduces it +# (the small C lib compiles in ~1 min). If the loong musl cross-toolchain is +# genuinely absent, prebuild fails hard — run-jweb.sh runs all six carpets +# with no skip path, so a missing JNI is a real failure, never skipped. +# The jetty / netty / r2dbc / war carpets do not use sqlite and run normally. +# +# ── ROOTFS SIZE ─────────────────────────────────────────────────────────────────────── +# The harness copies the ~1 GiB base alpine rootfs to a per-app image, runs THIS prebuild, +# then injects the overlay via debugfs WITHOUT resizing — large files get silently +# truncated if the fs is full. One JDK17 (~330 MiB) + the dependency jars (~43 MiB) + +# carpets.jar fit after we grow the image to 2.5 GiB (grow-only truncate + e2fsck + +# resize2fs). The running JVM only maps a -Xmx512m heap, so the larger image is free. +# +# Env from the app runner: STARRY_ARCH, STARRY_OVERLAY_DIR, STARRY_APP_DIR, STARRY_ROOTFS, +# STARRY_STAGING_ROOT. +set -euo pipefail + +app_dir="${STARRY_APP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +arch="${STARRY_ARCH:?prebuild: STARRY_ARCH required}" +overlay_dir="${STARRY_OVERLAY_DIR:?prebuild: STARRY_OVERLAY_DIR required}" +rootfs_img="${STARRY_ROOTFS:?prebuild: STARRY_ROOTFS required}" + +PROG="$app_dir/programs" + +# ── Asset cache root (PORTABLE) ─────────────────────────────────────────────────────── +# Holds the JDK17 distributions, the Maven dependency jars, the compiled carpets.jar, the +# riscv64 glibc runtime deb, and the per-arch sqlite JNI .so (same tree layout +# openjdk-multi/java-lang/java-jse prep use). On a clean machine this dir does not pre-exist; each +# asset is fetched from its OFFICIAL URL and re-used. A developer who already has the assets points +# JAVA_DL_ROOT at their cache and every fetch short-circuits. +DL="${JAVA_DL_ROOT:-${STARRY_STAGING_ROOT:-$app_dir}/.cache/java-dl}" +ALPINE_CDN="${ALPINE_CDN:-https://dl-cdn.alpinelinux.org/alpine}" +MAVEN_CENTRAL="${MAVEN_CENTRAL:-https://repo1.maven.org/maven2}" +ROOTFS_SIZE="${JWEB_ROOTFS_SIZE:-2560M}" + +# ── Portable fetch-ensure layer ─────────────────────────────────────────────────────── +# ensure_asset [sha256] +# Cache hit (sha256 matches when given) -> used as-is, zero network. Otherwise curl the URL +# to a temp file, verify sha256 when given (mismatch = hard error), atomically move into +# place. An empty/omitted sha skips verification (rolling Alpine apks: cache copy is the +# pinned golden, URL is a best-effort refill). An empty URL with no cache is a hard error. +ensure_asset() { + local dest="$1" url="$2" want="${3:-}" + if [[ -f "$dest" ]]; then + if [[ -n "$want" ]] && command -v sha256sum >/dev/null 2>&1; then + local have; have="$(sha256sum "$dest" | cut -d' ' -f1)" + if [[ "$have" == "$want" ]]; then echo "prebuild: cache hit $dest (sha256 ok)"; return 0; fi + echo "prebuild: cache file $dest sha256 mismatch (have $have want $want) — refetching" >&2 + rm -f "$dest" + else + echo "prebuild: cache hit $dest"; return 0 + fi + fi + command -v curl >/dev/null 2>&1 || { echo "prebuild: need curl to fetch $url (no cached $dest)" >&2; exit 4; } + [[ -n "$url" ]] || { echo "prebuild: no cached $dest and no URL to fetch it from" >&2; exit 4; } + echo "prebuild: fetching $(basename "$dest") <- $url" + mkdir -p "$(dirname "$dest")" + curl -fSL --retry 3 --connect-timeout 20 "$url" -o "$dest.tmp" + if [[ -n "$want" ]] && command -v sha256sum >/dev/null 2>&1; then + local got; got="$(sha256sum "$dest.tmp" | cut -d' ' -f1)" + [[ "$got" == "$want" ]] || { echo "prebuild: sha256 mismatch for $url (got $got want $want)" >&2; rm -f "$dest.tmp"; exit 4; } + fi + mv -f "$dest.tmp" "$dest" +} + +# extract_jar_entry +# Extract ONE entry from a jar/zip without assuming a single unzip tool (unzip -> jar -> +# python3), then install it at . Used to pull the jar-bundled glibc riscv64 sqlite +# JNI out of the sha256-pinned sqlite-jdbc jar (no extra download / no cross-build). +extract_jar_entry() { + local jar="$1" entry="$2" dest="$3" + [[ -f "$jar" ]] || { echo "prebuild: extract_jar_entry: missing jar $jar" >&2; return 1; } + local t; t="$(mktemp -d)" + if command -v unzip >/dev/null 2>&1; then + unzip -oq "$jar" "$entry" -d "$t" >/dev/null 2>&1 || true + elif command -v jar >/dev/null 2>&1; then + ( cd "$t" && jar xf "$jar" "$entry" ) >/dev/null 2>&1 || true + elif command -v python3 >/dev/null 2>&1; then + python3 - "$jar" "$entry" "$t" <<'PY' || true +import sys, zipfile +jar, entry, dest = sys.argv[1], sys.argv[2], sys.argv[3] +with zipfile.ZipFile(jar) as z: + z.extract(entry, dest) +PY + else + echo "prebuild: need unzip, jar, or python3 to extract $entry from $(basename "$jar")" >&2 + rm -rf "$t"; return 1 + fi + [[ -f "$t/$entry" ]] || { echo "prebuild: entry $entry not found in $(basename "$jar")" >&2; rm -rf "$t"; return 1; } + install -Dm0644 "$t/$entry" "$dest" + rm -rf "$t" +} + +ensure_host_tools() { + local missing=() + command -v tar >/dev/null 2>&1 || missing+=(tar) + command -v curl >/dev/null 2>&1 || missing+=(curl) + command -v resize2fs >/dev/null 2>&1 || missing+=(e2fsprogs) + command -v e2fsck >/dev/null 2>&1 || missing+=(e2fsprogs) + # riscv64 also needs 'ar' (binutils) to unpack the Debian libc6 .deb for the glibc runtime + # bridge, and 'unzip' to pull the jar-bundled glibc riscv64 sqlite JNI (jar/python3 fallbacks + # exist, so unzip is best-effort). + if [[ "$arch" == riscv64 ]]; then + command -v ar >/dev/null 2>&1 || missing+=(binutils) + command -v unzip >/dev/null 2>&1 || missing+=(unzip) + fi + if [[ "$arch" == loongarch64 ]]; then + # cross-compiling the musl loongarch64 sqlite JNI needs unzip (SQLite amalgamation zip) + + # perl (the two sqlite3.c source patches xerial applies). The loongarch64-linux-musl-gcc + # cross-toolchain is provided out-of-band (StarryOS .starry-env.sh PATH), not apt. + command -v unzip >/dev/null 2>&1 || missing+=(unzip) + command -v perl >/dev/null 2>&1 || missing+=(perl) + fi + if [[ ${#missing[@]} -gt 0 ]]; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends "${missing[@]}" + else + echo "prebuild: missing host tools and no apt-get: ${missing[*]}" >&2; exit 1 + fi + fi +} + +# The reproducible in-prebuild compile needs a HOST javac (any JDK >= 17; --release 17 targets +# bytecode 61). The staged target-arch JDK17 cannot be used because its javac is a +# riscv64/loongarch64/aarch64 binary. If the host has no javac, install one via apt (a +# build-time toolchain dependency — NOT a committed binary). +ensure_host_jdk() { + command -v javac >/dev/null 2>&1 && return 0 + if command -v apt-get >/dev/null 2>&1; then + echo "prebuild: no host javac — installing a JDK for the in-prebuild compile" + apt-get update + apt-get install -y --no-install-recommends default-jdk-headless \ + || apt-get install -y --no-install-recommends openjdk-17-jdk-headless + fi + command -v javac >/dev/null 2>&1 || { + echo "prebuild: host javac is required to compile carpets.jar from source (install a JDK17+)" >&2 + exit 1; } +} + +# Grow the per-app rootfs so the injected JDK + jars fit without truncation. Idempotent. +grow_rootfs() { + [[ -f "$rootfs_img" ]] || { echo "prebuild: rootfs image missing: $rootfs_img" >&2; exit 2; } + # Grow-only: the per-app base image is shared and may already be larger (another app grew it); + # NEVER shrink it (truncate -s to a smaller size corrupts the ext4). Only truncate up. + local cur target + cur=$(stat -c %s "$rootfs_img"); target=$(( ${ROOTFS_SIZE%M} * 1024 * 1024 )) + [[ "$cur" -lt "$target" ]] && truncate -s "$ROOTFS_SIZE" "$rootfs_img" + e2fsck -f -y "$rootfs_img" >/dev/null 2>&1 || true + resize2fs "$rootfs_img" >/dev/null 2>&1 || { echo "prebuild: resize2fs failed on $rootfs_img" >&2; exit 2; } + echo "prebuild: rootfs sized to $(( $(stat -c %s "$rootfs_img")/1024/1024 )) MiB" +} + +untar_strip1() { + local arc="$1" dest="$2" + [[ -f "$arc" ]] || { echo "prebuild: missing archive $arc" >&2; exit 2; } + mkdir -p "$dest"; tar xzf "$arc" -C "$dest" --strip-components=1 +} + +# ── JDK17 provisioning (per-arch; every arch is DOWNLOADABLE on a clean checkout) ─────── +# loongarch64 apks are pinned by sha256. x86_64/aarch64 openjdk17 is a ROLLING Alpine v3.22 +# patch level: the fetch targets the CURRENT version (17.0.19_p10-r0; older patch levels age off +# the CDN) with sha left unpinned, and stage_jdk17's prefix-glob (openjdk17-*-*.apk) consumes any +# patch level so a populated JAVA_DL_ROOT holding an older golden is used network-free. riscv64 is +# the Adoptium Temurin prebuilt GLIBC tarball, pinned by sha256 (see JDK17_RISCV_* below). +JDK17_X86AA_VER="${JDK17_X86AA_VER:-17.0.19_p10-r0}" +# riscv64: downloadable prebuilt GLIBC JDK17 (Adoptium Temurin 17.0.19+10). Overridable to a +# mirror / BellSoft / distro build of the SAME major version via the env vars. +JDK17_RISCV_TAR="OpenJDK17U-jdk_riscv64_linux_hotspot_17.0.19_10.tar.gz" +JDK17_RISCV_URL="${JDK17_RISCV_URL:-https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_riscv64_linux_hotspot_17.0.19_10.tar.gz}" +JDK17_RISCV_SHA="${JDK17_RISCV_SHA:-191cdd904aef8b8a7a91c98d649c7e3dc75b7341f112061231c2094c418fd630}" +ensure_jdk17() { + local d="$DL/openjdk17-apks/$arch" + case "$arch" in + x86_64|aarch64) + local alp a; [[ "$arch" == x86_64 ]] && alp=x86_64 || alp=aarch64 + for a in openjdk17-jdk openjdk17-jmods openjdk17-jre-headless openjdk17-jre; do + [[ -n "$(ls "$d/${a}-"*.apk 2>/dev/null | head -1)" ]] && continue + ensure_asset "$d/${a}-${JDK17_X86AA_VER}.apk" "$ALPINE_CDN/v3.22/community/$alp/${a}-${JDK17_X86AA_VER}.apk" + done ;; + loongarch64) + local v=17.0.17_p10-r0 + ensure_asset "$d/openjdk17-loongarch-jdk-${v}.apk" "$ALPINE_CDN/edge/community/loongarch64/openjdk17-loongarch-jdk-${v}.apk" e55611f2280854e9bc4e76785b51decf840015d26888f3c4eb15df9d603cc49c + ensure_asset "$d/openjdk17-loongarch-jmods-${v}.apk" "$ALPINE_CDN/edge/community/loongarch64/openjdk17-loongarch-jmods-${v}.apk" d9ad8763f8d7a13b5ce2618444bc5fcc43081b9c20fed50ee50cedb9f1eedbc1 + ensure_asset "$d/openjdk17-loongarch-jre-headless-${v}.apk" "$ALPINE_CDN/edge/community/loongarch64/openjdk17-loongarch-jre-headless-${v}.apk" 42ae887f2099d44bbaa7531dad11d29da47796ba06637e1259427d5e2a55d80d + ensure_asset "$d/openjdk17-loongarch-jre-${v}.apk" "$ALPINE_CDN/edge/community/loongarch64/openjdk17-loongarch-jre-${v}.apk" 9f867f80ce79cbffe51623e38b3085cb62e6d0d98e459425d8452a24e275f26f ;; + riscv64) + # Alpine ships NO riscv64 openjdk17 (only 21/25), so use a DOWNLOADABLE prebuilt GLIBC + # riscv64 JDK17 (Adoptium Temurin). StarryOS runs both musl and glibc; the JDK's own + # ld-linux interp is satisfied by the Debian glibc closure staged by + # stage_glibc_runtime_rv. The prebuilt JDK statically bundles zlib/libstdc++/libgcc, + # so libc6 is its entire external closure. + ensure_asset "$d/$JDK17_RISCV_TAR" "$JDK17_RISCV_URL" "$JDK17_RISCV_SHA" ;; + esac +} + +# Stage JDK17 into $overlay_dir/opt/jdk17 (full JDK with javac), per-arch source. +stage_jdk17() { + local jdst="$overlay_dir/opt/jdk17" d="$DL/openjdk17-apks/$arch" + rm -rf "$jdst"; mkdir -p "$jdst" + case "$arch" in + x86_64|aarch64) + local T; T="$(mktemp -d)"; local a apk + for a in openjdk17-jdk openjdk17-jmods openjdk17-jre-headless openjdk17-jre; do + apk="$(ls "$d/${a}-"*.apk 2>/dev/null | head -1)" + [[ -n "$apk" ]] && tar xzf "$apk" -C "$T" 2>/dev/null || true + done + cp -a "$T/usr/lib/jvm/java-17-openjdk/." "$jdst/"; rm -rf "$T" ;; + loongarch64) + local T; T="$(mktemp -d)"; local a apk + for a in openjdk17-loongarch-jdk openjdk17-loongarch-jmods openjdk17-loongarch-jre-headless openjdk17-loongarch-jre; do + apk="$(ls "$d/${a}-"*.apk 2>/dev/null | head -1)" + [[ -n "$apk" ]] && tar xzf "$apk" -C "$T" 2>/dev/null || true + done + cp -a "$T"/usr/lib/jvm/*/. "$jdst/"; rm -rf "$T" ;; + riscv64) + # Adoptium tarball top-level dir is jdk-17.0.19+10/ -> strip it. + untar_strip1 "$d/$JDK17_RISCV_TAR" "$jdst" ;; + esac + [[ -x "$jdst/bin/java" ]] || { echo "prebuild: jdk17 staged without java for $arch" >&2; exit 3; } + echo "prebuild: jdk17 staged ($(du -sh "$jdst" | cut -f1))" +} + +# Stage a real Debian glibc runtime closure for riscv64 so the downloadable prebuilt GLIBC JDK17 +# (and the jar-bundled glibc riscv64 sqlite JNI) resolve their libc.so.6 / libm.so.6 / +# ld-linux-riscv64-lp64d.so.1 references. This MIRRORS the merged java-lang app's +# stage_real_glibc_rv byte-for-byte (the SAME Debian trixie libc6 deb + sha256): extract the +# libc6 deb and drop libc/libm/libpthread/librt/libdl into the multiarch search path plus the +# loader at its interp path (/lib/ld-linux-riscv64-lp64d.so.1). StarryOS runs BOTH musl and +# glibc, so the glibc JDK uses its own interp + this closure while the base rootfs stays musl. +# The prebuilt JDK statically bundles zlib/libstdc++/libgcc, so libc6 is the ENTIRE external +# closure it needs (verified via the JDK's readelf NEEDED: libc.so.6 libm.so.6 libpthread.so.0 +# libdl.so.2 librt.so.1). No-op on the all-musl arches (x86_64/aarch64/loongarch64). +GLIBC_RV_DEB="libc6_2.41-12+deb13u3_riscv64.deb" +GLIBC_RV_DEB_URL="${GLIBC_RV_DEB_URL:-http://deb.debian.org/debian/pool/main/g/glibc/libc6_2.41-12+deb13u3_riscv64.deb}" +GLIBC_RV_DEB_SHA="${GLIBC_RV_DEB_SHA:-fee42ebb2a148cc0dbc46ba938d8d69495b6dd5250cecafed9d585c567550b7a}" +stage_glibc_runtime_rv() { + [[ "$arch" == riscv64 ]] || return 0 + local deb="$DL/glibc-debian/riscv64/$GLIBC_RV_DEB" + ensure_asset "$deb" "$GLIBC_RV_DEB_URL" "$GLIBC_RV_DEB_SHA" + command -v ar >/dev/null 2>&1 || { echo "prebuild: need 'ar' (binutils) to unpack the libc6 deb" >&2; exit 1; } + local t; t="$(mktemp -d)" + ( cd "$t" && ar x "$deb" && tar xf data.tar.* ) + mkdir -p "$overlay_dir/lib/riscv64-linux-gnu" "$overlay_dir/usr/lib/riscv64-linux-gnu" + cp -a "$t"/usr/lib/riscv64-linux-gnu/. "$overlay_dir/usr/lib/riscv64-linux-gnu/" 2>/dev/null || true + cp -a "$t"/lib/riscv64-linux-gnu/. "$overlay_dir/lib/riscv64-linux-gnu/" 2>/dev/null || true + local ldso; ldso="$(find "$t" -name 'ld-linux-riscv64-lp64d.so.1' 2>/dev/null | head -1)" + [[ -n "$ldso" ]] && install -Dm0755 "$ldso" "$overlay_dir/lib/ld-linux-riscv64-lp64d.so.1" + rm -rf "$t" + [[ -e "$overlay_dir/lib/ld-linux-riscv64-lp64d.so.1" ]] \ + || { echo "prebuild: riscv64 glibc loader not staged from $deb" >&2; exit 4; } + echo "prebuild: staged REAL Debian glibc runtime for riscv64 (bridges the prebuilt glibc JDK17 + glibc sqlite JNI)" +} + +# ── Third-party dependency jars (Maven Central by sha256) ────────────────────────────── +# " ". is appended to $MAVEN_CENTRAL. +# Arch-independent (pure JVM bytecode) so the SAME set is staged for every arch. Every sha256 +# was host-verified AND cross-checked against Maven Central's published .sha1 for the artifact. +# These are the exact coordinates the previously-committed fat "demo jars" bundled (read from +# their META-INF/maven/**/pom.{properties,xml}; the pom-less shaded deps — hibernate-core, +# hibernate-community-dialects, hibernate-commons-annotations, jakarta.servlet-api, h2, +# reactor-core, reactive-streams — were pinned by byte-identical class matching). Grouped by +# framework module; run-jweb.sh assembles a curated per-module classpath from this set. +DEP_LIBS=( + # --- jetty + war module (embedded Jetty 11.0.21 + Jakarta Servlet 5.0.2) --- + "jetty-server-11.0.21.jar org/eclipse/jetty/jetty-server/11.0.21/jetty-server-11.0.21.jar 466e5572a5e11253c01c92374cc2304861f1d75ef97c47237da2a59c35848aa5" + "jetty-http-11.0.21.jar org/eclipse/jetty/jetty-http/11.0.21/jetty-http-11.0.21.jar 2a8d7acf56ec9586b58b8e779993181036190ec33ae5ca70acb4657fe364d233" + "jetty-io-11.0.21.jar org/eclipse/jetty/jetty-io/11.0.21/jetty-io-11.0.21.jar 934d8a2a274724faca69ae0a51e71359ee25eae9bfcfbdda5db60a647ca1d609" + "jetty-util-11.0.21.jar org/eclipse/jetty/jetty-util/11.0.21/jetty-util-11.0.21.jar ed26e0e1d0a3ac9bda98e7c211230f5d250f62117ffd46cc616d290beef18788" + "jetty-jakarta-servlet-api-5.0.2.jar org/eclipse/jetty/toolchain/jetty-jakarta-servlet-api/5.0.2/jetty-jakarta-servlet-api-5.0.2.jar efb20997729f32bfa6c8a8319037c353f7ad460d5d49f336bf232998ea2358db" + "slf4j-api-2.0.9.jar org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar 0818930dc8d7debb403204611691da58e49d42c50b6ffcfdce02dadb7c3c2b6c" + # --- netty module (Netty 4.1.112.Final: transport/buffer/codec/codec-http) --- + "netty-common-4.1.112.Final.jar io/netty/netty-common/4.1.112.Final/netty-common-4.1.112.Final.jar b03967f32c65de5ed339b97729170e0289b22ffa5729e7f45f68bf6b431fb567" + "netty-buffer-4.1.112.Final.jar io/netty/netty-buffer/4.1.112.Final/netty-buffer-4.1.112.Final.jar bc182c48f5369d48cd8370d2ab0c5b8d99dd8ffa4a0f8ac701652d57bd380eff" + "netty-resolver-4.1.112.Final.jar io/netty/netty-resolver/4.1.112.Final/netty-resolver-4.1.112.Final.jar 6b4ac9f3b67f562f0770d57c389279ff9c708eb401e1a3635f52297f0f897edc" + "netty-transport-4.1.112.Final.jar io/netty/netty-transport/4.1.112.Final/netty-transport-4.1.112.Final.jar d38e31624d25ca790ee413d529c152170217ebedbcdcf61164fa6291f3a56c92" + "netty-transport-native-unix-common-4.1.112.Final.jar io/netty/netty-transport-native-unix-common/4.1.112.Final/netty-transport-native-unix-common-4.1.112.Final.jar e79ccea1b87a6348d4ebd3dfb37a2cccd9b7cb65c3375f6ccdac086c7b5ce487" + "netty-codec-4.1.112.Final.jar io/netty/netty-codec/4.1.112.Final/netty-codec-4.1.112.Final.jar 72db4f93629f7ea520d2998c08e2b1d69f9c6a4792b53da5e9a001d24c78b151" + "netty-codec-http-4.1.112.Final.jar io/netty/netty-codec-http/4.1.112.Final/netty-codec-http-4.1.112.Final.jar 21b502d1374d6992728d004e0c1c95544d46d971f55ea78dcb854ce1ac0c83bc" + "netty-handler-4.1.112.Final.jar io/netty/netty-handler/4.1.112.Final/netty-handler-4.1.112.Final.jar ea4d6062a5fb10a6e2364d8bbdebc1cfa814f1fc9f910ef57e5caf02fb15c588" + # --- mybatis module (MyBatis 3.5.16 + ognl + javassist) --- + "mybatis-3.5.16.jar org/mybatis/mybatis/3.5.16/mybatis-3.5.16.jar 1814d02fccd8dbeadf628cbac8962b1edaab9bfa67e8585c6a3663c48bd8953d" + "ognl-3.4.2.jar ognl/ognl/3.4.2/ognl-3.4.2.jar efb6bf5cb5bcad7a88932bd30a0861e5aac7382215fbd1f714ef59b739912852" + "javassist-3.30.2-GA.jar org/javassist/javassist/3.30.2-GA/javassist-3.30.2-GA.jar eba37290994b5e4868f3af98ff113f6244a6b099385d9ad46881307d3cb01aaf" + # --- shared by mybatis + hibernate (sqlite-jdbc + slf4j) --- + "sqlite-jdbc-3.46.1.3.jar org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar 4a4832720a65eaf7f4d6fd7ede52087b994dc5633c076f9e994dc0c8b4b0b4fa" + "slf4j-api-2.0.13.jar org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.jar e7c2a48e8515ba1f49fa637d57b4e2f590b3f5bd97407ac699c3aa5efb1204a9" + "slf4j-simple-2.0.13.jar org/slf4j/slf4j-simple/2.0.13/slf4j-simple-2.0.13.jar 3153fe1d689cffb94f1530b58470c306685ba68844de8857116e3b6ebb81d9f7" + # --- hibernate module (Hibernate ORM 6.4.4.Final + Jakarta Persistence 3.1 + transitives) --- + "hibernate-core-6.4.4.Final.jar org/hibernate/orm/hibernate-core/6.4.4.Final/hibernate-core-6.4.4.Final.jar a1324b7c80c800826c9a5d74b61b0de768141f967c2b082650cb7bf4675570a7" + "hibernate-community-dialects-6.4.4.Final.jar org/hibernate/orm/hibernate-community-dialects/6.4.4.Final/hibernate-community-dialects-6.4.4.Final.jar c448fc799cc079ffbb5d9fb8c32d1a6e62d6ab75c50ceaf67723466f4134f28a" + "hibernate-commons-annotations-6.0.6.Final.jar org/hibernate/common/hibernate-commons-annotations/6.0.6.Final/hibernate-commons-annotations-6.0.6.Final.jar cd974e0a8481fafdbaf9b4a0f08bb5a6c969b0365482763eedf77e6fd7f493b7" + "jakarta.persistence-api-3.1.0.jar jakarta/persistence/jakarta.persistence-api/3.1.0/jakarta.persistence-api-3.1.0.jar 475389446d35c6f46c565728b756dc508c284644ea2690644e0d8e7e339d42fd" + "jakarta.transaction-api-2.0.1.jar jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar 50c0a7c760c13ae6c042acf182b28f0047413db95b4636fb8879bcffab5ba875" + "jboss-logging-3.5.0.Final.jar org/jboss/logging/jboss-logging/3.5.0.Final/jboss-logging-3.5.0.Final.jar 7bb135b081952f6d32d83374619ae5201b05ca3bf862a28dd111016ce19b2c07" + "jandex-3.1.2.jar io/smallrye/jandex/3.1.2/jandex-3.1.2.jar dee12fa1787d5523ed1a02d6c63b19e7aef6ac560f7c6d70595db01aa37e041e" + "classmate-1.5.1.jar com/fasterxml/classmate/1.5.1/classmate-1.5.1.jar aab4de3006808c09d25dd4ff4a3611cfb63c95463cfd99e73d2e1680d229a33b" + "byte-buddy-1.14.11.jar net/bytebuddy/byte-buddy/1.14.11/byte-buddy-1.14.11.jar 62ae28187ed2b062813da6a9d567bfee733c341582699b62dd980230729a0313" + "antlr4-runtime-4.13.0.jar org/antlr/antlr4-runtime/4.13.0/antlr4-runtime-4.13.0.jar bd7f7b5d07bc0b047f10915b32ca4bb1de9e57d8049098882e4453c88c076a5d" + "jakarta.inject-api-2.0.1.jar jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar f7dc98062fccf14126abb751b64fab12c312566e8cbdc8483598bffcea93af7c" + "jakarta.xml.bind-api-4.0.0.jar jakarta/xml/bind/jakarta.xml.bind-api/4.0.0/jakarta.xml.bind-api-4.0.0.jar 57e3796ad5753640088f5f9d3c53c183f2c250b7dad90529ea3e19a5515aa122" + "jakarta.activation-api-2.1.0.jar jakarta/activation/jakarta.activation-api/2.1.0/jakarta.activation-api-2.1.0.jar 56e8d994095fe49c28138c60291482f66f18d12ac2b720e938697dce6a3135c7" + "jaxb-runtime-4.0.2.jar org/glassfish/jaxb/jaxb-runtime/4.0.2/jaxb-runtime-4.0.2.jar 1bc271e61b71ca4bd89eb053f3d2c91d478211b02a8982cb520f216fe0e9a939" + "jaxb-core-4.0.2.jar org/glassfish/jaxb/jaxb-core/4.0.2/jaxb-core-4.0.2.jar d7ff2954ad78480bbab9391cccff3a22f42a82b6e09aeca1c7d502411c470ccd" + "txw2-4.0.2.jar org/glassfish/jaxb/txw2/4.0.2/txw2-4.0.2.jar ea71912e4f0a42530f77c9840ae90019c46402dedfdf007cff03797429a0cf0c" + "angus-activation-2.0.0.jar org/eclipse/angus/angus-activation/2.0.0/angus-activation-2.0.0.jar 3a12d321a0f35aa9458ff9b6ee93a3de76b78e3f18b077c81721473d83079147" + "istack-commons-runtime-4.1.1.jar com/sun/istack/istack-commons-runtime/4.1.1/istack-commons-runtime-4.1.1.jar 7e8148c5bf5d5ae6f8c4534c1873f82e80bf7f9164fd09ee573df0013918dcd3" + # --- r2dbc module (R2DBC 1.0 SPI + r2dbc-h2 over in-memory H2 2.1.214 + reactor) --- + "r2dbc-spi-1.0.0.RELEASE.jar io/r2dbc/r2dbc-spi/1.0.0.RELEASE/r2dbc-spi-1.0.0.RELEASE.jar a5846c59fea336431a4ae72ca14edbf5299b78486fa308eafb383f4ae0ea74e5" + "r2dbc-h2-1.0.0.RELEASE.jar io/r2dbc/r2dbc-h2/1.0.0.RELEASE/r2dbc-h2-1.0.0.RELEASE.jar 747a7ba0c34da6464fc2a50d89200b4475c310a376ec9b322f9594e25033ca49" + "h2-2.1.214.jar com/h2database/h2/2.1.214/h2-2.1.214.jar d623cdc0f61d218cf549a8d09f1c391ff91096116b22e2475475fce4fbe72bd0" + "reactor-core-3.6.11.jar io/projectreactor/reactor-core/3.6.11/reactor-core-3.6.11.jar 14d81b2a3c0343ad532dec6268d56bd991c57fc426506d69810105e3d1c8abe2" + "reactive-streams-1.0.4.jar org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar f75ca597789b3dac58f61857b9ac2e1034a68fa672db35055a8fb4509e325f28" +) + +DEP_CACHE="$DL/java-web-libs" +ensure_deps() { + mkdir -p "$DEP_CACHE" + local entry fname path sha + for entry in "${DEP_LIBS[@]}"; do + # shellcheck disable=SC2086 + set -- $entry; fname="$1"; path="$2"; sha="$3" + ensure_asset "$DEP_CACHE/$fname" "$MAVEN_CENTRAL/$path" "$sha" + done +} + +# ── sqlite-jdbc native JNI (.so), per arch (MyBatis + Hibernate) ─────────────────────── +# riscv64: the rv64 JDK17 is the prebuilt GLIBC build, so the matching JNI is the sqlite-jdbc +# jar's OWN bundled GLIBC riscv64 native (org/sqlite/native/Linux/riscv64/libsqlitejdbc.so). +# Extract it from the already-fetched, sha256-pinned jar — fully reproducible, no separate +# download, no cross-build. (Byte-identical to the merged java-jse case.) +# loongarch64: the upstream jar ships NO loongarch64 native at all; the loong JDK17 is +# Alpine-musl, so a musl loong JNI is CROSS-COMPILED IN-PREBUILD from xerial/sqlite-jdbc's own +# C source (build_loong_sqlite_jni below), reproducibly (sha256-pinned sources). If the loong +# musl cross-toolchain is genuinely unavailable, prebuild fails hard (no skip path). +# +# build_loong_sqlite_jni — cross-compile the musl loongarch64 sqlite-jdbc JNI exactly +# as xerial's own Makefile does, then install it at . Steps (all from official source): +# (1) fetch xerial/sqlite-jdbc SOURCE tag 3.46.1.3 + the official SQLite 3.46.1 amalgamation +# (both sha256-pinned via ensure_asset); +# (2) generate NativeDB.h with the HOST javac -h from NativeDB.java (sqlite-jdbc jar + slf4j-api +# on the classpath — both are already-fetched dependency jars); +# (3) patch sqlite3.c with xerial's two perl edits (register extension functions + the +# JDBC_EXTENSIONS compile-option) and append src/main/ext/*.c; +# (4) compile sqlite3.o + NativeDB.o with loongarch64-linux-musl-gcc using xerial's exact +# CCFLAGS + SQLITE feature flags, link `-shared -static-libgcc -pthread -lm`, strip. +# Returns non-zero if the cross-toolchain / perl / unzip is unavailable; the caller then fails +# prebuild hard (run-jweb.sh has no skip path). The built .so's own sha256 is NOT pinned (it +# varies with the cross-gcc version); reproducibility is anchored on the pinned SOURCE inputs. +SQLITE_JDBC_SRC_URL="${SQLITE_JDBC_SRC_URL:-https://github.com/xerial/sqlite-jdbc/archive/refs/tags/3.46.1.3.tar.gz}" +SQLITE_JDBC_SRC_SHA="${SQLITE_JDBC_SRC_SHA:-5d662eb23a0db84ef597ef1800811a6dc42727e0d5fc43b752efd3224dc2695c}" +SQLITE_AMAL_URL="${SQLITE_AMAL_URL:-https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip}" +SQLITE_AMAL_SHA="${SQLITE_AMAL_SHA:-77823cb110929c2bcb0f5d48e4833b5c59a8a6e40cdea3936b99e199dbbe5784}" +SQLITE_AMAL_DIR="sqlite-amalgamation-3460100" +LOONG_MUSL_CC="${LOONG_MUSL_CC:-loongarch64-linux-musl-gcc}" +LOONG_MUSL_STRIP="${LOONG_MUSL_STRIP:-loongarch64-linux-musl-strip}" +build_loong_sqlite_jni() { + local out="$1" + command -v "$LOONG_MUSL_CC" >/dev/null 2>&1 || { echo "prebuild: NOTE $LOONG_MUSL_CC not on PATH — cannot cross-build loong sqlite JNI" >&2; return 1; } + command -v perl >/dev/null 2>&1 || { echo "prebuild: NOTE perl missing — cannot patch sqlite3.c" >&2; return 1; } + command -v unzip >/dev/null 2>&1 || { echo "prebuild: NOTE unzip missing — cannot unpack the SQLite amalgamation" >&2; return 1; } + ensure_host_jdk # host javac -h to emit NativeDB.h + local srctar="$DL/sqlitejdbc-src/sqlite-jdbc-3.46.1.3.tar.gz" + local amalzip="$DL/sqlitejdbc-src/${SQLITE_AMAL_DIR}.zip" + ensure_asset "$srctar" "$SQLITE_JDBC_SRC_URL" "$SQLITE_JDBC_SRC_SHA" + ensure_asset "$amalzip" "$SQLITE_AMAL_URL" "$SQLITE_AMAL_SHA" + local jdbcjar="$DEP_CACHE/sqlite-jdbc-3.46.1.3.jar" slf4j="$DEP_CACHE/slf4j-api-2.0.13.jar" + [[ -f "$jdbcjar" && -f "$slf4j" ]] || { echo "prebuild: NOTE sqlite-jdbc/slf4j dependency jars missing — cannot generate NativeDB.h" >&2; return 1; } + local B; B="$(mktemp -d)" + tar xzf "$srctar" -C "$B" --strip-components=1 + unzip -qo "$amalzip" -d "$B/amal" + local SRC="$B/src/main/java" AMAL="$B/amal/$SQLITE_AMAL_DIR" + mkdir -p "$B/inc" "$B/o" + # (2) NativeDB.h via host javac -h (no -sourcepath: resolve org.sqlite.* from the compiled jar) + if ! javac -cp "$jdbcjar:$slf4j" -d "$B/cls" -h "$B/inc" "$SRC/org/sqlite/core/NativeDB.java" 2>"$B/javac.log"; then + echo "prebuild: NOTE host javac -h failed generating NativeDB.h:" >&2; tail -5 "$B/javac.log" >&2; rm -rf "$B"; return 1 + fi + mv -f "$B/inc/org_sqlite_core_NativeDB.h" "$B/inc/NativeDB.h" + # (3) patch sqlite3.c (xerial's two perl edits) + append the extension-functions source + perl -p -e 's/^opendb_out:/ if(!db->mallocFailed \&\& rc==SQLITE_OK){ rc = RegisterExtensionFunctions(db); }\nopendb_out:/;' "$AMAL/sqlite3.c" > "$B/o/sqlite3.c.tmp" + perl -p -e 's/^(static const char \* const sqlite3azCompileOpt.+)$/\1\n "JDBC_EXTENSIONS",/;' "$B/o/sqlite3.c.tmp" > "$B/o/sqlite3.c" + cat "$B"/src/main/ext/*.c >> "$B/o/sqlite3.c" + cp "$AMAL/sqlite3.h" "$B/o/sqlite3.h" + # (4) compile + link + strip, using xerial's exact CCFLAGS + SQLITE feature flags + local CCF="-I$B/lib/inc_linux -I$B/o -Os -fPIC -fvisibility=hidden" + local SQLF="-DSQLITE_ENABLE_LOAD_EXTENSION=1 -DSQLITE_HAVE_ISNAN -DHAVE_USLEEP=1 \ + -DSQLITE_ENABLE_COLUMN_METADATA -DSQLITE_CORE -DSQLITE_ENABLE_FTS3 -DSQLITE_ENABLE_FTS3_PARENTHESIS \ + -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_STAT4 -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_MATH_FUNCTIONS -DSQLITE_THREADSAFE=1 -DSQLITE_DEFAULT_MEMSTATUS=0 \ + -DSQLITE_DEFAULT_FILE_PERMISSIONS=0666 -DSQLITE_MAX_VARIABLE_NUMBER=250000 \ + -DSQLITE_MAX_MMAP_SIZE=1099511627776 -DSQLITE_MAX_LENGTH=2147483647 -DSQLITE_MAX_COLUMN=32767 \ + -DSQLITE_MAX_SQL_LENGTH=1073741824 -DSQLITE_MAX_FUNCTION_ARG=127 -DSQLITE_MAX_ATTACHED=125 \ + -DSQLITE_MAX_PAGE_COUNT=4294967294 -DSQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS" + echo "prebuild: cross-compiling loongarch64 sqlite JNI with $($LOONG_MUSL_CC -dumpversion 2>/dev/null) (this takes ~1 min)" + # shellcheck disable=SC2086 + if ! "$LOONG_MUSL_CC" -o "$B/o/sqlite3.o" -c $CCF $SQLF "$B/o/sqlite3.c" 2>"$B/cc.log" \ + || ! "$LOONG_MUSL_CC" $CCF -I "$B/inc" -c -o "$B/o/NativeDB.o" "$SRC/org/sqlite/core/NativeDB.c" 2>>"$B/cc.log" \ + || ! "$LOONG_MUSL_CC" $CCF -o "$B/o/libsqlitejdbc.so" "$B/o/NativeDB.o" "$B/o/sqlite3.o" -shared -static-libgcc -pthread -lm 2>>"$B/cc.log"; then + echo "prebuild: NOTE loong sqlite JNI cross-compile failed:" >&2; tail -8 "$B/cc.log" >&2; rm -rf "$B"; return 1 + fi + command -v "$LOONG_MUSL_STRIP" >/dev/null 2>&1 && "$LOONG_MUSL_STRIP" "$B/o/libsqlitejdbc.so" 2>/dev/null || true + # sanity: the link succeeded and the output is an ELF shared object (magic 0x7f 'ELF'). + if [[ "$(head -c4 "$B/o/libsqlitejdbc.so" 2>/dev/null | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]]; then + echo "prebuild: NOTE loong sqlite JNI build produced a non-ELF object" >&2; rm -rf "$B"; return 1 + fi + install -Dm0644 "$B/o/libsqlitejdbc.so" "$out" + rm -rf "$B" + return 0 +} +ensure_sqlite_native() { + case "$arch" in + x86_64|aarch64) return 0 ;; # driver self-extracts its bundled Linux-Musl JNI + esac + local so="$DL/sqlitejdbc-native/$arch/libsqlitejdbc.so" + case "$arch" in + riscv64) + if [[ ! -f "$so" ]]; then + extract_jar_entry "$DEP_CACHE/sqlite-jdbc-3.46.1.3.jar" \ + "org/sqlite/native/Linux/riscv64/libsqlitejdbc.so" "$so" \ + || { echo "prebuild: ERROR could not extract the glibc riscv64 sqlite JNI from the pinned jar; MyBatis/Hibernate need it and run-jweb.sh fails (never skipped) without it" >&2; return 1; } + fi + echo "prebuild: sqlite-jdbc riscv64 JNI = jar-bundled glibc build (extracted from the pinned jar, staged)" ;; + loongarch64) + if [[ -f "$so" ]]; then + echo "prebuild: sqlite-jdbc loongarch64 JNI present in cache (musl loong build; will be staged)" + elif build_loong_sqlite_jni "$so"; then + echo "prebuild: sqlite-jdbc loongarch64 JNI cross-compiled in-prebuild from official source (musl loong; staged)" + else + echo "prebuild: ERROR could not cross-compile the loongarch64 sqlite JNI (needs the loongarch64-linux-musl cross-toolchain); MyBatis/Hibernate need it and run-jweb.sh fails (never skipped) without it" >&2 + return 1 + fi ;; + esac +} + +# ── Compile the carpet classes IN-PREBUILD (host javac, --release 17) ────────────────── +# Compiles programs/carpets/*.java with the full fetched dep set on the classpath into +# carpets.jar (org.starry.dod.*). Bytecode is arch-independent, so the result is cached and +# reused across arches. +CARPET_JAR="$DL/java-web-build/carpets.jar" +compile_carpets() { + ensure_host_jdk + mkdir -p "$(dirname "$CARPET_JAR")" + local cp; cp="$(printf '%s:' "$DEP_CACHE"/*.jar)" + local B; B="$(mktemp -d)" + echo "prebuild: compiling carpets.jar with host $(javac -version 2>&1) (--release 17)" + if javac --release 17 -encoding UTF-8 -cp "$cp" \ + -d "$B/classes" "$PROG"/carpets/*.java 2>"$B/javac.log"; then + ( cd "$B/classes" && jar cf "$B/carpets.jar" . ) + mv -f "$B/carpets.jar" "$CARPET_JAR" + echo "prebuild: compiled carpets.jar in-prebuild ($(du -h "$CARPET_JAR" | cut -f1))" + rm -rf "$B" + else + echo "prebuild: ERROR host javac failed to compile the carpets:" >&2 + cat "$B/javac.log" >&2 || true + rm -rf "$B"; exit 5 + fi + [[ -s "$CARPET_JAR" ]] || { echo "prebuild: carpets.jar not produced" >&2; exit 5; } +} + +# Stage carpets.jar + the dependency jars into /root/jweb, the per-arch sqlite JNI native into +# /root/jweb/native (rv/loong, when available), and the run-jweb.sh gate into /usr/bin. +stage_payload() { + local jw="$overlay_dir/root/jweb" + install -d "$jw" "$jw/libs" "$jw/native" + install -m0644 "$CARPET_JAR" "$jw/carpets.jar" + local entry fname + for entry in "${DEP_LIBS[@]}"; do + # shellcheck disable=SC2086 + set -- $entry; fname="$1" + install -m0644 "$DEP_CACHE/$fname" "$jw/libs/$fname" + done + case "$arch" in + riscv64|loongarch64) + local so="$DL/sqlitejdbc-native/$arch/libsqlitejdbc.so" + if [[ -f "$so" ]]; then + install -m0644 "$so" "$jw/native/libsqlitejdbc.so" + echo "prebuild: staged sqlite-jdbc JNI for $arch into /root/jweb/native" + else + echo "prebuild: ERROR sqlite-jdbc JNI for $arch missing at stage time (ensure_sqlite_native should have provisioned or failed); run-jweb.sh has no skip path" >&2 + exit 6 + fi ;; + esac + install -Dm0755 "$PROG/run-jweb.sh" "$overlay_dir/usr/bin/run-jweb.sh" + echo "prebuild: staged carpets.jar + $(ls "$jw/libs" | wc -l) dependency jars into /root/jweb, run-jweb.sh into /usr/bin" +} + +main() { + case "$arch" in x86_64|aarch64|riscv64|loongarch64) ;; *) echo "prebuild: unsupported arch: $arch" >&2; exit 1 ;; esac + ensure_host_tools + ensure_jdk17 + ensure_deps + ensure_sqlite_native || { echo "prebuild: ERROR sqlite-jdbc JNI required for $arch but could not be provisioned; run-jweb.sh runs all six carpets with no skip path, so this is a hard failure" >&2; exit 6; } + grow_rootfs + stage_jdk17 + stage_glibc_runtime_rv # riscv64 only: real Debian glibc closure for the prebuilt glibc JDK17 + compile_carpets + stage_payload + echo "prebuild: java-web overlay ready for $arch — $(du -sh "$overlay_dir" | cut -f1)" +} + +main "$@" diff --git a/apps/starry/java-web/programs/SOURCES.md b/apps/starry/java-web/programs/SOURCES.md new file mode 100644 index 0000000000..f11842260d --- /dev/null +++ b/apps/starry/java-web/programs/SOURCES.md @@ -0,0 +1,214 @@ +# java-web — asset provenance (source-only repo) + +This app commits **only source + manifests**. No compiled framework `.jar` and no native +`.so` are checked in. `prebuild.sh` fetches every third-party dependency from Maven Central by +sha256, compiles the six carpet classes in-prebuild with `javac --release 17`, and stages the +result into the per-app rootfs. Every arch — including riscv64 — is provisioned by a download, so +a clean checkout runs end-to-end without any pre-seeded private cache. This file records the +provenance of every fetched / built asset. + +## Third-party dependency jars (Maven Central, fetched by sha256) + +Base URL: `https://repo1.maven.org/maven2/` + ``. Each sha256 was verified against +Maven Central's published `.sha1` for the same artifact. These are the exact coordinates the +previously-committed fat "demo jars" bundled: coordinates with a `pom.properties` were read +directly from `META-INF/maven/**/pom.properties`; the pom-less shaded deps (hibernate-core, +hibernate-community-dialects, hibernate-commons-annotations, jetty-jakarta-servlet-api, h2, +reactor-core, reactive-streams) were pinned by **byte-identical `.class` matching** against +Maven Central releases (see notes at the bottom). All are arch-independent JVM bytecode, so the +same set is staged for every architecture. `run-jweb.sh` assembles a curated per-module +classpath from this set. + +### jetty + war module — embedded Eclipse Jetty 11.0.21 + Jakarta Servlet 5.0.2 + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| jetty-server 11.0.21 | org/eclipse/jetty/jetty-server/11.0.21/jetty-server-11.0.21.jar | 466e5572a5e11253c01c92374cc2304861f1d75ef97c47237da2a59c35848aa5 | +| jetty-http 11.0.21 | org/eclipse/jetty/jetty-http/11.0.21/jetty-http-11.0.21.jar | 2a8d7acf56ec9586b58b8e779993181036190ec33ae5ca70acb4657fe364d233 | +| jetty-io 11.0.21 | org/eclipse/jetty/jetty-io/11.0.21/jetty-io-11.0.21.jar | 934d8a2a274724faca69ae0a51e71359ee25eae9bfcfbdda5db60a647ca1d609 | +| jetty-util 11.0.21 | org/eclipse/jetty/jetty-util/11.0.21/jetty-util-11.0.21.jar | ed26e0e1d0a3ac9bda98e7c211230f5d250f62117ffd46cc616d290beef18788 | +| jetty-jakarta-servlet-api 5.0.2 | org/eclipse/jetty/toolchain/jetty-jakarta-servlet-api/5.0.2/jetty-jakarta-servlet-api-5.0.2.jar | efb20997729f32bfa6c8a8319037c353f7ad460d5d49f336bf232998ea2358db | +| slf4j-api 2.0.9 | org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar | 0818930dc8d7debb403204611691da58e49d42c50b6ffcfdce02dadb7c3c2b6c | + +### netty module — Netty 4.1.112.Final + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| netty-common 4.1.112.Final | io/netty/netty-common/4.1.112.Final/netty-common-4.1.112.Final.jar | b03967f32c65de5ed339b97729170e0289b22ffa5729e7f45f68bf6b431fb567 | +| netty-buffer 4.1.112.Final | io/netty/netty-buffer/4.1.112.Final/netty-buffer-4.1.112.Final.jar | bc182c48f5369d48cd8370d2ab0c5b8d99dd8ffa4a0f8ac701652d57bd380eff | +| netty-resolver 4.1.112.Final | io/netty/netty-resolver/4.1.112.Final/netty-resolver-4.1.112.Final.jar | 6b4ac9f3b67f562f0770d57c389279ff9c708eb401e1a3635f52297f0f897edc | +| netty-transport 4.1.112.Final | io/netty/netty-transport/4.1.112.Final/netty-transport-4.1.112.Final.jar | d38e31624d25ca790ee413d529c152170217ebedbcdcf61164fa6291f3a56c92 | +| netty-transport-native-unix-common 4.1.112.Final | io/netty/netty-transport-native-unix-common/4.1.112.Final/netty-transport-native-unix-common-4.1.112.Final.jar | e79ccea1b87a6348d4ebd3dfb37a2cccd9b7cb65c3375f6ccdac086c7b5ce487 | +| netty-codec 4.1.112.Final | io/netty/netty-codec/4.1.112.Final/netty-codec-4.1.112.Final.jar | 72db4f93629f7ea520d2998c08e2b1d69f9c6a4792b53da5e9a001d24c78b151 | +| netty-codec-http 4.1.112.Final | io/netty/netty-codec-http/4.1.112.Final/netty-codec-http-4.1.112.Final.jar | 21b502d1374d6992728d004e0c1c95544d46d971f55ea78dcb854ce1ac0c83bc | +| netty-handler 4.1.112.Final | io/netty/netty-handler/4.1.112.Final/netty-handler-4.1.112.Final.jar | ea4d6062a5fb10a6e2364d8bbdebc1cfa814f1fc9f910ef57e5caf02fb15c588 | + +> The fat `netty-demo.jar` bundled `netty-all` 4.1.112.Final, but at 4.1.x `netty-all` on +> Maven Central is an **empty 4.5 KB aggregator** (no classes) — it only declares the split +> modules as dependencies. The carpet's `io.netty.*` imports resolve to the eight split modules +> above (`netty-handler` + `netty-transport-native-unix-common` are pulled in by +> `netty-codec-http`). `jctools` is shaded inside `netty-common`, so no separate jctools jar is +> needed. + +### mybatis module — MyBatis 3.5.16 + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| mybatis 3.5.16 | org/mybatis/mybatis/3.5.16/mybatis-3.5.16.jar | 1814d02fccd8dbeadf628cbac8962b1edaab9bfa67e8585c6a3663c48bd8953d | +| ognl 3.4.2 | ognl/ognl/3.4.2/ognl-3.4.2.jar | efb6bf5cb5bcad7a88932bd30a0861e5aac7382215fbd1f714ef59b739912852 | +| javassist 3.30.2-GA | org/javassist/javassist/3.30.2-GA/javassist-3.30.2-GA.jar | eba37290994b5e4868f3af98ff113f6244a6b099385d9ad46881307d3cb01aaf | + +### shared by mybatis + hibernate — sqlite-jdbc + SLF4J + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| sqlite-jdbc 3.46.1.3 | org/xerial/sqlite-jdbc/3.46.1.3/sqlite-jdbc-3.46.1.3.jar | 4a4832720a65eaf7f4d6fd7ede52087b994dc5633c076f9e994dc0c8b4b0b4fa | +| slf4j-api 2.0.13 | org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.jar | e7c2a48e8515ba1f49fa637d57b4e2f590b3f5bd97407ac699c3aa5efb1204a9 | +| slf4j-simple 2.0.13 | org/slf4j/slf4j-simple/2.0.13/slf4j-simple-2.0.13.jar | 3153fe1d689cffb94f1530b58470c306685ba68844de8857116e3b6ebb81d9f7 | + +### hibernate module — Hibernate ORM 6.4.4.Final + Jakarta Persistence 3.1 + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| hibernate-core 6.4.4.Final | org/hibernate/orm/hibernate-core/6.4.4.Final/hibernate-core-6.4.4.Final.jar | a1324b7c80c800826c9a5d74b61b0de768141f967c2b082650cb7bf4675570a7 | +| hibernate-community-dialects 6.4.4.Final | org/hibernate/orm/hibernate-community-dialects/6.4.4.Final/hibernate-community-dialects-6.4.4.Final.jar | c448fc799cc079ffbb5d9fb8c32d1a6e62d6ab75c50ceaf67723466f4134f28a | +| hibernate-commons-annotations 6.0.6.Final | org/hibernate/common/hibernate-commons-annotations/6.0.6.Final/hibernate-commons-annotations-6.0.6.Final.jar | cd974e0a8481fafdbaf9b4a0f08bb5a6c969b0365482763eedf77e6fd7f493b7 | +| jakarta.persistence-api 3.1.0 | jakarta/persistence/jakarta.persistence-api/3.1.0/jakarta.persistence-api-3.1.0.jar | 475389446d35c6f46c565728b756dc508c284644ea2690644e0d8e7e339d42fd | +| jakarta.transaction-api 2.0.1 | jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar | 50c0a7c760c13ae6c042acf182b28f0047413db95b4636fb8879bcffab5ba875 | +| jboss-logging 3.5.0.Final | org/jboss/logging/jboss-logging/3.5.0.Final/jboss-logging-3.5.0.Final.jar | 7bb135b081952f6d32d83374619ae5201b05ca3bf862a28dd111016ce19b2c07 | +| jandex 3.1.2 | io/smallrye/jandex/3.1.2/jandex-3.1.2.jar | dee12fa1787d5523ed1a02d6c63b19e7aef6ac560f7c6d70595db01aa37e041e | +| classmate 1.5.1 | com/fasterxml/classmate/1.5.1/classmate-1.5.1.jar | aab4de3006808c09d25dd4ff4a3611cfb63c95463cfd99e73d2e1680d229a33b | +| byte-buddy 1.14.11 | net/bytebuddy/byte-buddy/1.14.11/byte-buddy-1.14.11.jar | 62ae28187ed2b062813da6a9d567bfee733c341582699b62dd980230729a0313 | +| antlr4-runtime 4.13.0 | org/antlr/antlr4-runtime/4.13.0/antlr4-runtime-4.13.0.jar | bd7f7b5d07bc0b047f10915b32ca4bb1de9e57d8049098882e4453c88c076a5d | +| jakarta.inject-api 2.0.1 | jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar | f7dc98062fccf14126abb751b64fab12c312566e8cbdc8483598bffcea93af7c | +| jakarta.xml.bind-api 4.0.0 | jakarta/xml/bind/jakarta.xml.bind-api/4.0.0/jakarta.xml.bind-api-4.0.0.jar | 57e3796ad5753640088f5f9d3c53c183f2c250b7dad90529ea3e19a5515aa122 | +| jakarta.activation-api 2.1.0 | jakarta/activation/jakarta.activation-api/2.1.0/jakarta.activation-api-2.1.0.jar | 56e8d994095fe49c28138c60291482f66f18d12ac2b720e938697dce6a3135c7 | +| jaxb-runtime 4.0.2 | org/glassfish/jaxb/jaxb-runtime/4.0.2/jaxb-runtime-4.0.2.jar | 1bc271e61b71ca4bd89eb053f3d2c91d478211b02a8982cb520f216fe0e9a939 | +| jaxb-core 4.0.2 | org/glassfish/jaxb/jaxb-core/4.0.2/jaxb-core-4.0.2.jar | d7ff2954ad78480bbab9391cccff3a22f42a82b6e09aeca1c7d502411c470ccd | +| txw2 4.0.2 | org/glassfish/jaxb/txw2/4.0.2/txw2-4.0.2.jar | ea71912e4f0a42530f77c9840ae90019c46402dedfdf007cff03797429a0cf0c | +| angus-activation 2.0.0 | org/eclipse/angus/angus-activation/2.0.0/angus-activation-2.0.0.jar | 3a12d321a0f35aa9458ff9b6ee93a3de76b78e3f18b077c81721473d83079147 | +| istack-commons-runtime 4.1.1 | com/sun/istack/istack-commons-runtime/4.1.1/istack-commons-runtime-4.1.1.jar | 7e8148c5bf5d5ae6f8c4534c1873f82e80bf7f9164fd09ee573df0013918dcd3 | + +### r2dbc module — R2DBC 1.0 SPI + r2dbc-h2 over in-memory H2 2.1.214 + +| artifact | maven-path | sha256 | +|:--|:--|:--| +| r2dbc-spi 1.0.0.RELEASE | io/r2dbc/r2dbc-spi/1.0.0.RELEASE/r2dbc-spi-1.0.0.RELEASE.jar | a5846c59fea336431a4ae72ca14edbf5299b78486fa308eafb383f4ae0ea74e5 | +| r2dbc-h2 1.0.0.RELEASE | io/r2dbc/r2dbc-h2/1.0.0.RELEASE/r2dbc-h2-1.0.0.RELEASE.jar | 747a7ba0c34da6464fc2a50d89200b4475c310a376ec9b322f9594e25033ca49 | +| h2 2.1.214 | com/h2database/h2/2.1.214/h2-2.1.214.jar | d623cdc0f61d218cf549a8d09f1c391ff91096116b22e2475475fce4fbe72bd0 | +| reactor-core 3.6.11 | io/projectreactor/reactor-core/3.6.11/reactor-core-3.6.11.jar | 14d81b2a3c0343ad532dec6268d56bd991c57fc426506d69810105e3d1c8abe2 | +| reactive-streams 1.0.4 | org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar | f75ca597789b3dac58f61857b9ac2e1034a68fa672db35055a8fb4509e325f28 | + +> The r2dbc module deliberately pins **h2 2.1.214** (not the newer 2.2.x used elsewhere): +> `r2dbc-h2` 1.0.0.RELEASE is compiled against H2 2.1.x internal APIs and is the exact version +> the fat `r2dbc-demo.jar` bundled. + +## Source → carpet-class mapping (compiled in-prebuild into `carpets.jar`) + +`javac --release 17 -cp programs/carpets/*.java` (host javac; `--release 17` +bytecode is identical for every target arch, so `carpets.jar` is cached and reused). + +| source | class | runtime deps (run-jweb.sh classpath) | marker | +|:--|:--|:--|:--| +| programs/carpets/JettyCarpet.java | org.starry.dod.JettyCarpet | jetty-server/http/io/util + jetty-jakarta-servlet-api + slf4j-api 2.0.9 | JETTY_DONE | +| programs/carpets/NettyCarpet.java | org.starry.dod.NettyCarpet | netty-common/buffer/resolver/transport(+unix-common)/codec/codec-http/handler | NETTY_DONE | +| programs/carpets/MyBatisCarpet.java | org.starry.dod.MyBatisCarpet (+ User, UserMapper) | mybatis + ognl + javassist + sqlite-jdbc + slf4j-api/simple 2.0.13 | MYBATIS_DONE | +| programs/carpets/HibernateCarpet.java | org.starry.dod.HibernateCarpet | hibernate-core + community-dialects + commons-annotations + jakarta.persistence/transaction/inject + jboss-logging + jandex + classmate + byte-buddy + antlr4-runtime + jakarta.xml.bind/activation + jaxb-runtime/core/txw2 + angus-activation + istack-commons-runtime + sqlite-jdbc + slf4j-api/simple 2.0.13 | HIBERNATE_DONE | +| programs/carpets/R2dbcCarpet.java | org.starry.dod.R2dbcCarpet | r2dbc-spi + r2dbc-h2 + h2 2.1.214 + reactor-core + reactive-streams | R2DBC_DONE | +| programs/carpets/WarCarpet.java | org.starry.dod.WarCarpet | jetty-server/http/io/util + jetty-jakarta-servlet-api + slf4j-api 2.0.9 (compiles servlets in-process with the JDK `javax.tools` compiler, servlet-api on the compiler classpath) | WAR_DONE | + +All six sources are in package `org.starry.dod`; `MyBatisCarpet.java` also declares the +package-private helper classes `User` and `UserMapper`. They are compiled together (the full +dep set on the classpath) but each RUNS with only its own module's curated classpath, so there +is never a duplicate SLF4J binding, servlet-api, or H2 on any one classpath. + +## OpenJDK 17 (per-arch; StarryOS runs both musl and glibc) + +StarryOS is libc-agnostic, so any prebuilt JDK17 of the matching major version works regardless +of the libc it was built against. Every arch is provisioned by a download on a clean checkout. + +- **x86_64 / aarch64**: Alpine v3.22/community `openjdk17-*` apks (musl; rolling patch level — + sha unpinned, cache copy authoritative, `JDK17_X86AA_VER` default `17.0.19_p10-r0`). +- **loongarch64**: Alpine edge/community `openjdk17-loongarch-*` apks `17.0.17_p10-r0` (musl; + sha256 pinned in `prebuild.sh`). +- **riscv64**: a **downloadable prebuilt glibc JDK17** — Adoptium Temurin `17.0.19+10` — because + **Alpine ships no riscv64 openjdk17** (only openjdk21/25 for riscv64). Bridged by a staged real + Debian glibc runtime closure (the JDK's own `ld-linux-riscv64-lp64d.so.1` interpreter loads it). + + | asset | URL | sha256 | + |:--|:--|:--| + | JDK17 tarball | https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_riscv64_linux_hotspot_17.0.19_10.tar.gz | 191cdd904aef8b8a7a91c98d649c7e3dc75b7341f112061231c2094c418fd630 | + | glibc runtime | http://deb.debian.org/debian/pool/main/g/glibc/libc6_2.41-12+deb13u3_riscv64.deb | fee42ebb2a148cc0dbc46ba938d8d69495b6dd5250cecafed9d585c567550b7a | + + The JDK statically bundles zlib/libstdc++/libgcc, so its only external closure is `libc6` + (verified via `readelf -d`: `libc.so.6 libm.so.6 libpthread.so.0 libdl.so.2 librt.so.1`), which + the Debian `libc6` deb supplies (into `/usr/lib/riscv64-linux-gnu` + the loader at + `/lib/ld-linux-riscv64-lp64d.so.1`). This is byte-for-byte the same glibc-runtime bridge the + merged `java-lang` app stages for its riscv64 JDK23 cell (`stage_real_glibc_rv`), and is + identical to the merged `java-jse` case. BellSoft Liberica generic-glibc riscv64 and the Debian + apt `openjdk-17-jdk-headless:riscv64` build are equivalent downloadable sources of the same + JDK17 (override via `JDK17_RISCV_URL` / `JDK17_RISCV_SHA`). + +## sqlite-jdbc native JNI (`libsqlitejdbc.so`), per arch (MyBatis + Hibernate) + +- **x86_64 / aarch64**: the fetched `sqlite-jdbc-3.46.1.3.jar` BUNDLES a musl JNI at + `org/sqlite/native/Linux-Musl/{x86_64,aarch64}/libsqlitejdbc.so`; the driver self-extracts and + `dlopen`s it at run time. Nothing is staged, nothing is committed. +- **riscv64**: the rv64 JDK17 is the prebuilt **glibc** build, so the matching JNI is the jar's + **own bundled glibc riscv64 native** at `org/sqlite/native/Linux/riscv64/libsqlitejdbc.so` + (its only external NEEDED are `libm.so.6` + `libc.so.6`, both in the staged Debian glibc + closure). `prebuild.sh` extracts it from the already-fetched, sha256-pinned jar — no extra + download and no cross-build — and stages it at `/root/jweb/native/libsqlitejdbc.so`; + `run-jweb.sh` points `org.sqlite.lib.path` at it. Byte-identical to the merged **java-jse** case. +- **loongarch64**: the upstream jar ships **no** loongarch64 native at all (neither + `Linux/loongarch64/` nor `Linux-Musl/loongarch64/`), and no vendor prebuilds one, so the loong + JDK17 (Alpine-musl) needs a musl loongarch64 JNI that is **cross-compiled in-prebuild from + official source** — `prebuild.sh`'s `build_loong_sqlite_jni` reproduces xerial/sqlite-jdbc's own + native build. It is **reproducible** (the two source inputs are sha256-pinned) and **not + committed**; it needs the `loongarch64-linux-musl-gcc` cross-toolchain (StarryOS `.starry-env.sh` + PATH). Steps, exactly as xerial's `Makefile`: + + | source input | URL | sha256 | + |:--|:--|:--| + | sqlite-jdbc source (tag 3.46.1.3) | https://github.com/xerial/sqlite-jdbc/archive/refs/tags/3.46.1.3.tar.gz | 5d662eb23a0db84ef597ef1800811a6dc42727e0d5fc43b752efd3224dc2695c | + | SQLite amalgamation 3.46.1 | https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip | 77823cb110929c2bcb0f5d48e4833b5c59a8a6e40cdea3936b99e199dbbe5784 | + + 1. generate `NativeDB.h` with the host `javac -h` from `src/main/java/org/sqlite/core/NativeDB.java` + (the fetched `sqlite-jdbc` + `slf4j-api-2.0.13` jars on the classpath); + 2. patch `sqlite3.c` with xerial's two `perl` edits (register the extension functions on + `openDatabase`, add the `JDBC_EXTENSIONS` compile-option) and append `src/main/ext/*.c`; + 3. `loongarch64-linux-musl-gcc -Os -fPIC -fvisibility=hidden` compile `sqlite3.o` (with xerial's + exact `-DSQLITE_ENABLE_FTS3/FTS5/RTREE/STAT4/DBSTAT_VTAB/COLUMN_METADATA/MATH_FUNCTIONS/…` + flag set) + `NativeDB.o`, link `-shared -static-libgcc -pthread -lm`, strip. + + The result is a LoongArch ELF64 musl shared object (`NEEDED: libc.so`, exporting the 61 + `Java_org_sqlite_core_NativeDB_*` symbols, embedding SQLite 3.46.1) staged at + `/root/jweb/native/libsqlitejdbc.so`; `run-jweb.sh` points `org.sqlite.lib.path` at it. Its own + sha256 is **not** pinned (it varies with the cross-gcc version) — reproducibility is anchored on + the two sha256-pinned source inputs + the fixed recipe. A developer who has already built it can + seed the cache (`$JAVA_DL_ROOT/sqlitejdbc-native/loongarch64/libsqlitejdbc.so`) to skip the + ~1-min compile. The JNI is provisioned on all four arches; if the loong musl cross-toolchain is + genuinely unavailable, `prebuild.sh` **fails hard** (no skip path). `run-jweb.sh` always runs and + counts all six carpets — a missing or unloadable JNI is a real FAIL, never a documented skip. The + jetty / netty / r2dbc / war carpets do not use sqlite. + +## Version-pinning notes (pom-less shaded deps) + +The fat demo jars were assembled with the maven-shade plugin, which kept `pom.properties` for +most bundled deps but not for a few. Those were pinned by extracting the bundled `.class` files +and finding the Maven Central release whose classes are **byte-identical**: + +- **hibernate-core / hibernate-community-dialects 6.4.4.Final** — the bundled transitive + `byte-buddy` is 1.14.11, which hibernate-core first declares at 6.4.4.Final (6.4.2/6.4.3 use + 1.14.7); all 6733 shared `org/hibernate/**` classes are byte-identical to 6.4.4.Final (0 + differing; later 6.4.x patch levels diverge). `SQLiteDialect` lives in + hibernate-community-dialects; `hibernate-commons-annotations` 6.0.6.Final supplies the + `org/hibernate/annotations/common/**` classes. +- **jetty-jakarta-servlet-api 5.0.2** — `jakarta.servlet:jakarta.servlet-api` has no 5.0.2 + (only 5.0.0); jetty 11 uses `org.eclipse.jetty.toolchain:jetty-jakarta-servlet-api`, and + jetty-server 11.0.21 resolves 5.0.2. All 85 bundled `jakarta/servlet/**` classes are + byte-identical to toolchain 5.0.2 (5.0.1 is also byte-identical; 5.0.2 is the version + jetty 11.0.21 pins). +- **h2 2.1.214, reactor-core 3.6.11, reactive-streams 1.0.4** — `Constants` / `Flux` / + `Publisher` classes byte-match these exact releases (reactor-core 3.6.11's Maven upload + timestamp 2024-10-15 06:42 matches the bundled `Flux.class` timestamp 2024-10-15 06:41). diff --git a/apps/starry/java-web/programs/carpets/HibernateCarpet.java b/apps/starry/java-web/programs/carpets/HibernateCarpet.java new file mode 100644 index 0000000000..2c07a2e475 --- /dev/null +++ b/apps/starry/java-web/programs/carpets/HibernateCarpet.java @@ -0,0 +1,957 @@ +package org.starry.dod; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.NamedQuery; +import jakarta.persistence.NoResultException; +import jakarta.persistence.NonUniqueResultException; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.Version; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.ParameterExpression; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.hibernate.Hibernate; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.cfg.Configuration; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Industrial / carpet-grade Hibernate ORM + Jakarta Persistence coverage suite. + * + * Framework: Hibernate ORM 6.x (Jakarta Persistence 3.1) over an in-memory + * SQLite database (org.xerial sqlite-jdbc 3.46.x, shared-cache memory mode), + * community SQLiteDialect. Single process, no external network, /tmp-free + * (pure in-memory). Self-counting; prints HIBERNATE_DONE only when fail==0. + */ +public class HibernateCarpet { + + /* shared-cache in-memory db kept alive by a held keepalive connection */ + static final String URL = "jdbc:sqlite:file:starrycarpetdb?mode=memory&cache=shared"; + + static int ok = 0; + static int fail = 0; + + static void ck(boolean cond, String name) { + if (cond) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name); + } + } + + static void eq(Object expected, Object actual, String name) { + if (Objects.equals(expected, actual)) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name + " expected=[" + expected + "] actual=[" + actual + "]"); + } + } + + static void group(String name, Runnable r) { + try { + r.run(); + } catch (Throwable e) { + fail++; + System.out.println("FAIL " + name + " threw " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + static long asLong(Object o) { + return ((Number) o).longValue(); + } + + static double asDouble(Object o) { + return ((Number) o).doubleValue(); + } + + /* ===================== entities ===================== */ + + public enum Grade { + JUNIOR, SENIOR, LEAD + } + + @Entity(name = "Department") + @Table(name = "departments") + public static class Department { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + Long id; + + @Column(name = "dept_name", nullable = false, length = 64, unique = true) + String name; + + @Column(name = "location", length = 128) + String location; + + @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + List employees = new ArrayList<>(); + + public Department() { + } + + public Department(String name, String location) { + this.name = name; + this.location = location; + } + + void add(Employee e) { + e.department = this; + this.employees.add(e); + } + + void remove(Employee e) { + this.employees.remove(e); + e.department = null; + } + } + + @Entity(name = "Employee") + @Table(name = "employees") + @NamedQuery(name = "Employee.byMinSalary", + query = "select e from Employee e where e.salary >= :min order by e.salary desc, e.id asc") + @NamedQuery(name = "Employee.countAll", + query = "select count(e) from Employee e") + public static class Employee { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + Long id; + + @Column(name = "emp_name", nullable = false, length = 64) + String name; + + @Column(name = "salary", nullable = false) + int salary; + + @Enumerated(EnumType.STRING) + @Column(name = "grade", length = 16) + Grade grade; + + @Version + @Column(name = "ver", nullable = false) + long version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "dept_id") + Department department; + + @Transient + String scratch; + + public Employee() { + } + + public Employee(String name, int salary, Grade grade) { + this.name = name; + this.salary = salary; + this.grade = grade; + } + } + + /* ===================== helpers ===================== */ + + static SessionFactory buildSessionFactory() { + Configuration cfg = new Configuration(); + cfg.setProperty("hibernate.connection.driver_class", "org.sqlite.JDBC"); + cfg.setProperty("hibernate.connection.url", URL); + cfg.setProperty("hibernate.dialect", "org.hibernate.community.dialect.SQLiteDialect"); + cfg.setProperty("hibernate.hbm2ddl.auto", "create"); + cfg.setProperty("hibernate.connection.pool_size", "1"); + cfg.setProperty("hibernate.connection.autocommit", "false"); + cfg.setProperty("hibernate.show_sql", "false"); + cfg.setProperty("hibernate.format_sql", "false"); + cfg.setProperty("hibernate.max_fetch_depth", "3"); + cfg.addAnnotatedClass(Department.class); + cfg.addAnnotatedClass(Employee.class); + return cfg.buildSessionFactory(); + } + + /** Wipe and reinsert a fixed, deterministic dataset (3 depts, 5 employees). */ + static void seed(SessionFactory sf) { + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + s.createMutationQuery("delete from Employee").executeUpdate(); + s.createMutationQuery("delete from Department").executeUpdate(); + + Department eng = new Department("Engineering", "Building A"); + Department sales = new Department("Sales", "Building B"); + Department research = new Department("Research", null); + + eng.add(new Employee("Alice", 7000, Grade.SENIOR)); + eng.add(new Employee("Bob", 5000, Grade.JUNIOR)); + eng.add(new Employee("Carol", 9000, Grade.LEAD)); + sales.add(new Employee("Dave", 6000, Grade.SENIOR)); + sales.add(new Employee("Eve", 4000, Grade.JUNIOR)); + + s.persist(eng); + s.persist(sales); + s.persist(research); + t.commit(); + } + } + + static Department deptByName(Session s, String n) { + return s.createQuery("from Department where name = :n", Department.class) + .setParameter("n", n).getSingleResult(); + } + + static Employee empByName(Session s, String n) { + return s.createQuery("from Employee where name = :n", Employee.class) + .setParameter("n", n).getSingleResult(); + } + + /* ===================== test groups ===================== */ + + // G1: bootstrap, SessionFactory / EntityManagerFactory, metamodel + static void g1(SessionFactory sf) { + ck(sf != null, "g1.sf.notNull"); + ck(sf.isOpen(), "g1.sf.open"); + ck(sf instanceof EntityManagerFactory, "g1.sf.isEMF"); + EntityManagerFactory emf = sf; + ck(emf.getMetamodel().getEntities().size() >= 2, "g1.metamodel.entities>=2"); + eq("Employee", emf.getMetamodel().entity(Employee.class).getName(), "g1.metamodel.Employee.name"); + ck(emf.getMetamodel().entity(Department.class) != null, "g1.metamodel.Department"); + ck(sf.getMetamodel() != null, "g1.sf.metamodel"); + ck(emf.isOpen(), "g1.emf.open"); + } + + // G2: seed sanity, aggregates baseline + static void g2(SessionFactory sf) { + seed(sf); + try (Session s = sf.openSession()) { + eq(3L, asLong(s.createQuery("select count(d) from Department d").getSingleResult()), "g2.deptCount"); + eq(5L, asLong(s.createQuery("select count(e) from Employee e").getSingleResult()), "g2.empCount"); + eq(31000L, asLong(s.createQuery("select sum(e.salary) from Employee e").getSingleResult()), "g2.sum"); + eq(6200.0, asDouble(s.createQuery("select avg(e.salary) from Employee e").getSingleResult()), "g2.avg"); + eq(4000L, asLong(s.createQuery("select min(e.salary) from Employee e").getSingleResult()), "g2.min"); + eq(9000L, asLong(s.createQuery("select max(e.salary) from Employee e").getSingleResult()), "g2.max"); + } + } + + // G3: basic CRUD via native Session (persist/find/get/byId/update/remove) + static void g3(SessionFactory sf) { + seed(sf); + Long newId; + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department d = new Department("QA", "Building C"); + ck(d.id == null, "g3.prePersist.idNull"); + s.persist(d); + s.flush(); + newId = d.id; + ck(newId != null, "g3.generatedId.notNull"); + ck(newId > 0, "g3.generatedId.positive"); + t.commit(); + } + try (Session s = sf.openSession()) { + Department byGet = s.get(Department.class, newId); + eq("QA", byGet.name, "g3.get.name"); + Department byId = s.byId(Department.class).load(newId); + eq("Building C", byId.location, "g3.byId.location"); + eq(byGet, byId, "g3.get==byId.firstLevelCache"); + } + // update in a tx + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department d = s.get(Department.class, newId); + d.location = "Building Z"; + t.commit(); + } + try (Session s = sf.openSession()) { + eq("Building Z", s.get(Department.class, newId).location, "g3.update.persisted"); + } + // remove + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department d = s.get(Department.class, newId); + s.remove(d); + t.commit(); + } + try (Session s = sf.openSession()) { + ck(s.get(Department.class, newId) == null, "g3.remove.gone"); + eq(3L, asLong(s.createQuery("select count(d) from Department d").getSingleResult()), "g3.count.backToBaseline"); + } + // persist employee referencing existing department, navigate + Long empId; + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department eng = deptByName(s, "Engineering"); + Employee e = new Employee("Frank", 8000, Grade.SENIOR); + e.department = eng; + s.persist(e); + s.flush(); + empId = e.id; + t.commit(); + } + try (Session s = sf.openSession()) { + Employee e = s.get(Employee.class, empId); + eq("Frank", e.name, "g3.emp.name"); + eq(8000, e.salary, "g3.emp.salary"); + // @ManyToOne(LAZY): association is an uninitialized proxy until touched + ck(!Hibernate.isInitialized(e.department), "g3.emp.dept.lazyNotInit"); + Department dept = (Department) Hibernate.unproxy(e.department); + eq("Engineering", dept.name, "g3.emp.deptNav"); + ck(Hibernate.isInitialized(e.department), "g3.emp.dept.initAfterAccess"); + } + } + + // G4: merge / detached entities + static void g4(SessionFactory sf) { + seed(sf); + Long id; + try (Session s = sf.openSession()) { + id = empByName(s, "Alice").id; + } + // load, detach, modify, merge + Employee detached; + try (Session s = sf.openSession()) { + detached = s.get(Employee.class, id); + } // session closed -> detached + detached.salary = 7777; + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + ck(!s.contains(detached), "g4.detached.notContained"); + Employee managed = (Employee) s.merge(detached); + ck(s.contains(managed), "g4.merged.contained"); + ck(managed != detached, "g4.merge.returnsCopy"); + t.commit(); + } + try (Session s = sf.openSession()) { + eq(7777, s.get(Employee.class, id).salary, "g4.merge.persisted"); + } + // merge a brand-new transient (id null) -> insert + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee fresh = new Employee("Grace", 5500, Grade.JUNIOR); + Employee managed = (Employee) s.merge(fresh); + ck(managed.id != null, "g4.merge.transient.assignsId"); + t.commit(); + } + try (Session s = sf.openSession()) { + eq(1L, asLong(s.createQuery("select count(e) from Employee e where e.name = 'Grace'") + .getSingleResult()), "g4.merge.transient.inserted"); + } + } + + // G5: flush / clear / refresh / evict / contains / isDirty + static void g5(SessionFactory sf) { + seed(sf); + Long id; + try (Session s = sf.openSession()) { + id = empByName(s, "Bob").id; + } + // flush makes row visible to same-tx query before commit + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee e = new Employee("Heidi", 4200, Grade.JUNIOR); + s.persist(e); + s.flush(); + long cnt = asLong(s.createQuery("select count(x) from Employee x where x.name = 'Heidi'") + .getSingleResult()); + eq(1L, cnt, "g5.flush.visibleInTx"); + t.rollback(); // discard + } + try (Session s = sf.openSession()) { + eq(0L, asLong(s.createQuery("select count(x) from Employee x where x.name = 'Heidi'") + .getSingleResult()), "g5.rollbackAfterFlush.gone"); + } + // clear() detaches everything + try (Session s = sf.openSession()) { + Employee e = s.get(Employee.class, id); + ck(s.contains(e), "g5.beforeClear.contained"); + s.clear(); + ck(!s.contains(e), "g5.afterClear.detached"); + } + // refresh reverts in-memory change + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee e = s.get(Employee.class, id); + int original = e.salary; + e.salary = 1; + ck(s.isDirty(), "g5.isDirty.afterChange"); + s.refresh(e); + eq(original, e.salary, "g5.refresh.reverted"); + ck(!s.isDirty(), "g5.notDirty.afterRefresh"); + t.commit(); + } + // evict single entity + try (Session s = sf.openSession()) { + Employee a = empByName(s, "Alice"); + Employee c = empByName(s, "Carol"); + s.evict(a); + ck(!s.contains(a), "g5.evict.target.detached"); + ck(s.contains(c), "g5.evict.other.stillManaged"); + } + } + + // G6: first-level (persistence-context) cache + getReference + static void g6(SessionFactory sf) { + seed(sf); + Long id; + try (Session s = sf.openSession()) { + id = empByName(s, "Carol").id; + } + try (Session s = sf.openSession()) { + Employee a = s.get(Employee.class, id); + Employee b = s.get(Employee.class, id); + ck(a == b, "g6.sameSession.sameIdentity"); + s.clear(); + Employee c = s.get(Employee.class, id); + ck(c != a, "g6.afterClear.newIdentity"); + eq(a.name, c.name, "g6.afterClear.sameData"); + } + try (Session s = sf.openSession()) { + Employee ref = s.getReference(Employee.class, id); + ck(ref != null, "g6.getReference.notNull"); + ck(s.contains(ref), "g6.getReference.contained"); + // getReference yields a proxy not hydrated from the DB yet + ck(!Hibernate.isInitialized(ref), "g6.getReference.notInitYet"); + Employee real = (Employee) Hibernate.unproxy(ref); + eq("Carol", real.name, "g6.getReference.loadsOnAccess"); + ck(Hibernate.isInitialized(ref), "g6.getReference.initAfterAccess"); + } + } + + // G7: transaction commit / rollback / status + constraint rollback + static void g7(SessionFactory sf) { + seed(sf); + long baseline; + try (Session s = sf.openSession()) { + baseline = asLong(s.createQuery("select count(d) from Department d").getSingleResult()); + } + // status active after begin, inactive after commit + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + ck(t.isActive(), "g7.tx.activeAfterBegin"); + t.commit(); + ck(!t.isActive(), "g7.tx.inactiveAfterCommit"); + } + // rollback discards an insert + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + s.persist(new Department("Temp", "void")); + t.rollback(); + ck(!t.isActive(), "g7.tx.inactiveAfterRollback"); + } + try (Session s = sf.openSession()) { + eq(baseline, asLong(s.createQuery("select count(d) from Department d").getSingleResult()), + "g7.rollback.discarded"); + } + // unique constraint violation -> exception -> rollback + boolean threw = false; + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + try { + s.persist(new Department("Engineering", "dup")); // dept_name is unique + s.flush(); + t.commit(); + } catch (RuntimeException ex) { + threw = true; + if (t.isActive()) { + t.rollback(); + } + } + } + ck(threw, "g7.uniqueConstraint.threw"); + // session machinery still usable afterward + try (Session s = sf.openSession()) { + eq(baseline, asLong(s.createQuery("select count(d) from Department d").getSingleResult()), + "g7.afterConstraint.baselineIntact"); + } + } + + // G8: HQL / JPQL breadth + static void g8(SessionFactory sf) { + seed(sf); + try (Session s = sf.openSession()) { + // select all + List all = s.createQuery("from Employee", Employee.class).getResultList(); + eq(5, all.size(), "g8.selectAll.size"); + + // named parameter on enum + List seniors = s.createQuery( + "select e from Employee e where e.grade = :g order by e.name", Employee.class) + .setParameter("g", Grade.SENIOR).getResultList(); + eq(2, seniors.size(), "g8.namedParam.enum.size"); + eq("Alice", seniors.get(0).name, "g8.namedParam.enum.first"); + + // positional parameter + long highCnt = asLong(s.createQuery( + "select count(e) from Employee e where e.salary >= ?1") + .setParameter(1, 6000).getSingleResult()); + eq(3L, highCnt, "g8.positionalParam.count"); + + // order by desc + Employee top = s.createQuery( + "from Employee e order by e.salary desc, e.id asc", Employee.class) + .setMaxResults(1).getSingleResult(); + eq("Carol", top.name, "g8.orderBy.top"); + + // aggregates + eq(5L, asLong(s.createQuery("select count(e) from Employee e", Long.class).getSingleResult()), + "g8.count.typed"); + eq(31000L, asLong(s.createQuery("select sum(e.salary) from Employee e").getSingleResult()), + "g8.sum"); + eq(6200.0, asDouble(s.createQuery("select avg(e.salary) from Employee e").getSingleResult()), + "g8.avg"); + + // scalar projection + List names = s.createQuery("select e.name from Employee e order by e.name", String.class) + .getResultList(); + eq(5, names.size(), "g8.projection.size"); + ck(names.contains("Alice") && names.contains("Eve"), "g8.projection.contains"); + + // join + long engCnt = asLong(s.createQuery( + "select count(e) from Employee e join e.department d where d.name = :dn") + .setParameter("dn", "Engineering").getSingleResult()); + eq(3L, engCnt, "g8.join.count"); + + // group by + having + List grouped = s.createQuery( + "select d.name, count(e) from Employee e join e.department d " + + "group by d.name having count(e) > 2", Object[].class).getResultList(); + eq(1, grouped.size(), "g8.groupByHaving.size"); + eq("Engineering", grouped.get(0)[0], "g8.groupByHaving.dept"); + eq(3L, asLong(grouped.get(0)[1]), "g8.groupByHaving.count"); + + // distinct + long distinctGrades = s.createQuery( + "select count(distinct e.grade) from Employee e").getSingleResult() instanceof Number + ? asLong(s.createQuery("select count(distinct e.grade) from Employee e").getSingleResult()) + : -1; + eq(3L, distinctGrades, "g8.distinct.grades"); + + // pagination + List page = s.createQuery( + "from Employee e order by e.salary desc, e.id asc", Employee.class) + .setFirstResult(1).setMaxResults(2).getResultList(); + eq(2, page.size(), "g8.pagination.size"); + eq("Alice", page.get(0).name, "g8.pagination.first"); + eq("Dave", page.get(1).name, "g8.pagination.second"); + + // named queries + List nq = s.createNamedQuery("Employee.byMinSalary", Employee.class) + .setParameter("min", 6000).getResultList(); + eq(3, nq.size(), "g8.namedQuery.size"); + eq("Carol", nq.get(0).name, "g8.namedQuery.firstDesc"); + eq(5L, asLong(s.createNamedQuery("Employee.countAll").getSingleResult()), + "g8.namedQuery.count"); + + // NoResultException + boolean noResult = false; + try { + s.createQuery("from Employee e where e.salary > 999999", Employee.class).getSingleResult(); + } catch (NoResultException ex) { + noResult = true; + } + ck(noResult, "g8.getSingleResult.noResult"); + + // NonUniqueResultException + boolean nonUnique = false; + try { + s.createQuery("from Employee", Employee.class).getSingleResult(); + } catch (NonUniqueResultException ex) { + nonUnique = true; + } + ck(nonUnique, "g8.getSingleResult.nonUnique"); + } + + // DML update + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + int updated = s.createMutationQuery( + "update Employee e set e.salary = e.salary + 1000 where e.grade = :g") + .setParameter("g", Grade.JUNIOR).executeUpdate(); + eq(2, updated, "g8.dml.update.count"); + t.commit(); + } + try (Session s = sf.openSession()) { + eq(33000L, asLong(s.createQuery("select sum(e.salary) from Employee e").getSingleResult()), + "g8.dml.update.effect"); + } + // DML delete + seed(sf); + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + int deleted = s.createMutationQuery("delete from Employee e where e.salary < :s") + .setParameter("s", 6000).executeUpdate(); + eq(2, deleted, "g8.dml.delete.count"); // Bob 5000, Eve 4000 + t.commit(); + } + try (Session s = sf.openSession()) { + eq(3L, asLong(s.createQuery("select count(e) from Employee e").getSingleResult()), + "g8.dml.delete.remaining"); + } + } + + // G9: Criteria API + static void g9(SessionFactory sf) { + seed(sf); + try (Session s = sf.openSession()) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + + // select all + CriteriaQuery all = cb.createQuery(Employee.class); + Root r1 = all.from(Employee.class); + all.select(r1); + eq(5, s.createQuery(all).getResultList().size(), "g9.selectAll"); + + // equal predicate + CriteriaQuery q2 = cb.createQuery(Employee.class); + Root r2 = q2.from(Employee.class); + q2.select(r2).where(cb.equal(r2.get("grade"), Grade.LEAD)); + List leads = s.createQuery(q2).getResultList(); + eq(1, leads.size(), "g9.equal.size"); + eq("Carol", leads.get(0).name, "g9.equal.value"); + + // gt predicate + order + CriteriaQuery q3 = cb.createQuery(Employee.class); + Root r3 = q3.from(Employee.class); + q3.select(r3).where(cb.gt(r3.get("salary"), 5000)) + .orderBy(cb.desc(r3.get("salary"))); + List high = s.createQuery(q3).getResultList(); + eq(3, high.size(), "g9.gt.size"); + eq("Carol", high.get(0).name, "g9.gt.orderDesc"); + + // like predicate + CriteriaQuery q4 = cb.createQuery(Employee.class); + Root r4 = q4.from(Employee.class); + q4.select(r4).where(cb.like(r4.get("name"), "A%")); + eq(1, s.createQuery(q4).getResultList().size(), "g9.like.size"); + + // and predicate + CriteriaQuery q5 = cb.createQuery(Employee.class); + Root r5 = q5.from(Employee.class); + Predicate p5 = cb.and(cb.gt(r5.get("salary"), 4000), + cb.like(r5.get("name"), "%e%")); + q5.select(r5).where(p5); + eq(2, s.createQuery(q5).getResultList().size(), "g9.and.size"); // Alice, Dave + + // or predicate + CriteriaQuery q6 = cb.createQuery(Employee.class); + Root r6 = q6.from(Employee.class); + Predicate p6 = cb.or(cb.equal(r6.get("grade"), Grade.LEAD), + cb.equal(r6.get("grade"), Grade.JUNIOR)); + q6.select(r6).where(p6); + eq(3, s.createQuery(q6).getResultList().size(), "g9.or.size"); // Carol + Bob + Eve + + // count via CriteriaBuilder.count + CriteriaQuery q7 = cb.createQuery(Long.class); + Root r7 = q7.from(Employee.class); + q7.select(cb.count(r7)); + eq(5L, s.createQuery(q7).getSingleResult(), "g9.count"); + + // parameter expression + CriteriaQuery q8 = cb.createQuery(Employee.class); + Root r8 = q8.from(Employee.class); + ParameterExpression minSal = cb.parameter(Integer.class, "minSal"); + q8.select(r8).where(cb.ge(r8.get("salary"), minSal)); + eq(3, s.createQuery(q8).setParameter("minSal", 6000).getResultList().size(), + "g9.parameterExpression"); + + // multiselect (projection of name + salary) + CriteriaQuery q9 = cb.createQuery(Object[].class); + Root r9 = q9.from(Employee.class); + q9.multiselect(r9.get("name"), r9.get("salary")); + List rows = s.createQuery(q9).getResultList(); + eq(5, rows.size(), "g9.multiselect.size"); + ck(rows.get(0).length == 2, "g9.multiselect.tupleWidth"); + + // criteria join + CriteriaQuery q10 = cb.createQuery(Employee.class); + Root r10 = q10.from(Employee.class); + Join dj = r10.join("department"); + q10.select(r10).where(cb.equal(dj.get("name"), "Engineering")); + eq(3, s.createQuery(q10).getResultList().size(), "g9.join.size"); + + // aggregate max via CriteriaBuilder + CriteriaQuery q11 = cb.createQuery(Integer.class); + Root r11 = q11.from(Employee.class); + q11.select(cb.max(r11.get("salary"))); + eq(9000L, asLong(s.createQuery(q11).getSingleResult()), "g9.max"); + } + } + + // G10: relationships, cascade, orphanRemoval, navigation + static void g10(SessionFactory sf) { + seed(sf); + Long deptId; + // cascade persist children + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department ops = new Department("Operations", "Building D"); + ops.add(new Employee("Ivan", 6100, Grade.SENIOR)); + ops.add(new Employee("Judy", 5900, Grade.JUNIOR)); + s.persist(ops); // cascade ALL persists employees + s.flush(); + deptId = ops.id; + ck(ops.employees.get(0).id != null, "g10.cascade.child0.id"); + ck(ops.employees.get(1).id != null, "g10.cascade.child1.id"); + t.commit(); + } + // reload, verify collection + back-reference + try (Session s = sf.openSession()) { + Department ops = s.get(Department.class, deptId); + eq(2, ops.employees.size(), "g10.collection.size"); + Employee any = ops.employees.get(0); + eq("Operations", any.department.name, "g10.backRef"); + } + // orphanRemoval: remove a child from the collection -> deleted + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department ops = s.get(Department.class, deptId); + Employee victim = ops.employees.get(0); + ops.remove(victim); + t.commit(); + } + try (Session s = sf.openSession()) { + Department ops = s.get(Department.class, deptId); + eq(1, ops.employees.size(), "g10.orphanRemoval.collection"); + eq(1L, asLong(s.createQuery("select count(e) from Employee e where e.department.id = :d") + .setParameter("d", deptId).getSingleResult()), "g10.orphanRemoval.dbCount"); + } + // cascade remove: delete department deletes remaining children + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Department ops = s.get(Department.class, deptId); + s.remove(ops); + t.commit(); + } + try (Session s = sf.openSession()) { + ck(s.get(Department.class, deptId) == null, "g10.cascadeRemove.deptGone"); + eq(0L, asLong(s.createQuery("select count(e) from Employee e where e.department.id = :d") + .setParameter("d", deptId).getSingleResult()), "g10.cascadeRemove.childrenGone"); + eq(5L, asLong(s.createQuery("select count(e) from Employee e").getSingleResult()), + "g10.cascadeRemove.baselineIntact"); + } + } + + // G11: native SQL queries + static void g11(SessionFactory sf) { + seed(sf); + try (Session s = sf.openSession()) { + long cnt = asLong(s.createNativeQuery("select count(*) from employees", Long.class) + .getSingleResult()); + eq(5L, cnt, "g11.native.count"); + + long engCnt = asLong(s.createNativeQuery( + "select count(*) from employees e join departments d on e.dept_id = d.id " + + "where d.dept_name = :dn", Long.class) + .setParameter("dn", "Engineering").getSingleResult()); + eq(3L, engCnt, "g11.native.join.param"); + + @SuppressWarnings("unchecked") + List names = s.createNativeQuery( + "select emp_name from employees order by salary desc") + .getResultList(); + eq(5, names.size(), "g11.native.scalarList.size"); + eq("Carol", String.valueOf(names.get(0)), "g11.native.scalarList.first"); + + // native query mapped to entity + Employee alice = (Employee) s.createNativeQuery( + "select * from employees where emp_name = 'Alice'", Employee.class) + .getSingleResult(); + eq(7000, alice.salary, "g11.native.entityMapping"); + } + } + + // G12: Jakarta Persistence EntityManager API path + static void g12(SessionFactory sf) { + seed(sf); + EntityManagerFactory emf = sf; + EntityManager em = emf.createEntityManager(); + try { + ck(em.isOpen(), "g12.em.open"); + ck(em.getCriteriaBuilder() != null, "g12.em.criteriaBuilder"); + ck(em.getMetamodel() != null, "g12.em.metamodel"); + + // persist via EntityTransaction + EntityTransaction tx = em.getTransaction(); + tx.begin(); + ck(tx.isActive(), "g12.tx.active"); + Department d = new Department("Legal", "Building E"); + em.persist(d); + em.flush(); + ck(d.id != null, "g12.em.persist.generatedId"); + ck(em.contains(d), "g12.em.contains"); + tx.commit(); + ck(!tx.isActive(), "g12.tx.committed"); + + Long id = d.id; + // find + Department found = em.find(Department.class, id); + eq("Legal", found.name, "g12.em.find"); + // getReference + Department ref = em.getReference(Department.class, id); + ck(ref != null, "g12.em.getReference"); + // TypedQuery + TypedQuery tq = em.createQuery("from Employee e order by e.salary desc", Employee.class); + eq(5, tq.getResultList().size(), "g12.em.typedQuery.size"); + eq("Carol", tq.setMaxResults(1).getResultList().get(0).name, "g12.em.typedQuery.top"); + + // merge + refresh + tx = em.getTransaction(); + tx.begin(); + found.location = "Building E2"; + em.flush(); + found.location = "scratch-not-flushed"; + em.refresh(found); + eq("Building E2", found.location, "g12.em.refresh"); + tx.commit(); + + // remove + tx = em.getTransaction(); + tx.begin(); + Department toDel = em.find(Department.class, id); + em.remove(toDel); + tx.commit(); + ck(em.find(Department.class, id) == null, "g12.em.remove"); + + // clear -> detach + Employee e = em.createQuery("from Employee e where e.name = 'Alice'", Employee.class) + .getSingleResult(); + ck(em.contains(e), "g12.em.beforeClear.contained"); + em.clear(); + ck(!em.contains(e), "g12.em.afterClear.detached"); + + // EntityTransaction rollback + tx = em.getTransaction(); + tx.begin(); + em.persist(new Department("Rollback", "void")); + tx.rollback(); + ck(!tx.isActive(), "g12.tx.rolledBack"); + eq(0L, asLong(em.createQuery("select count(d) from Department d where d.name = 'Rollback'") + .getSingleResult()), "g12.em.rollback.discarded"); + + // DML via EntityManager + tx = em.getTransaction(); + tx.begin(); + int up = em.createQuery("update Employee e set e.salary = e.salary + 100 where e.grade = :g") + .setParameter("g", Grade.LEAD).executeUpdate(); + eq(1, up, "g12.em.dml.update"); + tx.commit(); + } finally { + em.close(); + ck(!em.isOpen(), "g12.em.closed"); + } + } + + // G13: @Version optimistic locking + static void g13(SessionFactory sf) { + seed(sf); + Long id; + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee e = new Employee("Mallory", 5300, Grade.JUNIOR); + s.persist(e); + s.flush(); + id = e.id; + eq(0L, e.version, "g13.version.initialZero"); + t.commit(); + } + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee e = s.get(Employee.class, id); + e.salary = 5400; + t.commit(); + } + try (Session s = sf.openSession()) { + eq(1L, s.get(Employee.class, id).version, "g13.version.incrementedTo1"); + } + try (Session s = sf.openSession()) { + Transaction t = s.beginTransaction(); + Employee e = s.get(Employee.class, id); + e.salary = 5500; + t.commit(); + } + try (Session s = sf.openSession()) { + eq(2L, s.get(Employee.class, id).version, "g13.version.incrementedTo2"); + } + } + + /* ===================== main ===================== */ + + public static void main(String[] args) { + // keep framework logging quiet so the result markers stay clean + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error"); + System.setProperty("org.slf4j.simpleLogger.log.org.hibernate", "error"); + System.setProperty("org.jboss.logging.provider", "slf4j"); + + SessionFactory sf = null; + Connection keepAlive = null; + try { + Class.forName("org.sqlite.JDBC"); + // hold the shared-cache in-memory database alive for the whole run + keepAlive = DriverManager.getConnection(URL); + + sf = buildSessionFactory(); + final SessionFactory f = sf; + + group("G1.bootstrap", () -> g1(f)); + group("G2.seedSanity", () -> g2(f)); + group("G3.crud", () -> g3(f)); + group("G4.merge", () -> g4(f)); + group("G5.flushClearRefreshEvict", () -> g5(f)); + group("G6.firstLevelCache", () -> g6(f)); + group("G7.transaction", () -> g7(f)); + group("G8.hql", () -> g8(f)); + group("G9.criteria", () -> g9(f)); + group("G10.relationships", () -> g10(f)); + group("G11.nativeSql", () -> g11(f)); + group("G12.entityManager", () -> g12(f)); + group("G13.version", () -> g13(f)); + } catch (Throwable e) { + fail++; + System.out.println("FAIL fatal " + e.getClass().getName() + ": " + e.getMessage()); + e.printStackTrace(); + } finally { + try { + if (sf != null) { + sf.close(); + } + } catch (Throwable ignored) { + } + try { + if (keepAlive != null) { + keepAlive.close(); + } + } catch (Throwable ignored) { + } + } + + System.out.println("HIBERNATE_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) { + System.out.println("HIBERNATE_DONE"); + } + } +} diff --git a/apps/starry/java-web/programs/carpets/JettyCarpet.java b/apps/starry/java-web/programs/carpets/JettyCarpet.java new file mode 100644 index 0000000000..1aa026f46a --- /dev/null +++ b/apps/starry/java-web/programs/carpets/JettyCarpet.java @@ -0,0 +1,589 @@ +package org.starry.dod; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.server.handler.ContextHandlerCollection; +import org.eclipse.jetty.server.handler.HandlerList; +import org.eclipse.jetty.server.handler.ResourceHandler; +import org.eclipse.jetty.server.handler.ErrorHandler; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.eclipse.jetty.util.resource.Resource; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Carpet-level coverage for embedded Eclipse Jetty 11.0.21 (jetty-server + jakarta.servlet). + * + * Exercises: QueuedThreadPool sizing/naming, Server(ThreadPool) wiring, HttpConfiguration + + * HttpConnectionFactory, ServerConnector bound strictly to 127.0.0.1 on a high free port, + * AbstractHandler routing, ContextHandler / ContextHandlerCollection / HandlerList composition, + * ResourceHandler static file serving, custom ErrorHandler, full HTTP method matrix + * (GET/POST/PUT/DELETE/HEAD), request parsing (query/header/form/raw body), response shaping + * (status line / reason phrase / headers / content-type / body), error codes (404/405/500), + * and the Server start/isRunning/stop lifecycle. All traffic is real HttpURLConnection / + * loopback round-trips. No external network, no servlet container module needed. + */ +public final class JettyCarpet { + + static final String MARKER = "JETTY_DONE"; + static int ok = 0; + static int fail = 0; + + // ---- assertion helpers (self counting) ------------------------------- + static void check(String name, boolean cond) { + if (cond) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name); + } + } + + static void eq(String name, Object expected, Object actual) { + if (Objects.equals(expected, actual)) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name + " expected=[" + expected + "] actual=[" + actual + "]"); + } + } + + static void startsWith(String name, String value, String prefix) { + if (value != null && value.startsWith(prefix)) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name + " value=[" + value + "] prefix=[" + prefix + "]"); + } + } + + static void has(String name, String haystack, String needle) { + if (haystack != null && haystack.contains(needle)) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name + " value=[" + haystack + "] needle=[" + needle + "]"); + } + } + + // ---- tiny HTTP client over loopback ---------------------------------- + static final class Resp { + int code; + String reason; + String contentType; + String body; + Map headers = new HashMap<>(); + + String h(String k) { + return headers.get(k.toLowerCase()); + } + } + + static String readAll(InputStream in) throws IOException { + if (in == null) { + return ""; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) >= 0) { + bos.write(buf, 0, n); + } + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + + static Resp http(int port, String method, String path, + Map reqHeaders, byte[] body, String bodyContentType) throws IOException { + URL url = new URL("http://127.0.0.1:" + port + path); + HttpURLConnection c = (HttpURLConnection) url.openConnection(); + c.setRequestMethod(method); + c.setInstanceFollowRedirects(false); + c.setConnectTimeout(8000); + c.setReadTimeout(8000); + if (reqHeaders != null) { + for (Map.Entry e : reqHeaders.entrySet()) { + c.setRequestProperty(e.getKey(), e.getValue()); + } + } + if (body != null) { + c.setDoOutput(true); + if (bodyContentType != null) { + c.setRequestProperty("Content-Type", bodyContentType); + } + try (OutputStream os = c.getOutputStream()) { + os.write(body); + } + } + Resp r = new Resp(); + r.code = c.getResponseCode(); + r.reason = c.getResponseMessage(); + r.contentType = c.getContentType(); + for (Map.Entry> en : c.getHeaderFields().entrySet()) { + if (en.getKey() != null && en.getValue() != null && !en.getValue().isEmpty()) { + r.headers.put(en.getKey().toLowerCase(), en.getValue().get(0)); + } + } + InputStream in = (r.code >= 400) ? c.getErrorStream() : c.getInputStream(); + r.body = readAll(in); + c.disconnect(); + return r; + } + + static Resp get(int port, String path) throws IOException { + return http(port, "GET", path, null, null, null); + } + + // ---- application routing handler ------------------------------------- + static final class ApiHandler extends AbstractHandler { + final AtomicInteger seq = new AtomicInteger(0); + + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest req, HttpServletResponse resp) + throws IOException, ServletException { + String method = req.getMethod(); + String path = req.getPathInfo(); + if (path == null) { + path = "/"; + } + baseRequest.setHandled(true); + resp.setHeader("X-Handler", "api"); + + if (path.equals("/hello")) { + if (method.equals("GET") || method.equals("HEAD")) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain; charset=utf-8"); + resp.getWriter().print("hello"); + } else { + resp.setStatus(405); + resp.setHeader("Allow", "GET, HEAD"); + resp.setContentType("application/json"); + resp.getWriter().print("{\"error\":\"method\"}"); + } + return; + } + if (path.equals("/json")) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + resp.getWriter().print("{\"msg\":\"ok\",\"n\":42}"); + return; + } + if (path.equals("/echo")) { + String name = req.getParameter("name"); + if (name == null) { + name = "anon"; + } + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain; charset=utf-8"); + resp.getWriter().print("echo:" + name); + return; + } + if (path.equals("/multi")) { + String[] vs = req.getParameterValues("v"); + int len = (vs == null) ? 0 : vs.length; + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain"); + resp.getWriter().print(String.valueOf(len)); + return; + } + if (path.equals("/headers")) { + String token = req.getHeader("X-Token"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setHeader("X-Echo-Token", token); + resp.setContentType("text/plain"); + resp.getWriter().print("token=" + token); + return; + } + if (path.equals("/utf8")) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain; charset=utf-8"); + resp.getWriter().print("héllo-世界"); + return; + } + if (path.equals("/users")) { + if (method.equals("POST")) { + int id = seq.incrementAndGet(); + String name = req.getParameter("name"); + String age = req.getParameter("age"); + resp.setStatus(HttpServletResponse.SC_CREATED); + resp.setHeader("Location", "/api/users/" + id); + resp.setContentType("application/json"); + resp.getWriter().print("{\"id\":" + id + ",\"name\":\"" + name + "\",\"age\":" + age + "}"); + } else { + resp.setStatus(405); + resp.setHeader("Allow", "POST"); + resp.getWriter().print("{\"error\":\"method\"}"); + } + return; + } + if (path.startsWith("/users/")) { + String id = path.substring("/users/".length()); + if (method.equals("PUT")) { + String body = readBody(req); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain"); + resp.getWriter().print("updated:" + id + ":" + body); + } else if (method.equals("DELETE")) { + resp.setStatus(HttpServletResponse.SC_NO_CONTENT); + } else { + resp.setStatus(405); + resp.setHeader("Allow", "PUT, DELETE"); + resp.getWriter().print("{\"error\":\"method\"}"); + } + return; + } + if (path.equals("/raw")) { + String body = readBody(req); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("text/plain"); + resp.getWriter().print("recv:" + body); + return; + } + if (path.equals("/boom")) { + throw new ServletException("intentional failure for 500 path"); + } + // default: not found inside /api + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + resp.setContentType("application/json"); + resp.getWriter().print("{\"error\":\"not_found\"}"); + } + + private static String readBody(HttpServletRequest req) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader r = req.getReader()) { + char[] buf = new char[1024]; + int n; + while ((n = r.read(buf)) >= 0) { + sb.append(buf, 0, n); + } + } + return sb.toString(); + } + } + + // fallback handler placed last in the HandlerList: anything unmatched -> deterministic 404 + static final class RootFallback extends AbstractHandler { + @Override + public void handle(String target, Request baseRequest, + HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (baseRequest.isHandled()) { + return; + } + baseRequest.setHandled(true); + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + resp.setContentType("text/plain"); + resp.getWriter().print("ROOT-404"); + } + } + + static int findFreePort(int from, int to) throws IOException { + for (int p = from; p < to; p++) { + try (ServerSocket s = new ServerSocket()) { + s.setReuseAddress(true); + s.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), p)); + return p; + } catch (IOException ignore) { + // port busy -> try next + } + } + throw new IOException("no free 127.0.0.1 port in [" + from + "," + to + ")"); + } + + public static void main(String[] args) throws Exception { + // ---- static content directory under /tmp ------------------------- + Path staticDir = Files.createTempDirectory(Paths.get("/tmp"), "jetty-carpet-"); + Files.write(staticDir.resolve("data.txt"), "STATIC-FILE-OK".getBytes(StandardCharsets.UTF_8)); + Files.write(staticDir.resolve("data.json"), "{\"k\":1}".getBytes(StandardCharsets.UTF_8)); + Files.write(staticDir.resolve("index.html"), "INDEX-PAGE".getBytes(StandardCharsets.UTF_8)); + + // ---- thread pool ------------------------------------------------- + QueuedThreadPool pool = new QueuedThreadPool(16, 4); + pool.setName("starry-jetty"); + eq("pool.maxThreads", 16, pool.getMaxThreads()); + eq("pool.minThreads", 4, pool.getMinThreads()); + eq("pool.name", "starry-jetty", pool.getName()); + + // ---- server + connector (HttpConfiguration) ---------------------- + Server server = new Server(pool); + check("server.usesGivenPool", server.getThreadPool() == pool); + + HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.setSendServerVersion(false); + httpConfig.setSendDateHeader(true); + httpConfig.setOutputBufferSize(32768); + eq("httpConfig.sendServerVersion", false, httpConfig.getSendServerVersion()); + eq("httpConfig.sendDateHeader", true, httpConfig.getSendDateHeader()); + eq("httpConfig.outputBufferSize", 32768, httpConfig.getOutputBufferSize()); + + int port = findFreePort(18080, 18280); + ServerConnector connector = new ServerConnector(server, new HttpConnectionFactory(httpConfig)); + connector.setHost("127.0.0.1"); + connector.setPort(port); + connector.setIdleTimeout(30000L); + server.addConnector(connector); + + eq("connector.host", "127.0.0.1", connector.getHost()); + eq("connector.port", port, connector.getPort()); + eq("connector.idleTimeout", 30000L, connector.getIdleTimeout()); + eq("server.connectorCount", 1, server.getConnectors().length); + check("server.notRunningBeforeStart", !server.isRunning()); + check("server.notStartedBeforeStart", !server.isStarted()); + eq("connector.localPortBeforeStart", -1, connector.getLocalPort()); + + // ---- handler tree ------------------------------------------------ + ApiHandler api = new ApiHandler(); + ContextHandler apiCtx = new ContextHandler("/api"); + apiCtx.setAllowNullPathInfo(true); + apiCtx.setHandler(api); + eq("apiCtx.contextPath", "/api", apiCtx.getContextPath()); + + ResourceHandler resourceHandler = new ResourceHandler(); + resourceHandler.setBaseResource(Resource.newResource(staticDir.toFile())); + resourceHandler.setDirAllowed(false); + resourceHandler.setWelcomeFiles(new String[]{"index.html"}); + ContextHandler staticCtx = new ContextHandler("/static"); + staticCtx.setHandler(resourceHandler); + eq("staticCtx.contextPath", "/static", staticCtx.getContextPath()); + + ContextHandlerCollection contexts = new ContextHandlerCollection(); + contexts.addHandler(apiCtx); + contexts.addHandler(staticCtx); + eq("contexts.childCount", 2, contexts.getHandlers().length); + + HandlerList root = new HandlerList(); + root.addHandler(contexts); + root.addHandler(new RootFallback()); + eq("root.childCount", 2, root.getHandlers().length); + server.setHandler(root); + + ErrorHandler errorHandler = new ErrorHandler(); + errorHandler.setShowStacks(false); + server.setErrorHandler(errorHandler); + + try { + server.start(); + + // ---- post-start lifecycle -------------------------------- + check("server.isStarted", server.isStarted()); + check("server.isRunning", server.isRunning()); + check("server.notStopped", !server.isStopped()); + check("pool.isRunning", pool.isRunning()); + eq("connector.localPort", port, connector.getLocalPort()); + URI uri = server.getURI(); + check("server.uriNotNull", uri != null); + if (uri != null) { + eq("server.uri.port", port, uri.getPort()); + } else { + fail++; + System.out.println("FAIL server.uri.port (uri null)"); + } + + // ---- GET /api/hello : text/plain ------------------------- + Resp r = get(port, "/api/hello"); + eq("hello.code", 200, r.code); + eq("hello.reason", "OK", r.reason); + startsWith("hello.contentType", r.contentType, "text/plain"); + eq("hello.body", "hello", r.body); + eq("hello.xHandler", "api", r.h("X-Handler")); + check("hello.noServerHeader", r.h("Server") == null); + check("hello.hasDateHeader", r.h("Date") != null); + + // ---- GET /api/json : application/json -------------------- + r = get(port, "/api/json"); + eq("json.code", 200, r.code); + startsWith("json.contentType", r.contentType, "application/json"); + eq("json.body", "{\"msg\":\"ok\",\"n\":42}", r.body); + + // ---- GET /api/echo?name=Starry --------------------------- + r = get(port, "/api/echo?name=Starry"); + eq("echo.code", 200, r.code); + eq("echo.body", "echo:Starry", r.body); + + // ---- GET /api/echo (default param) ----------------------- + r = get(port, "/api/echo"); + eq("echoDefault.body", "echo:anon", r.body); + + // ---- GET /api/echo?name=a%20b (url-encoded space) -------- + r = get(port, "/api/echo?name=a%20b"); + eq("echoEncoded.body", "echo:a b", r.body); + + // ---- GET /api/multi?v=1&v=2&v=3 (multi-value param) ------ + r = get(port, "/api/multi?v=1&v=2&v=3"); + eq("multi.code", 200, r.code); + eq("multi.body", "3", r.body); + + // ---- GET /api/headers with request header ---------------- + Map hh = new HashMap<>(); + hh.put("X-Token", "abc123"); + r = http(port, "GET", "/api/headers", hh, null, null); + eq("headers.code", 200, r.code); + eq("headers.body", "token=abc123", r.body); + eq("headers.echoHeader", "abc123", r.h("X-Echo-Token")); + + // ---- GET /api/utf8 (charset) ----------------------------- + r = get(port, "/api/utf8"); + eq("utf8.code", 200, r.code); + has("utf8.contentTypeCharset", r.contentType.toLowerCase(), "charset=utf-8"); + eq("utf8.body", "héllo-世界", r.body); + + // ---- POST /api/users (form body) ------------------------- + byte[] form = "name=alice&age=30".getBytes(StandardCharsets.UTF_8); + r = http(port, "POST", "/api/users", null, form, "application/x-www-form-urlencoded"); + eq("createUser.code", 201, r.code); + eq("createUser.reason", "Created", r.reason); + eq("createUser.location", "/api/users/1", r.h("Location")); + startsWith("createUser.contentType", r.contentType, "application/json"); + eq("createUser.body", "{\"id\":1,\"name\":\"alice\",\"age\":30}", r.body); + + // second create -> id increments + r = http(port, "POST", "/api/users", null, + "name=bob&age=25".getBytes(StandardCharsets.UTF_8), "application/x-www-form-urlencoded"); + eq("createUser2.code", 201, r.code); + eq("createUser2.location", "/api/users/2", r.h("Location")); + eq("createUser2.body", "{\"id\":2,\"name\":\"bob\",\"age\":25}", r.body); + + // ---- POST /api/raw (raw text body) ----------------------- + r = http(port, "POST", "/api/raw", null, + "hello world".getBytes(StandardCharsets.UTF_8), "text/plain"); + eq("raw.code", 200, r.code); + eq("raw.body", "recv:hello world", r.body); + + // ---- PUT /api/users/7 (path id + body) ------------------- + r = http(port, "PUT", "/api/users/7", null, + "patch".getBytes(StandardCharsets.UTF_8), "text/plain"); + eq("put.code", 200, r.code); + eq("put.body", "updated:7:patch", r.body); + + // ---- DELETE /api/users/9 (204 no content) ---------------- + r = http(port, "DELETE", "/api/users/9", null, null, null); + eq("delete.code", 204, r.code); + eq("delete.reason", "No Content", r.reason); + eq("delete.emptyBody", "", r.body); + + // ---- HEAD /api/hello (no body) --------------------------- + r = http(port, "HEAD", "/api/hello", null, null, null); + eq("head.code", 200, r.code); + eq("head.emptyBody", "", r.body); + eq("head.xHandler", "api", r.h("X-Handler")); + + // ---- 405 method not allowed ------------------------------ + r = http(port, "POST", "/api/hello", null, new byte[0], "text/plain"); + eq("notAllowed.code", 405, r.code); + eq("notAllowed.reason", "Method Not Allowed", r.reason); + eq("notAllowed.allow", "GET, HEAD", r.h("Allow")); + + // ---- 404 inside /api context ----------------------------- + r = get(port, "/api/nope"); + eq("apiNotFound.code", 404, r.code); + eq("apiNotFound.reason", "Not Found", r.reason); + eq("apiNotFound.body", "{\"error\":\"not_found\"}", r.body); + + // ---- 404 from root fallback (unmatched context) ---------- + r = get(port, "/no/such/path"); + eq("rootNotFound.code", 404, r.code); + eq("rootNotFound.body", "ROOT-404", r.body); + + // ---- 500 server error path ------------------------------- + r = get(port, "/api/boom"); + eq("boom.code", 500, r.code); + has("boom.body", r.body, "500"); + + // ---- static resource serving ----------------------------- + r = get(port, "/static/data.txt"); + eq("static.code", 200, r.code); + startsWith("static.contentType", r.contentType, "text/plain"); + eq("static.body", "STATIC-FILE-OK", r.body); + + r = get(port, "/static/data.json"); + eq("staticJson.code", 200, r.code); + startsWith("staticJson.contentType", r.contentType, "application/json"); + eq("staticJson.body", "{\"k\":1}", r.body); + + // welcome file for directory root + r = get(port, "/static/"); + eq("welcome.code", 200, r.code); + startsWith("welcome.contentType", r.contentType, "text/html"); + eq("welcome.body", "INDEX-PAGE", r.body); + + // missing static resource + r = get(port, "/static/missing.txt"); + eq("staticMissing.code", 404, r.code); + + // ---- repeated requests (stability / keepalive churn) ----- + int loopOk = 0; + for (int i = 0; i < 20; i++) { + Resp lr = get(port, "/api/hello"); + if (lr.code == 200 && "hello".equals(lr.body)) { + loopOk++; + } + } + eq("loop.allOk", 20, loopOk); + + // ---- thread pool stayed within bounds -------------------- + check("pool.boundedThreads", pool.getThreads() <= pool.getMaxThreads()); + + } finally { + server.stop(); + } + + // ---- post-stop lifecycle ------------------------------------ + check("server.stoppedAfterStop", server.isStopped()); + check("server.notStartedAfterStop", !server.isStarted()); + check("server.notRunningAfterStop", !server.isRunning()); + check("connector.localPortClosed", connector.getLocalPort() <= 0); + + // connection refused after stop + boolean refused = false; + try { + get(port, "/api/hello"); + } catch (IOException e) { + refused = true; + } + check("server.connectionRefusedAfterStop", refused); + + // ---- cleanup temp files ------------------------------------- + try { + Files.deleteIfExists(staticDir.resolve("data.txt")); + Files.deleteIfExists(staticDir.resolve("data.json")); + Files.deleteIfExists(staticDir.resolve("index.html")); + Files.deleteIfExists(staticDir); + } catch (IOException ignore) { + // best effort + } + + System.out.println(MARKER + "_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) { + System.out.println(MARKER); + } + } +} diff --git a/apps/starry/java-web/programs/carpets/MyBatisCarpet.java b/apps/starry/java-web/programs/carpets/MyBatisCarpet.java new file mode 100644 index 0000000000..a280a6d7a1 --- /dev/null +++ b/apps/starry/java-web/programs/carpets/MyBatisCarpet.java @@ -0,0 +1,639 @@ +package org.starry.dod; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.MapKey; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Result; +import org.apache.ibatis.annotations.Results; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.SelectKey; +import org.apache.ibatis.annotations.SelectProvider; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.datasource.pooled.PooledDataSource; +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.executor.BatchResult; +import org.apache.ibatis.jdbc.SQL; +import org.apache.ibatis.logging.nologging.NoLoggingImpl; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.ResultContext; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.TransactionFactory; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; + +/** + * Carpet-level coverage of MyBatis 3.5.16 against the bundled in-memory SQLite + * database (org.xerial sqlite-jdbc 3.46.1.3, shared-cache memory DB kept alive + * by a single held connection). Single file, no XML, fully programmatic + * Configuration. Deterministic: no external network, /tmp only for the SQLite + * native-lib extraction, memory friendly, single threaded. + */ +public class MyBatisCarpet { + + static final String DRIVER = "org.sqlite.JDBC"; + // Shared-cache in-memory database; survives as long as KEEPALIVE stays open. + static final String URL = "jdbc:sqlite:file:starrycarpetmem?mode=memory&cache=shared"; + + static int ok = 0; + static int fail = 0; + static Connection keepAlive; + + static void check(String name, boolean cond) { + if (cond) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name); + } + } + + static void eqI(String name, long expected, long actual) { + check(name + " (exp=" + expected + " act=" + actual + ")", expected == actual); + } + + static void eqS(String name, String expected, String actual) { + check(name + " (exp=" + expected + " act=" + actual + ")", + expected == null ? actual == null : expected.equals(actual)); + } + + static void contains(String name, String haystack, String needle) { + check(name + " contains[" + needle + "]", haystack != null && haystack.contains(needle)); + } + + // ---- raw helpers over the keep-alive connection ------------------------ + + static void exec(String sql) throws Exception { + try (Statement st = keepAlive.createStatement()) { + st.execute(sql); + } + } + + static void resetUsers() throws Exception { + exec("DROP TABLE IF EXISTS users"); + exec("CREATE TABLE users (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "user_name TEXT NOT NULL, " + + "email TEXT, " + + "age INTEGER, " + + "active INTEGER)"); + } + + static void seed(String name, String email, int age, boolean active) throws Exception { + try (PreparedStatement ps = keepAlive.prepareStatement( + "INSERT INTO users(user_name,email,age,active) VALUES(?,?,?,?)")) { + ps.setString(1, name); + ps.setString(2, email); + ps.setInt(3, age); + ps.setInt(4, active ? 1 : 0); + ps.executeUpdate(); + } + } + + static int rawCount() throws Exception { + try (Statement st = keepAlive.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM users")) { + rs.next(); + return rs.getInt(1); + } + } + + static SqlSessionFactory factory; + + public static void main(String[] args) throws Exception { + Class.forName(DRIVER); + keepAlive = DriverManager.getConnection(URL); + try { + // --------------------------------------------------------------- + // Phase 1: programmatic Configuration / DataSource / TxFactory / + // TypeAlias / Mapper registration + SqlSessionFactoryBuilder + // --------------------------------------------------------------- + PooledDataSource pooled = new PooledDataSource(DRIVER, URL, null, null); + TransactionFactory txf = new JdbcTransactionFactory(); + Environment env = new Environment("dev", txf, pooled); + Configuration cfg = new Configuration(env); + cfg.setLogImpl(NoLoggingImpl.class); + cfg.setCacheEnabled(true); + cfg.getTypeAliasRegistry().registerAlias("user", User.class); + cfg.addMapper(UserMapper.class); + factory = new SqlSessionFactoryBuilder().build(cfg); + + check("factory built", factory != null); + eqS("env id", "dev", cfg.getEnvironment().getId()); + check("env datasource identity", cfg.getEnvironment().getDataSource() == pooled); + check("env tx factory type", + cfg.getEnvironment().getTransactionFactory() instanceof JdbcTransactionFactory); + check("hasMapper UserMapper", cfg.hasMapper(UserMapper.class)); + check("typeAlias user resolves", + User.class.equals(cfg.getTypeAliasRegistry().resolveAlias("user"))); + check("cache enabled", cfg.isCacheEnabled()); + check("datasource is pooled", cfg.getEnvironment().getDataSource() instanceof PooledDataSource); + eqS("pooled driver", DRIVER, pooled.getDriver()); + check("mapped statement findById exists", + cfg.hasStatement("org.starry.dod.UserMapper.findById")); + + // --------------------------------------------------------------- + // Phase 2: CRUD via Mapper interface + SqlSession string API + // --------------------------------------------------------------- + resetUsers(); + + User alice = new User("alice", "alice@x.io", 30, true); + User bob = new User("bob", "bob@x.io", 25, true); + User carol = new User("carol", "carol@x.io", 40, false); + + try (SqlSession s = factory.openSession(false)) { + UserMapper m = s.getMapper(UserMapper.class); + int r1 = m.insertGenKeys(alice); // @Options useGeneratedKeys + int r2 = m.insertSelectKey(bob); // @SelectKey + int r3 = s.insert("org.starry.dod.UserMapper.insertPlain", carol); + + eqI("insertGenKeys rows", 1, r1); + eqI("insertSelectKey rows", 1, r2); + eqI("session.insert rows", 1, r3); + check("insertGenKeys populated id", alice.getId() > 0); + check("insertSelectKey populated id", bob.getId() > 0); + // carol got its id via plain insert; fetch it back later by name + s.commit(); + } + eqI("rawCount after 3 inserts", 3, rawCount()); + + // top up to 5 rows with deterministic ages for pagination/search + seed("dave", "dave@x.io", 35, true); + seed("eve", "eve@x.io", 20, false); + eqI("rawCount after seed", 5, rawCount()); + + try (SqlSession s = factory.openSession()) { + UserMapper m = s.getMapper(UserMapper.class); + + // selectOne (mapper) + @Results column->property mapping + User fa = m.findById(alice.getId()); + check("findById alice not null", fa != null); + eqS("findById mapped name", "alice", fa.getName()); + eqS("findById email", "alice@x.io", fa.getEmail()); + eqI("findById age", 30, fa.getAge()); + check("findById active=true", fa.isActive()); + + User fc = m.findById(carolByName(s)); + check("findById carol active=false", fc != null && !fc.isActive()); + + // selectOne via SqlSession string API + User fbob = s.selectOne("org.starry.dod.UserMapper.findById", + java.util.Collections.singletonMap("id", bob.getId())); + eqS("selectOne string-api name", "bob", fbob.getName()); + + // selectList (mapper) + ordering + List all = m.findAll(); + eqI("findAll size", 5, all.size()); + check("findAll ordered asc", + all.get(0).getId() < all.get(all.size() - 1).getId()); + + // selectList via SqlSession string API + List all2 = s.selectList("org.starry.dod.UserMapper.findAll"); + eqI("selectList string-api size", 5, all2.size()); + + // selectMap via SqlSession + Map byId = + s.selectMap("org.starry.dod.UserMapper.findAll", "id"); + eqI("selectMap size", 5, byId.size()); + check("selectMap contains alice id", byId.containsKey(alice.getId())); + eqS("selectMap alice name", "alice", byId.get(alice.getId()).getName()); + + // @MapKey mapper method + Map mk = m.findAllAsMap(); + eqI("@MapKey map size", 5, mk.size()); + eqS("@MapKey alice name", "alice", mk.get(alice.getId()).getName()); + + // count + eqI("count() == 5", 5, m.count()); + + // ResultHandler + final int[] visited = {0}; + final StringBuilder names = new StringBuilder(); + s.select("org.starry.dod.UserMapper.findAll", new ResultHandler() { + @Override + public void handleResult(ResultContext context) { + User u = context.getResultObject(); + visited[0]++; + names.append(u.getName()).append(','); + } + }); + eqI("ResultHandler visited", 5, visited[0]); + contains("ResultHandler names", names.toString(), "alice"); + contains("ResultHandler names dave", names.toString(), "dave"); + + // RowBounds pagination via SqlSession string API + List page = s.selectList("org.starry.dod.UserMapper.findAll", + null, new RowBounds(1, 2)); + eqI("RowBounds page size", 2, page.size()); + check("RowBounds skipped first row", + page.get(0).getId() == bob.getId()); + + // RowBounds as a mapper method parameter + List page2 = m.findAllPaged(new RowBounds(0, 2)); + eqI("RowBounds mapper page size", 2, page2.size()); + + // Dynamic SQL provider: no-arg provider referencing #{name} + List byName = m.findByNameProvided("alice"); + eqI("provider byName size", 1, byName.size()); + eqS("provider byName name", "alice", byName.get(0).getName()); + + // Dynamic SQL provider with optional WHERE assembly (SQL builder) + eqI("search minAge>=30 size", 3, m.search(30, null).size()); + eqI("search name=bob size", 1, m.search(null, "bob").size()); + eqI("search minAge+name size", 1, m.search(30, "alice").size()); + eqI("search no filter size", 5, m.search(null, null).size()); + eqI("search no-match size", 0, m.search(1000, null).size()); + + // boundary: missing row -> selectOne null + check("findById missing -> null", m.findById(999999) == null); + + // update (mapper) + commit + re-read + int u1 = m.updateEmail(alice.getId(), "alice2@x.io"); + eqI("updateEmail rows", 1, u1); + s.commit(); + eqS("update reflected", "alice2@x.io", m.findById(alice.getId()).getEmail()); + + // update via SqlSession string API + Map up = new HashMap<>(); + up.put("id", bob.getId()); + up.put("email", "bob2@x.io"); + int u2 = s.update("org.starry.dod.UserMapper.updateEmail", up); + eqI("session.update rows", 1, u2); + s.commit(); + eqS("session.update reflected", "bob2@x.io", m.findById(bob.getId()).getEmail()); + + // update non-existent -> 0 + eqI("update missing rows", 0, m.updateEmail(999999, "none")); + + // delete (mapper) + commit + int d1 = m.deleteById(daveId(s)); + eqI("deleteById rows", 1, d1); + s.commit(); + eqI("count after delete == 4", 4, m.count()); + + // delete via SqlSession string API + int d2 = s.delete("org.starry.dod.UserMapper.deleteById", + java.util.Collections.singletonMap("id", eveId(s))); + eqI("session.delete rows", 1, d2); + s.commit(); + eqI("count after 2nd delete == 3", 3, m.count()); + + // delete non-existent -> 0 + eqI("delete missing rows", 0, m.deleteById(999999)); + } + + // --------------------------------------------------------------- + // Phase 3: local (L1) cache identity semantics + // --------------------------------------------------------------- + resetUsers(); + seed("solo", "solo@x.io", 50, true); + int soloId; + try (SqlSession cs = factory.openSession(false)) { + UserMapper m = cs.getMapper(UserMapper.class); + soloId = m.findAll().get(0).getId(); + User a = m.findById(soloId); + User b = m.findById(soloId); + check("L1 cache returns same instance", a == b); + cs.clearCache(); + User c = m.findById(soloId); + check("clearCache yields new instance", a != c); + eqS("clearCache value preserved", "solo", c.getName()); + } + try (SqlSession cs2 = factory.openSession(false)) { + User d = cs2.getMapper(UserMapper.class).findById(soloId); + eqS("fresh session value", "solo", d.getName()); + } + + // --------------------------------------------------------------- + // Phase 4: transaction commit / rollback / autocommit + // --------------------------------------------------------------- + resetUsers(); + try (SqlSession tx = factory.openSession(false)) { + tx.getMapper(UserMapper.class).insertPlain(new User("rb", "rb@x.io", 1, true)); + tx.rollback(); + } + eqI("rollback leaves 0 rows", 0, rawCount()); + + try (SqlSession tx = factory.openSession(false)) { + tx.getMapper(UserMapper.class).insertPlain(new User("cm", "cm@x.io", 2, true)); + tx.commit(); + } + eqI("commit leaves 1 row", 1, rawCount()); + + try (SqlSession tx = factory.openSession(true)) { // autoCommit + tx.getMapper(UserMapper.class).insertPlain(new User("ac", "ac@x.io", 3, true)); + } + eqI("autoCommit makes 2 rows", 2, rawCount()); + + // --------------------------------------------------------------- + // Phase 5: batch executor (ExecutorType.BATCH) + // --------------------------------------------------------------- + resetUsers(); + try (SqlSession bs = factory.openSession(ExecutorType.BATCH, false)) { + UserMapper m = bs.getMapper(UserMapper.class); + for (int i = 0; i < 10; i++) { + m.insertPlain(new User("b" + i, "b" + i + "@x.io", 10 + i, i % 2 == 0)); + } + List results = bs.flushStatements(); + check("batch flush produced results", results != null && !results.isEmpty()); + bs.commit(); + } + eqI("batch inserted 10 rows", 10, rawCount()); + + // --------------------------------------------------------------- + // Phase 6: UnpooledDataSource secondary factory (same memory DB) + // --------------------------------------------------------------- + UnpooledDataSource unpooled = new UnpooledDataSource(DRIVER, URL, null, null); + Environment env2 = new Environment("unpooled", new JdbcTransactionFactory(), unpooled); + Configuration cfg2 = new Configuration(env2); + cfg2.setLogImpl(NoLoggingImpl.class); + cfg2.addMapper(UserMapper.class); + SqlSessionFactory factory2 = new SqlSessionFactoryBuilder().build(cfg2); + check("unpooled datasource type", + cfg2.getEnvironment().getDataSource() instanceof UnpooledDataSource); + eqS("unpooled driver", DRIVER, unpooled.getDriver()); + try (SqlSession us = factory2.openSession()) { + eqI("unpooled sees batch rows", 10, us.getMapper(UserMapper.class).findAll().size()); + } + + // --------------------------------------------------------------- + // Phase 7: org.apache.ibatis.jdbc.SQL builder (standalone, no DB) + // --------------------------------------------------------------- + String sel = new SQL() {{ + SELECT("id"); + SELECT("user_name"); + FROM("users"); + WHERE("age > #{age}"); + ORDER_BY("id DESC"); + }}.toString(); + contains("SQL select SELECT", sel, "SELECT"); + contains("SQL select FROM", sel, "FROM users"); + contains("SQL select WHERE", sel, "WHERE"); + contains("SQL select ORDER BY", sel, "ORDER BY"); + + String ins = new SQL() {{ + INSERT_INTO("users"); + VALUES("user_name", "#{name}"); + VALUES("email", "#{email}"); + }}.toString(); + contains("SQL insert INSERT INTO", ins, "INSERT INTO users"); + contains("SQL insert VALUES", ins, "VALUES"); + + String upd = new SQL() {{ + UPDATE("users"); + SET("email = #{email}"); + WHERE("id = #{id}"); + }}.toString(); + contains("SQL update UPDATE", upd, "UPDATE users"); + contains("SQL update SET", upd, "SET"); + contains("SQL update WHERE", upd, "WHERE"); + + String del = new SQL() {{ + DELETE_FROM("users"); + WHERE("id = #{id}"); + }}.toString(); + contains("SQL delete DELETE FROM", del, "DELETE FROM users"); + contains("SQL delete WHERE", del, "WHERE"); + + } finally { + try { + if (keepAlive != null) { + keepAlive.close(); + } + } catch (Exception ignore) { + // ignore + } + } + + System.out.println("MYBATIS_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) { + System.out.println("MYBATIS_DONE"); + } + } + + // helpers to look up auto-increment ids by name (within an open session) + static int carolByName(SqlSession s) { + return idByName(s, "carol"); + } + + static int daveId(SqlSession s) { + return idByName(s, "dave"); + } + + static int eveId(SqlSession s) { + return idByName(s, "eve"); + } + + static int idByName(SqlSession s, String name) { + List all = s.getMapper(UserMapper.class).findAll(); + for (User u : all) { + if (name.equals(u.getName())) { + return u.getId(); + } + } + return -1; + } + + /** Dynamic SQL provider built with the fluent {@link SQL} builder. */ + public static class UserSqlProvider { + + public UserSqlProvider() { + } + + public String byName() { + return new SQL() {{ + SELECT("id, user_name, email, age, active"); + FROM("users"); + WHERE("user_name = #{name}"); + ORDER_BY("id"); + }}.toString(); + } + + public String search(Map p) { + return new SQL() {{ + SELECT("id, user_name, email, age, active"); + FROM("users"); + if (p.get("minAge") != null) { + WHERE("age >= #{minAge}"); + } + if (p.get("name") != null) { + WHERE("user_name = #{name}"); + } + ORDER_BY("id"); + }}.toString(); + } + } +} + +/** Plain mapped POJO. */ +class User { + private int id; + private String name; + private String email; + private int age; + private boolean active; + + public User() { + } + + public User(String name, String email, int age, boolean active) { + this.name = name; + this.email = email; + this.age = age; + this.active = active; + } + + public Map asMap() { + Map m = new HashMap<>(); + m.put("name", name); + m.put("email", email); + m.put("age", age); + m.put("active", active); + return m; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } +} + +/** Annotation-driven mapper interface exercised by the carpet. */ +interface UserMapper { + + @Insert("INSERT INTO users(user_name,email,age,active) " + + "VALUES(#{name},#{email},#{age},#{active})") + @Options(useGeneratedKeys = true, keyProperty = "id") + int insertGenKeys(User u); + + @Insert("INSERT INTO users(user_name,email,age,active) " + + "VALUES(#{name},#{email},#{age},#{active})") + @SelectKey(statement = "SELECT last_insert_rowid()", keyProperty = "id", + before = false, resultType = int.class) + int insertSelectKey(User u); + + @Insert("INSERT INTO users(user_name,email,age,active) " + + "VALUES(#{name},#{email},#{age},#{active})") + int insertPlain(User u); + + @Select("SELECT id, user_name, email, age, active FROM users WHERE id = #{id}") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + User findById(@Param("id") int id); + + @Select("SELECT id, user_name, email, age, active FROM users ORDER BY id") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + List findAll(); + + @Select("SELECT id, user_name, email, age, active FROM users ORDER BY id") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + @MapKey("id") + Map findAllAsMap(); + + @Select("SELECT id, user_name, email, age, active FROM users ORDER BY id") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + List findAllPaged(RowBounds rowBounds); + + @SelectProvider(type = MyBatisCarpet.UserSqlProvider.class, method = "byName") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + List findByNameProvided(@Param("name") String name); + + @SelectProvider(type = MyBatisCarpet.UserSqlProvider.class, method = "search") + @Results({ + @Result(id = true, property = "id", column = "id"), + @Result(property = "name", column = "user_name"), + @Result(property = "email", column = "email"), + @Result(property = "age", column = "age"), + @Result(property = "active", column = "active", javaType = boolean.class) + }) + List search(@Param("minAge") Integer minAge, @Param("name") String name); + + @Select("SELECT COUNT(*) FROM users") + long count(); + + @Update("UPDATE users SET email = #{email} WHERE id = #{id}") + int updateEmail(@Param("id") int id, @Param("email") String email); + + @Delete("DELETE FROM users WHERE id = #{id}") + int deleteById(@Param("id") int id); +} diff --git a/apps/starry/java-web/programs/carpets/NettyCarpet.java b/apps/starry/java-web/programs/carpets/NettyCarpet.java new file mode 100644 index 0000000000..f6c24c5053 --- /dev/null +++ b/apps/starry/java-web/programs/carpets/NettyCarpet.java @@ -0,0 +1,343 @@ +package org.starry.dod; + +import io.netty.bootstrap.Bootstrap; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.Unpooled; +import io.netty.buffer.UnpooledByteBufAllocator; +import io.netty.channel.*; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.codec.DelimiterBasedFrameDecoder; +import io.netty.handler.codec.Delimiters; +import io.netty.handler.codec.FixedLengthFrameDecoder; +import io.netty.handler.codec.LengthFieldBasedFrameDecoder; +import io.netty.handler.codec.LengthFieldPrepender; +import io.netty.handler.codec.LineBasedFrameDecoder; +import io.netty.handler.codec.MessageToMessageEncoder; +import io.netty.handler.codec.string.StringDecoder; +import io.netty.handler.codec.string.StringEncoder; +import io.netty.util.Attribute; +import io.netty.util.AttributeKey; +import io.netty.util.CharsetUtil; +import io.netty.util.ReferenceCountUtil; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/* Carpet-level coverage of Netty 4.x: ByteBuf (alloc / read-write / pooled / refcount / slice / + * compose), EmbeddedChannel-driven codec/handler unit tests (LineBased / DelimiterBased / + * FixedLength / LengthField(+Prepender) / String enc-dec / custom ByteToMessage / + * MessageToMessage), pipeline manipulation, ChannelFuture / Promise, AttributeKey, and two real + * loopback integrations (a TCP echo server + an HTTP-codec server). Deterministic + offline. */ +public class NettyCarpet { + static int ok = 0, fail = 0; + static void chk(boolean c, String n) { if (c) ok++; else { fail++; System.out.println("FAIL " + n); } } + + public static void main(String[] args) throws Exception { + byteBuf(); + codecsViaEmbedded(); + pipelineAndHandlers(); + futuresAndAttributes(); + tcpEchoLoopback(); + httpCodecLoopback(); + + System.out.println("NETTY_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) System.out.println("NETTY_DONE"); + } + + // ---------------------------------------------------------------- ByteBuf + static void byteBuf() { + ByteBuf b = Unpooled.buffer(16); + chk(b.capacity() >= 16 && b.readableBytes() == 0 && b.writableBytes() >= 16, "bytebuf-init"); + b.writeInt(0x01020304).writeByte(0xFF); + chk(b.readableBytes() == 5 && b.writerIndex() == 5, "bytebuf-write-index"); + chk(b.readInt() == 0x01020304, "bytebuf-readInt"); + chk((b.readByte() & 0xFF) == 0xFF, "bytebuf-readByte"); + chk(b.readableBytes() == 0, "bytebuf-drained"); + b.clear(); + chk(b.readerIndex() == 0 && b.writerIndex() == 0, "bytebuf-clear"); + + ByteBuf s = Unpooled.copiedBuffer("hello netty", CharsetUtil.UTF_8); + chk(s.toString(CharsetUtil.UTF_8).equals("hello netty"), "bytebuf-copiedBuffer-string"); + chk(s.indexOf(0, s.writerIndex(), (byte) 'n') == 6, "bytebuf-indexOf"); + ByteBuf slice = s.slice(0, 5); + chk(slice.toString(CharsetUtil.UTF_8).equals("hello"), "bytebuf-slice"); + ByteBuf dup = s.duplicate(); + chk(dup.readableBytes() == s.readableBytes(), "bytebuf-duplicate"); + + ByteBuf composite = Unpooled.wrappedBuffer( + Unpooled.copiedBuffer("ab", CharsetUtil.UTF_8), + Unpooled.copiedBuffer("cd", CharsetUtil.UTF_8)); + chk(composite.toString(CharsetUtil.UTF_8).equals("abcd"), "bytebuf-wrapped-composite"); + + chk(ByteBufUtil.hexDump(Unpooled.wrappedBuffer(new byte[]{0x0A, (byte) 0xFF})).equals("0aff"), "bytebufutil-hexdump"); + + ByteBuf pooled = PooledByteBufAllocator.DEFAULT.buffer(8); + chk(pooled.refCnt() == 1, "pooled-refcnt-init"); + pooled.retain(); + chk(pooled.refCnt() == 2, "pooled-retain"); + pooled.release(); + chk(pooled.refCnt() == 1, "pooled-release"); + chk(pooled.release(), "pooled-release-final"); + chk(pooled.refCnt() == 0, "pooled-refcnt-zero"); + + ByteBuf un = UnpooledByteBufAllocator.DEFAULT.heapBuffer(4); + chk(un.hasArray(), "unpooled-heap-hasArray"); + un.release(); + + ByteBuf order = Unpooled.buffer(8); + order.writeShort(0x1234); + chk(order.getUnsignedShort(0) == 0x1234, "bytebuf-unsigned-short"); + order.release(); + b.release(); s.release(); composite.release(); + } + + // ---------------------------------------------------------------- codecs (EmbeddedChannel) + static void codecsViaEmbedded() { + // LineBasedFrameDecoder + EmbeddedChannel line = new EmbeddedChannel(new LineBasedFrameDecoder(64), new StringDecoder(CharsetUtil.UTF_8)); + chk(line.writeInbound(Unpooled.copiedBuffer("one\ntwo\n", CharsetUtil.UTF_8)), "line-write"); + chk("one".equals(line.readInbound()), "line-frame-1"); + chk("two".equals(line.readInbound()), "line-frame-2"); + chk(line.readInbound() == null, "line-frame-none"); + chk(!line.finish(), "line-finish"); + + // DelimiterBasedFrameDecoder + EmbeddedChannel delim = new EmbeddedChannel( + new DelimiterBasedFrameDecoder(64, Delimiters.lineDelimiter()), new StringDecoder(CharsetUtil.UTF_8)); + delim.writeInbound(Unpooled.copiedBuffer("a\r\nb\r\n", CharsetUtil.UTF_8)); + chk("a".equals(delim.readInbound()), "delim-frame-1"); + chk("b".equals(delim.readInbound()), "delim-frame-2"); + delim.finish(); + + // FixedLengthFrameDecoder + EmbeddedChannel fix = new EmbeddedChannel(new FixedLengthFrameDecoder(3), new StringDecoder(CharsetUtil.UTF_8)); + fix.writeInbound(Unpooled.copiedBuffer("abcdef", CharsetUtil.UTF_8)); + chk("abc".equals(fix.readInbound()), "fixed-frame-1"); + chk("def".equals(fix.readInbound()), "fixed-frame-2"); + fix.finish(); + + // LengthFieldPrepender + LengthFieldBasedFrameDecoder round-trip + EmbeddedChannel enc = new EmbeddedChannel(new LengthFieldPrepender(4)); + enc.writeOutbound(Unpooled.copiedBuffer("payload", CharsetUtil.UTF_8)); + ByteBuf header = enc.readOutbound(); // prepender emits the length header... + ByteBuf payloadBuf = enc.readOutbound(); // ...and the original payload as a separate outbound + chk(header.getInt(0) == 7, "lengthprepender-header"); + ByteBuf framed = Unpooled.wrappedBuffer(header, payloadBuf); + chk(framed.readableBytes() == 11, "lengthprepender-framed-size"); + EmbeddedChannel dec = new EmbeddedChannel(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4), new StringDecoder(CharsetUtil.UTF_8)); + dec.writeInbound(framed); + chk("payload".equals(dec.readInbound()), "lengthfield-decode"); + enc.finish(); dec.finish(); + + // StringEncoder (outbound) + EmbeddedChannel sEnc = new EmbeddedChannel(new StringEncoder(CharsetUtil.UTF_8)); + sEnc.writeOutbound("hi"); + ByteBuf encoded = sEnc.readOutbound(); + chk(encoded.toString(CharsetUtil.UTF_8).equals("hi"), "string-encoder"); + encoded.release(); sEnc.finish(); + + // custom ByteToMessageDecoder: read 4-byte ints + EmbeddedChannel intDec = new EmbeddedChannel(new ByteToMessageDecoder() { + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + while (in.readableBytes() >= 4) out.add(in.readInt()); + } + }); + ByteBuf two = Unpooled.buffer().writeInt(11).writeInt(22); + intDec.writeInbound(two); + chk(Integer.valueOf(11).equals(intDec.readInbound()), "b2m-decode-1"); + chk(Integer.valueOf(22).equals(intDec.readInbound()), "b2m-decode-2"); + intDec.finish(); + + // custom MessageToMessageEncoder: Integer -> String + EmbeddedChannel m2m = new EmbeddedChannel(new MessageToMessageEncoder() { + protected void encode(ChannelHandlerContext ctx, Integer msg, List out) { out.add("n=" + msg); } + }); + m2m.writeOutbound(42); + chk("n=42".equals(m2m.readOutbound()), "m2m-encode"); + m2m.finish(); + } + + // ---------------------------------------------------------------- pipeline + handlers + static void pipelineAndHandlers() { + EmbeddedChannel ch = new EmbeddedChannel(); + ChannelPipeline p = ch.pipeline(); + ChannelInboundHandlerAdapter h1 = new ChannelInboundHandlerAdapter(); + StringDecoder h2 = new StringDecoder(CharsetUtil.UTF_8); + p.addLast("first", h1); + p.addLast("second", h2); + chk(p.get("first") == h1 && p.get("second") == h2, "pipeline-addLast-get"); + chk(p.first() == h1, "pipeline-first"); + p.addFirst("zero", new ChannelInboundHandlerAdapter()); + chk(p.first() != h1, "pipeline-addFirst"); + p.remove("zero"); + chk(p.first() == h1, "pipeline-remove"); + p.replace("first", "first2", new ChannelInboundHandlerAdapter()); + chk(p.get("first") == null && p.get("first2") != null, "pipeline-replace"); + List names = p.names(); + chk(names.contains("first2") && names.contains("second"), "pipeline-names"); + ch.finish(); + + // inbound flow: counting handler + final int[] reads = {0}; + EmbeddedChannel flow = new EmbeddedChannel(new ChannelInboundHandlerAdapter() { + public void channelRead(ChannelHandlerContext ctx, Object msg) { reads[0]++; ReferenceCountUtil.release(msg); } + }); + flow.writeInbound(Unpooled.copiedBuffer("x", CharsetUtil.UTF_8)); + flow.writeInbound(Unpooled.copiedBuffer("y", CharsetUtil.UTF_8)); + chk(reads[0] == 2, "inbound-channelRead-count"); + flow.finish(); + + // outbound flow: capture written + final AtomicReference written = new AtomicReference<>(); + EmbeddedChannel out = new EmbeddedChannel(new ChannelOutboundHandlerAdapter() { + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { written.set(msg); promise.setSuccess(); } + }); + out.writeOutbound("captured"); + chk("captured".equals(written.get()), "outbound-write-capture"); + out.finish(); + } + + // ---------------------------------------------------------------- futures + attributes + static void futuresAndAttributes() throws Exception { + EmbeddedChannel ch = new EmbeddedChannel(); + AttributeKey KEY = AttributeKey.valueOf("netty-carpet-key"); + Attribute attr = ch.attr(KEY); + chk(attr.get() == null, "attr-null-init"); + attr.set("v1"); + chk("v1".equals(ch.attr(KEY).get()), "attr-set-get"); + chk("v1".equals(attr.getAndSet("v2")), "attr-getAndSet"); + chk("v2".equals(attr.get()), "attr-after-getAndSet"); + + ChannelPromise pr = ch.newPromise(); + chk(!pr.isDone(), "promise-not-done"); + pr.setSuccess(); + chk(pr.isDone() && pr.isSuccess(), "promise-success"); + + ChannelPromise pf = ch.newPromise(); + pf.setFailure(new RuntimeException("boom")); + chk(pf.isDone() && !pf.isSuccess() && pf.cause() instanceof RuntimeException, "promise-failure"); + + ChannelFuture cf = ch.newSucceededFuture(); + chk(cf.isSuccess() && cf.await(2, TimeUnit.SECONDS), "succeeded-future"); + ch.finish(); + } + + // ---------------------------------------------------------------- real TCP echo (loopback) + static void tcpEchoLoopback() throws Exception { + NioEventLoopGroup boss = new NioEventLoopGroup(1); + NioEventLoopGroup worker = new NioEventLoopGroup(1); + Channel server = null; + try { + ServerBootstrap sb = new ServerBootstrap(); + sb.group(boss, worker).channel(NioServerSocketChannel.class) + .option(ChannelOption.SO_BACKLOG, 16) + .childOption(ChannelOption.TCP_NODELAY, true) + .childHandler(new ChannelInitializer() { + protected void initChannel(SocketChannel c) { + c.pipeline().addLast(new ChannelInboundHandlerAdapter() { + public void channelRead(ChannelHandlerContext ctx, Object msg) { ctx.writeAndFlush(msg); } + }); + } + }); + server = sb.bind(new InetSocketAddress("127.0.0.1", 0)).sync().channel(); + int port = ((InetSocketAddress) server.localAddress()).getPort(); + chk(port > 0 && server.isActive(), "tcp-server-active"); + + try (Socket sock = new Socket()) { + sock.connect(new InetSocketAddress("127.0.0.1", port), 5000); + sock.setSoTimeout(5000); + byte[] payload = "echo-me\n".getBytes(StandardCharsets.UTF_8); + sock.getOutputStream().write(payload); + sock.getOutputStream().flush(); + byte[] buf = new byte[payload.length]; + int read = 0; + while (read < buf.length) { + int r = sock.getInputStream().read(buf, read, buf.length - read); + if (r < 0) break; + read += r; + } + chk(read == payload.length && new String(buf, StandardCharsets.UTF_8).equals("echo-me\n"), "tcp-echo-roundtrip"); + } + } finally { + if (server != null) server.close().sync(); + boss.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + worker.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + chk(true, "tcp-graceful-shutdown"); + } + + // ---------------------------------------------------------------- real HTTP (loopback) + static void httpCodecLoopback() throws Exception { + NioEventLoopGroup boss = new NioEventLoopGroup(1); + NioEventLoopGroup worker = new NioEventLoopGroup(1); + Channel server = null; + try { + ServerBootstrap sb = new ServerBootstrap(); + sb.group(boss, worker).channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + protected void initChannel(SocketChannel c) { + c.pipeline().addLast(new io.netty.handler.codec.http.HttpServerCodec()); + c.pipeline().addLast(new io.netty.handler.codec.http.HttpObjectAggregator(65536)); + c.pipeline().addLast(new SimpleChannelInboundHandler() { + protected void channelRead0(ChannelHandlerContext ctx, io.netty.handler.codec.http.FullHttpRequest req) { + String body = "method=" + req.method().name() + " uri=" + req.uri(); + ByteBuf content = Unpooled.copiedBuffer(body, CharsetUtil.UTF_8); + io.netty.handler.codec.http.FullHttpResponse resp = new io.netty.handler.codec.http.DefaultFullHttpResponse( + io.netty.handler.codec.http.HttpVersion.HTTP_1_1, + io.netty.handler.codec.http.HttpResponseStatus.OK, content); + resp.headers().set(io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); + resp.headers().setInt(io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH, content.readableBytes()); + ctx.writeAndFlush(resp).addListener(ChannelFutureListener.CLOSE); + } + }); + } + }); + server = sb.bind(new InetSocketAddress("127.0.0.1", 0)).sync().channel(); + int port = ((InetSocketAddress) server.localAddress()).getPort(); + chk(server.isActive(), "http-server-active"); + + HttpURLConnection conn = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/ping").openConnection(); + conn.setConnectTimeout(5000); conn.setReadTimeout(5000); + chk(conn.getResponseCode() == 200, "http-status-200"); + chk(conn.getContentType().startsWith("text/plain"), "http-content-type"); + String body; + try (BufferedReader r = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder sbb = new StringBuilder(); String ln; + while ((ln = r.readLine()) != null) sbb.append(ln); + body = sbb.toString(); + } + chk(body.equals("method=GET uri=/ping"), "http-response-body"); + + HttpURLConnection post = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/data").openConnection(); + post.setRequestMethod("POST"); post.setDoOutput(true); + post.setConnectTimeout(5000); post.setReadTimeout(5000); + try (OutputStream os = post.getOutputStream()) { os.write("x".getBytes(StandardCharsets.UTF_8)); } + chk(post.getResponseCode() == 200, "http-post-status"); + try (BufferedReader r = new BufferedReader(new InputStreamReader(post.getInputStream(), StandardCharsets.UTF_8))) { + chk(r.readLine().equals("method=POST uri=/data"), "http-post-body"); + } + } finally { + if (server != null) server.close().sync(); + boss.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + worker.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); + } + } +} diff --git a/apps/starry/java-web/programs/carpets/R2dbcCarpet.java b/apps/starry/java-web/programs/carpets/R2dbcCarpet.java new file mode 100644 index 0000000000..f819eba028 --- /dev/null +++ b/apps/starry/java-web/programs/carpets/R2dbcCarpet.java @@ -0,0 +1,657 @@ +package org.starry.dod; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.ConnectionMetadata; +import io.r2dbc.spi.ColumnMetadata; +import io.r2dbc.spi.IsolationLevel; +import io.r2dbc.spi.Nullability; +import io.r2dbc.spi.Option; +import io.r2dbc.spi.R2dbcBadGrammarException; +import io.r2dbc.spi.R2dbcException; +import io.r2dbc.spi.Result; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; +import io.r2dbc.spi.Statement; +import io.r2dbc.spi.ValidationDepth; +import io.r2dbc.h2.H2ConnectionConfiguration; +import io.r2dbc.h2.H2ConnectionFactory; +import io.r2dbc.h2.H2ConnectionFactoryProvider; +import io.r2dbc.h2.H2ConnectionOption; +import io.r2dbc.h2.CloseableConnectionFactory; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiFunction; + +/** + * Carpet-level coverage of the R2DBC 1.0 reactive SPI driving the bundled + * r2dbc-h2 driver against an in-memory H2 database. No network sockets, no + * external resources: H2 mem runs fully in-process. Deterministic reactive + * collection via a custom reactive-streams Subscriber + CountDownLatch. + */ +public class R2dbcCarpet { + + static int ok = 0; + static int fail = 0; + static final Duration T = Duration.ofSeconds(20); + + static void check(String name, boolean cond) { + if (cond) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name); + } + } + + static void eq(String name, Object actual, Object expected) { + if (Objects.equals(actual, expected)) { + ok++; + } else { + fail++; + System.out.println("FAIL " + name + " expected=[" + expected + "] actual=[" + actual + "]"); + } + } + + // ---- deterministic blocking collector: a hand-written reactive-streams Subscriber ---- + static List drain(Publisher p) { + final List out = Collections.synchronizedList(new ArrayList()); + final CountDownLatch latch = new CountDownLatch(1); + final Throwable[] err = new Throwable[1]; + p.subscribe(new Subscriber() { + public void onSubscribe(Subscription s) { s.request(Long.MAX_VALUE); } + public void onNext(X x) { out.add(x); } + public void onError(Throwable t) { err[0] = t; latch.countDown(); } + public void onComplete() { latch.countDown(); } + }); + try { + if (!latch.await(20, TimeUnit.SECONDS)) { + throw new RuntimeException("drain timeout"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + if (err[0] != null) { + if (err[0] instanceof RuntimeException) throw (RuntimeException) err[0]; + throw new RuntimeException(err[0]); + } + return out; + } + + static X one(Publisher p) { + List l = drain(p); + return l.isEmpty() ? null : l.get(0); + } + + static void run(Publisher p) { + drain(p); // await completion, ignore (Void) results + } + + static void bindAll(Statement s, Object[] binds) { + for (int i = 0; i < binds.length; i++) { + s.bind(i, binds[i]); + } + } + + // DML: total rows updated, summed across emitted Results, via Result.getRowsUpdated() + static long update(Connection c, String sql, Object... binds) { + Statement s = c.createStatement(sql); + bindAll(s, binds); + List counts = drain(Flux.from(s.execute()).concatMap(r -> r.getRowsUpdated())); + long sum = 0; + for (Long x : counts) sum += x; + return sum; + } + + // DQL: map each Row via BiFunction, collected deterministically + static List query(Connection c, String sql, BiFunction m, Object... binds) { + Statement s = c.createStatement(sql); + bindAll(s, binds); + return drain(Flux.from(s.execute()).concatMap(r -> r.map(m))); + } + + public static void main(String[] args) { + Connection conn = null; + Connection conn2 = null; + ConnectionFactory primary = null; + try { + // ============================================================ + // SECTION A: driver discovery, ConnectionFactoryOptions, Option + // ============================================================ + ConnectionFactoryOptions opts = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, "h2") + .option(ConnectionFactoryOptions.PROTOCOL, "mem") + .option(ConnectionFactoryOptions.DATABASE, "carpetdb") + .option(ConnectionFactoryOptions.USER, "sa") + .option(ConnectionFactoryOptions.PASSWORD, "") + .build(); + + check("A.supports.h2", ConnectionFactories.supports(opts)); + ConnectionFactory cfFromOpts = ConnectionFactories.find(opts); + check("A.find.nonNull", cfFromOpts != null); + ConnectionFactory cfGet = ConnectionFactories.get(opts); + check("A.get.nonNull", cfGet != null); + eq("A.metadata.name", cfGet.getMetadata().getName(), "H2"); + + eq("A.opts.getValue.driver", opts.getValue(ConnectionFactoryOptions.DRIVER), "h2"); + eq("A.opts.required.protocol", opts.getRequiredValue(ConnectionFactoryOptions.PROTOCOL), "mem"); + eq("A.opts.required.database", opts.getRequiredValue(ConnectionFactoryOptions.DATABASE), "carpetdb"); + check("A.opts.hasOption.database", opts.hasOption(ConnectionFactoryOptions.DATABASE)); + check("A.opts.hasOption.host.absent", !opts.hasOption(ConnectionFactoryOptions.HOST)); + check("A.opts.getValue.host.null", opts.getValue(ConnectionFactoryOptions.HOST) == null); + + eq("A.option.driver.name", ConnectionFactoryOptions.DRIVER.name(), "driver"); + eq("A.option.protocol.name", ConnectionFactoryOptions.PROTOCOL.name(), "protocol"); + Option custom = Option.valueOf("customKey"); + eq("A.option.valueOf.name", custom.name(), "customKey"); + Option secret = Option.sensitiveValueOf("secretKey"); + eq("A.option.sensitive.name", secret.name(), "secretKey"); + eq("A.h2provider.driverConst", H2ConnectionFactoryProvider.H2_DRIVER, "h2"); + eq("A.h2provider.protoMem", H2ConnectionFactoryProvider.PROTOCOL_MEM, "mem"); + + // parse a full r2dbc URL into options + ConnectionFactoryOptions parsed = ConnectionFactoryOptions.parse("r2dbc:h2:mem:///parsedDb"); + eq("A.parse.driver", parsed.getValue(ConnectionFactoryOptions.DRIVER), "h2"); + eq("A.parse.protocol", parsed.getValue(ConnectionFactoryOptions.PROTOCOL), "mem"); + eq("A.parse.database", parsed.getValue(ConnectionFactoryOptions.DATABASE), "parsedDb"); + check("A.parse.supports", ConnectionFactories.supports(parsed)); + + // bogus driver: not supported, get() throws + ConnectionFactoryOptions bogus = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, "no-such-driver-xyz") + .build(); + check("A.supports.bogus.false", !ConnectionFactories.supports(bogus)); + boolean threwBogus = false; + try { + ConnectionFactories.get(bogus); + } catch (RuntimeException ex) { + threwBogus = true; + } + check("A.get.bogus.throws", threwBogus); + + // getRequiredValue on absent option throws + boolean threwReq = false; + try { + opts.getRequiredValue(ConnectionFactoryOptions.HOST); + } catch (RuntimeException ex) { + threwReq = true; + } + check("A.required.absent.throws", threwReq); + + // mutate(): derive new options and verify added option present, originals retained + ConnectionFactoryOptions mutated = opts.mutate() + .option(ConnectionFactoryOptions.CONNECT_TIMEOUT, Duration.ofSeconds(5)) + .build(); + check("A.mutate.hasNew", mutated.hasOption(ConnectionFactoryOptions.CONNECT_TIMEOUT)); + eq("A.mutate.retains.driver", mutated.getValue(ConnectionFactoryOptions.DRIVER), "h2"); + + // ============================================================ + // SECTION B: ConnectionFactory construction variants + // ============================================================ + // Primary factory keeps the named mem DB alive for the whole run (DB_CLOSE_DELAY=-1) + H2ConnectionConfiguration cfg = H2ConnectionConfiguration.builder() + .inMemory("carpetdb") + .property(H2ConnectionOption.DB_CLOSE_DELAY, "-1") + .username("sa") + .build(); + check("B.config.url.mem", cfg.toString().contains("carpetdb")); + primary = new H2ConnectionFactory(cfg); + eq("B.factory.metadata", primary.getMetadata().getName(), "H2"); + + // alternative builder form: H2ConnectionFactory.inMemory(...) + CloseableConnectionFactory sideFactory = H2ConnectionFactory.inMemory("sidecar"); + check("B.inMemory.factory.nonNull", sideFactory != null); + Connection sideConn = one(sideFactory.create()); + check("B.inMemory.connect", sideConn != null); + run(sideConn.close()); + run(sideFactory.close()); + + // ConnectionFactories.get(String URL) form + ConnectionFactory urlFactory = ConnectionFactories.get("r2dbc:h2:mem:///urlDb"); + check("B.url.factory.nonNull", urlFactory != null); + eq("B.url.factory.name", urlFactory.getMetadata().getName(), "H2"); + + // ============================================================ + // SECTION C: Connection lifecycle / metadata / isolation + // ============================================================ + conn = one(primary.create()); + check("C.connect.nonNull", conn != null); + ConnectionMetadata cmeta = conn.getMetadata(); + eq("C.db.product", cmeta.getDatabaseProductName(), "H2"); + check("C.db.version.nonEmpty", cmeta.getDatabaseVersion() != null && !cmeta.getDatabaseVersion().isEmpty()); + check("C.autocommit.default.true", conn.isAutoCommit()); + + Boolean vLocal = one(conn.validate(ValidationDepth.LOCAL)); + check("C.validate.local", Boolean.TRUE.equals(vLocal)); + Boolean vRemote = one(conn.validate(ValidationDepth.REMOTE)); + check("C.validate.remote", Boolean.TRUE.equals(vRemote)); + + IsolationLevel defaultIso = conn.getTransactionIsolationLevel(); + check("C.iso.default.nonNull", defaultIso != null); + eq("C.iso.default.readCommitted", defaultIso, IsolationLevel.READ_COMMITTED); + // r2dbc-h2 1.0 accepts the change request; its getter reports the session default + run(conn.setTransactionIsolationLevel(IsolationLevel.SERIALIZABLE)); + check("C.iso.set.accepted", conn.getTransactionIsolationLevel() != null); + run(conn.setTransactionIsolationLevel(IsolationLevel.READ_COMMITTED)); + eq("C.iso.afterReset.readCommitted", conn.getTransactionIsolationLevel(), IsolationLevel.READ_COMMITTED); + + // ============================================================ + // SECTION D: DDL + // ============================================================ + long ddl1 = update(conn, + "CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, " + + "age INT, active BOOLEAN, balance DECIMAL(12,2), born DATE, created TIMESTAMP, nick VARCHAR(64))"); + check("D.create.users.noRows", ddl1 == 0); + long ddl2 = update(conn, + "CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(64), amount BIGINT)"); + check("D.create.accounts.noRows", ddl2 == 0); + + // verify table presence via INFORMATION_SCHEMA + List tcount = query(conn, + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = $1 AND TABLE_SCHEMA = $2", + (row, md) -> row.get(0, Long.class), "USERS", "PUBLIC"); + eq("D.users.in.schema", tcount.size() == 1 ? tcount.get(0) : -1L, 1L); + + // ============================================================ + // SECTION E: INSERT (index bind, name bind, bindNull), generated keys, batch via add() + // ============================================================ + long ins1 = update(conn, + "INSERT INTO users (name, age, active, balance, born, created, nick) " + + "VALUES ($1,$2,$3,$4,$5,$6,$7)", + "alice", 30, Boolean.TRUE, new BigDecimal("100.50"), + LocalDate.of(1994, 3, 15), LocalDateTime.of(2024, 1, 2, 3, 4, 5), "ally"); + eq("E.insert.byIndex", ins1, 1L); + + // bind by name "$1".."$7" + Statement byName = conn.createStatement( + "INSERT INTO users (name, age, active, balance, born, created, nick) " + + "VALUES ($1,$2,$3,$4,$5,$6,$7)"); + byName.bind("$1", "bob").bind("$2", 25).bind("$3", Boolean.FALSE) + .bind("$4", new BigDecimal("42.00")).bind("$5", LocalDate.of(1999, 12, 31)) + .bind("$6", LocalDateTime.of(2024, 6, 1, 12, 0, 0)).bind("$7", "bobby"); + long ins2 = 0; + for (Long u : drain(Flux.from(byName.execute()).concatMap(r -> r.getRowsUpdated()))) ins2 += u; + eq("E.insert.byName", ins2, 1L); + + // bindNull for nullable columns (age, nick NULL) + Statement nullStmt = conn.createStatement( + "INSERT INTO users (name, age, active, balance, born, created, nick) " + + "VALUES ($1,$2,$3,$4,$5,$6,$7)"); + nullStmt.bind(0, "carol").bindNull(1, Integer.class).bind(2, Boolean.TRUE) + .bind(3, new BigDecimal("0.00")).bind(4, LocalDate.of(2000, 1, 1)) + .bind(5, LocalDateTime.of(2024, 7, 7, 7, 7, 7)).bindNull(6, String.class); + long ins3 = 0; + for (Long u : drain(Flux.from(nullStmt.execute()).concatMap(r -> r.getRowsUpdated()))) ins3 += u; + eq("E.insert.bindNull", ins3, 1L); + + // returnGeneratedValues: capture the auto-increment id + Statement genStmt = conn.createStatement( + "INSERT INTO users (name, age, active, balance, born, created, nick) " + + "VALUES ($1,$2,$3,$4,$5,$6,$7)") + .returnGeneratedValues("ID"); + genStmt.bind(0, "dave").bind(1, 40).bind(2, Boolean.TRUE) + .bind(3, new BigDecimal("9.99")).bind(4, LocalDate.of(1980, 5, 5)) + .bind(5, LocalDateTime.of(2024, 8, 8, 8, 8, 8)).bind(6, "davey"); + List genKeys = drain(Flux.from(genStmt.execute()) + .concatMap(r -> r.map((row, md) -> row.get(0, Integer.class)))); + check("E.generated.oneKey", genKeys.size() == 1); + check("E.generated.positive", genKeys.size() == 1 && genKeys.get(0) != null && genKeys.get(0) > 0); + + // batch via Statement.add(): two parameter sets in one statement + Statement multi = conn.createStatement( + "INSERT INTO users (name, age, active, balance, born, created, nick) " + + "VALUES ($1,$2,$3,$4,$5,$6,$7)"); + multi.bind(0, "erin").bind(1, 22).bind(2, Boolean.TRUE).bind(3, new BigDecimal("1.00")) + .bind(4, LocalDate.of(2002, 2, 2)).bind(5, LocalDateTime.of(2024, 9, 9, 9, 9, 9)).bind(6, "e"); + multi.add(); + multi.bind(0, "frank").bind(1, 55).bind(2, Boolean.FALSE).bind(3, new BigDecimal("2.00")) + .bind(4, LocalDate.of(1969, 6, 9)).bind(5, LocalDateTime.of(2024, 10, 10, 10, 10, 10)).bind(6, "f"); + List multiCounts = drain(Flux.from(multi.execute()).concatMap(r -> r.getRowsUpdated())); + long multiSum = 0; + for (Long u : multiCounts) multiSum += u; + eq("E.statement.add.twoResults", (long) multiCounts.size(), 2L); + eq("E.statement.add.sum", multiSum, 2L); + + // total row count now: alice, bob, carol, dave, erin, frank = 6 + long total = scalarLong(conn, "SELECT COUNT(*) FROM users"); + eq("E.users.total", total, 6L); + + // ============================================================ + // SECTION F: DQL — typed gets, untyped, predicates, ordering, aggregates, nulls, types + // ============================================================ + List allNames = query(conn, "SELECT name FROM users ORDER BY name ASC", + (row, md) -> row.get("name", String.class)); + eq("F.order.count", (long) allNames.size(), 6L); + eq("F.order.first", allNames.get(0), "alice"); + eq("F.order.last", allNames.get(allNames.size() - 1), "frank"); + + // typed get by name and by index must agree + List aliceRows = query(conn, + "SELECT id, name, age, active, balance, born, created FROM users WHERE name = $1", + (row, md) -> new Object[]{ + row.get("id", Integer.class), row.get(0, Integer.class), + row.get("name", String.class), row.get("age", Integer.class), + row.get("active", Boolean.class), row.get("balance", BigDecimal.class), + row.get("born", LocalDate.class), row.get("created", LocalDateTime.class), + row.get("name") /* untyped Object */ + }, "alice"); + check("F.alice.oneRow", aliceRows.size() == 1); + Object[] a = aliceRows.get(0); + eq("F.alice.id.byName==byIndex", a[0], a[1]); + eq("F.alice.name.typed", a[2], "alice"); + eq("F.alice.age", a[3], 30); + eq("F.alice.active", a[4], Boolean.TRUE); + check("F.alice.balance", ((BigDecimal) a[5]).compareTo(new BigDecimal("100.50")) == 0); + eq("F.alice.born", a[6], LocalDate.of(1994, 3, 15)); + eq("F.alice.created", a[7], LocalDateTime.of(2024, 1, 2, 3, 4, 5)); + eq("F.alice.name.untyped", a[8], "alice"); + + // WHERE with bound int predicate + long adults = scalarLong(conn, "SELECT COUNT(*) FROM users WHERE age >= $1", 30); + // ages -> alice30, bob25, carol NULL, dave40, erin22, frank55 => >=30: alice,dave,frank = 3 + eq("F.where.age.ge30", adults, 3L); + + // LIKE predicate + long likeA = scalarLong(conn, "SELECT COUNT(*) FROM users WHERE name LIKE $1", "a%"); + eq("F.like.a", likeA, 1L); // alice + + // aggregate MAX/MIN/SUM + Integer maxAge = scalarInt(conn, "SELECT MAX(age) FROM users"); + eq("F.max.age", maxAge, 55); + Integer minAge = scalarInt(conn, "SELECT MIN(age) FROM users"); + eq("F.min.age", minAge, 22); + + // NULL handling: carol has NULL age + NULL nick + List carol = query(conn, "SELECT age, nick FROM users WHERE name = $1", + (row, md) -> new Object[]{row.get("age", Integer.class), row.get("nick", String.class)}, "carol"); + check("F.carol.oneRow", carol.size() == 1); + check("F.carol.age.null", carol.get(0)[0] == null); + check("F.carol.nick.null", carol.get(0)[1] == null); + long nullAgeCount = scalarLong(conn, "SELECT COUNT(*) FROM users WHERE age IS NULL"); + eq("F.null.age.count", nullAgeCount, 1L); + + // Result.map(Function) single-arg form + List viaReadable = drain(Flux.from( + conn.createStatement("SELECT name FROM users WHERE name = $1").bind(0, "bob").execute()) + .concatMap(r -> r.map(readable -> readable.get(0, String.class)))); + eq("F.readable.form", viaReadable.size() == 1 ? viaReadable.get(0) : null, "bob"); + + // BIGINT round-trip via accounts table + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 1, "alice", 9000000000L); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 2, "bob", -123L); + List amounts = query(conn, "SELECT amount FROM accounts ORDER BY id", + (row, md) -> row.get("amount", Long.class)); + eq("F.bigint.count", (long) amounts.size(), 2L); + eq("F.bigint.value", amounts.get(0), 9000000000L); + eq("F.bigint.negative", amounts.get(1), -123L); + + // VARCHAR special characters round-trip (quotes via bind, no SQL injection risk) + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 3, "o'brien \"x\"", 7L); + String special = scalarStr(conn, "SELECT owner FROM accounts WHERE id = $1", 3); + eq("F.varchar.special", special, "o'brien \"x\""); + + // DOUBLE round-trip + update(conn, "CREATE TABLE nums (id INT PRIMARY KEY, d DOUBLE, dec DECIMAL(10,4))"); + update(conn, "INSERT INTO nums (id, d, dec) VALUES ($1,$2,$3)", 1, 3.14159d, new BigDecimal("2.7183")); + List nums = query(conn, "SELECT d, dec FROM nums WHERE id = $1", + (row, md) -> new Object[]{row.get("d", Double.class), row.get("dec", BigDecimal.class)}, 1); + check("F.double.value", nums.size() == 1 && Math.abs((Double) nums.get(0)[0] - 3.14159d) < 1e-9); + check("F.decimal.value", ((BigDecimal) nums.get(0)[1]).compareTo(new BigDecimal("2.7183")) == 0); + + // ============================================================ + // SECTION G: RowMetadata / ColumnMetadata + // ============================================================ + List mdList = drain(Flux.from( + conn.createStatement("SELECT id, name, age FROM users WHERE name = $1").bind(0, "alice").execute()) + .concatMap(r -> r.map((row, md) -> md))); + check("G.meta.captured", mdList.size() == 1); + RowMetadata md = mdList.get(0); + eq("G.meta.colCount", (long) md.getColumnMetadatas().size(), 3L); + ColumnMetadata c0 = md.getColumnMetadata(0); + eq("G.meta.col0.name", c0.getName().toUpperCase(), "ID"); + ColumnMetadata cName = md.getColumnMetadata("name"); + eq("G.meta.colName.name", cName.getName().toUpperCase(), "NAME"); + check("G.meta.contains.id", md.contains("id")); + check("G.meta.contains.absent.false", !md.contains("nonexistent_col")); + check("G.meta.col0.type.nonNull", c0.getType() != null); + check("G.meta.col0.javaType", c0.getJavaType() == Integer.class); + check("G.meta.colName.javaType.string", cName.getJavaType() == String.class); + check("G.meta.col0.nullability.nonNull", c0.getNullability() != null); + // name column is declared NOT NULL + Nullability nameNull = cName.getNullability(); + check("G.meta.name.notnull", nameNull == Nullability.NON_NULL || nameNull == Nullability.UNKNOWN); + + // ============================================================ + // SECTION H: UPDATE / DELETE + // ============================================================ + long upd = update(conn, "UPDATE users SET age = $1 WHERE name = $2", 31, "alice"); + eq("H.update.count", upd, 1L); + Integer newAge = scalarInt(conn, "SELECT age FROM users WHERE name = $1", "alice"); + eq("H.update.verified", newAge, 31); + + long updNone = update(conn, "UPDATE users SET age = $1 WHERE name = $2", 99, "ghost"); + eq("H.update.noMatch.zero", updNone, 0L); + + long del = update(conn, "DELETE FROM users WHERE name = $1", "frank"); + eq("H.delete.count", del, 1L); + long afterDel = scalarLong(conn, "SELECT COUNT(*) FROM users"); + eq("H.delete.total", afterDel, 5L); + + // ============================================================ + // SECTION I: Transactions (manual autocommit, explicit begin/commit/rollback, savepoints) + // ============================================================ + // autocommit toggling + run(conn.setAutoCommit(false)); + check("I.autocommit.false", !conn.isAutoCommit()); + run(conn.setAutoCommit(true)); + check("I.autocommit.true.again", conn.isAutoCommit()); + + // explicit begin + commit -> persists + run(conn.beginTransaction()); + check("I.inTx.autocommitFalse", !conn.isAutoCommit()); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 10, "tx-commit", 100L); + run(conn.commitTransaction()); + long committed = scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 10); + eq("I.commit.persists", committed, 1L); + + // explicit begin + rollback -> gone + run(conn.beginTransaction()); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 11, "tx-rollback", 200L); + long beforeRollback = scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 11); + eq("I.rollback.preVisible", beforeRollback, 1L); + run(conn.rollbackTransaction()); + long afterRollback = scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 11); + eq("I.rollback.gone", afterRollback, 0L); + + // explicit begin + commit (id=12) + run(conn.beginTransaction()); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 12, "begin-commit", 300L); + run(conn.commitTransaction()); + eq("I.beginCommit.persists", scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 12), 1L); + + // savepoints: insert A, savepoint, insert B, rollback to savepoint -> A kept, B gone + run(conn.beginTransaction()); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 20, "sp-A", 1L); + run(conn.createSavepoint("sp1")); + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 21, "sp-B", 2L); + long preSpRollback = scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id IN (20,21)"); + eq("I.savepoint.bothVisible", preSpRollback, 2L); + run(conn.rollbackTransactionToSavepoint("sp1")); + eq("I.savepoint.A.kept", scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 20), 1L); + eq("I.savepoint.B.gone", scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 21), 0L); + run(conn.releaseSavepoint("sp1")); + run(conn.commitTransaction()); + eq("I.savepoint.commit.A", scalarLong(conn, "SELECT COUNT(*) FROM accounts WHERE id = $1", 20), 1L); + + run(conn.setAutoCommit(true)); + check("I.autocommit.restored", conn.isAutoCommit()); + + // ============================================================ + // SECTION J: Connection.createBatch() + // ============================================================ + List batchCounts = drain(Flux.from(conn.createBatch() + .add("INSERT INTO accounts (id, owner, amount) VALUES (30,'batch1',1)") + .add("INSERT INTO accounts (id, owner, amount) VALUES (31,'batch2',2)") + .add("UPDATE accounts SET amount = 999 WHERE id = 30") + .execute()).concatMap(r -> r.getRowsUpdated())); + long batchSum = 0; + for (Long u : batchCounts) batchSum += u; + eq("J.batch.results", (long) batchCounts.size(), 3L); + eq("J.batch.sum", batchSum, 3L); + eq("J.batch.applied", scalarLong(conn, "SELECT amount FROM accounts WHERE id = $1", 30), 999L); + + // ============================================================ + // SECTION K: error / exception paths + // ============================================================ + boolean badGrammar = false; + try { + update(conn, "SELCT * FROM nope"); + } catch (R2dbcBadGrammarException ex) { + badGrammar = true; + } catch (R2dbcException ex) { + badGrammar = true; + } + check("K.badGrammar.throws", badGrammar); + + boolean dup = false; + try { + update(conn, "INSERT INTO accounts (id, owner, amount) VALUES ($1,$2,$3)", 1, "dupe", 0L); + } catch (R2dbcException ex) { + dup = true; + } + check("K.duplicateKey.throws", dup); + + boolean badCol = false; + try { + query(conn, "SELECT no_such_column FROM users", (row, m2) -> row.get(0)); + } catch (R2dbcException ex) { + badCol = true; + } + check("K.badColumn.throws", badCol); + + boolean notNullViol = false; + try { + update(conn, "INSERT INTO users (name, age) VALUES ($1,$2)", null, 1); + } catch (R2dbcException ex) { + notNullViol = true; + } catch (RuntimeException ex) { + notNullViol = true; + } + check("K.notNull.throws", notNullViol); + + // ============================================================ + // SECTION L: explicit Mono/Flux operator surface + // ============================================================ + // Flux.collectList().block() + List fluxNames = Flux.from( + conn.createStatement("SELECT name FROM users ORDER BY name").execute()) + .concatMap(r -> r.map((row, m3) -> row.get("name", String.class))) + .collectList().block(T); + check("L.flux.collectList", fluxNames != null && fluxNames.size() == 5); + eq("L.flux.first", fluxNames.get(0), "alice"); + + // Flux.count().block() + Long fluxCount = Flux.from(conn.createStatement("SELECT id FROM accounts").execute()) + .concatMap(r -> r.map((row, m4) -> row.get(0, Integer.class))) + .count().block(T); + long realAcc = scalarLong(conn, "SELECT COUNT(*) FROM accounts"); + eq("L.flux.count", fluxCount, realAcc); + + // Mono.from(...).block() on a Void publisher returns null without error + Object voidResult = Mono.from(conn.beginTransaction()).block(T); + check("L.mono.void.null", voidResult == null); + run(conn.rollbackTransaction()); + run(conn.setAutoCommit(true)); + + // Flux.reduce sum of amounts + Long sumAmt = Flux.from(conn.createStatement("SELECT amount FROM accounts").execute()) + .concatMap(r -> r.map((row, m5) -> row.get("amount", Long.class))) + .reduce(0L, (x, y) -> x + y).block(T); + Long sqlSum = scalarLong(conn, "SELECT SUM(amount) FROM accounts"); + eq("L.flux.reduce.sum", sumAmt, sqlSum); + + // Result.filter + flatMap on RowSegment + List filtered = drain(Flux.from( + conn.createStatement("SELECT name FROM users ORDER BY name").execute()) + .concatMap(res -> res + .filter(seg -> seg instanceof Result.RowSegment) + .map((row, m6) -> row.get("name", String.class)))); + eq("L.filter.rowSegments", (long) filtered.size(), 5L); + + // ============================================================ + // SECTION M: second connection sees committed data (shared mem DB) + // ============================================================ + conn2 = one(primary.create()); + check("M.conn2.nonNull", conn2 != null); + long seenByConn2 = scalarLong(conn2, "SELECT COUNT(*) FROM users"); + eq("M.conn2.sharedData", seenByConn2, 5L); + eq("M.conn2.metadata", conn2.getMetadata().getDatabaseProductName(), "H2"); + + // ============================================================ + // cleanup + // ============================================================ + update(conn, "DROP TABLE nums"); + update(conn, "DROP TABLE accounts"); + update(conn, "DROP TABLE users"); + run(conn2.close()); + conn2 = null; + run(conn.close()); + conn = null; + primary = null; + + } catch (Throwable t) { + fail++; + System.out.println("FAIL harness-exception " + t); + t.printStackTrace(System.out); + } finally { + try { if (conn2 != null) drain(conn2.close()); } catch (Throwable ignore) {} + try { if (conn != null) drain(conn.close()); } catch (Throwable ignore) {} + } + + System.out.println("R2DBC_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) { + System.out.println("R2DBC_DONE"); + } + System.exit(fail == 0 ? 0 : 1); + } + + // ---- scalar helpers ---- + static long scalarLong(Connection c, String sql, Object... binds) { + List l = query(c, sql, (row, md) -> { + Object v = row.get(0); + return v == null ? null : ((Number) v).longValue(); + }, binds); + Long v = l.isEmpty() ? null : l.get(0); + return v == null ? 0L : v; + } + + static Integer scalarInt(Connection c, String sql, Object... binds) { + List l = query(c, sql, (row, md) -> row.get(0, Integer.class), binds); + return l.isEmpty() ? null : l.get(0); + } + + static String scalarStr(Connection c, String sql, Object... binds) { + List l = query(c, sql, (row, md) -> row.get(0, String.class), binds); + return l.isEmpty() ? null : l.get(0); + } +} diff --git a/apps/starry/java-web/programs/carpets/WarCarpet.java b/apps/starry/java-web/programs/carpets/WarCarpet.java new file mode 100644 index 0000000000..b38c94107a --- /dev/null +++ b/apps/starry/java-web/programs/carpets/WarCarpet.java @@ -0,0 +1,798 @@ +package org.starry.dod; + +/* + * WarCarpet — industrial-grade carpet for the real ".war deployment" surface on + * Jetty 11.0.21 (jetty-server + jakarta.servlet 5.0 API, the only modules bundled + * in jetty-demo.jar; jetty-webapp/jetty-servlet are NOT present, so a faithful war + * pipeline is built directly on jetty-server + the jakarta.servlet API). + * + * Pipeline exercised (every step is real, nothing stubbed): + * 1. write two minimal jakarta HttpServlet sources to /tmp + * 2. compile them in-process with the JDK compiler (javax.tools) + * 3. assemble a STANDARD .war (WEB-INF/web.xml + WEB-INF/classes/** + welcome file) + * and zip it to /tmp/.../app.war + * 4. validate the .war artifact: zip entries + web.xml DOM (servlet/servlet-mapping/ + * init-param/context-param/welcome-file/load-on-startup) + * 5. explode the .war to a webapp dir (proving deployment reads FROM the artifact) + * 6. deploy: URLClassLoader over WEB-INF/classes, parse web.xml, instantiate + + * init() each servlet with its ServletConfig/ServletContext + init-params + * 7. run an embedded Jetty Server (bound 127.0.0.1) whose ContextHandler dispatches + * real HTTP requests through HttpServlet.service -> doGet/doPost/doPut/doDelete + * using the servlet-mapping algorithm (exact / prefix /x/* / extension *.ext / + * welcome-file / 404) + * 8. hit it with HttpURLConnection: status line, headers, content-type, body exact + * values, GET/POST/PUT/DELETE/HEAD/OPTIONS, multiple mappings, init-params, + * redirect, cookie, 404, 405 + * 9. stop the server, destroy() the servlets, verify the port is released + * + * Deterministic, loopback-only, no external network, /tmp temp files only, + * memory friendly (small thread pool), self-counting. + */ + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.lang.reflect.*; +import java.util.*; +import java.util.zip.*; +import javax.tools.*; +import javax.xml.parsers.*; +import org.w3c.dom.*; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.thread.QueuedThreadPool; + +public class WarCarpet { + + // ---- self-counting harness ---- + static int ok = 0, fail = 0; + static void pass() { ok++; } + static void fail(String name, String detail) { + fail++; + System.out.println("FAIL " + name + (detail == null ? "" : " :: " + detail)); + } + static void check(String name, boolean cond) { if (cond) pass(); else fail(name, null); } + static void eq(String name, Object exp, Object act) { + if (Objects.equals(exp, act)) pass(); + else fail(name, "expected=[" + exp + "] actual=[" + act + "]"); + } + static void contains(String name, String hay, String needle) { + if (hay != null && hay.contains(needle)) pass(); + else fail(name, "needle=[" + needle + "] not in [" + hay + "]"); + } + + static final String CTX = "/app"; + static final int PORT = 18456; + + static File WORK, WAR, WEBAPP; + + public static void main(String[] args) { + Server server = null; + Deployer deployer = null; + ServerConnector con = null; + try { + setupWorkDirs(); + buildWarSources(); + compileServlets(); + packageWar(); + + validateWarArtifact(); // structure + web.xml DOM asserts (pre-deploy) + explodeWar(); // deploy reads from the artifact + + deployer = new Deployer(WEBAPP, CTX); + deployer.deploy(); + eq("deploy.initCount", 2, deployer.initCount); + eq("deploy.servletCount", 2, deployer.servlets.size()); + eq("deploy.welcome", "index.html", deployer.welcomeFiles.isEmpty() ? null : deployer.welcomeFiles.get(0)); + + // ---- start embedded Jetty (loopback) ---- + QueuedThreadPool pool = new QueuedThreadPool(10, 3); + pool.setName("warcarpet"); + server = new Server(pool); + con = new ServerConnector(server, 1, 1); + con.setHost("127.0.0.1"); + con.setPort(PORT); + con.setIdleTimeout(15000); + server.addConnector(con); + + ContextHandler ctx = new ContextHandler(); + ctx.setContextPath(CTX); + ctx.setAllowNullPathInfo(true); + ctx.setHandler(new Dispatcher(deployer, CTX, WEBAPP)); + server.setHandler(ctx); + server.start(); + + check("server.started", server.isStarted()); + eq("connector.host", "127.0.0.1", con.getHost()); + int port = con.getLocalPort(); + check("connector.port>0", port > 0); + eq("connector.port.fixed", PORT, port); + + runHttpAsserts(port); + + // ---- shutdown ---- + server.stop(); + check("server.stopped", server.isStopped()); + deployer.destroyAll(); + eq("deploy.destroyCount", 2, deployer.destroyCount); + check("port.released", portFreeAfterStop(port)); + + } catch (Throwable t) { + fail("fatal", t.getClass().getName() + ": " + t.getMessage()); + t.printStackTrace(System.out); + try { if (server != null) server.stop(); } catch (Exception ignore) {} + } + + System.out.println("WAR_RESULT ok=" + ok + " fail=" + fail); + if (fail == 0) System.out.println("WAR_DONE"); + } + + // ================= work dirs ================= + static void setupWorkDirs() throws IOException { + String tmp = System.getProperty("java.io.tmpdir", "/tmp"); + File base = new File(tmp, "starry_warcarpet_work"); + WORK = base; + deleteRec(base); + new File(base, "src/com/example/web").mkdirs(); + new File(base, "warroot/WEB-INF/classes").mkdirs(); + WAR = new File(base, "app.war"); + WEBAPP = new File(base, "webapp"); + } + + static void deleteRec(File f) throws IOException { + if (!f.exists()) return; + if (f.isDirectory()) for (File c : f.listFiles()) deleteRec(c); + f.delete(); + } + + // ================= war source + web.xml ================= + static void buildWarSources() throws IOException { + String echo = + "package com.example.web;\n" + + "import java.io.*;\n" + + "import jakarta.servlet.*;\n" + + "import jakarta.servlet.http.*;\n" + + "public class EchoServlet extends HttpServlet {\n" + + " private String greeting = \"uninit\";\n" + + " private int initCount = 0;\n" + + " @Override public void init() throws ServletException {\n" + + " String g = getServletConfig().getInitParameter(\"greeting\");\n" + + " this.greeting = (g == null) ? \"none\" : g;\n" + + " this.initCount++;\n" + + " }\n" + + " @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp)\n" + + " throws ServletException, IOException {\n" + + " String mode = req.getParameter(\"mode\");\n" + + " if (\"redirect\".equals(mode)) { resp.sendRedirect(req.getContextPath()+\"/info\"); return; }\n" + + " resp.setStatus(200);\n" + + " resp.setCharacterEncoding(\"UTF-8\");\n" + + " resp.setContentType(\"text/plain\");\n" + + " resp.setHeader(\"X-Servlet\", \"echo\");\n" + + " resp.addHeader(\"X-Init\", String.valueOf(initCount));\n" + + " Cookie ck = new Cookie(\"warcookie\", \"ck1\"); ck.setPath(\"/\"); resp.addCookie(ck);\n" + + " String name = req.getParameter(\"name\");\n" + + " String[] vals = req.getParameterValues(\"name\");\n" + + " int vc = (vals == null) ? 0 : vals.length;\n" + + " String hdr = req.getHeader(\"X-Req\");\n" + + " PrintWriter w = resp.getWriter();\n" + + " w.print(\"GET|greeting=\"+greeting+\"|name=\"+name+\"|vals=\"+vc+\"|hdr=\"+hdr\n" + + " +\"|sp=\"+req.getServletPath()+\"|pi=\"+req.getPathInfo()+\"|qs=\"+req.getQueryString()\n" + + " +\"|proto=\"+req.getProtocol()+\"|method=\"+req.getMethod()+\"|ctx=\"+req.getContextPath());\n" + + " }\n" + + " @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp)\n" + + " throws ServletException, IOException {\n" + + " byte[] body = req.getInputStream().readAllBytes();\n" + + " resp.setStatus(200); resp.setContentType(\"text/plain\");\n" + + " resp.getWriter().print(\"POST|len=\"+body.length+\"|body=\"+new String(body,\"UTF-8\"));\n" + + " }\n" + + " @Override protected void doPut(HttpServletRequest req, HttpServletResponse resp)\n" + + " throws ServletException, IOException {\n" + + " byte[] body = req.getInputStream().readAllBytes();\n" + + " resp.setStatus(200); resp.setContentType(\"text/plain\");\n" + + " resp.getWriter().print(\"PUT|body=\"+new String(body,\"UTF-8\"));\n" + + " }\n" + + " @Override protected void doDelete(HttpServletRequest req, HttpServletResponse resp)\n" + + " throws ServletException, IOException {\n" + + " resp.setHeader(\"X-Deleted\", \"true\"); resp.setStatus(204);\n" + + " }\n" + + "}\n"; + + String info = + "package com.example.web;\n" + + "import java.io.*;\n" + + "import jakarta.servlet.*;\n" + + "import jakarta.servlet.http.*;\n" + + "public class InfoServlet extends HttpServlet {\n" + + " @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp)\n" + + " throws ServletException, IOException {\n" + + " ServletContext c = getServletContext();\n" + + " String appName = (c == null) ? \"noctx\" : c.getInitParameter(\"app.name\");\n" + + " resp.setStatus(200); resp.setCharacterEncoding(\"UTF-8\"); resp.setContentType(\"text/html\");\n" + + " resp.getWriter().print(\"\");\n" + + " }\n" + + "}\n"; + + Files.writeString(new File(WORK, "src/com/example/web/EchoServlet.java").toPath(), echo); + Files.writeString(new File(WORK, "src/com/example/web/InfoServlet.java").toPath(), info); + + String webxml = + "\n" + + "\n" + + " WarCarpetApp\n" + + " \n" + + " app.name\n" + + " warcarpet\n" + + " \n" + + " \n" + + " echo\n" + + " com.example.web.EchoServlet\n" + + " greetinghello-from-war\n" + + " 1\n" + + " \n" + + " \n" + + " info\n" + + " com.example.web.InfoServlet\n" + + " 2\n" + + " \n" + + " echo/echo\n" + + " echo/echo2/*\n" + + " echo*.do\n" + + " info/info\n" + + " index.html\n" + + "\n"; + Files.writeString(new File(WORK, "warroot/WEB-INF/web.xml").toPath(), webxml); + + String index = "Welcome WarCarpet"; + Files.writeString(new File(WORK, "warroot/index.html").toPath(), index); + } + + static void compileServlets() throws IOException { + JavaCompiler jc = ToolProvider.getSystemJavaCompiler(); + if (jc == null) throw new IllegalStateException("no system Java compiler (need a JDK)"); + String cp = System.getProperty("java.class.path"); + File classesDir = new File(WORK, "warroot/WEB-INF/classes"); + ByteArrayOutputStream errOut = new ByteArrayOutputStream(); + int rc = jc.run(null, null, errOut, + "-cp", cp, + "-d", classesDir.getAbsolutePath(), + new File(WORK, "src/com/example/web/EchoServlet.java").getAbsolutePath(), + new File(WORK, "src/com/example/web/InfoServlet.java").getAbsolutePath()); + eq("compile.rc", 0, rc); + if (rc != 0) System.out.println("compiler-output:\n" + errOut.toString(StandardCharsets.UTF_8)); + check("compile.EchoServlet.class", + new File(classesDir, "com/example/web/EchoServlet.class").isFile()); + check("compile.InfoServlet.class", + new File(classesDir, "com/example/web/InfoServlet.class").isFile()); + } + + static void packageWar() throws IOException { + File root = new File(WORK, "warroot"); + try (ZipOutputStream zos = new ZipOutputStream( + new BufferedOutputStream(new FileOutputStream(WAR)))) { + zipDir(root, root, zos); + } + check("war.exists", WAR.isFile()); + check("war.size>0", WAR.length() > 0); + } + + static void zipDir(File base, File dir, ZipOutputStream zos) throws IOException { + File[] kids = dir.listFiles(); + Arrays.sort(kids, Comparator.comparing(File::getName)); // deterministic order + for (File f : kids) { + String rel = base.toPath().relativize(f.toPath()).toString().replace(File.separatorChar, '/'); + if (f.isDirectory()) { + zos.putNextEntry(new ZipEntry(rel + "/")); + zos.closeEntry(); + zipDir(base, f, zos); + } else { + zos.putNextEntry(new ZipEntry(rel)); + zos.write(Files.readAllBytes(f.toPath())); + zos.closeEntry(); + } + } + } + + // ================= war artifact validation ================= + static void validateWarArtifact() throws Exception { + Set entries = new HashSet<>(); + byte[] webxmlBytes = null; + try (ZipFile zf = new ZipFile(WAR)) { + Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + entries.add(e.getName()); + if (e.getName().equals("WEB-INF/web.xml")) { + webxmlBytes = zf.getInputStream(e).readAllBytes(); + } + } + } + check("war.entry.webxml", entries.contains("WEB-INF/web.xml")); + check("war.entry.echo.class", entries.contains("WEB-INF/classes/com/example/web/EchoServlet.class")); + check("war.entry.info.class", entries.contains("WEB-INF/classes/com/example/web/InfoServlet.class")); + check("war.entry.index", entries.contains("index.html")); + check("war.entry.webinf.dir", entries.contains("WEB-INF/")); + check("war.webxml.read", webxmlBytes != null && webxmlBytes.length > 0); + + // DOM parse the deployment descriptor from inside the war + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(false); + Document doc = dbf.newDocumentBuilder().parse(new ByteArrayInputStream(webxmlBytes)); + Element rootEl = doc.getDocumentElement(); + eq("webxml.root", "web-app", rootEl.getNodeName()); + eq("webxml.version", "5.0", rootEl.getAttribute("version")); + + NodeList servlets = doc.getElementsByTagName("servlet"); + eq("webxml.servlet.count", 2, servlets.getLength()); + + Map byName = new HashMap<>(); + for (int i = 0; i < servlets.getLength(); i++) { + Element s = (Element) servlets.item(i); + byName.put(childText(s, "servlet-name"), s); + } + check("webxml.servlet.echo.present", byName.containsKey("echo")); + check("webxml.servlet.info.present", byName.containsKey("info")); + eq("webxml.echo.class", "com.example.web.EchoServlet", childText(byName.get("echo"), "servlet-class")); + eq("webxml.info.class", "com.example.web.InfoServlet", childText(byName.get("info"), "servlet-class")); + eq("webxml.echo.loadOnStartup", "1", childText(byName.get("echo"), "load-on-startup")); + + // echo init-param + Element ip = (Element) byName.get("echo").getElementsByTagName("init-param").item(0); + eq("webxml.echo.initParam.name", "greeting", childText(ip, "param-name")); + eq("webxml.echo.initParam.value", "hello-from-war", childText(ip, "param-value")); + + // context-param + Element cp = (Element) doc.getElementsByTagName("context-param").item(0); + eq("webxml.contextParam.name", "app.name", childText(cp, "param-name")); + eq("webxml.contextParam.value", "warcarpet", childText(cp, "param-value")); + + // mappings + NodeList maps = doc.getElementsByTagName("servlet-mapping"); + eq("webxml.mapping.count", 4, maps.getLength()); + Set patterns = new HashSet<>(); + for (int i = 0; i < maps.getLength(); i++) { + Element m = (Element) maps.item(i); + patterns.add(childText(m, "url-pattern")); + } + check("webxml.map.exact", patterns.contains("/echo")); + check("webxml.map.prefix", patterns.contains("/echo2/*")); + check("webxml.map.extension", patterns.contains("*.do")); + check("webxml.map.info", patterns.contains("/info")); + + // welcome + Element wfl = (Element) doc.getElementsByTagName("welcome-file-list").item(0); + eq("webxml.welcome", "index.html", childText(wfl, "welcome-file")); + } + + static String childText(Element parent, String tag) { + NodeList nl = parent.getElementsByTagName(tag); + if (nl.getLength() == 0) return null; + return nl.item(0).getTextContent().trim(); + } + + // ================= explode war (deployment) ================= + static void explodeWar() throws IOException { + deleteRec(WEBAPP); + WEBAPP.mkdirs(); + try (ZipFile zf = new ZipFile(WAR)) { + Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + File out = new File(WEBAPP, e.getName()); + if (e.isDirectory()) { out.mkdirs(); continue; } + out.getParentFile().mkdirs(); + try (InputStream in = zf.getInputStream(e)) { + Files.copy(in, out.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + } + check("explode.webxml", new File(WEBAPP, "WEB-INF/web.xml").isFile()); + check("explode.echo.class", new File(WEBAPP, "WEB-INF/classes/com/example/web/EchoServlet.class").isFile()); + check("explode.index", new File(WEBAPP, "index.html").isFile()); + } + + // ================= deployer (mini servlet container) ================= + static final class ServletDef { + String name; + HttpServlet servlet; + Map initParams = new LinkedHashMap<>(); + } + + static final class Resolution { + HttpServlet servlet; + String servletPath; + String pathInfo; + Resolution(HttpServlet s, String sp, String pi) { servlet = s; servletPath = sp; pathInfo = pi; } + } + + static final class Deployer { + final File webappDir; + final String contextPath; + final Map servlets = new LinkedHashMap<>(); + final Map contextParams = new LinkedHashMap<>(); + final Map contextAttrs = new HashMap<>(); + // mapping tables + final Map exact = new HashMap<>(); // /echo -> echo + final List prefixes = new ArrayList<>(); // {"/echo2","echo"} + final Map extensions = new HashMap<>(); // .do -> echo + final List welcomeFiles = new ArrayList<>(); + ServletContext servletContext; + URLClassLoader loader; + int initCount = 0, destroyCount = 0; + + Deployer(File webappDir, String contextPath) { + this.webappDir = webappDir; + this.contextPath = contextPath; + } + + void deploy() throws Exception { + File classes = new File(webappDir, "WEB-INF/classes"); + loader = new URLClassLoader(new URL[]{ classes.toURI().toURL() }, + WarCarpet.class.getClassLoader()); + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(false); + Document doc = dbf.newDocumentBuilder().parse(new File(webappDir, "WEB-INF/web.xml")); + + // context-params + NodeList cps = doc.getElementsByTagName("context-param"); + for (int i = 0; i < cps.getLength(); i++) { + Element e = (Element) cps.item(i); + contextParams.put(childText(e, "param-name"), childText(e, "param-value")); + } + servletContext = makeServletContext(); + + // welcome files + NodeList wfls = doc.getElementsByTagName("welcome-file"); + for (int i = 0; i < wfls.getLength(); i++) welcomeFiles.add(wfls.item(i).getTextContent().trim()); + + // servlet definitions (ordered by load-on-startup for realism) + NodeList sl = doc.getElementsByTagName("servlet"); + List ordered = new ArrayList<>(); + for (int i = 0; i < sl.getLength(); i++) ordered.add((Element) sl.item(i)); + ordered.sort(Comparator.comparingInt(e -> { + String los = childText(e, "load-on-startup"); + try { return los == null ? Integer.MAX_VALUE : Integer.parseInt(los); } + catch (NumberFormatException nfe) { return Integer.MAX_VALUE; } + })); + for (Element e : ordered) { + ServletDef def = new ServletDef(); + def.name = childText(e, "servlet-name"); + String cls = childText(e, "servlet-class"); + NodeList ips = e.getElementsByTagName("init-param"); + for (int j = 0; j < ips.getLength(); j++) { + Element ip = (Element) ips.item(j); + def.initParams.put(childText(ip, "param-name"), childText(ip, "param-value")); + } + Class c = Class.forName(cls, true, loader); + def.servlet = (HttpServlet) c.getDeclaredConstructor().newInstance(); + def.servlet.init(new Cfg(def.name, def.initParams, servletContext)); + initCount++; + servlets.put(def.name, def); + } + + // servlet-mappings + NodeList ms = doc.getElementsByTagName("servlet-mapping"); + for (int i = 0; i < ms.getLength(); i++) { + Element m = (Element) ms.item(i); + String name = childText(m, "servlet-name"); + NodeList ups = m.getElementsByTagName("url-pattern"); + for (int j = 0; j < ups.getLength(); j++) { + String pat = ups.item(j).getTextContent().trim(); + if (pat.startsWith("*.")) extensions.put(pat.substring(1), name); // ".do" + else if (pat.endsWith("/*")) prefixes.add(new String[]{ pat.substring(0, pat.length() - 2), name }); + else exact.put(pat, name); + } + } + // longest prefix first + prefixes.sort((a, b) -> Integer.compare(b[0].length(), a[0].length())); + } + + // servlet-mapping resolution algorithm (exact > longest-prefix > extension) + Resolution resolve(String inPath) { + String name = exact.get(inPath); + if (name != null) return new Resolution(servlets.get(name).servlet, inPath, null); + for (String[] p : prefixes) { + String pre = p[0]; + if (inPath.equals(pre)) return new Resolution(servlets.get(p[1]).servlet, pre, null); + if (inPath.startsWith(pre + "/")) + return new Resolution(servlets.get(p[1]).servlet, pre, inPath.substring(pre.length())); + } + int dot = inPath.lastIndexOf('.'); + if (dot >= 0) { + String ext = inPath.substring(dot); + String n2 = extensions.get(ext); + if (n2 != null) return new Resolution(servlets.get(n2).servlet, inPath, null); + } + return null; + } + + void destroyAll() { + for (ServletDef d : servlets.values()) { d.servlet.destroy(); destroyCount++; } + try { loader.close(); } catch (IOException ignore) {} + } + + ServletContext makeServletContext() { + InvocationHandler h = (proxy, method, a) -> { + switch (method.getName()) { + case "getInitParameter": return contextParams.get((String) a[0]); + case "getInitParameterNames": return Collections.enumeration(contextParams.keySet()); + case "getServletContextName": return "WarCarpetApp"; + case "getContextPath": return contextPath; + case "getAttribute": return contextAttrs.get((String) a[0]); + case "getAttributeNames": return Collections.enumeration(contextAttrs.keySet()); + case "setAttribute": contextAttrs.put((String) a[0], a[1]); return null; + case "removeAttribute": contextAttrs.remove((String) a[0]); return null; + case "getMajorVersion": return 5; + case "getMinorVersion": return 0; + case "getEffectiveMajorVersion": return 5; + case "getEffectiveMinorVersion": return 0; + case "getServerInfo": return "WarCarpet/1.0"; + case "getVirtualServerName": return "warcarpet"; + case "log": return null; + case "toString": return "WarCarpetServletContext"; + case "hashCode": return System.identityHashCode(proxy); + case "equals": return proxy == a[0]; + default: + Class rt = method.getReturnType(); + if (rt == int.class) return 0; + if (rt == boolean.class) return Boolean.FALSE; + if (rt == long.class) return 0L; + return null; + } + }; + return (ServletContext) java.lang.reflect.Proxy.newProxyInstance( + WarCarpet.class.getClassLoader(), + new Class[]{ ServletContext.class }, h); + } + } + + // ServletConfig backing each servlet + static final class Cfg implements ServletConfig { + final String name; + final Map ip; + final ServletContext ctx; + Cfg(String name, Map ip, ServletContext ctx) { this.name = name; this.ip = ip; this.ctx = ctx; } + public String getServletName() { return name; } + public ServletContext getServletContext() { return ctx; } + public String getInitParameter(String n) { return ip.get(n); } + public Enumeration getInitParameterNames() { return Collections.enumeration(ip.keySet()); } + } + + // request facade so the servlet sees the mapping-derived path triplet + static final class Facade extends HttpServletRequestWrapper { + final String ctxPath, servletPath, pathInfo; + Facade(HttpServletRequest r, String c, String s, String p) { + super(r); ctxPath = c; servletPath = s; pathInfo = p; + } + @Override public String getContextPath() { return ctxPath; } + @Override public String getServletPath() { return servletPath; } + @Override public String getPathInfo() { return pathInfo; } + } + + // Jetty handler that dispatches into the deployed war + static final class Dispatcher extends AbstractHandler { + final Deployer dep; + final String ctxPath; + final File webappDir; + Dispatcher(Deployer dep, String ctxPath, File webappDir) { + this.dep = dep; this.ctxPath = ctxPath; this.webappDir = webappDir; + } + @Override public void handle(String target, Request baseRequest, + HttpServletRequest req, HttpServletResponse resp) + throws IOException, ServletException { + baseRequest.setHandled(true); + String uri = req.getRequestURI(); + String inPath = uri.length() >= ctxPath.length() ? uri.substring(ctxPath.length()) : "/"; + if (inPath.isEmpty()) inPath = "/"; + + if (inPath.equals("/")) { serveWelcome(resp); return; } + + Resolution r = dep.resolve(inPath); + if (r == null) { + resp.setStatus(404); + resp.setContentType("text/plain"); + resp.getWriter().print("404|" + inPath); + return; + } + HttpServletRequest wrapped = new Facade(req, ctxPath, r.servletPath, r.pathInfo); + r.servlet.service(wrapped, resp); + } + + void serveWelcome(HttpServletResponse resp) throws IOException { + for (String wf : dep.welcomeFiles) { + File f = new File(webappDir, wf); + if (f.isFile()) { + resp.setStatus(200); + resp.setContentType("text/html"); + resp.setCharacterEncoding("UTF-8"); + resp.getOutputStream().write(Files.readAllBytes(f.toPath())); + return; + } + } + resp.setStatus(404); + resp.setContentType("text/plain"); + resp.getWriter().print("no-welcome"); + } + } + + // ================= HTTP client + asserts ================= + static final class Resp { + int code; + String body; + Map> headers; + String contentType; + String header(String n) { + if (headers == null) return null; + for (Map.Entry> e : headers.entrySet()) + if (e.getKey() != null && e.getKey().equalsIgnoreCase(n)) + return e.getValue().isEmpty() ? null : e.getValue().get(0); + return null; + } + } + + static Resp http(int port, String method, String path, Map reqHeaders, byte[] body) + throws IOException { + URL u = new URL("http://127.0.0.1:" + port + path); + HttpURLConnection c = (HttpURLConnection) u.openConnection(); + c.setInstanceFollowRedirects(false); + c.setConnectTimeout(3000); + c.setReadTimeout(3000); + c.setRequestMethod(method); + if (reqHeaders != null) for (Map.Entry e : reqHeaders.entrySet()) + c.setRequestProperty(e.getKey(), e.getValue()); + if (body != null) { + c.setDoOutput(true); + try (OutputStream os = c.getOutputStream()) { os.write(body); } + } + Resp r = new Resp(); + r.code = c.getResponseCode(); + r.headers = c.getHeaderFields(); + r.contentType = c.getContentType(); + InputStream in = (r.code >= 400) ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + if (in != null) { in.transferTo(bo); in.close(); } + r.body = bo.toString("UTF-8"); + c.disconnect(); + return r; + } + + static void runHttpAsserts(int port) throws IOException { + // ---- GET /app/echo?name=alice : exact mapping, init-param, headers, body ---- + Map h1 = new HashMap<>(); + h1.put("X-Req", "foo"); + Resp g = http(port, "GET", "/app/echo?name=alice", h1, null); + eq("echo.get.status", 200, g.code); + contains("echo.get.ctype", g.contentType, "text/plain"); + eq("echo.get.X-Servlet", "echo", g.header("X-Servlet")); + eq("echo.get.X-Init", "1", g.header("X-Init")); + check("echo.get.cookie", g.header("Set-Cookie") != null && g.header("Set-Cookie").contains("warcookie")); + check("echo.get.body.prefix", g.body.startsWith("GET|")); + contains("echo.get.greeting", g.body, "greeting=hello-from-war"); + contains("echo.get.name", g.body, "name=alice"); + contains("echo.get.hdr", g.body, "hdr=foo"); + contains("echo.get.servletPath", g.body, "sp=/echo"); + contains("echo.get.pathInfo.null", g.body, "pi=null"); + contains("echo.get.qs", g.body, "qs=name=alice"); + contains("echo.get.proto", g.body, "proto=HTTP/1.1"); + contains("echo.get.method", g.body, "method=GET"); + contains("echo.get.ctx", g.body, "ctx=/app"); + + // ---- GET no name -> name=null ---- + Resp gn = http(port, "GET", "/app/echo", null, null); + eq("echo.get.noname.status", 200, gn.code); + contains("echo.get.noname", gn.body, "name=null"); + contains("echo.get.noname.vals0", gn.body, "vals=0"); + + // ---- multi-valued param ---- + Resp gm = http(port, "GET", "/app/echo?name=a&name=b", null, null); + contains("echo.get.multi.vals2", gm.body, "vals=2"); + contains("echo.get.multi.first", gm.body, "name=a"); + + // ---- POST body echo (doPost) ---- + Resp p = http(port, "POST", "/app/echo", null, "payload123".getBytes(StandardCharsets.UTF_8)); + eq("echo.post.status", 200, p.code); + eq("echo.post.body", "POST|len=10|body=payload123", p.body); + + // ---- PUT body echo (doPut) ---- + Resp pu = http(port, "PUT", "/app/echo", null, "putbody".getBytes(StandardCharsets.UTF_8)); + eq("echo.put.status", 200, pu.code); + eq("echo.put.body", "PUT|body=putbody", pu.body); + + // ---- DELETE (doDelete) -> 204, header, empty body ---- + Resp d = http(port, "DELETE", "/app/echo", null, null); + eq("echo.delete.status", 204, d.code); + eq("echo.delete.header", "true", d.header("X-Deleted")); + eq("echo.delete.empty", "", d.body); + + // ---- prefix mapping /echo2/* : servletPath + pathInfo ---- + Resp pf = http(port, "GET", "/app/echo2/sub/path", null, null); + eq("echo.prefix.status", 200, pf.code); + contains("echo.prefix.sp", pf.body, "sp=/echo2"); + contains("echo.prefix.pi", pf.body, "pi=/sub/path"); + + Resp pf0 = http(port, "GET", "/app/echo2", null, null); + eq("echo.prefix0.status", 200, pf0.code); + contains("echo.prefix0.sp", pf0.body, "sp=/echo2"); + contains("echo.prefix0.pi.null", pf0.body, "pi=null"); + + // ---- extension mapping *.do -> echo ---- + Resp ex = http(port, "GET", "/app/report.do", null, null); + eq("echo.ext.status", 200, ex.code); + contains("echo.ext.sp", ex.body, "sp=/report.do"); + + // ---- redirect (sendRedirect -> 302 + Location) ---- + Resp rd = http(port, "GET", "/app/echo?mode=redirect", null, null); + eq("echo.redirect.status", 302, rd.code); + check("echo.redirect.location", rd.header("Location") != null && rd.header("Location").contains("/app/info")); + + // ---- OPTIONS -> Allow header lists implemented methods ---- + Resp op = http(port, "OPTIONS", "/app/echo", null, null); + eq("echo.options.status", 200, op.code); + String allow = op.header("Allow"); + check("echo.options.allow.get", allow != null && allow.contains("GET")); + check("echo.options.allow.post", allow != null && allow.contains("POST")); + check("echo.options.allow.put", allow != null && allow.contains("PUT")); + check("echo.options.allow.delete", allow != null && allow.contains("DELETE")); + + // ---- HEAD -> 200, no body ---- + Resp hd = http(port, "HEAD", "/app/echo", null, null); + eq("echo.head.status", 200, hd.code); + eq("echo.head.empty", "", hd.body); + + // ---- info servlet : context-param + servlet-name, text/html ---- + Resp inf = http(port, "GET", "/app/info", null, null); + eq("info.get.status", 200, inf.code); + contains("info.get.ctype", inf.contentType, "text/html"); + contains("info.get.appName", inf.body, "app=warcarpet"); + contains("info.get.servletName", inf.body, "servlet=info"); + + // ---- 405 : POST to info (only doGet implemented) ---- + Resp i405 = http(port, "POST", "/app/info", null, new byte[0]); + eq("info.post.405", 405, i405.code); + + // ---- welcome file (context root, both forms) ---- + Resp w1 = http(port, "GET", "/app/", null, null); + eq("welcome.slash.status", 200, w1.code); + contains("welcome.slash.ctype", w1.contentType, "text/html"); + contains("welcome.slash.body", w1.body, "Welcome WarCarpet"); + + Resp w2 = http(port, "GET", "/app", null, null); + eq("welcome.noslash.status", 200, w2.code); + contains("welcome.noslash.body", w2.body, "Welcome WarCarpet"); + + // ---- 404 inside context (no mapping) ---- + Resp nf = http(port, "GET", "/app/does-not-exist", null, null); + eq("notfound.inctx.status", 404, nf.code); + contains("notfound.inctx.body", nf.body, "404|/does-not-exist"); + + // ---- 404 outside context ---- + Resp out = http(port, "GET", "/elsewhere", null, null); + eq("notfound.outctx.status", 404, out.code); + + // ---- two distinct servlets dispatched on distinct mappings ---- + check("dispatch.distinct", g.body.startsWith("GET|") && inf.body.contains(" "/etc/ld-musl-$A.path" +export JAVA_HOME="$JH" PATH="$JH/bin:$PATH" + +# StarryOS JIT is still unstable -> force the interpreter on every JVM. +J="$JH/bin/java -Xint -Xms32m -Xmx512m" +D=/root/jweb +L=$D/libs + +# Per-module classpaths (== the original fat-jar contents; carpets.jar holds the compiled +# carpet classes, each module's third-party jars come from $L). +JETTY_CP="$D/carpets.jar:$L/jetty-server-11.0.21.jar:$L/jetty-http-11.0.21.jar:$L/jetty-io-11.0.21.jar:$L/jetty-util-11.0.21.jar:$L/jetty-jakarta-servlet-api-5.0.2.jar:$L/slf4j-api-2.0.9.jar" +NETTY_CP="$D/carpets.jar:$L/netty-common-4.1.112.Final.jar:$L/netty-buffer-4.1.112.Final.jar:$L/netty-resolver-4.1.112.Final.jar:$L/netty-transport-4.1.112.Final.jar:$L/netty-transport-native-unix-common-4.1.112.Final.jar:$L/netty-codec-4.1.112.Final.jar:$L/netty-codec-http-4.1.112.Final.jar:$L/netty-handler-4.1.112.Final.jar" +MYBATIS_CP="$D/carpets.jar:$L/mybatis-3.5.16.jar:$L/ognl-3.4.2.jar:$L/javassist-3.30.2-GA.jar:$L/sqlite-jdbc-3.46.1.3.jar:$L/slf4j-api-2.0.13.jar:$L/slf4j-simple-2.0.13.jar" +HIBERNATE_CP="$D/carpets.jar:$L/hibernate-core-6.4.4.Final.jar:$L/hibernate-community-dialects-6.4.4.Final.jar:$L/hibernate-commons-annotations-6.0.6.Final.jar:$L/jakarta.persistence-api-3.1.0.jar:$L/jakarta.transaction-api-2.0.1.jar:$L/jboss-logging-3.5.0.Final.jar:$L/jandex-3.1.2.jar:$L/classmate-1.5.1.jar:$L/byte-buddy-1.14.11.jar:$L/antlr4-runtime-4.13.0.jar:$L/jakarta.inject-api-2.0.1.jar:$L/jakarta.xml.bind-api-4.0.0.jar:$L/jakarta.activation-api-2.1.0.jar:$L/jaxb-runtime-4.0.2.jar:$L/jaxb-core-4.0.2.jar:$L/txw2-4.0.2.jar:$L/angus-activation-2.0.0.jar:$L/istack-commons-runtime-4.1.1.jar:$L/sqlite-jdbc-3.46.1.3.jar:$L/slf4j-api-2.0.13.jar:$L/slf4j-simple-2.0.13.jar" +R2DBC_CP="$D/carpets.jar:$L/r2dbc-spi-1.0.0.RELEASE.jar:$L/r2dbc-h2-1.0.0.RELEASE.jar:$L/h2-2.1.214.jar:$L/reactor-core-3.6.11.jar:$L/reactive-streams-1.0.4.jar" + +# sqlite-jdbc native JNI (per arch), used by MyBatis + Hibernate: +# x86_64/aarch64 : the driver self-extracts the jar's bundled Linux-Musl JNI (nothing staged). +# riscv64 : the glibc JDK17 loads the jar's bundled GLIBC riscv64 JNI staged by prebuild +# at $D/native (matches the glibc JDK + the Debian glibc closure). +# loongarch64 : prebuild cross-compiles a musl loong JNI in-prebuild from official source and +# stages it at $D/native. +# All four arches provision the JNI, so MyBatis + Hibernate always RUN and are counted; a missing +# or unloadable JNI is a real FAIL (surfaced by `run`), never a skip. +SQLP="" +case "$A" in + riscv64|loongarch64) + if [ -f "$D/native/libsqlitejdbc.so" ]; then + SQLP="-Dorg.sqlite.lib.path=$D/native -Dorg.sqlite.lib.name=libsqlitejdbc.so" + fi ;; +esac + +PASS=0 +TOTAL=0 +# strict count: a build/env that skips a module leaves TOTAL < the full set and must FAIL, +# not vacuously pass on TOTAL > 0. +EXPECTED_MODULES=6 +run() { # run + name="$1"; marker="$2"; shift 2 + TOTAL=$((TOTAL + 1)) + "$@" > "/tmp/$name.out" 2>&1 + if grep -aq "$marker" "/tmp/$name.out" 2>/dev/null; then + echo " OK $name ($marker)" + PASS=$((PASS + 1)) + else + echo " FAIL $name ($marker)" + grep -aiE 'exception|error|fail|caused by|bind|address' "/tmp/$name.out" | tail -6 + fi +} + +echo "=== java-web: JEE framework carpets (jetty/netty | mybatis/hibernate/r2dbc | war) ===" +run jetty JETTY_DONE $J -cp "$JETTY_CP" org.starry.dod.JettyCarpet +run netty NETTY_DONE $J -cp "$NETTY_CP" org.starry.dod.NettyCarpet +run mybatis MYBATIS_DONE $J $SQLP -cp "$MYBATIS_CP" org.starry.dod.MyBatisCarpet +run hibernate HIBERNATE_DONE $J $SQLP -cp "$HIBERNATE_CP" org.starry.dod.HibernateCarpet +run r2dbc R2DBC_DONE $J -cp "$R2DBC_CP" org.starry.dod.R2dbcCarpet +run war WAR_DONE $J -cp "$JETTY_CP" org.starry.dod.WarCarpet + +echo "AGGREGATE: PASS=$PASS TOTAL=$TOTAL" +if [ "$PASS" = "$TOTAL" ] && [ "$TOTAL" -eq "$EXPECTED_MODULES" ]; then + echo "JAVA_WEB_OK=$PASS/$TOTAL" + echo "TEST PASSED" + exit 0 +fi +echo "TEST FAILED" +exit 1 diff --git a/apps/starry/java-web/qemu-aarch64.toml b/apps/starry/java-web/qemu-aarch64.toml new file mode 100644 index 0000000000..b97d5687a5 --- /dev/null +++ b/apps/starry/java-web/qemu-aarch64.toml @@ -0,0 +1,16 @@ +# java-jse aarch64 — J2SE library + JSE standard-library carpet on OpenJDK 17. +# -cpu cortex-a72 (a 64-bit core; the virt default would pick AArch32). The rootfs is the +# per-app rootfs-aarch64-alpine.img which prebuild.sh grows to 2.5G. The JVM runs -Xmx256m. +args = [ + "-nographic", "-m", "2048M", "-smp", "1", "-cpu", "cortex-a72", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-aarch64-alpine.img", + "-device", "virtio-net-pci,netdev=net0", "-netdev", "user,id=net0", +] +uefi = false +to_bin = true +shell_prefix = "root@starry:/root #" +shell_init_cmd = "sh /usr/bin/run-jweb.sh" +success_regex = ['(?m)^TEST PASSED\s*$'] +fail_regex = ['(?i)\bpanic(?:ked)?\b', '(?m)^TEST FAILED\s*$'] +timeout = 7200 diff --git a/apps/starry/java-web/qemu-loongarch64.toml b/apps/starry/java-web/qemu-loongarch64.toml new file mode 100644 index 0000000000..3958cc5378 --- /dev/null +++ b/apps/starry/java-web/qemu-loongarch64.toml @@ -0,0 +1,20 @@ +# java-jse loongarch64 — J2SE library + JSE standard-library carpet on OpenJDK 17. +# JDK17 is the Loongson musl apk build. sqlite-jdbc loads the cross-built JNI .so staged at +# /root/jse/native. Platform: dynamic (axplat-dyn), same as aarch64/riscv64 — +# build-loongarch64*.toml carries no ax-hal/loongarch64-qemu-virt / plat-static / +# plat_dyn=false (mirrors the riscv64 dynamic config); uefi=false / to_bin=true is the +# dynamic platform's raw-binary boot path. The rootfs is the per-app +# rootfs-loongarch64-alpine.img which prebuild.sh grows to 2.5G. The JVM runs -Xmx256m. +args = [ + "-machine", "virt", "-cpu", "la464", "-nographic", "-m", "2048M", "-smp", "1", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-loongarch64-alpine.img", + "-device", "virtio-net-pci,netdev=net0", "-netdev", "user,id=net0", +] +uefi = false +to_bin = true +shell_prefix = "root@starry:/root #" +shell_init_cmd = "sh /usr/bin/run-jweb.sh" +success_regex = ['(?m)^TEST PASSED\s*$'] +fail_regex = ['(?i)\bpanic(?:ked)?\b', '(?m)^TEST FAILED\s*$'] +timeout = 18000 diff --git a/apps/starry/java-web/qemu-riscv64.toml b/apps/starry/java-web/qemu-riscv64.toml new file mode 100644 index 0000000000..fc9f25f37a --- /dev/null +++ b/apps/starry/java-web/qemu-riscv64.toml @@ -0,0 +1,20 @@ +# java-web riscv64 — JEE framework carpet on OpenJDK 17. +# Alpine ships no riscv64 openjdk17, so JDK17 is the downloadable prebuilt GLIBC build (Adoptium +# Temurin 17.0.19+10), bridged by a staged real Debian glibc runtime closure — StarryOS runs both +# musl and glibc (see prebuild.sh / programs/SOURCES.md). The JIT is forced off via -Xint by +# run-jweb.sh (RV64GC baseline; no vector/bitmanip extension emitted). MyBatis/Hibernate's +# sqlite-jdbc loads the jar's bundled glibc riscv64 JNI staged at /root/jweb/native. The rootfs is +# the per-app rootfs-riscv64-alpine.img which prebuild.sh grows to 2.5G. The JVM runs -Xmx512m. +args = [ + "-nographic", "-m", "2048M", "-smp", "1", "-cpu", "rv64", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-riscv64-alpine.img", + "-device", "virtio-net-pci,netdev=net0", "-netdev", "user,id=net0", +] +uefi = false +to_bin = true +shell_prefix = "root@starry:/root #" +shell_init_cmd = "sh /usr/bin/run-jweb.sh" +success_regex = ['(?m)^TEST PASSED\s*$'] +fail_regex = ['(?i)\bpanic(?:ked)?\b', '(?m)^TEST FAILED\s*$'] +timeout = 18000 diff --git a/apps/starry/java-web/qemu-x86_64.toml b/apps/starry/java-web/qemu-x86_64.toml new file mode 100644 index 0000000000..1bfde2ec97 --- /dev/null +++ b/apps/starry/java-web/qemu-x86_64.toml @@ -0,0 +1,17 @@ +# java-jse x86_64 — J2SE library + JSE standard-library carpet on OpenJDK 17. +# x86_64 boots the ELF kernel via the dynamic platform + OVMF (xtask prepares firmware +# automatically; no PVH note needed). The rootfs is the per-app rootfs-x86_64-alpine.img +# which prebuild.sh grows to 2.5G so JDK17 + the demo jars fit. The JVM runs -Xmx256m. +args = [ + "-nographic", "-m", "2048M", "-smp", "1", + "-device", "virtio-blk-pci,drive=disk0", + "-drive", "id=disk0,if=none,format=raw,file=${workspace}/tmp/axbuild/rootfs/rootfs-x86_64-alpine.img", + "-device", "virtio-net-pci,netdev=net0", "-netdev", "user,id=net0", +] +uefi = false +to_bin = false +shell_prefix = "root@starry:/root #" +shell_init_cmd = "sh /usr/bin/run-jweb.sh" +success_regex = ['(?m)^TEST PASSED\s*$'] +fail_regex = ['(?i)\bpanic(?:ked)?\b', '(?m)^TEST FAILED\s*$'] +timeout = 7200