Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Fix flaky test_command_timeout_fail in SSH provider - #65864

Merged
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh
Jul 31, 2026
Merged

Fix flaky test_command_timeout_fail in SSH provider#65864
potiuk merged 1 commit into
apache:mainfrom
Dev-iL:2604/deflake_ssh

Conversation

@Dev-iL

Copy link
Copy Markdown
Collaborator

Context

TestSSHHook::test_command_timeout_fail was intermittently failing in CI.

The original test opened a real SSH connection to ssh_default, ran sleep 1 with a 1 ms (0.001) timeout, and expected AirflowException to be raised. Two independent timeout mechanisms were racing:

  1. paramiko's channel-level socket timeout.
    exec_command(timeout=0.001) calls Channel.settimeout(0.001), which makes every subsequent recv / send on the channel raise socket.timeout after 1 ms. This fires inside paramiko's own transport thread and during any blocking read the channel performs internally.

  2. Airflow's select-loop timeout.
    exec_ssh_client_command passes cmd_timeout as the fourth argument to select([channel], [], [], cmd_timeout). When select returns an empty list, the function sets timedout = True and eventually raises AirflowException("SSH command timed out").

With a 1 ms deadline both mechanisms fire at roughly the same instant. Depending on thread scheduling, CPU load, and kernel select granularity:

  • Happy path (test passes):select returns first with an empty read list, Airflow's own timeout logic triggers, and the test catches AirflowException.
  • Unhappy path (test fails): paramiko's channel-level socket.timeout fires first—inside the transport thread or during the stdin.close() / channel.shutdown_write() calls that happen before the select loop is even reached. That surfaces as paramiko.ssh_exception.SSHException or socket.timeout, neither of which matches the AirflowException the test expects.

Because the race depends on real wall-clock time, it is non-deterministic. A fast CI runner tips the odds one way; a loaded one tips them the other.

What the fix does

The fix removes the real SSH connection entirely and replaces it with mocks that deterministically exercise the timeout detection logic inside exec_ssh_client_command.

The key mock is on select:

deffake_select(rlist, wlist, xlist, timeout=None):
asserttimeout==pytest.approx(0.001)
return [], [], []

Returning ([], [], []) simulates select reporting "nothing readable within the timeout window." The production code then follows its normal path:

readq, _, _=select([channel], [], [], cmd_timeout)
ifcmd_timeoutisnotNone:
timedout=notreadq# True, because readq is []
...
if ... ortimedout:
stdout.channel.shutdown_read()
stdout.channel.close()
break
...
iftimedout:
raiseAirflowException("SSH command timed out")

There is no second timeout mechanism in play. paramiko.SSHClient is a MagicMock(spec=...), so exec_command returns instantly with mock objects. stdin.close() and channel.shutdown_write() are no-ops on the mock, so they can never raise a socket-level exception. The only timeout that fires is Airflow's, which is exactly what the test is verifying.

What the fix verifies beyond the original test

The original test had a single assertion: "an AirflowException is raised." The new test adds:

AssertionWhat it catches
match="SSH command timed out" on pytest.raisesWrong exception message or wrong exception type
fake_select asserts timeout == 0.001cmd_timeout not threaded through to select
exec_command.assert_called_once_with(command=..., timeout=0.001, ...)cmd_timeout not passed to paramiko
mock_stdin.close.assert_called_once()Missing stdin cleanup
mock_channel.shutdown_write.assert_called_once()Missing write-direction shutdown
mock_channel.shutdown_read.assert_called_once()Missing read-direction shutdown on timeout
mock_channel.close.assert_called_once()Channel not closed on timeout
mock_stdout.close.assert_called_once()stdout file object not closed
mock_stderr.close.assert_called_once()stderr file object not closed

All mocks use spec= against the real paramiko types (paramiko.Channel, paramiko.ChannelFile, paramiko.ChannelStdinFile, paramiko.ChannelStderrFile, paramiko.SSHClient), so accessing a misspelled attribute on any mock will raise AttributeError immediately rather than silently returning a new MagicMock.

What the fix does NOT do

It does not test the data-reading path (stdout/stderr aggregation), the graceful-exit path (command finishes before timeout), or the channel.close() race condition handler (lines 492-498). Those are separate behaviors covered by other tests (test_command_timeout_success, test_command_timeout_not_set, and the broader exec_ssh_client_command integration tests). This test is scoped to one thing: the timeout detection and cleanup path.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Opus 4.6 following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

The test_command_timeout_fail test was flaky because it relied on real SSH
connections and a 1ms timeout, causing paramiko's internal socket timeouts
to fire before the select loop could catch them.
Replaced real SSH connection with mocks for deterministic testing:
- Mock paramiko.SSHClient.exec_command to return controlled channel objects
- Mock select to simulate immediate timeout
- Use spec on all mocks (Channel, ChannelFile, ChannelStdinFile,
ChannelStderrFile) to catch attribute typos
- Verify cmd_timeout is correctly threaded through to both exec_command
and select calls
- Assert cleanup side effects: stdin.close, shutdown_write, shutdown_read,
channel.close, stdout.close, stderr.close

@potiukpotiuk left a comment

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.

Thanks — and this fixes the flakiness at the right level rather than by tuning the timing.

The old test opened a real SSH connection and ran sleep 5, relying on cmd_timeout landing in a narrow window: paramiko passes that value to both channel-opening and output-reading, so too small produced Timeout opening channel. on a loaded runner, and too large let the command finish first. The previous fix widened it to 0.5s, which reduced the flake without removing the race — the test still depended on runner load.

Mocking the channel and select removes the timing dependence entirely: there's no real channel to open, so the constraint the old comment documented no longer applies, and cmd_timeout=0.001 is now just a value being threaded through rather than a wall-clock bet. That's why dropping that comment is correct here rather than a loss.

The replacement also tests more than the original did. fake_select asserting timeout == pytest.approx(0.001) and the exec_command(..., timeout=0.001) assertion both pin that cmd_timeout actually reaches paramiko — the thing the test is nominally about, which the old version never checked. And the six cleanup assertions (stdin.close, shutdown_write, shutdown_read, channel.close, stdout.close, stderr.close) are new coverage: resource cleanup on the timeout path wasn't verified at all before.

Two small things, neither blocking. assert mock.call() in mock_stdin.close.call_args_list reads more indirectly than mock_stdin.close.assert_called() for what it's checking. And the trade being made is worth naming: this is now a pure unit test, so it can no longer catch a change in paramiko's own behaviour — that's the right call for a flaky test, with real-connection coverage belonging in integration tests.


Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting

@potiuk
potiuk merged commit 41eff9b into apache:mainJul 31, 2026
82 checks passed
@Dev-iL
Dev-iL deleted the 2604/deflake_ssh branch August 1, 2026 03:42
dabla pushed a commit to dabla/airflow that referenced this pull request Aug 14, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:providersprovider:sshready for maintainer reviewSet after triaging when all criteria pass.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@Dev-iL@potiuk