This is an automated email from the ASF dual-hosted git repository.
jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a2f8a7ab39d Fix Java coordinator rejecting macOS dual-stack loopback
connections (#68960)
a2f8a7ab39d is described below
commit a2f8a7ab39d55300531edba310e0453bb2546cf3
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Thu Jun 25 18:02:31 2026 +0900
Fix Java coordinator rejecting macOS dual-stack loopback connections
(#68960)
## Why
On macOS the task JVM's dual-stack loopback connection is reported by
psutil in the IPv4-compatible form (`::127.0.0.1`), which the coordinator's
ownership check did not normalize (only the IPv4-mapped `::ffff:127.0.0.1` form
was), so every Java task was rejected with "process exited with 1 before
connecting".
## What
- Canonicalize IPv4-compatible IPv6 (`::a.b.c.d`) to plain IPv4 in
`_socket_address`, excluding `::` and `::1`, alongside the existing IPv4-mapped
handling.
- Add `test_matches_dual_stack_ipv4_compatible_connection` regression test.
---
##### Was generative AI tooling used to co-author this PR?
- [x] Yes, with help of Claude Code Opus 4.8 following [the
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
Signed-off-by: LIU ZHE YOU <[email protected]>
---
.../src/airflow/sdk/coordinators/_subprocess.py | 20 +++++++++----
.../tests/task_sdk/coordinators/test_subprocess.py | 35 ++++++++++++++++++++++
2 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
index 9550fdd0bc4..a0c3f518fb0 100644
--- a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
+++ b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
@@ -72,16 +72,26 @@ def _socket_address(value: tuple | str) -> tuple[str, int]
| None:
return None
host, port = value[:2]
host = str(host)
- # Canonicalize IPv4-mapped IPv6 ("::ffff:127.0.0.1" -> "127.0.0.1") so a
dual-stack
- # client (e.g. the JVM, shown v4-mapped in /proc/net/tcp6) matches the
AF_INET
- # supervisor socket's plain-IPv4 address in the ownership check below.
+ # Canonicalize an IPv4 address that a dual-stack client embeds in IPv6 so
it matches
+ # the AF_INET supervisor socket's plain-IPv4 address in the ownership
check below. A
+ # dual-stack JVM's loopback connection is rendered in two different forms
depending on
+ # the platform, and both must collapse to plain "127.0.0.1":
+ # * IPv4-mapped "::ffff:127.0.0.1" -> "127.0.0.1" (Linux, via
/proc/net/tcp6)
+ # * IPv4-compatible "::127.0.0.1" -> "127.0.0.1" (macOS, via
psutil)
+ # Otherwise the JVM's connection fails the check and every Java task is
rejected with
+ # "process exited with 1 before connecting".
try:
parsed = ipaddress.ip_address(host)
except ValueError:
pass
else:
- if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is
not None:
- host = str(parsed.ipv4_mapped)
+ if isinstance(parsed, ipaddress.IPv6Address):
+ if parsed.ipv4_mapped is not None:
+ host = str(parsed.ipv4_mapped)
+ elif 1 < int(parsed) <= 0xFFFFFFFF:
+ # IPv4-compatible IPv6: ::/96 with the IPv4 in the low 32
bits. Exclude
+ # "::" (unspecified) and "::1" (IPv6 loopback), which are not
IPv4.
+ host = str(ipaddress.IPv4Address(int(parsed)))
return host, int(port)
diff --git a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
index 5a89c73e780..62b7fbcf39c 100644
--- a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
+++ b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
@@ -372,6 +372,41 @@ class TestConnectionFromProcess:
client.close()
server.close()
+ def test_matches_dual_stack_ipv4_compatible_connection(self):
+ """A dual-stack child whose loopback is rendered in IPv4-compatible
form is accepted.
+
+ Companion to :meth:`test_matches_dual_stack_ipv4_mapped_connection`
for macOS
+ (#68938): there the JVM's loopback connection is reported by
``psutil`` as the
+ deprecated IPv4-compatible ``::127.0.0.1`` rather than the IPv4-mapped
+ ``::ffff:127.0.0.1`` seen on Linux. Both forms must canonicalize to
plain
+ ``127.0.0.1`` or the ownership check rejects the Java task. The OS
will not
+ reliably establish a routable ``::`` connection on demand, so
``psutil``'s view of
+ the child's connections is mocked to the form macOS actually reports.
+ """
+ server = _start_server()
+ _, server_port = server.getsockname()
+ client = socket.socket()
+ client.connect(("127.0.0.1", server_port))
+ conn, _ = server.accept()
+ child_port = conn.getpeername()[1]
+ mock_proc = MagicMock(spec=subprocess.Popen)
+ mock_proc.pid = os.getpid()
+
+ # On macOS psutil reports the child's dual-stack loopback in
IPv4-compatible form.
+ compat_conn = MagicMock(
+ laddr=("::127.0.0.1", child_port),
+ raddr=("::127.0.0.1", server_port),
+ )
+ try:
+ with patch("airflow.sdk.coordinators._subprocess.psutil.Process")
as mock_process:
+ mock_process.return_value.children.return_value = []
+ mock_process.return_value.net_connections.return_value =
[compat_conn]
+ assert _is_connection_from_process(conn, mock_proc) is True
+ finally:
+ conn.close()
+ client.close()
+ server.close()
+
def test_rejects_tcp_connection_not_owned_by_child_process(self):
server = _start_server()
_, port = server.getsockname()