diff --git a/.github/workflows/sdlhost-autoconnect.yml b/.github/workflows/sdlhost-autoconnect.yml new file mode 100644 index 0000000..d357386 --- /dev/null +++ b/.github/workflows/sdlhost-autoconnect.yml @@ -0,0 +1,59 @@ +name: SDLHost automatic-discovery regressions +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: sdlhost-autoconnect-${{ github.ref }} + cancel-in-progress: true +jobs: + native-host: + runs-on: ubuntu-24.04 + container: swift:6.2.1-noble + timeout-minutes: 25 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: libsdl-org/SDL + ref: fa2c02bb6e21974a89ea9824bc53c9932abe5f9c + path: build/SDL + persist-credentials: false + - name: Preserve the exact SDL input for reproduction + run: | + git config --global --add safe.directory "$PWD/build/SDL" + test "$(git -C build/SDL rev-parse HEAD)" = fa2c02bb6e21974a89ea9824bc53c9932abe5f9c + git -C build/SDL archive HEAD -o "$RUNNER_TEMP/sdl-source.zip" + - name: Retain pinned SDL source + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: sdlhost-pinned-source + path: ${{ runner.temp }}/sdl-source.zip + retention-days: 3 + - name: Install native build tools + run: | + apt-get update + apt-get install -y --no-install-recommends cmake ninja-build make g++ python3 libsystemd0 + - name: Execute real host, C facade and SDL consumers + env: + S2K_SDL_SOURCE: ${{ github.workspace }}/build/SDL + S2K_EXPECT_SDL_VERSION: 3004016 + run: | + set -o pipefail + bash tests/emulator-host/verify.sh 2>&1 | tee native-host.log + - name: Native host diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: sdlhost-autoconnect-diagnostics + path: native-host.log + if-no-files-found: warn + retention-days: 7 diff --git a/Integrations/Emulators/SDLHost.hpp b/Integrations/Emulators/SDLHost.hpp index bcda4e9..6a4cb5c 100644 --- a/Integrations/Emulators/SDLHost.hpp +++ b/Integrations/Emulators/SDLHost.hpp @@ -9,8 +9,9 @@ namespace Switch2Kit { /** Application-owned session for an SDL emulator. There is no process singleton. - * initialize/discover must first run on the macOS main thread. pump belongs to - * the emulator's input loop. Other calls are serialized; stop before SDL quits. + * On macOS, the first call allocating a context (initialize, policy selection, + * start or discover) must run on the main thread. pump belongs to the emulator's + * input loop. Other calls are serialized; stop before SDL quits. * The library remains loaded until process exit. This owner never starts a worker. */ class SDLHost final { @@ -28,6 +29,21 @@ class SDLHost final { context_ = s2k_create(nullptr, &error_); return error_; } + /** Select continuous or on-demand discovery without starting support. + * Hosts own consent and persistence. Disabling preserves ready controllers; + * a stopped host remains stopped. BUSY is returned while stop is finishing. */ + S2KResult setAutomaticDiscovery(bool enabled) { + if (const auto result = initialize(); result != S2K_OK) return result; + Guard lock(mutex_); + return error_ = s2k_set_automatic_discovery(context_, enabled ? 1u : 0u); + } + /** Start with the selected policy, without opening a finite discovery window. + * Call once after saved consent, or as an explicit user action, not per pump. */ + S2KResult start() { + if (const auto result = initialize(); result != S2K_OK) return result; + Guard lock(mutex_); + return error_ = s2k_start(context_); + } /** Explicit settings action: start support and open one bounded scan window. */ S2KResult discover(double seconds = 60.0) { if (const auto result = initialize(); result != S2K_OK) return result; diff --git a/tests/emulator-host/main.cpp b/tests/emulator-host/main.cpp index 1dfa9d6..fa00374 100644 --- a/tests/emulator-host/main.cpp +++ b/tests/emulator-host/main.cpp @@ -1,16 +1,146 @@ #include #include +#include #include #include extern "C" { S2KContext* test_input_create(); void test_input_report(S2KContext*, int32_t, uint32_t, const S2KState*); void test_input_retire(S2KContext*, int32_t); +uint32_t test_input_automatic_discovery(S2KContext*); +uint32_t test_input_discovery_configuration_count(S2KContext*); +uint32_t test_input_start_count(S2KContext*); +uint32_t test_input_discovery_count(S2KContext*); +uint32_t test_input_finish_stop(S2KContext*); +} +// Execute the production SDLHost, C context and SDL3 adapter. Only the radio +// source is controlled; this is not physical Bluetooth qualification. +static void onDemandPolicyTests() { + auto* fixture = test_input_create(); assert(fixture); + Switch2Kit::SDLHost host(fixture); + // No saved consent: selecting the default policy must not touch the radio. + assert(host.setAutomaticDiscovery(false) == S2K_OK); + assert(test_input_discovery_configuration_count(fixture) == 0); + assert(test_input_start_count(fixture) == 0 && !host.snapshot().running); + assert(host.start() == S2K_OK && host.start() == S2K_OK); + for (int i = 0; i < 100; ++i) assert(host.pump() == S2K_OK); + assert(host.snapshot().running && test_input_start_count(fixture) == 1); + assert(test_input_automatic_discovery(fixture) == 0); + assert(test_input_discovery_count(fixture) == 0); + assert(host.stop() == S2K_OK && host.snapshot().stopping); + assert(host.discover() == S2K_BUSY); + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + assert(test_input_finish_stop(fixture) == 1); + // Changing either policy while stopped is configuration, not a restart. + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(host.setAutomaticDiscovery(false) == S2K_OK); + assert(!host.snapshot().running && !host.snapshot().stopping); + assert(test_input_start_count(fixture) == 1); + assert(host.start() == S2K_OK && test_input_start_count(fixture) == 2); + assert(test_input_automatic_discovery(fixture) == 0); + assert(test_input_discovery_count(fixture) == 0); + assert(host.stop() == S2K_OK && test_input_finish_stop(fixture) == 1); + host.shutdown(); + std::puts("PASS policy-only start defaults to on-demand, busy discovery and stopped opt-out"); +} +static void automaticDiscoveryTests() { + auto* fixture = test_input_create(); assert(fixture); + Switch2Kit::SDLHost host(fixture); + assert(host.initialize() == S2K_OK && !host.snapshot().running); + assert(test_input_automatic_discovery(fixture) == 0); + assert(test_input_start_count(fixture) == 0); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(test_input_automatic_discovery(fixture) == 1); + assert(test_input_discovery_configuration_count(fixture) == 1); + assert(!host.snapshot().running && test_input_start_count(fixture) == 0); + assert(host.start() == S2K_OK && host.start() == S2K_OK); + assert(host.snapshot().running && test_input_start_count(fixture) == 1); + assert(test_input_discovery_count(fixture) == 0); + + S2KState state{}; state.sequence = 1; + test_input_report(fixture, 0, S2K_PRO, &state); + test_input_report(fixture, 1, S2K_PRO, &state); + assert(host.pump() == S2K_OK); + const std::string first = "s2k:00000000000000000000000000000001"; + const std::string second = "s2k:00000000000000000000000000000002"; + const auto original = host.instance(first), other = host.instance(second); + assert(original && other && original != other); + auto* pad = SDL_OpenGamepad(original); assert(pad); + state.sequence++; state.buttons = S2K_BUTTON_A; + test_input_report(fixture, 0, S2K_PRO, &state); + assert(host.pump() == S2K_OK && SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + state.sequence++; state.buttons = 0; + test_input_report(fixture, 0, S2K_PRO, &state); + // A policy change must neither consume the queued release nor detach input. + assert(host.setAutomaticDiscovery(false) == S2K_OK); + assert(test_input_automatic_discovery(fixture) == 0); + assert(SDL_GamepadConnected(pad) && SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + assert(host.instance(first) == original && host.instance(second) == other); + assert(host.pump() == S2K_OK && !SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_EAST)); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + for (int i = 0; i < 1000; ++i) { + assert(host.pump() == S2K_OK && host.snapshot().count == 2); + } + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + assert(test_input_discovery_configuration_count(fixture) == 3); + + // Settings can change policy while the input loop and enumeration are live. + // Exercise the production SDL-before-host lock order without timing sleeps. + std::atomic inputReady{false}, policyDone{false}; + std::thread settings([&] { + while (!inputReady.load()) std::this_thread::yield(); + for (int i = 0; i < 500; ++i) { + assert(host.setAutomaticDiscovery(false) == S2K_OK); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + assert(host.instance(first) == original && host.instance(second) == other); + } + policyDone.store(true); + }); + inputReady.store(true); + do { + assert(host.pump() == S2K_OK && host.snapshot().count == 2); + assert(SDL_GamepadConnected(pad)); + } while (!policyDone.load()); + settings.join(); + assert(test_input_discovery_configuration_count(fixture) == 1003); + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + + assert(host.stop() == S2K_OK && !host.snapshot().running); + assert(!SDL_GamepadConnected(pad)); + SDL_CloseGamepad(pad); + assert(host.instance(first) == 0 && host.instance(second) == 0); + assert(host.start() == S2K_BUSY && host.setAutomaticDiscovery(false) == S2K_BUSY); + assert(test_input_automatic_discovery(fixture) == 1); + assert(test_input_finish_stop(fixture) == 1 && test_input_finish_stop(fixture) == 0); + assert(host.setAutomaticDiscovery(true) == S2K_OK); + for (int i = 0; i < 1000; ++i) { + assert(host.pump() == S2K_OK && !host.snapshot().running); + } + assert(test_input_start_count(fixture) == 1 && test_input_discovery_count(fixture) == 0); + assert(host.start() == S2K_OK && test_input_start_count(fixture) == 2); + assert(test_input_automatic_discovery(fixture) == 1); + // A reverse-order new generation still resolves each original physical key. + state = {}; state.sequence = 1; + test_input_report(fixture, 1, S2K_PRO, &state); + test_input_report(fixture, 0, S2K_PRO, &state); + assert(host.pump() == S2K_OK); + assert(host.instance(first) && host.instance(first) != original); + assert(host.instance(second) && host.instance(second) != other); + assert(host.identity(host.instance(first)) == first && host.identity(host.instance(second)) == second); + assert(host.setAutomaticDiscovery(false) == S2K_OK && host.discover() == S2K_OK); + assert(test_input_start_count(fixture) == 2 && test_input_discovery_count(fixture) == 1); + assert(host.stop() == S2K_OK && test_input_finish_stop(fixture) == 1); + host.shutdown(); host.shutdown(); + assert(host.pump() == S2K_OK); + std::puts("PASS real SDLHost automatic policy, queued input, live opt-out, stop/busy/restart and physical identity"); } int main() { SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "0"); SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); assert(SDL_Init(SDL_INIT_GAMEPAD)); + onDemandPolicyTests(); + automaticDiscoveryTests(); auto* fixture = test_input_create(); Switch2Kit::SDLHost host(fixture); assert(host.initialize() == S2K_OK && host.snapshot().running == 0); diff --git a/tests/sdl-inprocess/Fixture.swift b/tests/sdl-inprocess/Fixture.swift index 8dab338..7742390 100644 --- a/tests/sdl-inprocess/Fixture.swift +++ b/tests/sdl-inprocess/Fixture.swift @@ -14,6 +14,8 @@ final class SDLTestSource: ControllerSource { var running = false var automaticDiscovery = false var discoveryConfigurationCount: UInt32 = 0 + var startCount: UInt32 = 0 + var discoveryCount: UInt32 = 0 var stopCompletion: (@Sendable () -> Void)? } let state = Mutex(State()) @@ -21,7 +23,7 @@ final class SDLTestSource: ControllerSource { .init(isRunning: value.running, bluetooth: .poweredOn, discovery: .paused, controllers: value.controllers.sorted { $0.key < $1.key }.map(\.value)) } - func start() { state.withLock { $0.running = true; hub.publish(snapshot($0), event: .status(snapshot($0))) } } + func start() { state.withLock { $0.startCount += 1; $0.running = true; hub.publish(snapshot($0), event: .status(snapshot($0))) } } func stop(completion: @escaping @Sendable () -> Void) { state.withLock { value in value.running = false; value.stopCompletion = completion @@ -30,7 +32,7 @@ final class SDLTestSource: ControllerSource { hub.publish(snapshot(value), event: .status(snapshot(value))) } } - func discover(seconds: Double) {} + func discover(seconds: Double) { state.withLock { $0.discoveryCount += 1 } } func setAutomaticDiscovery(_ enabled: Bool) { // Record policy intent without simulating Bluetooth or changing ready sessions. state.withLock { @@ -98,3 +100,33 @@ public func fixtureRumble(_ handle: OpaquePointer, _ index: Int32, _ strong: Uns strong.pointee = value?.strong ?? 0; weak.pointee = value?.weak ?? 0 return value?.count ?? 0 } + +// Native consumer inspection only. These exports are not SDK distribution APIs. +@_cdecl("test_input_automatic_discovery") +public func fixtureAutomaticDiscovery(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.automaticDiscovery ? 1 : 0 } +} +@_cdecl("test_input_discovery_configuration_count") +public func fixtureDiscoveryConfigurationCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.discoveryConfigurationCount } +} +@_cdecl("test_input_start_count") +public func fixtureStartCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.startCount } +} +@_cdecl("test_input_discovery_count") +public func fixtureDiscoveryCount(_ handle: OpaquePointer) -> UInt32 { + source(handle).state.withLock { $0.discoveryCount } +} +@_cdecl("test_input_finish_stop") +public func fixtureFinishStop(_ handle: OpaquePointer) -> UInt32 { + let completion = source(handle).state.withLock { value in + let completion = value.stopCompletion + value.stopCompletion = nil + return completion + } + // Deliver after releasing the source lock, as the real asynchronous source does. + guard let completion else { return 0 } + completion() + return 1 +}