Conversation
Remove invalid trailing comma in require section that was causing JSON parse errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
# Conflicts: # composer.json
- Increase recv buffer from 64KB to 128KB (configurable via recv_buffer_size) Larger buffers = fewer syscalls = better throughput - Add backend socket optimizations: - open_tcp_nodelay: Disable Nagle's algorithm for lower latency - socket_buffer_size: 2MB buffer for backend connections - Configurable connect timeout (default 5s, was hardcoded 30s) - Add new config options: - recv_buffer_size: Control forwarding buffer size - backend_connect_timeout: Control backend connection timeout - Add setConnectTimeout() method to TCP adapter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Optimizes system for high-throughput TCP proxy testing: - File descriptor limits (2M) - TCP backlog (65535) - Socket buffers (128MB max) - TCP Fast Open, tw_reuse, window scaling - Local port range (1024-65535) - CPU governor (performance mode) Usage: sudo ./benchmarks/setup-linux.sh # temporary sudo ./benchmarks/setup-linux.sh --persist # permanent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Conservative settings for production database proxies: - Keeps tcp_slow_start_after_idle=1 (default) to prevent bursts - Keeps tcp_no_metrics_save=0 (default) for cached route metrics - Uses tcp_fin_timeout=30 instead of aggressive 10 - Adds tcp_keepalive tuning to detect dead connections - Lower limits than benchmark script (still 1M connections) Use setup-linux.sh for benchmarks, setup-linux-production.sh for prod. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Single command to setup fresh Ubuntu/Debian droplet and run benchmarks: - Installs PHP 8.3 + Swoole - Installs Composer - Clones repo - Applies kernel tuning - Runs connection rate + throughput benchmarks Usage: curl -sL https://raw.githubusercontent.com/utopia-php/protocol-proxy/dev/benchmarks/bootstrap-droplet.sh | sudo bash Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
New benchmark modes: - Sustained load: continuous requests for N seconds, monitors memory/latency/errors - Max connections: opens and holds N concurrent connections Usage: # 60 second sustained load test BENCH_DURATION=60 BENCH_CONCURRENCY=1000 php benchmarks/tcp-sustained.php # 5 minute soak test BENCH_DURATION=300 BENCH_CONCURRENCY=2000 php benchmarks/tcp-sustained.php # Max connections test (hold 50k connections) BENCH_MODE=max_connections BENCH_TARGET_CONNECTIONS=50000 php benchmarks/tcp-sustained.php Output includes: conn/s, req/s, error rate, active connections, throughput, latency, memory Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Updated bootstrap-droplet.sh targets: - Burst: 1M connections (was 400k) - Throughput: 16GB (was 8GB) - Sustained: 4000 concurrency for ~100k conn/s (was 1000) - Max connections test: 100k (was 50k) Added note: these are per-pod numbers, scale linearly with more pods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
# Conflicts: # composer.json
…adapter internals
… and byte tracking into TCP proxy
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughThe PR performs a large-scale refactor and feature expansion: renames package/PSR-4 namespaces; replaces the ConnectionManager-based resolution with a new Resolver and Adapter abstraction; adds Protocol enum and Resolver result/exception types; implements new Swoole and Swoole Coroutine HTTP/TCP/SMTP servers and TCP TLS support (TlsContext/TLS); introduces TCP adapters with read/write split and an HTTP Adapter; removes legacy ConnectionManager, Resource, and ResourceStatus implementations; adds extensive benchmarking scripts, Docker and CI workflows, linting/format configs, many example/proxy entrypoints, and a broad suite of unit, integration, and performance tests. Several files were added, modified, or deleted across src/, proxies/, benchmarks/, tests/, and CI/docker configs. Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes ✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 9
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
README.md (1)
1-3:⚠️ Potential issue | 🟡 MinorTitle inconsistent with package rename.
The README title says "Appwrite Protocol Proxy" but
composer.jsonrenames the package toutopia-php/protocol-proxy. Consider updating the title to match the new branding.📝 Suggested title update
-# Appwrite Protocol Proxy +# Utopia Protocol Proxy🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@README.md` around lines 1 - 3, Update the README title string "Appwrite Protocol Proxy" to match the package rename in composer.json (package name "utopia-php/protocol-proxy"); locate the README header and replace the title with the new branding (e.g., "Utopia Protocol Proxy" or "utopia-php/protocol-proxy") so README and composer.json use consistent package naming.src/Server/SMTP/Swoole.php (3)
193-205:⚠️ Potential issue | 🟠 MajorClose the previous backend before replacing it on a repeated EHLO/HELO.
If the client sends EHLO/HELO again,
$conn['backend']is overwritten and only the most recent client is closed inonClose(). The earlier backend sockets are leaked for the lifetime of the worker.💡 Close the old backend before reconnecting
if (preg_match('/^(EHLO|HELO)\s+([^\s]+)/i', $data, $matches)) { $domain = $matches[2]; $conn['domain'] = $domain; + + if ($conn['backend'] instanceof Client) { + $conn['backend']->close(); + $conn['backend'] = null; + } // Route to backend using adapter $result = $this->adapter->route($domain);Also applies to: 269-274
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Server/SMTP/Swoole.php` around lines 193 - 205, When assigning a new backend client in handleHelo (and the similar block around the 269-274 region), ensure you close any existing backend socket stored in $conn['backend'] before overwriting it to prevent leaking connections: check if $conn['backend'] exists and is a valid/active client, call the appropriate close/disconnect method on that object (the same one used in onClose()), then replace $conn['backend'] with the newly returned value from connectToBackend; do the same cleanup in the other repeated-EHLO/HELO handling path so old backend sockets are always closed before being replaced.
157-179:⚠️ Potential issue | 🔴 CriticalHandle SMTP
DATAmode separately from command parsing.
$conn['state']is initialized but never used. After the backend replies354, the next bytes are message content until\r\n.\r\n, not SMTP verbs. Right now headers/body lines fall into the default branch and get500 Unknown command, so a normal mail submission cannot complete.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Server/SMTP/Swoole.php` around lines 157 - 179, The SMTP DATA body is being parsed as commands because $conn['state'] isn't used; modify the receive/parse block in Swoole.php to check $conn['state'] before extracting a 4-char verb and, if $conn['state']==='DATA', treat incoming bytes as message content (accumulate and forward to backend) until the terminator "\r\n.\r\n" is seen, then clear the state and handle the end-of-data reply; update forwardToBackend (or create a new handleData method) to set $conn['state']='DATA' when the backend responds with 354 and to accept raw data chunks thereafter so headers/body lines are not run through the default command switch, finally reset $conn['state'] after the message is completed.
220-238:⚠️ Potential issue | 🟠 MajorDo not spawn independent
recv()coroutines per command.A single
recv(8192)is not enough for multiline replies likeEHLO, and launching a detached read coroutine per command lets multiple coroutines read from the same backend socket. That can strand reply bytes or associate them with the wrong frontend command.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Server/SMTP/Swoole.php` around lines 220 - 238, forwardToBackend currently sends data then creates a detached Coroutine::create that calls backendClient->recv(8192), which can cause multiple coroutines to read the same backend socket and drop/misassociate multiline replies (e.g., EHLO). Replace this by removing the per-call Coroutine::create/recv from forwardToBackend; instead implement/ensure a single persistent reader coroutine for each backend connection (spawned when backendClient is attached) that continuously reads (loop recv until socket closed), buffers full SMTP replies and then dispatches them to the correct frontend fd(s) (use a pending-response queue or mapping keyed by backend connection stored on the connection object). Keep forwardToBackend to only backendClient->send($data) and register the frontend fd in the backend's pending-request structure so the shared reader can relay complete multiline responses back via $server->send($fd, $response).
🟠 Major comments (18)
.dockerignore-1-8 (1)
1-8:⚠️ Potential issue | 🟠 MajorKeep local secrets and volume data out of the Docker build context.
.envand/docker-volumes/are now ignored by Git, but they are still sent todocker buildbecause this file does not exclude them. That is an avoidable secret-leak risk and can massively inflate build context size on developer machines.🛡️ Proposed fix
.git .gitignore .idea vendor *.md +.env +/docker-volumes/ .dockerignore Dockerfile docker-compose.yml🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.dockerignore around lines 1 - 8, The .dockerignore currently omits .env and docker volume directories, so sensitive local secrets and large /docker-volumes/ data are being sent to docker build; update the .dockerignore to explicitly exclude .env (and any .env.* patterns you use) and the docker-volumes directory (e.g., docker-volumes/ or /docker-volumes/) so those files are not included in the build context; locate the .dockerignore file and add entries for ".env", ".env.*" (optional) and "docker-volumes/" to fix the leak and reduce build context size..github/workflows/lint.yml-18-25 (1)
18-25:⚠️ Potential issue | 🟠 MajorInstall the required PHP extensions before Composer runs.
The lint job installs dependencies before running Pint, but does not provision the native extensions required by the project. The
composer.jsondeclaresext-swoole(>=6.0) andext-redisas platform requirements. Without these extensions,composer installwill fail and the job will never reach Pint.🔧 Proposed fix
- name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.4' + extensions: swoole, redis tools: composer:v2🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.github/workflows/lint.yml around lines 18 - 25, The workflow installs PHP then runs composer before provisioning required native extensions, causing composer install to fail because ext-swoole and ext-redis are declared in composer.json; update the "Setup PHP" step (the one using shivammathur/setup-php@v2) to install the required extensions by adding the extensions/pecl entries for swoole (>=6.0) and redis (e.g., extensions: redis, swoole or pecl: swoole) so the environment satisfies the platform requirements before the "Install dependencies" step runs.benchmarks/compare-http-servers.sh-8-10 (1)
8-10:⚠️ Potential issue | 🟠 Major
COMPARE_HOST/COMPARE_PORTnever reach the proxy process.These knobs currently only change the health check and
benchmarks/http.php.start_proxy()still launchesproxies/http.phpwithout a matching listen-address override, so any non-default host/port points the probe and benchmark at a different endpoint than the proxy you just started.Also applies to: 52-77, 83-86
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/compare-http-servers.sh` around lines 8 - 10, The env vars COMPARE_HOST/COMPARE_PORT (host/port) are only used for the probe and benchmark but not passed to the proxy; update start_proxy() so the proxy process is launched with the same listen address by exporting or passing COMPARE_HOST/COMPARE_PORT into the PHP proxy invocation (proxies/http.php) or by adding CLI args to start_proxy() to forward host and port, then use those inside proxies/http.php to bind the server; ensure every proxy launch (all start_proxy() calls) is updated so the started proxy actually listens on host/port used by the health check and benchmark.benchmarks/http.php-52-67 (1)
52-67:⚠️ Potential issue | 🟠 MajorValidate
BENCH_SAMPLE_EVERYbefore using it.Right now
BENCH_SAMPLE_EVERY=0will throw at Line 154, and values larger than$requestscan leave$samplesempty so the latency goals report0.00msand pass with no real percentile data.🩹 Proposed fix
if ($concurrent < 1) { echo "Invalid concurrency.\n"; return; } + if ($sampleEvery < 1) { + echo "Invalid sample interval.\n"; + + return; + } + if ($sampleEvery > $requests) { + $sampleEvery = $requests; + }Also applies to: 154-155, 218-250
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/http.php` around lines 52 - 67, The code doesn't validate BENCH_SAMPLE_EVERY (and indirectly BENCH_SAMPLE_TARGET) so a zero or out-of-range value can cause a divide-by-zero or produce an empty $samples set; fix by sanitizing and clamping the sampler variables: ensure $sampleTarget = max(1, $envInt('BENCH_SAMPLE_TARGET', 200000)), then compute $sampleEvery = max(1, min($sampleEvery, $requests)) (or derive $sampleEvery = max(1, (int) ceil($requests / max(1, $sampleTarget)))) and when building $samples ensure at least one sample (e.g. compute sample count with max(1, (int) ceil($requests / $sampleEvery))). Update references to $sampleTarget, $sampleEvery and $samples where used (including the code paths around $requests checks and later uses at the percentile/latency reporting) so zero or too-large values are clamped and never produce division by zero or empty sample lists.benchmarks/bootstrap-droplet.sh-116-126 (1)
116-126:⚠️ Potential issue | 🟠 MajorDon't delete the workdir before deciding whether to reuse it.
Line 117 runs before the "already in the repo" check, so rerunning this script from
/tmp/protocol-proxy-benchcan wipe the checkout it is about to use.🧯 Proposed fix
WORKDIR="/tmp/protocol-proxy-bench" -rm -rf "$WORKDIR" if [ -f "composer.json" ] && grep -q "protocol-proxy" composer.json 2>/dev/null; then # Already in the repo WORKDIR="$(pwd)" echo " - Using current directory" else + rm -rf "$WORKDIR" git clone --depth 1 -b dev https://github.com/utopia-php/protocol-proxy.git "$WORKDIR" 2>/dev/null cd "$WORKDIR" echo " - Cloned to $WORKDIR" fi🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/bootstrap-droplet.sh` around lines 116 - 126, The script currently does rm -rf "$WORKDIR" before checking whether we are already running inside that repo; move or conditionally perform the removal so you don't wipe an existing checkout: first set WORKDIR="/tmp/protocol-proxy-bench" and then test if [ -f "composer.json" ] && grep -q "protocol-proxy" composer.json to detect reuse (setting WORKDIR="$(pwd)" when true) and only run rm -rf "$WORKDIR" in the else branch before git clone (or skip removal entirely when WORKDIR was set to the current directory); update the logic around WORKDIR and the clone branch that contains the git clone and cd so removal happens only when cloning a fresh copy.benchmarks/bootstrap-droplet.sh-47-48 (1)
47-48:⚠️ Potential issue | 🟠 MajorBootstrap pins PHP 8.3, but project requires PHP 8.4.
composer.jsonspecifies PHP>=8.4and8.4, but the bootstrap script hard-codes PHP 8.3 packages at lines 47-48, 70, and 99. On a fresh host, this will install the wrong PHP version, breakingcomposer installand potentially loading incorrect package versions.Affected lines
47-48: apt-get install -y -qq php8.3-cli php8.3-dev php8.3-xml php8.3-curl \ php8.3-mbstring php8.3-zip php-pear git unzip curl > /dev/null 2>&1 70: apt-get install -y -qq php8.3-swoole > /dev/null 2>&1 || { 99: echo " apt-get install php8.3-swoole"Update package versions to PHP 8.4.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/bootstrap-droplet.sh` around lines 47 - 48, The bootstrap script pins PHP packages to 8.3; update all occurrences of the php8.3 package names to php8.4 so the installed PHP matches composer.json; specifically replace package strings like "php8.3-cli", "php8.3-dev", "php8.3-xml", "php8.3-curl", "php8.3-mbstring", "php8.3-zip", and "php8.3-swoole" with their "php8.4-*" equivalents and update the echo that references "apt-get install php8.3-swoole" to "php8.4-swoole" so the install commands and error messages reflect PHP 8.4.benchmarks/bootstrap-droplet.sh-13-14 (1)
13-14:⚠️ Potential issue | 🟠 MajorThe automated benchmark path skips the backend startup.
The usage example above starts
benchmarks/tcp-backend.phpbefore invokingbenchmarks/tcp.php, but the actual run phase never does that. On a fresh droplet these benchmark commands are now running without the listener this script documents as required.Also applies to: 165-183
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/bootstrap-droplet.sh` around lines 13 - 14, The example run in benchmarks/bootstrap-droplet.sh documents starting benchmarks/tcp-backend.php before running benchmarks/tcp.php but the actual automated run omits starting the backend; update the script so any invocation that runs benchmarks/tcp.php first launches benchmarks/tcp-backend.php in the background (e.g., with proper stdout/stderr redirection), waits for it to be ready (sleep or a health-check loop), and ensures it is killed/cleaned up after the benchmark completes; specifically modify the sections that reference benchmarks/tcp-backend.php and benchmarks/tcp.php (also the repeated block around lines 165-183) to start the backend first, verify it started, then run the benchmark client.composer.json-12-17 (1)
12-17:⚠️ Potential issue | 🟠 MajorPath repository will cause installation failure for external consumers.
The path repository pointing to
../querywill cause Composer to abort during installation if the local directory does not exist. Sinceutopia-php/queryis already published on Packagist, the path repository is not necessary and will break installations for users without this monorepo structure. Remove the path repository from composer.json, or move it to a development-only configuration that is not committed to version control.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@composer.json` around lines 12 - 17, The composer.json contains a "repositories" entry with a path repository ("type": "path", "url": "../query") that will abort installation for external consumers if that local directory doesn't exist; remove this path repository from composer.json (or move it into a non-committed dev-only composer config) so Packagist's published package (utopia-php/query) is used instead; update or delete the "repositories" array entry referencing "../query" and commit the cleaned composer.json.composer.json-25-27 (1)
25-27:⚠️ Potential issue | 🟠 MajorWildcard versions for dev dependencies may cause CI instability.
Using
*forphpstan/phpstanandlaravel/pintcould pull incompatible major versions, causing unexpected CI failures. Additionally, PHPUnit 12.* is outdated—PHPUnit 13 is the current stable major version.📌 Pin to major versions
"require-dev": { - "phpunit/phpunit": "12.*", - "phpstan/phpstan": "*", - "laravel/pint": "*" + "phpunit/phpunit": "^13.0", + "phpstan/phpstan": "^2.0", + "laravel/pint": "^1.0" },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@composer.json` around lines 25 - 27, The dev-dependency versions are too permissive and PHPUnit is pinned to an older major; update composer.json so "phpunit/phpunit" is bumped from "12.*" to a current major constraint like "^13.0" and replace the wildcard "*" entries for "phpstan/phpstan" and "laravel/pint" with explicit major constraints (e.g., "^1" or the correct current major for your project) to avoid pulling breaking changes; edit the package entries for phpstan/phpstan and laravel/pint accordingly and run composer update / composer.lock regeneration to lock compatible versions.src/Server/TCP/SwooleCoroutine.php-183-225 (1)
183-225:⚠️ Potential issue | 🟠 MajorPotential race condition between forwarding coroutines.
When the backend→client coroutine (lines 183-195) exits and closes
$clientSocket, the main client→backend loop (lines 208-216) may still be waiting onrecv(). While this will eventually return false, there's a window where:
- B2C coroutine closes the backend socket externally (not shown but backend may close)
- B2C closes client socket (line 194)
- Main loop exits and tries to close
$backendSocket(line 219) which may already be in a broken stateConsider using a shared cancellation flag or
Coroutine::cancel()for cleaner shutdown coordination.Suggested approach using atomic flag
+ $closed = new \Swoole\Atomic(0); + // Start backend -> client forwarding in separate coroutine - Coroutine::create(function () use ($clientSocket, $backendSocket, $bufferSize, $adapter, $databaseId): void { + Coroutine::create(function () use ($clientSocket, $backendSocket, $bufferSize, $adapter, $databaseId, $closed): void { while (true) { $data = $backendSocket->recv($bufferSize); if ($data === false || $data === '') { break; } $adapter->recordBytes($databaseId, 0, \strlen($data)); if ($clientSocket->sendAll($data) === false) { break; } } - $clientSocket->close(); + $closed->set(1); }); // ... main loop ... - $adapter->notifyClose($databaseId); - $backendSocket->close(); + if ($closed->cmpset(0, 1)) { + // We initiated close + } + $adapter->notifyClose($databaseId); + @$backendSocket->close(); + @$clientSocket->close(); $adapter->closeBackendConnection($databaseId, $clientId);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Server/TCP/SwooleCoroutine.php` around lines 183 - 225, The backend->client Coroutine spawned with Coroutine::create can close $clientSocket while the main client->backend loop is blocked on $clientSocket->recv(), causing a race and potential double/invalid close of $backendSocket; fix by introducing a shared cancellation flag (e.g., $cancelled) or use Coroutine::cancel() on the spawned coroutine and check that flag in both loops: set the flag before closing sockets in the B2C coroutine, have the main loop break immediately if the flag is set (and skip closing $backendSocket if already closed), and guard any close() calls on $backendSocket/$clientSocket with the flag or an isClosed check and ensure adapter->notifyClose($databaseId) / adapter->closeBackendConnection($databaseId, $clientId) are called exactly once.tests/QueryParserTest.php-620-654 (1)
620-654:⚠️ Potential issue | 🟠 MajorKeep the microbenchmarks out of the default PHPUnit suite.
These 100k-iteration tests assert sub-microsecond timings, so they'll flap on slower CI runners and add avoidable time to every regular test run. Move them behind an opt-in env flag or a dedicated benchmark group instead of making them mandatory unit tests.
💡 One simple opt-in pattern
public function test_parse_performance(): void { + if (getenv('RUN_PERFORMANCE_TESTS') !== '1') { + self::markTestSkipped('Performance tests are opt-in'); + } + $pgData = $this->buildPgQuery('SELECT * FROM users WHERE id = 1'); $mysqlData = $this->buildMySQLQuery('SELECT * FROM users WHERE id = 1');public function test_classify_sql_performance(): void { + if (getenv('RUN_PERFORMANCE_TESTS') !== '1') { + self::markTestSkipped('Performance tests are opt-in'); + } + $queries = [ 'SELECT * FROM users WHERE id = 1',Also applies to: 656-681
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/QueryParserTest.php` around lines 620 - 654, The performance microbenchmark in test_parse_performance (and the similar test at lines 656-681) runs 100_000 iterations calling $this->pgParser->parse(...) and $this->mysqlParser->parse(...), asserting sub-microsecond timings which flaps CI and slows regular test runs; update these tests to be opt-in by either skipping when an env var is not set (e.g. if (!getenv('RUN_BENCHMARKS')) $this->markTestSkipped('Benchmarks disabled')) or by adding a PHPUnit group annotation (e.g. `@group` benchmark) and document running them separately, leaving the loop/measurement code and assertions unchanged so the benchmarks still execute only when opted into.benchmarks/compare-tcp-servers.sh-68-71 (1)
68-71:⚠️ Potential issue | 🟠 MajorTrack child PIDs instead of killing by command pattern.
These
pkill -fcalls will tear down any matching proxy/backend on the host, not just the ones this script spawned. That's especially risky whenCOMPARE_BACKEND_START=falseand another benchmark backend is already running.💡 Safer cleanup pattern
+proxy_pids=() +backend_pid= + cleanup() { - pkill -f "proxies/tcp.php" >/dev/null 2>&1 || true - pkill -f "php benchmarks/tcp-backend.php" >/dev/null 2>&1 || true + for pid in "${proxy_pids[@]:-}"; do + kill "$pid" >/dev/null 2>&1 || true + done + if [ -n "${backend_pid:-}" ]; then + kill "$backend_pid" >/dev/null 2>&1 || true + fi }nohup env BACKEND_HOST="${backend_host}" BACKEND_PORT="${backend_port}" \ php benchmarks/tcp-backend.php > /tmp/tcp-backend.log 2>&1 & + backend_pid=$!nohup env \ TCP_SERVER_IMPL="${impl}" \ TCP_BACKEND_ENDPOINT="${backend_host}:${backend_port}" \ ... php -d memory_limit=1G proxies/tcp.php > /tmp/tcp-proxy.log 2>&1 & + proxy_pids+=($!)Also applies to: 79-79, 100-100
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/compare-tcp-servers.sh` around lines 68 - 71, The cleanup() function currently uses pkill -f with patterns ("proxies/tcp.php" and "php benchmarks/tcp-backend.php") which can kill unrelated processes; instead, when spawning the proxy and backend capture and store their PIDs (e.g., assign $! to variables or write to a temp pidfile from the start logic that launches the processes referenced by proxies/tcp.php and php benchmarks/tcp-backend.php), update cleanup() to kill only those stored PIDs (kill <pid> || true) and remove the pidfile, and ensure a trap EXIT invokes cleanup so processes started by this script are the only ones torn down.benchmarks/compare-tcp-servers.sh-162-169 (1)
162-169:⚠️ Potential issue | 🟠 MajorFail fast when the backend never becomes reachable.
If
COMPARE_BACKEND_START=falseand nothing is listening on${backend_host}:${backend_port}, this loop falls through and the script still prints benchmark rows against a dead target. Exit once the retries are exhausted.💡 Abort on exhausted retries
+backend_ready=false for _ in {1..10}; do if php -r '$s=@stream_socket_client("tcp://'"${backend_host}:${backend_port}"'", $errno, $errstr, 0.5); if ($s) { fclose($s); exit(0);} exit(1);' >/dev/null 2>&1; then + backend_ready=true break fi sleep 0.5 done + +if [ "$backend_ready" != "true" ]; then + echo "Backend is not reachable at ${backend_host}:${backend_port}" >&2 + exit 1 +fi🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/compare-tcp-servers.sh` around lines 162 - 169, The readiness loop started by start_backend may exhaust retries and still continue benchmarking against a dead target; modify the loop around the PHP stream_socket_client probe (which checks backend_host and backend_port) to detect when all retries fail and then exit non‑zero with an error message; e.g., set a success flag or test the probe result after the for {1..10} loop and if unreachable (and COMPARE_BACKEND_START is false or however start_backend is gated) call echo to stderr and exit 1 so the script fails fast instead of running benchmarks against a nonresponsive backend.tests/Performance/PerformanceTest.php-791-807 (1)
791-807:⚠️ Potential issue | 🟠 Major
readResponse()is truncating benchmark traffic.A single
fread(..., 65536)is not a full PostgreSQL response. Large-payload tests and repeated-query loops leave unread bytes on the socket, so later iterations measure leftovers instead of the next request/response pair.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Performance/PerformanceTest.php` around lines 791 - 807, The current readResponse function reads only a single fread(…, 65536) which leaves unread bytes on the socket for large Postgres responses; update readResponse to loop and accumulate chunks until the full response is consumed: keep using stream_set_timeout and repeatedly call fread inside a loop, appending each non-empty chunk to a buffer; on an empty chunk call stream_get_meta_data($sock) and if meta['timed_out'] return false, otherwise check meta['unread_bytes'] (or feof) and only break the loop when unread_bytes == 0 (or EOF) to ensure the whole response is drained before returning the buffer; reference readResponse, stream_set_timeout, fread, and stream_get_meta_data to locate where to implement the loop and accumulation.src/Adapter.php-244-277 (1)
244-277:⚠️ Potential issue | 🟠 MajorThe SSRF guard can be bypassed via DNS rebinding.
validateEndpoint()checks one DNS result, but the actual connection later re-resolves the original hostname. A rebinding hostname can validate against a public address and then dial private space at connect time. If this check is meant to be authoritative, the code needs to validate all resolved addresses and pin the address it actually connects to.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Adapter.php` around lines 244 - 277, validateEndpoint currently uses gethostbyname to check a single DNS result, which allows DNS rebinding; instead resolve all A and AAAA records (e.g. via dns_get_record or equivalent) and validate every returned IP against the private/reserved ranges in the validateEndpoint function, and then ensure the code pins and uses one of those validated IPs for the actual connection (i.e. return or store the resolved IP chosen by validateEndpoint and modify the later connection code to use that returned IP rather than re-resolving the hostname). Ensure both IPv4 and IPv6 results are checked and the same private/reserved checks (ip2long ranges and IPv6 prefix checks) are applied to every resolved address.src/Server/TCP/Config.php-12-39 (1)
12-39:⚠️ Potential issue | 🟠 MajorFail fast on an empty or invalid port list.
src/Server/TCP/Swoole.phpimmediately uses$this->config->ports[0]. Ifportsis empty, startup dies with a runtime error instead of a clear config failure, and out-of-range ports are only caught much later by bind/connect code.Patch sketch
) { + if ($ports === []) { + throw new \InvalidArgumentException('At least one TCP port is required.'); + } + + foreach ($ports as $port) { + if ($port < 1 || $port > 65535) { + throw new \InvalidArgumentException("Invalid TCP port: {$port}"); + } + } + $this->reactorNum = $reactorNum ?? swoole_cpu_num() * 2; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/Server/TCP/Config.php` around lines 12 - 39, The TCP Config constructor currently accepts any array for $ports, causing downstream crashes when Swoole.php accesses $this->config->ports[0]; update the Config::__construct to validate $ports immediately: ensure the array is non-empty, each entry is an integer within 1..65535 (or castable to int if desired), and throw a clear InvalidArgumentException (or custom ConfigException) with a descriptive message if validation fails; reference the Config class and its __construct and the $ports property so the check is placed there to fail fast on invalid or empty port lists.tests/Integration/EdgeIntegrationTest.php-442-446 (1)
442-446:⚠️ Potential issue | 🟠 MajorThis validates cache expiry, not cache invalidation.
purge()in the mock is effectively a no-op, and the test then waits longer than the routing cache TTL before routing again. That means the assertion still passes if invalidation is broken, because natural expiry forces the re-resolve anyway. Re-route before the TTL elapses, or make invalidation observable from the adapter side.Also applies to: 760-763
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Integration/EdgeIntegrationTest.php` around lines 442 - 446, The test currently validates cache expiry, not invalidation: replace the sleep-based approach by exercising re-route immediately after calling $resolver->purge('invaldb') and asserting that invalidation occurred; specifically, update the EdgeIntegrationTest to (a) call the routing operation (the same method that triggers resolution) immediately after resolver->purge, (b) either make the resolver mock's purge() mutate observable state in the adapter/mock (so the adapter sees a cleared cache) or add an expectation on the adapter mock that purge()/invalidate was invoked, and (c) remove the sleep(2) so the test fails if purge() is a no-op. Reference the resolver->purge call and the routing invocation in the test and ensure assertions verify that the adapter observed the invalidation rather than waiting for TTL expiry.tests/Integration/EdgeIntegrationTest.php-492-525 (1)
492-525:⚠️ Potential issue | 🟠 MajorThese "concurrent" cases never execute concurrently.
Both methods only perform serial
route()calls in a single execution context. They verify multi-database routing, but they do not exercise concurrent access to the adapter or resolver state. Please either run them in parallel or rename them so the coverage claim matches reality.Also applies to: 530-566
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Integration/EdgeIntegrationTest.php` around lines 492 - 525, The test test_concurrent_resolution_of_multiple_databases() (and the similar 530-566 case) does not perform parallel execution — it calls EdgeMockResolver->registerDatabase and TCPAdapter->route() serially — so either make it actually concurrent or change the name; simplest fix: rename the test(s) and any references from "concurrent" to "serial" or "multiple" (e.g., test_resolution_of_multiple_databases) and update assertions/comments accordingly, keeping the same use of EdgeMockResolver, TCPAdapter, route(), and getStats() so the behavior and assertions remain unchanged.
🟡 Minor comments (9)
docker-compose.yml-70-71 (1)
70-71:⚠️ Potential issue | 🟡 MinorPotential port conflict with MariaDB.
Both
mariadb(line 13) andtcp-proxy(line 71) map to host port 3306. When running the base compose file without overrides, this will cause a port binding conflict. Consider using a different default port forTCP_MYSQL_PORTor documenting that these services shouldn't run simultaneously on the same host.- - "${TCP_MYSQL_PORT:-3306}:3306" + - "${TCP_MYSQL_PORT:-33060}:3306"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docker-compose.yml` around lines 70 - 71, The docker-compose mapping for tcp-proxy uses the same host port as the mariadb service, causing a port bind conflict; update the default mapping for the tcp-proxy MYSQL port (the TCP_MYSQL_PORT environment variable referenced in the tcp-proxy service) to a different host port (e.g., change default from 3306 to another unused port) or add a note in the compose file/service description that mariadb and tcp-proxy must not run simultaneously; modify the tcp-proxy service's port entry that uses "${TCP_MYSQL_PORT:-3306}:3306" (and/or the TCP_POSTGRES_PORT if applicable) to a new default or document the conflict so users can override TCP_MYSQL_PORT when needed.benchmarks/wrk2.sh-4-7 (1)
4-7:⚠️ Potential issue | 🟡 MinorError message references unused
WRK2_BINvariable.The error suggests setting
WRK2_BIN, but the script never uses this variable - it just callswrk2directly.if ! command -v wrk2 >/dev/null 2>&1; then - echo "wrk2 not found. Install wrk2 or set WRK2_BIN." >&2 + echo "wrk2 not found. Please install wrk2 first." >&2 exit 1 fi🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/wrk2.sh` around lines 4 - 7, The error message incorrectly references WRK2_BIN even though the script always calls wrk2; update the script to honor WRK2_BIN by using a resolved binary variable (e.g., WRK2_CMD="${WRK2_BIN:-wrk2}") and use WRK2_CMD in the check and later invocations (replace direct calls to wrk2 and the command -v check with "${WRK2_CMD}"/command -v "${WRK2_CMD}" ), or if you don't want a configurable path, remove the WRK2_BIN mention from the echo and keep the unconditional wrk2 usage; adjust the block around the existing check (the if ! command -v wrk2 ... fi) accordingly.benchmarks/test-bootstrap.sh-16-24 (1)
16-24:⚠️ Potential issue | 🟡 Minordnf detected but never used - script is apt-only.
The script detects
dnfavailability but all subsequent package installation commands useapt-get. This will fail on Fedora/RHEL systems.🐛 Suggested fix: either implement dnf support or remove the misleading check
echo "[1/6] Testing package manager..." -if command -v apt-get > /dev/null 2>&1; then - echo " OK: apt-get available" - apt-get update -qq -elif command -v dnf > /dev/null 2>&1; then - echo " OK: dnf available" -else - echo " FAIL: No supported package manager" +if ! command -v apt-get > /dev/null 2>&1; then + echo " FAIL: apt-get required (Debian/Ubuntu only)" exit 1 fi +echo " OK: apt-get available" +apt-get update -qq🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/test-bootstrap.sh` around lines 16 - 24, The conditional currently detects dnf but never uses it, causing failures on Fedora/RHEL; update the installation logic in the top-level package manager check to either remove the dnf test or add a dnf branch that sets a package manager variable (e.g., PM) and maps install/update flags appropriately, then replace hard-coded apt-get calls with that variable (or implement equivalent dnf commands) so the script works on both apt and dnf systems; modify the if/elif block and subsequent apt-get usage to reference the chosen package manager decision.benchmarks/wrk.sh-4-7 (1)
4-7:⚠️ Potential issue | 🟡 MinorHonor
WRK_BINconsistently.The script tells users to set
WRK_BIN, but both the presence check and the finalexecstill hard-codewrk, so a custom binary path will fail here.🔧 Proposed fix
+wrk_bin="${WRK_BIN:-wrk}" -if ! command -v wrk >/dev/null 2>&1; then - echo "wrk not found. Install wrk or set WRK_BIN." >&2 +if ! command -v "$wrk_bin" >/dev/null 2>&1; then + echo "${wrk_bin} not found. Install wrk or set WRK_BIN." >&2 exit 1 fi @@ -echo "Running: wrk -t${threads} -c${connections} -d${duration} ${extra_args[*]} ${url}" -exec wrk -t"${threads}" -c"${connections}" -d"${duration}" "${extra_args[@]}" "${url}" +echo "Running: ${wrk_bin} -t${threads} -c${connections} -d${duration} ${extra_args[*]} ${url}" +exec "$wrk_bin" -t"${threads}" -c"${connections}" -d"${duration}" "${extra_args[@]}" "${url}"Also applies to: 35-36
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/wrk.sh` around lines 4 - 7, The script currently hard-codes "wrk" for the existence check and when executing, ignoring the WRK_BIN override; change the checks and invocation to respect WRK_BIN by using a variable (e.g., WRK_BIN=${WRK_BIN:-wrk}) and replace occurrences of literal wrk with that variable (update the command -v check and the exec call, and the other occurrences noted around lines 35-36) so the script verifies and executes the configured binary rather than always using "wrk".benchmarks/tcp-sustained.php-207-216 (1)
207-216:⚠️ Potential issue | 🟡 MinorKeep-alive "PING" may not be valid for database protocols.
The handshake uses protocol-specific formats (MySQL/PostgreSQL), but the keep-alive loop sends a raw
"PING"string. Real PostgreSQL/MySQL connections don't recognize this command, which could cause protocol errors or connection termination.For accurate testing, consider using protocol-appropriate keep-alive messages or simply relying on TCP keep-alive without application-level pings.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/tcp-sustained.php` around lines 207 - 216, The loop currently sends a raw "PING" string over $client (in the while block using $running, Coroutine::sleep, $client->send, $client->recv, $stats['requests']->add), which is invalid for DB protocols; remove the application-level $client->send("PING") (and its recv expectation) and either rely on TCP keep-alive at the socket level or implement protocol-specific keep-alive outside this loop (e.g., a MySQL COM_PING packet or PostgreSQL-compatible message); update the loop to only sleep and count requests (or perform a proper protocol ping when configured) so you no longer send raw "PING" on a DB connection.PERFORMANCE.md-164-168 (1)
164-168:⚠️ Potential issue | 🟡 MinorTCP benchmark command uses incorrect flag syntax.
The command
php benchmarks/tcp.php --port=3306uses double-dash flag syntax, but the script reads configuration from environment variables (BENCH_PORT), not CLI arguments.📝 Fix the command to use environment variable
# MySQL connections -php benchmarks/tcp.php --port=3306 +BENCH_PORT=3306 php benchmarks/tcp.php🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@PERFORMANCE.md` around lines 164 - 168, The TCP benchmark command in PERFORMANCE.md is using CLI flag syntax that the script doesn't read; update the example to set the BENCH_PORT environment variable before running the script (referencing benchmarks/tcp.php and the BENCH_PORT env var) so the script picks up the port correctly; replace the `--port=3306` invocation with an environment-variable invocation using BENCH_PORT=3306 followed by the existing php benchmarks/tcp.php command.benchmarks/tcp.php-396-397 (1)
396-397:⚠️ Potential issue | 🟡 MinorHandshake send result not checked in non-persistent mode.
Unlike the persistent mode which checks
send()return values, the non-persistent mode ignores the result of$client->send($handshake). Failed sends won't be counted as errors, potentially skewing benchmark results.🔧 Add send result check
- $client->send($handshake); - $response = $client->recv(8192); + if ($client->send($handshake) === false) { + $errors++; + $client->close(); + continue; + } + $response = $client->recv(8192);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@benchmarks/tcp.php` around lines 396 - 397, In non-persistent mode the result of $client->send($handshake) is not checked, so failed sends are not counted; update the block around $client->send and $client->recv to capture the return value of $client->send($handshake), and if it returns false (or a non-positive value) increment the same error counter used elsewhere (e.g., $errors++), close or cleanup $client as appropriate, and skip calling $client->recv so the failure is recorded consistently with the persistent-mode handling.examples/http-proxy.php-59-64 (1)
59-64:⚠️ Potential issue | 🟡 MinorConnection count can become negative.
The decrement logic only checks if the key exists but doesn't prevent the count from going negative if
onDisconnectis called more times thanonConnectfor a given resource.Proposed fix
public function onDisconnect(string $resourceId, array $metadata = []): void { - if (isset($this->connectionCounts[$resourceId])) { + if (isset($this->connectionCounts[$resourceId]) && $this->connectionCounts[$resourceId] > 0) { $this->connectionCounts[$resourceId]--; } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@examples/http-proxy.php` around lines 59 - 64, The onDisconnect method can drive connectionCounts[resourceId] negative if disconnects outnumber connects; modify onDisconnect (in the class where connectionCounts is maintained) to check that the current count is greater than zero before decrementing and, after decrementing, remove the key or set it to zero when it reaches zero to keep counts non-negative and avoid stale entries. Ensure you reference connectionCounts and onDisconnect when making the change.proxies/smtp.php-14-18 (1)
14-18:⚠️ Potential issue | 🟡 MinorUse an unprivileged default port for the standalone example.
The documented
php ...flow binds to port 25, which typically needs elevated privileges. Most contributors won't be able to run the example as written. Default to something like 2525 and keep 25 opt-in via env.💡 Small tweak
$config = [ // Server settings 'host' => '0.0.0.0', - 'port' => 25, + 'port' => (int) (getenv('SMTP_PORT') ?: 2525), 'workers' => swoole_cpu_num() * 2,Also applies to: 65-69
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@proxies/smtp.php` around lines 14 - 18, Update the standalone SMTP example to use an unprivileged default port (2525) instead of 25: change any hardcoded references and documentation strings ("php examples/smtp.php" usage and "telnet localhost 25") to use 2525, and modify the code that sets the bind port (e.g., the $port or PORT variable) to read an environment variable like SMTP_PORT with a default of 2525 so port 25 remains opt-in via env.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7e8dba5d-6ac1-407c-a016-c55a51b61b37
📒 Files selected for processing (77)
.dockerignore.env.example.github/workflows/integration.yml.github/workflows/lint.yml.github/workflows/static-analysis.yml.github/workflows/tests.yml.gitignoreDockerfileDockerfile.testPERFORMANCE.mdREADME.mdbenchmarks/README.mdbenchmarks/bootstrap-droplet.shbenchmarks/compare-http-servers.shbenchmarks/compare-tcp-servers.shbenchmarks/http-backend.phpbenchmarks/http-benchmark.phpbenchmarks/http.phpbenchmarks/setup-linux-production.shbenchmarks/setup-linux.shbenchmarks/stress-max.shbenchmarks/tcp-backend.phpbenchmarks/tcp-benchmark.phpbenchmarks/tcp-sustained.phpbenchmarks/tcp.phpbenchmarks/test-bootstrap.shbenchmarks/wrk.shbenchmarks/wrk2.shcomposer.jsondocker-compose.dev.ymldocker-compose.integration.ymldocker-compose.ymlexamples/http-edge-integration.phpexamples/http-proxy.phpexamples/smtp-proxy.phpexamples/tcp-proxy.phpphpunit.xmlpint.jsonproxies/http.phpproxies/smtp.phpproxies/tcp.phpsrc/Adapter.phpsrc/Adapter/TCP.phpsrc/ConnectionManager.phpsrc/ConnectionResult.phpsrc/Http/HttpConnectionManager.phpsrc/Http/HttpServer.phpsrc/Protocol.phpsrc/Resolver.phpsrc/Resolver/Exception.phpsrc/Resolver/ReadWriteResolver.phpsrc/Resolver/Result.phpsrc/Resource.phpsrc/ResourceStatus.phpsrc/Server/HTTP/Swoole.phpsrc/Server/HTTP/SwooleCoroutine.phpsrc/Server/SMTP/Swoole.phpsrc/Server/TCP/Config.phpsrc/Server/TCP/Swoole.phpsrc/Server/TCP/SwooleCoroutine.phpsrc/Server/TCP/TLS.phpsrc/Server/TCP/TlsContext.phpsrc/Smtp/SmtpConnectionManager.phpsrc/Tcp/TcpConnectionManager.phpsrc/Tcp/TcpServer.phptests/AdapterActionsTest.phptests/AdapterMetadataTest.phptests/AdapterStatsTest.phptests/ConnectionResultTest.phptests/Integration/EdgeIntegrationTest.phptests/MockReadWriteResolver.phptests/MockResolver.phptests/Performance/PerformanceTest.phptests/QueryParserTest.phptests/ReadWriteSplitTest.phptests/ResolverTest.phptests/TCPAdapterTest.php
💤 Files with no reviewable changes (12)
- benchmarks/http-benchmark.php
- src/Smtp/SmtpConnectionManager.php
- src/Http/HttpServer.php
- src/ConnectionManager.php
- examples/tcp-proxy.php
- examples/smtp-proxy.php
- src/Resource.php
- src/Http/HttpConnectionManager.php
- src/Tcp/TcpConnectionManager.php
- benchmarks/tcp-benchmark.php
- src/ResourceStatus.php
- src/Tcp/TcpServer.php
Dockerfile
Outdated
|
|
||
| WORKDIR /app | ||
|
|
||
| COPY composer.json composer.lock ./ |
There was a problem hiding this comment.
Build failing: composer.lock not found.
The CI pipeline is failing because composer.lock doesn't exist in the repository. Either commit the lock file or modify the COPY command.
🐛 Proposed fix
Option 1 - If composer.lock exists but isn't tracked:
git add composer.lock && git commit -m "Add composer.lock"Option 2 - If you intentionally don't want to track the lock file:
-COPY composer.json composer.lock ./
+COPY composer.json ./
+RUN composer install --no-dev --optimize-autoloader \🧰 Tools
🪛 GitHub Actions: Tests
[error] 28-28: Build failed during COPY: failed to compute cache key: failed to calculate checksum of ref ... "/composer.lock": not found
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Dockerfile` at line 28, The Dockerfile's COPY step references composer.lock
which does not exist causing CI to fail; either add and commit composer.lock to
the repo (git add composer.lock && git commit) so the existing COPY
composer.json composer.lock ./ works, or update the Dockerfile to stop copying
composer.lock (change the COPY line to only COPY composer.json ./ or add a
conditional/copy pattern that won't fail if composer.lock is absent) so builds
no longer error; locate the COPY composer.json composer.lock ./ line in the
Dockerfile to apply the chosen fix.
| protected function parseMySQLDatabaseId(string $data): string | ||
| { | ||
| // MySQL COM_INIT_DB packet (0x02) | ||
| $len = strlen($data); | ||
| if ($len <= 5 || \ord($data[4]) !== 0x02) { | ||
| throw new \Exception('Invalid MySQL database name'); | ||
| } |
There was a problem hiding this comment.
The MySQL startup parser is looking at the wrong packet type.
This method is used during connection setup, but a normal MySQL client sends its default schema in the handshake response packet, not in COM_INIT_DB. Rejecting everything except command 0x02 means standard clients still won't be routable even after the server-first greeting exists.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Adapter/TCP.php` around lines 312 - 318, The parser in
parseMySQLDatabaseId is incorrectly treating the packet as COM_INIT_DB (0x02);
instead, detect and parse the MySQL Handshake Response (client response) by
reading the 4-byte little-endian capability flags at the start of $data, check
the CLIENT_CONNECT_WITH_DB flag (0x00000008), then locate the database string by
advancing past the username and auth-response fields according to the handshake
response format (skip username NUL, read auth-response length/bytes, then the
optional null-terminated database); throw on missing CLIENT_CONNECT_WITH_DB or
malformed layout and return the extracted database name.
| public function getBackendConnection(string $databaseId, int $clientFd): Client | ||
| { | ||
| // Check if we already have a connection for this database | ||
| $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; | ||
|
|
||
| if (isset($this->backendConnections[$cacheKey])) { | ||
| return $this->backendConnections[$cacheKey]; | ||
| } | ||
|
|
||
| // Get backend endpoint via routing | ||
| $result = $this->route($databaseId); | ||
|
|
||
| // Create new TCP connection to backend | ||
| [$host, $port] = \explode(':', $result->endpoint.':'.$this->port); |
There was a problem hiding this comment.
Use the writer route for the primary backend socket.
When read/write split is enabled, this still resolves the long-lived backend connection with route(). If resolve() is not identical to resolveWrite(), every write and transaction-pinned query can be sent to the wrong backend.
Patch sketch
- $result = $this->route($databaseId);
+ $result = $this->readWriteSplit && $this->resolver instanceof ReadWriteResolver
+ ? $this->routeWrite($databaseId)
+ : $this->route($databaseId);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Adapter/TCP.php` around lines 430 - 443, getBackendConnection currently
always uses the generic route() result for the long-lived backend socket, which
can send writes/transaction-pinned queries to the wrong host when read/write
split is enabled; change it to choose the writer/primary endpoint (e.g., call
the resolver that returns the write/primary route such as resolveWrite() or
routeWrite()) when the connection will be used for writes or when a transaction
is pinned, otherwise keep using the read route; update getBackendConnection to
detect read/write-split mode and transaction-pinned state and call the
write-resolving method (reference getBackendConnection, route(),
resolveWrite()/routeWrite(), and resolve()) so the cached backendConnections use
the correct primary socket for writes.
| /** @var array<string, string> $requestServer */ | ||
| $requestServer = $request->server ?? []; | ||
| $method = strtoupper($requestServer['request_method'] ?? 'GET'); | ||
| $path = $requestServer['request_uri'] ?? '/'; |
There was a problem hiding this comment.
Preserve the query string when proxying.
Both forwarders rebuild the backend target from the path only. A request like /search?q=foo is forwarded as /search, so the proxy changes the resource being fetched.
Patch sketch
- $path = $requestServer['request_uri'] ?? '/';
+ $path = $requestServer['request_uri'] ?? '/';
+ $query = $requestServer['query_string'] ?? '';
+ if ($query !== '') {
+ $path .= '?'.$query;
+ }Apply the same change in the raw forwarder before composing $requestLine.
Also applies to: 447-451
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Server/HTTP/Swoole.php` around lines 321 - 324, The proxy currently drops
the query string because $path is built from $requestServer['request_uri']
without preserving the query; update the logic in Swoole.php where $path is
derived (the block setting $requestServer, $method, $path) to include the query
string when present (e.g., use REQUEST_URI or combine path and query) and apply
the same fix in the raw forwarder before composing $requestLine (the code around
lines that build $requestLine) so forwarded requests like /search?q=foo keep
?q=foo; ensure both forwarder paths reference the same $path variable that
includes the query string.
| /** @var array<string, string> $requestServer */ | ||
| $requestServer = $request->server ?? []; | ||
| $method = strtoupper($requestServer['request_method'] ?? 'GET'); | ||
| $path = $requestServer['request_uri'] ?? '/'; |
There was a problem hiding this comment.
Preserve the query string when proxying.
Both forwarders rebuild the backend target from the path only. A request like /search?q=foo is forwarded as /search, so the proxy changes the resource being fetched.
Patch sketch
- $path = $requestServer['request_uri'] ?? '/';
+ $path = $requestServer['request_uri'] ?? '/';
+ $query = $requestServer['query_string'] ?? '';
+ if ($query !== '') {
+ $path .= '?'.$query;
+ }Apply the same change in the raw forwarder before composing $requestLine.
Also applies to: 425-429
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Server/HTTP/SwooleCoroutine.php` around lines 299 - 302, The proxy
currently drops the query string when reconstructing the backend target from
$requestServer['request_uri']; fix by appending the query string when present
(e.g., if (!empty($requestServer['query_string'])) { $path .= '?' .
$requestServer['query_string']; }) where $path is set (around the $method/$path
assignment) and apply the same change in the raw forwarder before composing
$requestLine so both forwarders preserve queries (refer to $requestServer,
$path, and the code that builds $requestLine).
| public function onConnect(Server $server, int $fd, int $reactorId): void | ||
| { | ||
| /** @var array<string, mixed> $info */ | ||
| $info = $server->getClientInfo($fd); | ||
| /** @var int $port */ | ||
| $port = $info['server_port'] ?? 0; | ||
| $this->clientPorts[$fd] = $port; | ||
|
|
||
| if ($this->config->logConnections) { | ||
| echo "Client #{$fd} connected to port {$port}\n"; | ||
| } | ||
| } |
There was a problem hiding this comment.
3306 never emits the required MySQL greeting.
The proxy waits for client bytes and then tries to parse a database ID, but MySQL is server-first. A normal client will stall at connect because it expects the proxy to send the initial handshake packet before any routable payload exists.
Also applies to: 289-295
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 193-193: Avoid unused parameters such as '$reactorId'. (undefined)
(UnusedFormalParameter)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Server/TCP/Swoole.php` around lines 193 - 204, The onConnect handler
currently only records client port and logs the connection but does not emit the
MySQL server handshake, causing clients (which expect a server-first greeting)
to stall; update onConnect (and the similar code for lines 289-295) to build and
send a proper MySQL initial handshake packet immediately after storing
$this->clientPorts[$fd] (using $server->send($fd, $handshakePayload) or the
appropriate Swoole send call) — include server version, connection id,
capability flags, charset, and auth-salt bytes consistent with MySQL protocol so
clients receive the greeting before any client-side payload is parsed.
| // If read/write split is enabled, establish read replica connection | ||
| if ($adapter->isReadWriteSplit() && $this->resolver instanceof ReadWriteResolver) { | ||
| try { | ||
| $readResult = $adapter->routeQuery($databaseId, QueryType::Read); | ||
| $readEndpoint = $readResult->endpoint; | ||
| [$readHost, $readPort] = \explode(':', $readEndpoint . ':' . $port); | ||
|
|
||
| // Only create separate read connection if it differs from the write endpoint | ||
| $writeResult = $adapter->routeQuery($databaseId, QueryType::Write); | ||
| if ($readEndpoint !== $writeResult->endpoint) { | ||
| $readClient = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP); | ||
| $readClient->set([ | ||
| 'timeout' => $this->config->backendConnectTimeout, | ||
| 'connect_timeout' => $this->config->backendConnectTimeout, | ||
| 'open_tcp_nodelay' => true, | ||
| 'socket_buffer_size' => 2 * 1024 * 1024, | ||
| ]); | ||
|
|
||
| if ($readClient->connect($readHost, (int) $readPort, $this->config->backendConnectTimeout)) { | ||
| $this->readBackendClients[$fd] = $readClient; | ||
| // Forward initial startup message to read replica too | ||
| $readClient->send($data); | ||
| // Start forwarding from read replica back to client | ||
| $this->startForwarding($server, $fd, $readClient); |
There was a problem hiding this comment.
Don't attach one frontend session to two backend sessions.
The startup packet is sent to both the primary and replica, and both backends start streaming responses into the same client FD. That duplicates startup/auth frames and lets later read/write replies race out of order, which breaks database session semantics.
Also applies to: 334-339
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Server/TCP/Swoole.php` around lines 297 - 320, The code currently assigns
the same frontend session FD to both write and read backends by storing
$this->readBackendClients[$fd], sending the startup packet
($readClient->send($data)), and calling $this->startForwarding($server, $fd,
$readClient); instead only create/connect the read replica socket but do NOT
attach it to the frontend FD, do NOT send the initial startup/auth packet to it,
and do NOT call startForwarding for it; instead keep the read client privately
managed (or indexed by a replica-specific key) and route read queries explicitly
through it from your proxy logic so only the primary write backend is streaming
responses into the frontend FD (apply the same change for the other occurrence
at the 334-339 block).
| public function toSwooleConfig(): array | ||
| { | ||
| $config = [ | ||
| 'ssl_cert_file' => $this->tls->certPath, | ||
| 'ssl_key_file' => $this->tls->keyPath, | ||
| 'ssl_protocols' => $this->tls->minProtocol, | ||
| 'ssl_ciphers' => $this->tls->ciphers, | ||
| 'ssl_allow_self_signed' => false, | ||
| ]; | ||
|
|
||
| if ($this->tls->caPath !== '') { | ||
| $config['ssl_client_cert_file'] = $this->tls->caPath; | ||
| } | ||
|
|
||
| if ($this->tls->requireClientCert) { | ||
| $config['ssl_verify_peer'] = true; | ||
| $config['ssl_verify_depth'] = 10; | ||
| } else { | ||
| $config['ssl_verify_peer'] = false; | ||
| } | ||
|
|
||
| return $config; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how minProtocol is defined in the TLS class
ast-grep --pattern $'class TLS {
$$$
}'Repository: utopia-php/protocol-proxy
Length of output: 6982
🏁 Script executed:
# Search for Swoole ssl_protocols usage or documentation in the codebase
rg -i "ssl_protocols" --type php -A 2 -B 2Repository: utopia-php/protocol-proxy
Length of output: 474
🌐 Web query:
Swoole ssl_protocols configuration parameter format TLS
💡 Result:
ssl_protocols in Swoole/OpenSwoole is an integer bitmask: you bitwise-OR (|) the protocol constants you want to allow (not a string like "TLSv1.2 TLSv1.3"). By default, all protocols are enabled. [1]
Example (allow only TLS 1.2 + TLS 1.3):
$server->set([
'ssl_protocols' => SWOOLE_SSL_TLSv1_2 | SWOOLE_SSL_TLSv1_3,
]);Supported values include: SWOOLE_SSL_TLSv1, SWOOLE_SSL_TLSv1_1, SWOOLE_SSL_TLSv1_2, SWOOLE_SSL_TLSv1_3, SWOOLE_SSL_SSLv2. [1]
(These constants are also listed in the PHP manual, e.g. SWOOLE_SSL_TLSv1_2.) [2]
Sources: [1] OpenSwoole docs – Swoole Server ssl_protocols configuration, [2] PHP manual – Swoole predefined constants.
Fix ssl_protocols to allow TLS 1.2 and above, not only TLS 1.2.
Swoole's ssl_protocols expects a bitmask of allowed protocol constants. Currently, $this->tls->minProtocol is set to a single constant (SWOOLE_SSL_TLSv1_2), which disables TLS 1.3. For a "minimum" version, use a bitmask like SWOOLE_SSL_TLSv1_2 | SWOOLE_SSL_TLSv1_3 (or include other versions as needed).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/Server/TCP/TlsContext.php` around lines 42 - 64, The ssl_protocols value
in toSwooleConfig currently uses $this->tls->minProtocol as a single constant
(e.g., SWOOLE_SSL_TLSv1_2) which excludes newer protocols; change to provide a
bitmask of allowed protocols instead (e.g., combine SWOOLE_SSL_TLSv1_2 |
SWOOLE_SSL_TLSv1_3 or build the mask programmatically based on
$this->tls->minProtocol) so that toSwooleConfig returns ssl_protocols as a
bitwise OR of supported SWOOLE_SSL_* constants rather than a single constant.
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (2)
Dockerfile (1)
30-30: Pin the Composer image instead of usinglatest.
composer:latestmakes this build non-reproducible and can break on upstream image changes. Please pin a Composer version or digest here.Proposed change
-COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Dockerfile` at line 30, The Dockerfile currently references the moving tag "composer:latest" in the COPY --from stage; replace that floating tag with a pinned Composer image tag or digest (e.g., a specific version tag or sha256 digest) so the build is reproducible; update the COPY --from source to use the chosen pinned reference and, if desired, add a build ARG like COMPOSER_VERSION to centralize the version choice for future updates.tests/AdapterStatsTest.php (1)
29-32: Avoid synchronizing this test to the wall clock.This adds up to a second of delay per run and still relies on scheduler timing around the cache TTL boundary. A controllable clock/timestamp seam in the adapter/router would make the cache-hit path deterministic and keep the suite fast.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/AdapterStatsTest.php` around lines 29 - 32, The test currently syncs to the wall clock using time() and usleep() which adds delays and flakiness; remove this loop and make the timestamp deterministic by introducing a controllable clock/seam: add or use a Clock/TimeProvider interface in the adapter/router (or a timestamp parameter on the cache-check method used in AdapterStatsTest) and in the test inject a fixed/mock clock that returns the desired times so the cache-hit/path and TTL behavior are exercised deterministically without sleeping; update AdapterStatsTest to use the mock Clock/TimeProvider (or pass explicit timestamps) instead of calling time() and usleep().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Dockerfile`:
- Around line 36-40: The container currently runs as root at runtime; create and
switch to a non-root user in the Dockerfile, ensure application files are owned
by that user, and switch to that user before the CMD that launches the proxy
(referencing the EXPOSE lines and CMD ["php", "proxies/http.php"]).
Specifically: add a non-root user/group (e.g., create user "app" or "proxy"),
chown the application directory and any runtime directories to that user, and
add a USER instruction prior to the CMD so the proxy runs as the dedicated user
instead of root; no capability changes are needed because the proxy binds only
to unprivileged ports (8080, 8081, 8025).
- Around line 31-34: Remove the --ignore-platform-reqs flag from the Composer
install command in the Dockerfile so Composer validates required PHP extensions
(ext-swoole, ext-redis) at build time; update the RUN composer install
invocation (the existing "RUN composer install \ --no-dev \
--optimize-autoloader \ --ignore-platform-reqs" command) to omit
--ignore-platform-reqs and ensure the image installs/ENABLED the ext-swoole and
ext-redis extensions before running that Composer step so platform checks
succeed.
In `@tests/Integration/EdgeIntegrationTest.php`:
- Around line 492-525: The test testConcurrentResolutionOfMultipleDatabases is
currently invoking $adapter->route() sequentially in a loop so it doesn't
exercise concurrency; change the test to invoke TCPAdapter::route concurrently
(e.g., spawn N parallel tasks/runtimes/workers or use an async framework)
against the same EdgeMockResolver registrations, collect/join all results before
asserting, then perform the same endpoint/protocol and getStats() assertions to
verify cache behavior under true concurrent resolution; apply the same change to
the other similar test referenced (the block around 530-566) so both actually
run route() in parallel rather than one-by-one.
- Around line 442-452: The test currently proves TTL expiry, not invalidation,
because Adapter::route has its own 1s routing cache; instead of sleeping, change
the test to exercise the cache owner's invalidation path directly: after calling
EdgeMockResolver::purge('invaldb') assert that the resolver recorded the purge
(e.g., check whatever log/record EdgeMockResolver stores) and then force the
adapter to bypass/clear its internal routing cache before calling
Adapter::route('invaldb') (use the adapter's public cache-clear/invalidate
method or add one if missing) and finally assert getResolveCount() increased to
2; alternatively remove the sleep and assert the resolver-side purge effect
directly by inspecting EdgeMockResolver's recorded purges and that a subsequent
resolution used the resolver rather than relying on TTL expiry.
- Around line 909-920: The resolve method currently catches all
ResolverException and fails over every error; change it to only fail over on
transient availability errors (e.g., UNAVAILABLE, TIMEOUT) and rethrow for
definitive errors (e.g., NOT_FOUND, FORBIDDEN). In the resolve(string
$resourceId): Result implementation, after catching ResolverException $e,
inspect the exception's status/code (from the ResolverException API) and if it
indicates a transient availability issue set $this->failedOver = true and return
$this->secondary->resolve($resourceId); otherwise rethrow $e so primary->resolve
and secondary->resolve behavior is preserved for definitive errors. Ensure
$this->failedOver is only set when actually failing over.
- Around line 928-960: The wrapper currently records
onConnect/onDisconnect/track calls into local arrays but never forwards them to
the actual resolver that served resolve(), so child resolvers' cleanup/metrics
are bypassed; modify resolve() to record which underlying resolver instance
(primary or secondary) was chosen for each resourceId (e.g. persist a map like
$this->resolvedMap[$resourceId] = $chosenResolver) and update onConnect,
onDisconnect, track, purge to look up the resolver from that map and delegate
the call to that resolver (fall back to existing local arrays only if no mapping
exists); ensure purge also removes the mapping and still calls
$this->primary->purge/$this->secondary->purge as needed and keep getStats()
returning child resolvers' stats while optionally including wrapper counters.
In `@tests/QueryParserTest.php`:
- Around line 620-682: The micro-benchmarks testParsePerformance and
testClassifySqlPerformance are flaky in CI; move them out of the blocking
PHPUnit suite by making them opt-in: either add a PHPUnit group annotation
(e.g., `@group` performance) on the test methods (testParsePerformance,
testClassifySqlPerformance) so they can be excluded from default runs, or gate
their execution at runtime by checking an env var (e.g., if
(!getenv('RUN_PERF_TESTS')) $this->markTestSkipped('Performance tests
disabled');). Ensure references to pgParser->parse and pgParser->classifySQL
(and mysqlParser->parse) remain intact so the benchmarks still run when
explicitly enabled.
- Around line 575-579: The test testClassifyCteNoFinalKeyword is asserting that
pgParser->classifySQL('WITH x AS (SELECT 1)') returns QueryType::Read, but that
WITH-only input is incomplete and should be classified as QueryType::Unknown to
match the runtime safety in src/Adapter/TCP (which treats unknown as
primary-only); update the expectation in testClassifyCteNoFinalKeyword to assert
QueryType::Unknown from pgParser->classifySQL instead of QueryType::Read.
---
Nitpick comments:
In `@Dockerfile`:
- Line 30: The Dockerfile currently references the moving tag "composer:latest"
in the COPY --from stage; replace that floating tag with a pinned Composer image
tag or digest (e.g., a specific version tag or sha256 digest) so the build is
reproducible; update the COPY --from source to use the chosen pinned reference
and, if desired, add a build ARG like COMPOSER_VERSION to centralize the version
choice for future updates.
In `@tests/AdapterStatsTest.php`:
- Around line 29-32: The test currently syncs to the wall clock using time() and
usleep() which adds delays and flakiness; remove this loop and make the
timestamp deterministic by introducing a controllable clock/seam: add or use a
Clock/TimeProvider interface in the adapter/router (or a timestamp parameter on
the cache-check method used in AdapterStatsTest) and in the test inject a
fixed/mock clock that returns the desired times so the cache-hit/path and TTL
behavior are exercised deterministically without sleeping; update
AdapterStatsTest to use the mock Clock/TimeProvider (or pass explicit
timestamps) instead of calling time() and usleep().
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 08062e47-f934-4dd5-a61e-fac924f3ebad
📒 Files selected for processing (11)
DockerfilePERFORMANCE.mdtests/AdapterActionsTest.phptests/AdapterMetadataTest.phptests/AdapterStatsTest.phptests/ConnectionResultTest.phptests/Integration/EdgeIntegrationTest.phptests/QueryParserTest.phptests/ReadWriteSplitTest.phptests/ResolverTest.phptests/TCPAdapterTest.php
💤 Files with no reviewable changes (1)
- PERFORMANCE.md
🚧 Files skipped from review as they are similar to previous changes (5)
- tests/ResolverTest.php
- tests/TCPAdapterTest.php
- tests/ConnectionResultTest.php
- tests/AdapterActionsTest.php
- tests/ReadWriteSplitTest.php
| RUN composer install \ | ||
| --no-dev \ | ||
| --optimize-autoloader \ | ||
| --ignore-platform-reqs |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
git ls-files | grep -i dockerfile | head -20Repository: utopia-php/protocol-proxy
Length of output: 79
🏁 Script executed:
cat -n Dockerfile | head -50Repository: utopia-php/protocol-proxy
Length of output: 1089
🏁 Script executed:
cat composer.jsonRepository: utopia-php/protocol-proxy
Length of output: 1895
Remove --ignore-platform-reqs to validate required extensions.
The Dockerfile installs ext-swoole and ext-redis before running Composer, and composer.json explicitly requires both extensions. Using --ignore-platform-reqs suppresses Composer's platform validation, masking installation failures and deferring errors to runtime. Remove the flag to ensure Composer verifies that the required extensions are actually available.
Proposed change
RUN composer install \
--no-dev \
- --optimize-autoloader \
- --ignore-platform-reqs
+ --optimize-autoloader📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| RUN composer install \ | |
| --no-dev \ | |
| --optimize-autoloader \ | |
| --ignore-platform-reqs | |
| RUN composer install \ | |
| --no-dev \ | |
| --optimize-autoloader |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Dockerfile` around lines 31 - 34, Remove the --ignore-platform-reqs flag from
the Composer install command in the Dockerfile so Composer validates required
PHP extensions (ext-swoole, ext-redis) at build time; update the RUN composer
install invocation (the existing "RUN composer install \ --no-dev \
--optimize-autoloader \ --ignore-platform-reqs" command) to omit
--ignore-platform-reqs and ensure the image installs/ENABLED the ext-swoole and
ext-redis extensions before running that Composer step so platform checks
succeed.
| COPY . . | ||
|
|
||
| EXPOSE 8080 8081 8025 | ||
|
|
||
| CMD ["php", "proxies/http.php"] |
There was a problem hiding this comment.
Drop root before starting the proxy.
This container still runs as root at runtime. The proxy only binds to unprivileged ports, so it should run as a dedicated user instead of keeping full container privileges.
Proposed change
+RUN addgroup -S app && adduser -S -G app app
+
-COPY . .
+COPY --chown=app:app . .
+RUN chown -R app:app /app
EXPOSE 8080 8081 8025
+USER app
CMD ["php", "proxies/http.php"]🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@Dockerfile` around lines 36 - 40, The container currently runs as root at
runtime; create and switch to a non-root user in the Dockerfile, ensure
application files are owned by that user, and switch to that user before the CMD
that launches the proxy (referencing the EXPOSE lines and CMD ["php",
"proxies/http.php"]). Specifically: add a non-root user/group (e.g., create user
"app" or "proxy"), chown the application directory and any runtime directories
to that user, and add a USER instruction prior to the CMD so the proxy runs as
the dedicated user instead of root; no capability changes are needed because the
proxy binds only to unprivileged ports (8080, 8081, 8025).
| // Invalidate the resolver cache | ||
| $resolver->purge('invaldb'); | ||
|
|
||
| // Wait for the routing table cache to expire (1 second TTL) | ||
| sleep(2); | ||
|
|
||
| $second = $adapter->route('invaldb'); | ||
| $this->assertFalse($second->metadata['cached']); | ||
|
|
||
| // Should have resolved twice | ||
| $this->assertSame(2, $resolver->getResolveCount()); |
There was a problem hiding this comment.
This test passes even if invalidation is broken.
src/Adapter/TCP.php:490-520 keeps a separate 1-second routing cache inside the adapter, and sleep(2) guarantees that cache expires before the second route() call. On top of that, EdgeMockResolver::purge() only records the resource ID. So this assertion proves TTL expiry, not cache invalidation. Exercise the cache owner's invalidation path directly, or assert the resolver-side purge effect instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/Integration/EdgeIntegrationTest.php` around lines 442 - 452, The test
currently proves TTL expiry, not invalidation, because Adapter::route has its
own 1s routing cache; instead of sleeping, change the test to exercise the cache
owner's invalidation path directly: after calling
EdgeMockResolver::purge('invaldb') assert that the resolver recorded the purge
(e.g., check whatever log/record EdgeMockResolver stores) and then force the
adapter to bypass/clear its internal routing cache before calling
Adapter::route('invaldb') (use the adapter's public cache-clear/invalidate
method or add one if missing) and finally assert getResolveCount() increased to
2; alternatively remove the sleep and assert the resolver-side purge effect
directly by inspecting EdgeMockResolver's recorded purges and that a subsequent
resolution used the resolver rather than relying on TTL expiry.
| public function testConcurrentResolutionOfMultipleDatabases(): void | ||
| { | ||
| $resolver = new EdgeMockResolver(); | ||
| $databaseCount = 20; | ||
|
|
||
| for ($i = 1; $i <= $databaseCount; $i++) { | ||
| $resolver->registerDatabase("concurrent{$i}", [ | ||
| 'host' => "10.0.10.{$i}", | ||
| 'port' => 5432, | ||
| 'username' => "user_{$i}", | ||
| 'password' => "pass_{$i}", | ||
| ]); | ||
| } | ||
|
|
||
| $adapter = new TCPAdapter($resolver, port: 5432); | ||
| $adapter->setSkipValidation(true); | ||
|
|
||
| $results = []; | ||
| for ($i = 1; $i <= $databaseCount; $i++) { | ||
| $results[$i] = $adapter->route("concurrent{$i}"); | ||
| } | ||
|
|
||
| // Verify each database resolved to its correct endpoint | ||
| for ($i = 1; $i <= $databaseCount; $i++) { | ||
| $this->assertSame("10.0.10.{$i}:5432", $results[$i]->endpoint); | ||
| $this->assertSame(Protocol::PostgreSQL, $results[$i]->protocol); | ||
| } | ||
|
|
||
| // All should have been cache misses (first resolution) | ||
| $stats = $adapter->getStats(); | ||
| $this->assertSame($databaseCount, $stats['cacheMisses']); | ||
| $this->assertSame(0, $stats['cacheHits']); | ||
| $this->assertSame($databaseCount, $stats['routingTableSize']); | ||
| } |
There was a problem hiding this comment.
These “concurrent” tests are fully sequential.
Both methods call route() one request at a time in a plain loop/try-catch, so there is no overlapping work here. That leaves the concurrency path untested while the test names claim otherwise.
Also applies to: 530-566
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/Integration/EdgeIntegrationTest.php` around lines 492 - 525, The test
testConcurrentResolutionOfMultipleDatabases is currently invoking
$adapter->route() sequentially in a loop so it doesn't exercise concurrency;
change the test to invoke TCPAdapter::route concurrently (e.g., spawn N parallel
tasks/runtimes/workers or use an async framework) against the same
EdgeMockResolver registrations, collect/join all results before asserting, then
perform the same endpoint/protocol and getStats() assertions to verify cache
behavior under true concurrent resolution; apply the same change to the other
similar test referenced (the block around 530-566) so both actually run route()
in parallel rather than one-by-one.
| public function resolve(string $resourceId): Result | ||
| { | ||
| $this->failedOver = false; | ||
|
|
||
| try { | ||
| return $this->primary->resolve($resourceId); | ||
| } catch (ResolverException $e) { | ||
| $this->failedOver = true; | ||
|
|
||
| // Try secondary; let its exception propagate if it also fails | ||
| return $this->secondary->resolve($resourceId); | ||
| } |
There was a problem hiding this comment.
Fail over only on transient availability errors.
Catching every ResolverException means primary NOT_FOUND or FORBIDDEN responses are retried against the secondary. That can turn a definitive miss/deny into a successful route and makes the failover tests assert the wrong contract. Restrict failover to transient availability failures such as UNAVAILABLE/TIMEOUT and rethrow the rest.
Suggested guard
try {
return $this->primary->resolve($resourceId);
} catch (ResolverException $e) {
+ if (!\in_array($e->getCode(), [ResolverException::UNAVAILABLE, ResolverException::TIMEOUT], true)) {
+ throw $e;
+ }
+
$this->failedOver = true;
// Try secondary; let its exception propagate if it also fails
return $this->secondary->resolve($resourceId);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public function resolve(string $resourceId): Result | |
| { | |
| $this->failedOver = false; | |
| try { | |
| return $this->primary->resolve($resourceId); | |
| } catch (ResolverException $e) { | |
| $this->failedOver = true; | |
| // Try secondary; let its exception propagate if it also fails | |
| return $this->secondary->resolve($resourceId); | |
| } | |
| public function resolve(string $resourceId): Result | |
| { | |
| $this->failedOver = false; | |
| try { | |
| return $this->primary->resolve($resourceId); | |
| } catch (ResolverException $e) { | |
| if (!\in_array($e->getCode(), [ResolverException::UNAVAILABLE, ResolverException::TIMEOUT], true)) { | |
| throw $e; | |
| } | |
| $this->failedOver = true; | |
| // Try secondary; let its exception propagate if it also fails | |
| return $this->secondary->resolve($resourceId); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/Integration/EdgeIntegrationTest.php` around lines 909 - 920, The
resolve method currently catches all ResolverException and fails over every
error; change it to only fail over on transient availability errors (e.g.,
UNAVAILABLE, TIMEOUT) and rethrow for definitive errors (e.g., NOT_FOUND,
FORBIDDEN). In the resolve(string $resourceId): Result implementation, after
catching ResolverException $e, inspect the exception's status/code (from the
ResolverException API) and if it indicates a transient availability issue set
$this->failedOver = true and return $this->secondary->resolve($resourceId);
otherwise rethrow $e so primary->resolve and secondary->resolve behavior is
preserved for definitive errors. Ensure $this->failedOver is only set when
actually failing over.
| public function onConnect(string $resourceId, array $metadata = []): void | ||
| { | ||
| $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; | ||
| } | ||
|
|
||
| public function onDisconnect(string $resourceId, array $metadata = []): void | ||
| { | ||
| $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; | ||
| } | ||
|
|
||
| public function track(string $resourceId, array $metadata = []): void | ||
| { | ||
| $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; | ||
| } | ||
|
|
||
| public function purge(string $resourceId): void | ||
| { | ||
| $this->invalidations[] = $resourceId; | ||
| $this->primary->purge($resourceId); | ||
| $this->secondary->purge($resourceId); | ||
| } | ||
|
|
||
| /** | ||
| * @return array<string, mixed> | ||
| */ | ||
| public function getStats(): array | ||
| { | ||
| return [ | ||
| 'resolver' => 'edge-failover', | ||
| 'failedOver' => $this->failedOver, | ||
| 'primary' => $this->primary->getStats(), | ||
| 'secondary' => $this->secondary->getStats(), | ||
| ]; |
There was a problem hiding this comment.
Lifecycle callbacks stop at the wrapper.
src/Adapter.php:75-142 sends connect/close/track events to the resolver wired into the adapter. Here those callbacks are only appended to local arrays, while getStats() reports the child resolvers' stats instead of these local counters. So the resolver that actually served resolve() never sees lifecycle callbacks and its cleanup/metrics logic is bypassed. Persist the chosen resolver per resourceId during resolve() and delegate subsequent lifecycle calls to it.
One way to wire the callbacks through
class EdgeFailoverResolver implements Resolver
{
protected bool $failedOver = false;
+ /** `@var` array<string, Resolver> */
+ protected array $activeResolvers = [];
@@
public function resolve(string $resourceId): Result
{
$this->failedOver = false;
try {
- return $this->primary->resolve($resourceId);
+ $result = $this->primary->resolve($resourceId);
+ $this->activeResolvers[$resourceId] = $this->primary;
+
+ return $result;
} catch (ResolverException $e) {
$this->failedOver = true;
// Try secondary; let its exception propagate if it also fails
- return $this->secondary->resolve($resourceId);
+ $result = $this->secondary->resolve($resourceId);
+ $this->activeResolvers[$resourceId] = $this->secondary;
+
+ return $result;
}
}
@@
public function onConnect(string $resourceId, array $metadata = []): void
{
$this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata];
+ ($this->activeResolvers[$resourceId] ?? $this->primary)->onConnect($resourceId, $metadata);
}
@@
public function onDisconnect(string $resourceId, array $metadata = []): void
{
$this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata];
+ ($this->activeResolvers[$resourceId] ?? $this->primary)->onDisconnect($resourceId, $metadata);
+ unset($this->activeResolvers[$resourceId]);
}
@@
public function track(string $resourceId, array $metadata = []): void
{
$this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata];
+ ($this->activeResolvers[$resourceId] ?? $this->primary)->track($resourceId, $metadata);
}
@@
public function purge(string $resourceId): void
{
$this->invalidations[] = $resourceId;
+ unset($this->activeResolvers[$resourceId]);
$this->primary->purge($resourceId);
$this->secondary->purge($resourceId);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/Integration/EdgeIntegrationTest.php` around lines 928 - 960, The
wrapper currently records onConnect/onDisconnect/track calls into local arrays
but never forwards them to the actual resolver that served resolve(), so child
resolvers' cleanup/metrics are bypassed; modify resolve() to record which
underlying resolver instance (primary or secondary) was chosen for each
resourceId (e.g. persist a map like $this->resolvedMap[$resourceId] =
$chosenResolver) and update onConnect, onDisconnect, track, purge to look up the
resolver from that map and delegate the call to that resolver (fall back to
existing local arrays only if no mapping exists); ensure purge also removes the
mapping and still calls $this->primary->purge/$this->secondary->purge as needed
and keep getStats() returning child resolvers' stats while optionally including
wrapper counters.
| public function testClassifyCteNoFinalKeyword(): void | ||
| { | ||
| // Bare WITH with no recognizable final statement - defaults to READ | ||
| $sql = 'WITH x AS (SELECT 1)'; | ||
| $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); |
There was a problem hiding this comment.
Don't lock an incomplete WITH statement to the read path.
WITH x AS (SELECT 1) is syntactically incomplete. Asserting QueryType::Read here blesses an unsafe fallback that can route malformed SQL to replicas, even though src/Adapter/TCP.php:179-215 intentionally keeps QueryType::Unknown on primary for safety.
Suggested expectation
public function testClassifyCteNoFinalKeyword(): void
{
- // Bare WITH with no recognizable final statement - defaults to READ
+ // Incomplete CTE should stay on the primary path for safety.
$sql = 'WITH x AS (SELECT 1)';
- $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql));
+ $this->assertSame(QueryType::Unknown, $this->pgParser->classifySQL($sql));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public function testClassifyCteNoFinalKeyword(): void | |
| { | |
| // Bare WITH with no recognizable final statement - defaults to READ | |
| $sql = 'WITH x AS (SELECT 1)'; | |
| $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); | |
| public function testClassifyCteNoFinalKeyword(): void | |
| { | |
| // Incomplete CTE should stay on the primary path for safety. | |
| $sql = 'WITH x AS (SELECT 1)'; | |
| $this->assertSame(QueryType::Unknown, $this->pgParser->classifySQL($sql)); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/QueryParserTest.php` around lines 575 - 579, The test
testClassifyCteNoFinalKeyword is asserting that pgParser->classifySQL('WITH x AS
(SELECT 1)') returns QueryType::Read, but that WITH-only input is incomplete and
should be classified as QueryType::Unknown to match the runtime safety in
src/Adapter/TCP (which treats unknown as primary-only); update the expectation
in testClassifyCteNoFinalKeyword to assert QueryType::Unknown from
pgParser->classifySQL instead of QueryType::Read.
| public function testParsePerformance(): void | ||
| { | ||
| $pgData = $this->buildPgQuery('SELECT * FROM users WHERE id = 1'); | ||
| $mysqlData = $this->buildMySQLQuery('SELECT * FROM users WHERE id = 1'); | ||
|
|
||
| $iterations = 100_000; | ||
|
|
||
| // PostgreSQL parse performance | ||
| $start = \hrtime(true); | ||
| for ($i = 0; $i < $iterations; $i++) { | ||
| $this->pgParser->parse($pgData); | ||
| } | ||
| $pgElapsed = (\hrtime(true) - $start) / 1_000_000_000; // seconds | ||
| $pgPerQuery = ($pgElapsed / $iterations) * 1_000_000; // microseconds | ||
|
|
||
| // MySQL parse performance | ||
| $start = \hrtime(true); | ||
| for ($i = 0; $i < $iterations; $i++) { | ||
| $this->mysqlParser->parse($mysqlData); | ||
| } | ||
| $mysqlElapsed = (\hrtime(true) - $start) / 1_000_000_000; | ||
| $mysqlPerQuery = ($mysqlElapsed / $iterations) * 1_000_000; | ||
|
|
||
| // Both should be under 1 microsecond per parse | ||
| $this->assertLessThan( | ||
| 1.0, | ||
| $pgPerQuery, | ||
| \sprintf('PostgreSQL parse took %.3f us/query (target: < 1.0 us)', $pgPerQuery) | ||
| ); | ||
| $this->assertLessThan( | ||
| 1.0, | ||
| $mysqlPerQuery, | ||
| \sprintf('MySQL parse took %.3f us/query (target: < 1.0 us)', $mysqlPerQuery) | ||
| ); | ||
| } | ||
|
|
||
| public function testClassifySqlPerformance(): void | ||
| { | ||
| $queries = [ | ||
| 'SELECT * FROM users WHERE id = 1', | ||
| "INSERT INTO logs (msg) VALUES ('test')", | ||
| 'BEGIN', | ||
| ' /* comment */ SELECT 1', | ||
| 'WITH cte AS (SELECT 1) SELECT * FROM cte', | ||
| ]; | ||
|
|
||
| $iterations = 100_000; | ||
|
|
||
| $start = \hrtime(true); | ||
| for ($i = 0; $i < $iterations; $i++) { | ||
| $this->pgParser->classifySQL($queries[$i % \count($queries)]); | ||
| } | ||
| $elapsed = (\hrtime(true) - $start) / 1_000_000_000; | ||
| $perQuery = ($elapsed / $iterations) * 1_000_000; | ||
|
|
||
| // Threshold is 2us to account for CTE queries which require parenthesis-depth scanning. | ||
| // Simple queries (SELECT, INSERT, BEGIN) are well under 1us individually. | ||
| $this->assertLessThan( | ||
| 2.0, | ||
| $perQuery, | ||
| \sprintf('classifySQL took %.3f us/query (target: < 2.0 us)', $perQuery) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Move these micro-benchmarks out of the blocking PHPUnit suite.
The < 1us / < 2us thresholds depend heavily on CPU speed, CI contention, coverage settings, and thermal throttling, so these tests will fail intermittently without any parser regression. Keep them as opt-in benchmarks or a dedicated performance job instead of regular pass/fail unit tests.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/QueryParserTest.php` around lines 620 - 682, The micro-benchmarks
testParsePerformance and testClassifySqlPerformance are flaky in CI; move them
out of the blocking PHPUnit suite by making them opt-in: either add a PHPUnit
group annotation (e.g., `@group` performance) on the test methods
(testParsePerformance, testClassifySqlPerformance) so they can be excluded from
default runs, or gate their execution at runtime by checking an env var (e.g.,
if (!getenv('RUN_PERF_TESTS')) $this->markTestSkipped('Performance tests
disabled');). Ensure references to pgParser->parse and pgParser->classifySQL
(and mysqlParser->parse) remain intact so the benchmarks still run when
explicitly enabled.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@README.md`:
- Around line 391-426: The fenced ASCII-art diagram block in README.md lacks a
language tag; update the opening fence for the diagram (the triple-backtick that
precedes the ASCII art starting with the box drawing "┌────────────────") to
include a language tag (e.g., "text") so it becomes ```text and leave the
closing ``` unchanged to satisfy markdownlint MD040.
| ``` | ||
| ┌─────────────────────────────────────────────────────────────────┐ | ||
| │ Protocol Proxy │ | ||
| │ Protocol Proxy │ | ||
| ├─────────────────────────────────────────────────────────────────┤ | ||
| │ │ | ||
| │ │ | ||
| │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ | ||
| │ │ HTTP │ │ TCP │ │ SMTP │ │ | ||
| │ │ Server │ │ Server │ │ Server │ │ | ||
| │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ | ||
| │ │ │ │ │ | ||
| │ │ ┌────┴─────┐ │ │ | ||
| │ │ │ TCP │ │ │ | ||
| │ │ │ Adapter │ │ │ | ||
| │ │ └────┬─────┘ │ │ | ||
| │ │ │ │ │ | ||
| │ └─────────────────┴──────────────────┘ │ | ||
| │ │ │ | ||
| │ ┌────────▼────────┐ │ | ||
| │ │ ConnectionMgr │ │ | ||
| │ │ (Abstract) │ │ | ||
| │ │ Adapter │ │ | ||
| │ │ (Base Class) │ │ | ||
| │ └────────┬────────┘ │ | ||
| │ │ │ | ||
| │ ┌───────────────┼───────────────┐ │ | ||
| │ │ │ │ │ | ||
| │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ | ||
| │ │ Cache │ │ Database│ │ Compute │ │ | ||
| │ │ Layer │ │ Pool │ │ API │ │ | ||
| │ └─────────┘ └─────────┘ └─────────┘ │ | ||
| │ │ | ||
| └─────────────────────────────────────────────────────────────────┘ | ||
| │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ | ||
| │ │Resolver │ │ReadWrite│ │ Query │ │ | ||
| │ │(resolve)│ │Resolver │ │ Parser │ │ | ||
| │ └────┬────┘ └────┬────┘ └────┬────┘ │ | ||
| │ │ │ │ │ | ||
| │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ | ||
| │ │ Routing │ │ R/W │ │ PG/MY/ │ │ | ||
| │ │ Cache │ │ Split │ │ Mongo │ │ | ||
| │ └─────────┘ └─────────┘ └─────────┘ │ | ||
| │ │ | ||
| └──────────────────────────────────────────────────────────────────┘ | ||
| ``` |
There was a problem hiding this comment.
Add a language tag to the architecture diagram fence.
The block starting at Line 391 is missing a fenced-code language, which matches the markdownlint MD040 warning.
Suggested fix
-```
+```text
┌─────────────────────────────────────────────────────────────────┐
│ Protocol Proxy │
├─────────────────────────────────────────────────────────────────┤
@@
└──────────────────────────────────────────────────────────────────┘</details>
<!-- suggestion_start -->
<details>
<summary>📝 Committable suggestion</summary>
> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
```suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)
[warning] 391-391: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@README.md` around lines 391 - 426, The fenced ASCII-art diagram block in
README.md lacks a language tag; update the opening fence for the diagram (the
triple-backtick that precedes the ASCII art starting with the box drawing
"┌────────────────") to include a language tag (e.g., "text") so it becomes
```text and leave the closing ``` unchanged to satisfy markdownlint MD040.
- Remove local path repository for utopia-php/query (use Packagist) - Add Dockerfile.test for unit test workflow - Add swoole/redis extensions to lint workflow - Fix Dockerfile COPY referencing uncommitted composer.lock Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Switch utopia-php/query from dev-main to dev-feat-builder (has Parser/Type classes) - Fix ordered_imports lint issues in Swoole.php and EdgeIntegrationTest.php - Add PHPDoc type annotations to satisfy PHPStan level=max - Fix unpack() false check, null-safe fclose, and remove unused property Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Increase parse performance assertion from 1.0 to 2.0 us/query to prevent flaky failures on shared CI hardware. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…alidation, routing cache, TCP adapter, and more
Summary by CodeRabbit
New Features
Documentation
Tests
Chores