Skip to content

fix(tests): bound exposing_contextmanager's portforward retry loop - #4273

Merged
openshift-merge-bot[bot] merged 1 commit into
mainfrom
fix/socket-proxy-bounded-remote-timeout
Aug 3, 2026
Merged

fix(tests): bound exposing_contextmanager's portforward retry loop#4273
openshift-merge-bot[bot] merged 1 commit into
mainfrom
fix/socket-proxy-bounded-remote-timeout

Conversation

@jiridanek

@jiridanek jiridanek commented Aug 2, 2026

Copy link
Copy Markdown
Member

Description

Implements "Fix #2" from the suggested (not-yet-implemented) hardening ideas in
red-hat-data-services/notebooks#2684:
bound exposing_contextmanager()'s kubernetes.stream.portforward() retry loop in
tests/containers/kubernetes_utils.py.

exposing_contextmanager() retried portforward() in a
while not pf or not pf.connected or not s: loop with no deadline and no give-up
condition
. Verified via the kubernetes client library source that the underlying
websocket.connect(...) call has no per-call timeout plumbed through portforward()'s
public API -- a single iteration can, in principle, hang forever (e.g. a wedged
connection to the API server), not just retry quickly.

Because SocketProxy.listen_and_serve_until_canceled() handles one client
synchronously, a stuck call here blocks the entire proxy -- no other Wait.until
retry can ever be serviced again for that pod, with no way to recover.

Changes:

  • exposing_contextmanager() gains a timeout: float = 30 parameter. Once exceeded,
    cleans up any partial s/pf and raises TimeoutError.
  • The deadline check alone only runs between retry loop iterations, so a single
    portforward() call that itself hangs would never let it fire again -- added
    _portforward_with_timeout(), which runs that specific call in a daemon thread
    bounded by join(timeout). On timeout the call is abandoned (thread/connection
    leaked for the remainder of the test process) rather than joined; deliberately not
    using a process-wide websocket.setdefaulttimeout() since multiple SocketProxy
    instances (one per ImageDeployment) can call portforward() concurrently from
    different threads.
  • SocketProxy.listen_and_serve_until_canceled() now also catches the new
    TimeoutError alongside the existing BrokenPipeError/ConnectionResetError, so
    the proxy gives up on just that one connection and keeps serving future retries.

This is a straight port of
red-hat-data-services/notebooks#2691
(rhoai-2.25), where it was implemented and CI-verified first, and where a CodeRabbit
review caught the single-hung-call gap described above (fixed there first, then ported
here). Per this repo's usual sync direction this would normally go upstream first, but
this specific enhancement was sequenced rhoai-2.25mainrhoai-3.5 since, at
the time this work started, rhoai-2.25 had the only CI coverage of this exact code
path (openshift-container-tests, from red-hat-data-services#2685) and
main didn't (#4257). #4257 has since been independently resolved by #4259 while this
PR was in progress, so main's openshift-container-tests job will also exercise
this change directly in this PR's own CI.

How Has This Been Tested?

  • ast.parse, uv run ruff format --check, uv run ruff check, uv run pyright
    all clean on both changed files.
  • No dedicated unit tests exist for exposing_contextmanager/SocketProxy
    (confirmed via grep) -- real coverage is via the openshift-container-tests CI job,
    which exercises this exact code path here and already went green on the
    rhoai-2.25 port (PR fix-permissions script exits successfully on missing target, potentially masking errors #2691, all 5 matrix legs) both before and after the
    CodeRabbit-driven fix.
  • Local functional testing of three scenarios against this exact code:
    • a portforward() call that hangs forever -> correctly bounded, raises
      TimeoutError at the configured timeout instead of hanging
    • a pod that fails fast repeatedly (not ready yet) -> unaffected, still converges
      on the loop's own deadline as before
    • a pod that succeeds on a later retry -> unaffected

Self checklist (all need to be checked):

  • Ensure that you have run make test (gmake on macOS) before asking for review
  • Changes to everything except Dockerfile.konflux files should be done in odh/notebooks and automatically synced to rhds/notebooks. For Konflux-specific changes, modify Dockerfile.konflux files directly in rhds/notebooks as these require special attention in the downstream repository and flow to the upcoming RHOAI release.

Merge criteria:

  • The commits are squashed in a cohesive manner and have meaningful messages.
  • Testing instructions have been added in the PR body (for PRs involving changes that are not immediately obvious).
  • The developer has manually tested the changes and verified that the changes work

exposing_contextmanager() retried kubernetes.stream.portforward() forever with
no deadline and no give-up condition. Because SocketProxy handles one client
synchronously, a single stuck iteration (e.g. a wedged connection to the API
server -- the kubernetes client doesn't expose a per-call connect timeout for
portforward()) blocks the entire proxy, starving every later Wait.until retry
with no way to recover.

Add a bounded deadline (default 30s) that raises TimeoutError once exceeded,
and catch it in SocketProxy.listen_and_serve_until_canceled() alongside the
existing BrokenPipeError/ConnectionResetError handling so the proxy gives up
on just that one connection and keeps serving future retries instead of the
exception escaping to the outer handler and killing the whole proxy thread.

The deadline check alone only runs between retry loop iterations, so a single
portforward() call that itself hangs would never let it fire again -- bound
that specific call too via a new _portforward_with_timeout() helper, which
runs it in a daemon thread bounded by join(timeout). On timeout the call is
abandoned (thread/connection leaked for the remainder of the test process)
rather than joined; deliberately not using a process-wide
websocket.setdefaulttimeout() since multiple SocketProxy instances can call
portforward() concurrently from different threads.

Straight port of red-hat-data-services#2691 (rhoai-2.25), where this
was implemented and CI-verified first (openshift-container-tests job exercises
this exact code path there; no equivalent job exists on main yet, tracked by
#4257).

red-hat-data-services#2684
@openshift-ci
openshift-ci Bot requested review from atheo89 and daniellutz August 2, 2026 20:52
@github-actions github-actions Bot added the review-requested GitHub Bot creates notification on #pr-review-ai-ide-team slack channel label Aug 2, 2026
@openshift-ci openshift-ci Bot added the size/m label Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The Kubernetes utility now accepts a configurable timeout. It bounds each kubernetes.stream.portforward call in a daemon thread and enforces a deadline across retries. It propagates connection exceptions and closes existing resources before retrying or raising TimeoutError. SocketProxy now catches TimeoutError, logs the remote connection failure, and continues accepting connections.

Estimated code review effort: 3 (Moderate) | ~25 minutes


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors)

Check name Status Explanation Resolution
Linked Issues check ❌ Error The PR does not address linked issue #2 requirements for Dockerfile labels, the OpenShift client, or the Makefile variable rename. Update the PR to implement issue #2, or link the correct issue for the port-forward timeout changes.
Out of Scope Changes check ❌ Error The port-forward timeout and SocketProxy changes are unrelated to the directly linked Dockerfile and Makefile objectives in issue #2. Remove the unrelated changes or update the linked issue to match the port-forward timeout scope.
✅ Passed checks (9 passed)
Check name Status Explanation
Branch Prefix Policy ✅ Passed Base is main, and the PR title fix(tests): bound exposing_contextmanager's portforward retry loop has no branch prefix.
Contribution Quality And Spam Detection ✅ Passed The code and PR body document a concrete hung-call failure, link issue #2684, and report three scenario tests; evidence does not support two qualifying signals from different categories.
No Hardcoded Secrets ✅ Passed No hardcoded secrets found in the two changed Python files; no CWE-798/CWE-259 pattern, credential URL, private key, or long base64 value was added.
No Weak Cryptography ✅ Passed The two changed files add timeout, threading, socket, and logging logic only; scans found no MD5, SHA-1, DES, RC4, Blowfish, ECB, custom crypto, or secret comparisons.
No Injection Vectors ✅ Passed Only tests/ files changed; added code uses API/socket calls and f-strings for messages, with no CWE-78, CWE-89, CWE-94, CWE-502, or CWE-79 injection pattern.
No Privileged Containers ✅ Passed PR changes only two Python test helpers; no Kubernetes/OpenShift manifest, Helm template, or Dockerfile changed, and the diff contains no prohibited privilege settings. No CWE/CVE applies.
No Sensitive Data In Logs ✅ Passed The changed log records a connection-failure message and exception text; the new TimeoutError contains only the pod name and timeout, with no credentials, tokens, PII, or raw bodies.
Title check ✅ Passed The title uses imperative wording, has no trailing period, and clearly describes the port-forward timeout change.
Description check ✅ Passed The description includes the required sections, detailed testing information, and completed self-checklist and merge criteria.

Comment @coderabbitai help to get the list of available commands.

pf = kubernetes.stream.portforward(
api_method=core_v1_api.connect_get_namespaced_pod_portforward,
name=pod.metadata.name,
namespace=pod.metadata.namespace,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 low: Consider using threading.Thread(..., daemon=True) explicitly via keyword argument or adding a brief docstring note about _portforward_with_timeout's thread cleanup semantics. Since the thread is daemonized and abandoned on timeout, it will terminate when the test process exits.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

CI status [antigravity]

Run: Build Notebooks (pr) #307666035733/3 complete · 1 passed · 2 skipped
Last updated: 2026-08-02T20:53:19Z

No workbench image jobs ran; all matrix jobs were skipped.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR implements a robust timeout mechanism for kubernetes.stream.portforward() calls in test utilities by running them in bounded daemon threads. This successfully prevents indefinite hangs when connecting to Kubernetes API servers and cleanly handles TimeoutError in the socket proxy listener.

🔍 General Feedback

  • The use of daemon threads with join(timeout) is a clean way to bound synchronous calls without relying on process-wide defaults, appropriately balancing test infrastructure robustness.

I have not posted any inline review comments as the changes are well-implemented and correct.

@openshift-ci openshift-ci Bot added size/m and removed size/m labels Aug 2, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 43.75%. Comparing base (052e6a8) to head (c9023d2).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #4273   +/-   ##
=======================================
  Coverage   43.75%   43.75%           
=======================================
  Files          45       45           
  Lines        5814     5814           
  Branches      974      974           
=======================================
  Hits         2544     2544           
  Misses       3053     3053           
  Partials      217      217           
Flag Coverage Δ
python 43.75% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 052e6a8...c9023d2. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@openshift-ci openshift-ci Bot added size/m and removed size/m labels Aug 2, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/containers/kubernetes_utils.py (1)

488-493: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the single-port join.

",".join(str(p) for p in [8888]) builds a string from a one-element list literal. Use the literal string directly for the same result with less code.

♻️ Proposed simplification
             pf = kubernetes.stream.portforward(
                 api_method=core_v1_api.connect_get_namespaced_pod_portforward,
                 name=pod.metadata.name,
                 namespace=pod.metadata.namespace,
-                ports=",".join(str(p) for p in [8888]),
+                ports="8888",
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/containers/kubernetes_utils.py` around lines 488 - 493, In the
portforward call, replace the one-element join expression in the ports argument
with the equivalent literal string "8888", leaving the surrounding Kubernetes
stream configuration unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/containers/kubernetes_utils.py`:
- Around line 466-507: Update _portforward_with_timeout so timed-out
port-forward operations cannot accumulate indefinitely across the pytest
process. Replace the abandoned daemon-thread approach with a cancellable or
isolated execution strategy, or enforce a process-wide limit that stops further
attempts after the permitted leak threshold; ensure repeated SocketProxy retries
are bounded and existing success, propagated-error, and timeout behavior remains
intact.

---

Nitpick comments:
In `@tests/containers/kubernetes_utils.py`:
- Around line 488-493: In the portforward call, replace the one-element join
expression in the ports argument with the equivalent literal string "8888",
leaving the surrounding Kubernetes stream configuration unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited), Repository UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 0b66bb09-e989-41c7-b5b3-4a18cafadefa

📥 Commits

Reviewing files that changed from the base of the PR and between 052e6a8 and c9023d2.

📒 Files selected for processing (2)
  • tests/containers/kubernetes_utils.py
  • tests/containers/socket_proxy.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • opendatahub-io/kubeflow (manual)
  • opendatahub-io/opendatahub-operator (manual)
  • opendatahub-io/odh-dashboard (manual)

Comment on lines +466 to +507
def _portforward_with_timeout(
core_v1_api: kubernetes.client.CoreV1Api, pod: kubernetes.client.models.V1Pod, timeout: float
) -> kubernetes.stream.ws_client.PortForward:
"""Runs kubernetes.stream.portforward() with a wall-clock bound.

The kubernetes client doesn't expose a per-call connect timeout for portforward() -- it calls
websocket.connect() with no timeout, so a wedged connection to the API server can hang this call
forever. The only alternative the library offers is a process-wide `websocket.setdefaulttimeout()`,
which isn't safe to use here since multiple SocketProxy instances (one per ImageDeployment) can be
making concurrent portforward() calls from different threads. So we run the call in a helper thread
and bound it with join(timeout) instead.

If the call doesn't return in time, it is abandoned rather than joined: the daemon thread and any
partially established connection are leaked for the remainder of the test process. That's an
accepted tradeoff for test infrastructure that's about to raise TimeoutError and fail the test
(and the process will exit soon after) rather than hang forever with no recovery.
"""
pf_result: list[kubernetes.stream.ws_client.PortForward] = []
error_result: list[Exception] = []

def _target() -> None:
try:
pf = kubernetes.stream.portforward(
api_method=core_v1_api.connect_get_namespaced_pod_portforward,
name=pod.metadata.name,
namespace=pod.metadata.namespace,
ports=",".join(str(p) for p in [8888]),
)
pf_result.append(pf)
except Exception as e: # propagated to the caller below, not swallowed
error_result.append(e)

thread = threading.Thread(target=_target, daemon=True)
thread.start()
thread.join(timeout)
if error_result:
raise error_result[0]
if not pf_result:
raise TimeoutError(
f"kubernetes.stream.portforward() to {pod.metadata.name} did not return within {timeout:.1f}s"
)
return pf_result[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the test invocation stops on first failure (limits leak accumulation).
set -euo pipefail
rg -n '\-x\b|--maxfail|exitfirst' Makefile tests/containers/pytest.ini pyproject.toml 2>/dev/null || true
fd -e ini -e cfg pytest --exec cat {} \;

Repository: opendatahub-io/notebooks

Length of output: 3239


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- CI pytest invocations ---'
rg -n -i --glob '.github/**' --glob '.tekton/**' --glob 'Makefile*' --glob '*.yml' --glob '*.yaml' --glob '*.sh' --glob '*.mk' 'pytest|maxfail|exitfirst| -x([[:space:]]|$)' . || true
printf '%s\n' '--- helper call sites and lifecycle ---'
rg -n '_portforward_with_timeout|portforward|class SocketProxy|def deploy\(' tests/containers tests 2>/dev/null | head -250
printf '%s\n' '--- relevant source context ---'
sed -n '430,540p' tests/containers/kubernetes_utils.py

Repository: opendatahub-io/notebooks

Length of output: 9058


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for p in Path(".").rglob("*"):
    if p.is_file() and (".github" in p.parts or ".tekton" in p.parts or p.name in {"Makefile", "pytest.ini", "pyproject.toml"}):
        try:
            s = p.read_text()
        except (UnicodeDecodeError, OSError):
            continue
        if "pytest" in s.lower() or "maxfail" in s.lower() or "exitfirst" in s.lower():
            print(f"{p}:")
            for i, line in enumerate(s.splitlines(), 1):
                if any(x in line.lower() for x in ("pytest", "maxfail", "exitfirst")):
                    print(f"  {i}: {line}")
PY

Repository: opendatahub-io/notebooks

Length of output: 5267


🏁 Script executed:

set -euo pipefail
printf '%s\n' '--- container-test workflow execution and failure policy ---'
sed -n '120,175p' .github/workflows/test-containers.yaml
sed -n '215,245p' .github/workflows/test-containers.yaml
printf '%s\n' '--- template workflow execution and failure policy ---'
sed -n '475,505p' .github/workflows/build-notebooks-TEMPLATE.yaml
sed -n '552,580p' .github/workflows/build-notebooks-TEMPLATE.yaml
printf '%s\n' '--- deployment and cleanup call graph ---'
sed -n '90,260p' tests/containers/kubernetes_utils.py
sed -n '500,555p' tests/containers/kubernetes_utils.py
sed -n '1,180p' tests/containers/socket_proxy.py

Repository: opendatahub-io/notebooks

Length of output: 25256


🏁 Script executed:

set -euo pipefail
python3 - <<'PY'
from pathlib import Path
needles = (
    "exposing_contextmanager(",
    "SocketProxy(",
    ".close(",
    "yield",
)
for path in [Path("tests/containers/kubernetes_utils.py"), Path("tests/containers/socket_proxy.py")]:
    print(f"--- {path} ---")
    lines = path.read_text().splitlines()
    for i, line in enumerate(lines, 1):
        if any(n in line for n in needles):
            lo, hi = max(1, i - 3), min(len(lines), i + 5)
            print("\n".join(f"{j}: {lines[j-1]}" for j in range(lo, hi + 1)))
            print()
PY

Repository: opendatahub-io/notebooks

Length of output: 6036


Bound abandoned port-forward attempts across the pytest process. CI runs pytest without -x or --maxfail, so tests continue after failures. Each timeout leaves a daemon thread, and the blocked client may retain sockets for the rest of the process. Repeated SocketProxy retries can accumulate these resources across tests (CWE-400). Use a cancellable or isolated operation, or enforce a process-wide leak limit that stops the session. Logging alone does not release resources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/containers/kubernetes_utils.py` around lines 466 - 507, Update
_portforward_with_timeout so timed-out port-forward operations cannot accumulate
indefinitely across the pytest process. Replace the abandoned daemon-thread
approach with a cancellable or isolated execution strategy, or enforce a
process-wide limit that stops further attempts after the permitted leak
threshold; ensure repeated SocketProxy retries are bounded and existing success,
propagated-error, and timeout behavior remains intact.

Source: Path instructions

@openshift-ci openshift-ci Bot added the lgtm label Aug 3, 2026
@openshift-ci

openshift-ci Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: ysok

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci openshift-ci Bot added the approved label Aug 3, 2026
@openshift-merge-bot
openshift-merge-bot Bot merged commit ef862b2 into main Aug 3, 2026
57 checks passed
@openshift-merge-bot
openshift-merge-bot Bot deleted the fix/socket-proxy-bounded-remote-timeout branch August 3, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved lgtm review-requested GitHub Bot creates notification on #pr-review-ai-ide-team slack channel size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants