Skip to content
Prev Previous commit
Next Next commit
fix: Bind the ui and lineage servers dual-stack too
start_server (ui_server.py) and start_lineage_server (lineage_server.py)
both call uvicorn.run(app, host=host, port=port) directly. A plain
host="::" there binds IPv6-only -- asyncio's loop.create_server sets
IPV6_V6ONLY=1 on any socket it creates itself from a host string -- which
silently drops IPv4 clients on every host, not just IPv6-less ones. This
is the same bug the metrics and REST registry servers had, fixed
separately on fix/sdk-dual-stack-binds.

Add feast.utils._make_dual_stack_socket(port), a shared version of
_make_rest_socket's pre-bound-socket approach, and route both servers
through it when host == "::": build the socket with IPV6_V6ONLY cleared
and hand it to uvicorn.Server(config).run(sockets=[sock]) instead of
uvicorn.run(host=...). Any other host keeps today's plain uvicorn.run
behavior unchanged.

This closes the gap the Operator's dualStack option (feat/operator-dual-stack)
depends on: without this fix, enabling dualStack for the ui/lineage
services would have silently regressed their IPv4 reachability instead of
adding IPv6.

Signed-off-by: dbbvitor <vitor.diniz@gympass.com>
  • Loading branch information
dbbvitor authored and ntkathole committed Oct 1, 2026
commit 2531291542e6c67a7722ae9c9435483eb517bb0f
13 changes: 12 additions & 1 deletion sdk/python/feast/lineage_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,15 @@ def start_lineage_server(
import uvicorn

logger.info(f"Starting Feast OpenLineage server on {scheme}://{host}:{port}")
uvicorn.run(app, host=host, port=port, **ssl_kwargs)

if host == "::":
# Plain uvicorn.run(host="::") binds IPv6-only (see
# feast.utils._make_dual_stack_socket), which would drop IPv4
# clients on every host, not just IPv6-less ones.
from feast.utils import _make_dual_stack_socket

sock = _make_dual_stack_socket(port)
config = uvicorn.Config(app, **ssl_kwargs)
uvicorn.Server(config).run(sockets=[sock])
else:
uvicorn.run(app, host=host, port=port, **ssl_kwargs)
22 changes: 14 additions & 8 deletions sdk/python/feast/ui_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1216,13 +1216,19 @@ def start_server(

logger.info(f"Starting Feast UI server on {host}:{port}")

ssl_kwargs: dict = {}
if tls_key_path and tls_cert_path:
uvicorn.run(
app,
host=host,
port=port,
ssl_keyfile=tls_key_path,
ssl_certfile=tls_cert_path,
)
ssl_kwargs["ssl_keyfile"] = tls_key_path
ssl_kwargs["ssl_certfile"] = tls_cert_path

if host == "::":
# Plain uvicorn.run(host="::") binds IPv6-only (see
# feast.utils._make_dual_stack_socket), which would drop IPv4
# clients on every host, not just IPv6-less ones.
from feast.utils import _make_dual_stack_socket

sock = _make_dual_stack_socket(port)
config = uvicorn.Config(app, **ssl_kwargs)
uvicorn.Server(config).run(sockets=[sock])
else:
uvicorn.run(app, host=host, port=port)
uvicorn.run(app, host=host, port=port, **ssl_kwargs)
25 changes: 25 additions & 0 deletions sdk/python/feast/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ def _ipv6_available() -> bool:
return False


def _make_dual_stack_socket(port: int) -> socket.socket:
"""Build a listening socket bound dual-stack ("::") when the host supports
IPv6, or IPv4-only ("0.0.0.0") otherwise (e.g. ``ipv6.disable=1`` kernels).

A plain ``host="::"`` passed to a framework's own server (uvicorn's
``uvicorn.run``, asyncio's ``loop.create_server``) gets ``IPV6_V6ONLY=1``
set on the socket it creates for itself, which silently drops IPv4
clients on every host, not just IPv6-less ones. Binding the socket here,
with ``IPV6_V6ONLY`` explicitly cleared, and handing it to the server
pre-built avoids that.
"""
if _ipv6_available():
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
address: tuple = ("::", port)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
address = ("0.0.0.0", port)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(address)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
sock.listen(socket.SOMAXCONN)
sock.setblocking(False)
return sock

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Socket leak: if bind() or listen() throws (e.g. port already in use), the socket is never closed. Wrap the post-creation steps:

try:
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(address)
    sock.listen(socket.SOMAXCONN)
    sock.setblocking(False)
except:
    sock.close()
    raise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try-except pattern adopted



Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setblocking(False) bakes in an asyncio assumption. The function name is generic, but a caller expecting a blocking socket would fail silently. Consider either documenting this in the docstring or adding a blocking=False parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the blocking parameter to the func, defaulting to False, and added it to the docstring.

def _parse_feature_or_view_ref(ref: str) -> Tuple[str, Optional[int], Optional[str]]:
"""Parse 'fv_name[@version][:feature]' into (fv_name, version_number, feature_name).

Expand Down
97 changes: 96 additions & 1 deletion sdk/python/tests/unit/openlineage/test_lineage_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the standalone lineage server (lineage_server.py)."""

from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

import pytest

Expand Down Expand Up @@ -163,3 +163,98 @@ def test_returns_none_without_authz(self):

result = _build_rbac_callback(store)
assert result is None


class TestStartLineageServer:
"""Test start_lineage_server."""

def _make_mock_store(self):
store = MagicMock()
store.config = MagicMock()
store.config.openlineage = None
store.config.auth = None
store.config.auth_config = None
return store

@patch("uvicorn.Server")
@patch("uvicorn.Config")
@patch("uvicorn.run")
@patch("feast.lineage_server.create_lineage_app")
def test_dual_stack_binds_prebuilt_socket(
self, mock_create_app, mock_run, mock_config_cls, mock_server_cls
):
"""host="::" must go through a pre-bound dual-stack socket, not
uvicorn.run(host="::"), which binds IPv6-only and drops IPv4 clients."""
from feast.lineage_server import start_lineage_server

mock_app = MagicMock()
mock_create_app.return_value = mock_app
mock_sock = MagicMock()

with patch(
"feast.utils._make_dual_stack_socket", return_value=mock_sock
) as mock_make_sock:
start_lineage_server(self._make_mock_store(), host="::", port=6580)

mock_make_sock.assert_called_once_with(6580)
mock_config_cls.assert_called_once_with(mock_app)
mock_server_cls.assert_called_once_with(mock_config_cls.return_value)
mock_server_cls.return_value.run.assert_called_once_with(sockets=[mock_sock])
mock_run.assert_not_called()

@patch("feast.utils._make_dual_stack_socket")
@patch("uvicorn.Server")
@patch("uvicorn.Config")
@patch("uvicorn.run")
@patch("feast.lineage_server.create_lineage_app")
def test_plain_host_uses_uvicorn_run(
self,
mock_create_app,
mock_run,
mock_config_cls,
mock_server_cls,
mock_make_sock,
):
"""A non dual-stack host keeps today's plain uvicorn.run behavior. Also
mocks Config/Server/_make_dual_stack_socket so a broken host
comparison fails fast on an assertion instead of hanging in a real
uvicorn.Server.run()."""
from feast.lineage_server import start_lineage_server

mock_app = MagicMock()
mock_create_app.return_value = mock_app

start_lineage_server(self._make_mock_store(), host="0.0.0.0", port=6580)

mock_run.assert_called_once_with(mock_app, host="0.0.0.0", port=6580)
mock_server_cls.assert_not_called()
mock_make_sock.assert_not_called()

@patch("feast.utils._make_dual_stack_socket")
@patch("uvicorn.Server")
@patch("uvicorn.Config")
@patch("uvicorn.run")
@patch("feast.lineage_server.create_lineage_app")
def test_non_wildcard_host_uses_uvicorn_run(
self,
mock_create_app,
mock_run,
mock_config_cls,
mock_server_cls,
mock_make_sock,
):
"""A host that sorts after "::" (e.g. starting with a letter) must
still take the plain uvicorn.run path -- regression test for a
comparison mutant (host == "::" weakened to <=/>=) that a
"0.0.0.0"-only test can't catch, since "0.0.0.0" sorts before "::"
either way."""
from feast.lineage_server import start_lineage_server

mock_app = MagicMock()
mock_create_app.return_value = mock_app

start_lineage_server(self._make_mock_store(), host="example.com", port=6580)

mock_run.assert_called_once_with(mock_app, host="example.com", port=6580)
mock_server_cls.assert_not_called()
mock_make_sock.assert_not_called()
85 changes: 85 additions & 0 deletions sdk/python/tests/unit/test_ui_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,88 @@ def test_registry_refresh_endpoint_error(mock_feature_store):
client = TestClient(app, raise_server_exceptions=False)
resp = client.post("/api/v1/registry/refresh")
assertpy.assert_that(resp.status_code).is_equal_to(500)


@patch("feast.ui_server.uvicorn")
@patch("feast.ui_server.get_app")
def test_start_server_dual_stack_binds_prebuilt_socket(mock_get_app, mock_uvicorn):
"""host="::" must go through a pre-bound dual-stack socket, not
uvicorn.run(host="::"), which binds IPv6-only and drops IPv4 clients."""
from feast.ui_server import start_server

mock_app = MagicMock()
mock_get_app.return_value = mock_app
mock_sock = MagicMock()

with patch(
"feast.utils._make_dual_stack_socket", return_value=mock_sock
) as mock_make_sock:
start_server(
MagicMock(),
host="::",
port=8888,
project_id=TEST_PROJECT_NAME,
tls_key_path="key.pem",
tls_cert_path="cert.pem",
)

mock_make_sock.assert_called_once_with(8888)
mock_uvicorn.Config.assert_called_once_with(
mock_app, ssl_keyfile="key.pem", ssl_certfile="cert.pem"
)
mock_uvicorn.Server.assert_called_once_with(mock_uvicorn.Config.return_value)
mock_uvicorn.Server.return_value.run.assert_called_once_with(sockets=[mock_sock])
mock_uvicorn.run.assert_not_called()


@patch("feast.utils._make_dual_stack_socket")
@patch("feast.ui_server.uvicorn")
@patch("feast.ui_server.get_app")
def test_start_server_plain_host_uses_uvicorn_run(
mock_get_app, mock_uvicorn, mock_make_sock
):
"""A non dual-stack host keeps today's plain uvicorn.run behavior. Also
mocks _make_dual_stack_socket so a broken host comparison fails fast on
an assertion instead of hanging in a real uvicorn.Server.run()."""
from feast.ui_server import start_server

mock_app = MagicMock()
mock_get_app.return_value = mock_app

start_server(
MagicMock(),
host="0.0.0.0",
port=8888,
project_id=TEST_PROJECT_NAME,
)

mock_uvicorn.run.assert_called_once_with(mock_app, host="0.0.0.0", port=8888)
mock_uvicorn.Server.assert_not_called()
mock_make_sock.assert_not_called()


@patch("feast.utils._make_dual_stack_socket")
@patch("feast.ui_server.uvicorn")
@patch("feast.ui_server.get_app")
def test_start_server_non_wildcard_host_uses_uvicorn_run(
mock_get_app, mock_uvicorn, mock_make_sock
):
"""A host that sorts after "::" (e.g. starting with a letter) must still
take the plain uvicorn.run path -- regression test for a comparison
mutant (host == "::" weakened to >=) that a "0.0.0.0"-only test can't
catch, since "0.0.0.0" < "::" either way."""
from feast.ui_server import start_server

mock_app = MagicMock()
mock_get_app.return_value = mock_app

start_server(
MagicMock(),
host="example.com",
port=8888,
project_id=TEST_PROJECT_NAME,
)

mock_uvicorn.run.assert_called_once_with(mock_app, host="example.com", port=8888)
mock_uvicorn.Server.assert_not_called()
mock_make_sock.assert_not_called()
30 changes: 28 additions & 2 deletions sdk/python/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
populates the GetOnlineFeaturesResponse.
"""

import socket
from datetime import datetime, timezone
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

from feast.protos.feast.serving.ServingService_pb2 import (
FieldStatus,
GetOnlineFeaturesResponse,
)
from feast.protos.feast.types.Value_pb2 import Value as ValueProto
from feast.utils import _populate_response_from_feature_data
from feast.utils import _make_dual_stack_socket, _populate_response_from_feature_data


def _make_table(name="test_fv"):
Expand Down Expand Up @@ -440,3 +441,28 @@ def test_shared_source_ref_order_independent(self):
feature.name for feature in src_entries[0].projection.features
)
assert projected == ["a", "b"]


def test_make_dual_stack_socket_binds_dual_stack_when_ipv6_available():
mock_sock = MagicMock()
with patch("feast.utils._ipv6_available", return_value=True):
with patch("socket.socket", return_value=mock_sock) as mock_socket_cls:
result = _make_dual_stack_socket(6580)

mock_socket_cls.assert_called_once_with(socket.AF_INET6, socket.SOCK_STREAM)
mock_sock.setsockopt.assert_any_call(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
mock_sock.setsockopt.assert_any_call(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
mock_sock.bind.assert_called_once_with(("::", 6580))
mock_sock.listen.assert_called_once_with(socket.SOMAXCONN)
mock_sock.setblocking.assert_called_once_with(False)
assert result is mock_sock


def test_make_dual_stack_socket_falls_back_to_ipv4():
mock_sock = MagicMock()
with patch("feast.utils._ipv6_available", return_value=False):
with patch("socket.socket", return_value=mock_sock) as mock_socket_cls:
_make_dual_stack_socket(6580)

mock_socket_cls.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM)
mock_sock.bind.assert_called_once_with(("0.0.0.0", 6580))