Skip to content

Fix rerun poisoning in ConnectionTests, and move the test template off a retired preview API version - #1869

Open
Ewerton Scaboro da Silva (ewertons) wants to merge 6 commits into
mainfrom
fix-iothub-arm-apiversion
Open

Fix rerun poisoning in ConnectionTests, and move the test template off a retired preview API version#1869
Ewerton Scaboro da Silva (ewertons) wants to merge 6 commits into
mainfrom
fix-iothub-arm-apiversion

Conversation

@ewertons

@ewertons Ewerton Scaboro da Silva (ewertons) commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Two things: a fix for a test harness regression currently on main, and hygiene on the e2e test resource template. The template part does not fix an outage - see below.

Test harness fix

RerunFailedTestRule reuses one test instance for every attempt, so the "teardown has run" flag added in #1863 stayed set across reruns. Every attempt after the first published its fresh client and had it closed immediately, and open() failed with Client was closed while attempting to open the connection. One flaky timeout became a guaranteed failure of all three attempts, which is worse than before #1863.

Build 163484: run 3 acquired its device at 03:39:27.203 and logged "Closing device client" at 03:39:27.768, half a second later and before any connect.

Each setup now takes a generation number and teardown records the generation it covered, so an earlier attempt's teardown cannot reach a later attempt. The same check makes a superseded setup reclaim what it produced rather than publish it over the identity the running attempt is using.

Also adds logging around identity acquisition and around open. The stalls go silent after "Acquiring test device", which does not separate a setup stuck getting an identity from a connect that never completes.

Carried here rather than in its own PR because this PR cannot go green without it: the regression is on main and lands in this PR's merge commit.

Verified across the interleavings - three reruns, teardown mid setup, the ECC variant of that, a superseded setup overlapping a later attempt, normal ECC ordering, and repeated dispose. The rerun cases fail against the previous model and pass against this one.

ARM template hygiene

What changed

  • Microsoft.Devices/IotHubs moves from 2021-03-03-preview to GA 2023-06-30. It was the only preview pin in the template; every other resource was already on a GA version. Preview API versions are retired on a much shorter horizon than GA.
  • StorageEndpoints -> storageEndpoints, the documented casing. ARM matches property names case-insensitively, so file upload has always been configured correctly; the wrong casing was a schema warning, not a defect.
  • test-resources.json is regenerated from the bicep. That surfaced EnableIotHubSecuritySolution, a parameter the bicep had declared since January that the committed JSON was missing, so the two had drifted. It is referenced by nothing - no resource, no output, and not by the deploy script - and no security solution resource has ever existed here, so it is removed at the source rather than published. The emitted template therefore has the same seven parameters as main.

Correcting the original premise

This PR was opened believing the retired preview API version caused the deployCloudTestResources failures on 09-01. That was wrong. main deployed successfully at 163159 and 163283 without this change, and a re-run of another PR on the exact commit that had failed deployed fine with no change at all. The outage was transient and cleared on its own. A retired API version cannot intermittently succeed.

Judge this on the three items above, not as a fix for that.

Verification

  • bicep build succeeds; regenerating the JSON from the bicep reproduces the committed file byte for byte.
  • Diagnostics are 4x BCP073 (tier read-only), identical to what main's template produces with the same tool. The 2x BCP089 that the old casing produced are gone.
  • Emitted template diffed before and after: only the apiVersion string, that one key's casing, and generator metadata differ. Resource list, sku and outputs unchanged.

No Azure subscription access here, so the deployment itself is exercised by the gate rather than locally.

Note on the earlier red check

The Linux failure on this branch was CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_true], which had nothing to do with these two files. That test bounded itself with the same 60s the client gives one MQTT CONNECT round trip, so a stalled attempt and the test expired together. Fixed in #1863 and now on main, which this branch has been updated with.

Every pipeline has been failing in deployCloudTestResources since
2026-09-01, on main as well as on pull requests, before any test runs:

  ERROR: {"status":"Failed","error":{"code":"DeploymentFailed",
    "details":[{"code":"BadRequest", ...
  e2eTestsSetup.ps1:89 throw "Error running resource group deployment."

Nothing under vsts/E2ETestsSetup had changed since May, so the trigger is
service side. The template pinned Microsoft.Devices/IotHubs to
2021-03-03-preview; preview API versions are retired on a much shorter
horizon than GA ones, and a retired version fails exactly this way. Every
other resource in the template is already on a GA version.

Moved to 2023-06-30, the newest GA version. The properties the template
sets - eventHubEndpoints, cloudToDevice, messagingEndpoints,
StorageEndpoints, enableFileUploadNotifications, sku - are all GA stable
and unchanged.

test-resources.json is regenerated from the bicep, so it also picks up
EnableIotHubSecuritySolution, a parameter the bicep has declared since
January that the committed JSON was missing. It is never referenced, and
it defaults to false, so it changes nothing at deployment. Resource list
and outputs are otherwise identical.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The IoT Hub resource still uses StorageEndpoints casing, which does not match the documented storageEndpoints schema for Microsoft.Devices/IotHubs@2023-06-30 and may cause deployment/configuration failures under the new GA API.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Updates the Azure test-resource deployment templates used by deployCloudTestResources to stop using a retired preview IoT Hub API version, unblocking CI resource-group deployments for the Java SDK’s E2E test infrastructure.

Changes:

  • Update Microsoft.Devices/IotHubs from 2021-03-03-preview to GA 2023-06-30 in the Bicep source template.
  • Regenerate the ARM JSON (test-resources.json) from Bicep, updating generator metadata and syncing parameters (including EnableIotHubSecuritySolution with a default of false).
File summaries
File Description
vsts/E2ETestsSetup/test-resources.bicep Updates IoT Hub resource API version to GA 2023-06-30.
vsts/E2ETestsSetup/test-resources.json Regenerated ARM template reflecting the new IoT Hub apiVersion and synced parameter metadata.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread vsts/E2ETestsSetup/test-resources.bicep
The template wrote StorageEndpoints. ARM matches property names case
insensitively, which is why file upload has been configured correctly all
along, but the documented schema is storageEndpoints and the bicep type
checker is case sensitive, so the wrong casing produced a BCP089 warning
saying the property is not allowed.

Emitted template is byte identical apart from that one key, so this
changes no deployed configuration. It clears both BCP089 warnings, and it
means the template no longer relies on case insensitive matching.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

Correction to post on PR 1869 once GitHub access is restored

My earlier claim was wrong

I said the retired-preview-API diagnosis was "confirmed". It is not. Correcting.

Deploy outcomes for deployCloudTestResources:

Build Pipeline Branch Queued Has fix? Deploy
163048 Windows 1863 09-01 16:50 no failed
163054 Linux main 09-01 17:25 no failed
163055 Windows main 09-01 17:25 no failed
163057 Android main 09-01 17:25 no failed
163073/74/75 all three 1863 09-01 17:37 no failed
163159 Linux main 09-02 02:00 no SUCCEEDED
163172 Android main 09-02 06:00 no failed
163173 Windows main 09-02 06:00 no failed
163194/95/96 all three 1869 09-02 13:15 yes succeeded
163283 Linux main 09-03 02:00 no SUCCEEDED

163159 and 163283 are the problem for my theory: main, without this change, deploying fine. A retired API version cannot intermittently succeed. So the failures were intermittent and the retirement diagnosis is probably wrong.

The inner BadRequest message is truncated in the task log at a fixed length, so the actual cause is still unread. Teardown succeeded in every affected build, so orphaned resources hitting a quota does not explain it either.

The clean control is the next main Windows/Android nightly, which runs without this change. If its deploy succeeds, this PR fixed nothing.

What this PR is actually worth

Judge it as hygiene, not as an outage fix:

  • 2021-03-03-preview -> 2023-06-30 GA. Preview versions do get retired; being the only preview pin in the template was a real risk regardless of whether it caused this.
  • StorageEndpoints -> storageEndpoints, the documented casing. Clears both BCP089 warnings.
  • Regenerating the JSON also syncs EnableIotHubSecuritySolution, which the bicep has declared since January and the committed JSON was missing. Unreferenced, defaults to false.

Verified inert: emitted template diffed before/after is identical apart from the apiVersion string and that one key's casing. Resource list, sku and outputs unchanged. Bicep diagnostics identical to main built with the same tool (4x BCP073), so this introduces none.

I have no Azure access here, so the deployment itself is exercised only by the gate.

The Linux failure on this PR

163194 failed one test, CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_true], 60s timeout on JDK 21. Windows 163195 and Android 163196 passed.

This PR changes 2 ARM template files and no Java, gradle, pom, PowerShell, YAML or shell. It cannot affect a Java test.

That test is the dominant failure on main: over the last 13 main Linux builds, 8 failed, and 6 of those involved CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_*] (163159, 162834, 162803, 162703, 162611, 162121). Needs a re-run.

@ewertons

Copy link
Copy Markdown
Contributor Author

Why Linux JDK 21 failed here

I called this "a known flake" without reading it. That was lazy. It has a specific cause, and it is not random.

ConnectionTests.CanOpenConnection:277 -> Object.wait:366 -> Object.wait0 » TestTimedOut
test timed out after 60000 milliseconds   (3 attempts, 180s total)
Tests run: 497, Failures: 0, Errors: 1, Skipped: 339

Line 277 on main is open(true). The thread is parked in Object.wait inside the open, so the connection never establishes; it is not the close.

The variant is [MQTT_WS_SAS_DEVICE_CLIENT_true_true]. Those trailing flags are useHttpProxy=true, useHttpProxyAuth=true, so it runs through the authenticated proxy the test class starts locally.

It is the proxied variants, every time

ConnectionTests has 28 parameter combinations: 12 proxied, 16 not. Every CanOpenConnection failure in the recent Linux builds is a proxied one - 163194, 163159, 162834, 162803, 162703, 162611, 162121, all _true_false or _true_true. Zero non-proxied failures, from the larger half of the matrix. Random flakiness would put roughly 57% of them in the non-proxied variants.

Why this branch is exposed

This PR branches from main, and on main that class still has both defects:

  • proxyServer.startAsync(port) on lines 217 and 222, return value discarded. The future that completes when the port is listening is thrown away, so a test can send traffic to a proxy that is not accepting yet.
  • dispose() is defined on line 184 and has 0 callers. There is no import org.junit.After in the file. Every client the class opens is leaked and keeps retrying through those same local proxies for the life of the JVM.

Both are exactly what #1863 fixes: on that branch ProxyServerTools.startProxyServer appears twice and testInstance.dispose() is called from an @After.

So this is not this PR's doing - it changes 2 ARM template files, no Java, gradle, pom, PowerShell, YAML or shell - and it is not unexplained. It is a real defect in the test harness that is fixed in #1863 and not yet merged.

Ordering note: #1863 is currently blocked behind this PR, since its Windows and Android jobs were failing in resource deployment before reaching any test. Whether that deployment failure was actually fixed by this change is still unproven - see my previous comment; main has since deployed successfully twice without it.

@ewertons

Copy link
Copy Markdown
Contributor Author

The Linux failure here is not stale, but it is the only thing red

Checks on this PR point at exactly three builds, all from 09-02 13:15 on 75d0070b4, and no build has run since:

Pipeline Build Result
Java Linux 163194 failed
Java Windows 163195 succeeded
Java Android 163196 succeeded

Within 163194, Linux JDK 21 is attempt 1 with no retry. Its sibling jobs - JDK 8, 11, 17, deploy and teardown - all passed, which is probably what looked like new passing runs. Only JDK 21 failed, on CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_true].

The premise for this PR is now disproven

I said earlier this was unproven. It is now settled, against it.

PR #1863 was re-run on the same commit that had failed in resource deployment, with no change of any kind, and deployCloudTestResources succeeded on both Windows 163288 and Android 163289 - the exact step that had failed on 163074 and 163075. main had already deployed cleanly at 163159 and 163283.

So the deployment outage was transient and cleared on its own. This change did not fix it.

What this PR is still worth

Hygiene, and it should be judged only as that:

  • Microsoft.Devices/IotHubs moves off 2021-03-03-preview, the only preview pin in the template, onto GA 2023-06-30. Preview versions do get retired eventually; that risk was real even though it is not what happened here.
  • StorageEndpoints -> storageEndpoints, the documented casing, clearing both BCP089 warnings.
  • Regenerating the JSON syncs EnableIotHubSecuritySolution, which the bicep has declared since January and the committed JSON was missing.

Verified inert: emitted template is identical apart from the apiVersion string and that one key's casing; resource list, sku and outputs unchanged; bicep diagnostics identical to main built with the same tool.

Reasonable to close this if the drift and the preview pin are not considered worth carrying on their own.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The changes are low-risk template hygiene (GA apiVersion + casing + regeneration) and do not introduce functional logic changes that would block merging.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread vsts/E2ETestsSetup/test-resources.json Outdated
EnableIotHubSecuritySolution has been declared in the bicep since January
and is referenced nowhere: not by another parameter, not by a resource,
not by an output, and not by e2eTestsSetup.ps1, which passes only
StorageAccountName and HubUnitsCount. No security solution resource has
ever existed in this template, so it was never wired to anything.

It stayed invisible because the committed JSON had drifted from the bicep
and did not carry it. Regenerating the JSON in this change would have
published a dead parameter to callers, so it is removed at the source
instead.

The emitted template now has the same seven parameters as before, so this
removes the only parameter that regenerating would otherwise have added.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

The Linux JDK 21 failure is not from this PR, and it is a regression in main

Windows 163486 and Android 163487 passed. Linux 163484 failed on tokenRenewalWorks and CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_false]. This PR changes two ARM template files and no Java, so it cannot affect either.

The CanOpenConnection failure exposes a defect in #1863, which I wrote and which is now on main:

03:35:27.194  Starting test: CanOpenConnection[MQTT_WS_SAS_DEVICE_CLIENT_true_false]
03:35:27.197  Acquiring test device from testSasDeviceQueue
              ... 120s, no connection status transition ...
03:37:27.200  Test failed on run 1, test timed out after 120000 ms. Rerunning.
              ... 120s ...
03:39:27.202  Test failed on run 2, test timed out after 120000 ms. Rerunning.
03:39:27.203  Acquiring test device from testSasDeviceQueue
03:39:27.768  Closing device client...              <-- 0.5s in, before any connect
03:40:27.205  Device client closed                  <-- 60s in close()
03:40:27.205  Test failed on final rerun

Two separate things.

The rerun poisoning is mine. ConnectionTestInstance.disposed is set in dispose() and never reset, and RerunFailedTestRule reuses the same instance across attempts. So once the first attempt's @After has run, every later attempt calls setup(), hands its fresh client to trackForCleanup, and disposeIfTeardownAlreadyRan() immediately closes it - which is the 0.5s "Closing device client" above. open(true) then fails with Client was closed while attempting to open the connection. A single flaky timeout is converted into a guaranteed three attempt failure. That is worse than the behaviour before #1863 and needs fixing on main.

The underlying stall is not fixed. Run 1 had no prior dispose, so it is unaffected by the above, and it still burned 120s with no status transition. Raising the bound from 60s to 120s did not make this survivable, which is what I hoped it would do. I said at the time that it made a stall survivable rather than stopping stalls; that claim was too optimistic for a stall of this length.

Neither belongs on this PR. I will raise the rerun fix separately against main.

…open

RerunFailedTestRule reuses the same test instance for every attempt at a
test, so "teardown has run" could not be a plain flag on that instance.
It was. Once the first attempt's @after had run, every later attempt
called setup(), published its fresh client, and disposeIfTeardownAlreadyRan
immediately closed it. open() then failed with "Client was closed while
attempting to open the connection", turning one flaky timeout into a
guaranteed failure of all three attempts.

Build 163484 shows it: run 3 acquired its device at 03:39:27.203 and
logged "Closing device client" at 03:39:27.768, half a second later and
before any connect.

Each setup now takes a generation number and teardown records the
generation it covered, so a setup only cleans up after itself when
teardown has run for its own generation. beginSetup also reclaims any
residue from a previous attempt first, which is normally nothing.

The same generation check makes a superseded setup safe: a setup
abandoned by the timeout that finishes after a later attempt has started
now reclaims what it produced instead of publishing it over the identity
the running attempt is using.

Also logs around identity acquisition and around open. The stalls seen so
far go silent after "Acquiring test device", which does not distinguish a
setup stuck getting an identity from a connect that never completes.
These three lines separate the two.

Verified the lifecycle across the interleavings: three reruns, teardown
mid setup, the ECC variant of that, a superseded setup overlapping a
later attempt, normal ECC ordering, and repeated dispose. The rerun cases
fail against the previous model and pass against this one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons Ewerton Scaboro da Silva (ewertons) changed the title Move the IoT hub in the test template off a retired preview API version Fix rerun poisoning in ConnectionTests, and move the test template off a retired preview API version Sep 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The updated ConnectionTests lifecycle code still has concurrency/cleanup edge cases (atomic publication under lock and avoiding double ECC deletion) that should be addressed to prevent flaky behavior and misleading CI errors.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

…devices twice

Two follow ups on the generation scoped lifecycle.

trackForCleanup set identityToDispose under lifecycleLock but assigned
the public identity field after releasing it. dispose() is explicitly
allowed to run concurrently with setup, so it could interleave between
the two writes and claim the identity before the test body could see it.
Both writes now happen under the same lock, so what teardown owns and
what the test body sees change together.

disposeSupersededIdentity deleted the ECC device as well as closing the
client. That device is already owned elsewhere: setupEccDevice always
registers it and calls trackEccDeviceForCleanup before building the
identity, so either that call removed it directly when it was superseded
at registration, or the dispose that superseded the attempt removed it.
Deleting again logged a not found error over a cleanup that had already
succeeded. The superseded path now only reclaims the client.

Verified both against the interleavings, including an ECC attempt
superseded after registration and one superseded at registration. The
double delete reproduces against the previous code and does not against
this one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ewertons

Copy link
Copy Markdown
Contributor Author

The new logging localises the stall: it is open(), and it is JDK 21 only

Build 163502 ran on dc3f070e1. Windows and Android passed in full. Linux failed on Linux JDK 21 only.

The rerun fix worked

The error is now test timed out after 120000 milliseconds, 360007ms total for three clean 120s attempts. It is no longer Client was closed while attempting to open the connection, and there is no sub-second "Closing device client" before a connect. One flaky timeout is no longer converted into three guaranteed failures.

Where it hangs

Counting the new markers in the failing job:

marker count
Acquiring test identity 6
Test identity acquired 7
Opening client 8
Client opened 4

Every acquisition completes, consistently in about 0.53s. Half the opens never complete. So the stall is inside client.open(true), not in Tools.getTestDevice and not in the shared queue lock. That was the question the previous logs could not answer.

It is not random

Linux JDK 21 is the failing job in every build where this has appeared - 163073, 163194, 163484, 163502 - while Linux JDK 8, 11 and 17 passed in all four, and Windows JDK 21 passed. Four for four on one JDK, on one platform, is not flakiness.

That reframes it. This is not general proxy starvation, which would not respect a JDK boundary. Something about the MQTT-over-WebSocket connect through the local proxy behaves differently on JDK 21 on Linux. The most likely candidates are the TLS defaults that changed after JDK 8 and the WebSocket upgrade path, but I have not proven either and will not guess further without evidence.

Worth noting CanOpenConnection carries no @FlakeyTest, so this runs in gate builds, and Linux JDK 21 is a required check.

Two review comments addressed

Both were correct and are fixed in 79785f079:

  • trackForCleanup published identity outside lifecycleLock while setting identityToDispose inside it. Both writes now happen under the lock.
  • disposeSupersededIdentity deleted the ECC device that trackEccDeviceForCleanup or the superseding dispose() had already removed, logging a not-found error over a cleanup that had succeeded. The superseded path now reclaims only the client.

@ewertons

Copy link
Copy Markdown
Contributor Author

Correction: this is not JDK 21 only

My previous comment said "four for four on one JDK, on one platform, is not flakiness". That was wrong, and the sample was selection biased - the four builds I cited were the ones I had been investigating, which were the JDK 21 failures.

Across the last 25 completed Java Linux builds, counting every JDK job:

Job Failures
Linux JDK 21 9
Linux JDK 8 5
Linux JDK 17 3
Linux JDK 11 1

Every JDK fails sometimes. 14 of those 25 builds had at least one JDK job fail.

What survives is a skew, not exclusivity. JDK 11, 17 and 21 run the identical 499 test set, and on that like for like comparison JDK 21 fails 9 times against 3 and 1. JDK 8 is not comparable, since it runs the full 2274 test suite including unit tests.

So JDK 21 is disproportionately affected and worth investigating first, but any theory that requires it to be JDK 21 exclusive is already contradicted. I am not treating "it must be a JDK 21 behaviour change" as established.

What does still hold, from the logging in dc3f070e1:

  • Identity acquisition always completes, consistently in about 0.53s.
  • Half the opens in the failing job never complete: Opening client 8, Client opened 4.

So the stall is inside client.open(true) rather than in Tools.getTestDevice or the shared queue lock. That part is measured rather than inferred, and it is what the next investigation starts from.

@ewertons

Copy link
Copy Markdown
Contributor Author

Local reproduction attempt: the proxy plus TLS path is not it

Tried to reproduce without a hub, by driving the SDK's own proxied TLS path the way this class does: an embedded proxyee 1.7.6 proxy in-process, a local TLS server, clients built through the SDK's HttpProxySocketFactory and ProxiedSSLSocket, forcing the handshake.

JDK handshakes stalls >5s errors p50 max
8 180 0 0 24ms 390ms
11 180 0 0 19ms 447ms
17 180 0 0 21ms 486ms
21 180 0 0 18ms 415ms

Repeated pinned to 2 CPUs, matching the hosted agent, with 12 concurrent threads and 360 handshakes: still 0 stalls, max 664ms, on both 8 and 21.

Negative result, and a useful one. The proxy plus TLS path alone does not reproduce this under any load I can generate, and JDK 21 was marginally the fastest. Proxy starvation as a standalone explanation is not supported by this.

What the harness did not cover, and what the investigation moves to next:

  1. The WebSocket upgrade. The failing variant is MQTT_WS. The harness did raw TLS through the tunnel; it never exercised the WebSocket upgrade Paho layers on top. That is now the most likely place to look.
  2. A real service endpoint: real round trip times, a real certificate chain, SNI, and a peer that can be slow.
  3. The full suite running concurrently rather than one class alone.

This does not block this PR. Its own changes are the template hygiene and the rerun poisoning fix, and the rerun fix is confirmed working in 163502: the failure is now a clean 120s timeout instead of three attempts poisoned into immediate failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants