Skip to content

Commit b3bf3e8

Browse files
DennisDyalloclaude
andcommitted
refactor(testutils): extract macOS device-cache warm-up into shared helper
The PreviewSignTests static ctor's polling block defeats a YubiKeyDeviceListener startup race specific to macOS (the IOKit run loop arms on a background thread and Update() can return before the first arrival callback fires). The same race will affect any other Fido2/Hid integration test class whose base constructor enumerates devices, so promote the workaround into DeviceListenerCacheWarmup.WaitForFirstDevice() in TestUtilities. PreviewSignTests now calls the helper from its static ctor; behavior is unchanged. Other test classes can adopt the helper as needed. Underlying SDK race remains unfixed and tracked separately — a real fix needs to drive synchronous initial enumeration through MacOSHidDeviceListener or have YubiKeyDeviceListener.Update() wait for the first callback pass, both of which are larger than this PR's scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cef578c commit b3bf3e8

2 files changed

Lines changed: 76 additions & 30 deletions

File tree

Yubico.YubiKey/tests/integration/Yubico/YubiKey/Fido2/PreviewSignTests.cs

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,36 +29,10 @@ namespace Yubico.YubiKey.Fido2
2929
/// </remarks>
3030
public class PreviewSignTests : FidoSessionIntegrationTestBase
3131
{
32-
// Wait for the YubiKeyDeviceListener's internal cache to populate
33-
// before any instance constructor runs. On macOS the listener's
34-
// _internalCache (read by FindByTransport(All).GetAll()) is
35-
// populated asynchronously by background USB notifications; without
36-
// this poll the base class ctor's GetSession() call races the
37-
// listener and throws DeviceNotFoundException even when a supported
38-
// YubiKey is plugged in.
39-
//
40-
// Polls every 100ms for up to 5 seconds. Returns as soon as at least
41-
// one device is visible, OR after the timeout (in which case the
42-
// tests SKIP cleanly via [SkippableFact(typeof(DeviceNotFoundException))]).
43-
static PreviewSignTests()
44-
{
45-
try
46-
{
47-
for (int i = 0; i < 50; i++)
48-
{
49-
if (YubiKeyDevice.FindAll().Any())
50-
{
51-
return;
52-
}
53-
System.Threading.Thread.Sleep(100);
54-
}
55-
}
56-
catch
57-
{
58-
// Swallow — if enumeration throws, tests SKIP via
59-
// DeviceNotFoundException anyway.
60-
}
61-
}
32+
// Defeat the macOS YubiKeyDeviceListener startup race before the
33+
// base class's instance ctor runs. See DeviceListenerCacheWarmup
34+
// for the full rationale.
35+
static PreviewSignTests() => DeviceListenerCacheWarmup.WaitForFirstDevice();
6236

6337
[SkippableFact(typeof(DeviceNotFoundException))]
6438
public void MakeCredentialWithPreviewSign_ReturnsGeneratedKey()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright 2025 Yubico AB
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License").
4+
// You may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Linq;
16+
using System.Threading;
17+
18+
namespace Yubico.YubiKey.TestUtilities
19+
{
20+
/// <summary>
21+
/// Workaround for a YubiKeyDeviceListener startup race observed on macOS:
22+
/// MacOSHidDeviceListener arms its IOKit run loop on a background thread,
23+
/// so the first call to YubiKeyDeviceListener.Update() (which runs
24+
/// synchronously in the listener's constructor) can complete before the
25+
/// HID layer has fired its initial arrival callbacks. The result is a
26+
/// transient empty cache for ~hundreds of milliseconds after the listener
27+
/// is created, which makes test base classes that enumerate in their
28+
/// instance constructor see DeviceNotFoundException even when a device is
29+
/// plugged in.
30+
///
31+
/// Intended use: invoke from a test class's static constructor (runs once,
32+
/// before any [Fact] body) so the listener has time to populate before
33+
/// instance construction kicks off.
34+
///
35+
/// SDK fix tracked separately. Until then, every Fido2/Hid integration
36+
/// test class that doesn't already do its own warm-up should call this.
37+
/// </summary>
38+
public static class DeviceListenerCacheWarmup
39+
{
40+
/// <summary>
41+
/// Poll YubiKeyDevice.FindAll() until it returns at least one device,
42+
/// or until the timeout elapses. Returns silently in either case;
43+
/// downstream tests are expected to SKIP cleanly via
44+
/// DeviceNotFoundException if no device materialises.
45+
/// </summary>
46+
/// <param name="timeoutMilliseconds">Total wall-clock budget. Default 5s.</param>
47+
/// <param name="pollIntervalMilliseconds">Poll interval. Default 100ms.</param>
48+
public static void WaitForFirstDevice(
49+
int timeoutMilliseconds = 5000,
50+
int pollIntervalMilliseconds = 100)
51+
{
52+
try
53+
{
54+
int iterations = timeoutMilliseconds / pollIntervalMilliseconds;
55+
for (int i = 0; i < iterations; i++)
56+
{
57+
if (YubiKeyDevice.FindAll().Any())
58+
{
59+
return;
60+
}
61+
62+
Thread.Sleep(pollIntervalMilliseconds);
63+
}
64+
}
65+
catch
66+
{
67+
// Swallow — if enumeration throws, downstream test skip path
68+
// (DeviceNotFoundException) handles user feedback.
69+
}
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)