Skip to content

fix: derive async-runner loopback scheme+port from the internal server, not the public URL#402

Merged
chubes4 merged 1 commit into
mainfrom
fix/loopback-target-scheme-port
Jul 3, 2026
Merged

fix: derive async-runner loopback scheme+port from the internal server, not the public URL#402
chubes4 merged 1 commit into
mainfrom
fix/loopback-target-scheme-port

Conversation

@chubes4

@chubes4 chubes4 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Root cause

WP_Agent_Workflow_Action_Scheduler_Branch_Executor::loopback_dispatch_target() warms N async-runner workers by firing N concurrent loopback POSTs to admin-ajax.php (the as_async_request_queue_runner action). To dodge multi-second .local mDNS resolution it rewrites the dispatch host to 127.0.0.1 and carries the real host in a Host: header — correct. But it also preserved the scheme and port parsed from the public admin-ajax URL — always https, default :443.

On a split runtime whose internal worker pool is plain HTTP on a non-443 port (public HTTPS front door on .local:443, internal pool on localhost:8882), the rewritten target becomes https://127.0.0.1/wp-admin/admin-ajax.php?…. There is no HTTPS listener on 127.0.0.1:443, so every concurrent async-runner POST fails the TLS handshake (ssl/tls alert handshake failure). The loopback burst that should wake N workers for N branches all fail, and branches fall back to the serial ~60s WP-Cron heartbeat drain — the last cap on true N-way parallel branch fanout.

Measured directly on the affected runtime with Requests::request_multiple:

  • https://127.0.0.1/… target → 0/8 succeed (TLS handshake failure).
  • http://localhost:8882/… + canonical Host: header → 8/8 → HTTP 200, 8 workers claim + drain 8 distinct branches.

The fix

Add the agents_workflow_async_runner_loopback_base filter so a runtime (or an mu-plugin) can declare where its local server is ACTUALLY reachable on the loop, e.g. http://localhost:8882. When set, its scheme/host/port replace the public ones; the original path + query (the AS nonce) are carried onto it, and the canonical public Host: header is preserved so the local server still virtual-hosts to the correct site.

When the filter is unset the behavior is byte-for-byte the pre-filter default (host → 127.0.0.1, scheme + port + Host header from the public URL), so no single-server runtime regresses. The .local→127.0.0.1 mDNS dodge is intact; the scheme/port fix composes cleanly with it.

The filter default is generic — agents-api stays product-agnostic; the runtime supplies its real internal loopback base.

Tests

  • tests/workflow-async-loopback-target-smoke.php (new) — real assertions on the constructed target for both the default (https://127.0.0.1/… + canonical Host) and the plain-HTTP-non-443 override (http://localhost:8882/…, not https://127.0.0.1:443/…) cases, plus nonce survival and explicit-port handling. Wired into composer smoke.
  • Full smoke suite green; PHPStan level max clean.

AI assistance

  • AI assistance: Yes
  • Tool(s): Claude Opus 4.8 (Claude Code)
  • Used for: Root-cause confirmation, the filter design, the code + comments, and the smoke test. Chris remains responsible for every line.

…r, not the public URL

The Action Scheduler loopback burst that warms N async-runner workers rewrites
the dispatch host to 127.0.0.1 (to dodge .local mDNS latency) but PRESERVED the
scheme and port parsed from the public admin-ajax URL — always https, default
:443. On a split runtime whose internal worker pool is plain HTTP on a non-443
port (public HTTPS front door on .local:443, internal pool on localhost:8882),
the rewritten target becomes https://127.0.0.1/... with no TLS listener on
127.0.0.1:443, so every concurrent async-runner POST fails the TLS handshake.
The fan-out then silently collapses to the serial ~60s WP-Cron heartbeat drain —
the last cap on true N-way parallel branch fanout.

Add the agents_workflow_async_runner_loopback_base filter so a runtime (or an
mu-plugin) can declare where its local server is ACTUALLY reachable on the loop,
e.g. http://localhost:8882. When set, its scheme/host/port replace the public
ones; the original path + query (the AS nonce) are carried onto it and the
canonical public Host header is preserved so the local server still virtual-hosts
to the correct site. When the filter is unset the behavior is byte-for-byte the
pre-filter default (host -> 127.0.0.1, scheme + port + Host from the public URL),
so no single-server runtime regresses.

Adds tests/workflow-async-loopback-target-smoke.php with real assertions on the
constructed target for both the default and the plain-HTTP-non-443 override case.
PHPStan level max clean; full smoke suite green.
@chubes4 chubes4 merged commit 41c77fa into main Jul 3, 2026
2 checks passed
@chubes4 chubes4 deleted the fix/loopback-target-scheme-port branch July 3, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant